From 4ba77630c8383d564ac9e5f7395f10787538c7c8 Mon Sep 17 00:00:00 2001 From: emilis Date: Mon, 23 Jun 2025 09:48:28 +0100 Subject: [PATCH] initial commit at a basic partial working state --- .gitignore | 5 + Cargo.lock | 2876 +++++++++++++++++ Cargo.toml | 8 + werewolves-macros/Cargo.toml | 13 + werewolves-macros/src/checks.rs | 321 ++ werewolves-macros/src/hashlist.rs | 36 + werewolves-macros/src/lib.rs | 545 ++++ werewolves-macros/src/targets.rs | 305 ++ werewolves-proto/Cargo.toml | 17 + werewolves-proto/src/diedto.rs | 66 + werewolves-proto/src/error.rs | 74 + werewolves-proto/src/game/mod.rs | 298 ++ werewolves-proto/src/game/night.rs | 964 ++++++ werewolves-proto/src/game/settings.rs | 127 + werewolves-proto/src/game/village.rs | 248 ++ werewolves-proto/src/lib.rs | 30 + werewolves-proto/src/message.rs | 79 + werewolves-proto/src/message/host.rs | 74 + werewolves-proto/src/message/ident.rs | 75 + werewolves-proto/src/message/night.rs | 133 + werewolves-proto/src/modifier.rs | 7 + werewolves-proto/src/nonzero.rs | 18 + werewolves-proto/src/player.rs | 301 ++ werewolves-proto/src/role.rs | 183 ++ werewolves-server/Cargo.toml | 29 + werewolves-server/pkg/blog.service | 19 + werewolves-server/src/client.rs | 257 ++ werewolves-server/src/communication/host.rs | 42 + werewolves-server/src/communication/lobby.rs | 47 + werewolves-server/src/communication/mod.rs | 44 + werewolves-server/src/communication/player.rs | 50 + werewolves-server/src/connection.rs | 231 ++ werewolves-server/src/game.rs | 324 ++ werewolves-server/src/host.rs | 149 + werewolves-server/src/lobby.rs | 345 ++ werewolves-server/src/main.rs | 267 ++ werewolves-server/src/runner.rs | 98 + werewolves-server/src/saver.rs | 49 + werewolves/.cargo/config.toml | 2 + werewolves/Cargo.lock | 1572 +++++++++ werewolves/Cargo.toml | 42 + werewolves/Trunk.toml | 13 + werewolves/img/icon.png | Bin 0 -> 1168 bytes werewolves/index.html | 27 + werewolves/index.scss | 1018 ++++++ werewolves/src/app.rs | 19 + werewolves/src/assets.rs | 1 + werewolves/src/callback.rs | 45 + werewolves/src/components/action/prompt.rs | 109 + werewolves/src/components/action/result.rs | 96 + werewolves/src/components/action/target.rs | 112 + werewolves/src/components/action/wolves.rs | 42 + werewolves/src/components/button.rs | 28 + werewolves/src/components/cover.rs | 34 + werewolves/src/components/host/mod.rs | 107 + werewolves/src/components/identity.rs | 31 + werewolves/src/components/input_name.rs | 73 + werewolves/src/components/lobby.rs | 30 + werewolves/src/components/lobby_player.rs | 50 + werewolves/src/components/notification.rs | 19 + werewolves/src/components/reveal.rs | 86 + werewolves/src/components/settings.rs | 169 + werewolves/src/main.rs | 106 + werewolves/src/pages/client.rs | 534 +++ werewolves/src/pages/error.rs | 60 + werewolves/src/pages/host.rs | 566 ++++ werewolves/src/storage.rs | 43 + 67 files changed, 13788 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 werewolves-macros/Cargo.toml create mode 100644 werewolves-macros/src/checks.rs create mode 100644 werewolves-macros/src/hashlist.rs create mode 100644 werewolves-macros/src/lib.rs create mode 100644 werewolves-macros/src/targets.rs create mode 100644 werewolves-proto/Cargo.toml create mode 100644 werewolves-proto/src/diedto.rs create mode 100644 werewolves-proto/src/error.rs create mode 100644 werewolves-proto/src/game/mod.rs create mode 100644 werewolves-proto/src/game/night.rs create mode 100644 werewolves-proto/src/game/settings.rs create mode 100644 werewolves-proto/src/game/village.rs create mode 100644 werewolves-proto/src/lib.rs create mode 100644 werewolves-proto/src/message.rs create mode 100644 werewolves-proto/src/message/host.rs create mode 100644 werewolves-proto/src/message/ident.rs create mode 100644 werewolves-proto/src/message/night.rs create mode 100644 werewolves-proto/src/modifier.rs create mode 100644 werewolves-proto/src/nonzero.rs create mode 100644 werewolves-proto/src/player.rs create mode 100644 werewolves-proto/src/role.rs create mode 100644 werewolves-server/Cargo.toml create mode 100644 werewolves-server/pkg/blog.service create mode 100644 werewolves-server/src/client.rs create mode 100644 werewolves-server/src/communication/host.rs create mode 100644 werewolves-server/src/communication/lobby.rs create mode 100644 werewolves-server/src/communication/mod.rs create mode 100644 werewolves-server/src/communication/player.rs create mode 100644 werewolves-server/src/connection.rs create mode 100644 werewolves-server/src/game.rs create mode 100644 werewolves-server/src/host.rs create mode 100644 werewolves-server/src/lobby.rs create mode 100644 werewolves-server/src/main.rs create mode 100644 werewolves-server/src/runner.rs create mode 100644 werewolves-server/src/saver.rs create mode 100644 werewolves/.cargo/config.toml create mode 100644 werewolves/Cargo.lock create mode 100644 werewolves/Cargo.toml create mode 100644 werewolves/Trunk.toml create mode 100644 werewolves/img/icon.png create mode 100644 werewolves/index.html create mode 100644 werewolves/index.scss create mode 100644 werewolves/src/app.rs create mode 100644 werewolves/src/assets.rs create mode 100644 werewolves/src/callback.rs create mode 100644 werewolves/src/components/action/prompt.rs create mode 100644 werewolves/src/components/action/result.rs create mode 100644 werewolves/src/components/action/target.rs create mode 100644 werewolves/src/components/action/wolves.rs create mode 100644 werewolves/src/components/button.rs create mode 100644 werewolves/src/components/cover.rs create mode 100644 werewolves/src/components/host/mod.rs create mode 100644 werewolves/src/components/identity.rs create mode 100644 werewolves/src/components/input_name.rs create mode 100644 werewolves/src/components/lobby.rs create mode 100644 werewolves/src/components/lobby_player.rs create mode 100644 werewolves/src/components/notification.rs create mode 100644 werewolves/src/components/reveal.rs create mode 100644 werewolves/src/components/settings.rs create mode 100644 werewolves/src/main.rs create mode 100644 werewolves/src/pages/client.rs create mode 100644 werewolves/src/pages/error.rs create mode 100644 werewolves/src/pages/host.rs create mode 100644 werewolves/src/storage.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a440ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/werewolves/dist/ +/target/ +.vscode +build-and-send.fish +werewolves-saves/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3cb0498 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2876 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "atom_syndication" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3" +dependencies = [ + "chrono", + "derive_builder", + "diligent-date-parser", + "never", + "quick-xml", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.1.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.100", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "data-encoding" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.100", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "diligent-date-parser" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9" +dependencies = [ + "chrono", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.5.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.5.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "futures-channel", + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom 0.2.15", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "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-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http 1.3.1", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.3.1", +] + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[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.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +dependencies = [ + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.100", +] + +[[package]] +name = "indexmap" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime-sniffer" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b8b2a64cd735f1d5f17ff6701ced3cc3c54851f9448caf454cd9c923d812408" +dependencies = [ + "mime", + "url", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "never" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.9", + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror 1.0.69", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "prettyplease" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb" +dependencies = [ + "proc-macro2", + "syn 2.0.100", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "quick-xml" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ce8c88de324ff838700f36fb6ab86c96df0e3c4ab6ef3a9b2044465cce1369" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha", + "rand_core", + "zerocopy", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.2", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +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.100", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-logger" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "werewolves" +version = "0.1.0" +dependencies = [ + "chrono", + "ciborium", + "convert_case", + "futures", + "getrandom 0.3.2", + "gloo 0.11.0", + "instant", + "log", + "once_cell", + "rand", + "serde", + "serde_json", + "thiserror 2.0.12", + "uuid", + "wasm-bindgen-futures", + "wasm-logger", + "web-sys", + "werewolves-macros", + "werewolves-proto", + "yew", + "yew-router", +] + +[[package]] +name = "werewolves-macros" +version = "0.1.0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "werewolves-proto" +version = "0.1.0" +dependencies = [ + "log", + "pretty_assertions", + "rand", + "serde", + "serde_json", + "thiserror 2.0.12", + "uuid", + "werewolves-macros", +] + +[[package]] +name = "werewolves-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "atom_syndication", + "axum", + "axum-extra", + "chrono", + "ciborium", + "colored", + "futures", + "log", + "mime-sniffer", + "pretty_env_logger", + "rand", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "werewolves-macros", + "werewolves-proto", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.1", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link 0.1.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link 0.1.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +dependencies = [ + "windows-link 0.2.0", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "yew-router" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" +dependencies = [ + "gloo 0.10.0", + "js-sys", + "route-recognizer", + "serde", + "serde_urlencoded", + "tracing", + "urlencoding", + "wasm-bindgen", + "web-sys", + "yew", + "yew-router-macro", +] + +[[package]] +name = "yew-router-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..091b37d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "3" +members = [ + "werewolves", + "werewolves-macros", + "werewolves-proto", + "werewolves-server", +] diff --git a/werewolves-macros/Cargo.toml b/werewolves-macros/Cargo.toml new file mode 100644 index 0000000..6545f89 --- /dev/null +++ b/werewolves-macros/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "werewolves-macros" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } +convert_case = { version = "0.8" } diff --git a/werewolves-macros/src/checks.rs b/werewolves-macros/src/checks.rs new file mode 100644 index 0000000..d096fe9 --- /dev/null +++ b/werewolves-macros/src/checks.rs @@ -0,0 +1,321 @@ +use core::hash::Hash; +use std::collections::HashMap; + +use convert_case::{Case, Casing}; +use quote::{ToTokens, quote, quote_spanned}; +use syn::{parenthesized, parse::Parse, spanned::Spanned}; + +use crate::hashlist::HashList; + +#[derive(Debug)] +pub struct ChecksAs { + name: syn::Ident, + checks_as: HashList, + total_fields: usize, +} + +// impl HashList { +// pub fn add_spanned(&mut self, key: ChecksAsArg, value: V) { +// if let Some(orig_key) = self.0.keys().find(|k| **k == key).cloned() { +// let new_span = key +// .span() +// .join(orig_key.span()) +// .unwrap_or(key.span().located_at(orig_key.span())); +// let mut vals = self.0.remove(&key).unwrap(); +// vals.push(value); +// self.0.insert(key.with_span(new_span), vals); +// return; +// } +// self.0.insert(key, vec![value]); +// } +// } + +#[derive(Debug, Clone)] +enum ChecksAsArg { + AsSelf(proc_macro2::Span), + Name(syn::Ident), + Path(syn::Path), +} + +impl PartialEq for ChecksAsArg { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::AsSelf(_), Self::AsSelf(_)) => true, + (Self::Name(l0), Self::Name(r0)) => l0 == r0, + (Self::Path(l0), Self::Path(r0)) => l0 == r0, + _ => false, + } + } +} + +impl Eq for ChecksAsArg {} + +impl Hash for ChecksAsArg { + fn hash(&self, state: &mut H) { + #[derive(Hash)] + enum ChecksAsArgHash<'a> { + AsSelf, + Name(&'a syn::Ident), + Path(&'a syn::Path), + } + core::mem::discriminant(&match self { + ChecksAsArg::AsSelf(_) => ChecksAsArgHash::AsSelf, + ChecksAsArg::Name(ident) => ChecksAsArgHash::Name(ident), + ChecksAsArg::Path(path) => ChecksAsArgHash::Path(path), + }) + .hash(state); + } +} + +impl ChecksAsArg { + fn span(&self) -> proc_macro2::Span { + match self { + ChecksAsArg::AsSelf(span) => *span, + ChecksAsArg::Name(ident) => ident.span(), + ChecksAsArg::Path(path) => path.span(), + } + } + + fn with_span(&self, span: proc_macro2::Span) -> Self { + match self { + ChecksAsArg::AsSelf(_) => ChecksAsArg::AsSelf(span), + ChecksAsArg::Name(ident) => { + ChecksAsArg::Name(syn::Ident::new(ident.to_string().as_str(), span)) + } + ChecksAsArg::Path(path) => ChecksAsArg::Path(syn::Path { + leading_colon: path.leading_colon, + segments: path + .segments + .iter() + .cloned() + .map(|c| syn::PathSegment { + ident: syn::Ident::new(c.ident.to_string().as_str(), span), + arguments: c.arguments, + }) + .collect(), + }), + } + } +} + +enum PathOrName { + Path(syn::Path), + Name(syn::LitStr), +} + +impl Parse for PathOrName { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + if input.peek(syn::LitStr) { + Ok(Self::Name(input.parse()?)) + } else { + Ok(Self::Path(input.parse()?)) + } + } +} + +impl ChecksAsArg { + fn from_attr(attr: &syn::Attribute) -> syn::Result { + match &attr.meta { + syn::Meta::Path(path) => { + path.require_ident()?; + Ok(Self::AsSelf(path.span())) + } + syn::Meta::NameValue(v) => Err(syn::Error::new( + attr.meta.span(), + format!("meta_name_value: {v:?}").as_str(), + )), + syn::Meta::List(list) => match list.parse_args::()? { + PathOrName::Path(path) => Ok(Self::Path(path)), + PathOrName::Name(lit_str) => Ok(Self::Name(syn::Ident::new( + lit_str.value().as_str(), + lit_str.span(), + ))), + }, + } + } + + fn filter(attr: &&syn::Attribute) -> bool { + attr.path() + .get_ident() + .map(|id| id.to_string().as_str() == CHECKS_AS_PATH) + .unwrap_or_default() + } +} + +const CHECKS_AS_PATH: &str = "checks"; + +impl ChecksAs { + pub fn parse(input: syn::DeriveInput) -> Result { + let name = input.ident; + let mut checks_as = HashList::new(); + let total_fields; + match &input.data { + syn::Data::Enum(data) => { + total_fields = data.variants.len(); + for field in data.variants.iter() { + for attr in field + .attrs + .iter() + .filter(ChecksAsArg::filter) + .map(ChecksAsArg::from_attr) + { + let attr = attr?; + let mut field = field.clone(); + field.ident.set_span(attr.span()); + + checks_as.add(attr, field); + } + } + } + + _ => todo!(), + }; + + Ok(Self { + name, + checks_as, + total_fields, + }) + } +} + +fn fields_args(field: &syn::Variant) -> proc_macro2::TokenStream { + match &field.fields { + syn::Fields::Named(f) => { + let named = f.named.iter().map(|f| f.ident.clone()); + quote! { {#(#named: _),*} } + } + syn::Fields::Unnamed(f) => { + let f = f.unnamed.iter().map(|_| quote! {_}); + quote! { + (#(#f),*) + } + } + syn::Fields::Unit => quote! {}, + } +} + +impl ToTokens for ChecksAs { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + // panic!("{self:?}"); + let name = self.name.clone(); + let (path, non_path) = self + .checks_as + .clone() + .decompose() + .into_iter() + .partition::, _>(|(c, _)| matches!(c, ChecksAsArg::Path(_))); + for (check, fields) in non_path { + match check { + ChecksAsArg::AsSelf(_) => { + let fields = fields.into_iter().map(|f| { + let fn_name = syn::Ident::new( + format!("is_{}", f.ident.to_string().to_case(Case::Snake)).as_str(), + f.ident.span(), + ); + let ident = f.ident.clone(); + let args = fields_args(&f); + quote_spanned! { f.span() => + pub const fn #fn_name(&self) -> bool { + matches!(self, Self::#ident #args) + } + } + }); + tokens.extend(quote! { + impl #name { + #(#fields)* + } + }); + } + ChecksAsArg::Name(ident) => { + let fields = fields.into_iter().map(|f| { + let args = fields_args(&f); + let ident = f.ident.clone(); + + quote_spanned! {f.span() => + #name::#ident #args + } + }); + tokens.extend(quote! { + impl #name { + pub const fn #ident(&self) -> bool { + matches!(self, #(#fields)|*) + } + } + }); + } + ChecksAsArg::Path(_) => unreachable!(), + } + } + + let mut by_path_start = HashList::new(); + let mut tmp = path.iter(); + while let Some((ChecksAsArg::Path(path), fields)) = tmp.next() { + let start = path.segments.iter().next().unwrap().clone(); + by_path_start.add(start, (path.clone(), fields)); + } + let by_path = by_path_start.decompose(); + + for (start, paths) in by_path { + let mut unique_count = HashMap::new(); + let mut total_count = 0usize; + paths.iter().for_each(|(_, variants)| { + variants.iter().for_each(|v| { + unique_count.insert(v, ()); + total_count += 1; + }) + }); + let unique_variants = unique_count.keys().count(); + if unique_variants != total_count { + panic!("some fields have multiple ChecksAs values for the same type") + } + // nowadays everywhere i look i see patterns + let patterns = paths.into_iter().map(|(val, variants)| { + let idents = variants.iter().map(|v| { + let args = fields_args(v); + let ident = v.ident.clone(); + quote! { + #ident #args + } + }); + quote! { + #(#name::#idents)|* => #val, + } + }); + + let fn_name = syn::Ident::new( + start.ident.to_string().to_case(Case::Snake).as_str(), + start.span(), + ); + + let (ret, match_statement) = if unique_variants != self.total_fields { + ( + quote! {Option<#start>}, + quote! { + Some(match self { + #(#patterns)* + _ => return None, + }) + }, + ) + } else { + ( + quote! {#start}, + quote! { + match self { + #(#patterns)* + } + }, + ) + }; + + tokens.extend(quote! { + impl #name { + pub const fn #fn_name(&self) -> #ret { + #match_statement + } + } + }); + } + } +} diff --git a/werewolves-macros/src/hashlist.rs b/werewolves-macros/src/hashlist.rs new file mode 100644 index 0000000..7a0c72a --- /dev/null +++ b/werewolves-macros/src/hashlist.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; +use std::hash::Hash; + +pub struct HashList(HashMap>); + +impl core::fmt::Debug for HashList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("HashList").field(&self.0).finish() + } +} + +impl Clone for HashList { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl HashList { + pub fn new() -> Self { + Self(HashMap::new()) + } + + pub fn add(&mut self, key: K, value: V) { + match self.0.get_mut(&key) { + Some(values) => values.push(value), + None => { + self.0.insert(key, vec![value]); + } + } + } + pub fn decompose(&mut self) -> Box<[(K, Vec)]> { + let mut body = HashMap::new(); + core::mem::swap(&mut self.0, &mut body); + body.into_iter().collect() + } +} diff --git a/werewolves-macros/src/lib.rs b/werewolves-macros/src/lib.rs new file mode 100644 index 0000000..34ea540 --- /dev/null +++ b/werewolves-macros/src/lib.rs @@ -0,0 +1,545 @@ +use core::error::Error; +use std::{ + io, + path::{Path, PathBuf}, +}; + +use convert_case::Casing; +use proc_macro2::Span; +use quote::{ToTokens, quote}; +use syn::{braced, bracketed, parenthesized, parse::Parse, parse_macro_input}; + +mod checks; +pub(crate) mod hashlist; +mod targets; + +struct IncludePath { + dir_path: PathBuf, + modules: Vec, +} + +fn display_err(span: Span, err: impl Error) -> syn::Error { + syn::Error::new(span, err.to_string().as_str()) +} + +fn read_modules(span: Span, path: &Path) -> syn::Result> { + let read_dir = std::fs::read_dir(path).map_err(|err| display_err(span, err))?; + + Ok(read_dir + .map(|file| file.map_err(|err| display_err(span, err))) + .collect::, _>>()? + .into_iter() + .filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default()) + .filter_map(|file| { + file.file_name() + .to_string_lossy() + .strip_suffix(".rs") + .map(|f| f.to_string()) + }) + .map(|module_name| syn::Ident::new(&module_name, span)) + .collect::>()) +} + +impl Parse for IncludePath { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let directory = input.parse::()?; + + let dir_path = std::env::current_dir() + .unwrap() + .join(directory.value()) + .to_path_buf(); + + let modules = read_modules(directory.span(), &dir_path)?; + + Ok(Self { modules, dir_path }) + } +} +impl IncludePath { + fn posts_tokens(&self) -> proc_macro2::TokenStream { + let mod_decl = self.modules.iter().map(|module| { + let path = self.dir_path.join(format!("{module}.rs")); + let path = path.to_str().unwrap(); + quote! { + mod #module { + include!(#path); + }} + }); + let posts = self.modules.iter().map(|module| { + quote! { + #module::POST.into_post() + } + }); + + quote! { + #(#mod_decl)* + + pub const POSTS: &[Post] = &[#(#posts),*]; + } + } + fn normal_tokens(&self) -> proc_macro2::TokenStream { + let mod_decl = self.modules.iter().map(|module| { + quote! {mod #module;} + }); + let pub_use = self.modules.iter().map(|module| { + quote! { + #module::* + } + }); + + quote! { + #(#mod_decl)* + pub use {#(#pub_use),*}; + } + } +} +enum IncludeOutput { + Normal(IncludePath), + Posts(IncludePath), +} + +impl ToTokens for IncludeOutput { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(match self { + IncludeOutput::Normal(include_path) => include_path.normal_tokens(), + IncludeOutput::Posts(include_path) => include_path.posts_tokens(), + }); + } +} + +struct FileWithPath { + // bytes: Vec, + include: proc_macro2::TokenStream, + rel_path: String, +} +impl FileWithPath { + fn from_path(path: impl AsRef, origin_path: impl AsRef) -> Result { + let abs_path = path + .as_ref() + .canonicalize() + .ok() + .and_then(|p| p.to_str().map(|t| t.to_string())) + .ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))?; + let include = quote! { + include_bytes!(#abs_path) + }; + let origin_abs = origin_path.as_ref().canonicalize().and_then(|p| { + p.to_str() + .ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path")) + .map(|t| t.to_string()) + })?; + let rel_path = abs_path.replace(&origin_abs, ""); + + Ok(Self { include, rel_path }) + } +} + +fn find_matching_files_recursive( + p: P, + include_in_rerun: F, + directory_filter: D, + out: &mut Vec, + origin_path: &PathBuf, +) -> Result<(), io::Error> +where + P: AsRef, + F: Fn(&str) -> bool + Copy, + D: Fn(&str) -> bool + Copy, +{ + for item in std::fs::read_dir(p.as_ref())? { + let item = item?; + if item.file_type()?.is_dir() { + if directory_filter(item.file_name().to_str().ok_or(io::Error::new( + io::ErrorKind::InvalidData, + "file has no name", + ))?) { + find_matching_files_recursive( + item.path(), + include_in_rerun, + directory_filter, + out, + origin_path, + )?; + } + continue; + } + if let Some(file_name) = item.file_name().to_str() { + if include_in_rerun(file_name) { + out.push(FileWithPath::from_path(item.path(), origin_path)?); + } + } + } + + Ok(()) +} + +struct IncludeDist { + name: syn::Ident, + files: Vec, +} + +impl IncludeDist { + fn read_dist_path( + name: syn::Ident, + path_span: Span, + path: &Path, + origin_path: &PathBuf, + ) -> syn::Result { + let mut files = Vec::new(); + find_matching_files_recursive(path, |_| true, |_| true, &mut files, origin_path) + .map_err(|err| syn::Error::new(path_span, err.to_string()))?; + Ok(Self { name, files }) + } +} + +impl Parse for IncludeDist { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let name = input.parse::()?; + input.parse::()?; + let directory = input.parse::()?; + + let dir_path = std::env::current_dir() + .unwrap() + .join(directory.value()) + .to_path_buf(); + + Self::read_dist_path(name, directory.span(), &dir_path, &dir_path) + } +} + +impl ToTokens for IncludeDist { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + // std::collections::HashMap + let array_contents = self.files.iter().map(|file| { + let FileWithPath { include, rel_path } = file; + + quote! { + (#rel_path, #include) + } + }); + let name = &self.name; + tokens.extend(quote! { + pub const #name: &[(&'static str, &'static [u8])] = &[#(#array_contents),*]; + }); + } +} + +struct StaticLinks { + collect_into_const: Option, + relative_to: PathBuf, + files: Vec<(String, PathBuf)>, +} + +impl Parse for StaticLinks { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let collect_into_const = if input.peek(syn::Ident) { + let out = Some(input.parse()?); + input.parse::]>()?; + out + } else { + None + }; + const EXPECTED: &str = "expected 'relative to'"; + let directory = input.parse::()?; + { + let rel = input + .parse::() + .map_err(|err| syn::Error::new(err.span(), EXPECTED))?; + if rel != "relative" { + return Err(syn::Error::new(rel.span(), EXPECTED)); + } + } + { + let to = input + .parse::() + .map_err(|err| syn::Error::new(err.span(), EXPECTED))?; + if to != "to" { + return Err(syn::Error::new(to.span(), EXPECTED)); + } + } + let current_dir = std::env::current_dir().unwrap(); + let relative_to = current_dir + .join(input.parse::()?.value()) + .canonicalize() + .expect("cannonicalize relative to path"); + + let span = directory.span(); + + let path_dir = current_dir + .join(directory.value()) + .canonicalize() + .expect("canonicalize base path") + .to_path_buf(); + + let read_dir = std::fs::read_dir(&path_dir).map_err(|err| display_err(span, err))?; + + let files = read_dir + .map(|file| file.map_err(|err| display_err(span, err))) + .collect::, _>>()? + .into_iter() + .filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default()) + .map(|file| { + ( + file.file_name().to_str().expect("bad filename").to_string(), + file.path(), + ) + }) + .collect::>(); + Ok(StaticLinks { + collect_into_const, + relative_to, + files, + }) + } +} + +impl ToTokens for StaticLinks { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let base_path = self.relative_to.to_str().expect("base path to_string"); + let ident_with_decl = self.files.iter().map(|(file, path)| { + let file_ident = syn::Ident::new( + file.replace(['.', ' ', '-'], "_").to_uppercase().as_str(), + Span::call_site(), + ); + let filepath = path + .to_str() + .expect("path to string") + .replace(base_path, ""); + let decl = quote! { + pub const #file_ident: &'static str = #filepath; + }; + (file_ident, decl) + }); + let files = ident_with_decl.clone().map(|(_, q)| q); + + tokens.extend(quote! { + #(#files)* + }); + if let Some(collect_into_const) = &self.collect_into_const { + let idents = ident_with_decl.map(|(ident, _)| ident); + tokens.extend(quote! { + pub const #collect_into_const: &[&str] = &[#(#idents),*]; + }); + } + } +} + +impl StaticLinks { + fn into_demand_metadata(self) -> StaticLinksDemandMetadata { + let StaticLinks { + collect_into_const, + relative_to, + files, + } = self; + StaticLinksDemandMetadata { + collect_into_const, + relative_to, + files, + } + } +} + +struct StaticLinksDemandMetadata { + collect_into_const: Option, + relative_to: PathBuf, + files: Vec<(String, PathBuf)>, +} + +impl ToTokens for StaticLinksDemandMetadata { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let base_path = self.relative_to.to_str().expect("base path to_string"); + let ident_with_decl = self.files.iter().map(|(file, path)| { + let file_ident = syn::Ident::new( + file.replace(['.', ' ', '-'], "_").to_uppercase().as_str(), + Span::call_site(), + ); + let filepath = path + .to_str() + .expect("path to string") + .replace(base_path, ""); + let decl = quote! { + pub const #file_ident: &'static str = #filepath; + }; + (file_ident, filepath, decl) + }); + let files = ident_with_decl.clone().map(|(_, _, q)| q); + + tokens.extend(quote! { + #(#files)* + }); + if let Some(collect_into_const) = &self.collect_into_const { + let idents = ident_with_decl.clone().map(|(ident, _, _)| ident); + tokens.extend(quote! { + pub const #collect_into_const: &[&str] = &[#(#idents),*]; + }); + } + + let (paths_and_snake_idents, members): (Vec<_>, Vec<_>) = ident_with_decl + .clone() + .map(|(i, filepath, _)| { + let snake_ident = syn::Ident::new( + i.to_string().to_case(convert_case::Case::Snake).as_str(), + i.span(), + ); + let member = quote! { + pub #snake_ident: Metadata, + }; + ((filepath, snake_ident), member) + }) + .unzip(); + + let find_by_key = paths_and_snake_idents.iter().map(|(path, ident)| { + quote! { + #path => &self.#ident + } + }); + + tokens.extend(quote! { + pub struct MetadataMap { + #(#members)* + } + + impl MetadataMap { + pub fn find_by_key(&self, key: &str) -> Option<&Metadata> { + Some(match key { + #(#find_by_key,)* + _ => return None, + }) + } + } + }); + } +} + +#[proc_macro] +pub fn include_dist(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let incl_dist = parse_macro_input!(input as IncludeDist); + quote! {#incl_dist}.into() +} + +#[proc_macro] +pub fn posts(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let pubuse = IncludeOutput::Posts(parse_macro_input!(input as IncludePath)); + quote! {#pubuse}.into() +} + +#[proc_macro] +pub fn include_path(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let incl_path = IncludeOutput::Normal(parse_macro_input!(input as IncludePath)); + quote! {#incl_path}.into() +} + +#[proc_macro] +pub fn static_links(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let static_links = parse_macro_input!(input as StaticLinks); + quote! {#static_links}.into() +} + +#[proc_macro] +pub fn static_links_demand_metadata(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let demand_metadata = parse_macro_input!(input as StaticLinks).into_demand_metadata(); + quote! {#demand_metadata}.into() +} + +fn fields_args(field: &syn::Variant) -> proc_macro2::TokenStream { + match &field.fields { + syn::Fields::Named(f) => { + let named = f.named.iter().map(|f| f.ident.clone()); + quote! { {#(#named: _),*} } + } + syn::Fields::Unnamed(f) => { + let f = f.unnamed.iter().map(|_| quote! {_}); + quote! { + (#(#f),*) + } + } + syn::Fields::Unit => quote! {}, + } +} + +#[proc_macro_derive(Titles)] +pub fn villager_roles(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let villager_roles: syn::DeriveInput = parse_macro_input!(input); + let name = syn::Ident::new( + format!("{}Title", villager_roles.ident).as_str(), + villager_roles.ident.span(), + ); + let original_name = &villager_roles.ident; + let variants = match &villager_roles.data { + syn::Data::Enum(data_enum) => data_enum.variants.iter().collect::>(), + _ => todo!(), + }; + let display_match = variants.iter().map(|v| { + let name_str = v.ident.to_string(); + let name = v.ident.clone(); + quote! { + Self::#name => f.write_str(#name_str), + } + }); + + let title_match = variants.iter().map(|v| { + let args = fields_args(v); + let field_name = v.ident.clone(); + quote! { + #original_name::#field_name #args => #name::#field_name, + } + }); + + let names_count = variants.len(); + let names_const_val = variants + .iter() + .map(|v| { + let name = v.ident.clone(); + quote! {Self::#name} + }) + .collect::>(); + + let enum_variant_decl = variants.iter().map(|v| { + let ident = v.ident.clone(); + let attrs = v.attrs.iter(); + quote! { + #(#attrs)* + #ident + } + }); + quote! { + #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, Eq, Hash, werewolves_macros::ChecksAs)] + pub enum #name { + #(#enum_variant_decl,)* + } + + impl #original_name { + pub const fn title(&self) -> #name { + match self { + #(#title_match)* + } + } + } + + impl #name { + pub const ALL: [Self; #names_count] = [#(#names_const_val,)*]; + } + + impl core::fmt::Display for #name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #(#display_match)* + } + } + } + } + .into() +} + +#[proc_macro_derive(ChecksAs, attributes(checks))] +pub fn checks_as(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let checks_as = checks::ChecksAs::parse(parse_macro_input!(input)).unwrap(); + + quote! {#checks_as}.into() +} + +#[proc_macro_derive(Extract, attributes(extract))] +pub fn extract(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let checks_as = targets::Targets::parse(parse_macro_input!(input)).unwrap(); + + quote! {#checks_as}.into() +} diff --git a/werewolves-macros/src/targets.rs b/werewolves-macros/src/targets.rs new file mode 100644 index 0000000..0cdb170 --- /dev/null +++ b/werewolves-macros/src/targets.rs @@ -0,0 +1,305 @@ +use std::collections::HashMap; + +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream; +use quote::{ToTokens, quote, quote_spanned}; +use syn::spanned::Spanned; + +use crate::hashlist::HashList; + +const TARGETS_PATH: &str = "extract"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum VariantFieldIndex { + Ident(syn::Ident), + Numeric(syn::LitInt), + None, +} + +impl VariantFieldIndex { + const fn is_numeric(&self) -> bool { + match self { + Self::Numeric(_) => true, + _ => false, + } + } +} + +impl syn::parse::Parse for VariantFieldIndex { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + if input.is_empty() { + Ok(VariantFieldIndex::None) + } else if input.peek(syn::LitInt) { + Ok(VariantFieldIndex::Numeric(input.parse()?)) + } else { + Ok(VariantFieldIndex::Ident(input.parse()?)) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct VariantAttr { + index: VariantFieldIndex, + alias: Option, +} + +impl syn::parse::Parse for VariantAttr { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let index = if input.peek(syn::token::As) { + VariantFieldIndex::None + } else { + input.parse::()? + }; + if !input.peek(syn::token::As) { + return Ok(Self { index, alias: None }); + } + input.parse::()?; + + Ok(Self { + index, + alias: Some(input.parse()?), + }) + } +} + +#[derive(Debug, Clone)] +struct TargetVariant { + attr: VariantAttr, + access: TokenStream, + ty: syn::Type, + variant: syn::Variant, +} + +impl TargetVariant { + fn name(&self) -> syn::Ident { + if let Some(alias) = self.attr.alias.as_ref() { + return alias.clone(); + } + match &self.attr.index { + VariantFieldIndex::Ident(ident) => ident.clone(), + VariantFieldIndex::Numeric(_) | VariantFieldIndex::None => self.variant.ident.clone(), + } + } +} + +#[derive(Debug)] +pub struct Targets { + name: syn::Ident, + targets: Vec, + total_fields: usize, +} + +impl Targets { + pub fn parse(input: syn::DeriveInput) -> Result { + let name = input.ident; + let mut targets: Vec = Vec::new(); + let total_fields; + match &input.data { + syn::Data::Enum(data) => { + total_fields = data.variants.len(); + for field in data.variants.iter() { + let field_ident = &field.ident; + for attr in field.attrs.iter().filter(|attr| { + attr.path() + .get_ident() + .map(|id| id.to_string().as_str() == TARGETS_PATH) + .unwrap_or_default() + }) { + let attr_span = attr.span(); + let attr = attr.parse_args::()?; + let (access, ty) = match &field.fields { + syn::Fields::Named(fields) => match &attr.index { + VariantFieldIndex::Ident(ident) => { + let mut matching_val = None; + let mut accesses = Vec::new(); + for field in fields.named.iter() { + let matching = field + .ident + .as_ref() + .map(|i| i.to_string() == ident.to_string()) + .unwrap_or_default(); + if matching_val.is_some() && matching { + panic!("duplicate?") + } + + let ident = field.ident.clone().unwrap(); + if matching { + matching_val = Some((ident.clone(), field.ty.clone())); + accesses.push(quote! { + #ident + }); + } else { + accesses.push(quote! { + #ident: _ + }); + } + } + let (mut matching_ident, matching_ty) = matching_val.ok_or( + syn::Error::new(ident.span(), "no such variant field"), + )?; + matching_ident.set_span(ident.span()); + + ( + quote! { + #name::#field_ident { #(#accesses),* } => &#matching_ident, + }, + matching_ty, + ) + } + + VariantFieldIndex::Numeric(num) => { + return Err(syn::Error::new( + num.span(), + "cannot used numeric index for a variant with named fields", + )); + } + VariantFieldIndex::None => { + if fields.named.len() == 1 { + let field = fields.named.iter().next().unwrap(); + let field_ident = field.ident.as_ref().unwrap(); + ( + quote! { + #name::#field_ident { #field_ident } => &#field_ident, + }, + field.ty.clone(), + ) + } else { + return Err(syn::Error::new( + fields.named.span(), + "unnamed field index with more than one field", + )); + } + } + }, + syn::Fields::Unnamed(fields) => { + if let VariantFieldIndex::Numeric(num) = &attr.index { + let num = num.base10_parse::()? as usize; + let field = fields + .unnamed + .iter() + .nth(num) + .ok_or(syn::Error::new( + attr_span, + "field index out of range", + ))? + .clone(); + let left = (0..num).map(|_| quote! {_}); + let right = (num..fields.unnamed.len()).map(|_| quote! {_}); + ( + quote! { + #name::#field_ident(#(#left),*, val, #(#right),*) => &val, + }, + field.ty.clone(), + ) + } else if fields.unnamed.len() == 1 { + ( + quote! { + #name::#field_ident(val) => &val, + }, + fields.unnamed.iter().next().unwrap().ty.clone(), + ) + } else { + return Err(syn::Error::new( + fields.span(), + "unnamed fields without numeric index", + )); + } + } + syn::Fields::Unit => { + return Err(syn::Error::new( + field.span(), + "target cannot be a unit field", + )); + } + }; + let target = TargetVariant { + attr, + ty, + access, + variant: field.clone(), + }; + // if targets.iter().any(|t| t.name() == target.name()) { + // return Err(syn::Error::new(attr_span, "duplicate target field")); + // } + // target.variant.ident.set_span(attr_span); + + targets.push(target); + } + } + } + + _ => todo!(), + }; + + Ok(Self { + name, + targets, + total_fields, + }) + } +} + +impl ToTokens for Targets { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + // this is so horrid and haphazard but idc rn + // commenting to make the criticizing voice stfu + let mut by_target = { + let mut out = HashList::new(); + for target in self.targets.iter() { + out.add(target.name().to_string(), target.clone()); + } + out + }; + let fns = by_target.decompose().into_iter().map(|(_, targets)| { + if targets.is_empty() { + return quote! {}; + } + let all_variants = targets.len() == self.total_fields; + let fn_ret = { + let ty = targets.first().unwrap().ty.clone(); + if all_variants { + quote! {&#ty} + } else { + quote! {Option<&#ty>} + } + }; + let fn_name = { + let name = targets.first().unwrap().name(); + syn::Ident::new(name.to_string().to_case(Case::Snake).as_str(), name.span()) + }; + let raw_accesses = targets.iter().map(|t| { + let access = &t.access; + + quote_spanned! { t.attr.alias.as_ref().unwrap().span() => + #access + } + }); + let accesses = if all_variants { + quote! { + match self { + #(#raw_accesses)* + } + } + } else { + quote! { + Some(match self { + #(#raw_accesses)* + _ => return None, + }) + } + }; + + quote! { + pub const fn #fn_name(&self) -> #fn_ret { + #accesses + } + } + }); + let name = &self.name; + tokens.extend(quote! { + impl #name { + #(#fns)* + } + }); + } +} diff --git a/werewolves-proto/Cargo.toml b/werewolves-proto/Cargo.toml new file mode 100644 index 0000000..9def041 --- /dev/null +++ b/werewolves-proto/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "werewolves-proto" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = { version = "2" } +log = { version = "0.4" } +serde_json = { version = "1.0" } +serde = { version = "1.0", features = ["derive"] } +uuid = { version = "1.17", features = ["v4", "serde"] } +rand = { version = "0.9" } +werewolves-macros = { path = "../werewolves-macros" } + + +[dev-dependencies] +pretty_assertions = { version = "1" } diff --git a/werewolves-proto/src/diedto.rs b/werewolves-proto/src/diedto.rs new file mode 100644 index 0000000..4ef858f --- /dev/null +++ b/werewolves-proto/src/diedto.rs @@ -0,0 +1,66 @@ +use core::{fmt::Debug, num::NonZeroU8}; + +use serde::{Deserialize, Serialize}; + +use crate::{game::DateTime, player::CharacterId}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum DiedTo { + Execution { + day: NonZeroU8, + }, + MapleWolf { + source: CharacterId, + night: NonZeroU8, + starves_if_fails: bool, + }, + MapleWolfStarved { + night: NonZeroU8, + }, + Militia { + killer: CharacterId, + night: NonZeroU8, + }, + Wolfpack { + night: NonZeroU8, + }, + AlphaWolf { + killer: CharacterId, + night: NonZeroU8, + }, + Shapeshift { + into: CharacterId, + night: NonZeroU8, + }, + Hunter { + killer: CharacterId, + night: NonZeroU8, + }, + Guardian { + killer: CharacterId, + night: NonZeroU8, + }, +} + +impl DiedTo { + pub const fn date_time(&self) -> DateTime { + match self { + DiedTo::Execution { day } => DateTime::Day { number: *day }, + + DiedTo::Guardian { killer: _, night } + | DiedTo::MapleWolf { + source: _, + night, + starves_if_fails: _, + } + | DiedTo::MapleWolfStarved { night } + | DiedTo::Militia { killer: _, night } + | DiedTo::Wolfpack { night } + | DiedTo::AlphaWolf { killer: _, night } + | DiedTo::Shapeshift { into: _, night } + | DiedTo::Hunter { killer: _, night } => DateTime::Night { + number: night.get(), + }, + } + } +} diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs new file mode 100644 index 0000000..3d9f470 --- /dev/null +++ b/werewolves-proto/src/error.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{player::CharacterId, role::RoleTitle}; + +#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)] +pub enum GameError { + #[error("too many roles. have {players} players, but {roles} roles (incl wolves)")] + TooManyRoles { players: u8, roles: u8 }, + #[error("wolves range must start at 1")] + NoWolves, + #[error("message invalid for game state")] + InvalidMessageForGameState, + #[error("no executions during night time")] + NoExecutionsAtNight, + #[error("no-trial not allowed")] + NoTrialNotAllowed, + #[error("chracter is already dead")] + CharacterAlreadyDead, + #[error("no matching character found")] + NoMatchingCharacterFound, + #[error("character not in joined player pool")] + CharacterNotInJoinedPlayers, + #[error("{0}")] + GenericError(String), + #[error("invalid cause of death")] + InvalidCauseOfDeath, + #[error("invalid target")] + InvalidTarget, + #[error("timed out")] + TimedOut, + #[error("host channel closed")] + HostChannelClosed, + #[error("not all players connected")] + NotAllPlayersConnected, + #[error("too few players: got {got} but the settings require at least {need}")] + TooFewPlayers { got: u8, need: u8 }, + #[error("it's already daytime")] + AlreadyDaytime, + #[error("it's not the end of the night yet")] + NotEndOfNight, + #[error("it's not day yet")] + NotDayYet, + #[error("it's not night")] + NotNight, + #[error("invalid role, expected {expected:?} got {got:?}")] + InvalidRole { expected: RoleTitle, got: RoleTitle }, + #[error("villagers cannot be added to settings")] + CantAddVillagerToSettings, + #[error("no mentor for an apprentice to be an apprentice to :(")] + NoApprenticeMentor, + #[error("BUG: cannot find character in village, but they should be there")] + CannotFindTargetButShouldBeThere, + #[error("inactive game object")] + InactiveGameObject, + #[error("socket error: {0}")] + SocketError(String), + #[error("this night is over")] + NightOver, + #[error("no night actions")] + NoNightActions, + #[error("still awaiting response")] + AwaitingResponse, + #[error("current state already has a response")] + NightNeedsNext, + #[error("night zero actions can only be obtained on night zero")] + NotNightZero, + #[error("wolves intro in progress")] + WolvesIntroInProgress, + #[error("a game is still ongoing")] + GameOngoing, + #[error("needs a role reveal")] + NeedRoleReveal, +} diff --git a/werewolves-proto/src/game/mod.rs b/werewolves-proto/src/game/mod.rs new file mode 100644 index 0000000..a0b6b7c --- /dev/null +++ b/werewolves-proto/src/game/mod.rs @@ -0,0 +1,298 @@ +mod night; +mod settings; +mod village; + +use core::{ + fmt::Debug, + num::NonZeroU8, + ops::{Deref, Range, RangeBounds}, +}; + +use rand::{Rng, seq::SliceRandom}; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::GameError, + game::night::Night, + message::{ + CharacterState, Identification, + host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage}, + }, + player::CharacterId, +}; +pub use {settings::GameSettings, village::Village}; + +type Result = core::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Game { + previous: Vec, + state: GameState, +} + +impl Game { + pub fn new(players: &[Identification], settings: GameSettings) -> Result { + Ok(Self { + previous: Vec::new(), + state: GameState::Night { + night: Night::new(Village::new(players, settings)?)?, + }, + }) + } + + pub const fn village(&self) -> &Village { + match &self.state { + GameState::Day { village, marked: _ } => village, + GameState::Night { night } => night.village(), + } + } + + pub fn process(&mut self, message: HostGameMessage) -> Result { + match (&mut self.state, message) { + (GameState::Night { night }, HostGameMessage::Night(HostNightMessage::Next)) => { + night.next()?; + self.process(HostGameMessage::GetState) + } + ( + GameState::Day { village: _, marked }, + HostGameMessage::Day(HostDayMessage::MarkForExecution(target)), + ) => { + match marked + .iter() + .enumerate() + .find_map(|(idx, mark)| (mark == &target).then_some(idx)) + { + Some(idx) => { + marked.swap_remove(idx); + } + None => marked.push(target), + } + + self.process(HostGameMessage::GetState) + } + (GameState::Day { village, marked }, HostGameMessage::Day(HostDayMessage::Execute)) => { + if let Some(outcome) = village.execute(marked)? { + return Ok(ServerToHostMessage::GameOver(outcome)); + } + let night = Night::new(village.clone())?; + self.previous.push(self.state.clone()); + self.state = GameState::Night { night }; + self.process(HostGameMessage::GetState) + } + (GameState::Day { village, marked }, HostGameMessage::GetState) => { + if let Some(outcome) = village.is_game_over() { + return Ok(ServerToHostMessage::GameOver(outcome)); + } + Ok(ServerToHostMessage::Daytime { + marked: marked.clone().into_boxed_slice(), + characters: village + .characters() + .into_iter() + .map(|c| CharacterState { + player_id: c.player_id().clone(), + character_id: c.character_id().clone(), + public_identity: c.public_identity().clone(), + role: c.role().title(), + died_to: c.died_to().cloned(), + }) + .collect(), + day: match village.date_time() { + DateTime::Day { number } => number, + DateTime::Night { number: _ } => unreachable!(), + }, + }) + } + (GameState::Night { night }, HostGameMessage::GetState) => { + if let Some(res) = night.current_result() { + let char = night.current_character().unwrap(); + return Ok(ServerToHostMessage::ActionResult( + char.public_identity().clone(), + res.clone(), + )); + } + if let Some(prompt) = night.current_prompt() { + let char = night.current_character().unwrap(); + return Ok(ServerToHostMessage::ActionPrompt( + char.public_identity().clone(), + prompt.clone(), + )); + } + match night.next() { + Ok(_) => self.process(HostGameMessage::GetState), + Err(GameError::NightOver) => { + let village = night.collect_completed()?; + self.previous.push(self.state.clone()); + self.state = GameState::Day { + village, + marked: Vec::new(), + }; + + self.process(HostGameMessage::GetState) + } + Err(err) => Err(err), + } + } + ( + GameState::Night { night }, + HostGameMessage::Night(HostNightMessage::ActionResponse(resp)), + ) => match night.received_response(resp.clone()) { + Ok(res) => Ok(ServerToHostMessage::ActionResult( + night.current_character().unwrap().public_identity().clone(), + res, + )), + Err(GameError::NightNeedsNext) => match night.next() { + Ok(_) => self.process(HostGameMessage::Night( + HostNightMessage::ActionResponse(resp), + )), + Err(GameError::NightOver) => { + // since the block handling HostGameMessage::GetState for night + // already manages the NightOver state, just invoke it + self.process(HostGameMessage::GetState) + } + Err(err) => Err(err), + }, + Err(err) => Err(err), + }, + (GameState::Night { night: _ }, HostGameMessage::Day(_)) + | ( + GameState::Day { + village: _, + marked: _, + }, + HostGameMessage::Night(_), + ) => Err(GameError::InvalidMessageForGameState), + } + } + + pub fn game_over(&self) -> Option { + self.state.game_over() + } + + pub fn game_state(&self) -> &GameState { + &self.state + } + + pub fn previous_game_states(&self) -> &[GameState] { + &self.previous + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GameState { + Day { + village: Village, + marked: Vec, + }, + Night { + night: Night, + }, +} + +impl GameState { + pub fn game_over(&self) -> Option { + match self { + GameState::Day { village, marked: _ } => village.is_game_over(), + GameState::Night { night: _ } => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum GameOver { + VillageWins, + WolvesWin, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pool +where + T: Debug + Clone, +{ + pub pool: Vec, + pub range: Range, +} + +impl Pool +where + T: Debug + Clone, +{ + pub const fn new(pool: Vec, range: Range) -> Self { + Self { pool, range } + } + + pub fn collapse(mut self, rng: &mut impl Rng, max: u8) -> Vec { + let range = match self.range.end_bound() { + core::ops::Bound::Included(end) => { + if max < *end { + self.range.start..max + 1 + } else { + self.range.clone() + } + } + core::ops::Bound::Excluded(end) => { + if max <= *end { + self.range.start..max + 1 + } else { + self.range.clone() + } + } + core::ops::Bound::Unbounded => self.range.start..max + 1, + }; + let count = rng.random_range(range); + self.pool.shuffle(rng); + self.pool.truncate(count as _); + self.pool + } +} + +impl Deref for Pool +where + T: Debug + Clone, +{ + type Target = [T]; + + fn deref(&self) -> &Self::Target { + &self.pool + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Maybe { + Yes, + No, + Maybe, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum DateTime { + Day { number: NonZeroU8 }, + Night { number: u8 }, +} + +impl Default for DateTime { + fn default() -> Self { + DateTime::Day { + number: NonZeroU8::new(1).unwrap(), + } + } +} + +impl DateTime { + pub const fn is_day(&self) -> bool { + matches!(self, DateTime::Day { number: _ }) + } + + pub const fn is_night(&self) -> bool { + matches!(self, DateTime::Night { number: _ }) + } + + pub const fn next(self) -> Self { + match self { + DateTime::Day { number } => DateTime::Night { + number: number.get(), + }, + DateTime::Night { number } => DateTime::Day { + number: NonZeroU8::new(number + 1).unwrap(), + }, + } + } +} diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs new file mode 100644 index 0000000..e290f5c --- /dev/null +++ b/werewolves-proto/src/game/night.rs @@ -0,0 +1,964 @@ +use core::{num::NonZeroU8, ops::Not}; +use std::collections::VecDeque; + +use serde::{Deserialize, Serialize}; +use werewolves_macros::Extract; + +use super::Result; +use crate::{ + diedto::DiedTo, + error::GameError, + game::{DateTime, Village}, + message::night::{ActionPrompt, ActionResponse, ActionResult}, + player::{Character, CharacterId, Protection}, + role::{PreviousGuardianAction, Role, RoleBlock, RoleTitle}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Night { + village: Village, + night: u8, + action_queue: VecDeque<(ActionPrompt, Character)>, + changes: Vec, + night_state: NightState, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Extract)] +pub enum NightChange { + RoleChange(CharacterId, RoleTitle), + HunterTarget { + source: CharacterId, + target: CharacterId, + }, + Kill { + target: CharacterId, + died_to: DiedTo, + }, + RoleBlock { + source: CharacterId, + target: CharacterId, + block_type: RoleBlock, + }, + Shapeshift { + source: CharacterId, + }, + Protection { + target: CharacterId, + protection: Protection, + }, +} + +struct ResponseOutcome { + pub result: ActionResult, + pub change: Option, + pub unless: Option, +} + +enum Unless { + TargetBlocked(CharacterId), + TargetsBlocked(CharacterId, CharacterId), +} + +impl From for ActionResult { + fn from(value: Unless) -> Self { + match value { + Unless::TargetBlocked(_) | Unless::TargetsBlocked(_, _) => ActionResult::RoleBlocked, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(clippy::large_enum_variant)] +enum NightState { + Active { + current_prompt: ActionPrompt, + current_char: CharacterId, + current_result: Option, + }, + Complete, +} + +impl Night { + pub fn new(village: Village) -> Result { + let night = match village.date_time() { + DateTime::Day { number: _ } => return Err(GameError::NotNight), + DateTime::Night { number } => number, + }; + + let mut action_queue = village + .characters() + .into_iter() + .map(|c| c.night_action_prompt(&village).map(|prompt| (prompt, c))) + .collect::>>()? + .into_iter() + .filter_map(|(prompt, char)| prompt.map(|p| (p, char))) + .collect::>(); + action_queue.sort_by(|(left_prompt, _), (right_prompt, _)| { + left_prompt + .partial_cmp(right_prompt) + .unwrap_or(core::cmp::Ordering::Equal) + }); + let mut action_queue = VecDeque::from(action_queue); + let (current_prompt, current_char) = if night == 0 { + ( + ActionPrompt::WolvesIntro { + wolves: village + .living_wolf_pack_players() + .into_iter() + .map(|w| (w.target(), w.role().title())) + .collect(), + }, + village + .living_wolf_pack_players() + .into_iter() + .next() + .unwrap() + .character_id() + .clone(), + ) + } else { + action_queue + .pop_front() + .map(|(p, c)| (p, c.character_id().clone())) + .ok_or(GameError::NoNightActions)? + }; + let night_state = NightState::Active { + current_char, + current_prompt, + current_result: None, + }; + let mut changes = Vec::new(); + if let Some(night_nz) = NonZeroU8::new(night) { + // TODO: prob should be an end-of-night thing + changes = village + .dead_characters() + .into_iter() + .filter_map(|c| c.died_to().map(|d| (c, d))) + .filter_map(|(c, d)| match c.role() { + Role::Hunter { target } => target.clone().map(|t| (c, t, d)), + _ => None, + }) + .filter_map(|(c, t, d)| match d.date_time() { + DateTime::Day { number } => (number.get() == night).then_some((c, t)), + DateTime::Night { number: _ } => None, + }) + .map(|(c, target)| NightChange::Kill { + target, + died_to: DiedTo::Hunter { + killer: c.character_id().clone(), + night: night_nz, + }, + }) + .collect(); + } + + Ok(Self { + night, + changes, + village, + night_state, + action_queue, + }) + } + + pub fn collect_completed(&self) -> Result { + if !matches!(self.night_state, NightState::Complete) { + return Err(GameError::NotEndOfNight); + } + let mut new_village = self.village.clone(); + let changes = ChangesLookup(&self.changes); + for change in self.changes.iter() { + match change { + NightChange::RoleChange(character_id, role_title) => new_village + .character_by_id_mut(character_id) + .unwrap() + .role_change(*role_title, DateTime::Night { number: self.night })?, + NightChange::HunterTarget { source, target } => { + if let Role::Hunter { target: t } = + new_village.character_by_id_mut(source).unwrap().role_mut() + { + t.replace(target.clone()); + } + if changes.killed(source).is_some() + && changes.protected(source).is_none() + && changes.protected(target).is_none() + { + new_village + .character_by_id_mut(target) + .unwrap() + .kill(DiedTo::Hunter { + killer: source.clone(), + night: NonZeroU8::new(self.night).unwrap(), + }) + } + } + NightChange::Kill { target, died_to } => { + if let DiedTo::MapleWolf { + source, + night, + starves_if_fails: true, + } = died_to + && changes.protected(target).is_some() + { + // kill maple first, then act as if they get their kill attempt + new_village + .character_by_id_mut(source) + .unwrap() + .kill(DiedTo::MapleWolfStarved { night: *night }); + } + + if let Some(prot) = changes.protected(target) { + match prot { + Protection::Guardian { + source, + guarding: true, + } => { + let kill_source = match died_to { + DiedTo::MapleWolfStarved { night } => { + new_village + .character_by_id_mut(target) + .unwrap() + .kill(DiedTo::MapleWolfStarved { night: *night }); + continue; + } + DiedTo::Execution { day: _ } => unreachable!(), + DiedTo::MapleWolf { + source, + night: _, + starves_if_fails: _, + } + | DiedTo::Militia { + killer: source, + night: _, + } + | DiedTo::AlphaWolf { + killer: source, + night: _, + } + | DiedTo::Hunter { + killer: source, + night: _, + } => source.clone(), + DiedTo::Wolfpack { night: _ } => { + if let Some(wolf_to_kill) = new_village + .living_wolf_pack_players() + .into_iter() + .find(|w| matches!(w.role(), Role::Werewolf)) + .map(|w| w.character_id().clone()) + .or_else(|| { + new_village + .living_wolf_pack_players() + .into_iter() + .next() + .map(|w| w.character_id().clone()) + }) + { + wolf_to_kill + } else { + // No wolves? Game over? + continue; + } + } + DiedTo::Shapeshift { into: _, night: _ } => target.clone(), + DiedTo::Guardian { + killer: _, + night: _, + } => continue, + }; + new_village.character_by_id_mut(&kill_source).unwrap().kill( + DiedTo::Guardian { + killer: source.clone(), + night: NonZeroU8::new(self.night).unwrap(), + }, + ); + new_village.character_by_id_mut(source).unwrap().kill( + DiedTo::Wolfpack { + night: NonZeroU8::new(self.night).unwrap(), + }, + ); + continue; + } + Protection::Guardian { + source: _, + guarding: false, + } => continue, + Protection::Protector { source: _ } => continue, + } + } + + new_village + .character_by_id_mut(target) + .unwrap() + .kill(DiedTo::Wolfpack { + night: NonZeroU8::new(self.night).unwrap(), + }); + } + NightChange::Shapeshift { source } => { + // TODO: shapeshift should probably notify immediately after it happens + if let Some(target) = changes.wolf_pack_kill_target() + && changes.protected(target).is_none() + { + let ss = new_village.character_by_id_mut(source).unwrap(); + match ss.role_mut() { + Role::Shapeshifter { shifted_into } => { + *shifted_into = Some(target.clone()) + } + _ => unreachable!(), + } + ss.kill(DiedTo::Shapeshift { + into: target.clone(), + night: NonZeroU8::new(self.night).unwrap(), + }); + let target = new_village.find_by_character_id_mut(target).unwrap(); + target + .role_change( + RoleTitle::Werewolf, + DateTime::Night { number: self.night }, + ) + .unwrap(); + } + } + NightChange::RoleBlock { + source: _, + target: _, + block_type: _, + } + | NightChange::Protection { + target: _, + protection: _, + } => {} + } + } + new_village.to_day()?; + Ok(new_village) + } + + pub fn received_response(&mut self, resp: ActionResponse) -> Result { + if let ( + NightState::Active { + current_prompt: ActionPrompt::WolvesIntro { wolves: _ }, + current_char: _, + current_result, + }, + ActionResponse::WolvesIntroAck, + ) = (&mut self.night_state, &resp) + { + *current_result = Some(ActionResult::WolvesIntroDone); + return Ok(ActionResult::WolvesIntroDone); + } + + match self.received_response_with_role_blocks(resp) { + Ok((result, Some(change))) => { + match &mut self.night_state { + NightState::Active { + current_prompt: _, + current_char: _, + current_result, + } => current_result.replace(result.clone()), + NightState::Complete => return Err(GameError::NightOver), + }; + self.changes.push(change); + Ok(result) + } + Ok((result, None)) => { + match &mut self.night_state { + NightState::Active { + current_prompt: _, + current_char: _, + current_result, + } => { + current_result.replace(result.clone()); + } + NightState::Complete => return Err(GameError::NightOver), + }; + Ok(result) + } + Err(err) => Err(err), + } + } + fn received_response_with_role_blocks( + &self, + resp: ActionResponse, + ) -> Result<(ActionResult, Option)> { + match self.received_response_inner(resp) { + Ok(ResponseOutcome { + result, + change, + unless: Some(Unless::TargetBlocked(unless_blocked)), + }) => { + if self.changes.iter().any(|c| match c { + NightChange::RoleBlock { + source: _, + target, + block_type: _, + } => target == &unless_blocked, + _ => false, + }) { + Ok((ActionResult::RoleBlocked, None)) + } else { + Ok((result, change)) + } + } + Ok(ResponseOutcome { + result, + change, + unless: Some(Unless::TargetsBlocked(unless_blocked1, unless_blocked2)), + }) => { + if self.changes.iter().any(|c| match c { + NightChange::RoleBlock { + source: _, + target, + block_type: _, + } => target == &unless_blocked1 || target == &unless_blocked2, + _ => false, + }) { + Ok((ActionResult::RoleBlocked, None)) + } else { + Ok((result, change)) + } + } + + Ok(ResponseOutcome { + result, + change, + unless: None, + }) => Ok((result, change)), + Err(err) => Err(err), + } + } + + fn received_response_inner(&self, resp: ActionResponse) -> Result { + let (current_prompt, current_char) = match &self.night_state { + NightState::Active { + current_prompt: _, + current_char: _, + current_result: Some(_), + } => return Err(GameError::NightNeedsNext), + NightState::Active { + current_prompt, + current_char, + current_result: None, + } => (current_prompt, current_char), + NightState::Complete => return Err(GameError::NightOver), + }; + + match (current_prompt, resp) { + (ActionPrompt::RoleChange { new_role }, ActionResponse::RoleChangeAck) => { + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::RoleChange(current_char.clone(), *new_role)), + unless: None, + }) + } + (ActionPrompt::Seer { living_players }, ActionResponse::Seer(target)) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + + Ok(ResponseOutcome { + result: ActionResult::Seer( + self.village + .character_by_id(&target) + .unwrap() + .role() + .alignment(), + ), + change: None, + unless: Some(Unless::TargetBlocked(target)), + }) + } + ( + ActionPrompt::Arcanist { living_players }, + ActionResponse::Arcanist(target1, target2), + ) => { + if !(living_players.iter().any(|p| p.character_id == target1) + && living_players.iter().any(|p| p.character_id == target2)) + { + return Err(GameError::InvalidTarget); + } + let target1_align = self + .village + .character_by_id(&target1) + .unwrap() + .role() + .alignment(); + let target2_align = self + .village + .character_by_id(&target2) + .unwrap() + .role() + .alignment(); + Ok(ResponseOutcome { + result: ActionResult::Arcanist { + same: target1_align == target2_align, + }, + change: None, + unless: Some(Unless::TargetsBlocked(target1, target2)), + }) + } + (ActionPrompt::Gravedigger { dead_players }, ActionResponse::Gravedigger(target)) => { + if !dead_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + let target_role = self.village.character_by_id(&target).unwrap().role(); + Ok(ResponseOutcome { + result: ActionResult::GraveDigger( + matches!( + target_role, + Role::Shapeshifter { + shifted_into: Some(_) + } + ) + .not() + .then(|| target_role.title()), + ), + change: None, + unless: Some(Unless::TargetBlocked(target)), + }) + } + ( + ActionPrompt::Hunter { + current_target: _, + living_players, + }, + ActionResponse::Hunter(target), + ) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::HunterTarget { + target: target.clone(), + source: current_char.clone(), + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + (ActionPrompt::Militia { living_players }, ActionResponse::Militia(Some(target))) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + let night = if let Some(night) = NonZeroU8::new(self.night) { + night + } else { + return Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }); + }; + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Kill { + target: target.clone(), + died_to: DiedTo::Militia { + night, + killer: current_char.clone(), + }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + (ActionPrompt::Militia { living_players: _ }, ActionResponse::Militia(None)) => { + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }) + } + ( + ActionPrompt::MapleWolf { + kill_or_die, + living_players, + }, + ActionResponse::MapleWolf(Some(target)), + ) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + let night = if let Some(night) = NonZeroU8::new(self.night) { + night + } else { + return Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }); + }; + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Kill { + target: target.clone(), + died_to: DiedTo::MapleWolf { + night, + source: current_char.clone(), + starves_if_fails: *kill_or_die, + }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + ( + ActionPrompt::MapleWolf { + kill_or_die: true, + living_players: _, + }, + ActionResponse::MapleWolf(None), + ) => { + let night = if let Some(night) = NonZeroU8::new(self.night) { + night + } else { + return Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }); + }; + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Kill { + target: current_char.clone(), + died_to: DiedTo::MapleWolfStarved { night }, + }), + unless: None, + }) + } + + ( + ActionPrompt::MapleWolf { + kill_or_die: false, + living_players: _, + }, + ActionResponse::MapleWolf(None), + ) => Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }), + ( + ActionPrompt::Guardian { + previous: Some(previous), + living_players, + }, + ActionResponse::Guardian(target), + ) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + let guarding = match previous { + PreviousGuardianAction::Protect(prev_target) => { + prev_target.character_id == target + } + PreviousGuardianAction::Guard(prev_target) => { + if prev_target.character_id == target { + return Err(GameError::InvalidTarget); + } else { + false + } + } + }; + + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Protection { + target: target.clone(), + protection: Protection::Guardian { + source: current_char.clone(), + guarding, + }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + ( + ActionPrompt::Guardian { + previous: None, + living_players, + }, + ActionResponse::Guardian(target), + ) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Protection { + target: target.clone(), + protection: Protection::Guardian { + source: current_char.clone(), + guarding: false, + }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + ( + ActionPrompt::WolfPackKill { living_villagers }, + ActionResponse::WolfPackKillVote(target), + ) => { + let night = match NonZeroU8::new(self.night) { + Some(night) => night, + None => { + return Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }); + } + }; + if !living_villagers.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Kill { + target: target.clone(), + died_to: DiedTo::Wolfpack { night }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + (ActionPrompt::Shapeshifter, ActionResponse::Shapeshifter(true)) => { + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Shapeshift { + source: current_char.clone(), + }), + unless: None, + }) + } + ( + ActionPrompt::AlphaWolf { + living_villagers: _, + }, + ActionResponse::AlphaWolf(None), + ) + | (ActionPrompt::Shapeshifter, ActionResponse::Shapeshifter(false)) => { + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }) + } + ( + ActionPrompt::AlphaWolf { living_villagers }, + ActionResponse::AlphaWolf(Some(target)), + ) => { + let night = match NonZeroU8::new(self.night) { + Some(night) => night, + None => { + return Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: None, + unless: None, + }); + } + }; + if !living_villagers.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Kill { + target: target.clone(), + died_to: DiedTo::AlphaWolf { + killer: current_char.clone(), + night, + }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + (ActionPrompt::DireWolf { living_players }, ActionResponse::Direwolf(target)) => { + if !living_players.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::RoleBlock { + source: current_char.clone(), + target, + block_type: RoleBlock::Direwolf, + }), + unless: None, + }) + } + (ActionPrompt::Protector { targets }, ActionResponse::Protector(target)) => { + if !targets.iter().any(|p| p.character_id == target) { + return Err(GameError::InvalidTarget); + } + + Ok(ResponseOutcome { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::Protection { + target: target.clone(), + protection: Protection::Protector { + source: current_char.clone(), + }, + }), + unless: Some(Unless::TargetBlocked(target)), + }) + } + + // For other responses that are invalid -- this allows the match to error + // if a new prompt is added + (ActionPrompt::RoleChange { new_role: _ }, _) + | (ActionPrompt::Seer { living_players: _ }, _) + | (ActionPrompt::Protector { targets: _ }, _) + | (ActionPrompt::Arcanist { living_players: _ }, _) + | (ActionPrompt::Gravedigger { dead_players: _ }, _) + | ( + ActionPrompt::Hunter { + current_target: _, + living_players: _, + }, + _, + ) + | (ActionPrompt::Militia { living_players: _ }, _) + | ( + ActionPrompt::MapleWolf { + kill_or_die: _, + living_players: _, + }, + _, + ) + | ( + ActionPrompt::Guardian { + previous: _, + living_players: _, + }, + _, + ) + | ( + ActionPrompt::WolfPackKill { + living_villagers: _, + }, + _, + ) + | (ActionPrompt::Shapeshifter, _) + | ( + ActionPrompt::AlphaWolf { + living_villagers: _, + }, + _, + ) + | (ActionPrompt::DireWolf { living_players: _ }, _) => { + Err(GameError::InvalidMessageForGameState) + } + + (ActionPrompt::WolvesIntro { wolves: _ }, _) => { + Err(GameError::InvalidMessageForGameState) + } + } + } + + pub const fn village(&self) -> &Village { + &self.village + } + + pub const fn current_result(&self) -> Option<&ActionResult> { + match &self.night_state { + NightState::Active { + current_prompt: _, + current_char: _, + current_result, + } => current_result.as_ref(), + NightState::Complete => None, + } + } + + pub const fn current_prompt(&self) -> Option<&ActionPrompt> { + match &self.night_state { + NightState::Active { + current_prompt, + current_char: _, + current_result: _, + } => Some(current_prompt), + NightState::Complete => None, + } + } + + pub const fn current_character_id(&self) -> Option<&CharacterId> { + match &self.night_state { + NightState::Active { + current_prompt: _, + current_char, + current_result: _, + } => Some(current_char), + NightState::Complete => None, + } + } + + pub fn current_character(&self) -> Option<&Character> { + self.current_character_id() + .and_then(|id| self.village.character_by_id(id)) + } + + pub const fn complete(&self) -> bool { + matches!(self.night_state, NightState::Complete) + } + + pub fn next(&mut self) -> Result<()> { + match &self.night_state { + NightState::Active { + current_prompt: _, + current_char: _, + current_result: Some(_), + } => {} + NightState::Active { + current_prompt: _, + current_char: _, + current_result: None, + } => return Err(GameError::AwaitingResponse), + NightState::Complete => return Err(GameError::NightOver), + } + if let Some((prompt, character)) = self.action_queue.pop_front() { + self.night_state = NightState::Active { + current_prompt: prompt, + current_char: character.character_id().clone(), + current_result: None, + }; + } else { + self.night_state = NightState::Complete; + } + + Ok(()) + } + + pub const fn changes(&self) -> &[NightChange] { + self.changes.as_slice() + } +} + +struct ChangesLookup<'a>(&'a [NightChange]); + +impl<'a> ChangesLookup<'a> { + pub fn killed(&self, target: &CharacterId) -> Option<&'a DiedTo> { + self.0.iter().find_map(|c| match c { + NightChange::Kill { target: t, died_to } => (t == target).then_some(died_to), + _ => None, + }) + } + + pub fn protected(&self, target: &CharacterId) -> Option<&'a Protection> { + self.0.iter().find_map(|c| match c { + NightChange::Protection { + target: t, + protection, + } => (t == target).then_some(protection), + _ => None, + }) + } + + pub fn wolf_pack_kill_target(&self) -> Option<&'a CharacterId> { + self.0.iter().find_map(|c| match c { + NightChange::Kill { + target, + died_to: DiedTo::Wolfpack { night: _ }, + } => Some(target), + _ => None, + }) + } +} diff --git a/werewolves-proto/src/game/settings.rs b/werewolves-proto/src/game/settings.rs new file mode 100644 index 0000000..b0173dd --- /dev/null +++ b/werewolves-proto/src/game/settings.rs @@ -0,0 +1,127 @@ +use super::Result; +use core::num::NonZeroU8; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::{error::GameError, role::RoleTitle}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GameSettings { + roles: HashMap, +} + +impl Default for GameSettings { + fn default() -> Self { + Self { + roles: [ + (RoleTitle::Werewolf, NonZeroU8::new(1).unwrap()), + (RoleTitle::Seer, NonZeroU8::new(1).unwrap()), + // (RoleTitle::Militia, NonZeroU8::new(1).unwrap()), + // (RoleTitle::Guardian, NonZeroU8::new(1).unwrap()), + (RoleTitle::Apprentice, NonZeroU8::new(1).unwrap()), + ] + .into_iter() + .collect(), + } + } +} + +impl GameSettings { + pub fn spread(&self) -> Box<[RoleTitle]> { + self.roles + .iter() + .flat_map(|(r, c)| [*r].repeat(c.get() as _)) + .collect() + } + pub fn wolves_count(&self) -> usize { + self.roles + .iter() + .filter_map(|(r, c)| { + if r.wolf() { + Some(c.get() as usize) + } else { + None + } + }) + .sum() + } + + pub fn village_roles_count(&self) -> usize { + self.roles + .iter() + .filter_map(|(r, c)| { + if !r.wolf() { + Some(c.get() as usize) + } else { + None + } + }) + .sum() + } + + pub fn roles(&self) -> Box<[(RoleTitle, NonZeroU8)]> { + self.roles.iter().map(|(r, c)| (*r, *c)).collect() + } + + pub fn villagers_needed_for_player_count(&self, players: usize) -> Result { + let min = self.min_players_needed(); + if min > players { + return Err(GameError::TooFewPlayers { + got: players as _, + need: min as _, + }); + } + Ok(players - self.roles.values().map(|c| c.get() as usize).sum::()) + } + + pub fn check(&self) -> Result<()> { + if self.wolves_count() == 0 { + return Err(GameError::NoWolves); + } + if self + .roles + .iter() + .any(|(r, _)| matches!(r, RoleTitle::Apprentice)) + && self.roles.iter().filter(|(r, _)| r.is_mentor()).count() == 0 + { + return Err(GameError::NoApprenticeMentor); + } + Ok(()) + } + + pub fn min_players_needed(&self) -> usize { + let (wolves, villagers) = (self.wolves_count(), self.village_roles_count()); + + if wolves > villagers { + wolves + 1 + wolves + } else if wolves < villagers { + wolves + villagers + } else { + wolves + villagers + 1 + } + } + + pub fn add(&mut self, role: RoleTitle) -> Result<()> { + if role == RoleTitle::Villager { + return Err(GameError::CantAddVillagerToSettings); + } + match self.roles.get_mut(&role) { + Some(count) => *count = NonZeroU8::new(count.get() + 1).unwrap(), + None => { + self.roles.insert(role, NonZeroU8::new(1).unwrap()); + } + } + Ok(()) + } + + pub fn sub(&mut self, role: RoleTitle) { + if let Some(count) = self.roles.get_mut(&role) + && count.get() != 1 + { + *count = NonZeroU8::new(count.get() - 1).unwrap(); + } else { + self.roles.remove(&role); + } + } +} diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs new file mode 100644 index 0000000..1050ff0 --- /dev/null +++ b/werewolves-proto/src/game/village.rs @@ -0,0 +1,248 @@ +use core::num::NonZeroU8; + +use rand::{Rng, seq::SliceRandom}; +use serde::{Deserialize, Serialize}; + +use super::Result; +use crate::{ + error::GameError, + game::{DateTime, GameOver, GameSettings}, + message::{Identification, Target}, + player::{Character, CharacterId, PlayerId}, + role::{Role, RoleTitle}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Village { + characters: Vec, + date_time: DateTime, +} + +impl Village { + pub fn new(players: &[Identification], settings: GameSettings) -> Result { + if settings.min_players_needed() > players.len() { + return Err(GameError::TooManyRoles { + players: players.len() as u8, + roles: settings.min_players_needed() as u8, + }); + } + settings.check()?; + + let roles_spread = settings.spread(); + let potential_apprentice_havers = roles_spread + .iter() + .filter(|r| r.is_mentor()) + .map(|r| r.title_to_role_excl_apprentice()) + .collect::>(); + + let mut roles = roles_spread + .into_iter() + .chain( + (0..settings.villagers_needed_for_player_count(players.len())?) + .map(|_| RoleTitle::Villager), + ) + .map(|title| match title { + RoleTitle::Apprentice => Role::Apprentice(Box::new( + potential_apprentice_havers + [rand::rng().random_range(0..potential_apprentice_havers.len())] + .clone(), + )), + _ => title.title_to_role_excl_apprentice(), + }) + .collect::>(); + + assert_eq!(players.len(), roles.len()); + roles.shuffle(&mut rand::rng()); + Ok(Self { + characters: players + .iter() + .cloned() + .zip(roles) + .map(|(player, role)| Character::new(player, role)) + .collect(), + date_time: DateTime::Night { number: 0 }, + }) + } + + pub const fn date_time(&self) -> DateTime { + self.date_time + } + + pub fn find_by_character_id(&self, character_id: &CharacterId) -> Option<&Character> { + self.characters + .iter() + .find(|c| c.character_id() == character_id) + } + + pub fn find_by_character_id_mut( + &mut self, + character_id: &CharacterId, + ) -> Option<&mut Character> { + self.characters + .iter_mut() + .find(|c| c.character_id() == character_id) + } + + fn wolves_count(&self) -> usize { + self.characters.iter().filter(|c| c.is_wolf()).count() + } + + fn villager_count(&self) -> usize { + self.characters.iter().filter(|c| c.is_village()).count() + } + + pub fn is_game_over(&self) -> Option { + let wolves = self.wolves_count(); + let villagers = self.villager_count(); + + if wolves == 0 { + return Some(GameOver::VillageWins); + } + + if wolves >= villagers { + return Some(GameOver::WolvesWin); + } + + None + } + + pub fn execute(&mut self, characters: &[CharacterId]) -> Result> { + let day = match self.date_time { + DateTime::Day { number } => number, + DateTime::Night { number: _ } => return Err(GameError::NoExecutionsAtNight), + }; + + if characters.is_empty() { + return Err(GameError::NoTrialNotAllowed); + } + let targets = self + .characters + .iter_mut() + .filter(|c| characters.contains(c.character_id())) + .collect::>(); + if targets.len() != characters.len() { + return Err(GameError::CannotFindTargetButShouldBeThere); + } + for t in targets { + t.execute(day)?; + } + + self.date_time = self.date_time.next(); + Ok(self.is_game_over()) + } + + pub fn to_day(&mut self) -> Result { + if self.date_time.is_day() { + return Err(GameError::AlreadyDaytime); + } + self.date_time = self.date_time.next(); + Ok(self.date_time) + } + + pub fn living_wolf_pack_players(&self) -> Box<[Character]> { + self.characters + .iter() + .filter(|c| c.role().wolf() && c.alive()) + .cloned() + .collect() + } + + pub fn living_players(&self) -> Box<[Target]> { + self.characters + .iter() + .filter(|c| c.alive()) + .map(Character::target) + .collect() + } + + pub fn target_by_id(&self, character_id: &CharacterId) -> Option { + self.character_by_id(character_id).map(Character::target) + } + + pub fn living_villagers(&self) -> Box<[Target]> { + self.characters + .iter() + .filter(|c| c.alive() && c.is_village()) + .map(Character::target) + .collect() + } + + pub fn living_players_excluding(&self, exclude: &CharacterId) -> Box<[Target]> { + self.characters + .iter() + .filter(|c| c.alive() && c.character_id() != exclude) + .map(Character::target) + .collect() + } + + pub fn dead_targets(&self) -> Box<[Target]> { + self.characters + .iter() + .filter(|c| !c.alive()) + .map(Character::target) + .collect() + } + + pub fn dead_characters(&self) -> Box<[&Character]> { + self.characters.iter().filter(|c| !c.alive()).collect() + } + + pub fn living_characters_by_role(&self, role: RoleTitle) -> Box<[Character]> { + self.characters + .iter() + .filter(|c| c.role().title() == role) + .cloned() + .collect() + } + + pub fn characters(&self) -> Box<[Character]> { + self.characters.iter().cloned().collect() + } + + pub fn character_by_id_mut(&mut self, character_id: &CharacterId) -> Option<&mut Character> { + self.characters + .iter_mut() + .find(|c| c.character_id() == character_id) + } + + pub fn character_by_id(&self, character_id: &CharacterId) -> Option<&Character> { + self.characters + .iter() + .find(|c| c.character_id() == character_id) + } + + pub fn character_by_player_id(&self, player_id: &PlayerId) -> Option<&Character> { + self.characters.iter().find(|c| c.player_id() == player_id) + } +} + +impl RoleTitle { + pub fn title_to_role_excl_apprentice(self) -> Role { + match self { + RoleTitle::Villager => Role::Villager, + RoleTitle::Scapegoat => Role::Scapegoat, + RoleTitle::Seer => Role::Seer, + RoleTitle::Arcanist => Role::Arcanist, + RoleTitle::Elder => Role::Elder { + knows_on_night: NonZeroU8::new(rand::rng().random_range(1u8..3)).unwrap(), + }, + RoleTitle::Werewolf => Role::Werewolf, + RoleTitle::AlphaWolf => Role::AlphaWolf { killed: None }, + RoleTitle::DireWolf => Role::DireWolf, + RoleTitle::Shapeshifter => Role::Shapeshifter { shifted_into: None }, + RoleTitle::Apprentice => panic!("title_to_role_excl_apprentice got an apprentice role"), + RoleTitle::Protector => Role::Protector { + last_protected: None, + }, + RoleTitle::Gravedigger => Role::Gravedigger, + RoleTitle::Hunter => Role::Hunter { target: None }, + RoleTitle::Militia => Role::Militia { targeted: None }, + RoleTitle::MapleWolf => Role::MapleWolf { + last_kill_on_night: 0, + }, + RoleTitle::Guardian => Role::Guardian { + last_protected: None, + }, + } + } +} diff --git a/werewolves-proto/src/lib.rs b/werewolves-proto/src/lib.rs new file mode 100644 index 0000000..74ec04e --- /dev/null +++ b/werewolves-proto/src/lib.rs @@ -0,0 +1,30 @@ +#![allow(clippy::new_without_default)] +use error::GameError; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +// pub mod action; +pub mod diedto; +pub mod error; +pub mod game; +pub mod message; +pub mod modifier; +pub mod nonzero; +pub mod player; +pub mod role; + +#[derive(Debug, Error, Clone, Serialize, Deserialize)] +pub enum MessageError { + #[error("{0}")] + GameError(#[from] GameError), +} + +pub(crate) trait MustBeInVillage { + fn must_be_in_village(self) -> Result; +} + +impl MustBeInVillage for Option { + fn must_be_in_village(self) -> Result { + self.ok_or(GameError::CannotFindTargetButShouldBeThere) + } +} diff --git a/werewolves-proto/src/message.rs b/werewolves-proto/src/message.rs new file mode 100644 index 0000000..f34229e --- /dev/null +++ b/werewolves-proto/src/message.rs @@ -0,0 +1,79 @@ +pub mod host; +mod ident; +pub mod night; + +use core::{fmt::Display, num::NonZeroU8}; + +pub use ident::*; +use night::{ActionPrompt, ActionResponse, ActionResult, RoleChange}; +use serde::{Deserialize, Serialize}; + +use crate::{ + error::GameError, + game::GameOver, + player::{Character, CharacterId}, + role::RoleTitle, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ClientMessage { + Hello, + Goodbye, + GetState, + RoleAck, + UpdateSelf(UpdateSelf), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum UpdateSelf { + Name(String), + Number(NonZeroU8), + Pronouns(Option), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DayCharacter { + pub character_id: CharacterId, + pub name: String, + pub alive: bool, +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct Target { + pub character_id: CharacterId, + pub public: PublicIdentity, +} + +impl Display for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Target { + character_id, + public, + } = self; + write!(f, "{public} [(c){character_id}]") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ServerMessage { + Disconnect, + LobbyInfo { + joined: bool, + players: Box<[PublicIdentity]>, + }, + GameInProgress, + GameStart { + role: RoleTitle, + }, + InvalidMessageForGameState, + NoSuchTarget, + GameOver(GameOver), + Update(PlayerUpdate), + Sleep, + Reset, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PlayerUpdate { + Number(NonZeroU8), +} diff --git a/werewolves-proto/src/message/host.rs b/werewolves-proto/src/message/host.rs new file mode 100644 index 0000000..b06a7f0 --- /dev/null +++ b/werewolves-proto/src/message/host.rs @@ -0,0 +1,74 @@ +use core::num::NonZeroU8; + +use serde::{Deserialize, Serialize}; + +use crate::{ + error::GameError, + game::{GameOver, GameSettings}, + message::{ + PublicIdentity, Target, + night::{ActionPrompt, ActionResponse, ActionResult}, + }, + player::{CharacterId, PlayerId}, +}; + +use super::{CharacterState, PlayerState}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostMessage { + GetState, + Lobby(HostLobbyMessage), + InGame(HostGameMessage), + ForceRoleAckFor(CharacterId), + NewLobby, + Echo(ServerToHostMessage), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostGameMessage { + Day(HostDayMessage), + Night(HostNightMessage), + GetState, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostNightMessage { + ActionResponse(ActionResponse), + Next, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostDayMessage { + Execute, + MarkForExecution(CharacterId), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HostLobbyMessage { + GetState, + Kick(PlayerId), + GetGameSettings, + SetGameSettings(GameSettings), + Start, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ServerToHostMessage { + Disconnect, + Daytime { + characters: Box<[CharacterState]>, + marked: Box<[CharacterId]>, + day: NonZeroU8, + }, + ActionPrompt(PublicIdentity, ActionPrompt), + ActionResult(PublicIdentity, ActionResult), + Lobby(Box<[PlayerState]>), + GameSettings(GameSettings), + Error(GameError), + GameOver(GameOver), + WaitingForRoleRevealAcks { + ackd: Box<[Target]>, + waiting: Box<[Target]>, + }, + CoverOfDarkness, +} diff --git a/werewolves-proto/src/message/ident.rs b/werewolves-proto/src/message/ident.rs new file mode 100644 index 0000000..972787b --- /dev/null +++ b/werewolves-proto/src/message/ident.rs @@ -0,0 +1,75 @@ +use core::{fmt::Display, num::NonZeroU8}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + diedto::DiedTo, + player::{CharacterId, PlayerId}, + role::RoleTitle, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Identification { + pub player_id: PlayerId, + pub public: PublicIdentity, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PublicIdentity { + pub name: String, + pub pronouns: Option, + pub number: NonZeroU8, +} + +impl Default for PublicIdentity { + fn default() -> Self { + Self { + name: Default::default(), + pronouns: Default::default(), + number: NonZeroU8::new(1).unwrap(), + } + } +} + +impl Display for PublicIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let PublicIdentity { + name, + pronouns, + number, + } = self; + let pronouns = pronouns + .as_ref() + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + write!(f, "[{number}] {name}{pronouns}") + } +} + +impl core::hash::Hash for Identification { + fn hash(&self, state: &mut H) { + self.player_id.hash(state); + } +} + +impl Display for Identification { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Identification { player_id, public } = self; + write!(f, "{public} [{player_id}]") + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlayerState { + pub identification: Identification, + pub connected: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CharacterState { + pub player_id: PlayerId, + pub character_id: CharacterId, + pub public_identity: PublicIdentity, + pub role: RoleTitle, + pub died_to: Option, +} diff --git a/werewolves-proto/src/message/night.rs b/werewolves-proto/src/message/night.rs new file mode 100644 index 0000000..dcbc1c4 --- /dev/null +++ b/werewolves-proto/src/message/night.rs @@ -0,0 +1,133 @@ +use serde::{Deserialize, Serialize}; +use werewolves_macros::ChecksAs; + +use crate::{ + player::CharacterId, + role::{Alignment, PreviousGuardianAction, Role, RoleTitle}, +}; + +use super::Target; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ActionPrompt { + WolvesIntro { + wolves: Box<[(Target, RoleTitle)]>, + }, + RoleChange { + new_role: RoleTitle, + }, + Seer { + living_players: Box<[Target]>, + }, + Protector { + targets: Box<[Target]>, + }, + Arcanist { + living_players: Box<[Target]>, + }, + Gravedigger { + dead_players: Box<[Target]>, + }, + Hunter { + current_target: Option, + living_players: Box<[Target]>, + }, + Militia { + living_players: Box<[Target]>, + }, + MapleWolf { + kill_or_die: bool, + living_players: Box<[Target]>, + }, + Guardian { + previous: Option, + living_players: Box<[Target]>, + }, + WolfPackKill { + living_villagers: Box<[Target]>, + }, + Shapeshifter, + AlphaWolf { + living_villagers: Box<[Target]>, + }, + DireWolf { + living_players: Box<[Target]>, + }, +} + +impl PartialOrd for ActionPrompt { + fn partial_cmp(&self, other: &Self) -> Option { + fn ordering_num(prompt: &ActionPrompt) -> u8 { + match prompt { + ActionPrompt::WolvesIntro { wolves: _ } => 0, + ActionPrompt::Guardian { + living_players: _, + previous: _, + } + | ActionPrompt::Protector { targets: _ } => 1, + ActionPrompt::WolfPackKill { + living_villagers: _, + } => 2, + ActionPrompt::Shapeshifter => 3, + ActionPrompt::AlphaWolf { + living_villagers: _, + } => 4, + ActionPrompt::DireWolf { living_players: _ } => 5, + ActionPrompt::Seer { living_players: _ } + | ActionPrompt::Arcanist { living_players: _ } + | ActionPrompt::Gravedigger { dead_players: _ } + | ActionPrompt::Hunter { + current_target: _, + living_players: _, + } + | ActionPrompt::Militia { living_players: _ } + | ActionPrompt::MapleWolf { + kill_or_die: _, + living_players: _, + } + | ActionPrompt::RoleChange { new_role: _ } => 0xFF, + } + } + ordering_num(self).partial_cmp(&ordering_num(other)) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Deserialize, ChecksAs)] +pub enum ActionResponse { + Seer(CharacterId), + Arcanist(CharacterId, CharacterId), + Gravedigger(CharacterId), + Hunter(CharacterId), + Militia(Option), + MapleWolf(Option), + Guardian(CharacterId), + WolfPackKillVote(CharacterId), + #[checks] + Shapeshifter(bool), + AlphaWolf(Option), + Direwolf(CharacterId), + Protector(CharacterId), + #[checks] + RoleChangeAck, + WolvesIntroAck, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ActionResult { + RoleBlocked, + Seer(Alignment), + Arcanist { same: bool }, + GraveDigger(Option), + WolvesMustBeUnanimous, + WaitForOthersToVote, + GoBackToSleep, + RoleRevealDone, + WolvesIntroDone, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RoleChange { + Elder(Role), + Apprentice(Role), + Shapeshift(Role), +} diff --git a/werewolves-proto/src/modifier.rs b/werewolves-proto/src/modifier.rs new file mode 100644 index 0000000..7a7a37c --- /dev/null +++ b/werewolves-proto/src/modifier.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Modifier { + Drunk, + Insane, +} diff --git a/werewolves-proto/src/nonzero.rs b/werewolves-proto/src/nonzero.rs new file mode 100644 index 0000000..9fb30ce --- /dev/null +++ b/werewolves-proto/src/nonzero.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[repr(transparent)] +pub struct NonZeroSub100U8(u8); +impl NonZeroSub100U8 { + pub const fn new(val: u8) -> Option { + if val == 0 || val > 99 { + None + } else { + Some(Self(val)) + } + } + + pub const fn get(&self) -> u8 { + self.0 + } +} diff --git a/werewolves-proto/src/player.rs b/werewolves-proto/src/player.rs new file mode 100644 index 0000000..1910407 --- /dev/null +++ b/werewolves-proto/src/player.rs @@ -0,0 +1,301 @@ +use core::{fmt::Display, num::NonZeroU8}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + diedto::DiedTo, + error::GameError, + game::{DateTime, Village}, + message::{Identification, PublicIdentity, Target, night::ActionPrompt}, + modifier::Modifier, + role::{MAPLE_WOLF_ABSTAIN_LIMIT, PreviousGuardianAction, Role, RoleTitle}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PlayerId(uuid::Uuid); + +impl PlayerId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } + pub const fn from_u128(v: u128) -> Self { + Self(uuid::Uuid::from_u128(v)) + } +} + +impl Display for PlayerId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct CharacterId(uuid::Uuid); + +impl CharacterId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Display for CharacterId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Player { + id: PlayerId, + name: String, +} + +impl Player { + pub fn new(name: String) -> Self { + Self { + id: PlayerId::new(), + name, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Protection { + Guardian { source: CharacterId, guarding: bool }, + Protector { source: CharacterId }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum KillOutcome { + Killed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Character { + player_id: PlayerId, + character_id: CharacterId, + public: PublicIdentity, + role: Role, + modifier: Option, + died_to: Option, + role_changes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleChange { + role: Role, + new_role: RoleTitle, + changed_on_night: u8, +} + +impl Character { + pub fn new(Identification { player_id, public }: Identification, role: Role) -> Self { + Self { + role, + public, + player_id, + character_id: CharacterId::new(), + modifier: None, + died_to: None, + role_changes: Vec::new(), + } + } + + pub fn target(&self) -> Target { + Target { + character_id: self.character_id.clone(), + public: self.public.clone(), + } + } + + pub const fn public_identity(&self) -> &PublicIdentity { + &self.public + } + + pub fn name(&self) -> &str { + &self.public.name + } + + pub const fn number(&self) -> NonZeroU8 { + self.public.number + } + + pub const fn pronouns(&self) -> Option<&str> { + match self.public.pronouns.as_ref() { + Some(p) => Some(p.as_str()), + None => None, + } + } + + pub fn died_to(&self) -> Option<&DiedTo> { + self.died_to.as_ref() + } + + pub fn kill(&mut self, died_to: DiedTo) { + match &self.died_to { + Some(_) => {} + None => self.died_to = Some(died_to), + } + } + + pub const fn alive(&self) -> bool { + self.died_to.is_none() + } + + pub fn execute(&mut self, day: NonZeroU8) -> Result<(), GameError> { + if self.died_to.is_some() { + return Err(GameError::CharacterAlreadyDead); + } + self.died_to = Some(DiedTo::Execution { day }); + Ok(()) + } + + pub const fn character_id(&self) -> &CharacterId { + &self.character_id + } + + pub const fn player_id(&self) -> &PlayerId { + &self.player_id + } + + pub const fn role(&self) -> &Role { + &self.role + } + + pub const fn role_mut(&mut self) -> &mut Role { + &mut self.role + } + + pub fn role_change(&mut self, new_role: RoleTitle, at: DateTime) -> Result<(), GameError> { + let mut role = new_role.title_to_role_excl_apprentice(); + core::mem::swap(&mut role, &mut self.role); + self.role_changes.push(RoleChange { + role, + new_role, + changed_on_night: match at { + DateTime::Day { number: _ } => return Err(GameError::NotNight), + DateTime::Night { number } => number, + }, + }); + + Ok(()) + } + + pub const fn is_wolf(&self) -> bool { + self.role.wolf() + } + + pub const fn is_village(&self) -> bool { + !self.is_wolf() + } + + pub fn night_action_prompt( + &self, + village: &Village, + ) -> Result, GameError> { + if !self.alive() || !self.role.wakes(village) { + return Ok(None); + } + let night = match village.date_time() { + DateTime::Day { number: _ } => return Err(GameError::NotNight), + DateTime::Night { number } => number, + }; + Ok(Some(match &self.role { + Role::Shapeshifter { + shifted_into: Some(_), + } + | Role::AlphaWolf { killed: Some(_) } + | Role::Militia { targeted: Some(_) } + | Role::Scapegoat + | Role::Villager => return Ok(None), + Role::Seer => ActionPrompt::Seer { + living_players: village.living_players_excluding(&self.character_id), + }, + Role::Arcanist => ActionPrompt::Arcanist { + living_players: village.living_players_excluding(&self.character_id), + }, + Role::Protector { + last_protected: Some(last_protected), + } => ActionPrompt::Protector { + targets: village.living_players_excluding(last_protected), + }, + Role::Protector { + last_protected: None, + } => ActionPrompt::Protector { + targets: village.living_players_excluding(&self.character_id), + }, + Role::Apprentice(role) => { + let current_night = match village.date_time() { + DateTime::Day { number: _ } => return Ok(None), + DateTime::Night { number } => number, + }; + return Ok(village + .characters() + .into_iter() + .filter(|c| c.role().title() == role.title()) + .filter_map(|char| char.died_to) + .any(|died_to| match died_to.date_time() { + DateTime::Day { number } => number.get() + 1 >= current_night, + DateTime::Night { number } => number + 1 >= current_night, + }) + .then(|| ActionPrompt::RoleChange { + new_role: role.title(), + })); + } + Role::Elder { knows_on_night } => { + let current_night = match village.date_time() { + DateTime::Day { number: _ } => return Ok(None), + DateTime::Night { number } => number, + }; + return Ok((current_night == knows_on_night.get()).then_some({ + ActionPrompt::RoleChange { + new_role: RoleTitle::Elder, + } + })); + } + Role::Militia { targeted: None } => ActionPrompt::Militia { + living_players: village.living_players_excluding(&self.character_id), + }, + Role::Werewolf => ActionPrompt::WolfPackKill { + living_villagers: village.living_players(), + }, + Role::AlphaWolf { killed: None } => ActionPrompt::AlphaWolf { + living_villagers: village.living_players_excluding(&self.character_id), + }, + Role::DireWolf => ActionPrompt::DireWolf { + living_players: village.living_players(), + }, + Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter, + Role::Gravedigger => ActionPrompt::Gravedigger { + dead_players: village.dead_targets(), + }, + Role::Hunter { target } => ActionPrompt::Hunter { + current_target: target.as_ref().and_then(|t| village.target_by_id(t)), + living_players: village.living_players_excluding(&self.character_id), + }, + Role::MapleWolf { last_kill_on_night } => ActionPrompt::MapleWolf { + kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night, + living_players: village.living_players_excluding(&self.character_id), + }, + Role::Guardian { + last_protected: Some(PreviousGuardianAction::Guard(prev_target)), + } => ActionPrompt::Guardian { + previous: Some(PreviousGuardianAction::Guard(prev_target.clone())), + living_players: village.living_players_excluding(&prev_target.character_id), + }, + Role::Guardian { + last_protected: Some(PreviousGuardianAction::Protect(prev_target)), + } => ActionPrompt::Guardian { + previous: Some(PreviousGuardianAction::Protect(prev_target.clone())), + living_players: village.living_players(), + }, + Role::Guardian { + last_protected: None, + } => ActionPrompt::Guardian { + previous: None, + living_players: village.living_players(), + }, + })) + } +} diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs new file mode 100644 index 0000000..cc2aa1a --- /dev/null +++ b/werewolves-proto/src/role.rs @@ -0,0 +1,183 @@ +use core::num::NonZeroU8; + +use serde::{Deserialize, Serialize}; +use werewolves_macros::{ChecksAs, Titles}; + +use crate::{ + game::{DateTime, Village}, + message::Target, + player::CharacterId, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, ChecksAs, Titles)] +pub enum Role { + #[checks(Alignment::Village)] + Villager, + #[checks(Alignment::Wolves)] + #[checks("killer")] + #[checks("powerful")] + Scapegoat, + #[checks(Alignment::Village)] + #[checks("powerful")] + #[checks("is_mentor")] + Seer, + #[checks(Alignment::Village)] + #[checks("powerful")] + #[checks("is_mentor")] + Arcanist, + #[checks(Alignment::Village)] + #[checks("powerful")] + #[checks("is_mentor")] + Gravedigger, + #[checks(Alignment::Village)] + #[checks("killer")] + #[checks("powerful")] + #[checks("is_mentor")] + #[checks] + Hunter { target: Option }, + #[checks(Alignment::Village)] + #[checks("killer")] + #[checks("powerful")] + #[checks("is_mentor")] + Militia { targeted: Option }, + #[checks(Alignment::Wolves)] + #[checks("killer")] + #[checks("powerful")] + #[checks("is_mentor")] + MapleWolf { last_kill_on_night: u8 }, + #[checks(Alignment::Village)] + #[checks("powerful")] + #[checks("killer")] + #[checks("is_mentor")] + Guardian { + last_protected: Option, + }, + #[checks(Alignment::Village)] + #[checks("powerful")] + #[checks("is_mentor")] + Protector { last_protected: Option }, + #[checks(Alignment::Village)] + #[checks("powerful")] + Apprentice(Box), + #[checks(Alignment::Village)] + #[checks("powerful")] + #[checks("is_mentor")] + Elder { knows_on_night: NonZeroU8 }, + + #[checks(Alignment::Wolves)] + #[checks("killer")] + #[checks("powerful")] + #[checks("wolf")] + Werewolf, + #[checks(Alignment::Wolves)] + #[checks("killer")] + #[checks("powerful")] + #[checks("wolf")] + AlphaWolf { killed: Option }, + #[checks(Alignment::Village)] + #[checks("killer")] + #[checks("powerful")] + #[checks("wolf")] + DireWolf, + #[checks(Alignment::Wolves)] + #[checks("killer")] + #[checks("powerful")] + #[checks("wolf")] + Shapeshifter { shifted_into: Option }, +} + +impl Role { + /// [RoleTitle] as shown to the player on role assignment + pub const fn initial_shown_role(&self) -> RoleTitle { + match self { + Role::Apprentice(_) | Role::Elder { knows_on_night: _ } => RoleTitle::Villager, + _ => self.title(), + } + } + + pub fn wakes(&self, village: &Village) -> bool { + let night_zero = match village.date_time() { + DateTime::Day { number: _ } => return false, + DateTime::Night { number } => number == 0, + }; + if night_zero { + return match self { + Role::DireWolf | Role::Arcanist | Role::Seer => true, + + Role::Shapeshifter { shifted_into: _ } + | Role::Werewolf + | Role::AlphaWolf { killed: _ } + | Role::Elder { knows_on_night: _ } + | Role::Gravedigger + | Role::Hunter { target: _ } + | Role::Militia { targeted: _ } + | Role::MapleWolf { + last_kill_on_night: _, + } + | Role::Guardian { last_protected: _ } + | Role::Apprentice(_) + | Role::Villager + | Role::Scapegoat + | Role::Protector { last_protected: _ } => false, + }; + } + match self { + Role::AlphaWolf { killed: Some(_) } + | Role::Werewolf + | Role::Scapegoat + | Role::Militia { targeted: Some(_) } + | Role::Villager => false, + + Role::Shapeshifter { shifted_into: _ } + | Role::DireWolf + | Role::AlphaWolf { killed: None } + | Role::Arcanist + | Role::Protector { last_protected: _ } + | Role::Gravedigger + | Role::Hunter { target: _ } + | Role::Militia { targeted: None } + | Role::MapleWolf { + last_kill_on_night: _, + } + | Role::Guardian { last_protected: _ } + | Role::Seer => true, + + Role::Apprentice(role) => village + .characters() + .iter() + .any(|c| c.role().title() == role.title()), + + Role::Elder { knows_on_night } => match village.date_time() { + DateTime::Night { number } => number == knows_on_night.get(), + _ => false, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Alignment { + Village, + Wolves, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ChecksAs)] +pub enum ArcanistCheck { + #[checks] + Same, + #[checks] + Different, +} + +pub const MAPLE_WOLF_ABSTAIN_LIMIT: NonZeroU8 = NonZeroU8::new(3).unwrap(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RoleBlock { + Direwolf, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum PreviousGuardianAction { + Protect(Target), + Guard(Target), +} diff --git a/werewolves-server/Cargo.toml b/werewolves-server/Cargo.toml new file mode 100644 index 0000000..ade8660 --- /dev/null +++ b/werewolves-server/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "werewolves-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1.44", features = ["full"] } +log = { version = "0.4" } +pretty_env_logger = { version = "0.5" } +# env_logger = { version = "0.11" } +futures = "0.3.31" +anyhow = { version = "1" } +werewolves-proto = { path = "../werewolves-proto" } +werewolves-macros = { path = "../werewolves-macros" } +mime-sniffer = { version = "0.1" } +chrono = { version = "0.4" } +atom_syndication = { version = "0.12" } +axum-extra = { version = "0.10", features = ["typed-header"] } +rand = { version = "0.9" } +serde_json = { version = "1.0" } +serde = { version = "1.0", features = ["derive"] } +thiserror = { version = "2" } +ciborium = { version = "0.2", optional = true } +colored = { version = "3.0" } + +[features] +# default = ["cbor"] +cbor = ["dep:ciborium"] diff --git a/werewolves-server/pkg/blog.service b/werewolves-server/pkg/blog.service new file mode 100644 index 0000000..e2b0076 --- /dev/null +++ b/werewolves-server/pkg/blog.service @@ -0,0 +1,19 @@ +[Unit] +Description=blog +After=network.target + +[Service] +Type=simple + +User=blog +Group=blog + +WorkingDirectory=/home/blog +Environment=RUST_LOG=info +Environment=PORT=3024 +ExecStart=/home/blog/blog-server + +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/werewolves-server/src/client.rs b/werewolves-server/src/client.rs new file mode 100644 index 0000000..77965e3 --- /dev/null +++ b/werewolves-server/src/client.rs @@ -0,0 +1,257 @@ +use core::net::SocketAddr; + +use crate::{ + AppState, XForwardedFor, + connection::{ConnectionId, JoinedPlayer}, + runner::IdentifiedClientMessage, +}; +use axum::{ + extract::{ + ConnectInfo, State, WebSocketUpgrade, + ws::{self, Message, WebSocket}, + }, + response::IntoResponse, +}; +use axum_extra::{TypedHeader, headers}; +use colored::Colorize; +use tokio::sync::broadcast::{Receiver, Sender}; +use werewolves_proto::message::{ClientMessage, Identification, ServerMessage, UpdateSelf}; + +pub async fn handler( + ws: WebSocketUpgrade, + user_agent: Option>, + x_forwarded_for: Option>, + ConnectInfo(addr): ConnectInfo, + State(state): State, +) -> impl IntoResponse { + let who = x_forwarded_for + .map(|x| x.to_string()) + .unwrap_or_else(|| addr.to_string()) + .italic(); + log::info!( + "{who}{} connected.", + user_agent + .map(|agent| format!(" (User-Agent: {})", agent.as_str())) + .unwrap_or_default(), + ); + let player_list = state.joined_players; + + // finalize the upgrade process by returning upgrade callback. + // we can customize the callback by sending additional info such as address. + ws.on_upgrade(move |mut socket| async move { + let ident = match get_identification(&mut socket, &who).await { + Ok(ident) => ident, + Err(err) => { + log::warn!("identification failed for {who}: {err}"); + return; + } + }; + log::info!("connected {who} as {ident}"); + let connection_id = ConnectionId::new(ident.player_id.clone()); + let recv = { + let (send, recv) = tokio::sync::broadcast::channel(100); + player_list + .insert_or_replace( + ident.player_id.clone(), + JoinedPlayer::new( + send, + recv, + connection_id.clone(), + ident.public.name.clone(), + ident.public.number.clone(), + ident.public.pronouns.clone(), + ), + ) + .await + }; + + Client::new( + ident.clone(), + connection_id.clone(), + socket, + who.to_string(), + state.send, + recv, + ) + .run() + .await; + + log::info!("ending connection with {who}"); + player_list.disconnect(&connection_id).await; + }) +} + +async fn get_identification( + socket: &mut WebSocket, + who: &str, +) -> Result { + loop { + let msg_bytes = &socket + .recv() + .await + .ok_or(anyhow::anyhow!( + "connection from {who} closed before identification" + ))? + .map_err(|err| anyhow::anyhow!("connection error from {who} at identification: {err}"))? + .into_data(); + #[cfg(not(feature = "cbor"))] + let res = serde_json::from_slice::(msg_bytes); + #[cfg(feature = "cbor")] + let res = ciborium::from_reader::(msg_bytes); + match res { + Ok(id) => return Ok(id), + Err(err) => { + log::error!("invalid identification message from {who}: {err}"); + continue; + } + } + } +} + +#[allow(unused)] +enum MessagePayload { + Bytes(axum::body::Bytes), + Utf8(ws::Utf8Bytes), +} + +struct Client { + ident: Identification, + connection_id: ConnectionId, + socket: WebSocket, + who: String, + sender: Sender, + receiver: Receiver, + message_history: Vec, +} + +impl Client { + fn new( + ident: Identification, + connection_id: ConnectionId, + socket: WebSocket, + who: String, + sender: Sender, + receiver: Receiver, + ) -> Self { + Self { + ident, + connection_id, + socket, + who, + sender, + receiver, + message_history: Vec::new(), + } + } + #[cfg(feature = "cbor")] + fn decode_message(msg: MessagePayload) -> Result { + match msg { + MessagePayload::Bytes(bytes) => Ok(ciborium::from_reader(bytes.iter().as_slice())?), + MessagePayload::Utf8(_) => Err(anyhow::anyhow!( + "cbor doesn't use utf8 strings for messages" + )), + } + } + + #[cfg(not(feature = "cbor"))] + fn decode_message(msg: MessagePayload) -> Result { + match msg { + MessagePayload::Bytes(bytes) => Ok(serde_json::from_slice(&bytes)?), + MessagePayload::Utf8(text) => Ok(serde_json::from_str(&text)?), + } + } + + async fn on_recv( + &mut self, + msg: Result, + conn_id: ConnectionId, + ) -> Result<(), anyhow::Error> { + use crate::LogError; + + let msg = match msg { + Ok(msg) => msg, + Err(err) => return Err(err.into()), + }; + + let message: ClientMessage = match msg { + Message::Binary(bytes) => Self::decode_message(MessagePayload::Bytes(bytes))?, + Message::Text(text) => Self::decode_message(MessagePayload::Utf8(text))?, + Message::Ping(ping) => { + self.socket.send(Message::Pong(ping)).await.log_debug(); + return Ok(()); + } + Message::Pong(_) => return Ok(()), + Message::Close(Some(close_frame)) => { + log::debug!("sent close frame: {close_frame:?}"); + return Ok(()); + } + Message::Close(None) => { + log::debug!("host closed connection"); + return Ok(()); + } + }; + if let ClientMessage::UpdateSelf(update) = &message { + match update { + UpdateSelf::Name(name) => self.ident.public.name = name.clone(), + UpdateSelf::Number(num) => self.ident.public.number = *num, + UpdateSelf::Pronouns(pronouns) => self.ident.public.pronouns = pronouns.clone(), + } + } + + self.sender.send(IdentifiedClientMessage { + message, + identity: self.ident.clone(), + })?; + + Ok(()) + } + + async fn handle_message(&mut self, message: ServerMessage) -> Result<(), anyhow::Error> { + self.socket + .send({ + #[cfg(not(feature = "cbor"))] + { + ws::Message::Text(serde_json::to_string(&message)?.into()) + } + #[cfg(feature = "cbor")] + ws::Message::Binary({ + let mut v = Vec::new(); + ciborium::into_writer(&message, &mut v)?; + v.into() + }) + }) + .await?; + self.message_history.push(message); + + Ok(()) + } + + async fn run(mut self) { + loop { + if let Err(err) = tokio::select! { + msg = self.socket.recv() => { + match msg { + Some(msg) => self.on_recv(msg, self.connection_id.clone()).await, + None => { + return; + }, + } + }, + r = self.receiver.recv() => { + match r { + Ok(msg) => { + self.handle_message(msg).await + } + Err(err) => { + log::warn!("[{}] recv error: {err}", self.connection_id.player_id()); + return; + } + } + } + } { + log::error!("[{}][{}] {err}", self.connection_id.player_id(), self.who); + return; + } + } + } +} diff --git a/werewolves-server/src/communication/host.rs b/werewolves-server/src/communication/host.rs new file mode 100644 index 0000000..a098576 --- /dev/null +++ b/werewolves-server/src/communication/host.rs @@ -0,0 +1,42 @@ +use tokio::sync::{broadcast::Sender, mpsc::Receiver}; +use werewolves_proto::{ + error::GameError, + message::host::{HostMessage, ServerToHostMessage}, +}; + +pub struct HostComms { + send: Sender, + recv: Receiver, +} + +impl HostComms { + pub const fn new( + host_send: Sender, + host_recv: Receiver, + ) -> Self { + Self { + send: host_send, + recv: host_recv, + } + } + + #[cfg(debug_assertions)] + pub async fn recv(&mut self) -> Option { + match self.recv.recv().await { + Some(msg) => Some(msg), + None => None, + } + } + + #[cfg(not(debug_assertions))] + pub async fn recv(&mut self) -> Option { + self.recv.recv().await + } + + pub fn send(&mut self, message: ServerToHostMessage) -> Result<(), GameError> { + self.send + .send(message) + .map_err(|err| GameError::GenericError(err.to_string()))?; + Ok(()) + } +} diff --git a/werewolves-server/src/communication/lobby.rs b/werewolves-server/src/communication/lobby.rs new file mode 100644 index 0000000..831144d --- /dev/null +++ b/werewolves-server/src/communication/lobby.rs @@ -0,0 +1,47 @@ +use tokio::sync::broadcast::Receiver; +use werewolves_proto::{error::GameError, player::PlayerId}; + +use crate::{communication::Comms, runner::Message}; + +use super::{HostComms, player::PlayerIdComms}; + +pub struct LobbyComms { + comms: Comms, + connect_recv: Receiver<(PlayerId, bool)>, +} + +impl LobbyComms { + pub fn new(comms: Comms, connect_recv: Receiver<(PlayerId, bool)>) -> Self { + Self { + comms, + connect_recv, + } + } + + pub fn into_inner(self) -> (Comms, Receiver<(PlayerId, bool)>) { + (self.comms, self.connect_recv) + } + + pub const fn player(&mut self) -> &mut PlayerIdComms { + self.comms.player() + } + + pub const fn host(&mut self) -> &mut HostComms { + self.comms.host() + } + + pub async fn next_message(&mut self) -> Result { + tokio::select! { + r = self.comms.message() => { + r + } + r = self.connect_recv.recv() => { + match r { + Ok((player_id, true)) => Ok(Message::Connect(player_id)), + Ok((player_id, false)) => Ok(Message::Disconnect(player_id)), + Err(err) => Err(GameError::GenericError(err.to_string())), + } + } + } + } +} diff --git a/werewolves-server/src/communication/mod.rs b/werewolves-server/src/communication/mod.rs new file mode 100644 index 0000000..865ea84 --- /dev/null +++ b/werewolves-server/src/communication/mod.rs @@ -0,0 +1,44 @@ +use werewolves_proto::error::GameError; + +use crate::{ + communication::{host::HostComms, player::PlayerIdComms}, + runner::Message, +}; + +pub mod host; +pub mod lobby; +pub mod player; + +pub struct Comms { + host: HostComms, + player: PlayerIdComms, +} + +impl Comms { + pub const fn new(host: HostComms, player: PlayerIdComms) -> Self { + Self { host, player } + } + + pub const fn host(&mut self) -> &mut HostComms { + &mut self.host + } + + pub const fn player(&mut self) -> &mut PlayerIdComms { + &mut self.player + } + + pub async fn message(&mut self) -> Result { + tokio::select! { + msg = self.host.recv() => { + match msg { + Some(msg) => Ok(Message::Host(msg)), + None => Err(GameError::HostChannelClosed), + } + + } + Ok(msg) = self.player.recv() => { + Ok(Message::Client(msg)) + } + } + } +} diff --git a/werewolves-server/src/communication/player.rs b/werewolves-server/src/communication/player.rs new file mode 100644 index 0000000..32fce4d --- /dev/null +++ b/werewolves-server/src/communication/player.rs @@ -0,0 +1,50 @@ +use core::time::Duration; +use std::collections::HashMap; + +use colored::Colorize; +use tokio::{ + sync::broadcast::{Receiver, Sender}, + time::Instant, +}; +use werewolves_proto::{ + error::GameError, + message::{ClientMessage, ServerMessage, Target, night::ActionResponse}, + player::{Character, CharacterId, PlayerId}, +}; + +use crate::{connection::JoinedPlayers, runner::IdentifiedClientMessage}; + +pub struct PlayerIdComms { + joined_players: JoinedPlayers, + message_recv: Receiver, + connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>, +} + +impl PlayerIdComms { + pub fn new( + joined_players: JoinedPlayers, + message_recv: Receiver, + connect_recv: tokio::sync::broadcast::Receiver<(PlayerId, bool)>, + ) -> Self { + Self { + joined_players, + message_recv, + connect_recv, + } + } + + pub async fn recv(&mut self) -> Result { + match self + .message_recv + .recv() + .await + .map_err(|err| GameError::GenericError(err.to_string())) + { + Ok(msg) => { + log::debug!("got message: {}", format!("{msg:?}").dimmed()); + Ok(msg) + } + Err(err) => Err(err), + } + } +} diff --git a/werewolves-server/src/connection.rs b/werewolves-server/src/connection.rs new file mode 100644 index 0000000..caa0fba --- /dev/null +++ b/werewolves-server/src/connection.rs @@ -0,0 +1,231 @@ +use core::num::NonZeroU8; +use std::{collections::HashMap, sync::Arc}; + +use colored::Colorize; +use tokio::{ + sync::{ + Mutex, + broadcast::{Receiver, Sender}, + }, + time::Instant, +}; +use werewolves_proto::{ + error::GameError, + message::{PublicIdentity, ServerMessage}, + player::PlayerId, +}; + +use crate::LogError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ConnectionId(PlayerId, Instant); + +impl ConnectionId { + pub fn new(player_id: PlayerId) -> Self { + Self(player_id, Instant::now()) + } + + pub const fn player_id(&self) -> &PlayerId { + &self.0 + } + + pub const fn connect_time(&self) -> &Instant { + &self.1 + } +} + +#[derive(Debug)] +pub struct JoinedPlayer { + sender: Sender, + receiver: Receiver, + active_connection: ConnectionId, + in_game: bool, + pub name: String, + pub number: NonZeroU8, + pub pronouns: Option, +} + +impl JoinedPlayer { + pub const fn new( + sender: Sender, + receiver: Receiver, + active_connection: ConnectionId, + name: String, + number: NonZeroU8, + pronouns: Option, + ) -> Self { + Self { + name, + number, + sender, + pronouns, + receiver, + active_connection, + in_game: false, + } + } + pub fn resubscribe_reciever(&self) -> Receiver { + self.receiver.resubscribe() + } + + pub fn sender(&self) -> Sender { + self.sender.clone() + } +} + +#[derive(Debug, Clone)] +pub struct JoinedPlayers { + players: Arc>>, + connect_state_sender: Sender<(PlayerId, bool)>, +} + +impl JoinedPlayers { + pub fn new(connect_state_sender: Sender<(PlayerId, bool)>) -> Self { + Self { + connect_state_sender, + players: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn send_all_lobby(&self, in_lobby: Box<[PublicIdentity]>, in_lobby_ids: &[PlayerId]) { + let players: tokio::sync::MutexGuard<'_, HashMap> = + self.players.lock().await; + let senders = players + .iter() + .map(|(pid, p)| (pid.clone(), p.sender.clone())) + .collect::>(); + core::mem::drop(players); + for (pid, send) in senders { + send.send(ServerMessage::LobbyInfo { + joined: in_lobby_ids.contains(&pid), + players: in_lobby.clone(), + }) + .log_debug(); + } + } + + pub async fn is_connected(&self, player_id: &PlayerId) -> bool { + self.players.lock().await.contains_key(player_id) + } + + pub async fn update(&self, player_id: &PlayerId, f: impl FnOnce(&mut JoinedPlayer)) { + if let Some(p) = self + .players + .lock() + .await + .iter_mut() + .find_map(|(pid, p)| (player_id == pid).then_some(p)) + { + f(p) + } + } + + pub async fn get_name(&self, player_id: &PlayerId) -> Option { + self.players.lock().await.iter().find_map(|(pid, p)| { + if pid == player_id { + Some(p.name.clone()) + } else { + None + } + }) + } + + /// Disconnect the player + /// + /// Will not disconnect if the player is currently in a game, allowing them to reconnect + pub async fn disconnect(&self, connection: &ConnectionId) -> Option { + let mut map = self.players.lock().await; + + self.connect_state_sender + .send((connection.0.clone(), false)) + .log_warn(); + + if map + .get(connection.player_id()) + .map(|p| p.active_connection == *connection && !p.in_game) + .unwrap_or_default() + { + return map.remove(connection.player_id()); + } + + None + } + + pub async fn start_game_with(&self, players: &[PlayerId]) -> Result { + let mut map = self.players.lock().await; + if !players.iter().all(|p| map.contains_key(p)) { + return Err(GameError::NotAllPlayersConnected); + } + for player in players { + unsafe { map.get_mut(player).unwrap_unchecked() }.in_game = true; + } + + Ok(InGameToken::new( + self.clone(), + players.iter().cloned().collect(), + )) + } + + pub async fn release_from_game(&self, players: &[PlayerId]) { + self.players + .lock() + .await + .iter_mut() + .filter(|(p, _)| players.contains(*p)) + .for_each(|(_, p)| p.in_game = false) + } + + pub async fn get_sender(&self, player_id: &PlayerId) -> Option> { + self.players + .lock() + .await + .get(player_id) + .map(|c| c.sender.clone()) + } + + pub async fn insert_or_replace( + &self, + player_id: PlayerId, + player: JoinedPlayer, + ) -> Receiver { + let mut map = self.players.lock().await; + + if let Some(old) = map.insert(player_id.clone(), player) { + let old_map_entry = unsafe { map.get_mut(&player_id).unwrap_unchecked() }; + old_map_entry.receiver = old.resubscribe_reciever(); + + old.receiver + } else { + self.connect_state_sender + .send((player_id.clone(), true)) + .log_warn(); + unsafe { map.get(&player_id).unwrap_unchecked() } + .receiver + .resubscribe() + } + } +} + +pub struct InGameToken { + joined_players: JoinedPlayers, + players_in_game: Option>, +} +impl InGameToken { + const fn new(joined_players: JoinedPlayers, players_in_game: Box<[PlayerId]>) -> Self { + Self { + joined_players, + players_in_game: Some(players_in_game), + } + } +} +impl Drop for InGameToken { + fn drop(&mut self) { + let joined_players = self.joined_players.clone(); + if let Some(players) = self.players_in_game.take() { + tokio::spawn(async move { + let players_in_game = players; + joined_players.release_from_game(&players_in_game).await; + }); + } + } +} diff --git a/werewolves-server/src/game.rs b/werewolves-server/src/game.rs new file mode 100644 index 0000000..285eb35 --- /dev/null +++ b/werewolves-server/src/game.rs @@ -0,0 +1,324 @@ +use core::ops::Not; + +use crate::{ + LogError, + communication::{Comms, lobby::LobbyComms}, + connection::{InGameToken, JoinedPlayers}, + lobby::{Lobby, PlayerIdSender}, + runner::{IdentifiedClientMessage, Message}, +}; +use tokio::{sync::broadcast::Receiver, time::Instant}; +use werewolves_proto::{ + error::GameError, + game::{Game, GameOver, Village}, + message::{ + ClientMessage, Identification, ServerMessage, + host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage}, + }, + player::{Character, PlayerId}, +}; + +type Result = core::result::Result; + +pub struct GameRunner { + game: Game, + comms: Comms, + connect_recv: Receiver<(PlayerId, bool)>, + player_sender: PlayerIdSender, + roles_revealed: bool, + joined_players: JoinedPlayers, + _release_token: InGameToken, + cover_of_darkness: bool, +} + +impl GameRunner { + pub const fn new( + game: Game, + comms: Comms, + player_sender: PlayerIdSender, + connect_recv: Receiver<(PlayerId, bool)>, + joined_players: JoinedPlayers, + release_token: InGameToken, + ) -> Self { + Self { + game, + comms, + connect_recv, + player_sender, + joined_players, + roles_revealed: false, + _release_token: release_token, + cover_of_darkness: true, + } + } + + pub const fn comms(&mut self) -> &mut Comms { + &mut self.comms + } + + pub fn into_lobby(self) -> Lobby { + Lobby::new( + self.joined_players, + LobbyComms::new(self.comms, self.connect_recv), + ) + } + + pub const fn proto_game(&self) -> &Game { + &self.game + } + + pub async fn role_reveal(&mut self) { + for char in self.game.village().characters() { + if let Err(err) = self.player_sender.send_if_present( + char.player_id(), + ServerMessage::GameStart { + role: char.role().initial_shown_role(), + }, + ) { + log::warn!( + "failed sending role info to [{}]({}): {err}", + char.player_id(), + char.name() + ) + } + } + let mut acks = self + .game + .village() + .characters() + .into_iter() + .map(|c| (c, false)) + .collect::>(); + + let update_host = |acks: &[(Character, bool)], comms: &mut Comms| { + comms + .host() + .send(ServerToHostMessage::WaitingForRoleRevealAcks { + ackd: acks + .iter() + .filter_map(|(a, ackd)| ackd.then_some(a.target())) + .collect(), + waiting: acks + .iter() + .filter_map(|(a, ackd)| ackd.not().then_some(a.target())) + .collect(), + }) + .log_err(); + }; + (update_host)(&acks, &mut self.comms); + let notify_of_role = |player_id: &PlayerId, village: &Village, sender: &PlayerIdSender| { + if let Some(char) = village.character_by_player_id(player_id) { + sender + .send_if_present( + player_id, + ServerMessage::GameStart { + role: char.role().initial_shown_role(), + }, + ) + .log_debug(); + } + }; + + let mut last_err_log = tokio::time::Instant::now() - tokio::time::Duration::from_secs(60); + while acks.iter().any(|(_, ackd)| !*ackd) { + let msg = match self.comms.message().await { + Ok(msg) => msg, + Err(err) => { + if (tokio::time::Instant::now() - last_err_log).as_secs() >= 30 { + log::error!("recv during role_reveal: {err}"); + last_err_log = tokio::time::Instant::now(); + } + continue; + } + }; + match msg { + Message::Host(HostMessage::ForceRoleAckFor(char_id)) => { + if let Some((c, ackd)) = + acks.iter_mut().find(|(c, _)| c.character_id() == &char_id) + { + *ackd = true; + (notify_of_role)(c.player_id(), self.game.village(), &self.player_sender); + } + (update_host)(&acks, &mut self.comms); + } + Message::Host(_) => { + (update_host)(&acks, &mut self.comms); + } + Message::Client(IdentifiedClientMessage { + identity: + Identification { + player_id, + public: _, + }, + message: ClientMessage::RoleAck, + }) => { + if let Some((_, ackd)) = + acks.iter_mut().find(|(t, _)| t.player_id() == &player_id) + { + *ackd = true; + self.player_sender + .send_if_present(&player_id, ServerMessage::Sleep) + .log_debug(); + } + (update_host)(&acks, &mut self.comms); + } + Message::Client(IdentifiedClientMessage { + identity: + Identification { + player_id, + public: _, + }, + message: _, + }) + | Message::Connect(player_id) => { + (notify_of_role)(&player_id, self.game.village(), &self.player_sender) + } + Message::Disconnect(_) => {} + } + } + + self.roles_revealed = true; + } + + pub async fn next(&mut self) -> Option { + let msg = self.comms.host().recv().await.expect("host channel closed"); + match self.host_message(msg) { + Ok(resp) => { + self.comms.host().send(resp).log_warn(); + } + Err(err) => { + self.comms + .host() + .send(ServerToHostMessage::Error(err)) + .log_warn(); + } + } + self.game.game_over() + } + + pub fn host_message(&mut self, message: HostMessage) -> Result { + if !self.roles_revealed { + return Err(GameError::NeedRoleReveal); + } + if self.cover_of_darkness { + match &message { + HostMessage::GetState | HostMessage::InGame(HostGameMessage::GetState) => { + return Ok(ServerToHostMessage::CoverOfDarkness); + } + HostMessage::InGame(HostGameMessage::Night(HostNightMessage::Next)) => { + self.cover_of_darkness = false; + return self.host_message(HostMessage::GetState); + } + _ => return Err(GameError::InvalidMessageForGameState), + }; + } + match message { + HostMessage::GetState => self.game.process(HostGameMessage::GetState), + HostMessage::InGame(msg) => self.game.process(msg), + HostMessage::Lobby(_) | HostMessage::NewLobby | HostMessage::ForceRoleAckFor(_) => { + Err(GameError::InvalidMessageForGameState) + } + HostMessage::Echo(echo) => Ok(echo), + } + } +} + +pub struct GameEnd { + game: Option, + result: GameOver, + last_error_log: Instant, +} + +impl GameEnd { + pub fn new(game: GameRunner, result: GameOver) -> Self { + Self { + result, + game: Some(game), + last_error_log: Instant::now() - core::time::Duration::from_secs(60), + } + } + + const fn game(&mut self) -> Result<&mut GameRunner> { + match self.game.as_mut() { + Some(game) => Ok(game), + None => Err(GameError::InactiveGameObject), + } + } + + pub fn end_screen(&mut self) -> Result<()> { + let result = self.result; + for char in self.game()?.game.village().characters() { + self.game()? + .player_sender + .send_if_present(char.player_id(), ServerMessage::GameOver(result)) + .log_debug(); + } + self.game()? + .comms + .host() + .send(ServerToHostMessage::GameOver(result)) + .log_warn(); + + Ok(()) + } + + pub async fn next(&mut self) -> Option { + let msg = match self.game().unwrap().comms.message().await { + Ok(msg) => msg, + Err(err) => { + if (Instant::now() - self.last_error_log).as_secs() >= 30 { + log::error!("getting message: {err}"); + self.last_error_log = Instant::now(); + } + return None; + } + }; + + match msg { + Message::Host(HostMessage::Echo(msg)) => { + self.game().unwrap().comms.host().send(msg).log_debug(); + } + Message::Host(HostMessage::GetState) => { + let result = self.result; + self.game() + .unwrap() + .comms + .host() + .send(ServerToHostMessage::GameOver(result)) + .log_debug() + } + Message::Host(HostMessage::NewLobby) => { + self.game() + .unwrap() + .comms + .host() + .send(ServerToHostMessage::Lobby(Box::new([]))) + .log_debug(); + let lobby = self.game.take().unwrap().into_lobby(); + return Some(lobby); + } + Message::Host(_) => self + .game() + .unwrap() + .comms + .host() + .send(ServerToHostMessage::Error( + GameError::InvalidMessageForGameState, + )) + .log_debug(), + Message::Client(IdentifiedClientMessage { + identity, + message: _, + }) => { + let result = self.result; + self.game() + .unwrap() + .player_sender + .send_if_present(&identity.player_id, ServerMessage::GameOver(result)) + .log_debug(); + } + Message::Connect(_) | Message::Disconnect(_) => {} + } + None + } +} diff --git a/werewolves-server/src/host.rs b/werewolves-server/src/host.rs new file mode 100644 index 0000000..6f065b6 --- /dev/null +++ b/werewolves-server/src/host.rs @@ -0,0 +1,149 @@ +use core::net::SocketAddr; + +use axum::{ + extract::{ + ConnectInfo, State, WebSocketUpgrade, + ws::{self, Message, WebSocket}, + }, + response::IntoResponse, +}; +use axum_extra::{TypedHeader, headers}; +use colored::Colorize; +use tokio::sync::{broadcast::Receiver, mpsc::Sender}; +use werewolves_proto::message::host::{HostMessage, ServerToHostMessage}; + +use crate::{AppState, LogError, XForwardedFor}; + +pub async fn handler( + ws: WebSocketUpgrade, + user_agent: Option>, + x_forwarded_for: Option>, + ConnectInfo(addr): ConnectInfo, + State(state): State, +) -> impl IntoResponse { + let who = x_forwarded_for + .map(|x| x.to_string()) + .unwrap_or_else(|| addr.to_string()); + log::info!( + "{who}{} connected.", + user_agent + .map(|agent| format!(" (User-Agent: {})", agent.as_str())) + .unwrap_or_default(), + ); + + // finalize the upgrade process by returning upgrade callback. + // we can customize the callback by sending additional info such as address. + ws.on_upgrade(move |socket| async move { + Host::new( + socket, + state.host_send.clone(), + state.host_recv.resubscribe(), + ) + .run() + .await + }) +} + +struct Host { + socket: WebSocket, + host_send: Sender, + server_recv: Receiver, +} + +impl Host { + pub fn new( + socket: WebSocket, + host_send: Sender, + server_recv: Receiver, + ) -> Self { + Self { + host_send, + server_recv, + socket, + } + } + + async fn on_recv( + &mut self, + msg: Option>, + ) -> Result<(), anyhow::Error> { + #[cfg(not(feature = "cbor"))] + let msg: HostMessage = serde_json::from_slice( + &match msg { + Some(Ok(msg)) => msg, + Some(Err(err)) => return Err(err.into()), + None => { + log::warn!("[host] no message"); + return Ok(()); + } + } + .into_data(), + )?; + #[cfg(feature = "cbor")] + let msg: HostMessage = { + let bytes = match msg { + Some(Ok(msg)) => msg.into_data(), + Some(Err(err)) => return Err(err.into()), + None => { + log::warn!("[host] no message"); + return Ok(()); + } + }; + let slice: &[u8] = &bytes; + ciborium::from_reader(slice)? + }; + if let HostMessage::Echo(echo) = &msg { + self.send_message(echo).await.log_warn(); + return Ok(()); + } + log::debug!( + "{} {}", + "[host::incoming::message]".bold(), + format!("{msg:?}").dimmed() + ); + Ok(self.host_send.send(msg).await?) + } + + async fn send_message(&mut self, msg: &ServerToHostMessage) -> Result<(), anyhow::Error> { + Ok(self + .socket + .send( + #[cfg(not(feature = "cbor"))] + ws::Message::Text(serde_json::to_string(msg)?.into()), + #[cfg(feature = "cbor")] + ws::Message::Binary({ + let mut bytes = Vec::new(); + ciborium::into_writer(msg, &mut bytes)?; + bytes.into() + }), + ) + .await?) + } + + pub async fn run(mut self) { + loop { + tokio::select! { + msg = self.socket.recv() => { + if let Err(err) = self.on_recv(msg).await { + log::error!("{} {err}", "[host::incoming]".bold()); + return; + } + }, + msg = self.server_recv.recv() => { + match msg { + Ok(msg) => { + log::debug!("sending message to host: {}", format!("{msg:?}").dimmed()); + if let Err(err) = self.send_message(&msg).await { + log::error!("{} {err}", "[host::outgoing]".bold()) + } + }, + Err(err) => { + log::error!("{} {err}", "[host::mpsc]".bold()); + return; + } + } + } + }; + } + } +} diff --git a/werewolves-server/src/lobby.rs b/werewolves-server/src/lobby.rs new file mode 100644 index 0000000..0a0e535 --- /dev/null +++ b/werewolves-server/src/lobby.rs @@ -0,0 +1,345 @@ +use core::{ + num::NonZeroU8, + ops::{Deref, DerefMut}, + time::Duration, +}; +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast::Sender; +use werewolves_proto::{ + error::GameError, + game::{Game, GameSettings, Village}, + message::{ + CharacterState, ClientMessage, DayCharacter, Identification, PlayerState, ServerMessage, + UpdateSelf, + host::{HostLobbyMessage, HostMessage, ServerToHostMessage}, + }, + player::PlayerId, +}; + +use crate::{ + LogError, + communication::lobby::LobbyComms, + connection::JoinedPlayers, + game::GameRunner, + runner::{IdentifiedClientMessage, Message}, +}; + +pub struct Lobby { + players_in_lobby: PlayerIdSender, + settings: GameSettings, + joined_players: JoinedPlayers, + comms: Option, +} + +impl Lobby { + pub fn new(joined_players: JoinedPlayers, comms: LobbyComms) -> Self { + Self { + joined_players, + comms: Some(comms), + settings: GameSettings::default(), + players_in_lobby: PlayerIdSender(Vec::new()), + } + } + + const fn comms(&mut self) -> Result<&mut LobbyComms, GameError> { + match self.comms.as_mut() { + Some(comms) => Ok(comms), + None => Err(GameError::InactiveGameObject), + } + } + + pub async fn send_lobby_info_to_clients(&mut self) { + let players = self + .players_in_lobby + .iter() + .map(|(id, _)| id.public.clone()) + .collect::>(); + self.joined_players + .send_all_lobby( + players, + &self + .players_in_lobby + .iter() + .map(|(id, _)| id.player_id.clone()) + .collect::>(), + ) + .await; + } + + async fn get_lobby_player_list(&self) -> Box<[PlayerState]> { + let mut players = Vec::new(); + for (player, _) in self.players_in_lobby.iter() { + players.push(PlayerState { + identification: player.clone(), + connected: self.joined_players.is_connected(&player.player_id).await, + }); + } + + players.into_boxed_slice() + } + + async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> { + let players = self.get_lobby_player_list().await; + self.comms()? + .host() + .send(ServerToHostMessage::Lobby(players)) + .map_err(|err| GameError::GenericError(err.to_string())) + } + + pub async fn next(&mut self) -> Option { + let msg = self + .comms() + .unwrap() + .next_message() + .await + .expect("get next message"); + + match self.next_inner(msg.clone()).await.map_err(|err| (msg, err)) { + Ok(None) => {} + Ok(Some(mut game)) => { + game.role_reveal().await; + match game.host_message(HostMessage::GetState) { + Ok(msg) => { + log::info!("initial message after night reveal: {msg:?}"); + game.comms().host().send(msg).log_warn(); + } + Err(err) => { + log::error!("processing get_state after role reveal to host: {err}") + } + } + + return Some(game); + } + Err((Message::Host(_), err)) => self + .comms() + .unwrap() + .host() + .send(ServerToHostMessage::Error(err)) + .log_warn(), + Err(( + Message::Client(IdentifiedClientMessage { + identity: + Identification { + player_id, + public: _, + }, + message: _, + }), + GameError::InvalidMessageForGameState, + )) => { + let _ = self + .players_in_lobby + .send_if_present(&player_id, ServerMessage::InvalidMessageForGameState); + } + Err(( + Message::Client(IdentifiedClientMessage { + identity: Identification { player_id, public }, + message: _, + }), + err, + )) => { + log::error!("processing message from {public} [{player_id}]: {err}"); + let _ = self + .players_in_lobby + .send_if_present(&player_id, ServerMessage::Reset); + } + Err((Message::Connect(_), _)) | Err((Message::Disconnect(_), _)) => {} + } + None + } + + async fn next_inner(&mut self, msg: Message) -> Result, GameError> { + match msg { + Message::Host(HostMessage::InGame(_)) + | Message::Host(HostMessage::ForceRoleAckFor(_)) => { + return Err(GameError::InvalidMessageForGameState); + } + Message::Host(HostMessage::NewLobby) => self + .comms() + .unwrap() + .host() + .send(ServerToHostMessage::Error( + GameError::InvalidMessageForGameState, + )) + .log_warn(), + Message::Host(HostMessage::Lobby(HostLobbyMessage::GetState)) + | Message::Host(HostMessage::GetState) => self.send_lobby_info_to_host().await?, + Message::Host(HostMessage::Lobby(HostLobbyMessage::GetGameSettings)) => { + let msg = ServerToHostMessage::GameSettings(self.settings.clone()); + let _ = self.comms().unwrap().host().send(msg); + } + Message::Host(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(settings))) => { + settings.check()?; + self.settings = settings; + } + Message::Host(HostMessage::Lobby(HostLobbyMessage::Start)) => { + if self.players_in_lobby.len() < self.settings.min_players_needed() { + return Err(GameError::TooFewPlayers { + got: self.players_in_lobby.len() as _, + need: self.settings.min_players_needed() as _, + }); + } + let playing_players = self + .players_in_lobby + .iter() + .map(|(id, _)| id.clone()) + .collect::>(); + let release_token = self + .joined_players + .start_game_with( + &playing_players + .iter() + .map(|id| id.player_id.clone()) + .collect::>(), + ) + .await?; + + let game = Game::new(&playing_players, self.settings.clone())?; + assert_eq!(game.village().characters().len(), playing_players.len()); + + let (comms, recv) = self.comms.take().unwrap().into_inner(); + return Ok(Some(GameRunner::new( + game, + comms, + self.players_in_lobby.drain(), + recv, + self.joined_players.clone(), + release_token, + ))); + } + Message::Client(IdentifiedClientMessage { + identity, + message: ClientMessage::Hello, + }) => { + if self + .players_in_lobby + .iter_mut() + .any(|p| p.0.player_id == identity.player_id) + { + // Already have the player + return Ok(None); + } + if let Some(sender) = self.joined_players.get_sender(&identity.player_id).await { + self.players_in_lobby.push((identity, sender.clone())); + self.send_lobby_info_to_clients().await; + self.send_lobby_info_to_host().await?; + } + } + Message::Host(HostMessage::Lobby(HostLobbyMessage::Kick(player_id))) + | Message::Client(IdentifiedClientMessage { + identity: + Identification { + player_id, + public: _, + }, + message: ClientMessage::Goodbye, + }) => { + log::error!("we are in there"); + if let Some(remove_idx) = self + .players_in_lobby + .iter() + .enumerate() + .find_map(|(idx, p)| (p.0.player_id == player_id).then_some(idx)) + { + log::error!("removing player {player_id} at idx {remove_idx}"); + self.players_in_lobby.swap_remove(remove_idx); + self.send_lobby_info_to_host().await?; + self.send_lobby_info_to_clients().await; + } + } + Message::Client(IdentifiedClientMessage { + identity: + Identification { + player_id, + public: _, + }, + message: ClientMessage::GetState, + }) => { + let msg = ServerMessage::LobbyInfo { + joined: self + .players_in_lobby + .iter() + .any(|(p, _)| p.player_id == player_id), + players: self + .players_in_lobby + .iter() + .map(|(id, _)| id.public.clone()) + .collect(), + }; + if let Some(sender) = self.joined_players.get_sender(&player_id).await { + sender.send(msg).log_debug(); + } + } + Message::Client(IdentifiedClientMessage { + identity: _, + message: ClientMessage::RoleAck, + }) => return Err(GameError::InvalidMessageForGameState), + Message::Client(IdentifiedClientMessage { + identity: Identification { player_id, public }, + message: ClientMessage::UpdateSelf(_), + }) => { + self.joined_players + .update(&player_id, move |p| { + p.name = public.name; + p.number = public.number; + p.pronouns = public.pronouns; + }) + .await; + self.send_lobby_info_to_clients().await; + self.send_lobby_info_to_host().await.log_debug(); + } + Message::Connect(_) | Message::Disconnect(_) => self.send_lobby_info_to_host().await?, + Message::Host(HostMessage::Echo(msg)) => { + self.comms()?.host().send(msg).log_warn(); + } + } + + Ok(None) + } +} + +pub struct PlayerIdSender(Vec<(Identification, Sender)>); + +impl Deref for PlayerIdSender { + type Target = Vec<(Identification, Sender)>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for PlayerIdSender { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl PlayerIdSender { + pub fn find(&self, player_id: &PlayerId) -> Option<&Sender> { + self.iter() + .find_map(|(id, s)| (&id.player_id == player_id).then_some(s)) + } + + pub fn send_if_present( + &self, + player_id: &PlayerId, + message: ServerMessage, + ) -> Result<(), GameError> { + if let Some(sender) = self.find(player_id) { + sender + .send(message) + .map(|_| ()) + .map_err(|err| GameError::GenericError(err.to_string())) + } else { + Ok(()) + } + } + + pub fn drain(&mut self) -> Self { + let mut swapped = Self(vec![]); + core::mem::swap(&mut swapped, self); + swapped + } +} diff --git a/werewolves-server/src/main.rs b/werewolves-server/src/main.rs new file mode 100644 index 0000000..1cf59df --- /dev/null +++ b/werewolves-server/src/main.rs @@ -0,0 +1,267 @@ +mod client; +mod communication; +mod connection; +mod game; +mod host; +mod lobby; +mod runner; +mod saver; + +use axum::{ + Router, + http::{Request, header}, + response::IntoResponse, + routing::{any, get}, +}; +use axum_extra::headers; +use communication::lobby::LobbyComms; +use connection::JoinedPlayers; +use core::{fmt::Display, net::SocketAddr, str::FromStr}; +use log::Record; +use runner::IdentifiedClientMessage; +use std::{env, io::Write, path::Path}; +use tokio::{ + sync::{broadcast, mpsc}, + time::Instant, +}; + +use crate::{ + communication::{Comms, host::HostComms, player::PlayerIdComms}, + saver::FileSaver, +}; + +const DEFAULT_PORT: u16 = 8080; +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_SAVE_DIR: &str = "werewolves-saves/"; + +#[tokio::main] +async fn main() { + // pretty_env_logger::init(); + use colored::Colorize; + pretty_env_logger::formatted_builder() + .parse_default_env() + .format(|f, record| { + let time = chrono::Local::now().time().to_string().dimmed(); + + match record.file() { + Some(file) => { + let file = format!( + "[{file}{}]", + record + .line() + .map(|l| format!(":{l}")) + .unwrap_or_else(String::new), + ) + .dimmed(); + let level = match record.level() { + log::Level::Error => "[err]".red().bold(), + log::Level::Warn => "[warn]".yellow().bold(), + log::Level::Info => "[info]".white().bold(), + log::Level::Debug => "[debug]".dimmed().bold(), + log::Level::Trace => "[trace]".dimmed(), + }; + let args = record.args(); + + let arrow = "➢".bold().magenta(); + writeln!( + f, + "{time} {file}\n{level} {arrow} {args}", + // "⇗⇘⇗⇘⇗⇘".bold().dimmed(), + ) + } + _ => writeln!(f, "{time} [{}] {}", record.level(), record.args()), + } + }) + .try_init() + .unwrap(); + + let default_panic = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default_panic(info); + std::process::exit(1); + })); + + let host = env::var("HOST").unwrap_or(DEFAULT_HOST.to_string()); + let port = env::var("PORT") + .map_err(|err| anyhow::anyhow!("{err}")) + .map(|port_str| { + port_str + .parse::() + .unwrap_or_else(|err| panic!("parse PORT={port_str} failed: {err}")) + }) + .unwrap_or(DEFAULT_PORT); + let listen_addr = + SocketAddr::from_str(format!("{host}:{port}").as_str()).expect("invalid host/port"); + + let (send, recv) = broadcast::channel(100); + let (server_send, host_recv) = broadcast::channel(100); + let (host_send, server_recv) = mpsc::channel(100); + let (connect_send, connect_recv) = broadcast::channel(100); + let joined_players = JoinedPlayers::new(connect_send); + let lobby_comms = LobbyComms::new( + Comms::new( + HostComms::new(server_send, server_recv), + PlayerIdComms::new(joined_players.clone(), recv, connect_recv.resubscribe()), + ), + connect_recv, + ); + + let jp_clone = joined_players.clone(); + + let path = Path::new(option_env!("SAVE_PATH").unwrap_or(DEFAULT_SAVE_DIR)) + .canonicalize() + .expect("canonicalizing path"); + if let Err(err) = std::fs::create_dir(&path) + && !matches!(err.kind(), std::io::ErrorKind::AlreadyExists) + { + panic!("creating save dir at [{path:?}]: {err}") + } + // Check if we can write to the path + { + let test_file_path = path.join(".test"); + if let Err(err) = std::fs::File::create(&test_file_path) { + panic!("can't create files in {path:?}: {err}") + } + std::fs::remove_file(&test_file_path).log_err(); + } + + let saver = FileSaver::new(path); + tokio::spawn(async move { + crate::runner::run_game(jp_clone, lobby_comms, saver).await; + panic!("game over"); + }); + let state = AppState { + joined_players, + host_recv, + host_send, + send, + }; + + let app = Router::new() + .route("/connect/client", any(client::handler)) + .route("/connect/host", any(host::handler)) + .with_state(state) + .fallback(get(handle_http_static)); + let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap(); + log::info!("listening on {}", listener.local_addr().unwrap()); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); +} + +struct AppState { + joined_players: JoinedPlayers, + send: broadcast::Sender, + host_send: tokio::sync::mpsc::Sender, + host_recv: broadcast::Receiver, +} +impl Clone for AppState { + fn clone(&self) -> Self { + Self { + joined_players: self.joined_players.clone(), + send: self.send.clone(), + host_send: self.host_send.clone(), + host_recv: self.host_recv.resubscribe(), + } + } +} + +async fn handle_http_static(req: Request) -> impl IntoResponse { + use mime_sniffer::MimeTypeSniffer; + const INDEX_FILE: &[u8] = include_bytes!("../../werewolves/dist/index.html"); + let path = req.uri().path(); + + werewolves_macros::include_dist!(DIST_FILES, "werewolves/dist"); + + let file = if let Some(file) = DIST_FILES.iter().find_map(|(file_path, file)| { + if *file_path == path { + Some(*file) + } else { + None + } + }) { + file + } else { + return ( + [(header::CONTENT_TYPE, "text/html".to_string())], + INDEX_FILE, + ); + }; + + let mime = if path.ends_with(".js") { + "text/javascript".to_string() + } else if path.ends_with(".css") { + "text/css".to_string() + } else if path.ends_with(".wasm") { + "application/wasm".to_string() + } else { + file.sniff_mime_type() + .unwrap_or("application/octet-stream") + .to_string() + }; + + ([(header::CONTENT_TYPE, mime)], file) +} + +struct XForwardedFor(String); + +impl Display for XForwardedFor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0.as_str()) + } +} + +impl headers::Header for XForwardedFor { + fn name() -> &'static header::HeaderName { + static NAME: header::HeaderName = header::HeaderName::from_static("x-forwarded-for"); + &NAME + } + + fn decode<'i, I>(values: &mut I) -> Result + where + Self: Sized, + I: Iterator, + { + Ok(Self( + values + .next() + .and_then(|v| v.to_str().ok()) + .ok_or(headers::Error::invalid())? + .to_string(), + )) + } + + fn encode>(&self, _: &mut E) {} +} + +pub trait LogError { + fn log_warn(self); + fn log_err(self); + fn log_debug(self); +} + +impl LogError for Result +where + E: Display, +{ + fn log_warn(self) { + if let Err(err) = self { + log::warn!("{err}"); + } + } + + fn log_err(self) { + if let Err(err) = self { + log::error!("{err}"); + } + } + + fn log_debug(self) { + if let Err(err) = self { + log::debug!("{err}") + } + } +} diff --git a/werewolves-server/src/runner.rs b/werewolves-server/src/runner.rs new file mode 100644 index 0000000..4e602d0 --- /dev/null +++ b/werewolves-server/src/runner.rs @@ -0,0 +1,98 @@ +use core::time::Duration; + +use thiserror::Error; +use werewolves_proto::{ + error::GameError, + message::{ClientMessage, Identification, host::HostMessage}, + player::PlayerId, +}; + +use crate::{ + communication::lobby::LobbyComms, + connection::JoinedPlayers, + game::{GameEnd, GameRunner}, + lobby::Lobby, + saver::Saver, +}; + +#[derive(Debug, Error)] +enum GameOrSendError { + #[error("game error: {0}")] + GameError(#[from] GameError), + #[error("send error: {0}")] + SendError(#[from] tokio::sync::mpsc::error::SendError), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct IdentifiedClientMessage { + pub identity: Identification, + pub message: ClientMessage, +} + +pub async fn run_game(joined_players: JoinedPlayers, comms: LobbyComms, mut saver: impl Saver) { + let mut state = RunningState::Lobby(Lobby::new(joined_players, comms)); + loop { + match &mut state { + RunningState::Lobby(lobby) => { + if let Some(game) = lobby.next().await { + state = RunningState::Game(game) + } + } + RunningState::Game(game) => { + if let Some(result) = game.next().await { + match saver.save(game.proto_game()) { + Ok(path) => { + log::info!("saved game to {path}"); + } + Err(err) => { + log::error!("saving game: {err}"); + let game_clone = game.proto_game().clone(); + let mut saver_clone = saver.clone(); + tokio::spawn(async move { + let started = chrono::Utc::now(); + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + match saver_clone.save(&game_clone) { + Ok(path) => { + log::info!("saved game from {started} to {path}"); + return; + } + Err(err) => { + log::error!("saving game from {started}: {err}") + } + } + } + }); + } + } + state = match state { + RunningState::Game(game) => { + RunningState::GameOver(GameEnd::new(game, result)) + } + _ => unsafe { core::hint::unreachable_unchecked() }, + }; + } + } + RunningState::GameOver(end) => { + if let Some(mut new_lobby) = end.next().await { + new_lobby.send_lobby_info_to_clients().await; + state = RunningState::Lobby(new_lobby) + } + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Message { + Host(HostMessage), + Client(IdentifiedClientMessage), + Connect(PlayerId), + Disconnect(PlayerId), +} + +pub enum RunningState { + Lobby(Lobby), + Game(GameRunner), + GameOver(GameEnd), +} diff --git a/werewolves-server/src/saver.rs b/werewolves-server/src/saver.rs new file mode 100644 index 0000000..a06fd18 --- /dev/null +++ b/werewolves-server/src/saver.rs @@ -0,0 +1,49 @@ +use core::fmt::Display; +use std::{io::Write, path::PathBuf}; + +use thiserror::Error; +use werewolves_proto::game::Game; + +pub trait Saver: Clone + Send + 'static { + type Error: Display; + + fn save(&mut self, game: &Game) -> Result; +} + +#[derive(Debug, Error)] +pub enum FileSaverError { + #[error("io error: {0}")] + IoError(std::io::Error), + #[error("serialization error")] + SerializationError(#[from] serde_json::Error), +} + +impl From for FileSaverError { + fn from(value: std::io::Error) -> Self { + Self::IoError(value) + } +} + +#[derive(Debug, Clone)] +pub struct FileSaver { + path: PathBuf, +} + +impl FileSaver { + pub const fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl Saver for FileSaver { + type Error = FileSaverError; + + fn save(&mut self, game: &Game) -> Result { + let name = format!("werewolves_{}.json", chrono::Utc::now().timestamp()); + let path = self.path.join(name.clone()); + let mut file = std::fs::File::create_new(path.clone())?; + serde_json::to_writer_pretty(&mut file, &game)?; + file.flush()?; + Ok(path.to_str().map(|s| s.to_string()).unwrap_or(name)) + } +} diff --git a/werewolves/.cargo/config.toml b/werewolves/.cargo/config.toml new file mode 100644 index 0000000..2e07606 --- /dev/null +++ b/werewolves/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/werewolves/Cargo.lock b/werewolves/Cargo.lock new file mode 100644 index 0000000..97dfc3b --- /dev/null +++ b/werewolves/Cargo.lock @@ -0,0 +1,1572 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "blog-rs" +version = "0.1.0" +dependencies = [ + "chrono", + "gloo 0.11.0", + "instant", + "lipsum", + "log", + "once_cell", + "rand", + "serde", + "wasm-logger", + "web-sys", + "yew", + "yew-router", +] + +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.5.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.5.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "futures-channel", + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 0.2.0", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-render" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" +dependencies = [ + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" +dependencies = [ + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-worker-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[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 = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.100", +] + +[[package]] +name = "indexmap" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "lipsum" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636860251af8963cc40f6b4baadee105f02e21b28131d76eba8e40ce84ab8064" +dependencies = [ + "rand", + "rand_chacha", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb" +dependencies = [ + "proc-macro2", + "syn 2.0.100", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tokio" +version = "1.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +dependencies = [ + "backtrace", + "pin-project-lite", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +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.100", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-logger" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "yew-router" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" +dependencies = [ + "gloo 0.10.0", + "js-sys", + "route-recognizer", + "serde", + "serde_urlencoded", + "tracing", + "urlencoding", + "wasm-bindgen", + "web-sys", + "yew", + "yew-router-macro", +] + +[[package]] +name = "yew-router-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] diff --git a/werewolves/Cargo.toml b/werewolves/Cargo.toml new file mode 100644 index 0000000..7e995f2 --- /dev/null +++ b/werewolves/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "werewolves" +version = "0.1.0" +edition = "2024" + + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +web-sys = { version = "0.3", features = [ + "HtmlTableCellElement", + "Event", + "EventTarget", + "HtmlImageElement", + "HtmlDivElement", + "HtmlSelectElement", +] } +log = "0.4" +rand = { version = "0.9", features = ["small_rng"] } +getrandom = { version = "0.3", features = ["wasm_js"] } +uuid = { version = "*", features = ["js"] } +yew = { version = "0.21", features = ["csr"] } +yew-router = "0.18.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", optional = true } +gloo = "0.11" +wasm-logger = "0.2" +instant = { version = "0.1", features = ["wasm-bindgen"] } +once_cell = "1" +chrono = { version = "0.4" } +werewolves-macros = { path = "../werewolves-macros" } +werewolves-proto = { path = "../werewolves-proto" } +futures = "0.3.31" +wasm-bindgen-futures = "0.4.50" +thiserror = { version = "2" } +convert_case = { version = "0.8" } +ciborium = { version = "0.2", optional = true } + +[features] +# default = ["cbor"] +default = ["json"] +cbor = ["dep:ciborium"] +json = ["dep:serde_json"] diff --git a/werewolves/Trunk.toml b/werewolves/Trunk.toml new file mode 100644 index 0000000..fa56fef --- /dev/null +++ b/werewolves/Trunk.toml @@ -0,0 +1,13 @@ +[build] +target = "index.html" # The index HTML file to drive the bundling process. +html_output = "index.html" # The name of the output HTML file. +release = true # Build in release mode. +dist = "dist" # The output dir for all final assets. +public_url = "/" # The public URL from which assets are to be served. +filehash = true # Whether to include hash values in the output file names. +inject_scripts = true # Whether to inject scripts (and module preloads) into the finalized output. +offline = false # Run without network access +frozen = false # Require Cargo.lock and cache are up to date +locked = false # Require Cargo.lock is up to date +minify = "always" # Control minification: can be one of: never, on_release, always +no_sri = false # Allow disabling sub-resource integrity (SRI) diff --git a/werewolves/img/icon.png b/werewolves/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..75e27c8a908e804f34fb4db5e3db3bdcc7d669e2 GIT binary patch literal 1168 zcma)+{Zq^d0Ea){eeLyHr6nngyK1Oy6|Oh!uBq8=leRZSFIHK-+4O=`UcNOruA&>M zRnp>WQj05fOOm}Q9631>y*N%|l}eF|VXK?w{(<}9nfc6T=K1wGDqSx!HCkWFd z6AXZy#ee~z&3j7+03st;w3g_(>v#YFEnXNP1nQ3)>$lJWaN@+l0YJ7LKpq6X%*unn zE*|h!26!led8wzHgS-GRV*N!vm;nG+`@ItY3{%A$GXU<=S%^JV{M`VUJN*KDnL~_u zhUUagSE3rAcKP`TL-+KKR%}dZ9O5jF<_7F0DJhPh^IY>&^p2$6)6HM++X7K*08+E6mJ2rT$;XF!6JK1mg%P7rYW zZ)&snaJxTU!UEYgqSsxRKF{L8YEaI)&jK3)fDndK@;2TNNUP9$%VJi@X{^ka%<_&t3YP7 zB@z}3obPJ?lo4d^ilwX5)VB$!c>5dwE{R>>z~fhSb?N7f0@IXA}1T`eld_o>21Q$_MGM)kPKE_By$vH2KI!ud2Srlt`@uXA|@3E3b4`LEgZ}lmgAr ztL-OW79`X*y%*Wp@;7_O+ZjAFmMRYz-1iLrV)UD1?DtZ0b+L~wnIyWck+fw_5()7A8ZiZF6 z`+QnbU+(IB|7quR>xZ{Lsn*vU+^bgYpT}kfCV4AjtwRWjNXPE%>~LB_2$@*vfAx|2BHe*P+mIPk?nE!W z^fi*&9wCz&JD9d?tyb-mS+GoOsp7(+p6g4sK^ezHb<{{&%m=HVYDsy6a*STgB|^I2 zJRFtC#@^^7ysyQD9dYbT`&=RHw!0YPuD&Dbw^cah5D})m*s% s!PG4Y2OJ1ydqIE#8Vtbt|A+e{XXPD=i{o6wxwAd+^Ih+AX{{{pUy+x?$^ZZW literal 0 HcmV?d00001 diff --git a/werewolves/index.html b/werewolves/index.html new file mode 100644 index 0000000..59c75ac --- /dev/null +++ b/werewolves/index.html @@ -0,0 +1,27 @@ + + + + + + + website + + + + + + + + + + + + + + + + + + + + diff --git a/werewolves/index.scss b/werewolves/index.scss new file mode 100644 index 0000000..8498298 --- /dev/null +++ b/werewolves/index.scss @@ -0,0 +1,1018 @@ +$wolves_color: rgba(255, 0, 0, 0.7); +$village_color: rgba(0, 0, 255, 0.7); +$connected_color: hsl(120, 68%, 50%); +$disconnected_color: hsl(0, 68%, 50%); + +html, +body { + margin: 0; +} + +body { + background: + linear-gradient(145deg, rgba(133, 0, 153, 1) 0%, rgba(57, 0, 153, 1) 100%); + min-height: 100vh; + font-size: 1.5rem; + max-width: 100vw; + user-select: none; +} + +main { + color: #fff6d5; + font-family: sans-serif; + text-align: center; + padding-bottom: 80px; + // min-height: 50vh; + // background: + // linear-gradient(145deg, rgba(133, 0, 153, 1) 0%, rgba(57, 0, 153, 1) 100%); +} + +.logo { + height: 20em; +} + +.burger { + background-color: transparent; + border: none; +} + +h1.navbar-item { + font-size: 32px; + align-self: flex-start; + padding-left: 30px; +} + +.navbar-start .navbar-item { + background-color: #fff6d5; + margin-left: 20px; + padding-left: 5px; + padding-right: 5px; +} + +$link_color: #432054; +$link_hover_color: hsl(280, 55%, 61%); +$link_bg_color: #fff6d5; +$border_color: #432054; +$shadow_color: hsl(280, 55%, 61%); +$shadow_color_2: hsl(300, 55%, 61%); +$link_filter: drop-shadow(5px 5px 0 $shadow_color) drop-shadow(5px 5px 0 $shadow_color_2); +$link_select_filter: invert(100%); + +$error_color: hsla(0, 95%, 61%, 0.7); +$error_shadow_color: hsla(340, 95%, 61%, 0.7); +$error_shadow_color_2: hsla(0, 95%, 61%, 0.7); +$error_filter: drop-shadow(5px 5px 0 $error_shadow_color) drop-shadow(5px 5px 0 $error_shadow_color_2); + +.navbar-item:hover { + filter: $link_select_filter; +} + +.navbar-menu { + justify-items: center; +} + +.navbar a, +.link-container a, +.pagination-item a { + text-decoration: none; + color: $link_color; +} + +.out-of-order { + filter: $link_select_filter; +} + +.navbar { + font-family: 'Cute Font'; + display: flex; + align-items: baseline; + + gap: 30px; + filter: $link_filter; +} + +.navbar-icon img { + width: 32px; + margin-left: 15px; +} + +.post-back button { + padding-top: 10px; + padding-bottom: 10px; +} + +[title] { + position: relative; + display: inline-flex; + justify-content: center; +} + +[title]:focus::after { + content: attr(title); + position: absolute; + top: 90%; + font: 'Cute Font'; + color: #000; + background-color: #fff; + border: 1px solid; + width: fit-content; + padding: 3px; + z-index: 3; +} + +@media only screen and (min-width : 872px) { + .footer { + display: inline-block; + left: 0; + right: 0; + bottom: 0; + font-size: 12px; + width: 100vw; + user-select: none; + height: auto; + position: fixed; + backdrop-filter: blur(1px); + } + + .post-back { + position: absolute; + left: 25px; + } + + .post-back button { + padding-top: 20px; + padding-bottom: 20px; + } + + .post-container { + display: flex; + flex-direction: column; + text-wrap: wrap; + width: 80vw; + margin-left: 10vw; + margin-right: 10vw; + justify-content: center; + } + + .smaller video { + width: 360px; + height: 360px; + } +} + +@media only screen and (max-width : 871px) { + .footer { + display: inline-block; + left: 0; + right: 0; + bottom: 0; + font-size: 12px; + width: 100vw; + user-select: none; + height: auto; + position: inherit; + } + + .post-container { + display: flex; + flex-direction: column; + text-wrap: wrap; + width: 90vw; + margin-left: 5vw; + margin-right: 5vw; + justify-content: center; + } + + .post-back { + margin-bottom: 20px; + position: relative; + left: 5vw; + width: 90vw; + + } + + .post-back button { + width: 100% + } +} + +.post-details { + gap: 0px; + display: inline-block; +} + +.post-details h3 { + font-size: 0.7rem; + font-weight: lighter; + font-style: italic; +} + +.footer .content { + filter: drop-shadow(0px 0px 6px #000000); +} + +.badge-list { + display: flex; + flex-wrap: wrap; + height: 100%; + max-width: 100vw; + + align-items: center; + padding-left: 10px; + padding-right: 10px; + padding-top: 5px; + padding-bottom: 8px; + gap: 10px; +} + +.badge { + filter: drop-shadow(5px 5px 0px #000000); + image-rendering: pixelated; +} + +.badge:hover { + filter: brightness(120%) drop-shadow(5px 5px 0px hsl(0, 0%, 30%)); + transform: scale(1.1) skew(-10deg, 10deg); +} + +.container .posts-list { + padding-bottom: 100px; +} + +.link-list { + font-family: 'Cute Font'; + display: inline-flex; + flex-direction: column; + + gap: 30px; + filter: $link_filter; +} + +.pagination-list { + list-style: none; + + font-family: 'Cute Font'; + display: inline-flex; + flex-direction: row; + + gap: 30px; + filter: $link_filter; +} + + +.link-container { + background-color: #fff6d5; + width: 100%; + padding-left: 3px; + padding-right: 3px; +} + +.link-container:hover { + filter: $link_select_filter; +} + +.home-body { + display: flex; + columns: 2; + flex-wrap: wrap; + width: 80%; + margin-left: 5%; + margin-right: 5%; + justify-content: space-evenly; +} + +.home-column { + margin-left: 5%; + margin-right: 5%; +} + + + +.post-body { + background-color: $link_bg_color; + font-family: 'Cute Font'; + filter: $link_filter; + color: $link_color; + + padding-left: 10px; + padding-right: 10px; +} + +.pagination { + display: flex; + justify-content: center; + gap: 20px; + width: 100vw; +} + +.pagination-list { + display: flex; + justify-content: left; + flex-direction: row; + gap: 15px; +} + +.pagination-list .pagination-item { + background-color: #fff6d5; + padding-left: 3px; + padding-right: 3px; +} + +.pagination-item:hover { + filter: $link_select_filter; +} + +.scratchpad-tile { + width: 10px; + height: 10px; + background: #fff6d5; +} + +.scratchpad-active-tile { + filter: invert(100%); +} + +.scratchpad-grid { + display: grid; + + grid-template-columns: repeat(32, 1fr); + grid-template-rows: repeat(32, 1fr); + + filter: $link_filter; +} + +.subline { + font-size: 1rem; + text-align: center; + opacity: 80%; + filter: brightness(80%); + font-style: italic; +} + + + + + +.player .number { + padding-top: 3px; + margin: 0px; + // color: white; +} + +.player:hover { + filter: brightness(120%); +} + +.player-container { + width: 100%; + margin-left: 10vw; + margin-right: 10vw; +} + +.player-title, +.player-description { + padding-left: 10px; + padding-right: 10px; + filter: $link_filter; + background-color: $link_bg_color; +} + +.player-description { + font-size: 0.8rem; + text-align: left; + padding-left: 20px; +} + +.video-player { + border: solid; + border-width: 5px; + border-color: $border_color; + background-color: $border_color; +} + +video { + max-width: 100%; + max-height: 100%; + + object-fit: scale-down; +} + +.video-container { + border: none; + background: none; + +} + + +.start-game { + align-self: center; + margin-bottom: 30px; + font-size: 2rem; + background-color: hsl(283, 100%, 80%); + position: relative; + display: inline-flex; + justify-content: center; +} + + +button { + font-size: 1rem; + font-family: 'Cute Font'; + padding-top: 2px; + padding-bottom: 2px; + + color: inherit; + border: none; + + width: fit-content; + height: fit-content; + outline: inherit; + padding-left: 5px; + padding-right: 5px; +} + +button:disabled { + filter: grayscale(80%); +} + +button:hover { + filter: brightness(80%); +} + +button:disabled:hover { + filter: sepia(100%); +} + +button:disabled:hover::after { + content: attr(reason); + position: absolute; + margin-top: 10px; + top: 90%; + font: 'Cute Font'; + color: #000; + background-color: #fff; + border: 1px solid; + min-width: 50vw; + width: fit-content; + padding: 3px; + z-index: 3; +} + +.player-title { + font-size: 1.2rem; + justify-self: center; + flex-grow: 10; + font-style: italic; + padding-top: 2px; + padding-bottom: 2px; +} + +.player-header { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + justify-content: left; + height: min-content; + align-items: stretch; + margin-bottom: 20px; +} + +.player-header>.button-container>button { + height: 100%; +} + +.embed-link-hint { + font-size: 0.8rem; + font-style: italic; + text-align: center; +} + +.post-content { + padding-left: 30px; + padding-right: 30px; +} + +.post-content p { + text-align: left; + font-size: 1.1rem; +} + +.definition { + text-decoration: underline dotted; + cursor: help; +} + +.intentionally-left-blank { + font-size: 0.8rem; + text-align: center; + font-style: italic; + user-select: none; + opacity: 70%; +} + +blockquote { + margin: 1.5em 10px; + padding: 0.5em 10px; + quotes: "\201C" "\201D" "\2018" "\2019"; + font-style: italic; +} + +blockquote p { + display: inline; +} + +.sic { + position: relative; + bottom: 1.5ex; + font-size: 60%; +} + +.settings { + list-style: none; + + font-family: 'Cute Font'; + // font-size: 0.7rem; + background-color: white; + display: flex; + // flex-wrap: wrap; + flex-direction: column; + // width: 100%; + margin-left: 20px; + margin-right: 20px; + + gap: 30px; + // filter: $link_filter; +} + +.settings h2 { + text-align: center; +} + +stack { + list-style: none; + + font-family: 'Cute Font'; + background-color: white; + display: flex; + // flex-wrap: wrap; + flex-direction: column; + padding: 20px; + + gap: 10px; +} + +stack>ul { + list-style: none; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-around; +} + +stack>ul>li { + display: inline-block; + // margin: 10px; +} + +h2 { + text-align: center; +} + +button.confirm { + align-self: center; + margin: 30px; + font-size: 2rem; +} + +.role-list { + list-style: none; + + font-family: 'Cute Font'; + background-color: white; + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: center; + padding: 20px; + + gap: 10px; +} + +.role-card { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + min-width: 320px; +} + + +.role-card button { + min-width: 25px; + min-height: 25px; + background-color: rgba(255, 255, 255, 0.5); + margin: 0px; + margin-left: 10px; + margin-right: 10px; +} + +.role-card.village { + background-color: $village_color; +} + +rolecard { + display: flex; + flex-direction: row; + align-items: stretch; + width: 100%; + text-align: center; + // gap: 20px; + justify-content: space-between; +} + +.role-card.wolves { + background-color: $wolves_color; +} + +bool_spacer { + min-width: 25px; + min-height: 25px; + margin: 0px; + margin-left: 10px; + margin-right: 10px; + margin-top: 5px; + margin-bottom: 5px; + background-color: rgba(255, 255, 255, 0.5); +} + +bool_role { + display: flex; + flex-direction: row; + align-items: stretch; + width: 100%; + text-align: center; + // gap: 20px; + justify-content: space-between; +} + +.wolves { + background-color: $wolves_color; +} + +.role-card.wolves bool_role input[type="checkbox"] { + min-width: 25px; + min-height: 25px; + opacity: 100%; + accent-color: $wolves_color; + margin: 0px; + margin-left: 10px; + margin-right: 10px; +} + + +.role-card.village bool_role input[type="checkbox"] { + min-width: 25px; + min-height: 25px; + opacity: 100%; + accent-color: $village_color; + margin: 0px; + margin-left: 10px; + margin-right: 10px; +} + +.error-container { + width: 70vw; + margin-left: 10vw; + margin-right: 10vw; +} + +.error-container button { + background: transparent; + font-size: 2rem; + position: sticky; + display: inline-block; + + &:hover { + // color: white; + filter: invert(20%); + font-size: 3rem; + } +} + +.error-message { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + margin: 30px; + text-align: center; + // gap: 20px; + justify-content: center; + gap: 30px; + background-color: $error_color; + filter: $error_filter; + + + padding-left: 5vw; + padding-right: 5vw; +} + + +.character { + background-color: $village_color; + width: fit-content; + padding-left: 10px; + padding-right: 10px; + padding-top: 5px; + padding-bottom: 5px; + margin: 10px; +} + + +.character.selected { + filter: hue-rotate(30deg); +} + +.character:hover { + filter: brightness(80%); +} + + +player { + background-color: rgba(255, 107, 255, 0.7); + width: fit-content; + padding-left: 10px; + padding-right: 10px; + padding-top: 5px; + padding-bottom: 5px; + margin: 10px; + color: rgba(255, 255, 255, 0.9); +} + +$client_shadow_color: hsl(260, 55%, 61%); +$client_shadow_color_2: hsl(240, 55%, 61%); +$client_filter: drop-shadow(5px 5px 0 $client_shadow_color) drop-shadow(5px 5px 0 $client_shadow_color_2); + +client { + list-style: none; + + font-family: 'Cute Font'; + // font-size: 0.7rem; + background-color: hsl(280, 55%, 61%); + display: flex; + // flex-wrap: wrap; + flex-direction: column; + margin-left: 20px; + margin-right: 20px; + padding: 30px; + + gap: 30px; + filter: $client_filter; + border: 2px solid black; +} + +clients { + list-style: none; + + font-family: 'Cute Font'; + // font-size: 0.7rem; + background-color: white; + display: flex; + flex-wrap: wrap; + flex-direction: row; + font-size: 2rem; + margin-left: 20px; + margin-right: 20px; + + gap: 10px; + border: solid 3px; + border-color: #432054; +} + +.role-reveal-cards { + list-style: none; + + font-family: 'Cute Font'; + // font-size: 0.7rem; + background-color: hsl(280, 55%, 61%); + display: flex; + // flex-wrap: wrap; + flex-direction: column; + margin-left: 20px; + margin-right: 20px; + padding: 30px; + + gap: 30px; + filter: $client_filter; + border: 2px solid black; +} + +.role-reveal-card { + background-color: purple; +} + +.role-reveal-card.ready { + background-color: green; +} + +.role-reveal-card.not-ready { + background-color: red; +} + +.pronouns { + font-size: 70%; + filter: opacity(70%); +} + +.row-list { + list-style: none; + + font-family: 'Cute Font'; + // font-size: 0.7rem; + // background-color: white; + // max-width: 90vw; + display: flex; + flex-wrap: wrap; + flex-direction: row; + font-size: 2rem; + margin-left: 20px; + margin-right: 20px; + justify-content: center; +} + +.gap { + gap: 10px; +} + + + +.column-list { + list-style: none; + justify-content: center; + + font-family: 'Cute Font'; + // font-size: 0.7rem; + // max-width: 90vw; + // background-color: white; + display: flex; + // flex-wrap: wrap; + flex-direction: column; + font-size: 2rem; + margin-left: 20px; + margin-right: 20px; + + padding-left: 5px; + padding-right: 5px; + padding-top: 5px; + padding-bottom: 5px; +} + +.box { + border: solid 3px; + border-color: #432054; +} + +.content { + background-color: white; + margin-left: 5vw; + margin-right: 5vw; + margin-top: 30px; + display: flexbox; + flex-basis: content; +} + +.sp-ace { + margin: 10px; +} + +.cover-of-darkness { + background-color: #000; + color: #fff; + font-size: 3rem; + position: fixed; + bottom: 0; + right: 0; + height: 100vh; + width: 100vw; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; +} + +.small { + font-size: 1.2rem; +} + +.ident { + gap: 0px; + margin: 0px; + padding: 0px; + + .submenu { + visibility: collapse; + + .button-container { + display: flex; + align-items: stretch; + } + } + + &:active, + &:focus, + &:hover { + .submenu { + visibility: visible; + } + } +} + +.baseline { + align-items: baseline; + // justify-content: space-evenly; +} + +nav.debug-nav { + position: sticky; + backdrop-filter: brightness(70%); + // background-color: $link_bg_color; + // filter: $link_filter; + display: flex; + align-items: flex-start; + + button { + color: #cccccc; + } +} + +error { + position: absolute; + top: 0; + left: 0; +} + +.identity { + list-style: none; + + font-family: 'Cute Font'; + display: flex; + flex-direction: column; + gap: 0px; + font-size: 1rem; + justify-content: flex-start; + margin: 0px; + padding: 0px; + + p { + margin: 0px; + padding: 0px; + } +} + +.button-container { + background-color: inherit; + + button { + background: transparent; + background-repeat: no-repeat; + border: none; + cursor: pointer; + overflow: hidden; + outline: none; + padding: 0px; + + &:hover { + background-color: rgba(0, 0, 0, 0.5); + } + } +} + + +.player { + + margin: 0px; + padding-left: 5px; + padding-right: 5px; + text-align: center; + + justify-content: center; + font-family: 'Cute Font'; + + background-color: $village_color; + border: 3px solid darken($village_color, 20%); + + &.on-the-block { + // background-color: brighten($village_color, 100%); + filter: hue-rotate(90deg); + } + + &.connected { + background-color: $connected_color; + border: 3px solid darken($connected_color, 20%); + } + + &.disconnected { + background-color: $disconnected_color; + border: 3px solid darken($disconnected_color, 20%); + } +} diff --git a/werewolves/src/app.rs b/werewolves/src/app.rs new file mode 100644 index 0000000..199f2ab --- /dev/null +++ b/werewolves/src/app.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; + +use yew::prelude::*; +use yew_router::{ + BrowserRouter, Router, Switch, + history::{AnyHistory, History, MemoryHistory}, +}; + +use crate::pages::*; + +#[function_component] +pub fn App() -> Html { + html! { +
+ // + // +
+ } +} diff --git a/werewolves/src/assets.rs b/werewolves/src/assets.rs new file mode 100644 index 0000000..e86b124 --- /dev/null +++ b/werewolves/src/assets.rs @@ -0,0 +1 @@ +werewolves_macros::static_links!("werewolves/assets" relative to "werewolves/"); diff --git a/werewolves/src/callback.rs b/werewolves/src/callback.rs new file mode 100644 index 0000000..c50ab3b --- /dev/null +++ b/werewolves/src/callback.rs @@ -0,0 +1,45 @@ +use core::fmt::Debug; +use std::sync::Arc; + +use futures::SinkExt; +use yew::{html::Scope, prelude::*}; + +pub fn send_message( + msg: T, + send: futures::channel::mpsc::Sender, +) -> Callback

{ + Callback::from(move |_| { + let mut send = send.clone(); + let msg = msg.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.send(msg.clone()).await { + log::error!("sending message <({msg:?})> in callback: {err}") + }; + }); + }) +} + +pub fn mouse_event(inner: F) -> Callback +where + F: Fn() + 'static, +{ + Callback::from(move |_| (inner)()) +} + +pub fn send_fn(msg_fn: F, send: futures::channel::mpsc::Sender) -> Callback

