fix: client hooks, wip story rework

This commit is contained in:
emilis 2026-01-06 00:50:51 +00:00
parent 01c61c143e
commit 7fc90eba74
No known key found for this signature in database
23 changed files with 2863 additions and 1095 deletions

362
Cargo.lock generated
View File

@ -26,12 +26,6 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "anymap2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -160,12 +154,6 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@ -255,18 +243,18 @@ dependencies = [
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.8.0" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.9.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190" checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@ -507,74 +495,23 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[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]] [[package]]
name = "gloo" name = "gloo"
version = "0.11.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372" checksum = "d15282ece24eaf4bd338d73ef580c6714c8615155c4190c781290ee3fa0fd372"
dependencies = [ dependencies = [
"gloo-console 0.3.0", "gloo-console",
"gloo-dialogs 0.2.0", "gloo-dialogs",
"gloo-events 0.2.0", "gloo-events",
"gloo-file 0.3.0", "gloo-file",
"gloo-history 0.2.2", "gloo-history",
"gloo-net 0.5.0", "gloo-net",
"gloo-render 0.2.0", "gloo-render",
"gloo-storage 0.3.0", "gloo-storage",
"gloo-timers 0.3.0", "gloo-timers",
"gloo-utils 0.2.0", "gloo-utils",
"gloo-worker 0.5.0", "gloo-worker",
]
[[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]] [[package]]
@ -583,23 +520,13 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261"
dependencies = [ dependencies = [
"gloo-utils 0.2.0", "gloo-utils",
"js-sys", "js-sys",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "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]] [[package]]
name = "gloo-dialogs" name = "gloo-dialogs"
version = "0.2.0" version = "0.2.0"
@ -610,16 +537,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-events" name = "gloo-events"
version = "0.2.0" version = "0.2.0"
@ -630,18 +547,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-file" name = "gloo-file"
version = "0.3.0" version = "0.3.0"
@ -649,28 +554,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"gloo-events 0.2.0", "gloo-events",
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "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]] [[package]]
name = "gloo-history" name = "gloo-history"
version = "0.2.2" version = "0.2.2"
@ -678,58 +567,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6"
dependencies = [ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.16",
"gloo-events 0.2.0", "gloo-events",
"gloo-utils 0.2.0", "gloo-utils",
"serde", "serde",
"serde-wasm-bindgen 0.6.5", "serde-wasm-bindgen",
"serde_urlencoded", "serde_urlencoded",
"thiserror 1.0.69", "thiserror 1.0.69",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "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]] [[package]]
name = "gloo-net" name = "gloo-net"
version = "0.5.0" version = "0.5.0"
@ -739,7 +586,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"gloo-utils 0.2.0", "gloo-utils",
"http 0.2.12", "http 0.2.12",
"js-sys", "js-sys",
"pin-project", "pin-project",
@ -751,16 +598,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-render" name = "gloo-render"
version = "0.2.0" version = "0.2.0"
@ -771,28 +608,13 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-storage" name = "gloo-storage"
version = "0.3.0" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a"
dependencies = [ dependencies = [
"gloo-utils 0.2.0", "gloo-utils",
"js-sys", "js-sys",
"serde", "serde",
"serde_json", "serde_json",
@ -801,16 +623,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@ -823,19 +635,6 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "gloo-utils" name = "gloo-utils"
version = "0.2.0" version = "0.2.0"
@ -849,42 +648,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "gloo-worker" name = "gloo-worker"
version = "0.5.0" version = "0.5.0"
@ -893,7 +656,7 @@ checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d"
dependencies = [ dependencies = [
"bincode", "bincode",
"futures", "futures",
"gloo-utils 0.2.0", "gloo-utils",
"gloo-worker-macros", "gloo-worker-macros",
"js-sys", "js-sys",
"pinned", "pinned",
@ -1190,9 +953,9 @@ dependencies = [
[[package]] [[package]]
name = "implicit-clone" name = "implicit-clone"
version = "0.4.9" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" checksum = "1689b939ee35e3a075b0834b5672efd43aec8a6e81a1c6002b76a5ca2f211ae0"
dependencies = [ dependencies = [
"implicit-clone-derive", "implicit-clone-derive",
"indexmap", "indexmap",
@ -1511,23 +1274,6 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.42" version = "1.0.42"
@ -1656,17 +1402,6 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde-wasm-bindgen" name = "serde-wasm-bindgen"
version = "0.6.5" version = "0.6.5"
@ -1936,6 +1671,23 @@ dependencies = [
"tungstenite", "tungstenite",
] ]
[[package]]
name = "tokise"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf97738ce15b9e9cc1671ea29b0f6c56538719e1a092d19cc2134bf144e40e"
dependencies = [
"futures",
"gloo",
"num_cpus",
"once_cell",
"pin-project",
"pinned",
"tokio",
"tokio-stream",
"wasm-bindgen-futures",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.11" version = "0.6.11"
@ -2209,10 +1961,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"ciborium", "ciborium",
"convert_case 0.8.0", "convert_case 0.10.0",
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"gloo 0.11.0", "gloo",
"instant", "instant",
"log", "log",
"once_cell", "once_cell",
@ -2537,22 +2289,22 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "yew" name = "yew"
version = "0.21.0" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" checksum = "3346273ed61b636f5d84e6c696d40f380045b5565b36c5c47f8fc634b8bf5be6"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures", "futures",
"gloo 0.10.0", "gloo",
"implicit-clone", "implicit-clone",
"indexmap", "indexmap",
"js-sys", "js-sys",
"prokio",
"rustversion", "rustversion",
"serde", "serde",
"slab", "slab",
"thiserror 1.0.69", "thiserror 2.0.17",
"tokio", "tokio",
"tokise",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@ -2562,26 +2314,26 @@ dependencies = [
[[package]] [[package]]
name = "yew-macro" name = "yew-macro"
version = "0.21.0" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" checksum = "479e94d645dde3749e81d488c1d32987509dd3b8c31650fcf6e3af1f370e913b"
dependencies = [ dependencies = [
"boolinator",
"once_cell", "once_cell",
"prettyplease", "prettyplease",
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion",
"syn 2.0.111", "syn 2.0.111",
] ]
[[package]] [[package]]
name = "yew-router" name = "yew-router"
version = "0.18.0" version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" checksum = "415cb628900ddf1eaf55ebd04163adf1ea80d3f5a9832a876554f9c0fdd4c282"
dependencies = [ dependencies = [
"gloo 0.10.0", "gloo",
"js-sys", "js-sys",
"route-recognizer", "route-recognizer",
"serde", "serde",
@ -2596,9 +2348,9 @@ dependencies = [
[[package]] [[package]]
name = "yew-router-macro" name = "yew-router-macro"
version = "0.18.0" version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" checksum = "9e87a3ce33434ab66a700edbaf2cc8a417d9b89f00a6fd8216fd6ac83b0e7b1c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@ -19,6 +19,7 @@ pub mod story;
mod village; mod village;
use core::{ use core::{
cmp::Ordering,
fmt::{Debug, Display}, fmt::{Debug, Display},
num::NonZeroU8, num::NonZeroU8,
ops::{Deref, Range, RangeBounds}, ops::{Deref, Range, RangeBounds},
@ -379,6 +380,35 @@ pub enum GameTime {
Night { number: u8 }, Night { number: u8 },
} }
impl PartialOrd for GameTime {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for GameTime {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(GameTime::Day { number: l }, GameTime::Day { number: r }) => l.cmp(r),
(GameTime::Day { number: l }, GameTime::Night { number: r }) => {
if *r >= l.get() {
Ordering::Less
} else {
Ordering::Greater
}
}
(GameTime::Night { number: l }, GameTime::Day { number: r }) => {
if *l > r.get() {
Ordering::Greater
} else {
Ordering::Less
}
}
(GameTime::Night { number: l }, GameTime::Night { number: r }) => l.cmp(r),
}
}
}
impl Display for GameTime { impl Display for GameTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {

View File

@ -209,12 +209,17 @@ pub enum StoryActionPrompt {
character_id: CharacterId, character_id: CharacterId,
chosen: CharacterId, chosen: CharacterId,
}, },
BeholderWakes {
character_id: CharacterId,
},
} }
impl StoryActionPrompt { impl StoryActionPrompt {
pub fn new(prompt: ActionPrompt) -> Option<Self> { pub fn new(prompt: ActionPrompt) -> Option<Self> {
Some(match prompt { Some(match prompt {
ActionPrompt::BeholderWakes { .. } => return None, // TODO: rework story anyway ActionPrompt::BeholderWakes { character_id } => Self::BeholderWakes {
character_id: character_id.character_id,
},
ActionPrompt::Bloodletter { ActionPrompt::Bloodletter {
character_id, character_id,
marked: Some(marked), marked: Some(marked),
@ -425,6 +430,35 @@ impl StoryActionPrompt {
| ActionPrompt::CoverOfDarkness => return None, | ActionPrompt::CoverOfDarkness => return None,
}) })
} }
pub const fn character_id(&self) -> Option<CharacterId> {
match self {
StoryActionPrompt::MasonsWake { .. } | StoryActionPrompt::WolfPackKill { .. } => None,
StoryActionPrompt::Seer { character_id, .. }
| StoryActionPrompt::Protector { character_id, .. }
| StoryActionPrompt::Arcanist { character_id, .. }
| StoryActionPrompt::Gravedigger { character_id, .. }
| StoryActionPrompt::Hunter { character_id, .. }
| StoryActionPrompt::Militia { character_id, .. }
| StoryActionPrompt::MapleWolf { character_id, .. }
| StoryActionPrompt::Guardian { character_id, .. }
| StoryActionPrompt::Adjudicator { character_id, .. }
| StoryActionPrompt::PowerSeer { character_id, .. }
| StoryActionPrompt::Mortician { character_id, .. }
| StoryActionPrompt::Beholder { character_id, .. }
| StoryActionPrompt::MasonLeaderRecruit { character_id, .. }
| StoryActionPrompt::Empath { character_id, .. }
| StoryActionPrompt::Vindicator { character_id, .. }
| StoryActionPrompt::PyreMaster { character_id, .. }
| StoryActionPrompt::Shapeshifter { character_id, .. }
| StoryActionPrompt::AlphaWolf { character_id, .. }
| StoryActionPrompt::DireWolf { character_id, .. }
| StoryActionPrompt::LoneWolfKill { character_id, .. }
| StoryActionPrompt::Insomniac { character_id, .. }
| StoryActionPrompt::Bloodletter { character_id, .. }
| StoryActionPrompt::BeholderWakes { character_id, .. } => Some(*character_id),
}
}
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@ -13,14 +13,16 @@ web-sys = { version = "0.3", features = [
"HtmlImageElement", "HtmlImageElement",
"HtmlDivElement", "HtmlDivElement",
"HtmlSelectElement", "HtmlSelectElement",
"HtmlDialogElement",
"DomRect",
] } ] }
wasm-bindgen = { version = "=0.2.100" } wasm-bindgen = { version = "=0.2.100" }
log = "0.4" log = "0.4"
rand = { version = "0.9", features = ["small_rng"] } rand = { version = "0.9", features = ["small_rng"] }
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }
uuid = { version = "*", features = ["js"] } uuid = { version = "*", features = ["js"] }
yew = { version = "0.21", features = ["csr"] } yew = { version = "0.22", features = ["csr"] }
yew-router = "0.18" yew-router = "0.19"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", optional = true } serde_json = { version = "1.0", optional = true }
gloo = "0.11" gloo = "0.11"
@ -33,7 +35,7 @@ werewolves-proto = { path = "../werewolves-proto" }
futures = "0.3" futures = "0.3"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
thiserror = { version = "2" } thiserror = { version = "2" }
convert_case = { version = "0.8" } convert_case = { version = "0.10" }
ciborium = { version = "0.2", optional = true } ciborium = { version = "0.2", optional = true }
[features] [features]

View File

@ -208,8 +208,8 @@ nav.host-nav {
block-size: max-content; block-size: max-content;
&>button { &>button {
width: 100%; width: 160px;
height: 100%; height: 75px;
border: 1px solid $disconnected_color; border: 1px solid $disconnected_color;
background-color: color.change($disconnected_color, $alpha: 0.15); background-color: color.change($disconnected_color, $alpha: 0.15);
color: $disconnected_color; color: $disconnected_color;
@ -977,17 +977,30 @@ error {
} }
input { // input {
background-color: rgba(255, 255, 255, 0.1); // background-color: rgba(255, 255, 255, 0.1);
// color: white;
// border: 2px solid rgba(255, 255, 255, 0.2);
// margin: 10px;
// }
input,
select {
border: 1px solid rgba(255, 255, 255, 0.7);
background-color: rgba(255, 255, 255, 0.07);
color: white; color: white;
border: 2px solid rgba(255, 255, 255, 0.2); font-size: 1em;
margin: 10px;
&:focus {
outline: 1px solid white;
background-color: white;
color: black;
}
} }
.info-update { .info-update {
border: 1px solid rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.5);
padding: 30px 0px 30px 0px; padding: 30px 0px 30px 0px;
font-size: 2rem; // font-size: 2rem;
align-content: stretch; align-content: stretch;
margin: 0; margin: 0;
position: fixed; position: fixed;
@ -1799,25 +1812,52 @@ li.choice {
} }
.signin { .signin {
@extend .row-list; display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: center; justify-content: center;
text-align: center;
& label {
font-size: 1.5rem;
}
&.full-height { &.full-height {
height: 100vh; height: 100vh;
max-width: 100vw;
}
.signin-box {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
gap: 3px;
// justify-content: center;
// text-align: center;
.field {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
width: 100%;
}
& label {
font-size: 1.25em;
} }
& input { & input {
height: 2rem; height: 2em;
text-align: center; // max-width: 80%;
width: 70%;
#number { &#number {
font-size: 2rem; text-align: center;
max-width: 50vw; // font-size: 2rem;
// width: 20%;
width: 3ch;
}
}
&>button {
margin-top: 7px;
} }
} }
} }
@ -1832,67 +1872,67 @@ li.choice {
} }
.story { // .story {
.cast { // .cast {
display: flex; // display: flex;
flex-direction: row; // flex-direction: row;
flex-wrap: wrap; // flex-wrap: wrap;
gap: 10px; // gap: 10px;
justify-content: center; // justify-content: center;
} // }
.time-period { // .time-period {
user-select: text; // user-select: text;
.day { // .day {
display: flex; // display: flex;
flex-direction: column; // flex-direction: column;
flex-wrap: wrap; // flex-wrap: wrap;
align-items: center; // align-items: center;
.executed { // .executed {
display: flex; // display: flex;
flex-direction: row; // flex-direction: row;
flex-wrap: wrap; // flex-wrap: wrap;
gap: 10px; // gap: 10px;
} // }
} // }
.night { // .night {
&>label { // &>label {
margin-left: 10vw; // margin-left: 10vw;
font-size: 2rem; // font-size: 2rem;
font-weight: lighter; // font-weight: lighter;
} // }
ul.changes, // ul.changes,
ul.choices { // ul.choices {
display: flex; // display: flex;
flex-direction: column; // flex-direction: column;
flex-wrap: nowrap; // flex-wrap: nowrap;
gap: 10px; // gap: 10px;
&>li { // &>li {
display: flex; // display: flex;
flex-direction: row; // flex-direction: row;
flex-wrap: wrap; // flex-wrap: wrap;
align-items: center; // align-items: center;
gap: 10px; // gap: 10px;
} // }
& span { // & span {
display: flex; // display: flex;
flex-direction: row; // flex-direction: row;
flex-wrap: wrap; // flex-wrap: wrap;
align-items: center; // align-items: center;
gap: 10px; // gap: 10px;
} // }
} // }
} // }
} // }
} // }
.attribute-span { .attribute-span {
display: flex; display: flex;
@ -2209,13 +2249,6 @@ li.choice {
gap: 10px; gap: 10px;
} }
.add-player {
background-color: black;
border: 1px solid white;
padding: 20px;
margin: 0px;
}
.joined { .joined {
$joined_color: rgba(0, 255, 0, 0.7); $joined_color: rgba(0, 255, 0, 0.7);
$joined_border: color.change($joined_color, $alpha: 1); $joined_border: color.change($joined_color, $alpha: 1);
@ -2370,6 +2403,7 @@ li.choice {
left: 0; left: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: white;
.dialog-box { .dialog-box {
border: 1px solid white; border: 1px solid white;
@ -2395,6 +2429,22 @@ li.choice {
} }
} }
} }
.close-dialog {
align-self: flex-end;
width: 100%;
margin: 0;
border: 1px solid $wolves_border;
color: $wolves_border;
&:hover {
background-color: $wolves_border_faint;
}
}
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.7);
} }
.about { .about {
@ -2648,3 +2698,110 @@ li.choice {
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 5px; gap: 5px;
} }
.story {
display: flex;
flex-direction: row;
flex-wrap: wrap;
// width: 100vw;
justify-content: space-evenly;
row-gap: 5px;
margin: 5vh 10vw 0px 10vw;
.character-headline {
display: flex;
flex-direction: row;
gap: 3px;
align-items: center;
.icon-spacer {
height: 32px;
width: 32px;
}
padding: 0.2em 1em 0.2em 1em;
min-width: 5cm;
.identity {
text-align: center;
flex-grow: 1;
}
}
.character-details {
display: none;
&.shown {
display: flex;
}
border-top: none;
flex-direction: column;
flex-wrap: nowrap;
gap: 3px;
padding: 2px 3px 2px 3px;
}
.story-time {
width: 100%;
.time {
width: 100%;
font-size: 1.5em;
font-weight: bold;
padding: 3px 0px 3px 0px;
display: block;
&:hover {
backdrop-filter: brightness(150%);
}
}
.details {
display: none;
&.shown {
display: flex;
}
flex-direction: column;
flex-wrap: nowrap;
}
}
}
dialog {
background-color: transparent;
border: none;
}
.object-submenu {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
.object {
width: 100%;
background-color: black;
}
menu {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 3px;
align-items: center;
width: 100%;
margin: 0;
padding: 0;
&>button,
&>div,
&>div>button {
width: 100%;
}
}
}

View File

@ -29,8 +29,9 @@ use yew::prelude::*;
use crate::{ use crate::{
clients::client::connection::{Connection2, ConnectionError}, clients::client::connection::{Connection2, ConnectionError},
components::{ components::{
Button, CoverOfDarkness, Footer, Identity, Story, Button, CoverOfDarkness, Footer, Identity,
client::{ClientNav, Signin}, client::{ClientNav, Signin},
story::Story,
}, },
storage::StorageKey, storage::StorageKey,
}; };
@ -39,6 +40,7 @@ use crate::WerewolfError;
#[derive(PartialEq, Debug, Clone)] #[derive(PartialEq, Debug, Clone)]
pub enum ClientEvent2 { pub enum ClientEvent2 {
Signin,
Disconnected, Disconnected,
Connecting, Connecting,
ShowRole(RoleTitle), ShowRole(RoleTitle),
@ -54,7 +56,6 @@ pub enum ClientEvent2 {
#[derive(Default, Clone, PartialEq)] #[derive(Default, Clone, PartialEq)]
pub struct ClientContext { pub struct ClientContext {
pub error_cb: Callback<Option<WerewolfError>>, pub error_cb: Callback<Option<WerewolfError>>,
pub forced_identity: Option<Identification>,
} }
static LOST_FOCUS: AtomicI64 = AtomicI64::new(0); static LOST_FOCUS: AtomicI64 = AtomicI64::new(0);
@ -73,6 +74,8 @@ pub(super) fn time_spent_unfocused() -> Option<TimeDelta> {
#[function_component] #[function_component]
pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
let ident_state = use_state(|| Option::<(PlayerId, PublicIdentity)>::None);
if gloo::utils::window().onfocus().is_none() { if gloo::utils::window().onfocus().is_none() {
let on_focus = { let on_focus = {
Closure::wrap(Box::new(move || { Closure::wrap(Box::new(move || {
@ -93,45 +96,67 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
} }
let client_state = use_state(|| ClientEvent2::Connecting); let client_state = use_state(|| ClientEvent2::Connecting);
let ClientContext { let ClientContext { error_cb } = use_context::<ClientContext>().unwrap_or_default();
error_cb, // let force = use_force_update();
forced_identity, let (send, recv) = yew::platform::pinned::mpsc::unbounded();
} = use_context::<ClientContext>().unwrap_or_default(); let send = use_state(|| send);
let force = use_force_update(); let recv = use_mut_ref(|| recv);
let connection =
use_mut_ref(|| Connection2::new(client_state.setter(), ident_state.clone(), recv));
let ident = if let Some(Identification { player_id, public }) = forced_identity { let on_signin = {
(player_id, public) let current_ident = ident_state.setter();
} else { let client_state = client_state.setter();
match PlayerId::load_from_storage() Callback::from(move |ident: PublicIdentity| {
.and_then(|pid| PublicIdentity::load_from_storage().map(|ident| (pid, ident))) let pid = PlayerId::new();
{ pid.save_to_storage().expect("saving player id");
Ok((pid, ident)) => (pid, ident),
Err(StorageError::KeyNotFound(_)) => {
let on_signin = Callback::from(move |ident: PublicIdentity| {
PlayerId::new().save_to_storage().expect("saving player id");
ident.save_to_storage().expect("saving ident"); ident.save_to_storage().expect("saving ident");
force.force_update();
}); current_ident.set(Some((pid, ident)));
client_state.set(ClientEvent2::Connecting);
})
};
if let ClientEvent2::Signin = &*client_state {
return html! { return html! {
<Signin callback={on_signin} /> <Signin callback={on_signin} />
}; };
} }
let ident = if let Some(current_ident) = ident_state.as_ref() {
current_ident.clone()
} else {
match PlayerId::load_from_storage()
.and_then(|pid| PublicIdentity::load_from_storage().map(|ident| (pid, ident)))
{
Ok((pid, ident)) => {
ident_state.set(Some((pid, ident.clone())));
(pid, ident)
}
Err(StorageError::KeyNotFound(_)) => {
client_state.set(ClientEvent2::Signin);
return html! {
// <Signin callback={on_signin}/>
};
}
Err(err) => { Err(err) => {
log::error!("storage error: {err}");
error_cb.emit(Some(err.into())); error_cb.emit(Some(err.into()));
PlayerId::delete(); PlayerId::delete();
PublicIdentity::delete(); PublicIdentity::delete();
force.force_update(); // force.force_update();
return html! {};
// client_state.set(ClientEvent2::Connecting);
client_state.set(ClientEvent2::Signin);
return html! {
// <Signin callback={on_signin} />
};
} }
} }
}; };
let ident = use_state(|| ident);
let (send, recv) = yew::platform::pinned::mpsc::unbounded();
let send = use_state(|| send);
let recv = use_mut_ref(|| recv);
let connection = use_mut_ref(|| Connection2::new(client_state.setter(), ident.clone(), recv));
let content = match &*client_state { let content = match &*client_state {
ClientEvent2::Signin => html! {
<Signin callback={on_signin} />
},
ClientEvent2::GameInProgress => html! { ClientEvent2::GameInProgress => html! {
<CoverOfDarkness message={"game in progress".to_string()}/> <CoverOfDarkness message={"game in progress".to_string()}/>
}, },
@ -245,7 +270,7 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
} }
}; };
html! { html! {
<ClientNav identity={ident.clone()} message_callback={client_nav_msg_cb} /> <ClientNav identity={ident_state.clone()} message_callback={client_nav_msg_cb} />
} }
}; };

View File

@ -45,7 +45,7 @@ fn url() -> String {
#[derive(Clone)] #[derive(Clone)]
pub struct Connection2 { pub struct Connection2 {
state: UseStateSetter<ClientEvent2>, state: UseStateSetter<ClientEvent2>,
ident: UseStateHandle<(PlayerId, PublicIdentity)>, ident: UseStateHandle<Option<(PlayerId, PublicIdentity)>>,
receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>, receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
active: Rc<RefCell<()>>, active: Rc<RefCell<()>>,
} }
@ -53,7 +53,7 @@ pub struct Connection2 {
impl Connection2 { impl Connection2 {
pub fn new( pub fn new(
state: UseStateSetter<ClientEvent2>, state: UseStateSetter<ClientEvent2>,
ident: UseStateHandle<(PlayerId, PublicIdentity)>, ident: UseStateHandle<Option<(PlayerId, PublicIdentity)>>,
receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>, receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
) -> Self { ) -> Self {
Self { Self {
@ -64,9 +64,19 @@ impl Connection2 {
} }
} }
fn identification(&self) -> Identification { fn identification(&self) -> Identification {
Identification { match self.ident.as_ref() {
player_id: self.ident.0, Some(ident) => Identification {
public: self.ident.1.clone(), player_id: ident.0,
public: ident.1.clone(),
},
None => Identification {
player_id: PlayerId::from_u128(0),
public: PublicIdentity {
name: String::new(),
pronouns: None,
number: None,
},
},
} }
} }
async fn connect_ws() -> WebSocket { async fn connect_ws() -> WebSocket {
@ -108,7 +118,10 @@ impl Connection2 {
yew::platform::spawn_local(async move { yew::platform::spawn_local(async move {
let active = conn.active.clone(); let active = conn.active.clone();
conn.active = Rc::new(RefCell::new(())); conn.active = Rc::new(RefCell::new(()));
let active_borrow = active.borrow_mut(); let Ok(active_borrow) = active.try_borrow_mut() else {
log::warn!("active connection already borrowed; exiting");
return;
};
conn.run().await; conn.run().await;
core::mem::drop(active_borrow); core::mem::drop(active_borrow);
}); });
@ -274,9 +287,11 @@ impl Connection2 {
return None; return None;
} }
ServerMessage::Update(PlayerUpdate::Number(new_num)) => { ServerMessage::Update(PlayerUpdate::Number(new_num)) => {
let (pid, mut ident) = (*self.ident).clone(); let Some((pid, mut ident)) = (*self.ident).clone() else {
return None;
};
ident.number = Some(new_num); ident.number = Some(new_num);
self.ident.set((pid, ident)); self.ident.set(Some((pid, ident)));
return None; return None;
} }
ServerMessage::GameInProgress => ClientEvent2::GameInProgress, ServerMessage::GameInProgress => ClientEvent2::GameInProgress,

View File

@ -42,10 +42,11 @@ use yew::{html::Scope, prelude::*};
use crate::{ use crate::{
callback, callback,
components::{ components::{
Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Story, Victory, Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Victory,
action::{ActionResultView, Prompt}, action::{ActionResultView, Prompt},
host::{CharacterStatesReadOnly, DaytimePlayerList, Setup}, host::{CharacterStatesReadOnly, DaytimePlayerList, Setup},
settings::Settings, settings::Settings,
story::Story,
}, },
pages::RolePage, pages::RolePage,
storage::StorageKey, storage::StorageKey,

View File

@ -26,3 +26,11 @@ const BASE_URL: &str = match option_env!("BASE_URL") {
Some(base_url) => base_url, Some(base_url) => base_url,
None => "ws://192.168.1.162:8080", None => "ws://192.168.1.162:8080",
}; };
use yew::prelude::*;
#[function_component]
pub fn StoryTest() -> Html {
html! {
<crate::components::story::Story story={crate::components::story::generate_story()}/>
}
}

View File

@ -27,7 +27,7 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct ClientNavProps { pub struct ClientNavProps {
pub identity: UseStateHandle<(PlayerId, PublicIdentity)>, pub identity: UseStateHandle<Option<(PlayerId, PublicIdentity)>>,
pub message_callback: Callback<ClientMessage>, pub message_callback: Callback<ClientMessage>,
} }
@ -38,18 +38,19 @@ pub fn ClientNav(
message_callback, message_callback,
}: &ClientNavProps, }: &ClientNavProps,
) -> Html { ) -> Html {
const MUST_HAVE_IDENTITY: &str = "client nav must have identity";
let pronouns = identity let pronouns = identity
.1
.pronouns
.as_ref() .as_ref()
.map(|pronouns| { .and_then(|identity| {
identity.1.pronouns.as_ref().map(|pronouns| {
html! { html! {
<div>{"("}{pronouns.as_str()}{")"}</div> <span>{"("}{pronouns.as_str()}{")"}</span>
} }
}) })
})
.unwrap_or_else(|| { .unwrap_or_else(|| {
html! { html! {
<div>{"(None)"}</div> <span class="faint">{"(None)"}</span>
} }
}); });
@ -62,10 +63,13 @@ pub fn ClientNav(
let submit_ident = identity.clone(); let submit_ident = identity.clone();
let current_num = identity let current_num = identity
.1 .as_ref()
.number .and_then(|identity| identity.1.number.map(|v| html! {{v.to_string()}}))
.map(|v| v.to_string()) .unwrap_or_else(|| {
.unwrap_or_else(|| String::from("???")); html! {
<span class="red">{"???"}</span>
}
});
let open_set = number_open.setter(); let open_set = number_open.setter();
let on_submit = { let on_submit = {
let val = current_value.clone(); let val = current_value.clone();
@ -74,13 +78,16 @@ pub fn ClientNav(
Some(num) => num, Some(num) => num,
None => return, None => return,
}; };
let Some(submit_ident_ref) = submit_ident.as_ref() else {
return;
};
message_callback.emit(ClientMessage::UpdateSelf(UpdateSelf::Number(num))); message_callback.emit(ClientMessage::UpdateSelf(UpdateSelf::Number(num)));
let new_ident = PublicIdentity { let new_ident = PublicIdentity {
name: submit_ident.1.name.clone(), name: submit_ident_ref.1.name.clone(),
pronouns: submit_ident.1.pronouns.clone(), pronouns: submit_ident_ref.1.pronouns.clone(),
number: Some(num), number: Some(num),
}; };
submit_ident.set((submit_ident.0, new_ident.clone())); submit_ident.set(Some((submit_ident_ref.0, new_ident.clone())));
if let Err(err) = new_ident.save_to_storage() { if let Err(err) = new_ident.save_to_storage() {
log::error!("saving public identity after change: {err}"); log::error!("saving public identity after change: {err}");
} }
@ -114,6 +121,7 @@ pub fn ClientNav(
let ident = identity.clone(); let ident = identity.clone();
let message_callback = message_callback.clone(); let message_callback = message_callback.clone();
Callback::from(move |value: String| -> Option<PublicIdentity> { Callback::from(move |value: String| -> Option<PublicIdentity> {
let ident = ident.as_ref().expect(MUST_HAVE_IDENTITY);
value.trim().is_empty().not().then(|| { value.trim().is_empty().not().then(|| {
let name = value.trim().to_string(); let name = value.trim().to_string();
message_callback message_callback
@ -134,6 +142,12 @@ pub fn ClientNav(
pronouns_open.set(false); pronouns_open.set(false);
}) })
}; };
let name_str = identity
.as_ref()
.map(|i| html! {{i.1.name.to_string()}})
.unwrap_or(html! {
<span class="red">{"???"}</span>
});
html! { html! {
<ClickableTextEdit <ClickableTextEdit
value={name.clone()} value={name.clone()}
@ -144,7 +158,7 @@ pub fn ClientNav(
on_open={close_others} on_open={close_others}
label={String::from("name")} label={String::from("name")}
> >
<div class="name">{identity.1.name.as_str()}</div> <div class="name">{name_str}</div>
</ClickableTextEdit> </ClickableTextEdit>
} }
}; };
@ -154,15 +168,18 @@ pub fn ClientNav(
let on_submit = { let on_submit = {
let ident = identity.clone(); let ident = identity.clone();
let message_callback = message_callback.clone(); let message_callback = message_callback.clone();
let pronuns_state = pronuns_state.clone();
Callback::from(move |value: String| -> Option<PublicIdentity> { Callback::from(move |value: String| -> Option<PublicIdentity> {
let pronouns = value.trim().is_empty().not().then_some(value); let pronouns = value.trim().is_empty().not().then_some(value);
message_callback.emit(ClientMessage::UpdateSelf(UpdateSelf::Pronouns( message_callback.emit(ClientMessage::UpdateSelf(UpdateSelf::Pronouns(
pronouns.clone(), pronouns.clone(),
))); )));
Some(PublicIdentity { pronuns_state.set(String::new());
pronouns, ident.as_ref().map(|id| {
name: ident.1.name.clone(), let mut public = id.1.clone();
number: ident.1.number, public.pronouns = pronouns;
ident.set(Some((id.0, public.clone())));
public
}) })
}) })
}; };
@ -196,14 +213,10 @@ pub fn ClientNav(
cb.emit(ClientMessage::Goodbye); cb.emit(ClientMessage::Goodbye);
let _ = gloo::utils::window().location().reload(); let _ = gloo::utils::window().location().reload();
}; };
let host_click = Callback::from(|_| {
if let Some(loc) = gloo::utils::document().location() {
let _ = loc.replace("/host");
}
});
html! { html! {
<> <>
<Button on_click={host_click}>{"host"}</Button> <a href="/host"><Button on_click={Callback::noop()}>{"host"}</Button></a>
<Button on_click={forgor}>{"forgor 💀"}</Button> <Button on_click={forgor}>{"forgor 💀"}</Button>
</> </>
} }
@ -223,7 +236,7 @@ struct ClickableTextEditProps {
#[prop_or_default] #[prop_or_default]
pub children: Html, pub children: Html,
pub value: UseStateHandle<String>, pub value: UseStateHandle<String>,
pub submit_ident: UseStateHandle<(PlayerId, PublicIdentity)>, pub submit_ident: UseStateHandle<Option<(PlayerId, PublicIdentity)>>,
pub on_submit: Callback<String, Option<PublicIdentity>>, pub on_submit: Callback<String, Option<PublicIdentity>>,
pub field_name: &'static str, pub field_name: &'static str,
pub state: UseStateHandle<bool>, pub state: UseStateHandle<bool>,
@ -257,8 +270,10 @@ fn ClickableTextEdit(
let submit = { let submit = {
let submit_ident = submit_ident.clone(); let submit_ident = submit_ident.clone();
move |_| { move |_| {
if let Some(new_ident) = message_callback.emit(value.trim().to_string()) { if let Some(new_ident) = message_callback.emit(value.trim().to_string())
submit_ident.set((submit_ident.0, new_ident.clone())); && let Some(ident) = submit_ident.as_ref()
{
submit_ident.set(Some((ident.0, new_ident.clone())));
if let Err(err) = new_ident.save_to_storage() { if let Err(err) = new_ident.save_to_storage() {
log::error!("saving public identity after change: {err}"); log::error!("saving public identity after change: {err}");
} }

View File

@ -23,6 +23,8 @@ use crate::components::Button;
#[derive(Debug, PartialEq, Properties)] #[derive(Debug, PartialEq, Properties)]
pub struct SigninProps { pub struct SigninProps {
pub callback: Callback<PublicIdentity>, pub callback: Callback<PublicIdentity>,
#[prop_or(true)]
pub full_height: bool,
} }
#[function_component] #[function_component]
@ -60,15 +62,34 @@ pub fn Signin(props: &SigninProps) -> Html {
}); });
let on_change = crate::components::input_element_number_oninput(num_value); let on_change = crate::components::input_element_number_oninput(num_value);
let full_height = props.full_height.then_some("full-height");
html! { html! {
<div class="signin full-height"> <div class={classes!("signin", full_height)}>
<div class="column-list"> <div class="signin-box">
<div class="field">
<label for="number">{"Seat Number"}</label>
<input
oninput={on_change}
type="text"
name="number"
id="number"
// autocomplete="off"
/>
</div>
<div class="field">
<label for="name">{"Name"}</label> <label for="name">{"Name"}</label>
<input oninput={name_on_input} name="name" id="name" type="text"/> <input
oninput={name_on_input}
name="name"
id="name"
type="text"
// autocomplete="name nickname username"
/>
</div>
<div class="field">
<label for="pronouns">{"Pronouns"}</label> <label for="pronouns">{"Pronouns"}</label>
<input oninput={pronouns_on_input} name="pronouns" id="pronouns" type="text"/> <input oninput={pronouns_on_input} name="pronouns" id="pronouns" type="text"/>
<label for="number">{"Number"}</label> </div>
<input oninput={on_change} type="text" name="number" id="number"/>
<Button on_click={on_click}>{"Submit"}</Button> <Button on_click={on_click}>{"Submit"}</Button>
</div> </div>
</div> </div>

View File

@ -109,18 +109,25 @@ pub fn ClickableNumberEdit(
label, label,
}: &ClickableNumberEditProps, }: &ClickableNumberEditProps,
) -> Html { ) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter(), 20); // let on_input = crate::components::input_element_string_oninput(value.setter(), 20);
let on_submit = on_submit.clone(); let on_submit = on_submit.clone();
let label = label.is_empty().not().then_some(html! { // let label = label.is_empty().not().then_some(html! {
<label>{label}</label> // <label>{label}</label>
}); // });
let options = html! { let options = html! {
<div class="info-update"> // <div class="info-update">
{label} // {label}
<input type="text" oninput={on_input} name={*field_name} autofocus=true/> // <input type="text" oninput={on_input} name={*field_name} autofocus=true/>
<Button on_click={on_submit.clone()}>{"ok"}</Button> // <Button on_click={on_submit.clone()}>{"ok"}</Button>
</div> // </div>
<NumberEdit
value={value.clone()}
on_submit={on_submit.clone()}
field_name={field_name}
label={label.clone()}
>
</NumberEdit>
}; };
html! { html! {
<ClickableField options={options} state={state.clone()} on_open={on_open.clone()}> <ClickableField options={options} state={state.clone()} on_open={on_open.clone()}>
@ -128,3 +135,36 @@ pub fn ClickableNumberEdit(
</ClickableField> </ClickableField>
} }
} }
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct NumberEditProps {
pub value: UseStateHandle<String>,
pub on_submit: Callback<()>,
pub field_name: &'static str,
#[prop_or_default]
pub label: String,
}
#[function_component]
pub fn NumberEdit(
NumberEditProps {
value,
on_submit,
field_name,
label,
}: &NumberEditProps,
) -> Html {
let on_input = crate::components::input_element_string_oninput(value.setter(), 20);
let on_submit = on_submit.clone();
let label = label.is_empty().not().then_some(html! {
<label>{label}</label>
});
html! {
<div class="info-update">
{label}
<input type="text" oninput={on_input} name={*field_name} autofocus=true/>
<Button on_click={on_submit.clone()}>{"ok"}</Button>
</div>
}
}

View File

@ -17,7 +17,10 @@ use core::num::NonZeroU8;
use werewolves_proto::{message::PlayerState, player::PlayerId}; use werewolves_proto::{message::PlayerState, player::PlayerId};
use yew::prelude::*; use yew::prelude::*;
use crate::components::{Button, ClickableField, ClickableNumberEdit, Identity}; use crate::components::{
Button, ClickableField, ClickableNumberEdit, Identity, NumberEdit,
modal::{Dialog, SubmenuDialog},
};
#[derive(Debug, PartialEq, Properties)] #[derive(Debug, PartialEq, Properties)]
pub struct LobbyPlayerProps { pub struct LobbyPlayerProps {
@ -35,7 +38,11 @@ pub enum LobbyPlayerAction {
#[function_component] #[function_component]
pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) -> Html { pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) -> Html {
let open = use_state(|| false); let open = use_state(|| false);
let class = player.connected.then_some("connected"); let class = if player.connected {
"connected"
} else {
"disconnected"
};
let pid = player.identification.player_id; let pid = player.identification.player_id;
let action_open = open.clone(); let action_open = open.clone();
let action = |action: LobbyPlayerAction| { let action = |action: LobbyPlayerAction| {
@ -65,9 +72,22 @@ pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) ->
on_action.emit((pid, LobbyPlayerAction::SetNumber(number))); on_action.emit((pid, LobbyPlayerAction::SetNumber(number)));
open.set(false); open.set(false);
}); });
const NUMBER_DIALOG_ID: &str = "player-set-number-dialog";
html! { html! {
<> <>
<Button on_click={(action)(LobbyPlayerAction::Kick)}>{"kick"}</Button> <Button on_click={(action)(LobbyPlayerAction::Kick)}>{"kick"}</Button>
// <Dialog
// id={NUMBER_DIALOG_ID.to_string()}
// button={html!{"set number"}}
// >
// <NumberEdit
// value={number}
// on_submit={on_number_submit}
// field_name="number"
// >
// </NumberEdit>
// </Dialog>
<ClickableNumberEdit <ClickableNumberEdit
state={number_open} state={number_open}
value={number} value={number}
@ -79,8 +99,17 @@ pub fn LobbyPlayer(LobbyPlayerProps { player, on_action }: &LobbyPlayerProps) ->
</> </>
} }
}); });
let object = html! {
<div class={classes!("player", class, "column-list")}>
<Identity ident={player.identification.public.clone()}/>
</div>
};
html! { html! {
// <SubmenuDialog
// object={object}
// >
// {submenu}
// </SubmenuDialog>
<ClickableField <ClickableField
state={open} state={open}
options={submenu} options={submenu}

View File

@ -0,0 +1,143 @@
// Copyright (C) 2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use wasm_bindgen::JsCast;
use web_sys::HtmlDialogElement;
use yew::prelude::*;
use crate::components::Button;
pub fn show_modal_by_id(id: &str) {
if let Some(dialog) = gloo::utils::document()
.get_element_by_id(id)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().ok())
&& let Err(err) = dialog.show_modal()
{
gloo::console::error!(format!("show modal [#{}]:", id), err)
}
}
pub fn close_modal_by_id(id: &str) {
if let Some(dialog) = gloo::utils::document()
.get_element_by_id(id)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().ok())
{
dialog.close();
}
}
pub fn show_modal_by_id_callback<ARG>(id: &str) -> Callback<ARG> {
let id = id.to_string();
Callback::from(move |_| {
show_modal_by_id(&id);
})
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct DialogProps {
#[prop_or_default]
pub id: String,
#[prop_or_default]
pub children: Html,
#[prop_or(true)]
pub close_backdrop: bool,
#[prop_or_default]
pub button: Html,
#[prop_or(true)]
pub close_button: bool,
}
#[function_component]
pub fn Dialog(
DialogProps {
id,
children,
close_backdrop,
button,
close_button,
}: &DialogProps,
) -> Html {
// use generated id if not supplied
let id = if id.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
id.to_string()
};
let close_cb = {
let id = id.clone();
Callback::from(move |_| {
if let Some(dialog) = gloo::utils::document()
.get_element_by_id(&id)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().ok())
{
dialog.close()
}
})
};
let close = close_button.then_some({
html! {
<Button on_click={close_cb.clone()} classes={classes!("close-dialog")}>{"close"}</Button>
}
});
let on_backdrop_click = close_backdrop.then_some({
let id = id.clone();
let close_cb = close_cb.clone();
Callback::from(move |ev: MouseEvent| {
let Some(dialog) = gloo::utils::document()
.get_element_by_id(&id)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().ok())
else {
return;
};
let Ok(Some(dialog)) = dialog.query_selector(".dialog-box") else {
return;
};
let rect = dialog.get_bounding_client_rect();
let is_in_dialog = rect.top() as i32 <= ev.client_y()
&& ev.client_y() <= rect.top() as i32 + rect.height() as i32
&& rect.left() as i32 <= ev.client_x()
&& ev.client_x() <= rect.left() as i32 + rect.width() as i32;
if !is_in_dialog {
close_cb.emit(());
}
})
});
let modal = html! {
<dialog id={id.clone()} onclick={on_backdrop_click}>
<div class="dialog">
<div class="dialog-box">
{close}
{children.clone()}
</div>
</div>
</dialog>
};
let button = (*button != html! {}).then_some({
let on_click = show_modal_by_id_callback(&id);
html! {
<Button on_click={on_click}>{button.clone()}</Button>
}
});
html! {
<div class="dialog-modal">
{button}
{modal}
</div>
}
}

View File

@ -0,0 +1,106 @@
// Copyright (C) 2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::components::Button;
use wasm_bindgen::JsCast;
use web_sys::HtmlDialogElement;
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct SubmenuProps {
#[prop_or_default]
pub id: String,
#[prop_or_default]
pub children: Html,
#[prop_or(true)]
pub close_backdrop: bool,
#[prop_or_default]
pub object: Html,
}
#[function_component]
pub fn SubmenuDialog(
SubmenuProps {
id,
children,
close_backdrop,
object,
}: &SubmenuProps,
) -> Html {
// use generated id if not supplied
let id = if id.is_empty() {
uuid::Uuid::new_v4().to_string()
} else {
id.to_string()
};
let close_cb = {
let id = id.clone();
Callback::from(move |_| {
if let Some(dialog) = gloo::utils::document()
.get_element_by_id(&id)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().ok())
{
dialog.close()
}
})
};
let on_backdrop_click = close_backdrop.then_some({
let id = id.clone();
let close_cb = close_cb.clone();
Callback::from(move |ev: MouseEvent| {
let Some(dialog) = gloo::utils::document()
.get_element_by_id(&id)
.and_then(|elem| elem.dyn_into::<HtmlDialogElement>().ok())
else {
return;
};
let Ok(Some(dialog)) = dialog.query_selector(".object-submenu") else {
return;
};
let rect = dialog.get_bounding_client_rect();
let is_in_dialog = rect.top() as i32 <= ev.client_y()
&& ev.client_y() <= rect.top() as i32 + rect.height() as i32
&& rect.left() as i32 <= ev.client_x()
&& ev.client_x() <= rect.left() as i32 + rect.width() as i32;
if !is_in_dialog {
close_cb.emit(());
}
})
});
let modal = html! {
<dialog id={id.clone()} onclick={on_backdrop_click}>
<div class="object-submenu">
<div class="object">
{object.clone()}
</div>
<menu>
{children.clone()}
</menu>
</div>
</dialog>
};
let open = crate::components::modal::show_modal_by_id_callback(&id);
html! {
<>
<div class="opener" onclick={open}>
{object.clone()}
</div>
{modal}
</>
}
}

View File

@ -30,6 +30,7 @@ use crate::{
components::{ components::{
Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon, Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon,
client::Signin, client::Signin,
modal::Dialog,
settings::{AddRoleCategory, SettingSlotAction, SettingsSlot}, settings::{AddRoleCategory, SettingSlotAction, SettingsSlot},
}, },
}; };
@ -274,17 +275,19 @@ pub fn Settings(
} }
}; };
let add_player_open = use_state(|| false);
let add_player_opts = html! {
<div class="add-player">
<Signin callback={on_add_player.clone()}/>
</div>
};
let current_roles = settings.slots().len(); let current_roles = settings.slots().len();
let min_roles = settings.min_players_needed(); let min_roles = settings.min_players_needed();
let min_roles_class = (current_roles < min_roles).then_some("red"); let min_roles_class = (current_roles < min_roles).then_some("red");
let player_count = players_in_lobby.len(); let player_count = players_in_lobby.len();
let player_count_class = (player_count != current_roles).then_some("red"); let player_count_class = (player_count != current_roles).then_some("red");
const ADD_PLAYER_DIALOG_ID: &str = "add-player-dialog";
let add_player_dialog_cb = {
let on_add_player = on_add_player.clone();
Callback::from(move |ident| {
on_add_player.emit(ident);
crate::components::modal::close_modal_by_id(ADD_PLAYER_DIALOG_ID);
})
};
html! { html! {
<div class="settings"> <div class="settings">
@ -295,12 +298,16 @@ pub fn Settings(
{clear_all_assignments} {clear_all_assignments}
{clear_bad_assigned} {clear_bad_assigned}
</div> </div>
<ClickableField <Dialog
options={add_player_opts} id={ADD_PLAYER_DIALOG_ID.to_string()}
state={add_player_open} button={html!{"add player"}}
close_button=false
> >
{"add player"} <div>
</ClickableField> <h3>{"manually add a player"}</h3>
<Signin callback={add_player_dialog_cb} full_height=false/>
</div>
</Dialog>
<div class="roles-add-list"> <div class="roles-add-list">
{add_roles_buttons} {add_roles_buttons}

View File

@ -1,613 +0,0 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::ops::Not;
use std::{collections::HashMap, rc::Rc};
use convert_case::{Case, Casing};
use werewolves_proto::{
aura::AuraTitle, character::{Character, CharacterId}, game::{
GameTime, SetupRole,
night::changes::NightChange,
story::{
DayDetail, GameActions, GameStory, NightChoice, StoryActionPrompt, StoryActionResult,
},
}, role::Alignment
};
use yew::prelude::*;
use crate::components::{
AuraSpan, CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon, attributes::{
AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan,
}
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct StoryProps {
pub story: GameStory,
}
#[function_component]
pub fn Story(StoryProps { story }: &StoryProps) -> Html {
let final_characters =
story
.final_village()
.unwrap_or_else(|_| story.starting_village.clone())
.characters()
.into_iter()
.map(|c| {
let dead =c.alive().not();
html! {
<>
<CharacterCard faint=true char={c} dead={dead}/>
</>
}
})
.collect::<Html>();
let bits = story
.iter()
.map(|(time, changes)| {
let characters = story
.village_at(match time {
GameTime::Day { .. } => {
time
},
GameTime::Night { .. } => {
time.previous().unwrap_or(time)
},
}).ok().flatten()
.map(|v| Rc::new(v.characters().into_iter()
.map(|c| (c.character_id(), c))
.collect::<HashMap<CharacterId, Character>>()))
.unwrap_or_else(||
Rc::new(story.starting_village
.characters().into_iter()
.map(|c| (c.character_id(), c))
.collect::<HashMap<_, _>>()));
let changes = match changes {
GameActions::DayDetails(day_changes) => {
let execute_list =
day_changes
.iter()
.map(|c| match c {
DayDetail::Execute(target) => *target,
})
.filter_map(|c| story.starting_village.character_by_id(c).ok())
.map(|c| {
html! {
<CharacterCard faint=true char={c.clone()}/>
}
})
.collect::<Html>();
day_changes.is_empty().not().then_some(html! {
<div class="day">
<h3>{"village executed"}</h3>
<div class="executed">
{execute_list}
</div>
</div>
})
}
GameActions::NightDetails(details) => details.choices.is_empty().not().then_some({
let choices = details
.choices
.iter()
.map(|c| {
html! {
<StoryNightChoice choice={c.clone()} characters={characters.clone()}/>
}
})
.collect::<Html>();
let changes = details
.changes
.iter()
.map(|c| {
html! {
<li class="change">
<StoryNightChange
change={c.clone()}
characters={characters.clone()}
/>
</li>
}
})
.collect::<Html>();
html! {
<div class="night">
<label>{"choices"}</label>
<ul class="choices">
{choices}
</ul>
<label>{"changes"}</label>
<ul class="changes">
{changes}
</ul>
</div>
}
}),
};
changes
.map(|changes| {
html! {
<div class="time-period">
<h1>{"on "}{time.to_string()}{"..."}</h1>
{changes}
</div>
}
})
.unwrap_or_default()
})
.collect::<Html>();
html! {
<div class="story">
<div class="cast">
{final_characters}
</div>
{bits}
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct StoryNightChangeProps {
change: NightChange,
characters: Rc<HashMap<CharacterId, Character>>,
}
#[function_component]
fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html {
match change {
NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{
<>
<CharacterCard faint=true char={character.clone()}/>
{"lost the"}
<AuraSpan aura={aura.title()}/>
{"aura"}
</>
}).unwrap_or_default(),
NightChange::ApplyAura { source, target, aura } => characters.get(source).and_then(|source| characters.get(target).map(|target| (source, target))).map(|(source, target)| {
html!{
<>
<CharacterCard faint=true char={target.clone()}/>
<span class="story-text">{"gained the"}</span>
<AuraSpan aura={aura.title()}/>
<span class="story-text">{"aura from"}</span>
<CharacterCard faint=true char={source.clone()}/>
</>
}
}).unwrap_or_default(),
NightChange::RoleChange(character_id, role_title) => characters
.get(character_id)
.map(|char| {
let mut new_char = char.clone();
let _ = new_char.role_change(*role_title, GameTime::Night { number: 0 });
html! {
<>
<CharacterCard faint=true char={char.clone()}/>
<span class="story-text">{"is now"}</span>
<CharacterCard faint=true char={new_char.clone()}/>
</>
}
})
.unwrap_or_default(),
NightChange::Kill { target, died_to } => {
characters
.get(target)
.map(|target| {
html! {
<>
<Icon source={IconSource::Skull} icon_type={IconType::Small}/>
<CharacterCard faint=true char={target.clone()}/>
<span class="story-text">{"died to"}</span>
<DiedToSpan died_to={died_to.title()}/>
</>
}
})
.unwrap_or_default()
},
NightChange::RoleBlock { source, target, .. } => characters
.get(source)
.and_then(|s| characters.get(target).map(|t| (s, t)))
.map(|(source, target)| {
html! {
<>
<CharacterCard faint=true char={source.clone()}/>
<span class="story-text">{"role blocked"}</span>
<CharacterCard faint=true char={target.clone()}/>
</>
}
})
.unwrap_or_default(),
NightChange::Shapeshift { source, into } => characters
.get(source)
.and_then(|s| characters.get(into).map(|i| (s, i)))
.map(|(source, into)| {
html! {
<>
<CharacterCard faint=true char={source.clone()}/>
<span class="story-text">{"shapeshifted into"}</span>
<CharacterCard faint=true char={into.clone()}/>
</>
}
})
.unwrap_or_default(),
NightChange::ElderReveal { elder } => characters
.get(elder)
.map(|elder| {
html! {
<>
<CharacterCard faint=true char={elder.clone()}/>
<span class="story-text">{"learned they are the Elder"}</span>
</>
}
})
.unwrap_or_default(),
NightChange::EmpathFoundScapegoat { empath, scapegoat } => characters
.get(empath)
.and_then(|e| characters.get(scapegoat).map(|s| (e, s)))
.map(|(empath, scapegoat)| {
html! {
<>
<CharacterCard faint=true char={empath.clone()}/>
<span class="story-text">{"found the scapegoat in"}</span>
<CharacterCard faint=true char={scapegoat.clone()}/>
<span class="story-text">{"and took on their curse"}</span>
</>
}
})
.unwrap_or_default(),
NightChange::HunterTarget { .. }
| NightChange::MasonRecruit { .. }
| NightChange::Protection { .. } => html! {}, // sorted in prompt side
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct StoryNightResultProps {
result: StoryActionResult,
characters: Rc<HashMap<CharacterId, Character>>,
}
#[function_component]
fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html {
match result {
StoryActionResult::ShiftFailed => html!{
<span class="story-text">{"but it failed"}</span>
},
StoryActionResult::Drunk => html! {
<>
<span class="story-text">{"but got "}</span>
<AuraSpan aura={AuraTitle::Drunk}/>
<span class="story-text">{" instead"}</span>
</>
},
StoryActionResult::BeholderSawEverything => html!{
<span class="story-text">{"and saw everything 👁️"}</span>
},
StoryActionResult::BeholderSawNothing => html!{
<span class="story-text">{"but saw nothing"}</span>
},
StoryActionResult::RoleBlocked => html! {
<span class="story-text">{"but was role blocked"}</span>
},
StoryActionResult::Seer(alignment) => {
html! {
<span>
<span class="story-text">{"and saw"}</span>
<AlignmentSpan alignment={*alignment}/>
</span>
}
}
StoryActionResult::PowerSeer { powerful } => {
html! {
<span>
<span class="story-text">{"and discovered they are"}</span>
<PowerfulSpan powerful={*powerful}/>
</span>
}
}
StoryActionResult::Adjudicator { killer } => html! {
<span>
<span class="story-text">{"and saw"}</span>
<KillerSpan killer={*killer}/>
</span>
},
StoryActionResult::Arcanist(same) => html! {
<span>
<span class="story-text">{"and saw"}</span>
<AlignmentComparisonSpan comparison={*same}/>
</span>
},
StoryActionResult::GraveDigger(None) => html! {
<span class="story-text">
{"found an empty grave"}
</span>
},
StoryActionResult::GraveDigger(Some(role_title)) => {
let category = Into::<SetupRole>::into(*role_title).category();
html! {
<span>
<span class="story-text">{"found the body of a"}</span>
<CategorySpan category={category} icon={role_title.icon()}>
{role_title.to_string().to_case(Case::Title)}
</CategorySpan>
</span>
}
}
StoryActionResult::Mortician(died_to_title) => html! {
<>
<span class="story-text">{"and found the cause of death to be"}</span>
<DiedToSpan died_to={*died_to_title}/>
</>
},
StoryActionResult::Insomniac { visits } => {
let visitors = visits
.iter()
.filter_map(|c| characters.get(c))
.map(|c| {
html! {
<CharacterCard faint=true char={c.clone()}/>
}
})
.collect::<Html>();
html! {
{visitors}
}
}
StoryActionResult::Empath { scapegoat: false } => html! {
<>
<span class="story-text">{"and saw that they are"}</span>
<span class="attribute-span faint">
<div class="inactive">
<Icon source={IconSource::Heart} icon_type={IconType::Small}/>
</div>
{"Not The Scapegoat"}
</span>
</>
},
StoryActionResult::Empath { scapegoat: true } => html! {
<>
<span class="story-text">{"and saw that they are"}</span>
<span class="attribute-span faint wolves">
<div>
<Icon source={IconSource::Heart} icon_type={IconType::Small}/>
</div>
{"The Scapegoat"}
</span>
</>
},
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct StoryNightChoiceProps {
choice: NightChoice,
characters: Rc<HashMap<CharacterId, Character>>,
}
#[function_component]
fn StoryNightChoice(StoryNightChoiceProps { choice, characters}: &StoryNightChoiceProps) -> Html {
let generate = |character_id: &CharacterId, chosen: &CharacterId, action: &'static str| {
characters
.get(character_id)
.and_then(|char| characters.get(chosen).map(|c| (char, c)))
.map(|(char, chosen)| {
html! {
<>
<CharacterCard faint=true char={char.clone()}/>
<span class="story-text">{action}</span>
<CharacterCard faint=true char={chosen.clone()}/>
</>
}
})
};
let choice_body = match &choice.prompt {
StoryActionPrompt::Arcanist {
character_id,
chosen: (chosen1, chosen2),
} => characters
.get(character_id)
.and_then(|arcanist| characters.get(chosen1).map(|c| (arcanist, c)))
.and_then(|(arcanist, chosen1)| {
characters
.get(chosen2)
.map(|chosen2| (arcanist, chosen1, chosen2))
})
.map(|(arcanist, chosen1, chosen2)| {
html! {
<>
<CharacterCard faint=true char={arcanist.clone()}/>
<span class="story-text">{"compared"}</span>
<CharacterCard faint=true char={chosen1.clone()}/>
<span class="story-text">{"and"}</span>
<CharacterCard faint=true char={chosen2.clone()}/>
</>
}
}),
StoryActionPrompt::MasonsWake { leader, masons } => characters.get(leader).map(|leader| {
let masons = masons
.iter()
.filter_map(|m| characters.get(m))
.map(|c| {
html! {
<CharacterCard faint=true char={c.clone()}/>
}
})
.collect::<Html>();
html! {
<>
<CharacterCard faint=true char={leader.clone()}/>
<span class="story-text">{"'s masons"}</span>
{masons}
<span class="story-text">{"convened in secret"}</span>
</>
}
}),
StoryActionPrompt::Bloodletter { character_id, chosen } => generate(character_id, chosen, "spilt wolf blood on"),
StoryActionPrompt::Vindicator {
character_id,
chosen,
}
| StoryActionPrompt::Protector {
character_id,
chosen,
} => generate(character_id, chosen, "protected"),
StoryActionPrompt::Gravedigger {
character_id,
chosen,
} => generate(character_id, chosen, "dug up"),
StoryActionPrompt::Adjudicator {
character_id,
chosen,
}
| StoryActionPrompt::PowerSeer {
character_id,
chosen,
}
| StoryActionPrompt::Empath {
character_id,
chosen,
}
| StoryActionPrompt::Seer {
character_id,
chosen,
} => generate(character_id, chosen, "checked"),
StoryActionPrompt::Hunter {
character_id,
chosen,
} => generate(character_id, chosen, "set a trap for"),
StoryActionPrompt::Militia {
character_id,
chosen,
} => generate(character_id, chosen, "shot"),
StoryActionPrompt::MapleWolf {
character_id,
chosen,
} => characters
.get(character_id)
.and_then(|char| characters.get(chosen).map(|c| (char, c)))
.map(|(char, chosen)| {
html! {
<>
<CharacterCard faint=true char={char.clone()}/>
<span class="story-text">{"invited"}</span>
<CharacterCard faint=true char={chosen.clone()}/>
<span class="story-text">{"for dinner"}</span>
</>
}
}),
StoryActionPrompt::Guardian {
character_id,
chosen,
guarding,
} => generate(
character_id,
chosen,
if *guarding { "guarded" } else { "protected" },
),
StoryActionPrompt::Mortician {
character_id,
chosen,
} => generate(character_id, chosen, "examined"),
StoryActionPrompt::Beholder {
character_id,
chosen,
} => generate(character_id, chosen, "👁️"),
StoryActionPrompt::MasonLeaderRecruit {
character_id,
chosen,
} => generate(character_id, chosen, "tried recruiting"),
StoryActionPrompt::PyreMaster {
character_id,
chosen,
} => generate(character_id, chosen, "torched"),
StoryActionPrompt::WolfPackKill { chosen } => {
characters.get(chosen).map(|chosen: &Character| {
html! {
<>
<AlignmentSpan alignment={Alignment::Wolves}/>
<span class="story-text">{"attempted a kill on"}</span>
<CharacterCard faint=true char={chosen.clone()} />
</>
}
})
}
StoryActionPrompt::Shapeshifter { character_id } => {
if choice.result.is_none() {
return html!{};
}
characters.get(character_id).map(|shifter| {
html! {
<>
<CharacterCard faint=true char={shifter.clone()} />
<span class="story-text">{"decided to shapeshift into the wolf kill target"}</span>
</>
}
})
}
StoryActionPrompt::AlphaWolf {
character_id,
chosen,
} => generate(character_id, chosen, "took a stab at"),
StoryActionPrompt::DireWolf {
character_id,
chosen,
} => generate(character_id, chosen, "roleblocked"),
StoryActionPrompt::LoneWolfKill {
character_id,
chosen,
} => generate(character_id, chosen, "sought vengeance from"),
StoryActionPrompt::Insomniac { character_id } => {
characters.get(character_id).map(|insomniac| {
html! {
<>
<CharacterCard faint=true char={insomniac.clone()} />
<span class="story-text">{"witnessed visits from"}</span>
</>
}
})
}
};
let result = choice.result.as_ref().map(|result| {
html! {
<StoryNightResult result={result.clone()} characters={characters.clone()}/>
}
});
choice_body
.map(|choice_body| {
html! {
<li class="choice">
<Icon
source={IconSource::ListItem}
icon_type={IconType::Small}
classes={classes!("li-icon")}
/>
{choice_body}
{result}
</li>
}
})
.unwrap_or_default()
}

View File

@ -0,0 +1,245 @@
// Copyright (C) 2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use std::{collections::HashMap, rc::Rc};
use werewolves_proto::{
character::{Character, CharacterId},
game::{
GameTime, SetupRole,
night::changes::NightChange,
story::{NightChoice, StoryActionPrompt},
},
};
use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType, Identity, PartialAssociatedIcon};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterStoryProps {
pub character: Character,
pub actions: Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
}
#[function_component]
pub fn CharacterStory(CharacterStoryProps { character, actions }: &CharacterStoryProps) -> Html {
let mut by_time = actions.clone();
by_time.sort_by_key(|s| s.0);
let by_time = by_time
.into_iter()
.map(|(time, changes, choices)| {
html! {
<CharacterStoryTime
character={character.clone()}
time={time}
changes={changes}
choices={choices}
/>
}
})
.collect::<Html>();
html! {
<div class="character-story">
<CharacterStoryContainer character={character.clone()}>
{by_time}
</CharacterStoryContainer>
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct CharacterStoryTimeProps {
pub character: Character,
pub time: GameTime,
pub changes: Box<[NightChange]>,
pub choices: Box<[NightChoice]>,
}
#[function_component]
fn CharacterStoryTime(
CharacterStoryTimeProps {
character,
time,
changes,
choices,
}: &CharacterStoryTimeProps,
) -> Html {
let open = use_state(|| false);
let time_text = match time {
GameTime::Day { number } => format!("day {number}"),
GameTime::Night { number } => format!("night {number}"),
};
let shown = open.then_some("shown");
let on_click = {
let open = open.clone();
Callback::from(move |_| open.set(!*open))
};
html! {
<div class="story-time">
<span class={classes!("time")} onclick={on_click}>
{time_text}
</span>
<div class={classes!("details", shown)}>
{"hello"}
</div>
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct ChoiceProps {
all_characters: Rc<[Character]>,
character: Character,
choice: NightChoice,
}
#[function_component]
fn Choice(
ChoiceProps {
all_characters,
character,
choice,
}: &ChoiceProps,
) -> Html {
let generate_prompt = |chosen: CharacterId| -> Html { todo!() };
let prompt = match &choice.prompt {
StoryActionPrompt::Guardian {
chosen, guarding, ..
} => todo!(),
StoryActionPrompt::Seer { chosen, .. } => todo!(),
StoryActionPrompt::Protector { chosen, .. } => todo!(),
StoryActionPrompt::Arcanist { chosen, .. } => todo!(),
StoryActionPrompt::Gravedigger { chosen, .. } => todo!(),
StoryActionPrompt::Hunter { chosen, .. } => todo!(),
StoryActionPrompt::Militia { chosen, .. } => todo!(),
StoryActionPrompt::MapleWolf { chosen, .. } => todo!(),
StoryActionPrompt::Adjudicator { chosen, .. } => todo!(),
StoryActionPrompt::PowerSeer { chosen, .. } => todo!(),
StoryActionPrompt::Mortician { chosen, .. } => todo!(),
StoryActionPrompt::Beholder { chosen, .. } => todo!(),
StoryActionPrompt::MasonsWake { leader, masons } => todo!(),
StoryActionPrompt::MasonLeaderRecruit { chosen, .. } => todo!(),
StoryActionPrompt::Empath { chosen, .. } => todo!(),
StoryActionPrompt::Vindicator { chosen, .. } => todo!(),
StoryActionPrompt::PyreMaster { chosen, .. } => todo!(),
StoryActionPrompt::WolfPackKill { chosen } => todo!(),
StoryActionPrompt::Shapeshifter { .. } => todo!(),
StoryActionPrompt::AlphaWolf { chosen, .. } => todo!(),
StoryActionPrompt::DireWolf { chosen, .. } => todo!(),
StoryActionPrompt::LoneWolfKill { chosen, .. } => todo!(),
StoryActionPrompt::Insomniac { .. } => todo!(),
StoryActionPrompt::Bloodletter { chosen, .. } => todo!(),
StoryActionPrompt::BeholderWakes { character_id } => todo!(),
};
html! {}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct ChangeProps {
all_characters: Rc<[Character]>,
change: NightChange,
}
#[function_component]
fn Change(
ChangeProps {
all_characters,
change,
}: &ChangeProps,
) -> Html {
match change {
NightChange::RoleChange(role_title, ..) => todo!(),
NightChange::Kill { target, died_to } => todo!(),
NightChange::RoleBlock {
source,
target,
block_type,
} => todo!(),
NightChange::Shapeshift { source, into } => todo!(),
NightChange::Protection { target, protection } => todo!(),
NightChange::ElderReveal { .. } => todo!(),
NightChange::MasonRecruit {
mason_leader,
recruiting,
} => todo!(),
NightChange::ApplyAura { source, aura, .. } => todo!(),
NightChange::LostAura { aura, .. } => todo!(),
NightChange::EmpathFoundScapegoat { .. } | NightChange::HunterTarget { .. } => {
return html! {};
}
}
html! {
<div class="change">
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct CharacterStoryContainerProps {
pub character: Character,
#[prop_or_default]
pub children: Html,
}
#[function_component]
fn CharacterStoryContainer(
CharacterStoryContainerProps {
character,
children,
}: &CharacterStoryContainerProps,
) -> Html {
let open = use_state(|| true);
let role_class = Into::<SetupRole>::into(character.role_title())
.category()
.class();
let icon = character
.role_title()
.icon()
.map(|source| {
html! {
<Icon source={source} icon_type={IconType::Small}/>
}
})
.unwrap_or(html! {
<div class="icon-spacer"/>
});
let dead_icon = character
.died_to()
.map(|_| {
html! {
<Icon source={IconSource::Skull} icon_type={IconType::Small}/>
}
})
.unwrap_or(html! {
<div class="icon-spacer"/>
});
let on_click = {
let open = open.clone();
Callback::from(move |_| open.set(!*open))
};
let shown = open.then_some("shown");
html! {
<div class="character-story-card">
<div class={classes!("character-headline", role_class, "faint")} onclick={on_click}>
{icon}
<Identity ident={character.identity().into_public()}/>
{dead_icon}
</div>
<div class={classes!("character-details", role_class, "faint", shown)}>
{children.clone()}
</div>
</div>
}
}

View File

@ -0,0 +1,30 @@
// Copyright (C) 2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct StoryErrorProps {
pub error: String,
}
#[function_component]
pub fn StoryError(StoryErrorProps { error }: &StoryErrorProps) -> Html {
html! {
<div class="story-error">
{error.clone()}
</div>
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,696 @@
// Copyright (C) 2025 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::ops::Not;
use std::{collections::HashMap, rc::Rc};
use convert_case::{Case, Casing};
use werewolves_proto::{
aura::AuraTitle,
character::{Character, CharacterId},
game::{
GameTime, SetupRole,
night::changes::NightChange,
story::{
DayDetail, GameActions, GameStory, NightChoice, StoryActionPrompt, StoryActionResult,
},
},
role::Alignment,
};
use yew::prelude::*;
use crate::components::{
AuraSpan, CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon,
attributes::{
AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan,
},
story::{CharacterStory, StoryError},
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct StoryProps {
pub story: GameStory,
}
#[function_component]
pub fn Story(StoryProps { story }: &StoryProps) -> Html {
let village = match story.final_village() {
Ok(village) => village,
Err(err) => {
return html! {
<StoryError error={err.to_string()}/>
};
}
};
let actions_by_character = village
.characters()
.into_iter()
.map(|c| {
let actions = story
.changes
.iter()
.filter_map(|(time, act)| {
let cid = c.character_id();
match act {
GameActions::DayDetails(_) => None,
GameActions::NightDetails(night) => Some((
*time,
night
.changes
.iter()
.filter(|nc| {
nc.target().map(|target| target == cid).unwrap_or_default()
})
.cloned()
.collect::<Box<[_]>>(),
night
.choices
.iter()
.filter(|nc| {
nc.prompt
.character_id()
.map(|c| c == cid)
.unwrap_or_default()
})
.cloned()
.collect::<Box<[_]>>(),
)),
}
})
.collect::<Box<[_]>>();
(c, actions)
})
.collect::<Box<[_]>>();
let chars = actions_by_character
.into_iter()
.map(|(char, actions)| {
html! {
<CharacterStory character={char} actions={actions} />
}
})
.collect::<Html>();
html! {
<div class="story">
{chars}
</div>
}
}
// #[function_component]
// pub fn OldStory(StoryProps { story }: &StoryProps) -> Html {
// let final_characters =
// story
// .final_village()
// .unwrap_or_else(|_| story.starting_village.clone())
// .characters()
// .into_iter()
// .map(|c| {
// let dead =c.alive().not();
// html! {
// <>
// <CharacterCard faint=true char={c} dead={dead}/>
// </>
// }
// })
// .collect::<Html>();
// let bits = story
// .iter()
// .map(|(time, changes)| {
// let characters = story
// .village_at(match time {
// GameTime::Day { .. } => {
// time
// },
// GameTime::Night { .. } => {
// time.previous().unwrap_or(time)
// },
// }).ok().flatten()
// .map(|v| Rc::new(v.characters().into_iter()
// .map(|c| (c.character_id(), c))
// .collect::<HashMap<CharacterId, Character>>()))
// .unwrap_or_else(||
// Rc::new(story.starting_village
// .characters().into_iter()
// .map(|c| (c.character_id(), c))
// .collect::<HashMap<_, _>>()));
// let changes = match changes {
// GameActions::DayDetails(day_changes) => {
// let execute_list =
// day_changes
// .iter()
// .map(|c| match c {
// DayDetail::Execute(target) => *target,
// })
// .filter_map(|c| story.starting_village.character_by_id(c).ok())
// .map(|c| {
// html! {
// <CharacterCard faint=true char={c.clone()}/>
// }
// })
// .collect::<Html>();
// day_changes.is_empty().not().then_some(html! {
// <div class="day">
// <h3>{"village executed"}</h3>
// <div class="executed">
// {execute_list}
// </div>
// </div>
// })
// }
// GameActions::NightDetails(details) => details.choices.is_empty().not().then_some({
// let choices = details
// .choices
// .iter()
// .map(|c| {
// html! {
// <StoryNightChoice choice={c.clone()} characters={characters.clone()}/>
// }
// })
// .collect::<Html>();
// let changes = details
// .changes
// .iter()
// .map(|c| {
// html! {
// <li class="change">
// <StoryNightChange
// change={c.clone()}
// characters={characters.clone()}
// />
// </li>
// }
// })
// .collect::<Html>();
// html! {
// <div class="night">
// <label>{"choices"}</label>
// <ul class="choices">
// {choices}
// </ul>
// <label>{"changes"}</label>
// <ul class="changes">
// {changes}
// </ul>
// </div>
// }
// }),
// };
// changes
// .map(|changes| {
// html! {
// <div class="time-period">
// <h1>{"on "}{time.to_string()}{"..."}</h1>
// {changes}
// </div>
// }
// })
// .unwrap_or_default()
// })
// .collect::<Html>();
// html! {
// <div class="story">
// <div class="cast">
// {final_characters}
// </div>
// {bits}
// </div>
// }
// }
// #[derive(Debug, Clone, PartialEq, Properties)]
// struct StoryNightChangeProps {
// change: NightChange,
// characters: Rc<HashMap<CharacterId, Character>>,
// }
// #[function_component]
// fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html {
// match change {
// NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{
// <>
// <CharacterCard faint=true char={character.clone()}/>
// {"lost the"}
// <AuraSpan aura={aura.title()}/>
// {"aura"}
// </>
// }).unwrap_or_default(),
// NightChange::ApplyAura { source, target, aura } => characters.get(source).and_then(|source| characters.get(target).map(|target| (source, target))).map(|(source, target)| {
// html!{
// <>
// <CharacterCard faint=true char={target.clone()}/>
// <span class="story-text">{"gained the"}</span>
// <AuraSpan aura={aura.title()}/>
// <span class="story-text">{"aura from"}</span>
// <CharacterCard faint=true char={source.clone()}/>
// </>
// }
// }).unwrap_or_default(),
// NightChange::RoleChange(character_id, role_title) => characters
// .get(character_id)
// .map(|char| {
// let mut new_char = char.clone();
// let _ = new_char.role_change(*role_title, GameTime::Night { number: 0 });
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{"is now"}</span>
// <CharacterCard faint=true char={new_char.clone()}/>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::Kill { target, died_to } => {
// characters
// .get(target)
// .map(|target| {
// html! {
// <>
// <Icon source={IconSource::Skull} icon_type={IconType::Small}/>
// <CharacterCard faint=true char={target.clone()}/>
// <span class="story-text">{"died to"}</span>
// <DiedToSpan died_to={died_to.title()}/>
// </>
// }
// })
// .unwrap_or_default()
// },
// NightChange::RoleBlock { source, target, .. } => characters
// .get(source)
// .and_then(|s| characters.get(target).map(|t| (s, t)))
// .map(|(source, target)| {
// html! {
// <>
// <CharacterCard faint=true char={source.clone()}/>
// <span class="story-text">{"role blocked"}</span>
// <CharacterCard faint=true char={target.clone()}/>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::Shapeshift { source, into } => characters
// .get(source)
// .and_then(|s| characters.get(into).map(|i| (s, i)))
// .map(|(source, into)| {
// html! {
// <>
// <CharacterCard faint=true char={source.clone()}/>
// <span class="story-text">{"shapeshifted into"}</span>
// <CharacterCard faint=true char={into.clone()}/>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::ElderReveal { elder } => characters
// .get(elder)
// .map(|elder| {
// html! {
// <>
// <CharacterCard faint=true char={elder.clone()}/>
// <span class="story-text">{"learned they are the Elder"}</span>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::EmpathFoundScapegoat { empath, scapegoat } => characters
// .get(empath)
// .and_then(|e| characters.get(scapegoat).map(|s| (e, s)))
// .map(|(empath, scapegoat)| {
// html! {
// <>
// <CharacterCard faint=true char={empath.clone()}/>
// <span class="story-text">{"found the scapegoat in"}</span>
// <CharacterCard faint=true char={scapegoat.clone()}/>
// <span class="story-text">{"and took on their curse"}</span>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::HunterTarget { .. }
// | NightChange::MasonRecruit { .. }
// | NightChange::Protection { .. } => html! {}, // sorted in prompt side
// }
// }
// #[derive(Debug, Clone, PartialEq, Properties)]
// struct StoryNightResultProps {
// result: StoryActionResult,
// characters: Rc<HashMap<CharacterId, Character>>,
// }
// #[function_component]
// fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html {
// match result {
// StoryActionResult::ShiftFailed => html!{
// <span class="story-text">{"but it failed"}</span>
// },
// StoryActionResult::Drunk => html! {
// <>
// <span class="story-text">{"but got "}</span>
// <AuraSpan aura={AuraTitle::Drunk}/>
// <span class="story-text">{" instead"}</span>
// </>
// },
// StoryActionResult::BeholderSawEverything => html!{
// <span class="story-text">{"and saw everything 👁️"}</span>
// },
// StoryActionResult::BeholderSawNothing => html!{
// <span class="story-text">{"but saw nothing"}</span>
// },
// StoryActionResult::RoleBlocked => html! {
// <span class="story-text">{"but was role blocked"}</span>
// },
// StoryActionResult::Seer(alignment) => {
// html! {
// <span>
// <span class="story-text">{"and saw"}</span>
// <AlignmentSpan alignment={*alignment}/>
// </span>
// }
// }
// StoryActionResult::PowerSeer { powerful } => {
// html! {
// <span>
// <span class="story-text">{"and discovered they are"}</span>
// <PowerfulSpan powerful={*powerful}/>
// </span>
// }
// }
// StoryActionResult::Adjudicator { killer } => html! {
// <span>
// <span class="story-text">{"and saw"}</span>
// <KillerSpan killer={*killer}/>
// </span>
// },
// StoryActionResult::Arcanist(same) => html! {
// <span>
// <span class="story-text">{"and saw"}</span>
// <AlignmentComparisonSpan comparison={*same}/>
// </span>
// },
// StoryActionResult::GraveDigger(None) => html! {
// <span class="story-text">
// {"found an empty grave"}
// </span>
// },
// StoryActionResult::GraveDigger(Some(role_title)) => {
// let category = Into::<SetupRole>::into(*role_title).category();
// html! {
// <span>
// <span class="story-text">{"found the body of a"}</span>
// <CategorySpan category={category} icon={role_title.icon()}>
// {role_title.to_string().to_case(Case::Title)}
// </CategorySpan>
// </span>
// }
// }
// StoryActionResult::Mortician(died_to_title) => html! {
// <>
// <span class="story-text">{"and found the cause of death to be"}</span>
// <DiedToSpan died_to={*died_to_title}/>
// </>
// },
// StoryActionResult::Insomniac { visits } => {
// let visitors = visits
// .iter()
// .filter_map(|c| characters.get(c))
// .map(|c| {
// html! {
// <CharacterCard faint=true char={c.clone()}/>
// }
// })
// .collect::<Html>();
// html! {
// {visitors}
// }
// }
// StoryActionResult::Empath { scapegoat: false } => html! {
// <>
// <span class="story-text">{"and saw that they are"}</span>
// <span class="attribute-span faint">
// <div class="inactive">
// <Icon source={IconSource::Heart} icon_type={IconType::Small}/>
// </div>
// {"Not The Scapegoat"}
// </span>
// </>
// },
// StoryActionResult::Empath { scapegoat: true } => html! {
// <>
// <span class="story-text">{"and saw that they are"}</span>
// <span class="attribute-span faint wolves">
// <div>
// <Icon source={IconSource::Heart} icon_type={IconType::Small}/>
// </div>
// {"The Scapegoat"}
// </span>
// </>
// },
// }
// }
// #[derive(Debug, Clone, PartialEq, Properties)]
// struct StoryNightChoiceProps {
// choice: NightChoice,
// characters: Rc<HashMap<CharacterId, Character>>,
// }
// #[function_component]
// fn StoryNightChoice(StoryNightChoiceProps { choice, characters}: &StoryNightChoiceProps) -> Html {
// let generate = |character_id: &CharacterId, chosen: &CharacterId, action: &'static str| {
// characters
// .get(character_id)
// .and_then(|char| characters.get(chosen).map(|c| (char, c)))
// .map(|(char, chosen)| {
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{action}</span>
// <CharacterCard faint=true char={chosen.clone()}/>
// </>
// }
// })
// };
// let choice_body = match &choice.prompt {
// StoryActionPrompt::Arcanist {
// character_id,
// chosen: (chosen1, chosen2),
// } => characters
// .get(character_id)
// .and_then(|arcanist| characters.get(chosen1).map(|c| (arcanist, c)))
// .and_then(|(arcanist, chosen1)| {
// characters
// .get(chosen2)
// .map(|chosen2| (arcanist, chosen1, chosen2))
// })
// .map(|(arcanist, chosen1, chosen2)| {
// html! {
// <>
// <CharacterCard faint=true char={arcanist.clone()}/>
// <span class="story-text">{"compared"}</span>
// <CharacterCard faint=true char={chosen1.clone()}/>
// <span class="story-text">{"and"}</span>
// <CharacterCard faint=true char={chosen2.clone()}/>
// </>
// }
// }),
// StoryActionPrompt::MasonsWake { leader, masons } => characters.get(leader).map(|leader| {
// let masons = masons
// .iter()
// .filter_map(|m| characters.get(m))
// .map(|c| {
// html! {
// <CharacterCard faint=true char={c.clone()}/>
// }
// })
// .collect::<Html>();
// html! {
// <>
// <CharacterCard faint=true char={leader.clone()}/>
// <span class="story-text">{"'s masons"}</span>
// {masons}
// <span class="story-text">{"convened in secret"}</span>
// </>
// }
// }),
// StoryActionPrompt::Bloodletter { character_id, chosen } => generate(character_id, chosen, "spilt wolf blood on"),
// StoryActionPrompt::BeholderWakes { character_id }=>characters
// .get(character_id)
// .map(|char| {
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{"woke up and saw"}</span>
// </>
// }
// }),
// StoryActionPrompt::Vindicator {
// character_id,
// chosen,
// }
// | StoryActionPrompt::Protector {
// character_id,
// chosen,
// } => generate(character_id, chosen, "protected"),
// StoryActionPrompt::Gravedigger {
// character_id,
// chosen,
// } => generate(character_id, chosen, "dug up"),
// StoryActionPrompt::Adjudicator {
// character_id,
// chosen,
// }
// | StoryActionPrompt::PowerSeer {
// character_id,
// chosen,
// }
// | StoryActionPrompt::Empath {
// character_id,
// chosen,
// }
// | StoryActionPrompt::Seer {
// character_id,
// chosen,
// } => generate(character_id, chosen, "checked"),
// StoryActionPrompt::Hunter {
// character_id,
// chosen,
// } => generate(character_id, chosen, "set a trap for"),
// StoryActionPrompt::Militia {
// character_id,
// chosen,
// } => generate(character_id, chosen, "shot"),
// StoryActionPrompt::MapleWolf {
// character_id,
// chosen,
// } => characters
// .get(character_id)
// .and_then(|char| characters.get(chosen).map(|c| (char, c)))
// .map(|(char, chosen)| {
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{"invited"}</span>
// <CharacterCard faint=true char={chosen.clone()}/>
// <span class="story-text">{"for dinner"}</span>
// </>
// }
// }),
// StoryActionPrompt::Guardian {
// character_id,
// chosen,
// guarding,
// } => generate(
// character_id,
// chosen,
// if *guarding { "guarded" } else { "protected" },
// ),
// StoryActionPrompt::Mortician {
// character_id,
// chosen,
// } => generate(character_id, chosen, "examined"),
// StoryActionPrompt::Beholder {
// character_id,
// chosen,
// } => generate(character_id, chosen, "👁️"),
// StoryActionPrompt::MasonLeaderRecruit {
// character_id,
// chosen,
// } => generate(character_id, chosen, "tried recruiting"),
// StoryActionPrompt::PyreMaster {
// character_id,
// chosen,
// } => generate(character_id, chosen, "torched"),
// StoryActionPrompt::WolfPackKill { chosen } => {
// characters.get(chosen).map(|chosen: &Character| {
// html! {
// <>
// <AlignmentSpan alignment={Alignment::Wolves}/>
// <span class="story-text">{"attempted a kill on"}</span>
// <CharacterCard faint=true char={chosen.clone()} />
// </>
// }
// })
// }
// StoryActionPrompt::Shapeshifter { character_id } => {
// if choice.result.is_none() {
// return html!{};
// }
// characters.get(character_id).map(|shifter| {
// html! {
// <>
// <CharacterCard faint=true char={shifter.clone()} />
// <span class="story-text">{"decided to shapeshift into the wolf kill target"}</span>
// </>
// }
// })
// }
// StoryActionPrompt::AlphaWolf {
// character_id,
// chosen,
// } => generate(character_id, chosen, "took a stab at"),
// StoryActionPrompt::DireWolf {
// character_id,
// chosen,
// } => generate(character_id, chosen, "roleblocked"),
// StoryActionPrompt::LoneWolfKill {
// character_id,
// chosen,
// } => generate(character_id, chosen, "sought vengeance from"),
// StoryActionPrompt::Insomniac { character_id } => {
// characters.get(character_id).map(|insomniac| {
// html! {
// <>
// <CharacterCard faint=true char={insomniac.clone()} />
// <span class="story-text">{"witnessed visits from"}</span>
// </>
// }
// })
// }
// };
// let result = choice.result.as_ref().map(|result| {
// html! {
// <StoryNightResult result={result.clone()} characters={characters.clone()}/>
// }
// });
// choice_body
// .map(|choice_body| {
// html! {
// <li class="choice">
// <Icon
// source={IconSource::ListItem}
// icon_type={IconType::Small}
// classes={classes!("li-icon")}
// />
// {choice_body}
// {result}
// </li>
// }
// })
// .unwrap_or_default()
// }

View File

@ -19,6 +19,9 @@ mod storage;
mod test_util; mod test_util;
mod components { mod components {
werewolves_macros::include_path!("werewolves/src/components"); werewolves_macros::include_path!("werewolves/src/components");
pub mod modal {
werewolves_macros::include_path!("werewolves/src/components/modal");
}
pub mod attributes { pub mod attributes {
werewolves_macros::include_path!("werewolves/src/components/attributes"); werewolves_macros::include_path!("werewolves/src/components/attributes");
} }
@ -34,6 +37,9 @@ mod components {
pub mod settings { pub mod settings {
werewolves_macros::include_path!("werewolves/src/components/settings"); werewolves_macros::include_path!("werewolves/src/components/settings");
} }
pub mod story {
werewolves_macros::include_path!("werewolves/src/components/story");
}
} }
mod pages { mod pages {
werewolves_macros::include_path!("werewolves/src/pages"); werewolves_macros::include_path!("werewolves/src/pages");
@ -81,13 +87,14 @@ fn main() {
} else { } else {
host.send_message(HostEvent::SetErrorCallback(error_callback)); host.send_message(HostEvent::SetErrorCallback(error_callback));
} }
} else if path.starts_with("/story") {
let story = yew::Renderer::<clients::StoryTest>::with_root(app_element).render();
} else { } else {
yew::Renderer::<ContextProvider<ClientContext>>::with_root_and_props( yew::Renderer::<ContextProvider<ClientContext>>::with_root_and_props(
app_element, app_element,
ContextProviderProps { ContextProviderProps {
context: ClientContext { context: ClientContext {
error_cb: error_callback.clone(), error_cb: error_callback.clone(),
forced_identity: None,
}, },
children: html! { children: html! {
<Client2 auto_join=false/> <Client2 auto_join=false/>

View File

@ -114,7 +114,7 @@ fn FalselyAppearsAs(
<div class="bottom-bound"> <div class="bottom-bound">
<h5> <h5>
{"ROLES THAT FALSELY APPEAR AS "} {"ROLES THAT FALSELY APPEAR AS "}
<span class="yellow">{alignment_text}</span> <span class="yellow">{*alignment_text}</span>
</h5> </h5>
<div class="false-positives yellow"> <div class="false-positives yellow">
{false_positives} {false_positives}