From f2fe6215aa39db0f93c849905d358926c7a80d0e Mon Sep 17 00:00:00 2001 From: spozitivom Date: Wed, 8 Apr 2026 13:28:12 +0300 Subject: [PATCH 1/2] feat(facade): add phase1 product write foundation for progression and stats --- Cargo.lock | 311 +++++++--- facade/Cargo.toml | 5 + facade/README.md | 32 + facade/scripts/init_product_db.ps1 | 19 + facade/sql/product_schema_v1.sql | 48 ++ facade/src/context.rs | 194 +++++- facade/src/db.rs | 521 +++++++++++++++- facade/src/main.rs | 910 +++++++++++++++++++++++++++- facade/src/product_event_service.rs | 379 ++++++++++++ facade/src/product_rules.rs | 47 ++ facade/src/product_store.rs | 360 +++++++++++ 11 files changed, 2707 insertions(+), 119 deletions(-) create mode 100644 facade/scripts/init_product_db.ps1 create mode 100644 facade/sql/product_schema_v1.sql create mode 100644 facade/src/product_event_service.rs create mode 100644 facade/src/product_rules.rs create mode 100644 facade/src/product_store.rs diff --git a/Cargo.lock b/Cargo.lock index dcd408ca..10a7a073 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -568,7 +568,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -579,7 +579,7 @@ checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -665,7 +665,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1077,7 +1077,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1218,7 +1218,7 @@ checksum = "3fa76293b4f7bb636ab88fd78228235b5248b4d05cc589aed610f954af5d7c7a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1229,9 +1229,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -1293,7 +1293,7 @@ checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1391,7 +1391,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1845,7 +1845,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1906,7 +1906,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1928,7 +1928,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -1981,7 +1981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1145d32e826a7748b69ee8fc62d3e6355ff7f1051df53141e7048162fc90481b" dependencies = [ "data-encoding", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2068,7 +2068,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2088,7 +2088,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "unicode-xid", ] @@ -2189,7 +2189,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2399,7 +2399,7 @@ checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2411,7 +2411,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2432,7 +2432,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -2802,7 +2802,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -3580,7 +3580,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -3650,7 +3650,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -3831,10 +3831,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -4062,7 +4064,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -4213,9 +4215,9 @@ checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" [[package]] name = "libc" -version = "0.2.169" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -4225,12 +4227,14 @@ checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.8.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -4741,7 +4745,7 @@ source = "git+https://github.com/mystenlabs/sui#ca155f399df75a25c695c940c5fc210e dependencies = [ "enum-compat-util", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5126,7 +5130,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5219,7 +5223,7 @@ dependencies = [ "proc-macro-crate 1.1.3", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5231,7 +5235,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5324,7 +5328,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5372,7 +5376,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5457,7 +5461,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.8", "smallvec", "windows-targets 0.52.6", ] @@ -5587,7 +5591,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5641,7 +5645,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5670,7 +5674,7 @@ checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5723,6 +5727,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "polyval" version = "0.6.2" @@ -5741,6 +5751,49 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +[[package]] +name = "postgres" +version = "0.19.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7915b33ed60abc46040cbcaa25ffa1c7ec240668e0477c4f3070786f5916d451" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acda0ebdebc28befa84bee35e651e4c5f09073d668c7aed4cf7e23c3cda84b23" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.12.1", + "md-5", + "memchr", + "rand 0.8.5", + "sha2 0.10.8", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66ea23a2d0e5734297357705193335e0a957696f34bed2f2faefacb2fec336f" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -5911,7 +5964,7 @@ checksum = "4ee1c9ac207483d5e7db4940700de86a9aae46ef90c48b57f99fe7edb8345e49" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -5934,7 +5987,7 @@ dependencies = [ "itertools 0.13.0", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -6219,14 +6272,19 @@ dependencies = [ "getrandom 0.2.15", "hyper 0.14.32", "jsonrpsee 0.17.1", + "postgres", + "postgres-protocol", + "postgres-types", "race-api", "race-core", "regex", "rusqlite", "serde", "serde_json", + "sha2 0.10.8", "thiserror 1.0.69", "tokio", + "tokio-postgres", "tower 0.4.13", "tower-http 0.4.4", "tracing", @@ -6608,7 +6666,7 @@ checksum = "a25d631e41bfb5fdcde1d4e2215f62f7f0afa3ff11e26563765bd6ea1d229aeb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -6620,6 +6678,15 @@ dependencies = [ "bitflags 2.8.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.8.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -6648,7 +6715,7 @@ checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -7256,7 +7323,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -7457,7 +7524,7 @@ checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -7468,7 +7535,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -7502,7 +7569,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -7544,7 +7611,7 @@ dependencies = [ "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -8573,7 +8640,7 @@ dependencies = [ "bs58 0.5.1", "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -9029,7 +9096,7 @@ checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" dependencies = [ "quote", "spl-discriminator-syn", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -9041,7 +9108,7 @@ dependencies = [ "proc-macro2", "quote", "sha2 0.10.8", - "syn 2.0.96", + "syn 2.0.117", "thiserror 1.0.69", ] @@ -9150,7 +9217,7 @@ dependencies = [ "proc-macro2", "quote", "sha2 0.10.8", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -9472,6 +9539,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[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 = "strsim" version = "0.10.0" @@ -9525,7 +9603,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -9750,7 +9828,7 @@ dependencies = [ "proc-macro2", "quote", "sui-enum-compat-util", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10003,9 +10081,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.96" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -10047,7 +10125,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10186,7 +10264,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10197,7 +10275,7 @@ checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10320,7 +10398,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10333,6 +10411,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-postgres" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d340244b32d920260ae7448cb72b6e238bddc3d4f7603394e7dd46ed8e48f5b8" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.8.5", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -10632,7 +10736,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10764,7 +10868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a615d6c2764852a2e88a4f16e9ce1ea49bb776b5872956309e170d63a042a34f" dependencies = [ "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -10803,6 +10907,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[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.14" @@ -10818,6 +10928,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-width" version = "0.1.14" @@ -11019,50 +11135,40 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.96", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -11070,22 +11176,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.96", - "wasm-bindgen-backend", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -11283,9 +11389,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -11334,6 +11440,17 @@ 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", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -11703,7 +11820,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "synstructure 0.13.1", ] @@ -11725,7 +11842,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -11745,7 +11862,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", "synstructure 0.13.1", ] @@ -11766,7 +11883,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] @@ -11788,7 +11905,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.96", + "syn 2.0.117", ] [[package]] diff --git a/facade/Cargo.toml b/facade/Cargo.toml index fed6db2d..fe99d47a 100644 --- a/facade/Cargo.toml +++ b/facade/Cargo.toml @@ -35,3 +35,8 @@ getrandom.workspace = true clap = { workspace = true, features = ["derive"] } rusqlite = { workspace = true, features = ["bundled"] } regex.workspace = true +sha2.workspace = true +postgres = "=0.19.7" +tokio-postgres = "=0.7.10" +postgres-protocol = "=0.6.7" +postgres-types = "=0.2.8" diff --git a/facade/README.md b/facade/README.md index 90ca3194..d3c16ba9 100644 --- a/facade/README.md +++ b/facade/README.md @@ -1 +1,33 @@ # A server to simulate a blockchain for development and test + +## Current local storage behavior + +`race-facade` now uses: + +- sqlite for gameplay/runtime-facing facade state +- Postgres for product-layer guest data when available + +Default local dev behavior: + +- sqlite db path: `data/facade.sqlite3` +- default Postgres product db url: `postgresql://postgres@localhost/race_poker_product` + +Startup priority: + +1. `--product-db-url` +2. `RACE_FACADE_PRODUCT_DB_URL` +3. default local Postgres dev db +4. sqlite-only fallback if the default local Postgres db is unavailable + +To force sqlite-only mode even when local Postgres exists: + +```powershell +$env:RACE_FACADE_DISABLE_DEFAULT_PRODUCT_DB='1' +cargo run -p race-facade +``` + +To initialize the local Postgres product db: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\init_product_db.ps1 +``` diff --git a/facade/scripts/init_product_db.ps1 b/facade/scripts/init_product_db.ps1 new file mode 100644 index 00000000..3420c1c9 --- /dev/null +++ b/facade/scripts/init_product_db.ps1 @@ -0,0 +1,19 @@ +param( + [string]$DbName = "race_poker_product", + [string]$User = "postgres", + [string]$AdminDb = "postgres" +) + +$ErrorActionPreference = "Stop" + +$schemaPath = Join-Path $PSScriptRoot "..\\sql\\product_schema_v1.sql" +$schemaPath = [System.IO.Path]::GetFullPath($schemaPath) + +$dbExists = psql -U $User -d $AdminDb -Atc "SELECT 1 FROM pg_database WHERE datname = '$DbName';" +if (-not $dbExists) { + psql -U $User -d $AdminDb -c "CREATE DATABASE $DbName;" +} + +psql -U $User -d $DbName -f $schemaPath + +Write-Host "Initialized Postgres product DB '$DbName' using schema $schemaPath" diff --git a/facade/sql/product_schema_v1.sql b/facade/sql/product_schema_v1.sql new file mode 100644 index 00000000..7857d0d1 --- /dev/null +++ b/facade/sql/product_schema_v1.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS guest_account ( + guest_id TEXT PRIMARY KEY, + player_addr TEXT NOT NULL UNIQUE, + nick TEXT NOT NULL, + status TEXT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS guest_session ( + session_id TEXT PRIMARY KEY, + guest_id TEXT NOT NULL REFERENCES guest_account(guest_id), + session_token_hash TEXT NOT NULL UNIQUE, + created_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + revoked_at BIGINT +); + +CREATE TABLE IF NOT EXISTS user_progress ( + guest_id TEXT PRIMARY KEY REFERENCES guest_account(guest_id), + rank_tier TEXT NOT NULL, + xp BIGINT NOT NULL, + level INTEGER NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_rating ( + guest_id TEXT PRIMARY KEY REFERENCES guest_account(guest_id), + rating INTEGER NOT NULL, + rank_bucket TEXT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_stats ( + guest_id TEXT PRIMARY KEY REFERENCES guest_account(guest_id), + hands_played BIGINT NOT NULL DEFAULT 0, + games_played BIGINT NOT NULL DEFAULT 0, + wins BIGINT NOT NULL DEFAULT 0, + losses BIGINT NOT NULL DEFAULT 0, + last_played_at BIGINT +); + +CREATE TABLE IF NOT EXISTS product_event_log ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + guest_id TEXT NOT NULL REFERENCES guest_account(guest_id), + created_at BIGINT NOT NULL +); diff --git a/facade/src/context.rs b/facade/src/context.rs index ce2afa05..e4a87b06 100644 --- a/facade/src/context.rs +++ b/facade/src/context.rs @@ -1,14 +1,21 @@ -use std::{fs::File, io::Read}; +use std::{fs::File, io::Read, path::Path}; use crate::{ db::{ create_game_account, create_game_bundle, create_player_info, create_recipient_account, - create_server_account, create_stake, create_token_account, list_game_accounts, + create_server_account, create_stake, create_token_account, create_guest_account, + create_guest_session, initialize_product_state, list_game_accounts, + increment_user_hands_played, insert_product_event_log_entry, list_token_accounts, prepare_all_tables, read_game_account, read_game_bundle, read_player_info, read_recipient_account, read_registration_account, read_server_account, - read_token_account, update_game_account, update_player_info, update_recipient_account, - update_stake, read_stake, PlayerInfo, Stake, + read_token_account, read_guest_account_by_guest_id, read_guest_account_by_player_addr, + read_guest_session_by_token_hash, read_user_progress, read_user_rating, read_user_stats, + record_user_joined_game, revoke_guest_session, update_game_account, + update_user_progress, ProductEventLogEntry, + update_player_info, update_recipient_account, update_stake, read_stake, GuestAccount, + GuestSession, PlayerInfo, Stake, UserProgress, UserRating, UserStats, }, + product_store::ProductStore, GameSpec, }; use race_core::types::{ @@ -19,17 +26,52 @@ use rusqlite::Connection; pub struct Context { conn: Connection, + product_store: Option, } impl Default for Context { fn default() -> Self { - let conn = Connection::open_in_memory().unwrap(); - prepare_all_tables(&conn).unwrap(); - Context { conn } + Self::in_memory() } } impl Context { + pub fn in_memory() -> Self { + let conn = Connection::open_in_memory().unwrap(); + prepare_all_tables(&conn).unwrap(); + Context { + conn, + product_store: None, + } + } + + #[allow(dead_code)] + pub fn open_sqlite>(db_path: P) -> anyhow::Result { + Self::open_sqlite_with_product_store(db_path, None) + } + + pub fn open_sqlite_with_product_store>( + db_path: P, + product_db_url: Option<&str>, + ) -> anyhow::Result { + let db_path = db_path.as_ref(); + if let Some(parent) = db_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + let conn = Connection::open(db_path)?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + prepare_all_tables(&conn)?; + let product_store = match product_db_url { + Some(url) => Some(ProductStore::connect(url)?), + None => None, + }; + Ok(Context { conn, product_store }) + } + pub fn load_games(&self, spec_paths: &[&str]) -> anyhow::Result<()> { for spec_path in spec_paths.iter() { self.add_game(spec_path)?; @@ -78,6 +120,13 @@ impl Context { icon: "https://raw.githubusercontent.com/NutsPokerTeam/token-list/main/assets/mainnet/RACE5fnTKB9obGtCusArTQ6hhdNXAtf3HarvJM17rxJ/logo.svg".into(), addr: "FACADE_RACE".into(), })?; + self.add_token(TokenAccount { + name: "Guest Chips".into(), + symbol: "GCHIP".into(), + decimals: 0, + icon: "".into(), + addr: "FACADE_GUEST_CHIPS".into(), + })?; Ok(()) } @@ -164,6 +213,12 @@ impl Context { Ok(()) } + #[cfg(test)] + pub fn create_stake(&self, stake: &Stake) -> anyhow::Result<()> { + create_stake(&self.conn, stake)?; + Ok(()) + } + pub fn create_recipient_account( &self, recipient_account: &RecipientAccount, @@ -179,6 +234,25 @@ impl Context { Ok(()) } + pub fn create_guest_account(&mut self, guest_account: &GuestAccount) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.create_guest_account(guest_account)?; + } else { + create_guest_account(&self.conn, guest_account)?; + initialize_product_state(&self.conn, &guest_account.guest_id, guest_account.created_at)?; + } + Ok(()) + } + + pub fn create_guest_session(&mut self, guest_session: &GuestSession) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.create_guest_session(guest_session)?; + } else { + create_guest_session(&self.conn, guest_session)?; + } + Ok(()) + } + pub fn get_game_bundle(&self, addr: &str) -> anyhow::Result> { Ok(read_game_bundle(&self.conn, addr)?) } @@ -203,6 +277,26 @@ impl Context { Ok(read_player_info(&self.conn, player_addr)?) } + pub fn get_guest_account_by_guest_id( + &mut self, + guest_id: &str, + ) -> anyhow::Result> { + if let Some(store) = self.product_store.as_mut() { + return store.read_guest_account_by_guest_id(guest_id); + } + Ok(read_guest_account_by_guest_id(&self.conn, guest_id)?) + } + + pub fn get_guest_session_by_token_hash( + &mut self, + session_token_hash: &str, + ) -> anyhow::Result> { + if let Some(store) = self.product_store.as_mut() { + return store.read_guest_session_by_token_hash(session_token_hash); + } + Ok(read_guest_session_by_token_hash(&self.conn, session_token_hash)?) + } + #[allow(unused)] pub fn get_registration_account( &self, @@ -234,6 +328,92 @@ impl Context { Ok(()) } + pub fn revoke_guest_session( + &mut self, + session_token_hash: &str, + revoked_at: u64, + ) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.revoke_guest_session(session_token_hash, revoked_at)?; + } else { + revoke_guest_session(&self.conn, session_token_hash, revoked_at)?; + } + Ok(()) + } + + pub fn get_user_progress(&mut self, guest_id: &str) -> anyhow::Result> { + if let Some(store) = self.product_store.as_mut() { + store.read_user_progress(guest_id) + } else { + Ok(read_user_progress(&self.conn, guest_id)?) + } + } + + pub fn get_guest_account_by_player_addr( + &mut self, + player_addr: &str, + ) -> anyhow::Result> { + if let Some(store) = self.product_store.as_mut() { + store.read_guest_account_by_player_addr(player_addr) + } else { + Ok(read_guest_account_by_player_addr(&self.conn, player_addr)?) + } + } + + pub fn get_user_rating(&mut self, guest_id: &str) -> anyhow::Result> { + if let Some(store) = self.product_store.as_mut() { + store.read_user_rating(guest_id) + } else { + Ok(read_user_rating(&self.conn, guest_id)?) + } + } + + pub fn get_user_stats(&mut self, guest_id: &str) -> anyhow::Result> { + if let Some(store) = self.product_store.as_mut() { + store.read_user_stats(guest_id) + } else { + Ok(read_user_stats(&self.conn, guest_id)?) + } + } + + pub fn record_user_joined_game(&mut self, guest_id: &str, now: u64) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.record_user_joined_game(guest_id, now)?; + } else { + record_user_joined_game(&self.conn, guest_id, now)?; + } + Ok(()) + } + + pub fn record_product_event_once( + &mut self, + entry: ProductEventLogEntry, + ) -> anyhow::Result { + if let Some(store) = self.product_store.as_mut() { + store.insert_product_event_log_entry(&entry) + } else { + Ok(insert_product_event_log_entry(&self.conn, &entry)?) + } + } + + pub fn update_user_progress(&mut self, progress: &UserProgress) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.update_user_progress(progress)?; + } else { + update_user_progress(&self.conn, progress)?; + } + Ok(()) + } + + pub fn increment_user_hands_played(&mut self, guest_id: &str) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.increment_user_hands_played(guest_id)?; + } else { + increment_user_hands_played(&self.conn, guest_id)?; + } + Ok(()) + } + pub fn update_player_info(&self, player_info: &PlayerInfo) -> anyhow::Result<()> { update_player_info(&self.conn, &player_info)?; Ok(()) diff --git a/facade/src/db.rs b/facade/src/db.rs index 97bd7832..cfa15961 100644 --- a/facade/src/db.rs +++ b/facade/src/db.rs @@ -1,5 +1,4 @@ //! Database related code for facade. -//! A memory-based instance is preferred. use std::collections::HashMap; @@ -32,6 +31,70 @@ pub(crate) struct Stake { pub amount: u64, } +#[derive(Clone, Debug)] +pub(crate) struct GuestAccount { + pub guest_id: String, + pub player_addr: String, + pub nick: String, + pub status: String, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct GuestSession { + pub session_id: String, + pub guest_id: String, + pub session_token_hash: String, + pub created_at: u64, + pub expires_at: u64, + pub revoked_at: Option, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct UserProgress { + pub guest_id: String, + pub rank_tier: String, + pub xp: u64, + pub level: u32, + pub updated_at: u64, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct UserRating { + pub guest_id: String, + pub rating: i32, + pub rank_bucket: String, + pub updated_at: u64, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct UserStats { + pub guest_id: String, + pub hands_played: u64, + pub games_played: u64, + pub wins: u64, + pub losses: u64, + pub last_played_at: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ProductEventLogEntry { + pub event_id: String, + pub event_type: String, + pub guest_id: String, + pub created_at: u64, +} + +const DEFAULT_RANK_TIER: &str = "Bronze I"; +const DEFAULT_RANK_BUCKET: &str = "Bronze I"; +const DEFAULT_LEVEL: u32 = 1; +const DEFAULT_XP: u64 = 0; +const DEFAULT_RATING: i32 = 1000; + // CRUD functions for Stake pub fn create_stake_table(conn: &Connection) -> Result<()> { @@ -45,6 +108,335 @@ pub fn create_stake_table(conn: &Connection) -> Result<()> { Ok(()) } +pub fn create_guest_tables(conn: &Connection) -> Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS guest_account ( + guest_id TEXT PRIMARY KEY, + player_addr TEXT NOT NULL UNIQUE, + nick TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS guest_session ( + session_id TEXT PRIMARY KEY, + guest_id TEXT NOT NULL, + session_token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER, + FOREIGN KEY (guest_id) REFERENCES guest_account(guest_id) + )", + [], + )?; + + Ok(()) +} + +pub fn create_product_state_tables(conn: &Connection) -> Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS user_progress ( + guest_id TEXT PRIMARY KEY, + rank_tier TEXT NOT NULL, + xp INTEGER NOT NULL, + level INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (guest_id) REFERENCES guest_account(guest_id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS user_rating ( + guest_id TEXT PRIMARY KEY, + rating INTEGER NOT NULL, + rank_bucket TEXT NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (guest_id) REFERENCES guest_account(guest_id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS user_stats ( + guest_id TEXT PRIMARY KEY, + hands_played INTEGER NOT NULL DEFAULT 0, + games_played INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + last_played_at INTEGER, + FOREIGN KEY (guest_id) REFERENCES guest_account(guest_id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS product_event_log ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + guest_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (guest_id) REFERENCES guest_account(guest_id) + )", + [], + )?; + + Ok(()) +} + +pub fn create_guest_account(conn: &Connection, guest_account: &GuestAccount) -> Result<()> { + conn.execute( + "INSERT INTO guest_account ( + guest_id, player_addr, nick, status, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + guest_account.guest_id, + guest_account.player_addr, + guest_account.nick, + guest_account.status, + guest_account.created_at, + guest_account.updated_at, + ], + )?; + Ok(()) +} + +pub fn read_guest_account_by_guest_id( + conn: &Connection, + guest_id: &str, +) -> Result> { + conn.query_row( + "SELECT guest_id, player_addr, nick, status, created_at, updated_at + FROM guest_account WHERE guest_id = ?1", + params![guest_id], + |row| { + Ok(GuestAccount { + guest_id: row.get(0)?, + player_addr: row.get(1)?, + nick: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }, + ) + .optional() +} + +pub fn read_guest_account_by_player_addr( + conn: &Connection, + player_addr: &str, +) -> Result> { + conn.query_row( + "SELECT guest_id, player_addr, nick, status, created_at, updated_at + FROM guest_account WHERE player_addr = ?1", + params![player_addr], + |row| { + Ok(GuestAccount { + guest_id: row.get(0)?, + player_addr: row.get(1)?, + nick: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + }, + ) + .optional() +} + +pub fn create_guest_session(conn: &Connection, guest_session: &GuestSession) -> Result<()> { + conn.execute( + "INSERT INTO guest_session ( + session_id, guest_id, session_token_hash, created_at, expires_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + guest_session.session_id, + guest_session.guest_id, + guest_session.session_token_hash, + guest_session.created_at, + guest_session.expires_at, + guest_session.revoked_at, + ], + )?; + Ok(()) +} + +pub fn read_guest_session_by_token_hash( + conn: &Connection, + session_token_hash: &str, +) -> Result> { + conn.query_row( + "SELECT session_id, guest_id, session_token_hash, created_at, expires_at, revoked_at + FROM guest_session WHERE session_token_hash = ?1", + params![session_token_hash], + |row| { + Ok(GuestSession { + session_id: row.get(0)?, + guest_id: row.get(1)?, + session_token_hash: row.get(2)?, + created_at: row.get(3)?, + expires_at: row.get(4)?, + revoked_at: row.get(5)?, + }) + }, + ) + .optional() +} + +pub fn revoke_guest_session( + conn: &Connection, + session_token_hash: &str, + revoked_at: u64, +) -> Result<()> { + conn.execute( + "UPDATE guest_session + SET revoked_at = ?1 + WHERE session_token_hash = ?2 AND revoked_at IS NULL", + params![revoked_at, session_token_hash], + )?; + Ok(()) +} + +pub fn initialize_product_state(conn: &Connection, guest_id: &str, now: u64) -> Result<()> { + conn.execute( + "INSERT INTO user_progress (guest_id, rank_tier, xp, level, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT (guest_id) DO NOTHING", + params![guest_id, DEFAULT_RANK_TIER, DEFAULT_XP, DEFAULT_LEVEL, now], + )?; + conn.execute( + "INSERT INTO user_rating (guest_id, rating, rank_bucket, updated_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (guest_id) DO NOTHING", + params![guest_id, DEFAULT_RATING, DEFAULT_RANK_BUCKET, now], + )?; + conn.execute( + "INSERT INTO user_stats (guest_id, hands_played, games_played, wins, losses, last_played_at) + VALUES (?1, 0, 0, 0, 0, NULL) + ON CONFLICT (guest_id) DO NOTHING", + params![guest_id], + )?; + Ok(()) +} + +pub fn read_user_progress(conn: &Connection, guest_id: &str) -> Result> { + conn.query_row( + "SELECT guest_id, rank_tier, xp, level, updated_at + FROM user_progress WHERE guest_id = ?1", + params![guest_id], + |row| { + Ok(UserProgress { + guest_id: row.get(0)?, + rank_tier: row.get(1)?, + xp: row.get(2)?, + level: row.get(3)?, + updated_at: row.get(4)?, + }) + }, + ) + .optional() +} + +pub fn read_user_rating(conn: &Connection, guest_id: &str) -> Result> { + conn.query_row( + "SELECT guest_id, rating, rank_bucket, updated_at + FROM user_rating WHERE guest_id = ?1", + params![guest_id], + |row| { + Ok(UserRating { + guest_id: row.get(0)?, + rating: row.get(1)?, + rank_bucket: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional() +} + +pub fn read_user_stats(conn: &Connection, guest_id: &str) -> Result> { + conn.query_row( + "SELECT guest_id, hands_played, games_played, wins, losses, last_played_at + FROM user_stats WHERE guest_id = ?1", + params![guest_id], + |row| { + Ok(UserStats { + guest_id: row.get(0)?, + hands_played: row.get(1)?, + games_played: row.get(2)?, + wins: row.get(3)?, + losses: row.get(4)?, + last_played_at: row.get(5)?, + }) + }, + ) + .optional() +} + +pub fn record_user_joined_game(conn: &Connection, guest_id: &str, now: u64) -> Result<()> { + conn.execute( + "UPDATE user_stats + SET games_played = games_played + 1, + last_played_at = ?2 + WHERE guest_id = ?1", + params![guest_id, now], + )?; + Ok(()) +} + +pub fn insert_product_event_log_entry( + conn: &Connection, + entry: &ProductEventLogEntry, +) -> Result { + let changed = conn.execute( + "INSERT INTO product_event_log ( + event_id, event_type, guest_id, created_at + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (event_id) DO NOTHING", + params![ + entry.event_id, + entry.event_type, + entry.guest_id, + entry.created_at, + ], + )?; + Ok(changed > 0) +} + +pub fn update_user_progress(conn: &Connection, progress: &UserProgress) -> Result<()> { + conn.execute( + "UPDATE user_progress + SET rank_tier = ?2, + xp = ?3, + level = ?4, + updated_at = ?5 + WHERE guest_id = ?1", + params![ + progress.guest_id, + progress.rank_tier, + progress.xp, + progress.level, + progress.updated_at, + ], + )?; + Ok(()) +} + +pub fn increment_user_hands_played(conn: &Connection, guest_id: &str) -> Result<()> { + conn.execute( + "UPDATE user_stats + SET hands_played = hands_played + 1 + WHERE guest_id = ?1", + params![guest_id], + )?; + Ok(()) +} + // Function to create a new stake entry pub fn create_stake(conn: &Connection, stake: &Stake) -> Result<()> { conn.execute( @@ -646,7 +1038,7 @@ pub fn create_registration_account_table(conn: &Connection) -> Result<()> { pub fn create_token_account(conn: &Connection, account: &TokenAccount) -> Result<()> { conn.execute( - "INSERT INTO token_account (name, symbol, icon, addr, decimals) VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT OR IGNORE INTO token_account (name, symbol, icon, addr, decimals) VALUES (?1, ?2, ?3, ?4, ?5)", params![ account.name, account.symbol, @@ -929,6 +1321,8 @@ pub fn create_server_account_table(conn: &Connection) -> Result<()> { pub fn prepare_all_tables(conn: &Connection) -> Result<()> { create_player_tables(conn)?; create_nft_table(conn)?; + create_guest_tables(conn)?; + create_product_state_tables(conn)?; create_game_account_table(conn)?; create_game_bundle_table(conn)?; create_registration_account_table(conn)?; @@ -941,7 +1335,7 @@ pub fn prepare_all_tables(conn: &Connection) -> Result<()> { #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::{collections::HashMap, fs, time::{SystemTime, UNIX_EPOCH}}; use rusqlite::{Connection, Result}; use race_core::types::{GameAccount, PlayerProfile, RecipientAccount, RecipientSlot, TokenAccount}; @@ -956,6 +1350,7 @@ mod tests { addr: "player1".to_string(), nick: "Player One".to_string(), pfp: Some("pfp1".to_string()), + credentials: vec![1, 2, 3], }; let balances = HashMap::from([("token1".to_string(), 100u64)]); let nft = super::Nft { @@ -1060,4 +1455,124 @@ mod tests { Ok(()) } + + #[test] + fn test_guest_account_and_session_crud() -> Result<()> { + let conn = Connection::open_in_memory()?; + super::prepare_all_tables(&conn)?; + + let guest_account = super::GuestAccount { + guest_id: "guest-1".into(), + player_addr: "guest_player_1".into(), + nick: "SmokeGuest".into(), + status: "active".into(), + created_at: 100, + updated_at: 100, + }; + let guest_session = super::GuestSession { + session_id: "session-1".into(), + guest_id: "guest-1".into(), + session_token_hash: "token-hash".into(), + created_at: 100, + expires_at: 200, + revoked_at: None, + }; + + super::create_guest_account(&conn, &guest_account)?; + super::create_guest_session(&conn, &guest_session)?; + + let stored_account = super::read_guest_account_by_guest_id(&conn, "guest-1")?.unwrap(); + assert_eq!(stored_account.player_addr, "guest_player_1"); + + let stored_session = + super::read_guest_session_by_token_hash(&conn, "token-hash")?.unwrap(); + assert_eq!(stored_session.guest_id, "guest-1"); + + super::revoke_guest_session(&conn, "token-hash", 150)?; + let revoked_session = + super::read_guest_session_by_token_hash(&conn, "token-hash")?.unwrap(); + assert_eq!(revoked_session.revoked_at, Some(150)); + + Ok(()) + } + + #[test] + fn test_guest_data_persists_across_sqlite_reopen() -> Result<()> { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let db_path = std::env::temp_dir().join(format!("race_facade_guest_persist_{unique}.sqlite")); + let _ = fs::remove_file(&db_path); + + { + let conn = Connection::open(&db_path)?; + super::prepare_all_tables(&conn)?; + + let player_info = super::PlayerInfo { + balances: HashMap::from([("FACADE_GUEST_CHIPS".to_string(), 1_000_000u64)]), + nfts: HashMap::new(), + profile: PlayerProfile { + addr: "guest_player_persist".to_string(), + nick: "Persisted Guest".to_string(), + pfp: None, + credentials: vec![7, 8, 9], + }, + }; + let guest_account = super::GuestAccount { + guest_id: "guest-persist".into(), + player_addr: "guest_player_persist".into(), + nick: "Persisted Guest".into(), + status: "active".into(), + created_at: 100, + updated_at: 100, + }; + let guest_session = super::GuestSession { + session_id: "session-persist".into(), + guest_id: "guest-persist".into(), + session_token_hash: "token-hash-persist".into(), + created_at: 100, + expires_at: 200, + revoked_at: None, + }; + + super::create_player_info(&conn, &player_info)?; + super::create_guest_account(&conn, &guest_account)?; + super::create_guest_session(&conn, &guest_session)?; + } + + { + let conn = Connection::open(&db_path)?; + super::prepare_all_tables(&conn)?; + + let stored_account = + super::read_guest_account_by_guest_id(&conn, "guest-persist")?.unwrap(); + assert_eq!(stored_account.player_addr, "guest_player_persist"); + + let stored_session = + super::read_guest_session_by_token_hash(&conn, "token-hash-persist")?.unwrap(); + assert_eq!(stored_session.guest_id, "guest-persist"); + + let stored_player = + super::read_player_info(&conn, "guest_player_persist")?.unwrap(); + assert_eq!(stored_player.profile.nick, "Persisted Guest"); + assert_eq!( + stored_player.balances.get("FACADE_GUEST_CHIPS").copied(), + Some(1_000_000) + ); + + super::revoke_guest_session(&conn, "token-hash-persist", 150)?; + } + + { + let conn = Connection::open(&db_path)?; + super::prepare_all_tables(&conn)?; + let revoked_session = + super::read_guest_session_by_token_hash(&conn, "token-hash-persist")?.unwrap(); + assert_eq!(revoked_session.revoked_at, Some(150)); + } + + let _ = fs::remove_file(&db_path); + Ok(()) + } } diff --git a/facade/src/main.rs b/facade/src/main.rs index 1042a7fc..04432871 100644 --- a/facade/src/main.rs +++ b/facade/src/main.rs @@ -3,10 +3,13 @@ mod context; mod db; +mod product_event_service; +mod product_rules; +mod product_store; use clap::{arg, Command}; use context::Context; -use db::{Nft, PlayerInfo}; +use db::{GuestAccount, GuestSession, Nft, PlayerInfo, UserProgress, UserRating, UserStats}; use hyper::Method; use jsonrpsee::server::{AllowHosts, ServerBuilder, ServerHandle}; use jsonrpsee::types::Params; @@ -21,14 +24,18 @@ use race_core::types::{ SettleParams, TokenAccount, Vote, VoteParams, VoteType, }; use race_core::types::{DepositStatus, RecipientSlotInit, RejectDepositsParams}; -use serde::Deserialize; +use product_event_service::{ProductEvent, ProductEventService}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; -use std::time::UNIX_EPOCH; +use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex; use tower::ServiceBuilder; use tower_http::cors::{Any, CorsLayer}; +use uuid::Uuid; type RpcResult = std::result::Result; @@ -36,8 +43,13 @@ const DEFAULT_MAX_SERVERS: usize = 3; const DEFAULT_VOTES_THRESHOLD: usize = 2; const DEFAULT_BALANCE: u64 = 1000000000; +const GUEST_INITIAL_BALANCE: u64 = 1000000; +const GUEST_SESSION_TTL_MS: u64 = 7 * 24 * 60 * 60 * 1000; +const GUEST_TOKEN_ADDR: &str = "FACADE_GUEST_CHIPS"; const HTTP_HOST: &str = "0.0.0.0:12002"; +const DEFAULT_DB_PATH: &str = "data/facade.sqlite3"; +const DEFAULT_PRODUCT_DB_URL: &str = "postgresql://postgres@localhost/race_poker_product"; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -50,7 +62,7 @@ pub struct GameSpec { data: Vec, } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct JoinInstruction { player_addr: String, @@ -115,10 +127,234 @@ pub struct CreateGameAccountInstruction { data: Vec, } +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestRegisterRequest { + nick: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestSessionRequest { + session_token: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestAccountSummary { + guest_id: String, + player_addr: String, + nick: String, + status: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestIdentityResponse { + guest: GuestAccountSummary, + profile: PlayerProfile, + balances: HashMap, + progress: GuestProgressSummary, + rating: GuestRatingSummary, + stats: GuestStatsSummary, + session_expires_at: u64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestRegisterResponse { + guest: GuestAccountSummary, + profile: PlayerProfile, + balances: HashMap, + progress: GuestProgressSummary, + rating: GuestRatingSummary, + stats: GuestStatsSummary, + session_token: String, + expires_at: u64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestProgressSummary { + rank_tier: String, + xp: u64, + level: u32, + updated_at: u64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestRatingSummary { + rating: i32, + rank_bucket: String, + updated_at: u64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestStatsSummary { + hands_played: u64, + games_played: u64, + wins: u64, + losses: u64, + last_played_at: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GuestLogoutResponse { + ok: bool, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct InternalGuestHandFinishedRequest { + event_id: String, + guest_id: String, + player_addr: String, + hand_id: String, + did_participate: bool, + did_win_hand: bool, + timestamp: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct InternalGuestSessionFinishedRequest { + event_id: String, + guest_id: String, + player_addr: String, + session_id: String, + hands_played_in_session: u64, + session_duration_seconds: u64, + timestamp: Option, +} + fn custom_error(e: Error) -> RpcError { RpcError::Custom(serde_json::to_string(&e).unwrap()) } +fn session_error(msg: &str) -> RpcError { + RpcError::Custom(msg.to_string()) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +fn hash_session_token(session_token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(session_token.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn make_guest_account_summary(guest: &GuestAccount) -> GuestAccountSummary { + GuestAccountSummary { + guest_id: guest.guest_id.clone(), + player_addr: guest.player_addr.clone(), + nick: guest.nick.clone(), + status: guest.status.clone(), + } +} + +fn make_guest_progress_summary(progress: &UserProgress) -> GuestProgressSummary { + GuestProgressSummary { + rank_tier: progress.rank_tier.clone(), + xp: progress.xp, + level: progress.level, + updated_at: progress.updated_at, + } +} + +fn make_guest_rating_summary(rating: &UserRating) -> GuestRatingSummary { + GuestRatingSummary { + rating: rating.rating, + rank_bucket: rating.rank_bucket.clone(), + updated_at: rating.updated_at, + } +} + +fn make_guest_stats_summary(stats: &UserStats) -> GuestStatsSummary { + GuestStatsSummary { + hands_played: stats.hands_played, + games_played: stats.games_played, + wins: stats.wins, + losses: stats.losses, + last_played_at: stats.last_played_at, + } +} + +fn load_guest_identity( + context: &mut Context, + session_token: &str, +) -> anyhow::Result<(GuestSession, GuestAccount, PlayerInfo, UserProgress, UserRating, UserStats)> { + let session_token_hash = hash_session_token(session_token); + let now = now_millis(); + + let guest_session = context + .get_guest_session_by_token_hash(&session_token_hash)? + .ok_or_else(|| anyhow::anyhow!("invalid-session"))?; + + if guest_session.revoked_at.is_some() { + return Err(anyhow::anyhow!("session-revoked")); + } + + if guest_session.expires_at < now { + return Err(anyhow::anyhow!("session-expired")); + } + + let guest_account = context + .get_guest_account_by_guest_id(&guest_session.guest_id)? + .ok_or_else(|| anyhow::anyhow!("guest-not-found"))?; + + let player_info = context + .get_player_info(&guest_account.player_addr)? + .ok_or_else(|| anyhow::anyhow!("player-profile-not-found"))?; + + let user_progress = context + .get_user_progress(&guest_account.guest_id)? + .ok_or_else(|| anyhow::anyhow!("guest-progress-not-found"))?; + + let user_rating = context + .get_user_rating(&guest_account.guest_id)? + .ok_or_else(|| anyhow::anyhow!("guest-rating-not-found"))?; + + let user_stats = context + .get_user_stats(&guest_account.guest_id)? + .ok_or_else(|| anyhow::anyhow!("guest-stats-not-found"))?; + + Ok(( + guest_session, + guest_account, + player_info, + user_progress, + user_rating, + user_stats, + )) +} + +fn make_guest_identity_response( + guest_session: &GuestSession, + guest_account: &GuestAccount, + player_info: &PlayerInfo, + user_progress: &UserProgress, + user_rating: &UserRating, + user_stats: &UserStats, +) -> GuestIdentityResponse { + GuestIdentityResponse { + guest: make_guest_account_summary(guest_account), + profile: player_info.profile.clone(), + balances: player_info.balances.clone(), + progress: make_guest_progress_summary(user_progress), + rating: make_guest_rating_summary(user_rating), + stats: make_guest_stats_summary(user_stats), + session_expires_at: guest_session.expires_at, + } +} + async fn get_game_bundle( params: Params<'_>, context: Arc>, @@ -170,7 +406,7 @@ async fn join(params: Params<'_>, context: Arc>) -> RpcResult<()> position, player_addr, } = params.one()?; - let context = context.lock().await; + let mut context = context.lock().await; // Check if the player profile exists? if context.get_player_info(&player_addr)?.is_none() { @@ -290,12 +526,65 @@ async fn join(params: Params<'_>, context: Arc>) -> RpcResult<()> } context.update_game_account(&game_account)?; context.update_stake(&stake)?; + if let Some(guest_account) = context.get_guest_account_by_player_addr(&player_addr)? { + let event_timestamp = now_millis(); + let join_event = ProductEvent::GuestTableJoined { + event_id: format!( + "table_joined:{}:{}:{}", + guest_account.guest_id, game_addr, player_addr + ), + guest_id: guest_account.guest_id, + player_addr: player_addr.clone(), + game_id: game_addr.clone(), + timestamp: event_timestamp, + }; + ProductEventService::apply(&mut context, join_event) + .map_err(|err| session_error(&err.to_string()))?; + } Ok(()) } else { return Err(custom_error(Error::GameAccountNotFound)); } } +async fn internal_guest_hand_finished( + params: Params<'_>, + context: Arc>, +) -> RpcResult<()> { + let request: InternalGuestHandFinishedRequest = params.one()?; + let mut context = context.lock().await; + let event = ProductEvent::GuestHandFinished { + event_id: request.event_id, + guest_id: request.guest_id, + player_addr: request.player_addr, + hand_id: request.hand_id, + did_participate: request.did_participate, + did_win_hand: request.did_win_hand, + timestamp: request.timestamp.unwrap_or_else(now_millis), + }; + ProductEventService::apply(&mut context, event).map_err(|err| session_error(&err.to_string()))?; + Ok(()) +} + +async fn internal_guest_session_finished( + params: Params<'_>, + context: Arc>, +) -> RpcResult<()> { + let request: InternalGuestSessionFinishedRequest = params.one()?; + let mut context = context.lock().await; + let event = ProductEvent::GuestSessionFinished { + event_id: request.event_id, + guest_id: request.guest_id, + player_addr: request.player_addr, + session_id: request.session_id, + hands_played_in_session: request.hands_played_in_session, + session_duration_seconds: request.session_duration_seconds, + timestamp: request.timestamp.unwrap_or_else(now_millis), + }; + ProductEventService::apply(&mut context, event).map_err(|err| session_error(&err.to_string()))?; + Ok(()) +} + async fn deposit(params: Params<'_>, context: Arc>) -> RpcResult<()> { let DepositParams { player_addr, @@ -435,6 +724,131 @@ async fn create_profile(params: Params<'_>, context: Arc>) -> Rpc Ok(()) } +async fn guest_register( + params: Params<'_>, + context: Arc>, +) -> RpcResult { + let GuestRegisterRequest { nick } = params.one()?; + let nick = nick.trim().to_string(); + if nick.is_empty() { + return Err(session_error("invalid-nick")); + } + + let now = now_millis(); + let guest_id = format!("guest_{}", Uuid::new_v4().simple()); + let player_addr = format!("guest_player_{}", Uuid::new_v4().simple()); + let session_id = format!("guest_session_{}", Uuid::new_v4().simple()); + let session_token = Uuid::new_v4().to_string(); + let expires_at = now + GUEST_SESSION_TTL_MS; + + let guest_account = GuestAccount { + guest_id: guest_id.clone(), + player_addr: player_addr.clone(), + nick: nick.clone(), + status: "active".into(), + created_at: now, + updated_at: now, + }; + + let profile = PlayerProfile { + addr: player_addr.clone(), + nick, + pfp: None, + credentials: Uuid::new_v4().as_bytes().to_vec(), + }; + + let player_info = PlayerInfo { + balances: HashMap::from([(GUEST_TOKEN_ADDR.to_string(), GUEST_INITIAL_BALANCE)]), + nfts: HashMap::new(), + profile: profile.clone(), + }; + + let guest_session = GuestSession { + session_id, + guest_id, + session_token_hash: hash_session_token(&session_token), + created_at: now, + expires_at, + revoked_at: None, + }; + + let mut context = context.lock().await; + context.create_guest_account(&guest_account)?; + context.create_player_info(&player_info)?; + context.create_guest_session(&guest_session)?; + let user_progress = context + .get_user_progress(&guest_account.guest_id)? + .ok_or_else(|| session_error("guest-progress-not-found"))?; + let user_rating = context + .get_user_rating(&guest_account.guest_id)? + .ok_or_else(|| session_error("guest-rating-not-found"))?; + let user_stats = context + .get_user_stats(&guest_account.guest_id)? + .ok_or_else(|| session_error("guest-stats-not-found"))?; + + Ok(GuestRegisterResponse { + guest: make_guest_account_summary(&guest_account), + profile, + balances: player_info.balances, + progress: make_guest_progress_summary(&user_progress), + rating: make_guest_rating_summary(&user_rating), + stats: make_guest_stats_summary(&user_stats), + session_token, + expires_at, + }) +} + +async fn guest_resume_session( + params: Params<'_>, + context: Arc>, +) -> RpcResult { + let GuestSessionRequest { session_token } = params.one()?; + let mut context = context.lock().await; + let (guest_session, guest_account, player_info, user_progress, user_rating, user_stats) = + load_guest_identity(&mut context, &session_token) + .map_err(|e| session_error(&e.to_string()))?; + Ok(make_guest_identity_response( + &guest_session, + &guest_account, + &player_info, + &user_progress, + &user_rating, + &user_stats, + )) +} + +async fn guest_get_me( + params: Params<'_>, + context: Arc>, +) -> RpcResult { + let GuestSessionRequest { session_token } = params.one()?; + let mut context = context.lock().await; + let (guest_session, guest_account, player_info, user_progress, user_rating, user_stats) = + load_guest_identity(&mut context, &session_token) + .map_err(|e| session_error(&e.to_string()))?; + Ok(make_guest_identity_response( + &guest_session, + &guest_account, + &player_info, + &user_progress, + &user_rating, + &user_stats, + )) +} + +async fn guest_logout( + params: Params<'_>, + context: Arc>, +) -> RpcResult { + let GuestSessionRequest { session_token } = params.one()?; + let now = now_millis(); + let mut context = context.lock().await; + let _ = load_guest_identity(&mut context, &session_token) + .map_err(|e| session_error(&e.to_string()))?; + context.revoke_guest_session(&hash_session_token(&session_token), now)?; + Ok(GuestLogoutResponse { ok: true }) +} + async fn get_profile( params: Params<'_>, context: Arc>, @@ -901,6 +1315,10 @@ async fn settle(params: Params<'_>, context: Arc>) -> RpcResult anyhow::Result { + run_server_at(context, HTTP_HOST.parse::()?).await +} + +async fn run_server_at(context: Context, bind_addr: SocketAddr) -> anyhow::Result { let cors = CorsLayer::new() .allow_methods([Method::POST]) .allow_origin(Any) @@ -911,7 +1329,7 @@ async fn run_server(context: Context) -> anyhow::Result { .max_response_body_size(1_000_000_000) .set_host_filtering(AllowHosts::Any) .set_middleware(middleware) - .build(HTTP_HOST.parse::()?) + .build(bind_addr) .await?; let context = Mutex::new(context); let mut module = RpcModule::new(context); @@ -929,11 +1347,20 @@ async fn run_server(context: Context) -> anyhow::Result { module.register_async_method("create_account", create_account)?; module.register_async_method("serve", serve)?; module.register_async_method("join", join)?; + module.register_async_method("internal_guest_hand_finished", internal_guest_hand_finished)?; + module.register_async_method( + "internal_guest_session_finished", + internal_guest_session_finished, + )?; module.register_async_method("deposit", deposit)?; module.register_async_method("settle", settle)?; module.register_async_method("vote", vote)?; module.register_async_method("list_tokens", list_tokens)?; module.register_async_method("reject_deposits", reject_deposits)?; + module.register_async_method("guest_register", guest_register)?; + module.register_async_method("guest_resume_session", guest_resume_session)?; + module.register_async_method("guest_get_me", guest_get_me)?; + module.register_async_method("guest_logout", guest_logout)?; let handle = http_server.start(module)?; Ok(handle) @@ -944,13 +1371,76 @@ fn cli() -> Command { .about("A mock server for local development with Race") .arg(arg!(-g ... "The path to a game spec json file")) .arg(arg!(-b ... "The path to a wasm bundle")) + .arg( + arg!(--db "Path to the facade sqlite database file") + .required(false) + .default_value(DEFAULT_DB_PATH), + ) + .arg( + arg!(--"product-db-url" "Optional Postgres URL for product-layer guest/session/progression data") + .required(false), + ) } -#[tokio::main] -async fn main() -> anyhow::Result<()> { +#[derive(Clone, Debug)] +enum ProductDbMode { + Explicit(String), + Default(String), + Disabled, +} + +fn main() -> anyhow::Result<()> { println!("Start at {}", HTTP_HOST); let matches = cli().get_matches(); - let context = Context::default(); + let db_path = matches + .get_one::("db") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_DB_PATH)); + println!("Using sqlite db at {}", db_path.display()); + let product_db_mode = if let Some(product_db_url) = + matches.get_one::("product-db-url").cloned() + { + ProductDbMode::Explicit(product_db_url) + } else if let Ok(product_db_url) = std::env::var("RACE_FACADE_PRODUCT_DB_URL") { + ProductDbMode::Explicit(product_db_url) + } else if std::env::var("RACE_FACADE_DISABLE_DEFAULT_PRODUCT_DB") + .ok() + .as_deref() + == Some("1") + { + ProductDbMode::Disabled + } else { + ProductDbMode::Default(DEFAULT_PRODUCT_DB_URL.to_string()) + }; + + let context = match &product_db_mode { + ProductDbMode::Explicit(product_db_url) => { + println!("Using Postgres product db: explicit configuration"); + Context::open_sqlite_with_product_store(&db_path, Some(product_db_url.as_str()))? + } + ProductDbMode::Default(product_db_url) => { + match Context::open_sqlite_with_product_store(&db_path, Some(product_db_url.as_str())) + { + Ok(context) => { + println!( + "Using Postgres product db: dev default {}", + DEFAULT_PRODUCT_DB_URL + ); + context + } + Err(err) => { + println!( + "Default Postgres product db unavailable, falling back to sqlite-only mode: {err}" + ); + Context::open_sqlite_with_product_store(&db_path, None)? + } + } + } + ProductDbMode::Disabled => { + println!("Default Postgres product db disabled; using sqlite-only mode"); + Context::open_sqlite_with_product_store(&db_path, None)? + } + }; context.load_default_tokens()?; if let Some(game_spec_paths) = matches.get_many::("game") { context.load_games(&game_spec_paths.map(String::as_str).collect::>())?; @@ -958,7 +1448,403 @@ async fn main() -> anyhow::Result<()> { if let Some(bundle_paths) = matches.get_many::("bundle") { context.load_bundles(&bundle_paths.map(String::as_str).collect::>())?; } - let server_handle = run_server(context).await?; - server_handle.stopped().await; - Ok(()) + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + runtime.block_on(async move { + let server_handle = run_server(context).await?; + server_handle.stopped().await; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonrpsee::{ + core::client::ClientT, + http_client::HttpClientBuilder, + rpc_params, + }; + use std::fs; + use tokio::net::TcpListener; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_guest_rpc_flow() { + let context = Context::in_memory(); + context.load_default_tokens().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind_addr = listener.local_addr().unwrap(); + drop(listener); + let server_handle = run_server_at(context, bind_addr).await.unwrap(); + + let client = HttpClientBuilder::default() + .build(format!("http://{bind_addr}")) + .unwrap(); + + let register_response: GuestRegisterResponse = client + .request( + "guest_register", + rpc_params![GuestRegisterRequest { + nick: "SmokeGuest".into(), + }], + ) + .await + .unwrap(); + + assert_eq!(register_response.guest.nick, "SmokeGuest"); + assert_eq!( + register_response + .balances + .get(GUEST_TOKEN_ADDR) + .copied(), + Some(GUEST_INITIAL_BALANCE) + ); + assert_eq!(register_response.profile.addr, register_response.guest.player_addr); + assert_eq!(register_response.progress.rank_tier, "Bronze I"); + assert_eq!(register_response.rating.rating, 1000); + assert_eq!(register_response.stats.games_played, 0); + + let resume_response: GuestIdentityResponse = client + .request( + "guest_resume_session", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token.clone(), + }], + ) + .await + .unwrap(); + + assert_eq!(resume_response.guest.guest_id, register_response.guest.guest_id); + assert_eq!(resume_response.profile.addr, register_response.profile.addr); + assert_eq!(resume_response.progress.level, 1); + assert_eq!(resume_response.rating.rank_bucket, "Bronze I"); + assert_eq!(resume_response.stats.games_played, 0); + assert_eq!( + resume_response.balances.get(GUEST_TOKEN_ADDR).copied(), + Some(GUEST_INITIAL_BALANCE) + ); + + let me_response: GuestIdentityResponse = client + .request( + "guest_get_me", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token.clone(), + }], + ) + .await + .unwrap(); + + assert_eq!(me_response.guest.player_addr, register_response.guest.player_addr); + assert_eq!(me_response.profile.nick, "SmokeGuest"); + assert_eq!(me_response.progress.xp, 0); + assert_eq!(me_response.stats.hands_played, 0); + + let logout_response: GuestLogoutResponse = client + .request( + "guest_logout", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token.clone(), + }], + ) + .await + .unwrap(); + + assert!(logout_response.ok); + + let resume_after_logout = client + .request::( + "guest_resume_session", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token, + }], + ) + .await; + + assert!(resume_after_logout.is_err()); + + server_handle.stop().unwrap(); + server_handle.stopped().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_join_updates_progress_and_stats_via_real_join_path() { + let context = Context::in_memory(); + context.load_default_tokens().unwrap(); + + let game_account = GameAccount { + addr: "game_join_test".into(), + title: "Join Test".into(), + bundle_addr: "bundle_join_test".into(), + owner_addr: "".into(), + settle_version: 0, + access_version: 0, + players: vec![], + data_len: 0, + data: vec![], + transactor_addr: None, + servers: vec![], + votes: vec![], + unlock_time: None, + max_players: 6, + deposits: vec![], + recipient_addr: "".into(), + entry_type: EntryType::Cash { + min_deposit: 100, + max_deposit: 1_000, + }, + token_addr: GUEST_TOKEN_ADDR.into(), + checkpoint_on_chain: None, + entry_lock: Default::default(), + bonuses: vec![], + balances: vec![], + }; + context.create_game_account(&game_account).unwrap(); + context + .create_stake(&db::Stake { + addr: "game_join_test".into(), + amount: 0, + }) + .unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind_addr = listener.local_addr().unwrap(); + drop(listener); + let server_handle = run_server_at(context, bind_addr).await.unwrap(); + + let client = HttpClientBuilder::default() + .build(format!("http://{bind_addr}")) + .unwrap(); + + let register_response: GuestRegisterResponse = client + .request( + "guest_register", + rpc_params![GuestRegisterRequest { + nick: "JoinGuest".into(), + }], + ) + .await + .unwrap(); + + client + .request::<(), _>( + "join", + rpc_params![JoinInstruction { + player_addr: register_response.guest.player_addr.clone(), + game_addr: "game_join_test".into(), + position: 0, + access_version: 0, + amount: 100, + }], + ) + .await + .unwrap(); + + let me_response: GuestIdentityResponse = client + .request( + "guest_get_me", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token, + }], + ) + .await + .unwrap(); + + assert_eq!(me_response.stats.games_played, 1); + assert!(me_response.stats.last_played_at.is_some()); + assert_eq!(me_response.progress.xp, product_rules::JOIN_XP_BONUS); + assert_eq!(me_response.progress.level, 1); + assert_eq!(me_response.progress.rank_tier, "Bronze I"); + + server_handle.stop().unwrap(); + server_handle.stopped().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_internal_ingestion_paths_for_hand_and_session_events() { + let context = Context::in_memory(); + context.load_default_tokens().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind_addr = listener.local_addr().unwrap(); + drop(listener); + let server_handle = run_server_at(context, bind_addr).await.unwrap(); + + let client = HttpClientBuilder::default() + .build(format!("http://{bind_addr}")) + .unwrap(); + + let register_response: GuestRegisterResponse = client + .request( + "guest_register", + rpc_params![GuestRegisterRequest { + nick: "EventGuest".into(), + }], + ) + .await + .unwrap(); + + client + .request::<(), _>( + "internal_guest_hand_finished", + rpc_params![InternalGuestHandFinishedRequest { + event_id: "hand:event:1".into(), + guest_id: register_response.guest.guest_id.clone(), + player_addr: register_response.guest.player_addr.clone(), + hand_id: "hand-1".into(), + did_participate: true, + did_win_hand: true, + timestamp: Some(200), + }], + ) + .await + .unwrap(); + client + .request::<(), _>( + "internal_guest_hand_finished", + rpc_params![InternalGuestHandFinishedRequest { + event_id: "hand:event:1".into(), + guest_id: register_response.guest.guest_id.clone(), + player_addr: register_response.guest.player_addr.clone(), + hand_id: "hand-1".into(), + did_participate: true, + did_win_hand: true, + timestamp: Some(200), + }], + ) + .await + .unwrap(); + client + .request::<(), _>( + "internal_guest_session_finished", + rpc_params![InternalGuestSessionFinishedRequest { + event_id: "session:event:1".into(), + guest_id: register_response.guest.guest_id.clone(), + player_addr: register_response.guest.player_addr.clone(), + session_id: "session-1".into(), + hands_played_in_session: 3, + session_duration_seconds: 300, + timestamp: Some(300), + }], + ) + .await + .unwrap(); + client + .request::<(), _>( + "internal_guest_session_finished", + rpc_params![InternalGuestSessionFinishedRequest { + event_id: "session:event:1".into(), + guest_id: register_response.guest.guest_id.clone(), + player_addr: register_response.guest.player_addr.clone(), + session_id: "session-1".into(), + hands_played_in_session: 3, + session_duration_seconds: 300, + timestamp: Some(300), + }], + ) + .await + .unwrap(); + + let me_response: GuestIdentityResponse = client + .request( + "guest_get_me", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token, + }], + ) + .await + .unwrap(); + + assert_eq!(me_response.stats.hands_played, 1); + assert_eq!( + me_response.progress.xp, + product_rules::HAND_PARTICIPATION_XP + + product_rules::HAND_WIN_BONUS + + product_rules::SESSION_COMPLETION_XP + ); + + server_handle.stop().unwrap(); + server_handle.stopped().await; + } + + #[test] + fn test_guest_persistence_across_context_restart() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let db_path = std::env::temp_dir().join(format!("race_facade_context_restart_{unique}.sqlite")); + let _ = fs::remove_file(&db_path); + + let session_token = "persist-session-token"; + let session_hash = hash_session_token(session_token); + + { + let mut context = Context::open_sqlite(&db_path).unwrap(); + context.load_default_tokens().unwrap(); + + let player_info = PlayerInfo { + balances: HashMap::from([(GUEST_TOKEN_ADDR.to_string(), GUEST_INITIAL_BALANCE)]), + nfts: HashMap::new(), + profile: PlayerProfile { + addr: "guest_player_restart".into(), + nick: "Restart Guest".into(), + pfp: None, + credentials: vec![1, 2, 3], + }, + }; + let guest_account = GuestAccount { + guest_id: "guest-restart".into(), + player_addr: "guest_player_restart".into(), + nick: "Restart Guest".into(), + status: "active".into(), + created_at: 100, + updated_at: 100, + }; + let guest_session = GuestSession { + session_id: "guest-session-restart".into(), + guest_id: "guest-restart".into(), + session_token_hash: session_hash.clone(), + created_at: 100, + expires_at: now_millis() + 60_000, + revoked_at: None, + }; + + context.create_guest_account(&guest_account).unwrap(); + context.create_player_info(&player_info).unwrap(); + context.create_guest_session(&guest_session).unwrap(); + } + + { + let mut context = Context::open_sqlite(&db_path).unwrap(); + context.load_default_tokens().unwrap(); + + let (guest_session, guest_account, player_info, user_progress, user_rating, user_stats) = + load_guest_identity(&mut context, session_token).unwrap(); + assert_eq!(guest_account.guest_id, "guest-restart"); + assert_eq!(player_info.profile.addr, "guest_player_restart"); + assert_eq!(user_progress.rank_tier, "Bronze I"); + assert_eq!(user_rating.rating, 1000); + assert_eq!(user_stats.games_played, 0); + assert_eq!( + player_info.balances.get(GUEST_TOKEN_ADDR).copied(), + Some(GUEST_INITIAL_BALANCE) + ); + + context + .revoke_guest_session(&session_hash, guest_session.created_at + 1) + .unwrap(); + } + + { + let mut context = Context::open_sqlite(&db_path).unwrap(); + let result = load_guest_identity(&mut context, session_token); + assert!(result.is_err()); + let err = result.err().unwrap(); + assert_eq!(err.to_string(), "session-revoked"); + } + + let _ = fs::remove_file(&db_path); + } } diff --git a/facade/src/product_event_service.rs b/facade/src/product_event_service.rs new file mode 100644 index 00000000..766c88c9 --- /dev/null +++ b/facade/src/product_event_service.rs @@ -0,0 +1,379 @@ +use anyhow::{anyhow, Result}; + +use crate::{ + context::Context, + db::{ProductEventLogEntry, UserProgress}, + product_rules::{ + level_for_xp, rank_tier_for_level, HAND_PARTICIPATION_XP, HAND_WIN_BONUS, + JOIN_XP_BONUS, MIN_HANDS_FOR_SESSION_COMPLETION, MIN_SESSION_DURATION_SECONDS, + SESSION_COMPLETION_XP, + }, +}; + +#[derive(Clone, Debug)] +pub enum ProductEvent { + GuestTableJoined { + event_id: String, + guest_id: String, + player_addr: String, + game_id: String, + timestamp: u64, + }, + GuestHandFinished { + event_id: String, + guest_id: String, + player_addr: String, + hand_id: String, + did_participate: bool, + did_win_hand: bool, + timestamp: u64, + }, + GuestSessionFinished { + event_id: String, + guest_id: String, + player_addr: String, + session_id: String, + hands_played_in_session: u64, + session_duration_seconds: u64, + timestamp: u64, + }, +} + +#[allow(dead_code)] +pub struct ProductEventApplyResult { + pub applied: bool, + pub xp_delta: u64, +} + +pub struct ProductEventService; + +impl ProductEventService { + pub fn apply(context: &mut Context, event: ProductEvent) -> Result { + match event { + ProductEvent::GuestTableJoined { + event_id, + guest_id, + player_addr, + game_id, + timestamp, + } => { + validate_guest_identity(context, &guest_id, &player_addr)?; + let was_inserted = context.record_product_event_once(ProductEventLogEntry { + event_id, + event_type: format!("guest_table_joined:{game_id}"), + guest_id: guest_id.clone(), + created_at: timestamp, + })?; + if !was_inserted { + return Ok(ProductEventApplyResult { applied: false, xp_delta: 0 }); + } + + context.record_user_joined_game(&guest_id, timestamp)?; + apply_xp_delta(context, &guest_id, JOIN_XP_BONUS, timestamp)?; + + Ok(ProductEventApplyResult { + applied: true, + xp_delta: JOIN_XP_BONUS, + }) + } + ProductEvent::GuestHandFinished { + event_id, + guest_id, + player_addr, + hand_id, + did_participate, + did_win_hand, + timestamp, + } => { + validate_guest_identity(context, &guest_id, &player_addr)?; + let was_inserted = context.record_product_event_once(ProductEventLogEntry { + event_id, + event_type: format!("guest_hand_finished:{hand_id}"), + guest_id: guest_id.clone(), + created_at: timestamp, + })?; + if !was_inserted { + return Ok(ProductEventApplyResult { applied: false, xp_delta: 0 }); + } + if !did_participate { + return Ok(ProductEventApplyResult { applied: true, xp_delta: 0 }); + } + + context.increment_user_hands_played(&guest_id)?; + let xp_delta = HAND_PARTICIPATION_XP + + if did_win_hand { HAND_WIN_BONUS } else { 0 }; + apply_xp_delta(context, &guest_id, xp_delta, timestamp)?; + + Ok(ProductEventApplyResult { + applied: true, + xp_delta, + }) + } + ProductEvent::GuestSessionFinished { + event_id, + guest_id, + player_addr, + session_id, + hands_played_in_session, + session_duration_seconds, + timestamp, + } => { + validate_guest_identity(context, &guest_id, &player_addr)?; + let was_inserted = context.record_product_event_once(ProductEventLogEntry { + event_id, + event_type: format!("guest_session_finished:{session_id}"), + guest_id: guest_id.clone(), + created_at: timestamp, + })?; + if !was_inserted { + return Ok(ProductEventApplyResult { applied: false, xp_delta: 0 }); + } + + if hands_played_in_session < MIN_HANDS_FOR_SESSION_COMPLETION + || session_duration_seconds < MIN_SESSION_DURATION_SECONDS + { + return Ok(ProductEventApplyResult { applied: true, xp_delta: 0 }); + } + + apply_xp_delta(context, &guest_id, SESSION_COMPLETION_XP, timestamp)?; + Ok(ProductEventApplyResult { + applied: true, + xp_delta: SESSION_COMPLETION_XP, + }) + } + } + } +} + +fn validate_guest_identity(context: &mut Context, guest_id: &str, player_addr: &str) -> Result<()> { + let guest = context + .get_guest_account_by_guest_id(guest_id)? + .ok_or_else(|| anyhow!("guest-account-not-found"))?; + + if guest.player_addr != player_addr { + return Err(anyhow!("guest-player-mismatch")); + } + + Ok(()) +} + +fn apply_xp_delta(context: &mut Context, guest_id: &str, xp_delta: u64, timestamp: u64) -> Result<()> { + let current = context + .get_user_progress(guest_id)? + .ok_or_else(|| anyhow!("user-progress-not-found"))?; + let xp = current.xp.saturating_add(xp_delta); + let level = level_for_xp(xp); + let rank_tier = rank_tier_for_level(level).to_string(); + + context.update_user_progress(&UserProgress { + guest_id: guest_id.to_string(), + rank_tier, + xp, + level, + updated_at: timestamp, + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ProductEvent, ProductEventService}; + use crate::{ + context::Context, + db::{GuestAccount, PlayerInfo}, + product_rules::{HAND_PARTICIPATION_XP, HAND_WIN_BONUS, JOIN_XP_BONUS, SESSION_COMPLETION_XP}, + }; + use race_core::types::PlayerProfile; + use std::collections::HashMap; + + const GUEST_TOKEN_ADDR: &str = "FACADE_GUEST_CHIPS"; + + fn seed_guest_context() -> Context { + let mut context = Context::in_memory(); + context.load_default_tokens().unwrap(); + + let guest_account = GuestAccount { + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + nick: "EventGuest".into(), + status: "active".into(), + created_at: 10, + updated_at: 10, + }; + let player_info = PlayerInfo { + balances: HashMap::from([(GUEST_TOKEN_ADDR.to_string(), 1_000_000)]), + nfts: HashMap::new(), + profile: PlayerProfile { + addr: guest_account.player_addr.clone(), + nick: guest_account.nick.clone(), + pfp: None, + credentials: vec![1], + }, + }; + + context.create_guest_account(&guest_account).unwrap(); + context.create_player_info(&player_info).unwrap(); + context + } + + #[test] + fn applies_table_join_event_once() { + let mut context = seed_guest_context(); + + let first = ProductEventService::apply( + &mut context, + ProductEvent::GuestTableJoined { + event_id: "join:1".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + timestamp: 100, + }, + ) + .unwrap(); + let second = ProductEventService::apply( + &mut context, + ProductEvent::GuestTableJoined { + event_id: "join:1".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + timestamp: 100, + }, + ) + .unwrap(); + + let progress = context.get_user_progress("guest-evt-1").unwrap().unwrap(); + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + + assert!(first.applied); + assert!(!second.applied); + assert_eq!(progress.xp, JOIN_XP_BONUS); + assert_eq!(progress.level, 1); + assert_eq!(progress.rank_tier, "Bronze I"); + assert_eq!(stats.games_played, 1); + assert_eq!(stats.last_played_at, Some(100)); + } + + #[test] + fn applies_hand_event_with_win_bonus_once() { + let mut context = seed_guest_context(); + + let first = ProductEventService::apply( + &mut context, + ProductEvent::GuestHandFinished { + event_id: "hand:1".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + hand_id: "h1".into(), + did_participate: true, + did_win_hand: true, + timestamp: 200, + }, + ) + .unwrap(); + let second = ProductEventService::apply( + &mut context, + ProductEvent::GuestHandFinished { + event_id: "hand:1".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + hand_id: "h1".into(), + did_participate: true, + did_win_hand: true, + timestamp: 200, + }, + ) + .unwrap(); + + let progress = context.get_user_progress("guest-evt-1").unwrap().unwrap(); + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + + assert!(first.applied); + assert!(!second.applied); + assert_eq!(first.xp_delta, HAND_PARTICIPATION_XP + HAND_WIN_BONUS); + assert_eq!(stats.hands_played, 1); + assert_eq!(progress.xp, HAND_PARTICIPATION_XP + HAND_WIN_BONUS); + } + + #[test] + fn ignores_non_participating_hand_for_stats_and_xp() { + let mut context = seed_guest_context(); + + let result = ProductEventService::apply( + &mut context, + ProductEvent::GuestHandFinished { + event_id: "hand:np".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + hand_id: "hnp".into(), + did_participate: false, + did_win_hand: false, + timestamp: 210, + }, + ) + .unwrap(); + + let progress = context.get_user_progress("guest-evt-1").unwrap().unwrap(); + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + + assert!(result.applied); + assert_eq!(result.xp_delta, 0); + assert_eq!(stats.hands_played, 0); + assert_eq!(progress.xp, 0); + } + + #[test] + fn applies_session_completion_only_when_eligible() { + let mut context = seed_guest_context(); + + let too_short = ProductEventService::apply( + &mut context, + ProductEvent::GuestSessionFinished { + event_id: "session:short".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + session_id: "s-short".into(), + hands_played_in_session: 1, + session_duration_seconds: 60, + timestamp: 300, + }, + ) + .unwrap(); + let eligible = ProductEventService::apply( + &mut context, + ProductEvent::GuestSessionFinished { + event_id: "session:eligible".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + session_id: "s-ok".into(), + hands_played_in_session: 3, + session_duration_seconds: 300, + timestamp: 301, + }, + ) + .unwrap(); + let duplicate = ProductEventService::apply( + &mut context, + ProductEvent::GuestSessionFinished { + event_id: "session:eligible".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + session_id: "s-ok".into(), + hands_played_in_session: 3, + session_duration_seconds: 300, + timestamp: 301, + }, + ) + .unwrap(); + + let progress = context.get_user_progress("guest-evt-1").unwrap().unwrap(); + + assert!(too_short.applied); + assert_eq!(too_short.xp_delta, 0); + assert!(eligible.applied); + assert_eq!(eligible.xp_delta, SESSION_COMPLETION_XP); + assert!(!duplicate.applied); + assert_eq!(progress.xp, SESSION_COMPLETION_XP); + } +} diff --git a/facade/src/product_rules.rs b/facade/src/product_rules.rs new file mode 100644 index 00000000..5a8a8c44 --- /dev/null +++ b/facade/src/product_rules.rs @@ -0,0 +1,47 @@ +pub const JOIN_XP_BONUS: u64 = 10; +pub const HAND_PARTICIPATION_XP: u64 = 2; +pub const HAND_WIN_BONUS: u64 = 3; +pub const SESSION_COMPLETION_XP: u64 = 20; +pub const MIN_HANDS_FOR_SESSION_COMPLETION: u64 = 3; +pub const MIN_SESSION_DURATION_SECONDS: u64 = 300; + +const LEVEL_THRESHOLDS: &[(u32, u64)] = &[ + (1, 0), + (2, 100), + (3, 250), + (4, 450), + (5, 700), + (6, 1_000), + (7, 1_350), + (8, 1_750), + (9, 2_200), + (10, 2_700), +]; + +pub fn level_for_xp(xp: u64) -> u32 { + let mut level = 1; + for (candidate_level, threshold) in LEVEL_THRESHOLDS { + if xp >= *threshold { + level = *candidate_level; + } else { + return level; + } + } + + let extra_xp = xp.saturating_sub(2_700); + level + (extra_xp / 600) as u32 +} + +pub fn rank_tier_for_level(level: u32) -> &'static str { + match level { + 1..=2 => "Bronze I", + 3..=4 => "Bronze II", + 5..=6 => "Silver I", + 7..=8 => "Silver II", + 9..=10 => "Gold I", + 11..=12 => "Gold II", + 13..=15 => "Platinum", + 16..=20 => "Diamond", + _ => "Hero", + } +} diff --git a/facade/src/product_store.rs b/facade/src/product_store.rs new file mode 100644 index 00000000..e716a9cc --- /dev/null +++ b/facade/src/product_store.rs @@ -0,0 +1,360 @@ +use postgres::{Client, NoTls}; + +use crate::db::{ + GuestAccount, GuestSession, ProductEventLogEntry, UserProgress, UserRating, UserStats, +}; + +const DEFAULT_RANK_TIER: &str = "Bronze I"; +const DEFAULT_RANK_BUCKET: &str = "Bronze I"; +const DEFAULT_LEVEL: i32 = 1; +const DEFAULT_XP: i64 = 0; +const DEFAULT_RATING: i32 = 1000; +const PRODUCT_SCHEMA_V1: &str = include_str!("../sql/product_schema_v1.sql"); + +pub struct ProductStore { + client: Client, +} + +impl ProductStore { + pub fn connect(database_url: &str) -> anyhow::Result { + let mut client = Client::connect(database_url, NoTls)?; + Self::prepare_tables(&mut client)?; + Ok(Self { client }) + } + + fn prepare_tables(client: &mut Client) -> anyhow::Result<()> { + client.batch_execute(PRODUCT_SCHEMA_V1)?; + Ok(()) + } + + pub fn create_guest_account(&mut self, guest_account: &GuestAccount) -> anyhow::Result<()> { + self.client.execute( + "INSERT INTO guest_account ( + guest_id, player_addr, nick, status, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6)", + &[ + &guest_account.guest_id, + &guest_account.player_addr, + &guest_account.nick, + &guest_account.status, + &(guest_account.created_at as i64), + &(guest_account.updated_at as i64), + ], + )?; + + self.initialize_product_state(&guest_account.guest_id, guest_account.created_at)?; + Ok(()) + } + + fn initialize_product_state(&mut self, guest_id: &str, now: u64) -> anyhow::Result<()> { + let now = now as i64; + self.client.execute( + "INSERT INTO user_progress (guest_id, rank_tier, xp, level, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (guest_id) DO NOTHING", + &[&guest_id, &DEFAULT_RANK_TIER, &DEFAULT_XP, &DEFAULT_LEVEL, &now], + )?; + self.client.execute( + "INSERT INTO user_rating (guest_id, rating, rank_bucket, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (guest_id) DO NOTHING", + &[&guest_id, &DEFAULT_RATING, &DEFAULT_RANK_BUCKET, &now], + )?; + self.client.execute( + "INSERT INTO user_stats (guest_id, hands_played, games_played, wins, losses, last_played_at) + VALUES ($1, 0, 0, 0, 0, NULL) + ON CONFLICT (guest_id) DO NOTHING", + &[&guest_id], + )?; + Ok(()) + } + + pub fn read_guest_account_by_guest_id( + &mut self, + guest_id: &str, + ) -> anyhow::Result> { + let row = self.client.query_opt( + "SELECT guest_id, player_addr, nick, status, created_at, updated_at + FROM guest_account WHERE guest_id = $1", + &[&guest_id], + )?; + + Ok(row.map(|row| GuestAccount { + guest_id: row.get::<_, String>(0), + player_addr: row.get::<_, String>(1), + nick: row.get::<_, String>(2), + status: row.get::<_, String>(3), + created_at: row.get::<_, i64>(4) as u64, + updated_at: row.get::<_, i64>(5) as u64, + })) + } + + pub fn read_guest_account_by_player_addr( + &mut self, + player_addr: &str, + ) -> anyhow::Result> { + let row = self.client.query_opt( + "SELECT guest_id, player_addr, nick, status, created_at, updated_at + FROM guest_account WHERE player_addr = $1", + &[&player_addr], + )?; + + Ok(row.map(|row| GuestAccount { + guest_id: row.get::<_, String>(0), + player_addr: row.get::<_, String>(1), + nick: row.get::<_, String>(2), + status: row.get::<_, String>(3), + created_at: row.get::<_, i64>(4) as u64, + updated_at: row.get::<_, i64>(5) as u64, + })) + } + + pub fn create_guest_session(&mut self, guest_session: &GuestSession) -> anyhow::Result<()> { + self.client.execute( + "INSERT INTO guest_session ( + session_id, guest_id, session_token_hash, created_at, expires_at, revoked_at + ) VALUES ($1, $2, $3, $4, $5, $6)", + &[ + &guest_session.session_id, + &guest_session.guest_id, + &guest_session.session_token_hash, + &(guest_session.created_at as i64), + &(guest_session.expires_at as i64), + &guest_session.revoked_at.map(|v| v as i64), + ], + )?; + Ok(()) + } + + pub fn read_user_progress(&mut self, guest_id: &str) -> anyhow::Result> { + let row = self.client.query_opt( + "SELECT guest_id, rank_tier, xp, level, updated_at + FROM user_progress WHERE guest_id = $1", + &[&guest_id], + )?; + + Ok(row.map(|row| UserProgress { + guest_id: row.get::<_, String>(0), + rank_tier: row.get::<_, String>(1), + xp: row.get::<_, i64>(2) as u64, + level: row.get::<_, i32>(3) as u32, + updated_at: row.get::<_, i64>(4) as u64, + })) + } + + pub fn read_user_rating(&mut self, guest_id: &str) -> anyhow::Result> { + let row = self.client.query_opt( + "SELECT guest_id, rating, rank_bucket, updated_at + FROM user_rating WHERE guest_id = $1", + &[&guest_id], + )?; + + Ok(row.map(|row| UserRating { + guest_id: row.get::<_, String>(0), + rating: row.get::<_, i32>(1), + rank_bucket: row.get::<_, String>(2), + updated_at: row.get::<_, i64>(3) as u64, + })) + } + + pub fn read_user_stats(&mut self, guest_id: &str) -> anyhow::Result> { + let row = self.client.query_opt( + "SELECT guest_id, hands_played, games_played, wins, losses, last_played_at + FROM user_stats WHERE guest_id = $1", + &[&guest_id], + )?; + + Ok(row.map(|row| UserStats { + guest_id: row.get::<_, String>(0), + hands_played: row.get::<_, i64>(1) as u64, + games_played: row.get::<_, i64>(2) as u64, + wins: row.get::<_, i64>(3) as u64, + losses: row.get::<_, i64>(4) as u64, + last_played_at: row.get::<_, Option>(5).map(|v| v as u64), + })) + } + + pub fn record_user_joined_game(&mut self, guest_id: &str, now: u64) -> anyhow::Result<()> { + self.client.execute( + "UPDATE user_stats + SET games_played = games_played + 1, + last_played_at = $2 + WHERE guest_id = $1", + &[&guest_id, &(now as i64)], + )?; + Ok(()) + } + + pub fn insert_product_event_log_entry( + &mut self, + entry: &ProductEventLogEntry, + ) -> anyhow::Result { + let changed = self.client.execute( + "INSERT INTO product_event_log ( + event_id, event_type, guest_id, created_at + ) VALUES ($1, $2, $3, $4) + ON CONFLICT (event_id) DO NOTHING", + &[ + &entry.event_id, + &entry.event_type, + &entry.guest_id, + &(entry.created_at as i64), + ], + )?; + Ok(changed > 0) + } + + pub fn update_user_progress(&mut self, progress: &UserProgress) -> anyhow::Result<()> { + self.client.execute( + "UPDATE user_progress + SET rank_tier = $2, + xp = $3, + level = $4, + updated_at = $5 + WHERE guest_id = $1", + &[ + &progress.guest_id, + &progress.rank_tier, + &(progress.xp as i64), + &(progress.level as i32), + &(progress.updated_at as i64), + ], + )?; + Ok(()) + } + + pub fn increment_user_hands_played(&mut self, guest_id: &str) -> anyhow::Result<()> { + self.client.execute( + "UPDATE user_stats + SET hands_played = hands_played + 1 + WHERE guest_id = $1", + &[&guest_id], + )?; + Ok(()) + } + + pub fn read_guest_session_by_token_hash( + &mut self, + session_token_hash: &str, + ) -> anyhow::Result> { + let row = self.client.query_opt( + "SELECT session_id, guest_id, session_token_hash, created_at, expires_at, revoked_at + FROM guest_session WHERE session_token_hash = $1", + &[&session_token_hash], + )?; + + Ok(row.map(|row| GuestSession { + session_id: row.get::<_, String>(0), + guest_id: row.get::<_, String>(1), + session_token_hash: row.get::<_, String>(2), + created_at: row.get::<_, i64>(3) as u64, + expires_at: row.get::<_, i64>(4) as u64, + revoked_at: row.get::<_, Option>(5).map(|v| v as u64), + })) + } + + pub fn revoke_guest_session( + &mut self, + session_token_hash: &str, + revoked_at: u64, + ) -> anyhow::Result<()> { + self.client.execute( + "UPDATE guest_session + SET revoked_at = $1 + WHERE session_token_hash = $2 AND revoked_at IS NULL", + &[&(revoked_at as i64), &session_token_hash], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::ProductStore; + use crate::db::{GuestAccount, GuestSession}; + use postgres::{Client, NoTls}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_pg_admin_url() -> Option { + std::env::var("RACE_FACADE_TEST_POSTGRES_URL").ok() + } + + fn db_url_for_database(admin_url: &str, db_name: &str) -> String { + if admin_url.contains("://") { + match admin_url.rsplit_once('/') { + Some((prefix, _)) => format!("{prefix}/{db_name}"), + None => admin_url.to_string(), + } + } else if admin_url.contains("dbname=") { + admin_url.replacen("dbname=postgres", &format!("dbname={db_name}"), 1) + } else { + format!("{admin_url} dbname={db_name}") + } + } + + #[test] + fn test_product_store_guest_flow_if_configured() -> anyhow::Result<()> { + let Some(admin_url) = test_pg_admin_url() else { + return Ok(()); + }; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_nanos(); + let db_name = format!("race_facade_test_{unique}"); + + { + let mut admin = Client::connect(&admin_url, NoTls)?; + admin.batch_execute(&format!("CREATE DATABASE {db_name}"))?; + } + + let product_db_url = db_url_for_database(&admin_url, &db_name); + let mut store = ProductStore::connect(&product_db_url)?; + + let guest_account = GuestAccount { + guest_id: "guest-pg-1".into(), + player_addr: "guest_player_pg_1".into(), + nick: "PgGuest".into(), + status: "active".into(), + created_at: 100, + updated_at: 100, + }; + + let guest_session = GuestSession { + session_id: "guest-session-pg-1".into(), + guest_id: "guest-pg-1".into(), + session_token_hash: "pg-token-hash".into(), + created_at: 100, + expires_at: 200, + revoked_at: None, + }; + + store.create_guest_account(&guest_account)?; + store.create_guest_session(&guest_session)?; + + let stored_account = store + .read_guest_account_by_guest_id("guest-pg-1")? + .expect("guest account"); + assert_eq!(stored_account.player_addr, "guest_player_pg_1"); + + let stored_session = store + .read_guest_session_by_token_hash("pg-token-hash")? + .expect("guest session"); + assert_eq!(stored_session.guest_id, "guest-pg-1"); + + store.revoke_guest_session("pg-token-hash", 150)?; + let revoked_session = store + .read_guest_session_by_token_hash("pg-token-hash")? + .expect("revoked session"); + assert_eq!(revoked_session.revoked_at, Some(150)); + + drop(store); + + { + let mut admin = Client::connect(&admin_url, NoTls)?; + admin.batch_execute(&format!("DROP DATABASE IF EXISTS {db_name} WITH (FORCE)"))?; + } + + Ok(()) + } +} From e8499b77151653db8c105e6a38e08a4ee11399a3 Mon Sep 17 00:00:00 2001 From: spozitivom Date: Wed, 8 Apr 2026 15:04:52 +0300 Subject: [PATCH 2/2] feat(facade): add phase1 result, wins/losses and rating layer --- facade/src/context.rs | 32 ++- facade/src/db.rs | 37 ++++ facade/src/main.rs | 127 ++++++++++++ facade/src/product_event_service.rs | 309 +++++++++++++++++++++++++++- facade/src/product_rules.rs | 61 ++++++ facade/src/product_store.rs | 37 ++++ 6 files changed, 596 insertions(+), 7 deletions(-) diff --git a/facade/src/context.rs b/facade/src/context.rs index e4a87b06..cd327b47 100644 --- a/facade/src/context.rs +++ b/facade/src/context.rs @@ -5,13 +5,14 @@ use crate::{ create_game_account, create_game_bundle, create_player_info, create_recipient_account, create_server_account, create_stake, create_token_account, create_guest_account, create_guest_session, initialize_product_state, list_game_accounts, - increment_user_hands_played, insert_product_event_log_entry, + increment_user_hands_played, increment_user_losses, increment_user_wins, + insert_product_event_log_entry, list_token_accounts, prepare_all_tables, read_game_account, read_game_bundle, read_player_info, read_recipient_account, read_registration_account, read_server_account, read_token_account, read_guest_account_by_guest_id, read_guest_account_by_player_addr, read_guest_session_by_token_hash, read_user_progress, read_user_rating, read_user_stats, record_user_joined_game, revoke_guest_session, update_game_account, - update_user_progress, ProductEventLogEntry, + update_user_progress, update_user_rating, ProductEventLogEntry, update_player_info, update_recipient_account, update_stake, read_stake, GuestAccount, GuestSession, PlayerInfo, Stake, UserProgress, UserRating, UserStats, }, @@ -414,6 +415,33 @@ impl Context { Ok(()) } + pub fn increment_user_wins(&mut self, guest_id: &str) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.increment_user_wins(guest_id)?; + } else { + increment_user_wins(&self.conn, guest_id)?; + } + Ok(()) + } + + pub fn increment_user_losses(&mut self, guest_id: &str) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.increment_user_losses(guest_id)?; + } else { + increment_user_losses(&self.conn, guest_id)?; + } + Ok(()) + } + + pub fn update_user_rating(&mut self, rating: &UserRating) -> anyhow::Result<()> { + if let Some(store) = self.product_store.as_mut() { + store.update_user_rating(rating)?; + } else { + update_user_rating(&self.conn, rating)?; + } + Ok(()) + } + pub fn update_player_info(&self, player_info: &PlayerInfo) -> anyhow::Result<()> { update_player_info(&self.conn, &player_info)?; Ok(()) diff --git a/facade/src/db.rs b/facade/src/db.rs index cfa15961..886677f7 100644 --- a/facade/src/db.rs +++ b/facade/src/db.rs @@ -437,6 +437,43 @@ pub fn increment_user_hands_played(conn: &Connection, guest_id: &str) -> Result< Ok(()) } +pub fn increment_user_wins(conn: &Connection, guest_id: &str) -> Result<()> { + conn.execute( + "UPDATE user_stats + SET wins = wins + 1 + WHERE guest_id = ?1", + params![guest_id], + )?; + Ok(()) +} + +pub fn increment_user_losses(conn: &Connection, guest_id: &str) -> Result<()> { + conn.execute( + "UPDATE user_stats + SET losses = losses + 1 + WHERE guest_id = ?1", + params![guest_id], + )?; + Ok(()) +} + +pub fn update_user_rating(conn: &Connection, rating: &UserRating) -> Result<()> { + conn.execute( + "UPDATE user_rating + SET rating = ?2, + rank_bucket = ?3, + updated_at = ?4 + WHERE guest_id = ?1", + params![ + rating.guest_id, + rating.rating, + rating.rank_bucket, + rating.updated_at, + ], + )?; + Ok(()) +} + // Function to create a new stake entry pub fn create_stake(conn: &Connection, stake: &Stake) -> Result<()> { conn.execute( diff --git a/facade/src/main.rs b/facade/src/main.rs index 04432871..2f5ba226 100644 --- a/facade/src/main.rs +++ b/facade/src/main.rs @@ -230,6 +230,22 @@ struct InternalGuestSessionFinishedRequest { timestamp: Option, } +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct InternalGuestTableResultRecordedRequest { + event_id: String, + guest_id: String, + player_addr: String, + game_id: String, + result_id: String, + entry_value: u64, + ending_value: u64, + opponent_count: u32, + hands_played_in_session: u64, + session_duration_seconds: u64, + timestamp: Option, +} + fn custom_error(e: Error) -> RpcError { RpcError::Custom(serde_json::to_string(&e).unwrap()) } @@ -585,6 +601,29 @@ async fn internal_guest_session_finished( Ok(()) } +async fn internal_guest_table_result_recorded( + params: Params<'_>, + context: Arc>, +) -> RpcResult<()> { + let request: InternalGuestTableResultRecordedRequest = params.one()?; + let mut context = context.lock().await; + let event = ProductEvent::GuestTableResultRecorded { + event_id: request.event_id, + guest_id: request.guest_id, + player_addr: request.player_addr, + game_id: request.game_id, + result_id: request.result_id, + entry_value: request.entry_value, + ending_value: request.ending_value, + opponent_count: request.opponent_count, + hands_played_in_session: request.hands_played_in_session, + session_duration_seconds: request.session_duration_seconds, + timestamp: request.timestamp.unwrap_or_else(now_millis), + }; + ProductEventService::apply(&mut context, event).map_err(|err| session_error(&err.to_string()))?; + Ok(()) +} + async fn deposit(params: Params<'_>, context: Arc>) -> RpcResult<()> { let DepositParams { player_addr, @@ -1352,6 +1391,10 @@ async fn run_server_at(context: Context, bind_addr: SocketAddr) -> anyhow::Resul "internal_guest_session_finished", internal_guest_session_finished, )?; + module.register_async_method( + "internal_guest_table_result_recorded", + internal_guest_table_result_recorded, + )?; module.register_async_method("deposit", deposit)?; module.register_async_method("settle", settle)?; module.register_async_method("vote", vote)?; @@ -1768,6 +1811,90 @@ mod tests { server_handle.stopped().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_internal_table_result_recorded_updates_stats_and_rating() { + let context = Context::in_memory(); + context.load_default_tokens().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bind_addr = listener.local_addr().unwrap(); + drop(listener); + let server_handle = run_server_at(context, bind_addr).await.unwrap(); + + let client = HttpClientBuilder::default() + .build(format!("http://{bind_addr}")) + .unwrap(); + + let register_response: GuestRegisterResponse = client + .request( + "guest_register", + rpc_params![GuestRegisterRequest { + nick: "ResultGuest".into(), + }], + ) + .await + .unwrap(); + + client + .request::<(), _>( + "internal_guest_table_result_recorded", + rpc_params![InternalGuestTableResultRecordedRequest { + event_id: "result:event:1".into(), + guest_id: register_response.guest.guest_id.clone(), + player_addr: register_response.guest.player_addr.clone(), + game_id: "table-1".into(), + result_id: "table-result-1".into(), + entry_value: 1_000, + ending_value: 1_300, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: Some(500), + }], + ) + .await + .unwrap(); + client + .request::<(), _>( + "internal_guest_table_result_recorded", + rpc_params![InternalGuestTableResultRecordedRequest { + event_id: "result:event:1".into(), + guest_id: register_response.guest.guest_id.clone(), + player_addr: register_response.guest.player_addr.clone(), + game_id: "table-1".into(), + result_id: "table-result-1".into(), + entry_value: 1_000, + ending_value: 1_300, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: Some(500), + }], + ) + .await + .unwrap(); + + let me_response: GuestIdentityResponse = client + .request( + "guest_get_me", + rpc_params![GuestSessionRequest { + session_token: register_response.session_token, + }], + ) + .await + .unwrap(); + + assert_eq!(me_response.stats.wins, 1); + assert_eq!(me_response.stats.losses, 0); + assert_eq!( + me_response.rating.rating, + 1000 + product_rules::STRONG_POSITIVE_RATING_DELTA + ); + assert_eq!(me_response.rating.rank_bucket, "Silver"); + + server_handle.stop().unwrap(); + server_handle.stopped().await; + } + #[test] fn test_guest_persistence_across_context_restart() { let unique = SystemTime::now() diff --git a/facade/src/product_event_service.rs b/facade/src/product_event_service.rs index 766c88c9..8bfa1edf 100644 --- a/facade/src/product_event_service.rs +++ b/facade/src/product_event_service.rs @@ -2,10 +2,12 @@ use anyhow::{anyhow, Result}; use crate::{ context::Context, - db::{ProductEventLogEntry, UserProgress}, + db::{ProductEventLogEntry, UserProgress, UserRating}, product_rules::{ - level_for_xp, rank_tier_for_level, HAND_PARTICIPATION_XP, HAND_WIN_BONUS, - JOIN_XP_BONUS, MIN_HANDS_FOR_SESSION_COMPLETION, MIN_SESSION_DURATION_SECONDS, + classify_table_result, level_for_xp, rank_bucket_for_rating, rank_tier_for_level, + rating_delta_for_result, TableResultCategory, HAND_PARTICIPATION_XP, HAND_WIN_BONUS, + JOIN_XP_BONUS, MIN_HANDS_FOR_RATING, MIN_HANDS_FOR_SESSION_COMPLETION, MIN_OPPONENTS_FOR_RATING, + MIN_RATING, MIN_SESSION_DURATION_SECONDS, MIN_SESSION_DURATION_SECONDS_FOR_RATING, SESSION_COMPLETION_XP, }, }; @@ -37,6 +39,19 @@ pub enum ProductEvent { session_duration_seconds: u64, timestamp: u64, }, + GuestTableResultRecorded { + event_id: String, + guest_id: String, + player_addr: String, + game_id: String, + result_id: String, + entry_value: u64, + ending_value: u64, + opponent_count: u32, + hands_played_in_session: u64, + session_duration_seconds: u64, + timestamp: u64, + }, } #[allow(dead_code)] @@ -141,6 +156,51 @@ impl ProductEventService { xp_delta: SESSION_COMPLETION_XP, }) } + ProductEvent::GuestTableResultRecorded { + event_id, + guest_id, + player_addr, + game_id, + result_id, + entry_value, + ending_value, + opponent_count, + hands_played_in_session, + session_duration_seconds, + timestamp, + } => { + validate_guest_identity(context, &guest_id, &player_addr)?; + let was_inserted = context.record_product_event_once(ProductEventLogEntry { + event_id, + event_type: format!("guest_table_result_recorded:{game_id}:{result_id}"), + guest_id: guest_id.clone(), + created_at: timestamp, + })?; + if !was_inserted { + return Ok(ProductEventApplyResult { applied: false, xp_delta: 0 }); + } + + let result_category = classify_table_result(entry_value, ending_value); + match result_category { + TableResultCategory::StrongPositive | TableResultCategory::ModeratePositive => { + context.increment_user_wins(&guest_id)?; + } + TableResultCategory::StrongNegative | TableResultCategory::ModerateNegative => { + context.increment_user_losses(&guest_id)?; + } + TableResultCategory::Neutral => {} + } + + if is_rating_eligible( + hands_played_in_session, + session_duration_seconds, + opponent_count, + ) { + apply_rating_delta(context, &guest_id, result_category, timestamp)?; + } + + Ok(ProductEventApplyResult { applied: true, xp_delta: 0 }) + } } } } @@ -175,13 +235,50 @@ fn apply_xp_delta(context: &mut Context, guest_id: &str, xp_delta: u64, timestam Ok(()) } +fn apply_rating_delta( + context: &mut Context, + guest_id: &str, + category: TableResultCategory, + timestamp: u64, +) -> Result<()> { + let current = context + .get_user_rating(guest_id)? + .ok_or_else(|| anyhow!("user-rating-not-found"))?; + let delta = rating_delta_for_result(category); + let rating = (current.rating + delta).max(MIN_RATING); + let rank_bucket = rank_bucket_for_rating(rating).to_string(); + + context.update_user_rating(&UserRating { + guest_id: guest_id.to_string(), + rating, + rank_bucket, + updated_at: timestamp, + })?; + Ok(()) +} + +fn is_rating_eligible( + hands_played_in_session: u64, + session_duration_seconds: u64, + opponent_count: u32, +) -> bool { + hands_played_in_session >= MIN_HANDS_FOR_RATING + && session_duration_seconds >= MIN_SESSION_DURATION_SECONDS_FOR_RATING + && opponent_count >= MIN_OPPONENTS_FOR_RATING +} + #[cfg(test)] mod tests { use super::{ProductEvent, ProductEventService}; use crate::{ context::Context, - db::{GuestAccount, PlayerInfo}, - product_rules::{HAND_PARTICIPATION_XP, HAND_WIN_BONUS, JOIN_XP_BONUS, SESSION_COMPLETION_XP}, + db::{GuestAccount, PlayerInfo, UserRating}, + product_rules::{ + classify_table_result, rank_bucket_for_rating, TableResultCategory, + HAND_PARTICIPATION_XP, HAND_WIN_BONUS, JOIN_XP_BONUS, + MODERATE_NEGATIVE_RATING_DELTA, MIN_RATING, SESSION_COMPLETION_XP, + STRONG_POSITIVE_RATING_DELTA, + }, }; use race_core::types::PlayerProfile; use std::collections::HashMap; @@ -376,4 +473,206 @@ mod tests { assert!(!duplicate.applied); assert_eq!(progress.xp, SESSION_COMPLETION_XP); } + + #[test] + fn classifies_table_results() { + assert_eq!( + classify_table_result(1_000, 1_300), + TableResultCategory::StrongPositive + ); + assert_eq!( + classify_table_result(1_000, 1_050), + TableResultCategory::ModeratePositive + ); + assert_eq!( + classify_table_result(1_000, 1_000), + TableResultCategory::Neutral + ); + assert_eq!( + classify_table_result(1_000, 950), + TableResultCategory::ModerateNegative + ); + assert_eq!( + classify_table_result(1_000, 700), + TableResultCategory::StrongNegative + ); + } + + #[test] + fn positive_result_updates_win_and_rating() { + let mut context = seed_guest_context(); + + let result = ProductEventService::apply( + &mut context, + ProductEvent::GuestTableResultRecorded { + event_id: "result:pos".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + result_id: "r-pos".into(), + entry_value: 1_000, + ending_value: 1_300, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: 400, + }, + ) + .unwrap(); + + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + let rating = context.get_user_rating("guest-evt-1").unwrap().unwrap(); + + assert!(result.applied); + assert_eq!(stats.wins, 1); + assert_eq!(stats.losses, 0); + assert_eq!(rating.rating, 1000 + STRONG_POSITIVE_RATING_DELTA); + assert_eq!(rating.rank_bucket, rank_bucket_for_rating(rating.rating)); + } + + #[test] + fn negative_result_updates_loss_and_rating_with_floor() { + let mut context = seed_guest_context(); + context + .update_user_rating(&UserRating { + guest_id: "guest-evt-1".into(), + rating: MIN_RATING, + rank_bucket: rank_bucket_for_rating(MIN_RATING).to_string(), + updated_at: 399, + }) + .unwrap(); + + let result = ProductEventService::apply( + &mut context, + ProductEvent::GuestTableResultRecorded { + event_id: "result:neg".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + result_id: "r-neg".into(), + entry_value: 1_000, + ending_value: 950, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: 401, + }, + ) + .unwrap(); + + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + let rating = context.get_user_rating("guest-evt-1").unwrap().unwrap(); + + assert!(result.applied); + assert_eq!(stats.wins, 0); + assert_eq!(stats.losses, 1); + assert_eq!(rating.rating, MIN_RATING.max(MIN_RATING + MODERATE_NEGATIVE_RATING_DELTA)); + } + + #[test] + fn neutral_result_updates_neither_win_nor_loss_and_leaves_rating() { + let mut context = seed_guest_context(); + + ProductEventService::apply( + &mut context, + ProductEvent::GuestTableResultRecorded { + event_id: "result:neutral".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + result_id: "r-neutral".into(), + entry_value: 1_000, + ending_value: 1_000, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: 402, + }, + ) + .unwrap(); + + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + let rating = context.get_user_rating("guest-evt-1").unwrap().unwrap(); + + assert_eq!(stats.wins, 0); + assert_eq!(stats.losses, 0); + assert_eq!(rating.rating, 1000); + } + + #[test] + fn non_eligible_result_updates_stats_but_not_rating() { + let mut context = seed_guest_context(); + + ProductEventService::apply( + &mut context, + ProductEvent::GuestTableResultRecorded { + event_id: "result:ineligible".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + result_id: "r-ineligible".into(), + entry_value: 1_000, + ending_value: 1_300, + opponent_count: 1, + hands_played_in_session: 4, + session_duration_seconds: 299, + timestamp: 403, + }, + ) + .unwrap(); + + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + let rating = context.get_user_rating("guest-evt-1").unwrap().unwrap(); + + assert_eq!(stats.wins, 1); + assert_eq!(rating.rating, 1000); + } + + #[test] + fn duplicate_result_event_is_ignored() { + let mut context = seed_guest_context(); + + let first = ProductEventService::apply( + &mut context, + ProductEvent::GuestTableResultRecorded { + event_id: "result:dup".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + result_id: "r-dup".into(), + entry_value: 1_000, + ending_value: 1_300, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: 404, + }, + ) + .unwrap(); + let second = ProductEventService::apply( + &mut context, + ProductEvent::GuestTableResultRecorded { + event_id: "result:dup".into(), + guest_id: "guest-evt-1".into(), + player_addr: "guest_player_evt_1".into(), + game_id: "table-1".into(), + result_id: "r-dup".into(), + entry_value: 1_000, + ending_value: 1_300, + opponent_count: 3, + hands_played_in_session: 6, + session_duration_seconds: 420, + timestamp: 404, + }, + ) + .unwrap(); + + let stats = context.get_user_stats("guest-evt-1").unwrap().unwrap(); + let rating = context.get_user_rating("guest-evt-1").unwrap().unwrap(); + + assert!(first.applied); + assert!(!second.applied); + assert_eq!(stats.wins, 1); + assert_eq!(rating.rating, 1000 + STRONG_POSITIVE_RATING_DELTA); + } } diff --git a/facade/src/product_rules.rs b/facade/src/product_rules.rs index 5a8a8c44..72e4b0d6 100644 --- a/facade/src/product_rules.rs +++ b/facade/src/product_rules.rs @@ -4,6 +4,24 @@ pub const HAND_WIN_BONUS: u64 = 3; pub const SESSION_COMPLETION_XP: u64 = 20; pub const MIN_HANDS_FOR_SESSION_COMPLETION: u64 = 3; pub const MIN_SESSION_DURATION_SECONDS: u64 = 300; +pub const MIN_HANDS_FOR_RATING: u64 = 5; +pub const MIN_SESSION_DURATION_SECONDS_FOR_RATING: u64 = 300; +pub const MIN_OPPONENTS_FOR_RATING: u32 = 2; +pub const MIN_RATING: i32 = 100; +pub const STRONG_POSITIVE_RATING_DELTA: i32 = 25; +pub const MODERATE_POSITIVE_RATING_DELTA: i32 = 15; +pub const NEUTRAL_RATING_DELTA: i32 = 0; +pub const MODERATE_NEGATIVE_RATING_DELTA: i32 = -15; +pub const STRONG_NEGATIVE_RATING_DELTA: i32 = -25; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TableResultCategory { + StrongPositive, + ModeratePositive, + Neutral, + ModerateNegative, + StrongNegative, +} const LEVEL_THRESHOLDS: &[(u32, u64)] = &[ (1, 0), @@ -45,3 +63,46 @@ pub fn rank_tier_for_level(level: u32) -> &'static str { _ => "Hero", } } + +pub fn classify_table_result(entry_value: u64, ending_value: u64) -> TableResultCategory { + if entry_value == 0 { + return TableResultCategory::Neutral; + } + + let baseline = entry_value as f64; + let net = ending_value as f64 - baseline; + let ratio = net / baseline; + + if ratio > 0.20 { + TableResultCategory::StrongPositive + } else if ratio > 0.0 { + TableResultCategory::ModeratePositive + } else if ratio < -0.20 { + TableResultCategory::StrongNegative + } else if ratio < 0.0 { + TableResultCategory::ModerateNegative + } else { + TableResultCategory::Neutral + } +} + +pub fn rating_delta_for_result(category: TableResultCategory) -> i32 { + match category { + TableResultCategory::StrongPositive => STRONG_POSITIVE_RATING_DELTA, + TableResultCategory::ModeratePositive => MODERATE_POSITIVE_RATING_DELTA, + TableResultCategory::Neutral => NEUTRAL_RATING_DELTA, + TableResultCategory::ModerateNegative => MODERATE_NEGATIVE_RATING_DELTA, + TableResultCategory::StrongNegative => STRONG_NEGATIVE_RATING_DELTA, + } +} + +pub fn rank_bucket_for_rating(rating: i32) -> &'static str { + match rating { + i32::MIN..=999 => "Bronze", + 1000..=1199 => "Silver", + 1200..=1399 => "Gold", + 1400..=1599 => "Platinum", + 1600..=1799 => "Diamond", + _ => "Hero", + } +} diff --git a/facade/src/product_store.rs b/facade/src/product_store.rs index e716a9cc..0140b1e4 100644 --- a/facade/src/product_store.rs +++ b/facade/src/product_store.rs @@ -233,6 +233,43 @@ impl ProductStore { Ok(()) } + pub fn increment_user_wins(&mut self, guest_id: &str) -> anyhow::Result<()> { + self.client.execute( + "UPDATE user_stats + SET wins = wins + 1 + WHERE guest_id = $1", + &[&guest_id], + )?; + Ok(()) + } + + pub fn increment_user_losses(&mut self, guest_id: &str) -> anyhow::Result<()> { + self.client.execute( + "UPDATE user_stats + SET losses = losses + 1 + WHERE guest_id = $1", + &[&guest_id], + )?; + Ok(()) + } + + pub fn update_user_rating(&mut self, rating: &UserRating) -> anyhow::Result<()> { + self.client.execute( + "UPDATE user_rating + SET rating = $2, + rank_bucket = $3, + updated_at = $4 + WHERE guest_id = $1", + &[ + &rating.guest_id, + &rating.rating, + &rating.rank_bucket, + &(rating.updated_at as i64), + ], + )?; + Ok(()) + } + pub fn read_guest_session_by_token_hash( &mut self, session_token_hash: &str,