+where + T: Clone + Debug + 'static, + F: Fn(P) -> T + 'static, + P: 'static, +{ + let msg_fn = Arc::new(msg_fn); + Callback::from(move |param| { + let mut send = send.clone(); + let msg_fn = msg_fn.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.send((msg_fn)(param)).await { + log::error!("sending message in callback: {err}") + }; + }); + }) +} diff --git a/werewolves/src/components/action/prompt.rs b/werewolves/src/components/action/prompt.rs new file mode 100644 index 0000000..d627369 --- /dev/null +++ b/werewolves/src/components/action/prompt.rs @@ -0,0 +1,109 @@ +use core::ops::Not; + +use werewolves_proto::{ + message::{ + PublicIdentity, Target, + host::{HostGameMessage, HostMessage, HostNightMessage}, + night::{ActionPrompt, ActionResponse}, + }, + player::CharacterId, +}; +use yew::prelude::*; + +use crate::components::{ + Identity, + action::{SingleTarget, WolvesIntro}, +}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct ActionPromptProps { + pub prompt: ActionPrompt, + pub ident: PublicIdentity, + #[prop_or_default] + pub big_screen: bool, + pub on_complete: Callback, +} + +#[function_component] +pub fn Prompt(props: &ActionPromptProps) -> Html { + let ident = props + .big_screen + .not() + .then(|| html! {}); + match &props.prompt { + ActionPrompt::WolvesIntro { wolves } => { + let on_complete = props.on_complete.clone(); + let on_complete = Callback::from(move |_| { + on_complete.emit(HostMessage::InGame( + werewolves_proto::message::host::HostGameMessage::Night( + werewolves_proto::message::host::HostNightMessage::ActionResponse( + werewolves_proto::message::night::ActionResponse::WolvesIntroAck, + ), + ), + )) + }); + html! { + + } + } + ActionPrompt::Seer { living_players } => { + let on_complete = props.on_complete.clone(); + let on_select = Callback::from(move |target: CharacterId| { + on_complete.emit(HostMessage::InGame(HostGameMessage::Night( + HostNightMessage::ActionResponse(ActionResponse::Seer(target)), + ))); + }); + html! { +

