Compare commits

..

No commits in common. "f3f4c43e81b821cb8eb074ccced9377838e24a98" and "01c61c143e3b90483b8e6a07c84d8c035917f10d" have entirely different histories.

33 changed files with 1208 additions and 3252 deletions

366
Cargo.lock generated
View File

@ -26,6 +26,12 @@ 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"
@ -154,6 +160,12 @@ 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"
@ -243,18 +255,18 @@ dependencies = [
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.9.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190" checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.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 = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@ -495,23 +507,74 @@ 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", "gloo-console 0.3.0",
"gloo-dialogs", "gloo-dialogs 0.2.0",
"gloo-events", "gloo-events 0.2.0",
"gloo-file", "gloo-file 0.3.0",
"gloo-history", "gloo-history 0.2.2",
"gloo-net", "gloo-net 0.5.0",
"gloo-render", "gloo-render 0.2.0",
"gloo-storage", "gloo-storage 0.3.0",
"gloo-timers", "gloo-timers 0.3.0",
"gloo-utils", "gloo-utils 0.2.0",
"gloo-worker", "gloo-worker 0.5.0",
]
[[package]]
name = "gloo-console"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
@ -520,13 +583,23 @@ 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", "gloo-utils 0.2.0",
"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"
@ -537,6 +610,16 @@ 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"
@ -547,6 +630,18 @@ 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"
@ -554,12 +649,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"gloo-events", "gloo-events 0.2.0",
"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"
@ -567,16 +678,58 @@ 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", "gloo-events 0.2.0",
"gloo-utils", "gloo-utils 0.2.0",
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen 0.6.5",
"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"
@ -586,7 +739,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"gloo-utils", "gloo-utils 0.2.0",
"http 0.2.12", "http 0.2.12",
"js-sys", "js-sys",
"pin-project", "pin-project",
@ -598,6 +751,16 @@ 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"
@ -610,11 +773,11 @@ dependencies = [
[[package]] [[package]]
name = "gloo-storage" name = "gloo-storage"
version = "0.3.0" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"
dependencies = [ dependencies = [
"gloo-utils", "gloo-utils 0.1.7",
"js-sys", "js-sys",
"serde", "serde",
"serde_json", "serde_json",
@ -623,6 +786,31 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-storage"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a"
dependencies = [
"gloo-utils 0.2.0",
"js-sys",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@ -635,6 +823,19 @@ 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"
@ -648,6 +849,42 @@ 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"
@ -656,7 +893,7 @@ checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d"
dependencies = [ dependencies = [
"bincode", "bincode",
"futures", "futures",
"gloo-utils", "gloo-utils 0.2.0",
"gloo-worker-macros", "gloo-worker-macros",
"js-sys", "js-sys",
"pinned", "pinned",
@ -953,9 +1190,9 @@ dependencies = [
[[package]] [[package]]
name = "implicit-clone" name = "implicit-clone"
version = "0.6.0" version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1689b939ee35e3a075b0834b5672efd43aec8a6e81a1c6002b76a5ca2f211ae0" checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84"
dependencies = [ dependencies = [
"implicit-clone-derive", "implicit-clone-derive",
"indexmap", "indexmap",
@ -1274,6 +1511,23 @@ 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"
@ -1402,6 +1656,17 @@ 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"
@ -1671,23 +1936,6 @@ 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"
@ -1961,10 +2209,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"ciborium", "ciborium",
"convert_case 0.10.0", "convert_case 0.8.0",
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"gloo", "gloo 0.11.0",
"instant", "instant",
"log", "log",
"once_cell", "once_cell",
@ -2289,22 +2537,22 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]] [[package]]
name = "yew" name = "yew"
version = "0.22.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3346273ed61b636f5d84e6c696d40f380045b5565b36c5c47f8fc634b8bf5be6" checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures", "futures",
"gloo", "gloo 0.10.0",
"implicit-clone", "implicit-clone",
"indexmap", "indexmap",
"js-sys", "js-sys",
"prokio",
"rustversion", "rustversion",
"serde", "serde",
"slab", "slab",
"thiserror 2.0.17", "thiserror 1.0.69",
"tokio", "tokio",
"tokise",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@ -2314,26 +2562,26 @@ dependencies = [
[[package]] [[package]]
name = "yew-macro" name = "yew-macro"
version = "0.22.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479e94d645dde3749e81d488c1d32987509dd3b8c31650fcf6e3af1f370e913b" checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2"
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.19.0" version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "415cb628900ddf1eaf55ebd04163adf1ea80d3f5a9832a876554f9c0fdd4c282" checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6"
dependencies = [ dependencies = [
"gloo", "gloo 0.10.0",
"js-sys", "js-sys",
"route-recognizer", "route-recognizer",
"serde", "serde",
@ -2348,9 +2596,9 @@ dependencies = [
[[package]] [[package]]
name = "yew-router-macro" name = "yew-router-macro"
version = "0.19.0" version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e87a3ce33434ab66a700edbaf2cc8a417d9b89f00a6fd8216fd6ac83b0e7b1c" checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@ -32,7 +32,6 @@ use crate::{
Alignment, HunterMut, HunterRef, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Alignment, HunterMut, HunterRef, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT,
Powerful, PreviousGuardianAction, Role, RoleTitle, Powerful, PreviousGuardianAction, Role, RoleTitle,
}, },
team::Team,
}; };
type Result<T> = core::result::Result<T, GameError>; type Result<T> = core::result::Result<T, GameError>;
@ -243,16 +242,6 @@ impl Character {
!self.is_wolf() !self.is_wolf()
} }
pub fn team(&self) -> Team {
if let Alignment::Traitor = self.alignment() {
return Team::AnyEvil;
}
if self.is_wolf() {
return Team::Wolves;
}
Team::Village
}
pub const fn known_elder(&self) -> bool { pub const fn known_elder(&self) -> bool {
matches!( matches!(
self.role, self.role,
@ -611,15 +600,8 @@ impl Character {
}) })
} }
Role::MasonLeader { .. } => { Role::MasonLeader { .. } => {
log::debug!( log::error!(
"night_action_prompts got to MasonLeader; "night_action_prompts got to MasonLeader, should be handled before the living check"
mason leader alive: {}; night: {night}; current prompt titles: {}",
self.alive(),
prompts
.iter()
.map(|p| p.title().to_string())
.collect::<Vec<_>>()
.join(", ")
); );
} }
Role::Empath { cursed: false } => prompts.push(ActionPrompt::Empath { Role::Empath { cursed: false } => prompts.push(ActionPrompt::Empath {

View File

@ -78,30 +78,6 @@ pub enum DiedTo {
} }
impl DiedTo { impl DiedTo {
pub const fn day(&self) -> Option<NonZeroU8> {
match self {
DiedTo::Execution { day } => Some(*day),
_ => None,
}
}
pub const fn night(&self) -> Option<u8> {
Some(match self {
DiedTo::Execution { .. } => return None,
DiedTo::MapleWolf { night, .. }
| DiedTo::MapleWolfStarved { night }
| DiedTo::Militia { night, .. }
| DiedTo::Wolfpack { night, .. }
| DiedTo::AlphaWolf { night, .. }
| DiedTo::Shapeshift { night, .. }
| DiedTo::Hunter { night, .. }
| DiedTo::GuardianProtecting { night, .. }
| DiedTo::PyreMasterLynchMob { night, .. }
| DiedTo::PyreMaster { night, .. } => night.get(),
DiedTo::LoneWolf { night, .. } | DiedTo::MasonLeaderRecruitFail { night, .. } => *night,
})
}
pub fn next_night(&self) -> Option<DiedTo> { pub fn next_night(&self) -> Option<DiedTo> {
let mut next = self.clone(); let mut next = self.clone();
match &mut next { match &mut next {

View File

@ -19,7 +19,6 @@ 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},
@ -192,7 +191,6 @@ impl Game {
GameActions::NightDetails(NightDetails::new( GameActions::NightDetails(NightDetails::new(
&night.used_actions(), &night.used_actions(),
recorded_changes, recorded_changes,
self.village(),
)), )),
)?; )?;
self.state = GameState::Day { self.state = GameState::Day {
@ -381,35 +379,6 @@ 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

@ -45,17 +45,13 @@ pub struct NightDetails {
} }
impl NightDetails { impl NightDetails {
pub fn new( pub fn new(choices: &[(ActionPrompt, ActionResult)], changes: Box<[NightChange]>) -> Self {
choices: &[(ActionPrompt, ActionResult)],
changes: Box<[NightChange]>,
village: &Village,
) -> Self {
Self { Self {
changes, changes,
choices: choices choices: choices
.iter() .iter()
.cloned() .cloned()
.filter_map(|(prompt, result)| NightChoice::new(prompt, result, village)) .filter_map(|(prompt, result)| NightChoice::new(prompt, result))
.collect(), .collect(),
} }
} }
@ -68,9 +64,9 @@ pub struct NightChoice {
} }
impl NightChoice { impl NightChoice {
pub fn new(prompt: ActionPrompt, result: ActionResult, village: &Village) -> Option<Self> { pub fn new(prompt: ActionPrompt, result: ActionResult) -> Option<Self> {
Some(Self { Some(Self {
prompt: StoryActionPrompt::new(prompt, village)?, prompt: StoryActionPrompt::new(prompt)?,
result: StoryActionResult::new(result), result: StoryActionResult::new(result),
}) })
} }
@ -189,7 +185,6 @@ pub enum StoryActionPrompt {
chosen: CharacterId, chosen: CharacterId,
}, },
WolfPackKill { WolfPackKill {
killing_wolf: CharacterId,
chosen: CharacterId, chosen: CharacterId,
}, },
Shapeshifter { Shapeshifter {
@ -214,17 +209,12 @@ 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, village: &Village) -> Option<Self> { pub fn new(prompt: ActionPrompt) -> Option<Self> {
Some(match prompt { Some(match prompt {
ActionPrompt::BeholderWakes { character_id } => Self::BeholderWakes { ActionPrompt::BeholderWakes { .. } => return None, // TODO: rework story anyway
character_id: character_id.character_id,
},
ActionPrompt::Bloodletter { ActionPrompt::Bloodletter {
character_id, character_id,
marked: Some(marked), marked: Some(marked),
@ -375,10 +365,7 @@ impl StoryActionPrompt {
ActionPrompt::WolfPackKill { ActionPrompt::WolfPackKill {
marked: Some(marked), marked: Some(marked),
.. ..
} => Self::WolfPackKill { } => Self::WolfPackKill { chosen: marked },
chosen: marked,
killing_wolf: village.killing_wolf().map(|c| c.character_id())?,
},
ActionPrompt::Shapeshifter { character_id } => Self::Shapeshifter { ActionPrompt::Shapeshifter { character_id } => Self::Shapeshifter {
character_id: character_id.character_id, character_id: character_id.character_id,
}, },
@ -438,39 +425,6 @@ impl StoryActionPrompt {
| ActionPrompt::CoverOfDarkness => return None, | ActionPrompt::CoverOfDarkness => return None,
}) })
} }
pub const fn character_id(&self) -> Option<CharacterId> {
match self {
StoryActionPrompt::MasonsWake { .. } => None,
StoryActionPrompt::WolfPackKill {
killing_wolf: character_id,
..
}
| 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,16 +13,14 @@ 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.22", features = ["csr"] } yew = { version = "0.21", features = ["csr"] }
yew-router = "0.19" yew-router = "0.18"
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"
@ -35,7 +33,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.10" } convert_case = { version = "0.8" }
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: 160px; width: 100%;
height: 75px; height: 100%;
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;
@ -784,34 +784,6 @@ clients {
display: flex; display: flex;
flex-basis: content; flex-basis: content;
} }
.story {
padding-bottom: 100px;
}
}
@media only screen and (max-width : 799px) {
.story-characters {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 3px;
row-gap: 5px;
justify-content: space-between;
overflow-x: scroll;
padding-bottom: 20px;
}
}
@media only screen and (min-width : 800px) {
.story-characters {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 3px;
row-gap: 5px;
justify-content: space-between;
}
} }
@media only screen and (min-width : 1900px) { @media only screen and (min-width : 1900px) {
@ -1005,30 +977,17 @@ 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;
font-size: 1em; border: 2px solid rgba(255, 255, 255, 0.2);
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;
@ -1349,7 +1308,7 @@ select {
background-color: $village_color; background-color: $village_color;
border: 1px solid $village_border; border: 1px solid $village_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $village_border; background-color: $village_border;
} }
@ -1358,7 +1317,7 @@ select {
border: 1px solid $village_border_faint; border: 1px solid $village_border_faint;
background-color: $village_color_faint; background-color: $village_color_faint;
&.hover:hover { &:hover {
background-color: $village_border_faint; background-color: $village_border_faint;
} }
} }
@ -1373,7 +1332,7 @@ select {
background-color: $wolves_color; background-color: $wolves_color;
border: 1px solid $wolves_border; border: 1px solid $wolves_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $wolves_border; background-color: $wolves_border;
} }
@ -1382,7 +1341,7 @@ select {
border: 1px solid $wolves_border_faint; border: 1px solid $wolves_border_faint;
background-color: $wolves_color_faint; background-color: $wolves_color_faint;
&.hover:hover { &:hover {
background-color: $wolves_border_faint; background-color: $wolves_border_faint;
} }
} }
@ -1397,7 +1356,7 @@ select {
background-color: $intel_color; background-color: $intel_color;
border: 1px solid $intel_border; border: 1px solid $intel_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $intel_border; background-color: $intel_border;
} }
@ -1406,7 +1365,7 @@ select {
border: 1px solid $intel_border_faint; border: 1px solid $intel_border_faint;
background-color: $intel_color_faint; background-color: $intel_color_faint;
&.hover:hover { &:hover {
background-color: $intel_border_faint; background-color: $intel_border_faint;
} }
} }
@ -1421,7 +1380,7 @@ select {
background-color: $defensive_color; background-color: $defensive_color;
border: 1px solid $defensive_border; border: 1px solid $defensive_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $defensive_border; background-color: $defensive_border;
} }
@ -1430,7 +1389,7 @@ select {
border: 1px solid $defensive_border_faint; border: 1px solid $defensive_border_faint;
background-color: $defensive_color_faint; background-color: $defensive_color_faint;
&.hover:hover { &:hover {
background-color: $defensive_border_faint; background-color: $defensive_border_faint;
} }
} }
@ -1445,7 +1404,7 @@ select {
background-color: $offensive_color; background-color: $offensive_color;
border: 1px solid $offensive_border; border: 1px solid $offensive_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $offensive_border; background-color: $offensive_border;
} }
@ -1454,7 +1413,7 @@ select {
border: 1px solid $offensive_border_faint; border: 1px solid $offensive_border_faint;
background-color: $offensive_color_faint; background-color: $offensive_color_faint;
&.hover:hover { &:hover {
background-color: $offensive_border_faint; background-color: $offensive_border_faint;
} }
} }
@ -1469,7 +1428,7 @@ select {
background-color: $starts_as_villager_color; background-color: $starts_as_villager_color;
border: 1px solid $starts_as_villager_border; border: 1px solid $starts_as_villager_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $starts_as_villager_border; background-color: $starts_as_villager_border;
} }
@ -1478,7 +1437,7 @@ select {
border: 1px solid $starts_as_villager_border_faint; border: 1px solid $starts_as_villager_border_faint;
background-color: $starts_as_villager_color_faint; background-color: $starts_as_villager_color_faint;
&.hover:hover { &:hover {
background-color: $starts_as_villager_border_faint; background-color: $starts_as_villager_border_faint;
} }
} }
@ -1493,7 +1452,7 @@ select {
background-color: $traitor_color; background-color: $traitor_color;
border: 1px solid $traitor_border; border: 1px solid $traitor_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $traitor_border; background-color: $traitor_border;
} }
@ -1502,7 +1461,7 @@ select {
border: 1px solid $traitor_border_faint; border: 1px solid $traitor_border_faint;
background-color: $traitor_color_faint; background-color: $traitor_color_faint;
&.hover:hover { &:hover {
background-color: $traitor_border_faint; background-color: $traitor_border_faint;
} }
} }
@ -1512,7 +1471,7 @@ select {
background-color: $drunk_color; background-color: $drunk_color;
border: 1px solid $drunk_border; border: 1px solid $drunk_border;
&.hover:hover { &:hover {
color: white; color: white;
background-color: $drunk_border; background-color: $drunk_border;
} }
@ -1521,7 +1480,7 @@ select {
border: 1px solid $drunk_border_faint; border: 1px solid $drunk_border_faint;
background-color: $drunk_color_faint; background-color: $drunk_color_faint;
&.hover:hover { &:hover {
background-color: $drunk_border_faint; background-color: $drunk_border_faint;
} }
} }
@ -1571,10 +1530,6 @@ select {
} }
.setup-screen { .setup-screen {
.inactive {
filter: brightness(0%);
}
margin-top: 2%; margin-top: 2%;
font-size: 1.5vw; font-size: 1.5vw;
@ -1725,6 +1680,11 @@ li.choice {
} }
} }
.inactive {
// filter: grayscale(100%) brightness(30%);
filter: brightness(0%);
}
.qrcode { .qrcode {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1839,52 +1799,25 @@ li.choice {
} }
.signin { .signin {
display: flex; @extend .row-list;
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 { & input {
display: flex; height: 2rem;
flex-direction: column; text-align: center;
flex-wrap: nowrap;
align-items: center;
gap: 3px;
// justify-content: center;
// text-align: center;
.field { #number {
display: flex; font-size: 2rem;
flex-direction: column; max-width: 50vw;
flex-wrap: nowrap;
align-items: center;
width: 100%;
}
& label {
font-size: 1.25em;
}
& input {
height: 2em;
// max-width: 80%;
width: 70%;
&#number {
text-align: center;
// font-size: 2rem;
// width: 20%;
width: 3ch;
}
}
&>button {
margin-top: 7px;
} }
} }
} }
@ -1899,132 +1832,116 @@ 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;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: baseline;
align-content: baseline; align-content: baseline;
justify-content: baseline;
justify-items: baseline; justify-items: baseline;
gap: 1ch;
padding-top: 2px; padding-top: 5px;
padding-bottom: 2px; padding-bottom: 5px;
padding-left: 2px; padding-left: 5px;
padding-right: 2px; padding-right: 10px;
.inactive { &:has(.killer) {
filter: grayscale(100%); border: 1px solid rgba(212, 85, 0, 0.5);
border: none;
} }
// img { &:has(.powerful) {
// vertical-align: sub; border: 1px solid rgba(0, 173, 193, 0.5);
// } }
&:has(.inactive) {
border: 1px solid rgba(255, 255, 255, 0.3);
}
img {
vertical-align: sub;
}
&.execution {
border: 1px solid rgba(255, 255, 255, 0.3);
}
} }
.alignment-eq, .alignment-eq {
.roleblock-span { img {
display: flex; vertical-align: sub;
flex-direction: row;
flex-wrap: wrap;
gap: 1ch;
align-items: center;
}
.highlight-span {
height: max-content;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 5px;
word-wrap: normal;
.name {
color: white;
} }
.dead { border: 1px solid $intel_border;
text-decoration: line-through; background-color: color.change($intel_color, $alpha: 0.1);
font-style: italic; padding-top: 5px;
} padding-bottom: 5px;
&:hover::after { padding-left: 10px;
content: attr(role); padding-right: 10px;
overflow-y: hidden;
position: absolute;
margin-top: 60px;
color: white;
background-color: black;
border: 1px solid white;
padding: 3px;
z-index: 4;
align-self: center;
justify-self: center;
}
} }
.character-span { .character-span {
@ -2033,12 +1950,7 @@ li.choice {
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
gap: 1ch; gap: 5px;
.dead {
text-decoration: line-through;
font-style: italic;
}
.number { .number {
color: rgba(255, 255, 0, 0.7); color: rgba(255, 255, 0, 0.7);
@ -2297,6 +2209,13 @@ 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);
@ -2451,7 +2370,6 @@ 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;
@ -2477,22 +2395,6 @@ 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 {
@ -2746,221 +2648,3 @@ dialog::backdrop {
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 5px; gap: 5px;
} }
.tabs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
// gap: 3px;
margin: 0;
align-self: flex-start;
width: 100%;
.tab-button:not(.selected) {
flex-grow: 1;
// color: white;
cursor: pointer;
}
.tab-button {
color: white;
flex-grow: 1;
cursor: pointer;
text-shadow: 2px 2px black;
}
}
.story {
display: flex;
flex-direction: row;
flex-wrap: wrap;
// width: 100vw;
justify-content: space-evenly;
row-gap: 5px;
margin: 5vh 5vw 0px 5vw;
.character-headline {
display: flex;
flex-direction: row;
gap: 3px;
align-items: center;
cursor: pointer;
.icon-spacer {
height: 32px;
width: 32px;
}
padding: 0.2em 1em 0.2em 1em;
min-width: 5cm;
.identity {
text-align: center;
flex-grow: 1;
}
}
.actions {
width: 100%;
// min-height: 30vh;
display: flex;
flex-direction: column;
}
.no-content {
filter: grayscale(60%);
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: default;
// backdrop-filter: brightness(20%);
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
.sub-headline {
display: none;
// margin-left: 0.5cm;
// font-size: 1.5em;
// font-style: italic;
}
.action,
.change {
font-size: 1.25em;
display: flex;
flex-direction: row;
gap: 1ch;
flex-wrap: wrap;
align-items: center;
}
.character-details {
display: flex;
flex-grow: 1;
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: 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%;
}
}
}
.wolves-highlight {
color: $wolves_color;
.number {
color: $wolves_border;
}
}
.village-highlight {
color: $village_color;
.number {
color: $village_border;
}
}
.intel-highlight {
color: $intel_color;
.number {
color: $intel_border;
}
}
.defensive-highlight {
color: $defensive_color;
.number {
color: $defensive_border;
}
}
.offensive-highlight {
color: $offensive_color;
.number {
color: $offensive_border;
}
}
.starts-as-villager-highlight {
color: $starts_as_villager_color;
.number {
color: $starts_as_villager_border;
}
}
.traitor-highlight {
color: $traitor_color;
.number {
color: $traitor_color;
}
}

View File

@ -1,4 +1,4 @@
// Copyright (C) 2026 Emilis Bliūdžius // Copyright (C) 2025 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as // it under the terms of the GNU Affero General Public License as
@ -12,22 +12,6 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::{character::Character, game::SetupRole, team::Team};
pub trait Class { pub trait Class {
fn class(&self) -> Option<&'static str>; fn class(&self) -> Option<&'static str>;
} }
impl Class for Character {
fn class(&self) -> Option<&'static str> {
if let Team::AnyEvil = self.team() {
return Some("traitor");
}
Some(
Into::<SetupRole>::into(self.role_title())
.category()
.class(),
)
}
}

View File

@ -29,9 +29,8 @@ use yew::prelude::*;
use crate::{ use crate::{
clients::client::connection::{Connection2, ConnectionError}, clients::client::connection::{Connection2, ConnectionError},
components::{ components::{
Button, CoverOfDarkness, Footer, Identity, Button, CoverOfDarkness, Footer, Identity, Story,
client::{ClientNav, Signin}, client::{ClientNav, Signin},
story::Story,
}, },
storage::StorageKey, storage::StorageKey,
}; };
@ -40,7 +39,6 @@ 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),
@ -56,6 +54,7 @@ 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);
@ -74,8 +73,6 @@ 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 || {
@ -96,67 +93,45 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
} }
let client_state = use_state(|| ClientEvent2::Connecting); let client_state = use_state(|| ClientEvent2::Connecting);
let ClientContext { error_cb } = use_context::<ClientContext>().unwrap_or_default(); let ClientContext {
// let force = use_force_update(); error_cb,
let (send, recv) = yew::platform::pinned::mpsc::unbounded(); forced_identity,
let send = use_state(|| send); } = use_context::<ClientContext>().unwrap_or_default();
let recv = use_mut_ref(|| recv); let force = use_force_update();
let connection =
use_mut_ref(|| Connection2::new(client_state.setter(), ident_state.clone(), recv));
let on_signin = { let ident = if let Some(Identification { player_id, public }) = forced_identity {
let current_ident = ident_state.setter(); (player_id, public)
let client_state = client_state.setter();
Callback::from(move |ident: PublicIdentity| {
let pid = PlayerId::new();
pid.save_to_storage().expect("saving player id");
ident.save_to_storage().expect("saving ident");
current_ident.set(Some((pid, ident)));
client_state.set(ClientEvent2::Connecting);
})
};
if let ClientEvent2::Signin = &*client_state {
return html! {
<Signin callback={on_signin} />
};
}
let ident = if let Some(current_ident) = ident_state.as_ref() {
current_ident.clone()
} else { } else {
match PlayerId::load_from_storage() match PlayerId::load_from_storage()
.and_then(|pid| PublicIdentity::load_from_storage().map(|ident| (pid, ident))) .and_then(|pid| PublicIdentity::load_from_storage().map(|ident| (pid, ident)))
{ {
Ok((pid, ident)) => { Ok((pid, ident)) => (pid, ident),
ident_state.set(Some((pid, ident.clone())));
(pid, ident)
}
Err(StorageError::KeyNotFound(_)) => { Err(StorageError::KeyNotFound(_)) => {
client_state.set(ClientEvent2::Signin); let on_signin = Callback::from(move |ident: PublicIdentity| {
PlayerId::new().save_to_storage().expect("saving player id");
ident.save_to_storage().expect("saving ident");
force.force_update();
});
return html! { return html! {
// <Signin callback={on_signin}/> <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()}/>
}, },
@ -270,7 +245,7 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
} }
}; };
html! { html! {
<ClientNav identity={ident_state.clone()} message_callback={client_nav_msg_cb} /> <ClientNav identity={ident.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<Option<(PlayerId, PublicIdentity)>>, ident: UseStateHandle<(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<Option<(PlayerId, PublicIdentity)>>, ident: UseStateHandle<(PlayerId, PublicIdentity)>,
receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>, receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
) -> Self { ) -> Self {
Self { Self {
@ -64,19 +64,9 @@ impl Connection2 {
} }
} }
fn identification(&self) -> Identification { fn identification(&self) -> Identification {
match self.ident.as_ref() { Identification {
Some(ident) => Identification { player_id: self.ident.0,
player_id: ident.0, public: self.ident.1.clone(),
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 {
@ -118,10 +108,7 @@ 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 Ok(active_borrow) = active.try_borrow_mut() else { let active_borrow = active.borrow_mut();
log::warn!("active connection already borrowed; exiting");
return;
};
conn.run().await; conn.run().await;
core::mem::drop(active_borrow); core::mem::drop(active_borrow);
}); });
@ -287,11 +274,9 @@ impl Connection2 {
return None; return None;
} }
ServerMessage::Update(PlayerUpdate::Number(new_num)) => { ServerMessage::Update(PlayerUpdate::Number(new_num)) => {
let Some((pid, mut ident)) = (*self.ident).clone() else { let (pid, mut ident) = (*self.ident).clone();
return None;
};
ident.number = Some(new_num); ident.number = Some(new_num);
self.ident.set(Some((pid, ident))); self.ident.set((pid, ident));
return None; return None;
} }
ServerMessage::GameInProgress => ClientEvent2::GameInProgress, ServerMessage::GameInProgress => ClientEvent2::GameInProgress,

View File

@ -42,11 +42,10 @@ use yew::{html::Scope, prelude::*};
use crate::{ use crate::{
callback, callback,
components::{ components::{
Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Victory, Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Story, 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,11 +26,3 @@ 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

@ -1,4 +1,4 @@
// Copyright (C) 2026 Emilis Bliūdžius // Copyright (C) 2025 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as // it under the terms of the GNU Affero General Public License as
@ -29,13 +29,15 @@ pub struct AlignmentSpanProps {
#[function_component] #[function_component]
pub fn AlignmentSpan(AlignmentSpanProps { alignment }: &AlignmentSpanProps) -> Html { pub fn AlignmentSpan(AlignmentSpanProps { alignment }: &AlignmentSpanProps) -> Html {
let class = match alignment { let class = match alignment {
role::Alignment::Village => "village-highlight", role::Alignment::Village => "village",
role::Alignment::Wolves => "wolves-highlight", role::Alignment::Wolves => "wolves",
role::Alignment::Traitor => "traitor-highlight", role::Alignment::Traitor => "traitor",
}; };
html! { html! {
<span class={classes!("attribute-span", class)}> <span class={classes!("attribute-span", "faint", class)}>
<Icon source={alignment.icon()} icon_type={IconType::Fit}/> <div>
<Icon source={alignment.icon()} icon_type={IconType::Small}/>
</div>
{alignment.to_string()} {alignment.to_string()}
</span> </span>
} }

View File

@ -29,14 +29,20 @@ pub fn AlignmentComparisonSpan(
match comparison { match comparison {
AlignmentEq::Same => html! { AlignmentEq::Same => html! {
<span class="alignment-eq"> <span class="alignment-eq">
<Icon source={IconSource::Equal} icon_type={IconType::Fit}/> <div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div>
<div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div>
{"the same"} {"the same"}
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
</span> </span>
}, },
AlignmentEq::Different => html! { AlignmentEq::Different => html! {
<span class="alignment-eq"> <span class="alignment-eq">
<Icon source={IconSource::NotEqual} icon_type={IconType::Fit}/> <div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div>
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
{"different"} {"different"}
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
<div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div>
</span> </span>
}, },
} }

View File

@ -25,10 +25,29 @@ pub struct DiedToSpanProps {
#[function_component] #[function_component]
pub fn DiedToSpan(DiedToSpanProps { died_to }: &DiedToSpanProps) -> Html { pub fn DiedToSpan(DiedToSpanProps { died_to }: &DiedToSpanProps) -> Html {
let class = match died_to {
DiedToTitle::Execution => "execution",
DiedToTitle::MapleWolfStarved | DiedToTitle::MapleWolf => {
SetupRoleTitle::MapleWolf.category().class()
}
DiedToTitle::Militia => SetupRoleTitle::Militia.category().class(),
DiedToTitle::LoneWolf
| DiedToTitle::AlphaWolf
| DiedToTitle::Shapeshift
| DiedToTitle::Wolfpack => SetupRoleTitle::Werewolf.category().class(),
DiedToTitle::Hunter => SetupRoleTitle::Hunter.category().class(),
DiedToTitle::GuardianProtecting => SetupRoleTitle::Guardian.category().class(),
DiedToTitle::PyreMasterLynchMob | DiedToTitle::PyreMaster => {
SetupRoleTitle::PyreMaster.category().class()
}
DiedToTitle::MasonLeaderRecruitFail => SetupRoleTitle::MasonLeader.category().class(),
};
let icon = died_to.icon().unwrap_or(IconSource::Skull); let icon = died_to.icon().unwrap_or(IconSource::Skull);
html! { html! {
<span class={classes!("attribute-span",)}> <span class={classes!("attribute-span", "faint", class)}>
<Icon source={icon} icon_type={IconType::Fit}/> <div>
<Icon source={icon} icon_type={IconType::Small}/>
</div>
{died_to.to_string().to_case(Case::Title)} {died_to.to_string().to_case(Case::Title)}
</span> </span>
} }

View File

@ -29,12 +29,10 @@ pub fn KillerSpan(KillerSpanProps { killer }: &KillerSpanProps) -> Html {
Killer::NotKiller => "inactive", Killer::NotKiller => "inactive",
}; };
html! { html! {
<span class={classes!("attribute-span")}> <span class={classes!("attribute-span", "faint")}>
<Icon <div class={classes!(class)}>
source={killer.icon()} <Icon source={killer.icon()} icon_type={IconType::Small}/>
icon_type={IconType::Fit} </div>
classes={classes!(class)}
/>
{killer.to_string()} {killer.to_string()}
</span> </span>
} }

View File

@ -29,12 +29,10 @@ pub fn PowerfulSpan(PowerfulSpanProps { powerful }: &PowerfulSpanProps) -> Html
Powerful::NotPowerful => "inactive", Powerful::NotPowerful => "inactive",
}; };
html! { html! {
<span class={classes!("attribute-span")}> <span class={classes!("attribute-span", "faint")}>
<Icon <div class={classes!(class)}>
source={powerful.icon()} <Icon source={powerful.icon()} icon_type={IconType::Small}/>
icon_type={IconType::Fit} </div>
classes={classes!(class)}
/>
{powerful.to_string()} {powerful.to_string()}
</span> </span>
} }

View File

@ -1,5 +1,3 @@
use core::ops::Not;
// Copyright (C) 2025 Emilis Bliūdžius // Copyright (C) 2025 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
@ -15,17 +13,10 @@ use core::ops::Not;
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use werewolves_proto::{ use werewolves_proto::{character::Character, game::SetupRole, message::CharacterIdentity};
character::Character,
game::{GameTime, SetupRole},
message::CharacterIdentity,
};
use yew::prelude::*; use yew::prelude::*;
use crate::{ use crate::components::{Icon, IconSource, IconType, PartialAssociatedIcon};
class::Class,
components::{Icon, IconSource, IconType, PartialAssociatedIcon},
};
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterCardProps { pub struct CharacterCardProps {
@ -98,47 +89,3 @@ pub fn CharacterTargetCard(
</span> </span>
} }
} }
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterHighlightProps {
pub char: Character,
pub time: GameTime,
}
#[function_component]
pub fn CharacterHighlight(
CharacterHighlightProps { char, time }: &CharacterHighlightProps,
) -> Html {
let class = char.class().map(|c| format!("{c}-highlight"));
let icon = char
.role_title()
.icon()
.map(|source| {
html! {
<Icon source={source} icon_type={IconType::Fit}/>
}
})
.unwrap_or(html! {
<div class="icon-spacer"/>
});
let dead = char.died_to().and_then(|died_to| {
let night = died_to.night();
let day = died_to.day();
match (time, day, night) {
(GameTime::Day { number }, Some(day), None) => number.get() > day.get(),
(GameTime::Night { number }, None, Some(night)) => *number > night,
(GameTime::Day { number }, None, Some(night)) => number.get() > night,
(GameTime::Night { number }, Some(day), None) => *number >= day.get(),
(_, None, None) | (_, Some(_), Some(_)) => true,
}
.then_some("dead")
});
let role_text = char.role_title().to_string().to_case(Case::Title);
html! {
<span class={classes!("highlight-span", class)} role={role_text}>
{icon}
<span class={classes!("number")}><b>{char.number().get()}</b></span>
<span class={classes!("name", dead)}>{char.name()}</span>
</span>
}
}

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<Option<(PlayerId, PublicIdentity)>>, pub identity: UseStateHandle<(PlayerId, PublicIdentity)>,
pub message_callback: Callback<ClientMessage>, pub message_callback: Callback<ClientMessage>,
} }
@ -38,19 +38,18 @@ 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()
.and_then(|identity| { .map(|pronouns| {
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! {
<span class="faint">{"(None)"}</span> <div>{"(None)"}</div>
} }
}); });
@ -63,13 +62,10 @@ pub fn ClientNav(
let submit_ident = identity.clone(); let submit_ident = identity.clone();
let current_num = identity let current_num = identity
.as_ref() .1
.and_then(|identity| identity.1.number.map(|v| html! {{v.to_string()}})) .number
.unwrap_or_else(|| { .map(|v| v.to_string())
html! { .unwrap_or_else(|| String::from("???"));
<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();
@ -78,16 +74,13 @@ 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_ref.1.name.clone(), name: submit_ident.1.name.clone(),
pronouns: submit_ident_ref.1.pronouns.clone(), pronouns: submit_ident.1.pronouns.clone(),
number: Some(num), number: Some(num),
}; };
submit_ident.set(Some((submit_ident_ref.0, new_ident.clone()))); submit_ident.set((submit_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}");
} }
@ -121,7 +114,6 @@ 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
@ -142,12 +134,6 @@ 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()}
@ -158,7 +144,7 @@ pub fn ClientNav(
on_open={close_others} on_open={close_others}
label={String::from("name")} label={String::from("name")}
> >
<div class="name">{name_str}</div> <div class="name">{identity.1.name.as_str()}</div>
</ClickableTextEdit> </ClickableTextEdit>
} }
}; };
@ -168,18 +154,15 @@ 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(),
))); )));
pronuns_state.set(String::new()); Some(PublicIdentity {
ident.as_ref().map(|id| { pronouns,
let mut public = id.1.clone(); name: ident.1.name.clone(),
public.pronouns = pronouns; number: ident.1.number,
ident.set(Some((id.0, public.clone())));
public
}) })
}) })
}; };
@ -213,10 +196,14 @@ 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! {
<> <>
<a href="/host"><Button on_click={Callback::noop()}>{"host"}</Button></a> <Button on_click={host_click}>{"host"}</Button>
<Button on_click={forgor}>{"forgor 💀"}</Button> <Button on_click={forgor}>{"forgor 💀"}</Button>
</> </>
} }
@ -236,7 +223,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<Option<(PlayerId, PublicIdentity)>>, pub submit_ident: UseStateHandle<(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>,
@ -270,10 +257,8 @@ 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()) {
&& let Some(ident) = submit_ident.as_ref() submit_ident.set((submit_ident.0, new_ident.clone()));
{
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,8 +23,6 @@ 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]
@ -62,34 +60,15 @@ 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={classes!("signin", full_height)}> <div class="signin full-height">
<div class="signin-box"> <div class="column-list">
<div class="field"> <label for="name">{"Name"}</label>
<label for="number">{"Seat Number"}</label> <input oninput={name_on_input} name="name" id="name" type="text"/>
<input <label for="pronouns">{"Pronouns"}</label>
oninput={on_change} <input oninput={pronouns_on_input} name="pronouns" id="pronouns" type="text"/>
type="text" <label for="number">{"Number"}</label>
name="number" <input oninput={on_change} type="text" name="number" id="number"/>
id="number"
// autocomplete="off"
/>
</div>
<div class="field">
<label for="name">{"Name"}</label>
<input
oninput={name_on_input}
name="name"
id="name"
type="text"
// autocomplete="name nickname username"
/>
</div>
<div class="field">
<label for="pronouns">{"Pronouns"}</label>
<input oninput={pronouns_on_input} name="pronouns" id="pronouns" type="text"/>
</div>
<Button on_click={on_click}>{"Submit"}</Button> <Button on_click={on_click}>{"Submit"}</Button>
</div> </div>
</div> </div>

View File

@ -109,25 +109,18 @@ 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()}>
@ -135,36 +128,3 @@ 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,10 +17,7 @@ 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::{ use crate::components::{Button, ClickableField, ClickableNumberEdit, Identity};
Button, ClickableField, ClickableNumberEdit, Identity, NumberEdit,
modal::{Dialog, SubmenuDialog},
};
#[derive(Debug, PartialEq, Properties)] #[derive(Debug, PartialEq, Properties)]
pub struct LobbyPlayerProps { pub struct LobbyPlayerProps {
@ -38,11 +35,7 @@ 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 = if player.connected { let class = player.connected.then_some("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| {
@ -72,22 +65,9 @@ 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}
@ -99,17 +79,8 @@ 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

@ -1,143 +0,0 @@
// 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

@ -1,106 +0,0 @@
// 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

@ -1,66 +0,0 @@
// 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 convert_case::{Case, Casing};
use werewolves_proto::{game::SetupRole, role::RoleTitle};
use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType, PartialAssociatedIcon};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct RoleSpanProps {
pub role: RoleTitle,
}
#[function_component]
pub fn RoleSpan(RoleSpanProps { role }: &RoleSpanProps) -> Html {
let class = Into::<SetupRole>::into(*role).category().class();
let icon = role
.icon()
.map(|icon| {
html! {
<Icon source={icon} icon_type={IconType::Small}/>
}
})
.unwrap_or(html! {<div class="icon-spacer"/>});
let role_name = role.to_string().to_case(Case::Title);
html! {
<span class={classes!("role-span", class, "faint")}>
{icon}
<span class="role-name">{role_name}</span>
</span>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct RoleblockProps {
#[prop_or_default]
pub children: Html,
}
#[function_component]
pub fn Roleblock(RoleblockProps { children }: &RoleblockProps) -> Html {
let content = if *children == html! {} {
html! {<span>{"Drunk"}</span>}
} else {
children.clone()
};
html! {
<span class={classes!("roleblock-span")}>
<Icon source={IconSource::Roleblock} icon_type={IconType::Fit}/>
{content}
</span>
}
}

View File

@ -30,7 +30,6 @@ 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},
}, },
}; };
@ -275,19 +274,17 @@ 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">
@ -298,16 +295,12 @@ pub fn Settings(
{clear_all_assignments} {clear_all_assignments}
{clear_bad_assigned} {clear_bad_assigned}
</div> </div>
<Dialog <ClickableField
id={ADD_PLAYER_DIALOG_ID.to_string()} options={add_player_opts}
button={html!{"add player"}} state={add_player_open}
close_button=false
> >
<div> {"add player"}
<h3>{"manually add a player"}</h3> </ClickableField>
<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

@ -0,0 +1,613 @@
// 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

@ -1,710 +0,0 @@
// 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 core::ops::Not;
use std::{collections::HashMap, rc::Rc};
use werewolves_proto::{
aura::AuraTitle,
character::{Character, CharacterId},
diedto::DiedTo,
game::{
GameTime, SetupRole,
night::changes::NightChange,
story::{NightChoice, StoryActionPrompt, StoryActionResult},
},
player::Protection,
role::{RoleBlock, RoleTitle},
};
use yew::prelude::*;
use crate::{
class::Class,
components::{
AuraSpan, Button, CharacterHighlight, Icon, IconSource, IconType, Identity, IdentitySpan,
PartialAssociatedIcon, RoleSpan, Roleblock,
attributes::{
AlignmentComparisonSpan, AlignmentSpan, DiedToSpan, KillerSpan, PowerfulSpan,
},
},
};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterStoryProps {
pub all_characters: Rc<[Character]>,
pub character: Character,
pub actions: Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
}
#[function_component]
pub fn CharacterStory(
CharacterStoryProps {
all_characters,
character,
actions,
}: &CharacterStoryProps,
) -> Html {
let class = character.class();
let mut by_time = actions
.iter()
.filter(|(_, changes, choices)| !(changes.is_empty() && choices.is_empty()))
.cloned()
.collect::<Box<[_]>>();
by_time.sort_by_key(|s| s.0);
let open_time = use_state(|| by_time.iter().next().map(|(t, _, _)| *t));
if let Some(current_open_time) = open_time.as_ref()
&& let Some(last) = by_time.last().map(|c| c.0)
{
if last < *current_open_time || !by_time.iter().any(|(t, _, _)| t == current_open_time) {
open_time.set(Some(last));
} else if let Some(first) = by_time.first().map(|c| c.0)
&& first > *current_open_time
{
open_time.set(Some(first));
}
}
let tabs = by_time
.into_iter()
.map(|(time, _, _)| {
let time_text = match time {
GameTime::Day { number } => format!("day {number}"),
GameTime::Night { number } => format!("night {number}"),
};
let on_click = {
let open_time = open_time.setter();
Callback::from(move |_| open_time.set(Some(time)))
};
let selected = open_time
.as_ref()
.map(|open| *open == time)
.unwrap_or_default();
let faint = selected.not().then_some("faint");
let selected = selected.then_some("selected");
html! {
<button
onclick={on_click}
class={classes!("tab-button", class, faint, selected, "hover")}
>
{time_text}
</button>
}
})
.collect::<Html>();
let story_content = open_time
.as_ref()
.and_then(|time| actions.iter().find(|(t, _, _)| t == time))
.map(|(time, changes, choices)| {
let choices = choices
.iter()
.map(|c| {
html! {
<Choice
all_characters={all_characters.clone()}
character={character.clone()}
choice={c.clone()}
all_choices_that_night={choices.clone()}
time={*time}
/>
}
})
.collect::<Html>();
let changes = changes
.iter()
.map(|c| {
html! {
<Change all_characters={all_characters.clone()} change={c.clone()} time={*time}/>
}
})
.collect::<Html>();
let changes = (changes == html! {}).not().then_some(html! {
<>
<span class="sub-headline">{"changes"}</span>
<div>
{changes}
</div>
</>
});
let choices = (choices == html! {}).not().then_some(html! {
<>
<span class="sub-headline">{"choices"}</span>
<div>
{choices}
</div>
</>
});
html! {
<>
{choices}
{changes}
</>
}
});
html! {
<div class="character-story">
<div class="tabs">
{tabs}
</div>
<div class="story-content">
{story_content}
</div>
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct ChoiceProps {
all_characters: Rc<[Character]>,
character: Character,
choice: NightChoice,
all_choices_that_night: Box<[NightChoice]>,
time: GameTime,
}
#[function_component]
fn Choice(
ChoiceProps {
all_characters,
character,
choice,
all_choices_that_night,
time,
}: &ChoiceProps,
) -> Html {
let generate_prompt = |chosen: CharacterId, wording: &'static str| -> Option<Html> {
all_characters
.iter()
.find(|c| c.character_id() == chosen)
.map(|chosen| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{wording}</span>
<CharacterHighlight
char={chosen.clone()}
time={*time}
/>
</>
}
})
};
let prompt = match &choice.prompt {
StoryActionPrompt::Guardian {
chosen, guarding, ..
} => generate_prompt(*chosen, if *guarding { "guarded" } else { "protected" }),
StoryActionPrompt::Seer { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::Protector { chosen, .. } => generate_prompt(*chosen, "protected"),
StoryActionPrompt::Arcanist {
chosen: (target1, target2),
..
} => all_characters
.iter()
.find(|c| c.character_id() == *target1)
.and_then(|t1| {
all_characters
.iter()
.find(|c| c.character_id() == *target2)
.map(|t2| (t1, t2))
})
.map(|(t1, t2)| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"checked"}</span>
<CharacterHighlight
char={t1.clone()}
time={*time}
/>
<span>{"and"}</span>
<CharacterHighlight
char={t2.clone()}
time={*time}
/>
</>
}
}),
StoryActionPrompt::Gravedigger { chosen, .. } => generate_prompt(*chosen, "dug"),
StoryActionPrompt::Hunter { chosen, .. } => generate_prompt(*chosen, "set a trap for"),
StoryActionPrompt::Militia { chosen, .. } => generate_prompt(*chosen, "shot"),
StoryActionPrompt::MapleWolf { chosen, .. } => generate_prompt(*chosen, "ate"),
StoryActionPrompt::Adjudicator { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::PowerSeer { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::Mortician { chosen, .. } => generate_prompt(*chosen, "examined"),
StoryActionPrompt::Beholder { chosen, .. } => generate_prompt(*chosen, "beheld"),
StoryActionPrompt::MasonLeaderRecruit { chosen, .. } => {
generate_prompt(*chosen, "attempted to recruit")
}
StoryActionPrompt::WolfPackKill { chosen, .. } => {
generate_prompt(*chosen, "took the wolfpack kill and attacked")
}
StoryActionPrompt::MasonsWake { leader, .. } => all_characters
.iter()
.find(|l| l.character_id() == *leader)
.map(|leader| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"woke with the masons of"}</span>
<CharacterHighlight
char={leader.clone()}
time={*time}
/>
</>
}
}),
StoryActionPrompt::Empath { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::Vindicator { chosen, .. } => generate_prompt(*chosen, "protected"),
StoryActionPrompt::PyreMaster { chosen, .. } => generate_prompt(*chosen, "burned"),
StoryActionPrompt::Shapeshifter { .. } => all_choices_that_night
.iter()
.find_map(|c| match c.prompt {
StoryActionPrompt::WolfPackKill { chosen, .. } => Some(chosen),
_ => None,
})
.and_then(|target| all_characters.iter().find(|c| c.character_id() == target))
.map(|target| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"chose to shift into"}</span>
<CharacterHighlight
char={target.clone()}
time={*time}
/>
</>
}
}),
StoryActionPrompt::Insomniac { .. } => Some(html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"woke in the night due to visits from: "}</span>
</>
}),
StoryActionPrompt::AlphaWolf { chosen, .. } => generate_prompt(*chosen, "killed"),
StoryActionPrompt::DireWolf { chosen, .. } => {
generate_prompt(*chosen, "roleblocked visitors to")
}
StoryActionPrompt::LoneWolfKill { chosen, .. } => generate_prompt(*chosen, "killed"),
StoryActionPrompt::Bloodletter { chosen, .. } => generate_prompt(*chosen, "bloodlet"),
StoryActionPrompt::BeholderWakes { character_id } => {
generate_prompt(*character_id, "woke again to see")
}
};
if prompt.is_none() {
return html! {};
}
let result = choice.result.as_ref().map(|result| match result {
StoryActionResult::RoleBlocked => html! {
<>
<span>{"but was"}</span>
<Roleblock>{"Roleblocked"}</Roleblock>
</>
},
StoryActionResult::Seer(alignment) => html! {
<>
<span>{"and saw"}</span>
<AlignmentSpan alignment={*alignment}/>
</>
},
StoryActionResult::PowerSeer { powerful } => html! {
<>
<span>{"and saw"}</span>
<PowerfulSpan powerful={*powerful}/>
</>
},
StoryActionResult::Adjudicator { killer } => html! {
<>
<span>{"and saw"}</span>
<KillerSpan killer={*killer}/>
</>
},
StoryActionResult::Arcanist(alignment_eq) => html! {
<>
<span>{"and saw them as"}</span>
<AlignmentComparisonSpan comparison={*alignment_eq}/>
</>
},
StoryActionResult::GraveDigger(None) => html! {
<span>{"but found an empty grave"}</span>
},
StoryActionResult::GraveDigger(Some(role_title)) => html! {
<>
<span>{"as"}</span>
<RoleSpan role={*role_title}/>
</>
},
StoryActionResult::Mortician(died_to_title) => html! {
<>
<span>{"and found"}</span>
<DiedToSpan died_to={*died_to_title}/>
<span>{"to be the cause of death"}</span>
</>
},
StoryActionResult::Insomniac { visits } => {
let mut visit_chars = vec![];
for visit in visits {
match all_characters.iter().find(|c| c.character_id() == *visit) {
Some(char) => visit_chars.push(char),
None => {
log::warn!("visit chars missing {visit}");
return html! {};
}
}
}
visit_chars
.into_iter()
.map(|visitor| {
html! {
<CharacterHighlight
char={visitor.clone()}
time={*time}
/>
}
})
.collect::<Html>()
}
StoryActionResult::Empath { scapegoat: true } => {
html! {<span>{"and found their scapegoat"}</span>}
}
StoryActionResult::Empath { scapegoat: false } => {
html! {<span>{"who was not a scapegoat"}</span>}
}
StoryActionResult::BeholderSawNothing => html! {
<>
{"and saw"}
<em>{"nothing"}</em>
</>
},
StoryActionResult::BeholderSawEverything => html! {
<>
{"and saw"}
<em>{"everything"}</em>
</>
},
StoryActionResult::ShiftFailed => html! {
{"however, their shift failed"}
},
StoryActionResult::Drunk => html! {
<>
<span>{"but got"}</span>
<AuraSpan aura={AuraTitle::Drunk} />
</>
},
});
html! {
<div class="action">
{prompt}
{result}
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct ChangeProps {
all_characters: Rc<[Character]>,
change: NightChange,
time: GameTime,
}
#[function_component]
fn Change(
ChangeProps {
all_characters,
change,
time,
}: &ChangeProps,
) -> Html {
match change {
NightChange::RoleChange(_, role_title) => Some(html! {
<>
<span>{"role changed to"}</span>
<RoleSpan role={*role_title}/>
</>
}),
NightChange::Kill { died_to, .. } => Some(html! {
<>
<span>{"died to"}</span>
<DiedToSpan died_to={died_to.title()}/>
</>
}),
NightChange::RoleBlock {
source,
block_type: RoleBlock::Direwolf,
..
} => all_characters
.iter()
.find(|s| s.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"had visitors role blocked by"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
NightChange::Shapeshift { source, .. } => all_characters
.iter()
.find(|c| c.character_id() == *source)
.map(|source| {
html! {
<>
{"shapeshifted by"}
<CharacterHighlight
char={source.clone()}
time={*time}
/>
{"and became a werewolf"}
</>
}
}),
NightChange::ElderReveal { .. } => Some(html! {
<>
<span>{"learned they are the"}</span>
<RoleSpan role={RoleTitle::Elder}/>
</>
}),
NightChange::MasonRecruit {
mason_leader,
recruiting,
} => all_characters
.iter()
.find(|c| c.character_id() == *mason_leader)
.and_then(|mason| {
all_characters
.iter()
.find(|c| c.character_id() == *recruiting)
.map(|recruiting| (mason, recruiting))
})
.map(|(mason, recruiting)| {
html! {
<>
<CharacterHighlight
char={mason.clone()}
time={*time}
/>
<span>{"recruited"}</span>
<CharacterHighlight
char={recruiting.clone()}
time={*time}
/>
<span>{"into the masons"}</span>
</>
}
}),
NightChange::ApplyAura { source, aura, .. } => {
let from = all_characters
.iter()
.find(|c| c.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"from"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
});
Some(html! {
<>
<span>{"received the"}</span>
<AuraSpan aura={aura.title()}/>
<span>{"aura"}</span>
{from}
</>
})
}
NightChange::LostAura { aura, .. } => Some(html! {
<>
<span>{"lost the"}</span>
<AuraSpan aura={aura.title()}/>
<span>{"aura"}</span>
</>
}),
NightChange::EmpathFoundScapegoat { scapegoat, .. } => all_characters
.iter()
.find(|c| c.character_id() == *scapegoat)
.map(|scapegoat| {
html! {
<>
<span>{"took on the scapegoat's curse from"}</span>
<CharacterHighlight
char={scapegoat.clone()}
time={*time}
/>
</>
}
}),
NightChange::HunterTarget { source, .. } => all_characters
.iter()
.find(|c| c.character_id() == *source)
.map(|source| {
html! {
<>
{"had the hunter's mark placed on them by"}
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
NightChange::Protection { target, protection } => {
let prot = match protection {
Protection::Guardian {
source,
guarding: true,
} => all_characters
.iter()
.find(|s| s.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"was guarded by"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
Protection::Guardian {
source,
guarding: false,
}
| Protection::Protector { source }
| Protection::Vindicator { source } => all_characters
.iter()
.find(|s| s.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"was protected by"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
};
all_characters
.iter()
.find(|t| t.character_id() == *target)
.and_then(|target| prot.map(|prot| (target, prot)))
.map(|(target, prot)| {
html! {
<>
<CharacterHighlight
char={target.clone()}
time={*time}
/>
{prot}
</>
}
})
}
}
.map(|change| {
html! {
<div class="change">
{change}
</div>
}
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterStoryButtonProps {
pub character: Character,
pub choices_by_time: Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
pub on_click: Callback<(
Character,
Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
)>,
}
#[function_component]
pub fn CharacterStoryButton(
CharacterStoryButtonProps {
character,
choices_by_time,
on_click,
}: &CharacterStoryButtonProps,
) -> Html {
let clickable = !choices_by_time
.iter()
.all(|(_, c1, c2)| c1.is_empty() && c2.is_empty());
let role_class = character.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 = if clickable {
let cb = on_click.clone();
let char = character.clone();
let act = choices_by_time.clone();
Callback::from(move |_| cb.emit((char.clone(), act.clone())))
} else {
Callback::noop()
};
let inactive = clickable.not().then_some("no-content");
let hover = clickable.then_some("hover");
html! {
<div class={classes!("character-headline", role_class, "faint", inactive, hover)} onclick={on_click}>
{icon}
<Identity ident={character.identity().into_public()}/>
{dead_icon}
</div>
}
}

View File

@ -1,30 +0,0 @@
// 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

@ -1,147 +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,
},
story::{CharacterStory, CharacterStoryButton, 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 selected_char = use_state::<
Option<(
Character,
Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
)>,
_,
>(|| actions_by_character.first().cloned());
let on_char_pick = {
let selected_char = selected_char.setter();
Callback::from(move |(char, actions)| selected_char.set(Some((char, actions))))
};
let chars = actions_by_character
.into_iter()
.map(|(char, actions)| {
html! {
<CharacterStoryButton character={char} choices_by_time={actions} on_click={on_char_pick.clone()} />
}
})
.collect::<Html>();
let all_characters = village.characters().into_iter().collect::<Rc<_>>();
let actions = selected_char.as_ref().map(|(char, actions)| {
let class = Into::<SetupRole>::into(char.role_title())
.category()
.class();
html! {
<>
<CharacterCard char={char.clone()} dead={char.died_to().is_some()} faint=true/>
<div class={classes!("character-details", class, "faint")}>
<CharacterStory
character={char.clone()}
actions={actions.clone()}
all_characters={all_characters}
/>
</div>
</>
}
});
html! {
<div class="story">
<div class="story-characters">
{chars}
</div>
<div class="actions">
{actions}
</div>
</div>
}
}

View File

@ -19,9 +19,6 @@ 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");
} }
@ -37,9 +34,6 @@ 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");
@ -87,14 +81,13 @@ 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}