diff --git a/.gitignore b/.gitignore index fa83e0e..3736e04 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ profile.json bot/models client_web/dist var + +deploy +clients/**/dist diff --git a/Cargo.lock b/Cargo.lock index 2141f27..8ce19af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -191,6 +191,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + [[package]] name = "arimaa_engine_step" version = "1.0.1" @@ -378,7 +390,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -411,7 +423,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -422,6 +434,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-login" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964ea6eb764a227baa8c3368e45c94d23b6863cc7b880c6c9e341c143c5a5ff7" +dependencies = [ + "axum", + "form_urlencoded", + "serde", + "subtle", + "thiserror 2.0.18", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions", + "tracing", + "urlencoding", +] + [[package]] name = "backbone-lib" version = "0.1.0" @@ -554,6 +585,15 @@ dependencies = [ "core2", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block" version = "0.1.6" @@ -569,6 +609,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "board-game" version = "0.8.2" @@ -1359,7 +1408,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1407,6 +1456,7 @@ dependencies = [ "backbone-lib", "futures", "getrandom 0.3.4", + "gloo-net 0.5.0", "gloo-storage", "gloo-timers", "leptos", @@ -1428,6 +1478,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "cobs" version = "0.3.0" @@ -1548,6 +1604,12 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1843,6 +1905,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -1864,6 +1935,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cubecl" version = "0.9.0" @@ -2412,6 +2492,41 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + [[package]] name = "decorum" version = "0.3.1" @@ -2440,6 +2555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -2516,11 +2632,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", +] + [[package]] name = "directories" version = "6.0.0" @@ -2868,6 +2996,12 @@ dependencies = [ "synstructure 0.12.6", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -3292,7 +3426,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -3435,6 +3569,27 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "gloo-net" version = "0.6.0" @@ -3445,7 +3600,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -3602,7 +3757,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -3728,7 +3883,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", ] [[package]] @@ -3740,6 +3904,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3757,7 +3932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3768,11 +3943,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3791,6 +3972,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hydration_context" version = "0.2.1" @@ -3816,7 +4006,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -3833,7 +4023,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -3854,7 +4044,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", "ipnet", @@ -4778,6 +4968,42 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos_router" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4168ead6a9715daba953aa842795cb2ad81b6e011a15745bd3d1baf86f76de95" +dependencies = [ + "any_spawner", + "either_of", + "futures", + "gloo-net 0.6.0", + "js-sys", + "leptos", + "leptos_router_macro", + "once_cell", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "thiserror 2.0.18", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_router_macro" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31197af38d209ffc5d9f89715381c415a1570176f8d23455fbe00d148e79640" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "leptos_server" version = "0.7.8" @@ -4917,6 +5143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", + "serde", ] [[package]] @@ -5047,6 +5274,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.2", +] + [[package]] name = "md5" version = "0.8.0" @@ -5112,6 +5349,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5136,7 +5383,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -5493,6 +5740,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -5614,6 +5870,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -5638,10 +5905,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", + "digest 0.10.7", + "hmac 0.12.1", + "password-hash 0.4.2", + "sha2 0.10.9", ] [[package]] @@ -5690,7 +5957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5703,6 +5970,25 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -5830,6 +6116,35 @@ dependencies = [ "serde", ] +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -6557,6 +6872,31 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "relay-server" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "axum-login", + "bytes", + "deadpool-postgres", + "futures-util", + "postcard", + "protocol", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-postgres", + "tower-http", + "tower-sessions", + "tracing", + "tracing-subscriber", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -6597,7 +6937,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -6722,7 +7062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ "bitflags 2.11.0", - "fallible-iterator", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -7077,8 +7417,8 @@ dependencies = [ "const_format", "dashmap", "futures", - "gloo-net", - "http", + "gloo-net 0.6.0", + "http 1.4.0", "js-sys", "once_cell", "pin-project-lite", @@ -7129,7 +7469,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -7140,7 +7480,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -7227,6 +7578,12 @@ dependencies = [ "quote", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -7333,6 +7690,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" +[[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.11.1" @@ -7788,6 +8156,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -7807,6 +8176,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +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.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7987,6 +8382,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http 1.4.0", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.8" @@ -7995,14 +8406,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", - "http", + "http 1.4.0", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8017,6 +8438,57 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" +dependencies = [ + "async-trait", + "http 1.4.0", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http 1.4.0", + "parking_lot", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + [[package]] name = "tracel-llvm" version = "20.1.4-7" @@ -8042,7 +8514,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tar", "walkdir", ] @@ -8228,6 +8700,29 @@ dependencies = [ "transpose", ] +[[package]] +name = "trictrac-web" +version = "0.1.0" +dependencies = [ + "backbone-lib", + "futures", + "getrandom 0.3.4", + "gloo-net 0.5.0", + "gloo-storage", + "gloo-timers", + "js-sys", + "leptos", + "leptos_i18n", + "leptos_router", + "rand 0.9.3", + "serde", + "serde_json", + "trictrac-store", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -8243,7 +8738,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.5", @@ -8260,7 +8755,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.3", @@ -8322,6 +8817,18 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -8337,6 +8844,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-segmentation" version = "1.13.2" @@ -8390,7 +8903,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -8442,6 +8955,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -8561,6 +9080,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -8579,6 +9107,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -8701,6 +9238,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-user-portal" +version = "0.1.0" +dependencies = [ + "gloo-net 0.5.0", + "js-sys", + "leptos", + "leptos_router", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -8874,6 +9426,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "wide" version = "0.7.33" @@ -9607,7 +10172,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac", + "hmac 0.12.1", "pbkdf2", "sha1", "time", diff --git a/Cargo.toml b/Cargo.toml index 72e3f08..94a1c6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,31 @@ [workspace] resolver = "2" -members = ["client_cli", "bot", "store", "spiel_bot", "client_web"] +members = [ + "store", + "clients/cli", + "clients/backbone-lib", + "clients/web", + "clients/web-game", + "clients/web-user-portal", + "server/protocol", + "server/relay-server", + "bot", + "spiel_bot", +] + +default-members = [ + "store", + "clients/cli", + "clients/backbone-lib", + "server/protocol", + "server/relay-server", + "bot", + "spiel_bot", +] + +# For the server we will need opt-level='3' +[profile.release] +opt-level = 'z' # Minimum space +lto = "fat" # Aggressive Link Time Optimization +codegen-units = 1 diff --git a/README.md b/README.md index e74fb69..4e7789f 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,133 @@ This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation. -The project is on its early stages. -Rules (without "schools") are implemented, as well as a rudimentary terminal interface which allow you to play against a bot which plays randomly. - -Training of AI bots is the work in progress. +The project is still on its early stages. ## Usage -`cargo run --bin=client_cli -- --bot random` +Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. + +```bash +# Run the relay server +just build-relay +just run-relay # listens on :8080 + +# Run the game (separate terminal) +just dev-game +``` + +Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name. + +Playing with the cli against the 'random' bot: `cargo run --bin=client_cli -- --bot random` ## Roadmap - [x] rules - [x] command line interface - [x] basic bot (random play) +- [ ] web client (in progress) +- [ ] network game (in progress) - [ ] AI bot -- [ ] network game -- [ ] web client ## Code structure - game rules and game state are implemented in the _store/_ folder. -- the command-line application is implemented in _client_cli/_; it allows you to play against a bot, or to have two bots play against each other +- the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other - the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. ### _store_ package The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented. -### _client_cli_ package +### _clients/cli_ package -`client_cli/src/game_runner.rs` contains the logic to make two bots play against each other. +`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other. ### _bot_ package - `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list. - `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (). - `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC). + +### multiplayer game + +Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). + +The system consists of: + +- A **relay server** (Axum/Tokio) that routes messages between players and manages rooms, without knowing anything about game rules. +- A **backbone library** that handles WebSocket connection, handshake, and message routing, exposing an async API to the game frontend. +- Game-specific **backend logic** implementing the `BackEndArchitecture` trait, which runs only on the hosting client. +- A **Leptos frontend** that connects to a session and reacts to state updates. + +There is no dedicated game server. One of the players acts as the host: their browser runs the game backend locally. The relay server only forwards messages — it never touches game state. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Host Client │ +│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │ +│ │ Leptos UI │◄──►│ GameSession │◄──►│ Backend │ │ +│ └─────────────┘ └────────┬─────────┘ └────────────┘ │ +└───────────────────────────── │ ────────────────────────────┘ + │ WebSocket + ┌──────▼──────┐ + │ Relay Server│ + └──────┬──────┘ + │ WebSocket +┌───────────────────────────────│────────────────────────────┐ +│ ┌─────────────┐ ┌─────────▼────────┐ │ +│ │ Leptos UI │◄──►│ GameSession │ (no backend) │ +│ └─────────────┘ └──────────────────┘ │ +│ Remote Client │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Data flow + +- **Actions** (e.g. "place stone at B3") flow from the UI to the host backend via `GameSession::send_action()`. +- **State updates** flow back as `ViewStateUpdate::Full` (full snapshot, on join or reset) or `ViewStateUpdate::Incremental` (delta, for animations). +- **Timers** are managed by the host's background task (wall-clock, no polling required from the game). + +#### backbone-lib session API + +The key design choice: `backbone-lib` owns a background async task per session. The Leptos app never drives a loop — it just awaits on events. + +#### Workspace + +**server/protocol** + +Shared message-type constants and the `JoinRequest` struct used during the WebSocket handshake. + +**server/relay-server** + +Listens on port 8080. Loads `GameConfig.json` on startup to know which games exist and their player limits: + +```json +[{ "name": "trictrac", "max_players": 10 }] +``` + +Games can be added at runtime via the `/reload` endpoint. `/enlist` lists active rooms. A watchdog cleans up inactive rooms every 20 minutes. + +For production, put it behind a reverse proxy with SSL (the browser requires `wss://` on HTTPS pages). Example Caddy config: + +``` +your-domain.com { + handle_path /api/* { + reverse_proxy localhost:8080 + } + file_server +} +``` + +**clients/backbone-lib** + +Modules: + +| Module | Purpose | +| ---------- | ---------------------------------------------------------------------------------------------------------- | +| `session` | `GameSession`, `connect()`, `SessionEvent`, `RoomConfig` | +| `host` | Background async task for the hosting client (drives `BackEndArchitecture`, manages timers) | +| `client` | Background async task for non-hosting clients | +| `protocol` | Wire encoding/decoding helpers (postcard + message-type bytes) | +| `platform` | `spawn_task` / `sleep_ms` abstractions (WASM: `spawn_local` + gloo-timers; native: thread + thread::sleep) | +| `traits` | `BackEndArchitecture`, `BackendCommand`, `ViewStateUpdate`, `SerializationCap` | diff --git a/clients/backbone-lib/Cargo.toml b/clients/backbone-lib/Cargo.toml new file mode 100644 index 0000000..1e57d93 --- /dev/null +++ b/clients/backbone-lib/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "backbone-lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +postcard = { version = "1.1", features = ["use-std"] } +bytes = "1.11" +ewebsock = "0.8" +protocol = { path = "../../server/protocol" } +futures = "0.3" +web-time = "1.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = "0.4" +gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/clients/backbone-lib/src/client.rs b/clients/backbone-lib/src/client.rs new file mode 100644 index 0000000..65c7fdb --- /dev/null +++ b/clients/backbone-lib/src/client.rs @@ -0,0 +1,84 @@ +//! Background task for the client (non-host) side of a session. + +use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender}; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; + +use crate::platform::sleep_ms; +use crate::protocol::{parse_client_update, send_disconnect, send_rpc}; +use crate::session::{BackendMsg, SessionEvent}; +use crate::traits::SerializationCap; + +pub(crate) async fn client_loop( + mut ws_sender: WsSender, + ws_receiver: WsReceiver, + mut action_rx: UnboundedReceiver>, + event_tx: UnboundedSender>, +) where + A: SerializationCap, + D: SerializationCap, + VS: SerializationCap, +{ + loop { + // 1. Drain outbound actions. + loop { + match action_rx.try_next() { + Ok(Some(BackendMsg::Action(action))) => { + send_rpc(&mut ws_sender, &action); + } + Ok(Some(BackendMsg::Disconnect)) => { + send_disconnect(&mut ws_sender, false); + event_tx + .unbounded_send(SessionEvent::Disconnected(None)) + .ok(); + return; + } + Ok(None) => { + send_disconnect(&mut ws_sender, false); + return; + } + Err(_) => break, + } + } + + // 2. Drain inbound state updates. + loop { + match ws_receiver.try_recv() { + Some(WsEvent::Message(WsMessage::Binary(data))) => { + match parse_client_update::(data) { + Ok(updates) => { + for u in updates { + event_tx + .unbounded_send(SessionEvent::Update(u)) + .ok(); + } + } + Err(e) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + } + } + Some(WsEvent::Closed) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some( + "Connection closed".to_string(), + ))) + .ok(); + return; + } + Some(WsEvent::Error(e)) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + Some(_) => continue, + None => break, + } + } + + sleep_ms(2).await; + } +} diff --git a/clients/backbone-lib/src/host.rs b/clients/backbone-lib/src/host.rs new file mode 100644 index 0000000..c78e228 --- /dev/null +++ b/clients/backbone-lib/src/host.rs @@ -0,0 +1,211 @@ +//! Background task for the host (game server) side of a session. + +use std::collections::HashSet; + +use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender}; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; +use web_time::{Duration, Instant}; + +use crate::platform::sleep_ms; +use crate::protocol::{ + ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state, + send_kick, send_reset, +}; +use crate::session::{BackendMsg, SessionEvent}; +use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate}; + +struct Timer { + id: u16, + fire_at: Instant, +} + +pub(crate) async fn host_loop( + mut ws_sender: WsSender, + ws_receiver: WsReceiver, + mut action_rx: UnboundedReceiver>, + event_tx: UnboundedSender>, + rule_variation: u16, + host_state: Option>, +) where + A: SerializationCap, + D: SerializationCap + Clone, + VS: SerializationCap + Clone, + Backend: BackEndArchitecture, +{ + let mut backend = host_state + .as_deref() + .and_then(|b| Backend::from_bytes(rule_variation, b)) + .unwrap_or_else(|| Backend::new(rule_variation)); + backend.player_arrival(0); + + // Push initial state to UI immediately. + let initial = backend.get_view_state().clone(); + event_tx + .unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial))) + .ok(); + + let mut timers: Vec = Vec::new(); + let mut cancelled_timers: HashSet = HashSet::new(); + let mut remote_player_count: u16 = 0; + + loop { + let mut client_joined = false; + + // 1. Drain local actions / detect session drop or disconnect request. + loop { + match action_rx.try_next() { + Ok(Some(BackendMsg::Action(action))) => { + backend.inform_rpc(0, action); + } + Ok(Some(BackendMsg::Disconnect)) => { + send_disconnect(&mut ws_sender, true); + event_tx + .unbounded_send(SessionEvent::Disconnected(None)) + .ok(); + return; + } + Ok(None) => { + // All senders dropped — session was dropped without calling disconnect(). + send_disconnect(&mut ws_sender, true); + return; + } + Err(_) => break, // Channel empty; nothing pending. + } + } + + // 2. Drain WebSocket events from the relay. + loop { + match ws_receiver.try_recv() { + Some(WsEvent::Message(WsMessage::Binary(data))) => { + match parse_server_command::(data) { + ToServerCommand::ClientJoin(id) => { + backend.player_arrival(id); + remote_player_count += 1; + client_joined = true; + } + ToServerCommand::ClientLeft(id) => { + backend.player_departure(id); + remote_player_count = remote_player_count.saturating_sub(1); + } + ToServerCommand::Rpc(id, payload) => { + backend.inform_rpc(id, payload); + } + ToServerCommand::Error(e) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + } + } + Some(WsEvent::Closed) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some( + "Connection closed".to_string(), + ))) + .ok(); + return; + } + Some(WsEvent::Error(e)) => { + event_tx + .unbounded_send(SessionEvent::Disconnected(Some(e))) + .ok(); + return; + } + Some(_) => continue, // Ignore Opened / text messages. + None => break, // No more events this iteration. + } + } + + // 3. Fire elapsed timers. + let now = Instant::now(); + let mut fired = Vec::new(); + timers.retain(|t| { + if t.fire_at <= now { + fired.push(t.id); + false + } else { + true + } + }); + for id in fired { + if !cancelled_timers.remove(&id) { + backend.timer_triggered(id); + } + } + + // 4. Drain and process backend commands. + let commands = backend.drain_commands(); + + if commands.is_empty() && !client_joined { + sleep_ms(2).await; + continue; + } + + let mut delta_batch: Vec = Vec::new(); + let mut reset = false; + + for cmd in commands { + match cmd { + BackendCommand::TerminateRoom => { + send_disconnect(&mut ws_sender, true); + event_tx + .unbounded_send(SessionEvent::Disconnected(None)) + .ok(); + return; + } + BackendCommand::SetTimer { timer_id, duration } => { + // Cancel any existing timer with the same id, then re-arm. + timers.retain(|t| t.id != timer_id); + cancelled_timers.remove(&timer_id); + timers.push(Timer { + id: timer_id, + fire_at: Instant::now() + Duration::from_secs_f32(duration), + }); + } + BackendCommand::CancelTimer { timer_id } => { + cancelled_timers.insert(timer_id); + } + BackendCommand::KickPlayer { player } => { + if remote_player_count > 0 { + send_kick(&mut ws_sender, player); + } + } + BackendCommand::ResetViewState => { + reset = true; + } + BackendCommand::Delta(d) => { + delta_batch.push(d); + } + } + } + + if reset { + // Reset supersedes all pending deltas: send fresh full state. + let state = backend.get_view_state().clone(); + if remote_player_count > 0 { + send_reset(&mut ws_sender, &state); + } + event_tx + .unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state))) + .ok(); + } else { + // Broadcast deltas, then notify local UI. + if remote_player_count > 0 && !delta_batch.is_empty() { + send_delta(&mut ws_sender, &delta_batch); + } + for d in delta_batch { + event_tx + .unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d))) + .ok(); + } + } + + // Send full state to clients that joined this iteration. + if client_joined { + send_full_state(&mut ws_sender, backend.get_view_state()); + } + + sleep_ms(2).await; + } +} diff --git a/clients/backbone-lib/src/lib.rs b/clients/backbone-lib/src/lib.rs new file mode 100644 index 0000000..d67a96c --- /dev/null +++ b/clients/backbone-lib/src/lib.rs @@ -0,0 +1,10 @@ +pub mod session; +pub mod traits; + +mod client; +mod host; +mod platform; +mod protocol; + +pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; +pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate}; diff --git a/clients/backbone-lib/src/platform.rs b/clients/backbone-lib/src/platform.rs new file mode 100644 index 0000000..92f2414 --- /dev/null +++ b/clients/backbone-lib/src/platform.rs @@ -0,0 +1,48 @@ +use std::future::Future; + +/// Spawns a background task. +/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required) +/// - Native: spawns an OS thread running `futures::executor::block_on` +#[cfg(target_arch = "wasm32")] +pub fn spawn_task(fut: F) +where + F: Future + 'static, +{ + wasm_bindgen_futures::spawn_local(fut); +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn_task(fut: F) +where + F: Future + Send + 'static, +{ + std::thread::spawn(move || { + futures::executor::block_on(fut); + }); +} + +/// Yields for approximately `ms` milliseconds. +/// - WASM: non-blocking yield via browser timer +/// - Native: blocks the current thread (safe on a dedicated background thread) +#[cfg(target_arch = "wasm32")] +pub async fn sleep_ms(ms: u32) { + gloo_timers::future::TimeoutFuture::new(ms).await; +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn sleep_ms(ms: u32) { + std::thread::sleep(std::time::Duration::from_millis(ms as u64)); +} + +/// Platform-agnostic bound for types that can be moved into a background task. +/// - WASM: only requires `'static` (single-threaded, no Send needed) +/// - Native: requires `Send + 'static` +#[cfg(target_arch = "wasm32")] +pub trait TaskBound: 'static {} +#[cfg(target_arch = "wasm32")] +impl TaskBound for T {} + +#[cfg(not(target_arch = "wasm32"))] +pub trait TaskBound: Send + 'static {} +#[cfg(not(target_arch = "wasm32"))] +impl TaskBound for T {} diff --git a/clients/backbone-lib/src/protocol.rs b/clients/backbone-lib/src/protocol.rs new file mode 100644 index 0000000..65f972a --- /dev/null +++ b/clients/backbone-lib/src/protocol.rs @@ -0,0 +1,159 @@ +//! Wire protocol encoding/decoding helpers. +//! +//! Translates between raw WebSocket binary frames and typed Rust values using +//! postcard serialization and the message-type constants from the `protocol` crate. + +use crate::traits::{SerializationCap, ViewStateUpdate}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use ewebsock::{WsMessage, WsSender}; +use postcard::{from_bytes, take_from_bytes, to_stdvec}; +use protocol::{ + CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE, + FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS, + SERVER_ERROR, SERVER_RPC, +}; + +// --------------------------------------------------------------------------- +// Inbound command types (relay → host) +// --------------------------------------------------------------------------- + +pub enum ToServerCommand { + ClientJoin(u16), + ClientLeft(u16), + Rpc(u16, A), + Error(String), +} + +// --------------------------------------------------------------------------- +// Send helpers +// --------------------------------------------------------------------------- + +fn send_binary(sender: &mut WsSender, data: &[u8]) { + sender.send(WsMessage::Binary(data.to_vec())); +} + +pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> { + let bytes = to_stdvec(req).map_err(|e| e.to_string())?; + send_binary(sender, &bytes); + Ok(()) +} + +pub fn send_rpc(sender: &mut WsSender, action: &A) { + let raw = to_stdvec(action).expect("Failed to serialize RPC"); + let mut buf = BytesMut::with_capacity(1 + raw.len()); + buf.put_u8(SERVER_RPC); + buf.put_slice(&raw); + send_binary(sender, &buf); +} + +pub fn send_delta(sender: &mut WsSender, deltas: &[D]) { + let serialized: Vec = deltas + .iter() + .flat_map(|d| to_stdvec(d).expect("Failed to serialize delta")) + .collect(); + let mut buf = BytesMut::with_capacity(1 + serialized.len()); + buf.put_u8(DELTA_UPDATE); + buf.put_slice(&serialized); + send_binary(sender, &buf); +} + +pub fn send_full_state(sender: &mut WsSender, state: &VS) { + let serialized = to_stdvec(state).expect("Failed to serialize full state"); + let mut buf = BytesMut::with_capacity(1 + serialized.len()); + buf.put_u8(FULL_UPDATE); + buf.put_slice(&serialized); + send_binary(sender, &buf); +} + +pub fn send_reset(sender: &mut WsSender, state: &VS) { + let serialized = to_stdvec(state).expect("Failed to serialize reset state"); + let mut buf = BytesMut::with_capacity(1 + serialized.len()); + buf.put_u8(RESET); + buf.put_slice(&serialized); + send_binary(sender, &buf); +} + +pub fn send_kick(sender: &mut WsSender, player_id: u16) { + let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE); + buf.put_u8(CLIENT_GETS_KICKED); + buf.put_u16(player_id); + send_binary(sender, &buf); +} + +pub fn send_disconnect(sender: &mut WsSender, as_host: bool) { + let msg = if as_host { + SERVER_DISCONNECTS + } else { + CLIENT_DISCONNECTS_SELF + }; + send_binary(sender, &[msg]); +} + +// --------------------------------------------------------------------------- +// Receive / parse helpers +// --------------------------------------------------------------------------- + +/// Parses the relay's handshake response. +/// +/// Returns `(player_id, rule_variation, reconnect_token)`. +pub fn parse_handshake_response(data: Vec) -> Result<(u16, u16, u64), String> { + let mut bytes = Bytes::from(data); + let msg = bytes.get_u8(); + match msg { + SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()), + HAND_SHAKE_RESPONSE => { + let player_id = bytes.get_u16(); + let rule_variation = bytes.get_u16(); + let token = bytes.get_u64(); + Ok((player_id, rule_variation, token)) + } + other => Err(format!("Unexpected handshake message id: {other}")), + } +} + +pub fn parse_server_command(data: Vec) -> ToServerCommand { + let mut bytes = Bytes::from(data); + let msg = bytes.get_u8(); + match msg { + SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()), + NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()), + CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()), + SERVER_RPC => { + let client_id = bytes.get_u16(); + let payload: A = + from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload"); + ToServerCommand::Rpc(client_id, payload) + } + other => ToServerCommand::Error(format!("Unknown server message id: {other}")), + } +} + +pub fn parse_client_update( + data: Vec, +) -> Result>, String> +where + VS: SerializationCap, + D: SerializationCap, +{ + let mut bytes = Bytes::from(data); + let msg = bytes.get_u8(); + match msg { + SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()), + DELTA_UPDATE => { + let mut result = Vec::new(); + let mut remaining: &[u8] = &bytes; + while !remaining.is_empty() { + let (delta, rest): (D, &[u8]) = + take_from_bytes(remaining).map_err(|e| e.to_string())?; + remaining = rest; + result.push(ViewStateUpdate::Incremental(delta)); + } + Ok(result) + } + FULL_UPDATE | RESET => { + let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?; + Ok(vec![ViewStateUpdate::Full(state)]) + } + other => Err(format!("Unknown client message id: {other}")), + } +} diff --git a/clients/backbone-lib/src/session.rs b/clients/backbone-lib/src/session.rs new file mode 100644 index 0000000..24314f7 --- /dev/null +++ b/clients/backbone-lib/src/session.rs @@ -0,0 +1,266 @@ +//! The public-facing session API. +//! +//! # Usage +//! +//! ```ignore +//! // Connect (async, returns after handshake completes) +//! let mut session: GameSession = +//! GameSession::connect::(RoomConfig { +//! relay_url: "ws://localhost:8080/ws".to_string(), +//! game_id: "my-game".to_string(), +//! room_id: "room-42".to_string(), +//! rule_variation: 0, +//! role: RoomRole::Create, +//! reconnect_token: None, +//! }) +//! .await?; +//! +//! // In a loop (e.g. Dioxus coroutine with futures::select!): +//! loop { +//! futures::select! { +//! cmd = ui_rx.next().fuse() => session.send_action(cmd), +//! event = session.next_event().fuse() => match event { +//! Some(SessionEvent::Update(u)) => view_state.apply(u), +//! Some(SessionEvent::Disconnected(reason)) | None => break, +//! } +//! } +//! } +//! ``` + +use ewebsock::{WsEvent, WsMessage}; +use futures::StreamExt; +use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use protocol::JoinRequest; + +use crate::client::client_loop; +use crate::host::host_loop; +use crate::platform::{TaskBound, sleep_ms, spawn_task}; +use crate::protocol::{parse_handshake_response, send_join_request}; +use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate}; + +// --------------------------------------------------------------------------- +// Public configuration types +// --------------------------------------------------------------------------- + +/// Whether to create a new room (host) or join an existing one (client). +pub enum RoomRole { + Create, + Join, +} + +/// Configuration required to connect to a game session. +pub struct RoomConfig { + /// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`). + pub relay_url: String, + /// Game identifier registered on the relay (e.g. `"tic-tac-toe"`). + pub game_id: String, + /// Room identifier shared between host and clients. + pub room_id: String, + /// Game mode/variant. Only used when `role` is `Create`. + pub rule_variation: u16, + pub role: RoomRole, + /// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh. + /// The value is the token returned by a previous successful handshake. + pub reconnect_token: Option, + /// Serialized backend state for host reconnect. + /// + /// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in + /// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host + /// reconnects so the game can resume from the last known state. + /// Ignored for non-host reconnects and normal connections. + pub host_state: Option>, +} + +/// Error returned by [`GameSession::connect`]. +#[derive(Debug)] +pub enum ConnectError { + WebSocket(String), + Handshake(String), +} + +impl std::fmt::Display for ConnectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"), + ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"), + } + } +} + +// --------------------------------------------------------------------------- +// Internal message type (UI → background task) +// --------------------------------------------------------------------------- + +pub(crate) enum BackendMsg { + Action(A), + Disconnect, +} + +// --------------------------------------------------------------------------- +// Session event (background task → UI) +// --------------------------------------------------------------------------- + +/// Events emitted by the session to the UI. +pub enum SessionEvent { + /// A state update arrived from the host backend. + Update(ViewStateUpdate), + /// The session ended. `None` = clean disconnect, `Some(reason)` = error. + Disconnected(Option), +} + +// --------------------------------------------------------------------------- +// GameSession +// --------------------------------------------------------------------------- + +/// A connected game session. +/// +/// Created by [`GameSession::connect`]. Holds channels to the background task +/// that owns the WebSocket connection and (on host) the game backend. +pub struct GameSession { + /// The player ID assigned by the relay server. Always `0` for the host. + pub player_id: u16, + /// The game mode/variant selected by the host. + pub rule_variation: u16, + /// `true` if this client is hosting the game (runs the backend). + pub is_host: bool, + /// Token to persist in localStorage for reconnect on page refresh. + /// Only meaningful for non-host players (player_id > 0). + pub reconnect_token: u64, + action_tx: UnboundedSender>, + event_rx: UnboundedReceiver>, +} + +impl GameSession +where + A: SerializationCap + TaskBound, + D: SerializationCap + Clone + TaskBound, + VS: SerializationCap + Clone + TaskBound, +{ + /// Connects to the relay server and performs the handshake. + /// + /// Returns after the relay confirms the player ID and rule variation. + /// Spawns a background task that drives the WebSocket connection for the + /// lifetime of the session. + /// + /// # Errors + /// Returns `Err` if the WebSocket cannot be opened or the handshake fails. + pub async fn connect(config: RoomConfig) -> Result + where + Backend: BackEndArchitecture + TaskBound, + { + let create_room = matches!(config.role, RoomRole::Create); + + // 1. Open WebSocket. + let (mut ws_sender, ws_receiver) = + ewebsock::connect(&config.relay_url, ewebsock::Options::default()) + .map_err(|e| ConnectError::WebSocket(e.to_string()))?; + + // 2. Wait for the Opened event (WASM WebSocket is async). + loop { + match ws_receiver.try_recv() { + Some(WsEvent::Opened) => break, + Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)), + Some(WsEvent::Closed) => { + return Err(ConnectError::WebSocket("Connection closed".to_string())); + } + Some(_) => continue, + None => sleep_ms(1).await, + } + } + + // 3. Send the join request. + let req = JoinRequest { + game_id: config.game_id, + room_id: config.room_id, + rule_variation: config.rule_variation, + create_room, + reconnect_token: config.reconnect_token, + }; + send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?; + + // 4. Wait for the handshake response. + let (player_id, rule_variation, reconnect_token) = loop { + match ws_receiver.try_recv() { + Some(WsEvent::Message(WsMessage::Binary(data))) => { + break parse_handshake_response(data).map_err(ConnectError::Handshake)?; + } + Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)), + Some(WsEvent::Closed) => { + // The relay may have sent a binary error frame just before + // closing. ewebsock can deliver Closed before that frame, + // so drain one more message to catch it. + if let Some(WsEvent::Message(WsMessage::Binary(data))) = + ws_receiver.try_recv() + { + break parse_handshake_response(data) + .map_err(ConnectError::Handshake)?; + } + return Err(ConnectError::Handshake( + "Connection closed during handshake".to_string(), + )); + } + Some(_) => continue, + None => sleep_ms(1).await, + } + }; + + // The relay assigns player_id == 0 exclusively to the host. + let is_host = player_id == 0; + + // 5. Set up channels between the UI and the background task. + let (action_tx, action_rx) = mpsc::unbounded::>(); + let (event_tx, event_rx) = mpsc::unbounded::>(); + + // 6. Spawn the background event loop. + if is_host { + spawn_task(host_loop::( + ws_sender, + ws_receiver, + action_rx, + event_tx, + rule_variation, + config.host_state, + )); + } else { + spawn_task(client_loop::( + ws_sender, + ws_receiver, + action_rx, + event_tx, + )); + } + + Ok(GameSession { + player_id, + rule_variation, + is_host, + reconnect_token, + action_tx, + event_rx, + }) + } + + /// Sends a game action to the backend (fire-and-forget). + pub fn send_action(&self, action: A) { + self.action_tx + .unbounded_send(BackendMsg::Action(action)) + .ok(); + } + + /// Awaits the next session event. + /// + /// Returns `None` if the background task has exited (i.e. the session is + /// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))` + /// before the channel closes. + pub async fn next_event(&mut self) -> Option> { + self.event_rx.next().await + } + + /// Signals the background task to send a graceful disconnect message and + /// shut down. Consumes the session. + pub fn disconnect(self) { + self.action_tx + .unbounded_send(BackendMsg::Disconnect) + .ok(); + } +} diff --git a/clients/backbone-lib/src/traits.rs b/clients/backbone-lib/src/traits.rs new file mode 100644 index 0000000..1ec50f7 --- /dev/null +++ b/clients/backbone-lib/src/traits.rs @@ -0,0 +1,97 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// Marker trait for types that can be serialized with postcard. +pub trait SerializationCap: Serialize + DeserializeOwned {} +impl SerializationCap for T where T: Serialize + DeserializeOwned {} + +/// State updates delivered to the frontend for rendering. +/// +/// - [`Full`](Self::Full): Immediately set all visual state, no animation. +/// - [`Incremental`](Self::Incremental): Apply with animation/transition. +pub enum ViewStateUpdate { + /// Complete game state snapshot. Received on join or after a reset. + Full(ViewState), + /// Incremental state change for animated transitions. + Incremental(DeltaInformation), +} + +/// Commands emitted by the game backend to control the session. +pub enum BackendCommand +where + DeltaInformation: SerializationCap, +{ + /// Incremental state change to be broadcast to all frontends. + Delta(DeltaInformation), + + /// Signals a complete reset: discard queued deltas, broadcast fresh full state. + ResetViewState, + + /// Forcibly removes a player from the session. + KickPlayer { player: u16 }, + + /// Schedules a callback after `duration` seconds. Overwrites any existing + /// timer with the same `timer_id`. + SetTimer { timer_id: u16, duration: f32 }, + + /// Cancels a previously scheduled timer. No-op if already fired or not set. + CancelTimer { timer_id: u16 }, + + /// Shuts down the entire room and disconnects all players. + TerminateRoom, +} + +/// The contract for game-specific server logic. +/// +/// Implement this on the host side. The session calls these methods in response +/// to network events and drives `drain_commands` to collect outbound messages. +/// +/// # Type Parameters +/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`) +/// * `DeltaInformation` — Incremental state changes for animations +/// * `ViewState` — Complete game snapshot for syncing new clients +pub trait BackEndArchitecture +where + ServerRpcPayload: SerializationCap, + DeltaInformation: SerializationCap, + ViewState: SerializationCap + Clone, +{ + /// Creates a new game instance. `rule_variation` selects the game mode. + fn new(rule_variation: u16) -> Self; + + /// Attempt to restore a previously running game from serialized bytes. + /// + /// Called when the host reconnects after a page refresh. The bytes are the + /// game-specific snapshot produced by the app layer (via `serde_json` or + /// similar) and stored in localStorage. + /// + /// Return `None` if restoration is not supported or the bytes are invalid — + /// the caller falls back to `new(rule_variation)`. + fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option + where + Self: Sized, + { + None + } + + /// Called when a player connects. Player will receive a full state snapshot + /// automatically after this returns. + fn player_arrival(&mut self, player: u16); + + /// Called when a player disconnects. + fn player_departure(&mut self, player: u16); + + /// Called when a player sends a game action. + fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload); + + /// Called when a previously scheduled timer fires. + fn timer_triggered(&mut self, timer_id: u16); + + /// Returns the complete current game state. + fn get_view_state(&self) -> &ViewState; + + /// Collects and clears all pending commands since the last drain. + /// + /// Implement with `std::mem::take(&mut self.command_list)`. + fn drain_commands(&mut self) -> Vec>; +} diff --git a/client_cli/Cargo.toml b/clients/cli/Cargo.toml similarity index 71% rename from client_cli/Cargo.toml rename to clients/cli/Cargo.toml index 52318cb..0149b1b 100644 --- a/client_cli/Cargo.toml +++ b/clients/cli/Cargo.toml @@ -13,9 +13,9 @@ bincode = "1.3.3" pico-args = "0.5.0" pretty_assertions = "1.4.0" renet = "0.0.13" -trictrac-store = { path = "../store" } -trictrac-bot = { path = "../bot" } -spiel_bot = { path = "../spiel_bot" } +trictrac-store = { path = "../../store" } +trictrac-bot = { path = "../../bot" } +spiel_bot = { path = "../../spiel_bot" } itertools = "0.13.0" env_logger = "0.11.6" log = "0.4.20" diff --git a/client_cli/src/app.rs b/clients/cli/src/app.rs similarity index 100% rename from client_cli/src/app.rs rename to clients/cli/src/app.rs diff --git a/client_cli/src/game_runner.rs b/clients/cli/src/game_runner.rs similarity index 100% rename from client_cli/src/game_runner.rs rename to clients/cli/src/game_runner.rs diff --git a/client_cli/src/main.rs b/clients/cli/src/main.rs similarity index 100% rename from client_cli/src/main.rs rename to clients/cli/src/main.rs diff --git a/client_web/Cargo.toml b/clients/web-game/Cargo.toml similarity index 86% rename from client_web/Cargo.toml rename to clients/web-game/Cargo.toml index 10d91b8..578be7c 100644 --- a/client_web/Cargo.toml +++ b/clients/web-game/Cargo.toml @@ -9,8 +9,8 @@ locales = ["en", "fr"] [dependencies] leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] } -trictrac-store = { path = "../store" } -backbone-lib = { path = "../../forks/multiplayer/backbone-lib" } +trictrac-store = { path = "../../store" } +backbone-lib = { path = "../backbone-lib" } leptos = { version = "0.7", features = ["csr"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" @@ -20,11 +20,13 @@ gloo-storage = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" +gloo-net = { version = "0.5", features = ["http"] } gloo-timers = { version = "0.3", features = ["futures"] } # getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # Must be a direct dependency (not just transitive) for the feature to take effect. getrandom = { version = "0.3", features = ["wasm_js"] } web-sys = { version = "0.3", features = [ + "RequestCredentials", "AudioContext", "AudioParam", "AudioNode", diff --git a/clients/web-game/Trunk.toml b/clients/web-game/Trunk.toml new file mode 100644 index 0000000..bae5297 --- /dev/null +++ b/clients/web-game/Trunk.toml @@ -0,0 +1,2 @@ +[serve] +port = 9091 diff --git a/client_web/assets/diceroll.mp3 b/clients/web-game/assets/diceroll.mp3 similarity index 100% rename from client_web/assets/diceroll.mp3 rename to clients/web-game/assets/diceroll.mp3 diff --git a/client_web/assets/style.css b/clients/web-game/assets/style.css similarity index 98% rename from client_web/assets/style.css rename to clients/web-game/assets/style.css index 3034f38..341be19 100644 --- a/client_web/assets/style.css +++ b/clients/web-game/assets/style.css @@ -1194,3 +1194,20 @@ body { color: var(--ui-red-accent); font-style: italic; } + + +.auth-badge { + font-size: 0.8rem; + text-align: center; + padding: 0.35rem 0.6rem; + border-radius: 5px; +} +.auth-badge--in { background: rgba(96,165,250,0.15); color: #93c5fd; } +.auth-badge--out { background: rgba(148,163,184,0.1); color: #64748b; } +.auth-badge a { color: #60a5fa; } + +.playing-as { + font-size: 0.8rem; + color: #64748b; + text-align: center; +} diff --git a/client_web/index.html b/clients/web-game/index.html similarity index 100% rename from client_web/index.html rename to clients/web-game/index.html diff --git a/client_web/locales/en.json b/clients/web-game/locales/en.json similarity index 100% rename from client_web/locales/en.json rename to clients/web-game/locales/en.json diff --git a/client_web/locales/fr.json b/clients/web-game/locales/fr.json similarity index 100% rename from client_web/locales/fr.json rename to clients/web-game/locales/fr.json diff --git a/client_web/src/app.rs b/clients/web-game/src/app.rs similarity index 90% rename from client_web/src/app.rs rename to clients/web-game/src/app.rs index 196a43a..ce355f4 100644 --- a/client_web/src/app.rs +++ b/clients/web-game/src/app.rs @@ -19,10 +19,17 @@ use trictrac_store::CheckerMove; use std::collections::VecDeque; -const RELAY_URL: &str = "ws://127.0.0.1:8080/ws"; +const RELAY_URL: &str = "ws://localhost:8080/ws"; const GAME_ID: &str = "trictrac"; const STORAGE_KEY: &str = "trictrac_session"; +// In debug builds trunk serves on 9091, relay is on 8080. +// In release the game is served by the relay itself — use relative paths. +#[cfg(debug_assertions)] +const HTTP_BASE: &str = "http://localhost:8080"; +#[cfg(not(debug_assertions))] +const HTTP_BASE: &str = ""; + /// The state the UI needs to render the game screen. #[derive(Clone, PartialEq)] pub struct GameUiState { @@ -93,6 +100,11 @@ struct StoredSession { view_state: Option, } +#[derive(Deserialize)] +struct MeResponse { + username: String, +} + fn save_session(session: &StoredSession) { LocalStorage::set(STORAGE_KEY, session).ok(); } @@ -105,6 +117,31 @@ fn clear_session() { LocalStorage::delete(STORAGE_KEY); } +/// Fire-and-forget: tell the relay server who won. Only called by the host. +async fn submit_game_result(room_code: String, game_state: ViewState) { + let [score_pl1, score_pl2] = game_state.scores; + let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes); + let outcomes = if score_pl1.holes < score_pl2.holes { + [("0", "loss"), ("1", "win")] + } else if score_pl2.holes < score_pl1.holes { + [("0", "win"), ("1", "loss")] + } else { + [("0", "draw"), ("1", "draw")] + }; + let body = serde_json::json!({ + "room_code": room_code, + "game_id": GAME_ID, + "result": result_str, + "outcomes": std::collections::HashMap::from(outcomes), + }); + let _ = gloo_net::http::Request::post(&format!("{HTTP_BASE}/games/result")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .unwrap() + .send() + .await; +} + #[component] pub fn App() -> impl IntoView { let stored = load_session(); @@ -115,6 +152,23 @@ pub fn App() -> impl IntoView { }; let screen = RwSignal::new(initial_screen); + // Auth: fetch once and expose to all child components via context. + let auth_username: RwSignal> = RwSignal::new(None); + provide_context(auth_username); + spawn_local(async move { + if let Ok(resp) = gloo_net::http::Request::get(&format!("{HTTP_BASE}/auth/me")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + { + if resp.status() == 200 { + if let Ok(me) = resp.json::().await { + auth_username.set(Some(me.username)); + } + } + } + }); + let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); let pending: RwSignal> = RwSignal::new(VecDeque::new()); provide_context(pending); @@ -238,6 +292,7 @@ pub fn App() -> impl IntoView { let player_id = session.player_id; let reconnect_token = session.reconnect_token; let mut vs = ViewState::default_with_names("Blancs", "Noirs"); + let mut result_submitted = false; loop { futures::select! { @@ -260,6 +315,15 @@ pub fn App() -> impl IntoView { ViewStateUpdate::Full(state) => vs = state, ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta), } + + // Host reports outcomes once per terminal game state. + if is_host && !result_submitted && vs.stage == SerStage::Ended { + result_submitted = true; + let room = room_id_for_storage.clone(); + let gs = vs.clone(); + spawn_local(submit_game_result(room, gs)); + } + if is_host { save_session(&StoredSession { relay_url: RELAY_URL.to_string(), @@ -423,7 +487,11 @@ async fn run_local_bot_game( /// Returns the checker moves to animate when the board changed between two ViewStates. /// Returns `None` when the board is unchanged or no real moves were recorded. /// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2. -fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Option<(CheckerMove, CheckerMove)> { +fn compute_last_moves( + prev: &ViewState, + next: &ViewState, + own_move: bool, +) -> Option<(CheckerMove, CheckerMove)> { if prev.board == next.board { return None; } @@ -436,7 +504,9 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Opt } if own_move { // m1 was already shown via the staged-moves overlay; only animate m2. - if m2 == CheckerMove::default() { return None; } + if m2 == CheckerMove::default() { + return None; + } return Some((m2, CheckerMove::default())); } Some((m1, m2)) diff --git a/client_web/src/components/board.rs b/clients/web-game/src/components/board.rs similarity index 100% rename from client_web/src/components/board.rs rename to clients/web-game/src/components/board.rs diff --git a/client_web/src/components/connecting_screen.rs b/clients/web-game/src/components/connecting_screen.rs similarity index 100% rename from client_web/src/components/connecting_screen.rs rename to clients/web-game/src/components/connecting_screen.rs diff --git a/client_web/src/components/die.rs b/clients/web-game/src/components/die.rs similarity index 100% rename from client_web/src/components/die.rs rename to clients/web-game/src/components/die.rs diff --git a/client_web/src/components/game_screen.rs b/clients/web-game/src/components/game_screen.rs similarity index 98% rename from client_web/src/components/game_screen.rs rename to clients/web-game/src/components/game_screen.rs index 909e266..2493680 100644 --- a/client_web/src/components/game_screen.rs +++ b/clients/web-game/src/components/game_screen.rs @@ -18,6 +18,8 @@ use super::scoring::ScoringPanel; pub fn GameScreen(state: GameUiState) -> impl IntoView { let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username not found in context"); let vs = state.view_state.clone(); let player_id = state.player_id; let is_my_turn = vs.active_mp_player == Some(player_id); @@ -240,6 +242,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { on:click=move |_| i18n.set_locale(Locale::fr) >"FR" + + {move || auth_username.get().map(|u| view! { +

"Playing as " {u}

+ })} +
) -> impl IntoView { let i18n = use_i18n(); @@ -11,6 +16,8 @@ pub fn LoginScreen(error: Option) -> impl IntoView { let cmd_tx = use_context::>() .expect("UnboundedSender not found in context"); + let auth_username = + use_context::>>().expect("auth_username not found in context"); let cmd_tx_create = cmd_tx.clone(); let cmd_tx_join = cmd_tx.clone(); @@ -47,6 +54,19 @@ pub fn LoginScreen(error: Option) -> impl IntoView { {error.map(|err| view! {

{err}

})} + // Auth status badge + {move || match auth_username.get() { + Some(u) => view! { +

"✓ Logged in as " {u}

+ }.into_any(), + None => view! { +

+ "Not logged in — games won't be tracked. " + "Create account" +

+ }.into_any(), + }} + + + + + + Player Portal + + + + + diff --git a/clients/web-user-portal/src/api.rs b/clients/web-user-portal/src/api.rs new file mode 100644 index 0000000..b6dced9 --- /dev/null +++ b/clients/web-user-portal/src/api.rs @@ -0,0 +1,191 @@ +use serde::{Deserialize, Serialize}; + +// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL. +// In release builds the portal is served by the relay itself — use relative paths. +#[cfg(debug_assertions)] +const BASE: &str = "http://localhost:8080"; +#[cfg(not(debug_assertions))] +const BASE: &str = ""; + +fn url(path: &str) -> String { + format!("{BASE}{path}") +} + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, Deserialize)] +pub struct MeResponse { + pub id: i64, + pub username: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UserProfile { + pub id: i64, + pub username: String, + pub created_at: i64, + pub total_games: i64, + pub wins: i64, + pub losses: i64, + pub draws: i64, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GameSummary { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub outcome: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GamesResponse { + pub games: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Participant { + pub player_id: i64, + pub outcome: Option, + pub username: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GameDetail { + pub id: i64, + pub game_id: String, + pub room_code: String, + pub started_at: i64, + pub ended_at: Option, + pub result: Option, + pub participants: Vec, +} + +// ── Request bodies ──────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct RegisterBody<'a> { + pub username: &'a str, + pub email: &'a str, + pub password: &'a str, +} + +#[derive(Serialize)] +pub struct LoginBody<'a> { + pub username: &'a str, + pub password: &'a str, +} + +// ── Fetch helpers ───────────────────────────────────────────────────────────── + +pub async fn get_me() -> Result { + let resp = gloo_net::http::Request::get(&url("/auth/me")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn post_login(username: &str, password: &str) -> Result { + let body = LoginBody { username, password }; + let resp = gloo_net::http::Request::post(&url("/auth/login")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_register(username: &str, email: &str, password: &str) -> Result { + let body = RegisterBody { username, email, password }; + let resp = gloo_net::http::Request::post(&url("/auth/register")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 201 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + let text = resp.text().await.unwrap_or_default(); + Err(text) + } +} + +pub async fn post_logout() -> Result<(), String> { + let resp = gloo_net::http::Request::post(&url("/auth/logout")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 204 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_profile(username: &str) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_games(username: &str, page: i64) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}/games?page={page}&per_page=20"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_game_detail(id: i64) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}"))) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +pub fn format_ts(ts: i64) -> String { + let ms = (ts * 1000) as f64; + let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms)); + date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED) + .as_string() + .unwrap_or_default() +} diff --git a/clients/web-user-portal/src/app.rs b/clients/web-user-portal/src/app.rs new file mode 100644 index 0000000..92a121a --- /dev/null +++ b/clients/web-user-portal/src/app.rs @@ -0,0 +1,67 @@ +use leptos::prelude::*; +use leptos_router::{components::{Route, Router, Routes, A}, path}; + +use crate::api::{self, MeResponse}; +use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage}; + +#[derive(Clone, Debug)] +pub struct AuthState { + pub user: RwSignal>, +} + +#[component] +pub fn App() -> impl IntoView { + let user = RwSignal::new(None::); + provide_context(AuthState { user }); + + // Probe session on load. + let auth = use_context::().unwrap(); + let _ = LocalResource::new(move || async move { + if let Ok(me) = api::get_me().await { + auth.user.set(Some(me)); + } + }); + + view! { + +