+ {ident} + +
+ } + } + ActionPrompt::RoleChange { new_role } => { + let on_complete = props.on_complete.clone(); + let on_click = Callback::from(move |_| { + on_complete.emit(HostMessage::InGame(HostGameMessage::Night( + HostNightMessage::ActionResponse(ActionResponse::RoleChangeAck), + ))) + }); + let cont = props.big_screen.not().then(|| { + html! { + + } + }); + html! { +
+ {ident} +

{"your role has changed"}

+

{new_role.to_string()}

+ {cont} +
+ } + } + ActionPrompt::Protector { targets } => todo!(), + ActionPrompt::Arcanist { living_players } => todo!(), + ActionPrompt::Gravedigger { dead_players } => todo!(), + ActionPrompt::Hunter { + current_target, + living_players, + } => todo!(), + ActionPrompt::Militia { living_players } => todo!(), + ActionPrompt::MapleWolf { + kill_or_die, + living_players, + } => todo!(), + ActionPrompt::Guardian { + previous, + living_players, + } => todo!(), + ActionPrompt::WolfPackKill { living_villagers } => todo!(), + ActionPrompt::Shapeshifter => todo!(), + ActionPrompt::AlphaWolf { living_villagers } => todo!(), + ActionPrompt::DireWolf { living_players } => todo!(), + } +} diff --git a/werewolves/src/components/action/result.rs b/werewolves/src/components/action/result.rs new file mode 100644 index 0000000..d9583e4 --- /dev/null +++ b/werewolves/src/components/action/result.rs @@ -0,0 +1,96 @@ +use core::ops::Not; + +use werewolves_proto::{ + message::{ + PublicIdentity, + host::{HostGameMessage, HostMessage, HostNightMessage}, + night::{ActionPrompt, ActionResponse, ActionResult}, + }, + role::Alignment, +}; +use yew::prelude::*; + +use crate::components::{CoverOfDarkness, Identity}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct ActionResultProps { + pub result: ActionResult, + pub ident: PublicIdentity, + #[prop_or_default] + pub big_screen: bool, + pub on_complete: Callback, +} + +#[function_component] +pub fn ActionResultView(props: &ActionResultProps) -> Html { + let ident = props + .big_screen + .not() + .then(|| html! {}); + let on_complete = props.on_complete.clone(); + let on_complete = Callback::from(move |_| { + on_complete.emit(HostMessage::InGame(HostGameMessage::Night( + HostNightMessage::Next, + ))) + }); + let cont = props + .big_screen + .not() + .then(|| html! {}); + match &props.result { + ActionResult::RoleBlocked => { + html! { +
+ {ident} +

{"you were role blocked"}

+ {cont} +
+ } + } + ActionResult::Seer(alignment) => html! { +
+ {ident} +

{"the alignment was"}

+

{match alignment { + Alignment::Village => "village", + Alignment::Wolves => "wolfpack", + }}

+ {cont} +
+ }, + ActionResult::Arcanist { same } => todo!(), + ActionResult::GraveDigger(role_title) => todo!(), + ActionResult::WolvesMustBeUnanimous => todo!(), + ActionResult::WaitForOthersToVote => todo!(), + ActionResult::GoBackToSleep => { + let next = props.big_screen.not().then(|| { + let on_complete = props.on_complete.clone(); + move |_| { + on_complete.emit(HostMessage::InGame(HostGameMessage::Night( + HostNightMessage::Next, + ))) + } + }); + html! { + + {"continue"} + + } + } + ActionResult::RoleRevealDone => todo!(), + ActionResult::WolvesIntroDone => { + let on_complete = props.on_complete.clone(); + let next = props.big_screen.not().then(|| { + Callback::from(move |_| { + on_complete.emit(HostMessage::InGame(HostGameMessage::Night( + HostNightMessage::Next, + ))) + }) + }); + + html! { + + } + } + } +} diff --git a/werewolves/src/components/action/target.rs b/werewolves/src/components/action/target.rs new file mode 100644 index 0000000..e9ab243 --- /dev/null +++ b/werewolves/src/components/action/target.rs @@ -0,0 +1,112 @@ +use core::ops::Not; + +use werewolves_proto::{message::Target, player::CharacterId}; +use yew::{html::Scope, prelude::*}; + +use crate::components::Identity; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct SingleTargetProps { + pub targets: Box<[Target]>, + #[prop_or_default] + pub headline: &'static str, + #[prop_or_default] + pub read_only: bool, + pub on_select: Callback, +} + +pub struct SingleTarget { + selected: Option, +} + +impl Component for SingleTarget { + type Message = CharacterId; + + type Properties = SingleTargetProps; + + fn create(_: &Context) -> Self { + Self { selected: None } + } + + fn view(&self, ctx: &Context) -> Html { + let SingleTargetProps { read_only, headline, targets, on_select } = ctx.props(); + let scope = ctx.link().clone(); + let card_select = Callback::from(move |target| { + scope.send_message(target); + }); + let targets = targets.iter().map(|t| { + html!{ + + } + }).collect::(); + let headline = if headline.trim().is_empty() { + html!() + } else { + html!(

{headline}

) + }; + + let on_select = on_select.clone(); + let on_click = if let Some(target) = self.selected.clone() { + Callback::from(move |_| on_select.emit(target.clone())) + } else { + Callback::from(|_| ()) + }; + + let submit = read_only.not().then(|| html!{ +
+ +
+ }); + + html!{ +
+ {headline} +
+ {targets} +
+ {submit} +
+ } + } + + fn update(&mut self, _: &Context, msg: Self::Message) -> bool { + if let Some(selected) = self.selected.as_ref() { + if selected == &msg { + self.selected = None; + } else { + self.selected = Some(msg); + } + } else { + self.selected = Some(msg); + } + true + } +} + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct TargetCardProps { + pub target: Target, + pub selected: bool, + pub on_select: Callback, +} + +#[function_component] +fn TargetCard(props: &TargetCardProps) -> Html { + let on_select = props.on_select.clone(); + let target = props.target.character_id.clone(); + let on_click = Callback::from(move |_| { + on_select.emit(target.clone()); + }); + html! { +
+ +
+ } +} diff --git a/werewolves/src/components/action/wolves.rs b/werewolves/src/components/action/wolves.rs new file mode 100644 index 0000000..4e2c1d8 --- /dev/null +++ b/werewolves/src/components/action/wolves.rs @@ -0,0 +1,42 @@ +use werewolves_proto::{message::Target, role::RoleTitle}; +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct WolvesIntroProps { + pub wolves: Box<[(Target, RoleTitle)]>, + pub big_screen: bool, + pub on_complete: Callback<()>, +} + +#[function_component] +pub fn WolvesIntro(props: &WolvesIntroProps) -> Html { + let on_complete = props.on_complete.clone(); + let on_complete = Callback::from(move |_| on_complete.emit(())); + html! { +
+

