From ad5a1613e3c706604c632a2e87565737d5f03aed Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:40:13 +0300 Subject: [PATCH] move players and accounts into postgres json stores are gone, they nulled out every data ref on load and wrote the null straight back on save. shop json and the purchase commands ended up in here too. --- Cargo.lock | 1458 ++++++++++++++++- Cargo.toml | 2 + config/shop.example.json | 12 + crates/auth-service/Cargo.toml | 1 + crates/auth-service/src/config.rs | 10 +- crates/auth-service/src/lib.rs | 2 +- crates/auth-service/src/service.rs | 35 +- crates/auth-service/src/store.rs | 141 -- crates/auth-service/src/store/mod.rs | 33 + crates/auth-service/src/store/postgres.rs | 77 + crates/game-service/Cargo.toml | 3 +- crates/game-service/src/config.rs | 14 +- crates/game-service/src/home.rs | 58 +- crates/game-service/src/home_mode.rs | 85 +- crates/game-service/src/lib.rs | 6 +- crates/game-service/src/profile.rs | 150 -- crates/game-service/src/service.rs | 77 +- crates/game-service/src/shop/catalog.rs | 73 + .../game-service/src/shop/default_shop.json | 12 + crates/game-service/src/shop/entry.rs | 158 ++ crates/game-service/src/shop/mod.rs | 4 + crates/game-service/src/store/mod.rs | 16 + crates/game-service/src/store/postgres.rs | 369 +++++ crates/game-service/src/store/profile.rs | 94 ++ crates/logic/Cargo.toml | 5 - crates/logic/src/commands/command.rs | 4 + crates/logic/src/commands/mod.rs | 10 + crates/logic/src/commands/shop.rs | 101 ++ crates/logic/src/data/data_ref.rs | 78 +- crates/logic/src/data/mod.rs | 3 +- crates/logic/src/data/typed.rs | 13 + crates/logic/src/home/logic_home_mode.rs | 50 +- crates/logic/src/lib.rs | 10 +- crates/storage/Cargo.toml | 13 + crates/storage/migrations/0001_initial.sql | 98 ++ crates/storage/src/error.rs | 23 + crates/storage/src/lib.rs | 7 + crates/storage/src/pool.rs | 66 + 38 files changed, 2929 insertions(+), 442 deletions(-) create mode 100644 config/shop.example.json delete mode 100644 crates/auth-service/src/store.rs create mode 100644 crates/auth-service/src/store/mod.rs create mode 100644 crates/auth-service/src/store/postgres.rs delete mode 100644 crates/game-service/src/profile.rs create mode 100644 crates/game-service/src/shop/catalog.rs create mode 100644 crates/game-service/src/shop/default_shop.json create mode 100644 crates/game-service/src/shop/entry.rs create mode 100644 crates/game-service/src/shop/mod.rs create mode 100644 crates/game-service/src/store/mod.rs create mode 100644 crates/game-service/src/store/postgres.rs create mode 100644 crates/game-service/src/store/profile.rs create mode 100644 crates/logic/src/commands/shop.rs create mode 100644 crates/storage/Cargo.toml create mode 100644 crates/storage/migrations/0001_initial.sql create mode 100644 crates/storage/src/error.rs create mode 100644 crates/storage/src/lib.rs create mode 100644 crates/storage/src/pool.rs diff --git a/Cargo.lock b/Cargo.lock index ae51f6d..6e43b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "async-trait" version = "0.1.92" @@ -22,6 +28,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "auth-service" version = "0.1.0" @@ -31,24 +46,187 @@ dependencies = [ "serde", "serde_json", "service-rpc", + "storage", "thiserror", "tokio", "tracing", "tracing-subscriber", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -56,7 +234,131 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", ] [[package]] @@ -69,6 +371,7 @@ dependencies = [ "serde", "serde_json", "service-rpc", + "storage", "thiserror", "titan", "tokio", @@ -92,6 +395,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -103,6 +416,185 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inventory" version = "0.3.24" @@ -123,6 +615,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -130,6 +625,49 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -141,7 +679,6 @@ name = "logic" version = "0.1.0" dependencies = [ "logic-derive", - "serde", "thiserror", "titan", "tracing", @@ -165,6 +702,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -179,7 +726,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -188,7 +735,52 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", ] [[package]] @@ -197,12 +789,98 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -260,6 +938,24 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -277,12 +973,92 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll-server" version = "0.1.0" @@ -343,6 +1119,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "service-rpc" version = "0.1.0" @@ -355,6 +1143,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -364,6 +1174,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -374,11 +1190,30 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -387,9 +1222,247 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-postgres", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "storage" +version = "0.1.0" +dependencies = [ + "sqlx", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -412,6 +1485,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -441,6 +1525,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "titan" version = "0.1.0" @@ -474,7 +1583,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -488,12 +1597,24 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -549,30 +1670,145 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -582,6 +1818,156 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -602,6 +1988,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index bea6000..c3f337b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ service-rpc = { path = "crates/service-rpc" } auth-service = { path = "crates/auth-service" } game-service = { path = "crates/game-service" } gateway = { path = "crates/gateway" } +storage = { path = "crates/storage" } tokio = { version = "1.45", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "signal", "fs"] } thiserror = "2" @@ -27,6 +28,7 @@ serde_json = "1" rand = "0.8" async-trait = "0.1" inventory = "0.3" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "migrate"] } proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/config/shop.example.json b/config/shop.example.json new file mode 100644 index 0000000..c86b213 --- /dev/null +++ b/config/shop.example.json @@ -0,0 +1,12 @@ +{ + "offers": [ + { "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" }, + { "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" }, + { "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" }, + { "id": 4, "give": "Knight#10", "cost": "Gold#500" }, + { "id": 5, "give": "Witch#4", "cost": "Gold#2000" }, + { "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" }, + { "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" }, + { "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" } + ] +} diff --git a/crates/auth-service/Cargo.toml b/crates/auth-service/Cargo.toml index 0685cbc..10d79af 100644 --- a/crates/auth-service/Cargo.toml +++ b/crates/auth-service/Cargo.toml @@ -7,6 +7,7 @@ description = "Account identity and session issuing service" [dependencies] service-rpc = { workspace = true } +storage = { workspace = true } tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/auth-service/src/config.rs b/crates/auth-service/src/config.rs index ef4e904..b859b7f 100644 --- a/crates/auth-service/src/config.rs +++ b/crates/auth-service/src/config.rs @@ -1,8 +1,8 @@ -use std::path::PathBuf; +use storage::DatabaseConfig; #[derive(Debug, Clone)] pub struct AuthConfig { pub listen: String, - pub store_path: PathBuf, + pub database: Option, pub min_client_build: i32, pub max_client_build: i32, pub content_fingerprint: Option, @@ -12,7 +12,7 @@ impl Default for AuthConfig { fn default() -> Self { Self { listen: "127.0.0.1:9401".to_owned(), - store_path: PathBuf::from("data/accounts.json"), + database: None, min_client_build: 0, max_client_build: i32::MAX, content_fingerprint: None, @@ -25,9 +25,7 @@ impl AuthConfig { let defaults = Self::default(); Self { listen: env_string("SCROLL_AUTH_LISTEN").unwrap_or(defaults.listen), - store_path: env_string("SCROLL_AUTH_STORE") - .map(PathBuf::from) - .unwrap_or(defaults.store_path), + database: DatabaseConfig::from_env(), min_client_build: env_i32("SCROLL_MIN_CLIENT_BUILD") .unwrap_or(defaults.min_client_build), max_client_build: env_i32("SCROLL_MAX_CLIENT_BUILD") diff --git a/crates/auth-service/src/lib.rs b/crates/auth-service/src/lib.rs index 118ebdf..2f4cc9e 100644 --- a/crates/auth-service/src/lib.rs +++ b/crates/auth-service/src/lib.rs @@ -4,4 +4,4 @@ pub mod store; pub mod time; pub use config::AuthConfig; pub use service::AuthService; -pub use store::{Account, AccountStore}; +pub use store::{generate_pass_token, Account, AccountStore}; diff --git a/crates/auth-service/src/service.rs b/crates/auth-service/src/service.rs index 1753f5d..c016f12 100644 --- a/crates/auth-service/src/service.rs +++ b/crates/auth-service/src/service.rs @@ -4,27 +4,30 @@ use service_rpc::{ RpcService, Session, }; use crate::config::AuthConfig; -use crate::store::AccountStore; +use crate::store::{unavailable, AccountStore}; use crate::time::{days_between, format_utc, unix_millis, unix_seconds}; +pub fn missing_database() -> std::io::Error { + std::io::Error::other( + "DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)", + ) +} pub const ERROR_CODE_GENERIC: i32 = 1; pub const ERROR_CODE_PATCH: i32 = 7; pub const ERROR_CODE_CLIENT_TOO_OLD: i32 = 8; pub const ERROR_CODE_BANNED: i32 = 11; pub struct AuthService { config: AuthConfig, - store: Arc, + store: AccountStore, } impl AuthService { pub async fn bootstrap(config: AuthConfig) -> std::io::Result> { - let store = Arc::new(AccountStore::open(&config.store_path).await?); - tracing::info!( - accounts = store.len().await, - store = %config.store_path.display(), - "account store ready" - ); + let database = config.database.as_ref().ok_or_else(missing_database)?; + let store = AccountStore::open(database).await?; + let accounts = store.count().await?; + tracing::info!(accounts, "account store ready"); Ok(Arc::new(Self { config, store })) } - pub fn store(&self) -> &Arc { + pub fn store(&self) -> &AccountStore { &self.store } fn version_verdict(&self, device: &DeviceInfo) -> Option<(i32, Option)> { @@ -67,7 +70,7 @@ impl AuthApi for AuthService { let stored = if account.is_zero() || pass_token.is_none() { None } else { - self.store.get(account).await + self.store.get(account).await.map_err(unavailable)? }; let stored = match stored { Some(existing) => { @@ -88,7 +91,7 @@ impl AuthApi for AuthService { existing } None => { - let created = self.store.create().await?; + let created = self.store.create().await.map_err(unavailable)?; tracing::info!(account = %created.account_ref(), "created account"); created } @@ -96,7 +99,8 @@ impl AuthApi for AuthService { let refreshed = self .store .touch_session(stored.account_ref()) - .await? + .await + .map_err(unavailable)? .unwrap_or(stored); let now = unix_seconds(); Ok(LoginOutcome::Accepted(Session { @@ -110,7 +114,12 @@ impl AuthApi for AuthService { })) } async fn resolve(&self, account: AccountRef) -> RpcResult { - Ok(self.store.get(account).await.is_some()) + Ok(self + .store + .get(account) + .await + .map_err(unavailable)? + .is_some()) } } #[async_trait::async_trait] diff --git a/crates/auth-service/src/store.rs b/crates/auth-service/src/store.rs deleted file mode 100644 index 04505c5..0000000 --- a/crates/auth-service/src/store.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use rand::distributions::Alphanumeric; -use rand::Rng; -use serde::{Deserialize, Serialize}; -use service_rpc::AccountRef; -use tokio::sync::RwLock; -use crate::time::unix_seconds; -pub const PASS_TOKEN_LEN: usize = 40; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Account { - pub high: i32, - pub low: i32, - pub pass_token: String, - pub created_at: i64, - pub last_seen_at: i64, - pub session_count: i32, - pub play_time_seconds: i32, - #[serde(default)] - pub banned: bool, -} -impl Account { - pub fn account_ref(&self) -> AccountRef { - AccountRef::new(self.high, self.low) - } -} -#[derive(Debug, Default, Serialize, Deserialize)] -struct StoreSnapshot { - next_low: i32, - accounts: Vec, -} -pub struct AccountStore { - path: PathBuf, - state: RwLock, -} -#[derive(Default)] -struct StoreState { - next_low: i32, - accounts: HashMap, -} -impl AccountStore { - pub async fn open(path: impl AsRef) -> std::io::Result { - let path = path.as_ref().to_path_buf(); - let snapshot = match tokio::fs::read(&path).await { - Ok(bytes) => serde_json::from_slice::(&bytes).unwrap_or_default(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => StoreSnapshot::default(), - Err(error) => return Err(error), - }; - let mut accounts = HashMap::with_capacity(snapshot.accounts.len()); - for account in snapshot.accounts { - accounts.insert(account.account_ref(), account); - } - Ok(Self { - path, - state: RwLock::new(StoreState { - next_low: snapshot.next_low.max(1), - accounts, - }), - }) - } - pub async fn create(&self) -> std::io::Result { - let now = unix_seconds(); - let account = { - let mut state = self.state.write().await; - let low = state.next_low; - state.next_low = state.next_low.wrapping_add(1).max(1); - let account = Account { - high: 0, - low, - pass_token: generate_pass_token(), - created_at: now, - last_seen_at: now, - session_count: 0, - play_time_seconds: 0, - banned: false, - }; - state - .accounts - .insert(account.account_ref(), account.clone()); - account - }; - self.persist().await?; - Ok(account) - } - pub async fn get(&self, account: AccountRef) -> Option { - self.state.read().await.accounts.get(&account).cloned() - } - pub async fn touch_session(&self, account: AccountRef) -> std::io::Result> { - let updated = { - let mut state = self.state.write().await; - match state.accounts.get_mut(&account) { - None => None, - Some(stored) => { - let now = unix_seconds(); - stored.play_time_seconds = stored - .play_time_seconds - .saturating_add(((now - stored.last_seen_at).clamp(0, 3_600)) as i32); - stored.last_seen_at = now; - stored.session_count = stored.session_count.saturating_add(1); - Some(stored.clone()) - } - } - }; - if updated.is_some() { - self.persist().await?; - } - Ok(updated) - } - pub async fn len(&self) -> usize { - self.state.read().await.accounts.len() - } - pub async fn is_empty(&self) -> bool { - self.state.read().await.accounts.is_empty() - } - async fn persist(&self) -> std::io::Result<()> { - let snapshot = { - let state = self.state.read().await; - StoreSnapshot { - next_low: state.next_low, - accounts: state.accounts.values().cloned().collect(), - } - }; - let encoded = serde_json::to_vec_pretty(&snapshot) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; - if let Some(parent) = self.path.parent() { - if !parent.as_os_str().is_empty() { - tokio::fs::create_dir_all(parent).await?; - } - } - let temporary = self.path.with_extension("json.tmp"); - tokio::fs::write(&temporary, &encoded).await?; - tokio::fs::rename(&temporary, &self.path).await - } -} -pub fn generate_pass_token() -> String { - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(PASS_TOKEN_LEN) - .map(char::from) - .collect() -} diff --git a/crates/auth-service/src/store/mod.rs b/crates/auth-service/src/store/mod.rs new file mode 100644 index 0000000..b0e5bac --- /dev/null +++ b/crates/auth-service/src/store/mod.rs @@ -0,0 +1,33 @@ +mod postgres; +use rand::distributions::Alphanumeric; +use rand::Rng; +use service_rpc::{AccountRef, RpcError}; +use storage::StorageError; +pub use postgres::AccountStore; +pub const PASS_TOKEN_LEN: usize = 40; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Account { + pub high: i32, + pub low: i32, + pub pass_token: String, + pub created_at: i64, + pub last_seen_at: i64, + pub session_count: i32, + pub play_time_seconds: i32, + pub banned: bool, +} +impl Account { + pub fn account_ref(&self) -> AccountRef { + AccountRef::new(self.high, self.low) + } +} +pub fn unavailable(error: StorageError) -> RpcError { + RpcError::Unavailable(error.to_string()) +} +pub fn generate_pass_token() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(PASS_TOKEN_LEN) + .map(char::from) + .collect() +} diff --git a/crates/auth-service/src/store/postgres.rs b/crates/auth-service/src/store/postgres.rs new file mode 100644 index 0000000..1188da9 --- /dev/null +++ b/crates/auth-service/src/store/postgres.rs @@ -0,0 +1,77 @@ +use service_rpc::AccountRef; +use storage::{Database, DatabaseConfig, Result, Row}; +use crate::store::{generate_pass_token, Account}; +use crate::time::unix_seconds; +pub struct AccountStore { + database: Database, +} +impl AccountStore { + pub async fn open(config: &DatabaseConfig) -> Result { + let database = Database::connect(config).await?; + database.migrate().await?; + Ok(Self { database }) + } +} +fn account_from_row(row: &storage::PgRow) -> Account { + Account { + high: row.get("account_high"), + low: row.get("account_low"), + pass_token: row.get("pass_token"), + created_at: row.get("created_at"), + last_seen_at: row.get("last_seen_at"), + session_count: row.get("session_count"), + play_time_seconds: row.get("play_time_seconds"), + banned: row.get("banned"), + } +} +impl AccountStore { + pub async fn create(&self) -> Result { + let now = unix_seconds(); + let row = storage::sqlx::query( + "insert into accounts ( + account_high, account_low, pass_token, created_at, last_seen_at, + session_count, play_time_seconds, banned + ) + values (0, nextval('accounts_low_seq')::integer, $1, $2, $2, 0, 0, false) + returning *", + ) + .bind(generate_pass_token()) + .bind(now) + .fetch_one(self.database.pool()) + .await?; + Ok(account_from_row(&row)) + } + pub async fn get(&self, account: AccountRef) -> Result> { + let row = storage::sqlx::query( + "select * from accounts where account_high = $1 and account_low = $2", + ) + .bind(account.high) + .bind(account.low) + .fetch_optional(self.database.pool()) + .await?; + Ok(row.as_ref().map(account_from_row)) + } + pub async fn touch_session(&self, account: AccountRef) -> Result> { + let row = storage::sqlx::query( + "update accounts + set play_time_seconds = play_time_seconds + + least(greatest($3 - last_seen_at, 0), 3600)::integer, + last_seen_at = $3, + session_count = session_count + 1 + where account_high = $1 and account_low = $2 + returning *", + ) + .bind(account.high) + .bind(account.low) + .bind(unix_seconds()) + .fetch_optional(self.database.pool()) + .await?; + Ok(row.as_ref().map(account_from_row)) + } + pub async fn count(&self) -> Result { + let total: i64 = storage::sqlx::query_scalar("select count(*) from accounts") + .fetch_one(self.database.pool()) + .await?; + Ok(total.max(0) as usize) + } +} diff --git a/crates/game-service/Cargo.toml b/crates/game-service/Cargo.toml index 8c8d5c6..3f400d7 100644 --- a/crates/game-service/Cargo.toml +++ b/crates/game-service/Cargo.toml @@ -7,8 +7,9 @@ description = "Home and avatar state service that produces scroll lobby payloads [dependencies] titan = { workspace = true } -logic = { workspace = true, features = ["serde"] } +logic = { workspace = true } service-rpc = { workspace = true } +storage = { workspace = true } tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/game-service/src/config.rs b/crates/game-service/src/config.rs index b16a294..3b962a7 100644 --- a/crates/game-service/src/config.rs +++ b/crates/game-service/src/config.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use storage::DatabaseConfig; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum DataSelector { @@ -93,9 +94,10 @@ impl Default for ChestRewardConfig { } #[derive(Debug, Clone)] pub struct GameConfig { + pub database: Option, pub listen: String, - pub store_path: PathBuf, pub csv_root: Option, + pub shop_path: Option, pub starter: StarterProfile, pub chest_reward: ChestRewardConfig, pub random_seed: i32, @@ -103,9 +105,10 @@ pub struct GameConfig { impl Default for GameConfig { fn default() -> Self { Self { + database: None, listen: "127.0.0.1:9402".to_owned(), - store_path: PathBuf::from("data/players.json"), csv_root: Some(PathBuf::from("assets")), + shop_path: Some(PathBuf::from("config/shop.json")), starter: StarterProfile::default(), chest_reward: ChestRewardConfig::default(), random_seed: 0x5EED, @@ -123,13 +126,14 @@ impl GameConfig { None => defaults.starter, }; Self { + database: DatabaseConfig::from_env(), listen: env_string("SCROLL_GAME_LISTEN").unwrap_or(defaults.listen), - store_path: env_string("SCROLL_GAME_STORE") - .map(PathBuf::from) - .unwrap_or(defaults.store_path), csv_root: env_string("SCROLL_CSV_ROOT") .map(PathBuf::from) .or(defaults.csv_root), + shop_path: env_string("SCROLL_SHOP") + .map(PathBuf::from) + .or(defaults.shop_path), starter, chest_reward: defaults.chest_reward, random_seed: env_string("SCROLL_RANDOM_SEED") diff --git a/crates/game-service/src/home.rs b/crates/game-service/src/home.rs index 7af49c1..a7aad45 100644 --- a/crates/game-service/src/home.rs +++ b/crates/game-service/src/home.rs @@ -1,9 +1,12 @@ use logic::model::{ - LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell, + LogicChest, LogicClientAvatar, LogicClientHome, LogicCommodityStore, LogicDataSlot, LogicSpell, LogicSpellCollection, LogicSpellDeck, LogicTimer, }; use titan::LogicLong; -use crate::profile::{OwnedCard, PlayerProfile}; +use logic::data::RESOURCE_GOLD; +use logic::{table, LogicDataRef}; +use crate::catalog::GOLD_RESOURCE_FALLBACK_INSTANCE; +use crate::store::{OwnedCard, PlayerProfile, StoredChest}; pub const COMMODITY_RESOURCES: usize = 0; fn spell_of(owned: &OwnedCard) -> LogicSpell { LogicSpell { @@ -17,25 +20,53 @@ fn spell_of(owned: &OwnedCard) -> LogicSpell { show_new_icon: false, } } +fn chest_of(stored: &StoredChest) -> LogicChest { + LogicChest { + data: stored.chest.clone(), + unlocked: stored.unlocked, + claimed: stored.claimed, + is_new: false, + unlock_timer: (stored.remaining_ticks > 0).then_some(LogicTimer { + remaining_ticks: stored.remaining_ticks, + total_ticks: stored.total_ticks, + end_timestamp: stored.end_timestamp, + }), + chest_id: stored.chest_id, + source: stored.source, + slot_index: stored.slot_index, + } +} +fn chest_slots(profile: &PlayerProfile) -> Vec> { + let mut slots = vec![None; profile.chest_slot_count]; + for stored in &profile.chests { + let Ok(index) = usize::try_from(stored.slot_index) else { + continue; + }; + if index < slots.len() { + slots[index] = Some(chest_of(stored)); + } + } + slots +} pub fn build_home(profile: &PlayerProfile) -> LogicClientHome { let deck = LogicSpellDeck::from_cards(profile.deck.iter().map(spell_of)); let collection = LogicSpellCollection::from_spells(profile.collection.iter().map(spell_of).collect()); let mut home = LogicClientHome { home_id: LogicLong::new(profile.account.high, profile.account.low), - chest_id_counter: 0, - free_chest_collect_count: 1, + chest_id_counter: profile.chest_id_counter, + free_chest_collect_count: profile.free_chest_collect_count, donation_capacity_limit_timer: LogicTimer::idle(), donated_capacity: 0, decks: vec![deck], spell_collection: collection, selected_deck_index: 0, - chest_slots: vec![None; profile.chest_slot_count], + chest_slots: chest_slots(profile), free_chest_timer: LogicTimer::idle(), donation_cooldown_timer: LogicTimer::idle(), free_chest: None, purchased_chest: None, - crowns_towards_crown_chest: 0, + crowns_towards_crown_chest: profile.crowns_towards_crown_chest, star_chest_timer_pending: false, star_chest_timer: LogicTimer::idle(), crown_chest: None, @@ -66,15 +97,22 @@ pub fn build_home(profile: &PlayerProfile) -> LogicClientHome { } home } +fn gold_resource(profile: &PlayerProfile) -> LogicDataRef { + if !profile.gold_resource.is_none() { + return profile.gold_resource.clone(); + } + let by_name = LogicDataRef::by_name(table::RESOURCES, RESOURCE_GOLD); + if !by_name.is_none() { + return by_name; + } + LogicDataRef::of(table::RESOURCES, GOLD_RESOURCE_FALLBACK_INSTANCE) +} pub fn build_avatar(profile: &PlayerProfile) -> LogicClientAvatar { let account = LogicLong::new(profile.account.high, profile.account.low); let mut commodities = LogicCommodityStore::default(); commodities.set( COMMODITY_RESOURCES, - vec![LogicDataSlot::new( - profile.gold_resource.clone(), - profile.gold, - )], + vec![LogicDataSlot::new(gold_resource(profile), profile.gold)], ); LogicClientAvatar { avatar_id: account, diff --git a/crates/game-service/src/home_mode.rs b/crates/game-service/src/home_mode.rs index 0e24d79..67132f3 100644 --- a/crates/game-service/src/home_mode.rs +++ b/crates/game-service/src/home_mode.rs @@ -1,19 +1,23 @@ use std::collections::HashMap; use std::sync::Arc; +use logic::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD}; use logic::{ - AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage, LogicClaimRewardCommand, - LogicHomeMode, OutOfSyncMessage, OwnHomeDataMessage, + chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage, + LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage, + OwnHomeDataMessage, }; use service_rpc::AccountRef; use tokio::sync::{Mutex, RwLock}; use crate::config::ChestRewardConfig; use crate::home::{build_avatar, build_home}; -use crate::profile::{OwnedCard, PlayerProfile}; use crate::rewards::RewardRoller; +use crate::shop::{ShopCatalog, ShopOffer}; +use crate::store::{OwnedCard, PlayerProfile, StoredChest}; pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; #[derive(Debug, Default)] pub struct TurnResult { pub claims: Vec, + pub purchases: Vec, pub out_of_sync: Option, pub changed: bool, } @@ -40,11 +44,44 @@ impl HomeMode { random_seed, ) } + fn purchase( + &mut self, + data: &LogicDataRef, + count: i32, + shop: &ShopCatalog, + ) -> Result { + let offer = shop + .offer_for(data) + .filter(|offer| offer.give.count == count) + .ok_or("nothing in the shop sells that")? + .clone(); + if !offer.cost.is_resource() { + return Err("the price must be a resource"); + } + match offer.cost.data.name() { + RESOURCE_GOLD if self.logic.spend_gold(offer.cost.count) => {} + RESOURCE_DIAMONDS if self.logic.spend_diamonds(offer.cost.count) => {} + RESOURCE_GOLD | RESOURCE_DIAMONDS => return Err("the player cannot afford it"), + _ => return Err("only gold and diamonds are accepted"), + } + if offer.give.is_resource() { + match offer.give.data.name() { + RESOURCE_GOLD => self.logic.add_gold(offer.give.count), + RESOURCE_DIAMONDS => self.logic.add_diamonds(offer.give.count), + _ => return Err("only gold and diamonds can be sold"), + } + } else if offer.give.is_card() { + self.logic + .grant_card(offer.give.data.clone(), offer.give.count); + } + Ok(offer) + } pub fn end_client_turn( &mut self, turn: &EndClientTurnMessage, reward: &ChestRewardConfig, roller: &RewardRoller, + shop: &ShopCatalog, ) -> TurnResult { let mut result = TurnResult::default(); let ticked = self @@ -74,6 +111,25 @@ impl HomeMode { "client command rejected" ); } + CommandOutcome::PurchaseRequested { data, count } => { + match self.purchase(&data, count, shop) { + Ok(offer) => { + if offer.give.is_chest() { + pending.push((chest_source::PURCHASED, 0)); + } + result.changed = true; + result.purchases.push(offer); + } + Err(reason) => { + tracing::warn!( + command = command.command_type(), + item = %data, + reason, + "shop purchase rejected" + ); + } + } + } CommandOutcome::Applied => result.changed = true, CommandOutcome::Ignored => {} } @@ -131,6 +187,29 @@ impl HomeMode { count: spell.count, }) .collect(); + profile.chest_id_counter = home.chest_id_counter; + profile.free_chest_collect_count = home.free_chest_collect_count; + profile.crowns_towards_crown_chest = home.crowns_towards_crown_chest; + profile.chest_slot_count = home.chest_slots.len(); + profile.chests = home + .chest_slots + .iter() + .flatten() + .map(|chest| { + let timer = chest.unlock_timer.unwrap_or(LogicTimer::idle()); + StoredChest { + chest: chest.data.clone(), + chest_id: chest.chest_id, + slot_index: chest.slot_index, + source: chest.source, + unlocked: chest.unlocked, + claimed: chest.claimed, + remaining_ticks: timer.remaining_ticks, + total_ticks: timer.total_ticks, + end_timestamp: timer.end_timestamp, + } + }) + .collect(); } } #[derive(Default)] diff --git a/crates/game-service/src/lib.rs b/crates/game-service/src/lib.rs index 9ad906a..0ec22fd 100644 --- a/crates/game-service/src/lib.rs +++ b/crates/game-service/src/lib.rs @@ -2,14 +2,16 @@ pub mod catalog; pub mod config; pub mod home; pub mod home_mode; -pub mod profile; pub mod rewards; pub mod service; +pub mod shop; +pub mod store; pub mod time; pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE}; pub use config::{CardRef, ChestRewardConfig, DataSelector, GameConfig, StarterProfile}; pub use home::{build_avatar, build_home}; pub use home_mode::{HomeMode, HomeModeRegistry, TurnResult}; -pub use profile::{PlayerProfile, ProfileStore}; pub use rewards::RewardRoller; pub use service::GameService; +pub use shop::{ShopCatalog, ShopEntry, ShopOffer}; +pub use store::{PlayerProfile, ProfileStore}; diff --git a/crates/game-service/src/profile.rs b/crates/game-service/src/profile.rs deleted file mode 100644 index 50aa449..0000000 --- a/crates/game-service/src/profile.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; -use service_rpc::AccountRef; -use tokio::sync::RwLock; -use logic::LogicDataRef; -use crate::catalog::Catalog; -use crate::config::StarterProfile; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OwnedCard { - pub card: LogicDataRef, - pub level_index: i32, - pub count: i32, -} -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PlayerProfile { - pub account: AccountRef, - pub name: String, - pub name_set_by_user: bool, - pub exp_level: i32, - pub exp_points: i32, - pub gold: i32, - pub diamonds: i32, - pub free_diamonds: i32, - pub trophies: i32, - pub arena: LogicDataRef, - pub chest_slot_count: usize, - pub deck: Vec, - pub collection: Vec, - pub battle_count: i32, - pub win_count: i32, - pub lose_count: i32, - pub npc_win_count: i32, - pub npc_lose_count: i32, - pub three_crown_wins: i32, - pub tutorials_finished: bool, - pub gold_resource: LogicDataRef, -} -impl PlayerProfile { - pub fn starter(account: AccountRef, starter: &StarterProfile, catalog: &Catalog) -> Self { - let card_of = |card: &crate::config::CardRef| { - catalog.resolve_card(card).map(|resolved| OwnedCard { - card: resolved, - level_index: starter.card_level_index, - count: starter.card_count, - }) - }; - Self { - account, - name: format!("{}{}", starter.name_prefix, account.low), - name_set_by_user: false, - exp_level: starter.exp_level, - exp_points: starter.exp_points, - gold: starter.gold, - diamonds: starter.diamonds, - free_diamonds: starter.free_diamonds, - trophies: starter.trophies, - arena: catalog.resolve_arena(&starter.arena), - chest_slot_count: starter.chest_slot_count, - deck: starter.deck.iter().filter_map(card_of).collect(), - collection: starter - .extra_collection - .iter() - .filter_map(card_of) - .collect(), - battle_count: 0, - win_count: 0, - lose_count: 0, - npc_win_count: starter.npc_win_count, - npc_lose_count: starter.npc_lose_count, - three_crown_wins: 0, - tutorials_finished: starter.finish_tutorials, - gold_resource: catalog.resolve_resource(&starter.gold_resource), - } - } -} -#[derive(Debug, Default, Serialize, Deserialize)] -struct StoreSnapshot { - profiles: Vec, -} -pub struct ProfileStore { - path: PathBuf, - profiles: RwLock>, -} -impl ProfileStore { - pub async fn open(path: impl AsRef) -> std::io::Result { - let path = path.as_ref().to_path_buf(); - let snapshot = match tokio::fs::read(&path).await { - Ok(bytes) => serde_json::from_slice::(&bytes).unwrap_or_default(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => StoreSnapshot::default(), - Err(error) => return Err(error), - }; - let profiles = snapshot - .profiles - .into_iter() - .map(|profile| (profile.account, profile)) - .collect(); - Ok(Self { - path, - profiles: RwLock::new(profiles), - }) - } - pub async fn get_or_create( - &self, - account: AccountRef, - starter: &StarterProfile, - catalog: &Catalog, - ) -> std::io::Result { - if let Some(profile) = self.profiles.read().await.get(&account) { - return Ok(profile.clone()); - } - let profile = { - let mut profiles = self.profiles.write().await; - profiles - .entry(account) - .or_insert_with(|| PlayerProfile::starter(account, starter, catalog)) - .clone() - }; - self.persist().await?; - Ok(profile) - } - pub async fn get(&self, account: AccountRef) -> Option { - self.profiles.read().await.get(&account).cloned() - } - pub async fn save(&self, profile: PlayerProfile) -> std::io::Result<()> { - self.profiles.write().await.insert(profile.account, profile); - self.persist().await - } - pub async fn len(&self) -> usize { - self.profiles.read().await.len() - } - pub async fn is_empty(&self) -> bool { - self.profiles.read().await.is_empty() - } - async fn persist(&self) -> std::io::Result<()> { - let snapshot = StoreSnapshot { - profiles: self.profiles.read().await.values().cloned().collect(), - }; - let encoded = serde_json::to_vec_pretty(&snapshot) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; - if let Some(parent) = self.path.parent() { - if !parent.as_os_str().is_empty() { - tokio::fs::create_dir_all(parent).await?; - } - } - let temporary = self.path.with_extension("json.tmp"); - tokio::fs::write(&temporary, &encoded).await?; - tokio::fs::rename(&temporary, &self.path).await - } -} diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index 77ce75b..f5eb00f 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -10,22 +10,30 @@ use titan::{Message, MessageMeta, Payload}; use crate::catalog::Catalog; use crate::config::GameConfig; use crate::home_mode::{available_server_command, HomeModeRegistry}; -use crate::profile::ProfileStore; use crate::rewards::RewardRoller; +use crate::shop::ShopCatalog; +use crate::store::{missing_database, unavailable, PlayerProfile, ProfileStore}; use crate::time::unix_seconds; pub struct GameService { config: GameConfig, - profiles: Arc, + profiles: ProfileStore, catalog: Arc, + shop: Arc, sessions: HomeModeRegistry, } impl GameService { pub async fn bootstrap(config: GameConfig) -> std::io::Result> { - let profiles = Arc::new(ProfileStore::open(&config.store_path).await?); let catalog = Arc::new(Catalog::load(config.csv_root.as_deref())); + let database = config.database.as_ref().ok_or_else(missing_database)?; + let profiles = ProfileStore::open(database).await?; + let profile_count = profiles.count().await?; + let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref())); + for offer in shop.describe() { + tracing::debug!(offer, "shop offer"); + } tracing::info!( - profiles = profiles.len().await, - store = %config.store_path.display(), + profiles = profile_count, + offers = shop.offers.len(), catalog = catalog.is_loaded(), commands = LogicCommandManager::registry().len(), "profile store ready" @@ -34,20 +42,31 @@ impl GameService { config, profiles, catalog, + shop, sessions: HomeModeRegistry::default(), })) } - pub fn profiles(&self) -> &Arc { + pub fn profiles(&self) -> &ProfileStore { &self.profiles } + async fn profile(&self, account: AccountRef) -> RpcResult { + if let Some(profile) = self.profiles.load(account).await.map_err(unavailable)? { + return Ok(profile); + } + let starter = PlayerProfile::starter(account, &self.config.starter, &self.catalog); + self.profiles + .create_if_absent(starter) + .await + .map_err(unavailable) + } pub fn catalog(&self) -> &Arc { &self.catalog } + pub fn shop(&self) -> &Arc { + &self.shop + } async fn roller(&self, account: AccountRef) -> RpcResult { - let profile = self - .profiles - .get_or_create(account, &self.config.starter, &self.catalog) - .await?; + let profile = self.profile(account).await?; let fallback: Vec = profile .deck .iter() @@ -60,12 +79,9 @@ impl GameService { let Some(session) = self.sessions.close(account).await else { return Ok(()); }; - let mut profile = self - .profiles - .get_or_create(account, &self.config.starter, &self.catalog) - .await?; + let mut profile = self.profile(account).await?; session.lock().await.write_back(&mut profile); - self.profiles.save(profile).await?; + self.profiles.save(&profile).await.map_err(unavailable)?; Ok(()) } } @@ -89,10 +105,7 @@ impl GameApi for GameService { account: AccountRef, kind: HomeRequestKind, ) -> RpcResult> { - let profile = self - .profiles - .get_or_create(account, &self.config.starter, &self.catalog) - .await?; + let profile = self.profile(account).await?; let session = self .sessions .get_or_open(account, &profile, unix_seconds() as i32) @@ -130,10 +143,7 @@ impl GameApi for GameService { return Ok(Vec::new()); } }; - let profile = self - .profiles - .get_or_create(account, &self.config.starter, &self.catalog) - .await?; + let profile = self.profile(account).await?; let session = self .sessions .get_or_open(account, &profile, unix_seconds() as i32) @@ -141,7 +151,7 @@ impl GameApi for GameService { let roller = self.roller(account).await?; let result = { let mut home = session.lock().await; - home.end_client_turn(&turn, &self.config.chest_reward, &roller) + home.end_client_turn(&turn, &self.config.chest_reward, &roller, &self.shop) }; tracing::debug!( %account, @@ -161,6 +171,18 @@ impl GameApi for GameService { ); replies.push(encode::(&out_of_sync)?); } + for offer in &result.purchases { + tracing::info!( + %account, + offer = offer.id, + cost = %offer.cost, + give = %offer.give, + "shop purchase" + ); + if let Err(error) = self.profiles.record_purchase(account, offer).await { + tracing::warn!(%account, offer = offer.id, %error, "could not record the purchase"); + } + } for claim in result.claims { tracing::info!( %account, @@ -173,12 +195,9 @@ impl GameApi for GameService { replies.push(encode(&available_server_command(claim))?); } if result.changed { - let mut stored = self - .profiles - .get_or_create(account, &self.config.starter, &self.catalog) - .await?; + let mut stored = self.profile(account).await?; session.lock().await.write_back(&mut stored); - self.profiles.save(stored).await?; + self.profiles.save(&stored).await.map_err(unavailable)?; } Ok(replies) } diff --git a/crates/game-service/src/shop/catalog.rs b/crates/game-service/src/shop/catalog.rs new file mode 100644 index 0000000..dd7e43e --- /dev/null +++ b/crates/game-service/src/shop/catalog.rs @@ -0,0 +1,73 @@ +use std::path::Path; +use serde::{Deserialize, Serialize}; +use crate::shop::entry::ShopEntry; +pub const DEFAULT_SHOP: &str = include_str!("default_shop.json"); +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShopOffer { + pub id: i32, + pub give: ShopEntry, + pub cost: ShopEntry, +} +impl ShopOffer { + pub fn sells(&self, data: &logic::LogicDataRef) -> bool { + &self.give.data == data + } +} +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShopCatalog { + #[serde(default)] + pub offers: Vec, +} +impl ShopCatalog { + pub fn builtin() -> Self { + match serde_json::from_str(DEFAULT_SHOP) { + Ok(catalog) => catalog, + Err(error) => { + tracing::warn!(%error, "the built in shop does not resolve against the loaded tables"); + Self::default() + } + } + } + pub fn load(path: Option<&Path>) -> Self { + let Some(path) = path else { + return Self::builtin(); + }; + match std::fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(catalog) => { + tracing::info!( + path = %path.display(), + offers = catalog.offers.len(), + "shop catalog loaded" + ); + catalog + } + Err(error) => { + tracing::warn!( + path = %path.display(), + %error, + "unreadable shop catalog, falling back to the built in one" + ); + Self::builtin() + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::builtin(), + Err(error) => { + tracing::warn!(path = %path.display(), %error, "shop catalog is unreadable"); + Self::builtin() + } + } + } + pub fn offer(&self, id: i32) -> Option<&ShopOffer> { + self.offers.iter().find(|offer| offer.id == id) + } + pub fn offer_for(&self, data: &logic::LogicDataRef) -> Option<&ShopOffer> { + self.offers.iter().find(|offer| offer.sells(data)) + } + pub fn describe(&self) -> Vec { + self.offers + .iter() + .map(|offer| format!("{} = {} -> {}", offer.id, offer.cost, offer.give)) + .collect() + } +} diff --git a/crates/game-service/src/shop/default_shop.json b/crates/game-service/src/shop/default_shop.json new file mode 100644 index 0000000..c86b213 --- /dev/null +++ b/crates/game-service/src/shop/default_shop.json @@ -0,0 +1,12 @@ +{ + "offers": [ + { "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" }, + { "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" }, + { "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" }, + { "id": 4, "give": "Knight#10", "cost": "Gold#500" }, + { "id": 5, "give": "Witch#4", "cost": "Gold#2000" }, + { "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" }, + { "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" }, + { "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" } + ] +} diff --git a/crates/game-service/src/shop/entry.rs b/crates/game-service/src/shop/entry.rs new file mode 100644 index 0000000..c5e1d90 --- /dev/null +++ b/crates/game-service/src/shop/entry.rs @@ -0,0 +1,158 @@ +use std::fmt; +use std::str::FromStr; +use logic::{table, LogicDataRef}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +pub const SEPARATOR: char = '#'; +pub const QUALIFIER: char = ':'; +pub const LOOKUP: &[(&str, i32)] = &[ + ("resource", table::RESOURCES), + ("card", table::SPELLS), + ("chest", table::TREASURE_CHESTS), + ("arena", table::ARENAS), +]; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryError { + Empty, + MissingCount(String), + BadCount(String), + UnknownQualifier(String), + UnknownName(String), +} +impl fmt::Display for EntryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EntryError::Empty => write!(f, "empty entry"), + EntryError::MissingCount(entry) => { + write!(f, "`{entry}` is missing its `{SEPARATOR}count` suffix") + } + EntryError::BadCount(count) => write!(f, "`{count}` is not a count"), + EntryError::UnknownQualifier(qualifier) => { + write!( + f, + "`{qualifier}` is not a table, expected one of {}", + aliases() + ) + } + EntryError::UnknownName(name) => { + write!(f, "`{name}` matches no row in {}", aliases()) + } + } + } +} +impl std::error::Error for EntryError {} +fn aliases() -> String { + LOOKUP + .iter() + .map(|(alias, _)| *alias) + .collect::>() + .join(", ") +} +fn table_of(alias: &str) -> Option { + LOOKUP + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(alias)) + .map(|(_, index)| *index) +} +fn alias_of(table_index: i32) -> Option<&'static str> { + LOOKUP + .iter() + .find(|(_, index)| *index == table_index) + .map(|(alias, _)| *alias) +} +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ShopEntry { + pub data: LogicDataRef, + pub count: i32, +} +impl ShopEntry { + pub fn new(data: LogicDataRef, count: i32) -> Self { + Self { data, count } + } + pub fn resolve(name: &str, count: i32) -> Result { + let (qualifier, bare) = match name.split_once(QUALIFIER) { + Some((qualifier, bare)) => (Some(qualifier.trim()), bare.trim()), + None => (None, name.trim()), + }; + if bare.is_empty() { + return Err(EntryError::Empty); + } + let data = match qualifier { + Some(qualifier) => { + let table_index = table_of(qualifier) + .ok_or_else(|| EntryError::UnknownQualifier(qualifier.to_owned()))?; + LogicDataRef::by_name(table_index, bare) + } + None => LOOKUP + .iter() + .map(|(_, table_index)| LogicDataRef::by_name(*table_index, bare)) + .find(|resolved| !resolved.is_none()) + .unwrap_or_default(), + }; + if data.is_none() { + return Err(EntryError::UnknownName(name.to_owned())); + } + Ok(Self { data, count }) + } + pub fn is_resource(&self) -> bool { + self.data.belongs_to(table::RESOURCES) + } + pub fn is_card(&self) -> bool { + self.data.belongs_to(table::SPELLS) + } + pub fn is_chest(&self) -> bool { + self.data.belongs_to(table::TREASURE_CHESTS) + } + fn ambiguous(&self) -> bool { + let name = self.data.name(); + let own = self.data.table_index(); + LOOKUP.iter().any(|(_, table_index)| { + Some(*table_index) != own && !LogicDataRef::by_name(*table_index, name).is_none() + }) + } +} +impl FromStr for ShopEntry { + type Err = EntryError; + fn from_str(entry: &str) -> Result { + let entry = entry.trim(); + if entry.is_empty() { + return Err(EntryError::Empty); + } + let (name, count) = entry + .rsplit_once(SEPARATOR) + .ok_or_else(|| EntryError::MissingCount(entry.to_owned()))?; + let count = count + .trim() + .parse::() + .map_err(|_| EntryError::BadCount(count.trim().to_owned()))?; + Self::resolve(name, count) + } +} +impl fmt::Display for ShopEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self + .data + .table_index() + .and_then(alias_of) + .filter(|_| self.ambiguous()) + { + Some(alias) => write!( + f, + "{alias}{QUALIFIER}{}{SEPARATOR}{}", + self.data.name(), + self.count + ), + None => write!(f, "{}{SEPARATOR}{}", self.data.name(), self.count), + } + } +} +impl Serialize for ShopEntry { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} +impl<'de> Deserialize<'de> for ShopEntry { + fn deserialize>(deserializer: D) -> Result { + let entry = String::deserialize(deserializer)?; + entry.parse().map_err(serde::de::Error::custom) + } +} diff --git a/crates/game-service/src/shop/mod.rs b/crates/game-service/src/shop/mod.rs new file mode 100644 index 0000000..7bed8ef --- /dev/null +++ b/crates/game-service/src/shop/mod.rs @@ -0,0 +1,4 @@ +mod catalog; +mod entry; +pub use catalog::{ShopCatalog, ShopOffer, DEFAULT_SHOP}; +pub use entry::{EntryError, ShopEntry, LOOKUP, QUALIFIER, SEPARATOR}; diff --git a/crates/game-service/src/store/mod.rs b/crates/game-service/src/store/mod.rs new file mode 100644 index 0000000..22d6f0c --- /dev/null +++ b/crates/game-service/src/store/mod.rs @@ -0,0 +1,16 @@ +mod postgres; +mod profile; +use service_rpc::RpcError; +use storage::StorageError; +pub use postgres::ProfileStore; +pub use profile::{ + OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK, +}; +pub fn unavailable(error: StorageError) -> RpcError { + RpcError::Unavailable(error.to_string()) +} +pub fn missing_database() -> std::io::Error { + std::io::Error::other( + "DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)", + ) +} diff --git a/crates/game-service/src/store/postgres.rs b/crates/game-service/src/store/postgres.rs new file mode 100644 index 0000000..50063d9 --- /dev/null +++ b/crates/game-service/src/store/postgres.rs @@ -0,0 +1,369 @@ +use logic::LogicDataRef; +use service_rpc::AccountRef; +use storage::sqlx::{Postgres, QueryBuilder}; +use storage::{Database, DatabaseConfig, PgRow, Result, Row}; +use crate::shop::ShopOffer; +use crate::store::{ + OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK, +}; +use crate::time::unix_seconds; +pub struct ProfileStore { + database: Database, +} +impl ProfileStore { + pub async fn open(config: &DatabaseConfig) -> Result { + let database = Database::connect(config).await?; + database.migrate().await?; + Ok(Self { database }) + } + async fn chests(&self, account: AccountRef) -> Result> { + let rows = storage::sqlx::query( + "select * from player_chests + where account_high = $1 and account_low = $2 + order by slot_index", + ) + .bind(account.high) + .bind(account.low) + .fetch_all(self.database.pool()) + .await?; + Ok(rows.iter().map(chest_from_row).collect()) + } + async fn cards(&self, account: AccountRef) -> Result<(Vec, Vec)> { + let rows = storage::sqlx::query( + "select holder, card_table, card_instance, card_name, level_index, count + from player_cards + where account_high = $1 and account_low = $2 + order by holder, position", + ) + .bind(account.high) + .bind(account.low) + .fetch_all(self.database.pool()) + .await?; + let mut deck = Vec::new(); + let mut collection = Vec::new(); + for row in &rows { + let holder: i16 = row.get("holder"); + let card = OwnedCard { + card: data_ref_from_row(row, "card_table", "card_instance", "card_name"), + level_index: row.get("level_index"), + count: row.get("count"), + }; + if holder == CARD_HOLDER_DECK { + deck.push(card); + } else { + collection.push(card); + } + } + Ok((deck, collection)) + } +} +fn data_ref_from_row( + row: &PgRow, + table_column: &str, + instance_column: &str, + name_column: &str, +) -> LogicDataRef { + let table_index: i32 = row.get(table_column); + if table_index == 0 { + return LogicDataRef::None; + } + let instance: i32 = row.get(instance_column); + let name: Option = row.get(name_column); + LogicDataRef::by_name_or_instance(table_index, name.as_deref(), instance) +} +fn parts(data: &LogicDataRef) -> (i32, i32, Option) { + match data.global_id() { + None => (0, 0, None), + Some(id) => { + let name = match data.name() { + "" => None, + name => Some(name.to_owned()), + }; + (id.class_id, id.instance_id, name) + } + } +} +fn chest_from_row(row: &PgRow) -> StoredChest { + StoredChest { + chest: data_ref_from_row(row, "chest_table", "chest_instance", "chest_name"), + chest_id: row.get("chest_id"), + slot_index: row.get("slot_index"), + source: row.get("source"), + unlocked: row.get("unlocked"), + claimed: row.get("claimed"), + remaining_ticks: row.get("remaining_ticks"), + total_ticks: row.get("total_ticks"), + end_timestamp: row.get("end_timestamp"), + } +} +fn profile_from_row( + row: &PgRow, + deck: Vec, + collection: Vec, + chests: Vec, +) -> PlayerProfile { + PlayerProfile { + account: AccountRef::new(row.get("account_high"), row.get("account_low")), + name: row.get("name"), + name_set_by_user: row.get("name_set_by_user"), + exp_level: row.get("exp_level"), + exp_points: row.get("exp_points"), + gold: row.get("gold"), + diamonds: row.get("diamonds"), + free_diamonds: row.get("free_diamonds"), + trophies: row.get("trophies"), + arena: data_ref_from_row(row, "arena_table", "arena_instance", "arena_name"), + chest_slot_count: row.get::("chest_slot_count").max(0) as usize, + deck, + collection, + battle_count: row.get("battle_count"), + win_count: row.get("win_count"), + lose_count: row.get("lose_count"), + npc_win_count: row.get("npc_win_count"), + npc_lose_count: row.get("npc_lose_count"), + three_crown_wins: row.get("three_crown_wins"), + tutorials_finished: row.get("tutorials_finished"), + gold_resource: data_ref_from_row( + row, + "gold_resource_table", + "gold_resource_instance", + "gold_resource_name", + ), + chest_id_counter: row.get("chest_id_counter"), + free_chest_collect_count: row.get("free_chest_collect_count"), + crowns_towards_crown_chest: row.get("crowns_towards_crown_chest"), + chests, + } +} +fn bind_cards<'a>( + builder: &mut QueryBuilder<'a, Postgres>, + account: AccountRef, + holder: i16, + cards: &'a [OwnedCard], +) { + for (position, card) in cards.iter().enumerate() { + let (table_index, instance, name) = parts(&card.card); + builder.push("("); + builder.push_bind(account.high); + builder.push(", "); + builder.push_bind(account.low); + builder.push(", "); + builder.push_bind(holder); + builder.push(", "); + builder.push_bind(position as i32); + builder.push(", "); + builder.push_bind(table_index); + builder.push(", "); + builder.push_bind(instance); + builder.push(", "); + builder.push_bind(name); + builder.push(", "); + builder.push_bind(card.level_index); + builder.push(", "); + builder.push_bind(card.count); + builder.push(")"); + if position + 1 < cards.len() { + builder.push(", "); + } + } +} +impl ProfileStore { + pub async fn load(&self, account: AccountRef) -> Result> { + let row = storage::sqlx::query( + "select * from players where account_high = $1 and account_low = $2", + ) + .bind(account.high) + .bind(account.low) + .fetch_optional(self.database.pool()) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let (deck, collection) = self.cards(account).await?; + let chests = self.chests(account).await?; + Ok(Some(profile_from_row(&row, deck, collection, chests))) + } + pub async fn create_if_absent(&self, profile: PlayerProfile) -> Result { + if let Some(existing) = self.load(profile.account).await? { + return Ok(existing); + } + self.save(&profile).await?; + Ok(self.load(profile.account).await?.unwrap_or(profile)) + } + pub async fn save(&self, profile: &PlayerProfile) -> Result<()> { + let account = profile.account; + let (arena_table, arena_instance, arena_name) = parts(&profile.arena); + let (gold_table, gold_instance, gold_name) = parts(&profile.gold_resource); + let mut transaction = self.database.pool().begin().await?; + storage::sqlx::query( + "insert into accounts ( + account_high, account_low, pass_token, created_at, last_seen_at + ) + values ($1, $2, '', $3, $3) + on conflict (account_high, account_low) do nothing", + ) + .bind(account.high) + .bind(account.low) + .bind(unix_seconds()) + .execute(&mut *transaction) + .await?; + storage::sqlx::query( + "insert into players ( + account_high, account_low, name, name_set_by_user, exp_level, exp_points, + gold, diamonds, free_diamonds, trophies, + arena_table, arena_instance, arena_name, + gold_resource_table, gold_resource_instance, gold_resource_name, + chest_slot_count, battle_count, win_count, lose_count, + npc_win_count, npc_lose_count, three_crown_wins, tutorials_finished, updated_at, + chest_id_counter, free_chest_collect_count, crowns_towards_crown_chest + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) + on conflict (account_high, account_low) do update set + name = excluded.name, + name_set_by_user = excluded.name_set_by_user, + exp_level = excluded.exp_level, + exp_points = excluded.exp_points, + gold = excluded.gold, + diamonds = excluded.diamonds, + free_diamonds = excluded.free_diamonds, + trophies = excluded.trophies, + arena_table = excluded.arena_table, + arena_instance = excluded.arena_instance, + arena_name = excluded.arena_name, + gold_resource_table = excluded.gold_resource_table, + gold_resource_instance = excluded.gold_resource_instance, + gold_resource_name = excluded.gold_resource_name, + chest_slot_count = excluded.chest_slot_count, + battle_count = excluded.battle_count, + win_count = excluded.win_count, + lose_count = excluded.lose_count, + npc_win_count = excluded.npc_win_count, + npc_lose_count = excluded.npc_lose_count, + three_crown_wins = excluded.three_crown_wins, + tutorials_finished = excluded.tutorials_finished, + updated_at = excluded.updated_at, + chest_id_counter = excluded.chest_id_counter, + free_chest_collect_count = excluded.free_chest_collect_count, + crowns_towards_crown_chest = excluded.crowns_towards_crown_chest", + ) + .bind(account.high) + .bind(account.low) + .bind(&profile.name) + .bind(profile.name_set_by_user) + .bind(profile.exp_level) + .bind(profile.exp_points) + .bind(profile.gold) + .bind(profile.diamonds) + .bind(profile.free_diamonds) + .bind(profile.trophies) + .bind(arena_table) + .bind(arena_instance) + .bind(arena_name) + .bind(gold_table) + .bind(gold_instance) + .bind(gold_name) + .bind(profile.chest_slot_count as i32) + .bind(profile.battle_count) + .bind(profile.win_count) + .bind(profile.lose_count) + .bind(profile.npc_win_count) + .bind(profile.npc_lose_count) + .bind(profile.three_crown_wins) + .bind(profile.tutorials_finished) + .bind(unix_seconds()) + .bind(profile.chest_id_counter) + .bind(profile.free_chest_collect_count) + .bind(profile.crowns_towards_crown_chest) + .execute(&mut *transaction) + .await?; + storage::sqlx::query( + "delete from player_cards where account_high = $1 and account_low = $2", + ) + .bind(account.high) + .bind(account.low) + .execute(&mut *transaction) + .await?; + for (holder, cards) in [ + (CARD_HOLDER_DECK, &profile.deck), + (CARD_HOLDER_COLLECTION, &profile.collection), + ] { + if cards.is_empty() { + continue; + } + let mut builder = QueryBuilder::new( + "insert into player_cards ( + account_high, account_low, holder, position, + card_table, card_instance, card_name, level_index, count + ) values ", + ); + bind_cards(&mut builder, account, holder, cards); + builder.build().execute(&mut *transaction).await?; + } + storage::sqlx::query( + "delete from player_chests where account_high = $1 and account_low = $2", + ) + .bind(account.high) + .bind(account.low) + .execute(&mut *transaction) + .await?; + for chest in &profile.chests { + let (chest_table, chest_instance, chest_name) = parts(&chest.chest); + storage::sqlx::query( + "insert into player_chests ( + account_high, account_low, slot_index, chest_table, chest_instance, chest_name, + chest_id, source, unlocked, claimed, remaining_ticks, total_ticks, end_timestamp + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", + ) + .bind(account.high) + .bind(account.low) + .bind(chest.slot_index) + .bind(chest_table) + .bind(chest_instance) + .bind(chest_name) + .bind(chest.chest_id) + .bind(chest.source) + .bind(chest.unlocked) + .bind(chest.claimed) + .bind(chest.remaining_ticks) + .bind(chest.total_ticks) + .bind(chest.end_timestamp) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) + } + pub async fn count(&self) -> Result { + let total: i64 = storage::sqlx::query_scalar("select count(*) from players") + .fetch_one(self.database.pool()) + .await?; + Ok(total.max(0) as usize) + } + pub async fn record_purchase(&self, account: AccountRef, offer: &ShopOffer) -> Result<()> { + let (cost_table, cost_instance, _) = parts(&offer.cost.data); + let (give_table, give_instance, _) = parts(&offer.give.data); + storage::sqlx::query( + "insert into shop_purchases ( + account_high, account_low, offer_id, + cost_table, cost_instance, cost_count, + give_table, give_instance, give_count, purchased_at + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + ) + .bind(account.high) + .bind(account.low) + .bind(offer.id) + .bind(cost_table) + .bind(cost_instance) + .bind(offer.cost.count) + .bind(give_table) + .bind(give_instance) + .bind(offer.give.count) + .bind(unix_seconds()) + .execute(self.database.pool()) + .await?; + Ok(()) + } +} diff --git a/crates/game-service/src/store/profile.rs b/crates/game-service/src/store/profile.rs new file mode 100644 index 0000000..805d7d7 --- /dev/null +++ b/crates/game-service/src/store/profile.rs @@ -0,0 +1,94 @@ +use service_rpc::AccountRef; +use logic::LogicDataRef; +use crate::catalog::Catalog; +use crate::config::StarterProfile; +pub const CARD_HOLDER_DECK: i16 = 0; +pub const CARD_HOLDER_COLLECTION: i16 = 1; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredChest { + pub chest: LogicDataRef, + pub chest_id: i32, + pub slot_index: i32, + pub source: i32, + pub unlocked: bool, + pub claimed: bool, + pub remaining_ticks: i32, + pub total_ticks: i32, + pub end_timestamp: i32, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCard { + pub card: LogicDataRef, + pub level_index: i32, + pub count: i32, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlayerProfile { + pub account: AccountRef, + pub name: String, + pub name_set_by_user: bool, + pub exp_level: i32, + pub exp_points: i32, + pub gold: i32, + pub diamonds: i32, + pub free_diamonds: i32, + pub trophies: i32, + pub arena: LogicDataRef, + pub chest_slot_count: usize, + pub deck: Vec, + pub collection: Vec, + pub battle_count: i32, + pub win_count: i32, + pub lose_count: i32, + pub npc_win_count: i32, + pub npc_lose_count: i32, + pub three_crown_wins: i32, + pub tutorials_finished: bool, + pub gold_resource: LogicDataRef, + pub chest_id_counter: i32, + pub free_chest_collect_count: i32, + pub crowns_towards_crown_chest: i32, + pub chests: Vec, +} +impl PlayerProfile { + pub fn starter(account: AccountRef, starter: &StarterProfile, catalog: &Catalog) -> Self { + let card_of = |card: &crate::config::CardRef| { + catalog.resolve_card(card).map(|resolved| OwnedCard { + card: resolved, + level_index: starter.card_level_index, + count: starter.card_count, + }) + }; + Self { + account, + name: format!("{}{}", starter.name_prefix, account.low), + name_set_by_user: false, + exp_level: starter.exp_level, + exp_points: starter.exp_points, + gold: starter.gold, + diamonds: starter.diamonds, + free_diamonds: starter.free_diamonds, + trophies: starter.trophies, + arena: catalog.resolve_arena(&starter.arena), + chest_slot_count: starter.chest_slot_count, + deck: starter.deck.iter().filter_map(card_of).collect(), + collection: starter + .extra_collection + .iter() + .filter_map(card_of) + .collect(), + battle_count: 0, + win_count: 0, + lose_count: 0, + npc_win_count: starter.npc_win_count, + npc_lose_count: starter.npc_lose_count, + three_crown_wins: 0, + tutorials_finished: starter.finish_tutorials, + gold_resource: catalog.resolve_resource(&starter.gold_resource), + chest_id_counter: 0, + free_chest_collect_count: 1, + crowns_towards_crown_chest: 0, + chests: Vec::new(), + } + } +} diff --git a/crates/logic/Cargo.toml b/crates/logic/Cargo.toml index a2ee0a7..d0fe6a1 100644 --- a/crates/logic/Cargo.toml +++ b/crates/logic/Cargo.toml @@ -6,13 +6,8 @@ rust-version.workspace = true license.workspace = true description = "Scroll game logic model and wire messages built on top of the titan engine" -[features] -default = [] -serde = ["dep:serde"] - [dependencies] titan = { workspace = true } logic-derive = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } -serde = { workspace = true, optional = true } diff --git a/crates/logic/src/commands/command.rs b/crates/logic/src/commands/command.rs index d4022d8..e899360 100644 --- a/crates/logic/src/commands/command.rs +++ b/crates/logic/src/commands/command.rs @@ -14,6 +14,10 @@ pub enum CommandOutcome { level: i32, gold_spent: i32, }, + PurchaseRequested { + data: LogicDataRef, + count: i32, + }, Applied, Rejected(&'static str), Ignored, diff --git a/crates/logic/src/commands/mod.rs b/crates/logic/src/commands/mod.rs index e8eeff1..4842026 100644 --- a/crates/logic/src/commands/mod.rs +++ b/crates/logic/src/commands/mod.rs @@ -3,6 +3,7 @@ mod command; mod header; mod manager; mod reward; +mod shop; mod spells; mod ui; pub use chest::{ @@ -13,15 +14,21 @@ pub use command::{CommandMeta, CommandOutcome, CommandRegistryEntry, Execute, Lo pub use header::LogicCommandHeader; pub use manager::LogicCommandManager; pub use reward::LogicReward; +pub use shop::{ + LogicBuyCardCommand, LogicBuyChestCommand, LogicBuyResourcePackCommand, + LogicShopSeedChangedCommand, +}; pub use spells::{LogicFuseSpellsCommand, LogicSortCollectionCommand}; pub use ui::{ LogicHelpOpenedCommand, LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicShopOpenedCommand, }; pub mod command_type { + pub const DIAMONDS_ADDED: i32 = 202; pub const CLAIM_REWARD: i32 = 213; pub const ADD_CHEST: i32 = 214; pub const ADD_SPELLS: i32 = 216; + pub const SHOP_SEED_CHANGED: i32 = 217; pub const SWAP_SPELLS: i32 = 500; pub const START_EXPLORING: i32 = 502; pub const START_REWARD_CLAIM: i32 = 503; @@ -34,6 +41,9 @@ pub mod command_type { pub const SORT_COLLECTION: i32 = 520; pub const MOVE_SPELL: i32 = 521; pub const COLLECT_MULTI_WIN_CHEST: i32 = 523; + pub const BUY_CHEST: i32 = 528; + pub const BUY_RESOURCE_PACK: i32 = 529; + pub const BUY_CARD: i32 = 530; pub const HELP_OPENED: i32 = 531; pub const SHOP_OPENED: i32 = 532; pub const REFRESH_ACHIEVEMENTS: i32 = 538; diff --git a/crates/logic/src/commands/shop.rs b/crates/logic/src/commands/shop.rs new file mode 100644 index 0000000..3b4725c --- /dev/null +++ b/crates/logic/src/commands/shop.rs @@ -0,0 +1,101 @@ +use logic_derive::Command; +use titan::Payload; +use crate::commands::command::{CommandOutcome, Execute}; +use crate::commands::command_type; +use crate::commands::header::LogicCommandHeader; +use crate::data::LogicDataRef; +use crate::home::LogicHomeMode; +pub const WEEKDAY_MIN: i32 = 1; +pub const WEEKDAY_MAX: i32 = 7; +#[derive(Debug, Default, Payload, Command)] +#[command(id = command_type::BUY_CHEST)] +pub struct LogicBuyChestCommand { + pub header: LogicCommandHeader, + #[codec(int)] + pub unused: i32, + pub chest: LogicDataRef, +} +impl Execute for LogicBuyChestCommand { + fn execute(&self, mode: &mut LogicHomeMode) -> CommandOutcome { + if self.chest.as_treasure_chest().is_none() { + return CommandOutcome::Rejected("that is not a treasure chest"); + } + if mode.is_claiming_reward() { + return CommandOutcome::Rejected("a reward claim is already in progress"); + } + mode.begin_claim(); + CommandOutcome::PurchaseRequested { + data: self.chest.clone(), + count: 1, + } + } +} +#[derive(Debug, Default, Payload, Command)] +#[command(id = command_type::BUY_RESOURCE_PACK)] +pub struct LogicBuyResourcePackCommand { + pub header: LogicCommandHeader, + pub pack: LogicDataRef, +} +impl Execute for LogicBuyResourcePackCommand { + fn execute(&self, _mode: &mut LogicHomeMode) -> CommandOutcome { + let Some(pack) = self.pack.as_resource_pack() else { + return CommandOutcome::Rejected("that is not a resource pack"); + }; + let amount = pack.amount(); + if amount < 1 { + return CommandOutcome::Rejected("the resource pack is empty"); + } + CommandOutcome::PurchaseRequested { + data: pack.resource(), + count: amount, + } + } +} +#[derive(Debug, Default, Payload, Command)] +#[command(id = command_type::BUY_CARD)] +pub struct LogicBuyCardCommand { + pub header: LogicCommandHeader, + #[codec(int)] + pub unused: i32, + pub card: LogicDataRef, +} +impl Execute for LogicBuyCardCommand { + fn execute(&self, _mode: &mut LogicHomeMode) -> CommandOutcome { + if self.card.as_spell().is_none() { + return CommandOutcome::Rejected("that is not a card"); + } + CommandOutcome::PurchaseRequested { + data: self.card.clone(), + count: 1, + } + } +} +#[derive(Debug, Default, Payload, Command)] +#[command(id = command_type::SHOP_SEED_CHANGED)] +pub struct LogicShopSeedChangedCommand { + #[codec(vint)] + pub shop_seed: i32, + #[codec(vint)] + pub seconds_to_cycle: i32, + #[codec(vint)] + pub weekday_index: i32, + #[codec(vint)] + pub server_command_id: i32, + pub header: LogicCommandHeader, +} +impl LogicShopSeedChangedCommand { + pub fn new(shop_seed: i32, seconds_to_cycle: i32, weekday_index: i32) -> Self { + Self { + shop_seed, + seconds_to_cycle, + weekday_index: weekday_index.clamp(WEEKDAY_MIN, WEEKDAY_MAX), + server_command_id: 0, + header: LogicCommandHeader::default(), + } + } +} +impl Execute for LogicShopSeedChangedCommand { + fn execute(&self, _mode: &mut LogicHomeMode) -> CommandOutcome { + CommandOutcome::Ignored + } +} diff --git a/crates/logic/src/data/data_ref.rs b/crates/logic/src/data/data_ref.rs index f7e9c96..053aa9f 100644 --- a/crates/logic/src/data/data_ref.rs +++ b/crates/logic/src/data/data_ref.rs @@ -5,7 +5,8 @@ use crate::data::logic_data::LogicData; use crate::data::logic_data_tables::LogicDataTables; use crate::data::tables::table; use crate::data::typed::{ - LogicArenaData, LogicRarityData, LogicResourceData, LogicSpellData, LogicTreasureChestData, + LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, + LogicTreasureChestData, }; #[derive(Clone, Default)] pub enum LogicDataRef { @@ -30,6 +31,15 @@ impl LogicDataRef { None => LogicDataRef::None, } } + pub fn by_name_or_instance(table_index: i32, name: Option<&str>, instance_id: i32) -> Self { + if let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) { + let resolved = Self::by_name(table_index, name); + if !resolved.is_none() { + return resolved; + } + } + Self::of(table_index, instance_id) + } pub fn spell(name: &str) -> Self { Self::by_name(table::SPELLS, name) } @@ -96,6 +106,9 @@ impl LogicDataRef { pub fn as_rarity(&self) -> Option { self.typed(table::RARITIES, LogicRarityData::new) } + pub fn as_resource_pack(&self) -> Option { + self.typed(table::RESOURCE_PACKS, LogicResourcePackData::new) + } } impl PartialEq for LogicDataRef { fn eq(&self, other: &Self) -> bool { @@ -153,66 +166,3 @@ impl Payload for LogicDataRef { Ok(Self::from_global_id(GlobalId::new(class_id, instance_id))) } } -#[cfg(feature = "serde")] -mod serde_support { - use serde::de::{self, MapAccess, Visitor}; - use serde::ser::SerializeMap; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use super::LogicDataRef; - impl Serialize for LogicDataRef { - fn serialize(&self, serializer: S) -> Result { - let Some(id) = self.global_id() else { - return serializer.serialize_none(); - }; - let mut map = serializer.serialize_map(Some(2))?; - map.serialize_entry("table", &id.class_id)?; - match self.name() { - "" => map.serialize_entry("instance", &id.instance_id)?, - name => map.serialize_entry("name", name)?, - } - map.end() - } - } - #[derive(Deserialize)] - struct Repr { - table: i32, - #[serde(default)] - name: Option, - #[serde(default)] - instance: Option, - } - impl<'de> Deserialize<'de> for LogicDataRef { - fn deserialize>(deserializer: D) -> Result { - struct RefVisitor; - impl<'de> Visitor<'de> for RefVisitor { - type Value = LogicDataRef; - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("null or a { table, name | instance } object") - } - fn visit_unit(self) -> Result { - Ok(LogicDataRef::None) - } - fn visit_none(self) -> Result { - Ok(LogicDataRef::None) - } - fn visit_some>( - self, - deserializer: D, - ) -> Result { - deserializer.deserialize_any(RefVisitor) - } - fn visit_map>(self, map: A) -> Result { - let repr = Repr::deserialize(de::value::MapAccessDeserializer::new(map))?; - if let Some(instance) = repr.instance { - return Ok(LogicDataRef::of(repr.table, instance)); - } - let name = repr.name.ok_or_else(|| { - de::Error::custom("a data reference needs either `name` or `instance`") - })?; - Ok(LogicDataRef::by_name(repr.table, &name)) - } - } - deserializer.deserialize_option(RefVisitor) - } - } -} diff --git a/crates/logic/src/data/mod.rs b/crates/logic/src/data/mod.rs index ed4574f..de29cbc 100644 --- a/crates/logic/src/data/mod.rs +++ b/crates/logic/src/data/mod.rs @@ -12,5 +12,6 @@ pub use logic_data_tables::{ }; pub use tables::{table, DataId, RESOURCE_DIAMONDS, RESOURCE_FREE_GOLD, RESOURCE_GOLD}; pub use typed::{ - LogicArenaData, LogicRarityData, LogicResourceData, LogicSpellData, LogicTreasureChestData, + LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, + LogicTreasureChestData, }; diff --git a/crates/logic/src/data/typed.rs b/crates/logic/src/data/typed.rs index eec003d..78d9a84 100644 --- a/crates/logic/src/data/typed.rs +++ b/crates/logic/src/data/typed.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use crate::data::data_ref::LogicDataRef; use crate::data::logic_data::LogicData; macro_rules! typed_data { ($name:ident) => { @@ -40,6 +41,18 @@ typed_data!(LogicArenaData); typed_data!(LogicResourceData); typed_data!(LogicTreasureChestData); typed_data!(LogicRarityData); +typed_data!(LogicResourcePackData); +impl LogicResourcePackData { + pub fn resource(&self) -> LogicDataRef { + LogicDataRef::by_name( + crate::data::tables::table::RESOURCES, + self.string("Resource"), + ) + } + pub fn amount(&self) -> i32 { + self.int("Amount") + } +} impl LogicSpellData { pub fn rarity(&self) -> &str { self.string("Rarity") diff --git a/crates/logic/src/home/logic_home_mode.rs b/crates/logic/src/home/logic_home_mode.rs index 43892a9..0bec567 100644 --- a/crates/logic/src/home/logic_home_mode.rs +++ b/crates/logic/src/home/logic_home_mode.rs @@ -1,7 +1,8 @@ use crate::commands::{CommandOutcome, LogicCommand, LogicReward}; use crate::data::{LogicDataRef, LogicRarityData}; use crate::model::{ - LogicChest, LogicClientAvatar, LogicClientHome, LogicSpell, LogicTimer, TICKS_PER_SECOND, + ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicSpell, LogicTimer, + TICKS_PER_SECOND, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogicHomeMode { @@ -107,6 +108,53 @@ impl LogicHomeMode { pub fn begin_claim(&mut self) { self.claiming_reward = true; } + pub fn diamonds(&self) -> i32 { + self.avatar.diamonds + } + pub fn add_diamonds(&mut self, amount: i32) { + self.avatar.diamonds = self.avatar.diamonds.saturating_add(amount).max(0); + } + pub fn spend_gold(&mut self, amount: i32) -> bool { + if amount < 0 || self.gold() < amount { + return false; + } + self.add_gold(-amount); + true + } + pub fn spend_diamonds(&mut self, amount: i32) -> bool { + if amount < 0 || self.avatar.diamonds < amount { + return false; + } + self.add_diamonds(-amount); + true + } + pub fn free_chest_slot(&self) -> Option { + self.home.chest_slots.iter().position(|slot| slot.is_none()) + } + pub fn grant_chest(&mut self, data: LogicDataRef, source: ChestSource) -> Option { + let slot_index = self.free_chest_slot()?; + let chest_id = self.home.chest_id_counter.saturating_add(1); + self.home.chest_id_counter = chest_id; + self.home.chest_slots[slot_index] = Some(LogicChest::locked( + data, + chest_id, + slot_index as i32, + source, + )); + Some(chest_id) + } + pub fn grant_card(&mut self, data: LogicDataRef, count: i32) { + self.grant_spell(&LogicSpell { + data, + level_index: 0, + create_time: 0, + count, + new_count: count, + recent_use_count: 0, + new_upgrade_available: false, + show_new_icon: true, + }); + } pub fn apply_reward(&mut self, reward: &LogicReward) { for spell in reward.spells.iter().flatten() { self.grant_spell(spell); diff --git a/crates/logic/src/lib.rs b/crates/logic/src/lib.rs index ddc0ba7..4dcc703 100644 --- a/crates/logic/src/lib.rs +++ b/crates/logic/src/lib.rs @@ -6,16 +6,18 @@ pub mod home; pub mod messages; pub mod model; pub use commands::{ - chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicClaimRewardCommand, + chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicBuyCardCommand, + LogicBuyChestCommand, LogicBuyResourcePackCommand, LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCollectMultiWinChestCommand, LogicCommand, LogicCommandHeader, LogicCommandManager, LogicFuseSpellsCommand, LogicHelpOpenedCommand, LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand, - LogicSortCollectionCommand, LogicStartRewardClaimCommand, + LogicShopSeedChangedCommand, LogicSortCollectionCommand, LogicStartRewardClaimCommand, }; pub use data::{ table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable, - LogicDataTableResource, LogicDataTables, LogicRarityData, LogicResourceData, LogicSpellData, - LogicTreasureChestData, DATA_TABLE_RESOURCES, TABLE_COUNT, + LogicDataTableResource, LogicDataTables, LogicRarityData, LogicResourceData, + LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES, + TABLE_COUNT, }; pub use factory::{scroll_message_registry, LogicScrollMessageFactory}; pub use home::LogicHomeMode; diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml new file mode 100644 index 0000000..d5ad9cf --- /dev/null +++ b/crates/storage/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "storage" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Postgres connection pooling and schema migrations shared by the scroll services" + +[dependencies] +sqlx = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } diff --git a/crates/storage/migrations/0001_initial.sql b/crates/storage/migrations/0001_initial.sql new file mode 100644 index 0000000..beef4da --- /dev/null +++ b/crates/storage/migrations/0001_initial.sql @@ -0,0 +1,98 @@ +create sequence if not exists accounts_low_seq as integer start with 1 minvalue 1; + +create table if not exists accounts ( + account_high integer not null, + account_low integer not null, + pass_token text not null, + created_at bigint not null, + last_seen_at bigint not null, + session_count integer not null default 0, + play_time_seconds integer not null default 0, + banned boolean not null default false, + primary key (account_high, account_low) +); + +create table if not exists players ( + account_high integer not null, + account_low integer not null, + name text not null, + name_set_by_user boolean not null default false, + exp_level integer not null, + exp_points integer not null, + gold integer not null, + diamonds integer not null, + free_diamonds integer not null, + trophies integer not null, + arena_table integer not null, + arena_instance integer not null, + arena_name text, + gold_resource_table integer not null, + gold_resource_instance integer not null, + gold_resource_name text, + chest_slot_count integer not null, + battle_count integer not null default 0, + win_count integer not null default 0, + lose_count integer not null default 0, + npc_win_count integer not null default 0, + npc_lose_count integer not null default 0, + three_crown_wins integer not null default 0, + tutorials_finished boolean not null default false, + chest_id_counter integer not null default 0, + free_chest_collect_count integer not null default 1, + crowns_towards_crown_chest integer not null default 0, + updated_at bigint not null default 0, + primary key (account_high, account_low), + foreign key (account_high, account_low) references accounts (account_high, account_low) on delete cascade +); + +create table if not exists player_cards ( + account_high integer not null, + account_low integer not null, + holder smallint not null, + position integer not null, + card_table integer not null, + card_instance integer not null, + card_name text, + level_index integer not null default 0, + count integer not null default 0, + primary key (account_high, account_low, holder, position), + foreign key (account_high, account_low) references players (account_high, account_low) on delete cascade +); + +create index if not exists player_cards_account_idx + on player_cards (account_high, account_low); + +create table if not exists player_chests ( + account_high integer not null, + account_low integer not null, + slot_index integer not null, + chest_table integer not null, + chest_instance integer not null, + chest_name text, + chest_id integer not null, + source integer not null default 0, + unlocked boolean not null default false, + claimed boolean not null default false, + remaining_ticks integer not null default 0, + total_ticks integer not null default 0, + end_timestamp integer not null default -1, + primary key (account_high, account_low, slot_index), + foreign key (account_high, account_low) references players (account_high, account_low) on delete cascade +); + +create table if not exists shop_purchases ( + id bigserial primary key, + account_high integer not null, + account_low integer not null, + offer_id integer not null, + cost_table integer not null, + cost_instance integer not null, + cost_count integer not null, + give_table integer not null, + give_instance integer not null, + give_count integer not null, + purchased_at bigint not null +); + +create index if not exists shop_purchases_account_idx + on shop_purchases (account_high, account_low, purchased_at desc); diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs new file mode 100644 index 0000000..e003541 --- /dev/null +++ b/crates/storage/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; +pub type Result = std::result::Result; +#[derive(Debug, Error)] +pub enum StorageError { + #[error("database: {0}")] + Database(#[from] sqlx::Error), + #[error("migration: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("{0}")] + Corrupt(String), +} +impl StorageError { + pub fn corrupt(message: impl Into) -> Self { + Self::Corrupt(message.into()) + } +} +impl From for std::io::Error { + fn from(error: StorageError) -> Self { + std::io::Error::other(error.to_string()) + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs new file mode 100644 index 0000000..49a3591 --- /dev/null +++ b/crates/storage/src/lib.rs @@ -0,0 +1,7 @@ +mod error; +mod pool; +pub use error::{Result, StorageError}; +pub use pool::{Database, DatabaseConfig}; +pub use sqlx; +pub use sqlx::postgres::PgRow; +pub use sqlx::{PgPool, Row}; diff --git a/crates/storage/src/pool.rs b/crates/storage/src/pool.rs new file mode 100644 index 0000000..d38bd0d --- /dev/null +++ b/crates/storage/src/pool.rs @@ -0,0 +1,66 @@ +use std::time::Duration; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use sqlx::{ConnectOptions, PgPool}; +use crate::error::Result; +#[derive(Debug, Clone)] +pub struct DatabaseConfig { + pub url: String, + pub max_connections: u32, + pub acquire_timeout: Duration, + pub statement_log_threshold: Duration, +} +impl DatabaseConfig { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + max_connections: 8, + acquire_timeout: Duration::from_secs(5), + statement_log_threshold: Duration::from_millis(250), + } + } + pub fn from_env() -> Option { + let url = std::env::var("DATABASE_URL") + .ok() + .filter(|value| !value.trim().is_empty())?; + let mut config = Self::new(url); + if let Some(max) = std::env::var("DATABASE_MAX_CONNECTIONS") + .ok() + .and_then(|value| value.parse().ok()) + { + config.max_connections = max; + } + Some(config) + } +} +#[derive(Debug, Clone)] +pub struct Database { + pool: PgPool, +} +impl Database { + pub async fn connect(config: &DatabaseConfig) -> Result { + let options: PgConnectOptions = + config.url.parse::()?.log_slow_statements( + tracing::log::LevelFilter::Warn, + config.statement_log_threshold, + ); + let pool = PgPoolOptions::new() + .max_connections(config.max_connections) + .acquire_timeout(config.acquire_timeout) + .connect_with(options) + .await?; + Ok(Self { pool }) + } + pub async fn migrate(&self) -> Result<()> { + sqlx::migrate!("./migrations").run(&self.pool).await?; + Ok(()) + } + pub fn pool(&self) -> &PgPool { + &self.pool + } + pub async fn server_version(&self) -> Result { + let version: String = sqlx::query_scalar("select version()") + .fetch_one(&self.pool) + .await?; + Ok(version) + } +}