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) + } +}