{"these are the wolves:"}

+ { + if props.big_screen { + html!() + } else { + html!{ + + } + } + } + { + props.wolves.iter().map(|w| html!{ +
+

{w.1.to_string()}

+

{w.0.public.name.clone()}

+ { + w.0.public.pronouns.as_ref().map(|p| html!{ +

{"("}{p.as_str()}{")"}

+ }).unwrap_or(html!()) + } +
+ }).collect::() + } +
+ } +} diff --git a/werewolves/src/components/button.rs b/werewolves/src/components/button.rs new file mode 100644 index 0000000..7107199 --- /dev/null +++ b/werewolves/src/components/button.rs @@ -0,0 +1,28 @@ +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct ButtonProperties { + pub on_click: Callback<()>, + #[prop_or_default] + pub disabled_reason: Option, + #[prop_or_default] + pub children: Html, +} + +#[function_component] +pub fn Button(props: &ButtonProperties) -> Html { + let on_click = props.on_click.clone(); + let on_click = Callback::from(move |_| on_click.emit(())); + html! { +
+ +
+ } +} diff --git a/werewolves/src/components/cover.rs b/werewolves/src/components/cover.rs new file mode 100644 index 0000000..468bc04 --- /dev/null +++ b/werewolves/src/components/cover.rs @@ -0,0 +1,34 @@ +use yew::prelude::*; + +use crate::components::Button; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct CoverOfDarknessProps { + #[prop_or_default] + pub next: Option>, + #[prop_or_else(|| String::from("night falls"))] + pub message: String, + #[prop_or_else(|| html!("begin"))] + pub children: Html, +} + +#[function_component] +pub fn CoverOfDarkness( + CoverOfDarknessProps { + message, + next, + children, + }: &CoverOfDarknessProps, +) -> Html { + let next = next.as_ref().map(|next| { + html! { + + } + }); + html! { +
+

{message}

+ {next} +
+ } +} diff --git a/werewolves/src/components/host/mod.rs b/werewolves/src/components/host/mod.rs new file mode 100644 index 0000000..3600998 --- /dev/null +++ b/werewolves/src/components/host/mod.rs @@ -0,0 +1,107 @@ +use core::ops::Not; + +use werewolves_proto::{message::CharacterState, player::CharacterId}; +use yew::prelude::*; + +use crate::components::{Button, Identity}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct DaytimePlayerListProps { + pub characters: Box<[CharacterState]>, + pub marked: Box<[CharacterId]>, + pub on_execute: Callback<()>, + pub on_mark: Callback, + pub big_screen: bool, +} + +#[function_component] +pub fn DaytimePlayerList( + DaytimePlayerListProps { + characters, + on_execute, + on_mark, + marked, + big_screen, + }: &DaytimePlayerListProps, +) -> Html { + let on_select = big_screen.not().then(|| on_mark.clone()); + let chars = characters + .iter() + .map(|c| { + html! { + + } + }) + .collect::(); + let button = big_screen.not().then(|| { + html! { + + } + }); + html! { +
+
+ {chars} +
+ {button} +
+ } +} + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct DaytimePlayerProps { + pub character: CharacterState, + pub on_the_block: bool, + pub on_select: Option>, +} + +#[function_component] +pub fn DaytimePlayer( + DaytimePlayerProps { + on_select, + on_the_block, + character: + CharacterState { + player_id: _, + character_id, + public_identity, + role: _, + died_to, + }, + }: &DaytimePlayerProps, +) -> Html { + let dead = died_to.is_some().then_some("dead"); + let button_text = if *on_the_block { "unmark" } else { "mark" }; + let on_the_block = on_the_block.then_some("on-the-block"); + let submenu = died_to.is_none().then_some(()).and_then(|_| { + on_select.as_ref().map(|on_select| { + let character_id = character_id.clone(); + let on_select = on_select.clone(); + let on_click = Callback::from(move |_| on_select.emit(character_id.clone())); + html! { + + } + }) + }); + + html! { +
+ + {submenu} +
+ } +} diff --git a/werewolves/src/components/identity.rs b/werewolves/src/components/identity.rs new file mode 100644 index 0000000..38de330 --- /dev/null +++ b/werewolves/src/components/identity.rs @@ -0,0 +1,31 @@ +use werewolves_proto::message::PublicIdentity; +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct IdentityProps { + pub ident: PublicIdentity, +} + +#[function_component] +pub fn Identity(props: &IdentityProps) -> Html { + let IdentityProps { + ident: + PublicIdentity { + name, + pronouns, + number, + }, + } = props; + let pronouns = pronouns.as_ref().map(|p| { + html! { +

{"("}{p}{")"}

+ } + }); + html! { +
+

{number.get()}

+

{name}

+ {pronouns} +
+ } +} diff --git a/werewolves/src/components/input_name.rs b/werewolves/src/components/input_name.rs new file mode 100644 index 0000000..e6b8497 --- /dev/null +++ b/werewolves/src/components/input_name.rs @@ -0,0 +1,73 @@ +use core::num::NonZeroU8; + +use web_sys::{HtmlInputElement, HtmlSelectElement, wasm_bindgen::JsCast}; +use werewolves_proto::message::PublicIdentity; +use yew::prelude::*; + +#[derive(Debug, PartialEq, Properties)] +pub struct InputProps { + #[prop_or_default] + pub initial_value: PublicIdentity, + pub callback: Callback, +} + +#[function_component] +pub fn InputName(props: &InputProps) -> Html { + let callback = props.callback.clone(); + let on_click = Callback::from(move |_| { + let name = gloo::utils::document() + .query_selector(".identity-input #name") + .expect("cannot find name input") + .and_then(|e| e.dyn_into::().ok()) + .expect("name input element not HtmlInputElement") + .value() + .trim() + .to_string(); + let pronouns = match gloo::utils::document() + .query_selector(".identity-input #pronouns") + .expect("cannot find pronouns input") + .and_then(|e| e.dyn_into::().ok()) + .expect("pronouns input element not HtmlInputElement") + .value() + .trim() + { + "" => None, + p => Some(p.to_string()), + }; + let number = gloo::utils::document() + .query_selector(".identity-input #number") + .expect("cannot find number input") + .and_then(|e| e.dyn_into::().ok()) + .expect("number input element not HtmlSelectElement") + .value() + .trim() + .parse::() + .expect("parse number"); + if name.is_empty() { + return; + } + callback.emit(PublicIdentity { + name, + pronouns, + number, + }); + }); + html! { +
+ + + + + + + +
+ } +} diff --git a/werewolves/src/components/lobby.rs b/werewolves/src/components/lobby.rs new file mode 100644 index 0000000..b7e0fe5 --- /dev/null +++ b/werewolves/src/components/lobby.rs @@ -0,0 +1,30 @@ +use werewolves_proto::{message::PlayerState, player::PlayerId}; +use yew::prelude::*; + +use crate::components::{LobbyPlayer, LobbyPlayerAction}; + +#[derive(Debug, PartialEq, Properties)] +pub struct LobbyProps { + pub players: Box<[PlayerState]>, + #[prop_or_default] + pub on_action: Option>, +} + +#[function_component] +pub fn Lobby(LobbyProps { players, on_action }: &LobbyProps) -> Html { + let mut players = players.clone(); + players.sort_by_key(|f| f.identification.public.number.get()); + html! { +
+

{format!("Players in lobby: {}", players.len())}

+
+ { + players + .into_iter() + .map(|p| html! {}) + .collect::() + } +
+
+ } +} diff --git a/werewolves/src/components/lobby_player.rs b/werewolves/src/components/lobby_player.rs new file mode 100644 index 0000000..c7585bf --- /dev/null +++ b/werewolves/src/components/lobby_player.rs @@ -0,0 +1,50 @@ +use web_sys::{HtmlDivElement, HtmlElement}; +use werewolves_proto::{message::PlayerState, player::PlayerId}; +use yew::prelude::*; + +use crate::components::{Button, Identity}; + +#[derive(Debug, PartialEq, Properties)] +pub struct LobbyPlayerProps { + pub player: PlayerState, + #[prop_or_default] + pub on_action: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LobbyPlayerAction { + Kick, +} + +#[function_component] +pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) -> Html { + let class = if player.connected { + "connected" + } else { + "disconnected" + }; + let pid = player.identification.player_id.clone(); + let action = |action: LobbyPlayerAction| { + let pid = pid.clone(); + if let Some(on_action) = on_action.as_ref() { + let on_action = on_action.clone(); + Callback::from(move |_| on_action.emit((pid.clone(), action))) + } else { + Callback::noop() + } + }; + let submenu = on_action.is_some().then(|| { + html! { + + } + }); + + html! { +
+ + {submenu} +
+ } +} diff --git a/werewolves/src/components/notification.rs b/werewolves/src/components/notification.rs new file mode 100644 index 0000000..4e23274 --- /dev/null +++ b/werewolves/src/components/notification.rs @@ -0,0 +1,19 @@ +use yew::prelude::*; + +#[derive(Debug, PartialEq, Properties)] +pub struct NotificationProps { + pub text: String, + pub callback: Callback<()>, +} + +#[function_component] +pub fn Notification(props: &NotificationProps) -> Html { + let cb = props.callback.clone(); + let on_click = Callback::from(move |_| cb.clone().emit(())); + html! { + +

{props.text.clone()}

+ +
+ } +} diff --git a/werewolves/src/components/reveal.rs b/werewolves/src/components/reveal.rs new file mode 100644 index 0000000..06f7671 --- /dev/null +++ b/werewolves/src/components/reveal.rs @@ -0,0 +1,86 @@ +use werewolves_proto::message::Target; +use yew::prelude::*; + +#[derive(Debug, PartialEq, Properties)] +pub struct RoleRevealProps { + pub ackd: Box<[Target]>, + pub waiting: Box<[Target]>, + pub on_force_ready: Callback, +} + +pub struct RoleReveal {} + +impl Component for RoleReveal { + type Message = Target; + + type Properties = RoleRevealProps; + + fn create(_ctx: &Context) -> Self { + Self {} + } + + fn view(&self, ctx: &Context) -> Html { + let RoleRevealProps { + ackd, + waiting, + on_force_ready, + } = ctx.props(); + let on_force_ready = on_force_ready.clone(); + let cards = ackd + .iter() + .map(|t| (t, true)) + .chain(waiting.iter().map(|t| (t, false))) + .map(|(t, ready)| { + html! { + + } + }) + .collect::(); + + let nack = waiting.clone(); + let on_force_all = Callback::from(move |_| { + for t in nack.clone() { + on_force_ready.emit(t); + } + }); + html! { +
+ + {cards} +
+ } + } +} + +#[derive(Debug, PartialEq, Properties)] +pub struct RoleRevealCardProps { + pub target: Target, + pub is_ready: bool, + pub on_force_ready: Callback, +} + +#[function_component] +pub fn RoleRevealCard(props: &RoleRevealCardProps) -> Html { + let class = if props.is_ready { "ready" } else { "not-ready" }; + let target = props.target.clone(); + let on_force_ready = props.on_force_ready.clone(); + let on_click = Callback::from(move |_| { + on_force_ready.emit(target.clone()); + }); + html! { +
+

{props.target.public.name.as_str()}

+ { + if !props.is_ready { + html! {} + } else { + html!{} + } + } +
+ } +} diff --git a/werewolves/src/components/settings.rs b/werewolves/src/components/settings.rs new file mode 100644 index 0000000..fe7180f --- /dev/null +++ b/werewolves/src/components/settings.rs @@ -0,0 +1,169 @@ +use convert_case::{Case, Casing}; +use web_sys::HtmlInputElement; +use werewolves_proto::{ + error::GameError, + game::GameSettings, + role::{Alignment, RoleTitle}, +}; +use yew::prelude::*; + +const ALIGN_VILLAGE: &str = "village"; +const ALIGN_WOLVES: &str = "wolves"; + +#[derive(Debug, PartialEq, Properties)] +pub struct SettingsProps { + pub settings: GameSettings, + pub players_in_lobby: usize, + pub on_update: Callback, + pub on_start: Callback<()>, + #[prop_or_default] + pub on_error: Option>, +} + +fn get_role_count(settings: &GameSettings, role: RoleTitle) -> u8 { + match role { + RoleTitle::Villager => { + panic!("villager should not be in the settings page") + } + _ => settings + .roles() + .into_iter() + .find_map(|(r, cnt)| (r == role).then_some(cnt.get())) + .unwrap_or_default(), + } +} + +enum AmountChange { + Increment(RoleTitle), + Decrement(RoleTitle), +} + +#[function_component] +pub fn Settings(props: &SettingsProps) -> Html { + let on_update = props.on_update.clone(); + let (start_game_disabled, reason) = match props.settings.check() { + Ok(_) => { + if props.players_in_lobby < props.settings.min_players_needed() { + (true, String::from("too few players for role setup")) + } else { + (false, String::new()) + } + } + Err(err) => (true, err.to_string()), + }; + let settings = props.settings.clone(); + let on_error = props.on_error.clone(); + let on_changed = Callback::from(move |change: AmountChange| { + let mut s = settings.clone(); + match change { + AmountChange::Increment(role) => { + if let Err(err) = s.add(role) + && let Some(on_error) = on_error.as_ref() + { + on_error.emit(err); + } + } + AmountChange::Decrement(role) => s.sub(role), + } + if s != settings { + on_update.emit(s) + } + }); + + let on_start = props.on_start.clone(); + let on_start_game = Callback::from(move |_| on_start.emit(())); + + let roles = RoleTitle::ALL + .into_iter() + .filter(|r| !matches!(r, RoleTitle::Scapegoat | RoleTitle::Villager)) + .map(|r| html! {}) + .collect::(); + html! { +
+

{format!("Min players for settings: {}", props.settings.min_players_needed())}

+
+ // + {roles} +
+ +
+ } +} + +enum BoolRoleSet { + Scapegoat { enabled: bool }, +} + +#[derive(Debug, PartialEq, Properties)] +struct BoolRoleProps { + pub role: RoleTitle, + pub enabled: bool, + pub on_changed: Callback, +} + +#[function_component] +fn BoolRoleCard(props: &BoolRoleProps) -> Html { + let align_class = if props.role.wolf() { + ALIGN_WOLVES + } else { + ALIGN_VILLAGE + }; + + let set_role = match props.role { + RoleTitle::Scapegoat => |enabled| BoolRoleSet::Scapegoat { enabled }, + _ => panic!("invalid role for bool card: {}", props.role), + }; + log::warn!("Role: {} | {};", props.role, props.enabled); + let enabled = props.enabled; + let role = props.role; + + let cb = props.on_changed.clone(); + let on_click = Callback::from(move |ev: MouseEvent| { + let input = ev + .target_dyn_into::() + .expect("input callback not on input"); + cb.emit(set_role(input.checked())) + }); + html! { +
+ //
+ + + {role.to_string()} + + + //
+
+ } +} + +#[derive(Debug, PartialEq, Properties)] +struct RoleProps { + pub role: RoleTitle, + pub amount: u8, + pub on_changed: Callback, +} + +#[function_component] +fn RoleCard(props: &RoleProps) -> Html { + let align_class = if props.role.wolf() { + ALIGN_WOLVES + } else { + ALIGN_VILLAGE + }; + let amount = props.amount; + let role = props.role; + let role_name = role.to_string().to_case(Case::Title); + + let cb = props.on_changed.clone(); + let decrease = Callback::from(move |_| cb.emit(AmountChange::Decrement(role))); + let cb = props.on_changed.clone(); + let increase = Callback::from(move |_| cb.emit(AmountChange::Increment(role))); + html! { +
+ + {role_name}{amount.to_string()} + +
+ } +} diff --git a/werewolves/src/main.rs b/werewolves/src/main.rs new file mode 100644 index 0000000..1c723c5 --- /dev/null +++ b/werewolves/src/main.rs @@ -0,0 +1,106 @@ +mod assets; +mod storage; +mod components { + werewolves_macros::include_path!("werewolves/src/components"); + pub mod host; + pub mod action { + werewolves_macros::include_path!("werewolves/src/components/action"); + } +} +mod pages { + werewolves_macros::include_path!("werewolves/src/pages"); +} +mod callback; +use core::num::NonZeroU8; + +use pages::{Client, ErrorComponent, Host, WerewolfError}; +use web_sys::{Element, HtmlElement, Url, wasm_bindgen::JsCast}; +use werewolves_proto::{ + message::{Identification, PublicIdentity}, + player::PlayerId, +}; +use yew::prelude::*; + +use crate::pages::ClientProps; + +fn main() { + wasm_logger::init(wasm_logger::Config::new(log::Level::Trace)); + let document = gloo::utils::document(); + let url = document.document_uri().expect("get uri"); + let url_obj = Url::new(&url).unwrap(); + let path = url_obj.pathname(); + log::warn!("path: {path}"); + let app_element = document.query_selector("app").unwrap().unwrap(); + let error_element = document.query_selector("error").unwrap().unwrap(); + let ec = yew::Renderer::::with_root(error_element).render(); + let cb_clone = ec.clone(); + let error_callback = + Callback::from(move |err: Option| cb_clone.send_message(err)); + + if path.starts_with("/host") { + let host = yew::Renderer::::with_root(app_element).render(); + host.send_message(pages::HostEvent::SetErrorCallback(error_callback)); + if path.starts_with("/host/big") { + host.send_message(pages::HostEvent::SetBigScreenState(true)); + } + } else if path.starts_with("/many-client") { + let mut number = 1..=0xFFu8; + for (player_id, name, dupe) in [ + ( + PlayerId::from_u128(1), + "player 1", + document.query_selector("app").unwrap().unwrap(), + ), + ( + PlayerId::from_u128(2), + "player 2", + document.query_selector("dupe1").unwrap().unwrap(), + ), + ( + PlayerId::from_u128(3), + "player 3", + document.query_selector("dupe2").unwrap().unwrap(), + ), + ( + PlayerId::from_u128(4), + "player 4", + document.query_selector("dupe3").unwrap().unwrap(), + ), + ( + PlayerId::from_u128(5), + "player 5", + document.query_selector("dupe4").unwrap().unwrap(), + ), + ( + PlayerId::from_u128(6), + "player 6", + document.query_selector("dupe5").unwrap().unwrap(), + ), + ( + PlayerId::from_u128(7), + "player 7", + document.query_selector("dupe6").unwrap().unwrap(), + ), + ] { + let client = + yew::Renderer::::with_root_and_props(dupe, ClientProps { auto_join: true }) + .render(); + client.send_message(pages::Message::ForceIdentity(Identification { + player_id, + public: PublicIdentity { + name: name.to_string(), + pronouns: Some(String::from("he/him")), + number: NonZeroU8::new(number.next().unwrap()).unwrap(), + }, + })); + client.send_message(pages::Message::SetErrorCallback(error_callback.clone())); + } + } else { + let client = yew::Renderer::::with_root_and_props( + app_element, + ClientProps { auto_join: false }, + ) + .render(); + client.send_message(pages::Message::SetErrorCallback(error_callback)); + } +} diff --git a/werewolves/src/pages/client.rs b/werewolves/src/pages/client.rs new file mode 100644 index 0000000..6ae71a0 --- /dev/null +++ b/werewolves/src/pages/client.rs @@ -0,0 +1,534 @@ +use core::{sync::atomic::AtomicBool, time::Duration}; +use std::collections::VecDeque; + +use futures::{ + SinkExt, StreamExt, + channel::mpsc::{Receiver, Sender}, +}; +use gloo::{ + net::websocket::{self, futures::WebSocket}, + storage::{LocalStorage, Storage, errors::StorageError}, +}; +use serde::Serialize; +use werewolves_proto::{ + error::GameError, + game::GameOver, + message::{ + ClientMessage, DayCharacter, Identification, PlayerUpdate, PublicIdentity, ServerMessage, + Target, + night::{ActionPrompt, ActionResponse, ActionResult}, + }, + player::{Character, CharacterId, PlayerId}, + role::RoleTitle, +}; +use yew::{html::Scope, prelude::*}; + +use crate::{ + components::{InputName, Notification}, + storage::StorageKey, +}; + +use super::WerewolfError; + +const DEBUG_URL: &str = "ws://192.168.1.162:8080/connect/client"; +const LIVE_URL: &str = "wss://wolf.emilis.dev/connect/client"; + +#[derive(Debug)] +pub enum Message { + SetErrorCallback(Callback>), + SetPublicIdentity(PublicIdentity), + RecvServerMessage(ServerMessage), + Connect, + ForceIdentity(Identification), +} + +pub struct Connection { + scope: Scope, + ident: Identification, + recv: Receiver, +} + +impl Connection { + async fn connect_ws() -> WebSocket { + let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL); + loop { + match WebSocket::open(url) { + Ok(ws) => break ws, + Err(err) => { + log::error!("connect: {err}"); + yew::platform::time::sleep(Duration::from_secs(1)).await; + } + } + } + } + + fn encode_message(msg: &impl Serialize) -> websocket::Message { + #[cfg(feature = "json")] + { + websocket::Message::Text(serde_json::to_string(msg).expect("message serialization")) + } + #[cfg(feature = "cbor")] + { + websocket::Message::Bytes({ + let mut v = Vec::new(); + ciborium::into_writer(msg, &mut v).expect("serializing message"); + v + }) + } + } + + async fn run(mut self) { + let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL); + 'outer: loop { + log::info!("connecting to {url}"); + let mut ws = Self::connect_ws().await.fuse(); + log::info!("connected to {url}"); + + if let Err(err) = ws.send(Self::encode_message(&self.ident)).await { + log::error!("websocket identification send: {err}"); + continue 'outer; + }; + + if let Err(err) = ws + .send(Self::encode_message(&ClientMessage::GetState)) + .await + { + log::error!("websocket identification send: {err}"); + continue 'outer; + }; + + loop { + let msg = futures::select! { + r = ws.next() => { + match r { + Some(Ok(msg)) => msg, + Some(Err(err)) => { + log::error!("websocket recv: {err}"); + continue 'outer; + }, + None => { + log::warn!("websocket closed"); + continue 'outer; + }, + } + } + r = self.recv.next() => { + match r { + Some(msg) => { + log::info!("sending message: {msg:?}"); + if let Err(err) = ws.send( + Self::encode_message(&msg) + ).await { + log::error!("websocket send error: {err}"); + continue 'outer; + } + continue; + }, + None => { + log::info!("recv channel closed"); + return; + }, + } + }, + }; + let parse = { + #[cfg(feature = "json")] + { + match msg { + websocket::Message::Text(text) => { + serde_json::from_str::(&text) + } + websocket::Message::Bytes(items) => serde_json::from_slice(&items), + } + } + #[cfg(feature = "cbor")] + { + match msg { + websocket::Message::Text(_) => { + log::error!("text messages not supported in cbor mode; discarding"); + continue; + } + websocket::Message::Bytes(bytes) => { + ciborium::from_reader::(bytes.as_slice()) + } + } + } + }; + match parse { + Ok(msg) => self.scope.send_message(Message::RecvServerMessage(msg)), + Err(err) => { + log::error!("parsing server message: {err}; ignoring.") + } + } + } + } + } +} + +#[derive(PartialEq)] +pub enum ClientEvent { + Disconnected, + Notification(String), + Waiting, + ShowRole(RoleTitle), + NotInLobby(Box<[PublicIdentity]>), + InLobby(Box<[PublicIdentity]>), + GameInProgress, + GameOver(GameOver), +} + +impl TryFrom for ClientEvent { + type Error = ServerMessage; + + fn try_from(msg: ServerMessage) -> Result { + Ok(match msg { + ServerMessage::Disconnect => Self::Disconnected, + ServerMessage::LobbyInfo { + joined: false, + players, + } => Self::NotInLobby(players), + ServerMessage::LobbyInfo { + joined: true, + players, + } => Self::InLobby(players), + ServerMessage::GameInProgress => Self::GameInProgress, + _ => return Err(msg), + }) + } +} + +pub struct Client { + player: Option, + send: Sender, + recv: Option>, + current_event: Option, + auto_join: bool, + + error_callback: Callback>, +} + +impl Client { + fn error(&self, err: WerewolfError) { + self.error_callback.emit(Some(err)) + } + + fn clear_error(&self) { + self.error_callback.emit(None) + } + + fn bug(&self, msg: &str) { + log::warn!("BUG: {msg}") + } +} + +#[derive(Debug, Clone, PartialEq, Copy, Properties)] +pub struct ClientProps { + #[prop_or_default] + pub auto_join: bool, +} + +impl Component for Client { + type Message = Message; + + type Properties = ClientProps; + + fn create(ctx: &Context) -> Self { + gloo::utils::document().set_title("Werewolves Player"); + let player = StorageKey::PlayerId + .get() + .ok() + .and_then(|p| StorageKey::PublicIdentity.get().ok().map(|n| (p, n))) + .map(|(player_id, public)| Identification { player_id, public }); + + let (send, recv) = futures::channel::mpsc::channel::(100); + + Self { + player, + send, + recv: Some(recv), + auto_join: ctx.props().auto_join, + error_callback: Callback::from(|err| { + if let Some(err) = err { + log::error!("{err}") + } + }), + current_event: None, + } + } + + fn view(&self, ctx: &Context) -> Html { + if self.player.is_none() { + let scope = ctx.link().clone(); + let callback = Callback::from(move |public: PublicIdentity| { + scope.send_message(Message::SetPublicIdentity(public)); + }); + return html! { + + }; + } else if self.recv.is_some() { + // Player info loaded, but connection isn't started + ctx.link().send_message(Message::Connect); + return html! {}; + } + + let msg = match self.current_event.as_ref() { + Some(msg) => msg, + None => { + return html! { +
+

{"Connecting..."}

+
+ }; + } + }; + + let content = match msg { + ClientEvent::Disconnected => html! { +
+

{"You were disconnected"}

+
+ }, + ClientEvent::Waiting => { + html! { +
+

{"Waiting"}

+
+ } + } + ClientEvent::ShowRole(role_title) => { + let send = self.send.clone(); + let on_click = + Callback::from( + move |_| { + while send.clone().try_send(ClientMessage::RoleAck).is_err() {} + }, + ); + html! { +
+

{format!("Your role: {role_title}")}

+ +
+ } + } + ClientEvent::NotInLobby(players) => { + let send = self.send.clone(); + let on_click = + Callback::from( + move |_| { + while send.clone().try_send(ClientMessage::Hello).is_err() {} + }, + ); + html! { +
+

{format!("Players in lobby: {}", players.len())}

+
    + {players.iter().map(|p| html!{

    {p.to_string()}

    }).collect::()} +
+ +
+ } + } + ClientEvent::InLobby(players) => { + let send = self.send.clone(); + let on_click = + Callback::from( + move |_| { + while send.clone().try_send(ClientMessage::Goodbye).is_err() {} + }, + ); + html! { +
+

{format!("Players in lobby: {}", players.len())}

+
    + {players.iter().map(|p| html!{

    {p.to_string()}

    }).collect::()} +
+ +
+ } + } + ClientEvent::GameInProgress => html! { +
+

{"game in progress"}

+
+ }, + ClientEvent::GameOver(result) => html! { +
+

{"game over"}

+

{ + match result { + GameOver::VillageWins => "village wins", + GameOver::WolvesWin => "wolves win", + }}

+
+ }, + ClientEvent::Notification(notification) => { + let scope = ctx.link().clone(); + let next_event = + // Callback::from(move |_| scope.clone().send_message(Message::)); + Callback::from(move |_| log::info!("nothing")); + html! { + + } + } + }; + + let player = self + .player + .as_ref() + .map(|player| { + let pronouns = if let Some(pronouns) = player.public.pronouns.as_ref() { + html! { +

{"("}{pronouns.as_str()}{")"}

+ } + } else { + html!() + }; + html! { + +

{player.public.number.get()}

+ {player.public.name.clone()} + {pronouns} +
+ } + }) + .unwrap_or(html!()); + + html! { + + {player} + {content} + + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + log::info!("update: {msg:?}"); + match msg { + Message::ForceIdentity(id) => { + self.player.replace(id); + true + } + Message::SetErrorCallback(cb) => { + self.error_callback = cb; + false + } + Message::SetPublicIdentity(public) => { + match self.player.as_mut() { + Some(p) => p.public = public, + None => { + let res = + StorageKey::PlayerId + .get_or_set(PlayerId::new) + .and_then(|player_id| { + StorageKey::PublicIdentity + .set(public.clone()) + .map(|_| Identification { player_id, public }) + }); + match res { + Ok(ident) => { + self.player = Some(ident.clone()); + if let Some(recv) = self.recv.take() { + yew::platform::spawn_local( + Connection { + scope: ctx.link().clone(), + ident, + recv, + } + .run(), + ); + } + } + Err(err) => { + self.error(err.into()); + return false; + } + } + } + } + true + } + Message::RecvServerMessage(msg) => { + if let ServerMessage::LobbyInfo { + joined: false, + players: _, + } = &msg + { + if self.auto_join { + let mut send = self.send.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.send(ClientMessage::Hello).await { + log::error!("send: {err}"); + } + }); + self.auto_join = false; + return false; + } + } + let msg = match msg.try_into() { + Ok(event) => { + self.current_event.replace(event); + return true; + } + Err(msg) => msg, + }; + match msg { + ServerMessage::GameStart { role } => { + self.current_event.replace(ClientEvent::ShowRole(role)); + } + ServerMessage::InvalidMessageForGameState => self.error( + WerewolfError::GameError(GameError::InvalidMessageForGameState), + ), + ServerMessage::NoSuchTarget => { + self.error(WerewolfError::InvalidTarget); + } + ServerMessage::GameInProgress + | ServerMessage::LobbyInfo { + joined: _, + players: _, + } + | ServerMessage::Disconnect => return false, + ServerMessage::GameOver(game_over) => { + self.current_event = Some(ClientEvent::GameOver(game_over)); + } + ServerMessage::Reset => { + let mut send = self.send.clone(); + self.current_event = Some(ClientEvent::Disconnected); + yew::platform::spawn_local(async move { + if let Err(err) = send.send(ClientMessage::GetState).await { + log::error!("send: {err}"); + } + }); + } + ServerMessage::Sleep => self.current_event = Some(ClientEvent::Waiting), + ServerMessage::Update(update) => match (update, self.player.as_mut()) { + (PlayerUpdate::Number(num), Some(player)) => { + player.public.number = num; + return true; + } + (_, None) => return false, + }, + }; + true + } + Message::Connect => { + if let Some(player) = self.player.as_ref() { + if let Some(recv) = self.recv.take() { + yew::platform::spawn_local( + Connection { + scope: ctx.link().clone(), + ident: player.clone(), + recv, + } + .run(), + ); + return true; + } + } + while let Err(err) = self.send.try_send(ClientMessage::GetState) { + log::error!("send IsThereALobby: {err}") + } + false + } + } + } +} diff --git a/werewolves/src/pages/error.rs b/werewolves/src/pages/error.rs new file mode 100644 index 0000000..16291d2 --- /dev/null +++ b/werewolves/src/pages/error.rs @@ -0,0 +1,60 @@ +use gloo::storage::errors::StorageError; +use thiserror::Error; +use werewolves_proto::error::GameError; +use yew::prelude::*; + +#[derive(Debug, Clone, PartialEq, Error)] +pub enum WerewolfError { + #[error("{0}")] + GameError(#[from] GameError), + #[error("local storage error: {0}")] + LocalStorageError(String), + #[error("invalid target")] + InvalidTarget, + #[error("send error: {0}")] + SendError(#[from] futures::channel::mpsc::SendError), +} + +impl From for WerewolfError { + fn from(storage_error: StorageError) -> Self { + Self::LocalStorageError(storage_error.to_string()) + } +} + +pub struct ErrorComponent { + error: Option, +} + +impl Component for ErrorComponent { + type Message = Option; + + type Properties = (); + + fn create(_ctx: &Context) -> Self { + Self { error: None } + } + + fn view(&self, ctx: &Context) -> Html { + let scope = ctx.link().clone(); + let clear = Callback::from(move |_| scope.send_message(None)); + match &self.error { + Some(err) => html! { +
+
+

{err.to_string()}

+ +
+
+ }, + None => html!(), + } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + // if msg == self.error { + // return false; + // } + self.error = msg; + true + } +} diff --git a/werewolves/src/pages/host.rs b/werewolves/src/pages/host.rs new file mode 100644 index 0000000..abcc71d --- /dev/null +++ b/werewolves/src/pages/host.rs @@ -0,0 +1,566 @@ +use core::{num::NonZeroU8, ops::Not, time::Duration}; + +use futures::{ + SinkExt, StreamExt, + channel::mpsc::{Receiver, Sender}, +}; +use gloo::net::websocket::{self, futures::WebSocket}; +use serde::Serialize; +use werewolves_proto::{ + error::GameError, + game::{GameOver, GameSettings}, + message::{ + CharacterState, Identification, PlayerState, PublicIdentity, Target, + host::{ + HostDayMessage, HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage, + ServerToHostMessage, + }, + night::{ActionPrompt, ActionResult}, + }, + player::{CharacterId, PlayerId}, +}; +use yew::{html::Scope, prelude::*}; + +use crate::{ + callback, + components::{ + Button, CoverOfDarkness, Lobby, LobbyPlayerAction, RoleReveal, Settings, + action::{ActionResultView, Prompt}, + host::DaytimePlayerList, + }, +}; + +use super::WerewolfError; + +const DEBUG_URL: &str = "ws://192.168.1.162:8080/connect/host"; +const LIVE_URL: &str = "wss://wolf.emilis.dev/connect/host"; + +async fn connect_ws() -> WebSocket { + let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL); + loop { + match WebSocket::open(url) { + Ok(ws) => break ws, + Err(err) => { + log::error!("connect: {err}"); + yew::platform::time::sleep(Duration::from_secs(1)).await; + } + } + } +} + +fn encode_message(msg: &impl Serialize) -> websocket::Message { + #[cfg(feature = "json")] + { + websocket::Message::Text(serde_json::to_string(msg).expect("message serialization")) + } + #[cfg(feature = "cbor")] + { + websocket::Message::Bytes({ + let mut v = Vec::new(); + ciborium::into_writer(msg, &mut v).expect("serializing message"); + v + }) + } +} + +async fn worker(mut recv: Receiver, scope: Scope) { + let url = option_env!("DEBUG").map(|_| DEBUG_URL).unwrap_or(LIVE_URL); + 'outer: loop { + log::info!("connecting to {url}"); + let mut ws = connect_ws().await.fuse(); + log::info!("connected to {url}"); + + if let Err(err) = ws.send(encode_message(&HostMessage::GetState)).await { + log::error!("sending request for player list: {err}"); + continue 'outer; + } + + let mut last_msg = chrono::Local::now(); + loop { + let msg = futures::select! { + r = ws.next() => { + match r { + Some(Ok(msg)) => { + last_msg = chrono::Local::now(); + msg + }, + Some(Err(err)) => { + log::error!("websocket recv: {err}"); + continue 'outer; + }, + None => { + log::warn!("websocket closed"); + continue 'outer; + }, + } + } + r = recv.next() => { + match r { + Some(msg) => { + log::info!("sending message: {msg:?}"); + if let Err(err) = ws.send( + encode_message(&msg) + ).await { + log::error!("websocket send error: {err}"); + continue 'outer; + } + continue; + }, + None => { + log::info!("recv channel closed"); + return; + }, + } + }, + }; + + let parse = { + #[cfg(feature = "json")] + { + match msg { + websocket::Message::Text(text) => { + serde_json::from_str::(&text) + } + websocket::Message::Bytes(items) => serde_json::from_slice(&items), + } + } + #[cfg(feature = "cbor")] + { + match msg { + websocket::Message::Text(_) => { + log::error!("text messages not supported in cbor mode; discarding"); + continue; + } + websocket::Message::Bytes(bytes) => { + ciborium::from_reader::(bytes.as_slice()) + } + } + } + }; + let took = chrono::Local::now() - last_msg; + if took.num_milliseconds() >= 100 { + log::warn!("took {took}") + } + match parse { + Ok(msg) => scope.send_message::(msg.into()), + Err(err) => { + log::error!("parsing server message: {err}; ignoring.") + } + } + } + } +} + +pub enum HostEvent { + SetErrorCallback(Callback>), + SetBigScreenState(bool), + SetState(HostState), + PlayerList(Box<[PlayerState]>), + Settings(GameSettings), + Error(GameError), +} +#[derive(Debug, Clone)] +pub enum HostState { + Disconnected, + Lobby { + players: Box<[PlayerState]>, + settings: GameSettings, + }, + Day { + characters: Box<[CharacterState]>, + marked_for_execution: Box<[CharacterId]>, + day: NonZeroU8, + }, + GameOver { + result: GameOver, + }, + RoleReveal { + ackd: Box<[Target]>, + waiting: Box<[Target]>, + }, + Prompt(PublicIdentity, ActionPrompt), + Result(PublicIdentity, ActionResult), + CoverOfDarkness, +} + +impl From for HostEvent { + fn from(msg: ServerToHostMessage) -> Self { + match msg { + ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected), + ServerToHostMessage::Daytime { + characters, + day, + marked: marked_for_execution, + } => HostEvent::SetState(HostState::Day { + characters, + day, + marked_for_execution, + }), + ServerToHostMessage::Lobby(players) => HostEvent::PlayerList(players), + ServerToHostMessage::GameSettings(settings) => HostEvent::Settings(settings), + ServerToHostMessage::Error(err) => HostEvent::Error(err), + ServerToHostMessage::GameOver(game_over) => { + HostEvent::SetState(HostState::GameOver { result: game_over }) + } + ServerToHostMessage::ActionPrompt(ident, prompt) => { + HostEvent::SetState(HostState::Prompt(ident, prompt)) + } + ServerToHostMessage::ActionResult(ident, result) => { + HostEvent::SetState(HostState::Result(ident, result)) + } + ServerToHostMessage::WaitingForRoleRevealAcks { ackd, waiting } => { + HostEvent::SetState(HostState::RoleReveal { ackd, waiting }) + } + ServerToHostMessage::CoverOfDarkness => HostEvent::SetState(HostState::CoverOfDarkness), + } + } +} + +pub struct Host { + send: Sender, + state: HostState, + error_callback: Callback>, + big_screen: bool, + debug: bool, +} + +impl Component for Host { + type Message = HostEvent; + + type Properties = (); + + fn create(ctx: &Context) -> Self { + gloo::utils::document().set_title("Werewolves Host"); + if let Some(clients) = gloo::utils::document() + .query_selector("clients") + .ok() + .flatten() + { + clients.remove(); + } + let (send, recv) = futures::channel::mpsc::channel(100); + let scope = ctx.link().clone(); + yew::platform::spawn_local(async move { worker(recv, scope).await }); + Self { + send, + state: HostState::Lobby { + players: Box::new([]), + settings: GameSettings::default(), + }, + debug: option_env!("DEBUG").is_some(), + big_screen: false, + error_callback: Callback::from(|err| { + if let Some(err) = err { + log::error!("{err}") + } + }), + } + } + + fn view(&self, _ctx: &Context) -> Html { + log::info!("state: {:?}", self.state); + let content = match self.state.clone() { + HostState::GameOver { result } => { + let send = self.send.clone(); + let new_lobby = Callback::from(move |_| { + let send = send.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.clone().send(HostMessage::NewLobby).await { + log::error!("send new lobby: {err}"); + } + }); + }); + html! { +
+

{format!("game over: {result:?}")}

+
+ +
+
+ } + } + HostState::Disconnected => html! { +
+

{"disconnected"}

+
+ }, + HostState::Lobby { players, settings } => { + let on_error = self.error_callback.clone(); + + let settings = self.big_screen.not().then(|| { + let send = self.send.clone(); + let on_changed = Callback::from(move |s| { + let send = send.clone(); + yew::platform::spawn_local(async move { + let mut send = send.clone(); + if let Err(err) = send + .send(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(s))) + .await + { + log::error!("sending game settings update: {err}"); + } + if let Err(err) = send + .send(HostMessage::Lobby(HostLobbyMessage::GetGameSettings)) + .await + { + log::error!("sending game settings get: {err}"); + } + }); + }); + let send = self.send.clone(); + let on_start = Callback::from(move |_| { + let send = send.clone(); + let on_error = on_error.clone(); + yew::platform::spawn_local(async move { + let mut send = send.clone(); + if let Err(err) = + send.send(HostMessage::Lobby(HostLobbyMessage::Start)).await + { + on_error.emit(Some(err.into())) + } + }); + }); + html! { + + } + }); + let on_action = self.big_screen.not().then(|| { + let on_error = self.error_callback.clone(); + let send = self.send.clone(); + Callback::from(move |(player_id, act): (PlayerId, LobbyPlayerAction)| { + let msg = match act { + LobbyPlayerAction::Kick => { + HostMessage::Lobby(HostLobbyMessage::Kick(player_id)) + } + }; + let mut send = send.clone(); + let on_error = on_error.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.send(msg).await { + on_error.emit(Some(err.into())) + } + }); + }) + }); + html! { +
+ {settings} + +
+ } + } + HostState::Day { + characters, + day, + marked_for_execution, + } => { + let on_mark = crate::callback::send_fn( + |target| { + HostMessage::InGame(HostGameMessage::Day(HostDayMessage::MarkForExecution( + target, + ))) + }, + self.send.clone(), + ); + let on_execute = crate::callback::send_message( + HostMessage::InGame(HostGameMessage::Day(HostDayMessage::Execute)), + self.send.clone(), + ); + html! { + <> +

{format!("Day {}", day.get())}

+ >() + } + on_execute={on_execute} + on_mark={on_mark} + /> + + } + } + HostState::RoleReveal { ackd, waiting } => { + let send = self.send.clone(); + let on_force_ready = Callback::from(move |target: Target| { + let send = send.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send + .clone() + .send(HostMessage::ForceRoleAckFor(target.character_id.clone())) + .await + { + log::error!("force role ack for [{target}]: {err}"); + } + }); + }); + html! { + + } + } + HostState::Prompt(ident, prompt) => { + let send = self.send.clone(); + let on_complete = Callback::from(move |msg| { + let mut send = send.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.send(msg).await { + log::error!("sending prompt response: {err}") + } + }); + }); + + html! { + + } + } + HostState::Result(ident, result) => { + let send = self.send.clone(); + let on_complete = Callback::from(move |msg| { + let mut send = send.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send.send(msg).await { + log::error!("sending action result response: {err}") + } + }); + }); + + html! { + + } + } + HostState::CoverOfDarkness => { + let next = self.big_screen.not().then(|| { + let send = self.send.clone(); + Callback::from(move |_| { + let mut send = send.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send + .send(HostMessage::InGame(HostGameMessage::Night( + HostNightMessage::Next, + ))) + .await + { + log::error!("sending action result response: {err}") + } + }); + }) + }); + return html! { + + }; + } + }; + let debug_nav = self.debug.then(|| { + let on_error_click = callback::send_message( + HostMessage::Echo(ServerToHostMessage::Error(GameError::NoApprenticeMentor)), + self.send.clone(), + ); + html! { + + } + }); + html! { + <> + {debug_nav} +
+ {content} +
+ + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + HostEvent::PlayerList(players) => { + match &mut self.state { + HostState::Lobby { + players: p, + settings: _, + } => *p = players, + HostState::CoverOfDarkness + | HostState::Prompt(_, _) + | HostState::Result(_, _) + | HostState::Disconnected + | HostState::RoleReveal { + ackd: _, + waiting: _, + } + | HostState::GameOver { result: _ } + | HostState::Day { + characters: _, + day: _, + marked_for_execution: _, + } => { + return false; + } + } + true + } + HostEvent::SetErrorCallback(callback) => { + self.error_callback = callback; + false + } + HostEvent::SetState(state) => { + self.state = state; + true + } + HostEvent::Settings(settings) => match &mut self.state { + HostState::Lobby { + players: _, + settings: s, + } => { + *s = settings; + true + } + HostState::CoverOfDarkness + | HostState::Prompt(_, _) + | HostState::Result(_, _) + | HostState::Disconnected + | HostState::RoleReveal { + ackd: _, + waiting: _, + } + | HostState::GameOver { result: _ } + | HostState::Day { + characters: _, + day: _, + marked_for_execution: _, + } => { + log::info!("ignoring settings get"); + false + } + }, + HostEvent::Error(err) => { + self.error_callback + .emit(Some(WerewolfError::GameError(err))); + false + } + HostEvent::SetBigScreenState(state) => { + self.big_screen = state; + self.debug = false; + self.error_callback = Callback::noop(); + false + } + } + } +} diff --git a/werewolves/src/storage.rs b/werewolves/src/storage.rs new file mode 100644 index 0000000..7631205 --- /dev/null +++ b/werewolves/src/storage.rs @@ -0,0 +1,43 @@ +use gloo::storage::{LocalStorage, Storage, errors::StorageError}; +use serde::{Deserialize, Serialize}; + +pub enum StorageKey { + PlayerId, + PublicIdentity, +} + +impl StorageKey { + const fn key(&self) -> &'static str { + match self { + StorageKey::PlayerId => "player_id", + StorageKey::PublicIdentity => "player_public", + } + } + pub fn get(&self) -> Result + where + T: for<'de> Deserialize<'de>, + { + LocalStorage::get(self.key()) + } + + pub fn set(&self, value: T) -> Result<(), StorageError> + where + T: Serialize, + { + LocalStorage::set(self.key(), value) + } + + pub fn get_or_set(&self, value_fn: impl FnOnce() -> T) -> Result + where + T: Serialize + for<'de> Deserialize<'de>, + { + match LocalStorage::get(self.key()) { + Ok(v) => Ok(v), + Err(StorageError::KeyNotFound(_)) => { + LocalStorage::set(self.key(), (value_fn)())?; + self.get() + } + Err(err) => Err(err), + } + } +}