diff --git a/werewolves-proto/src/message/night.rs b/werewolves-proto/src/message/night.rs index fcfa455..f5ecb10 100644 --- a/werewolves-proto/src/message/night.rs +++ b/werewolves-proto/src/message/night.rs @@ -556,7 +556,7 @@ pub enum ActionResponse { ContinueToResult, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Titles)] pub enum ActionResult { RoleBlocked, Drunk, @@ -609,7 +609,7 @@ impl ActionResult { pub struct Visits(Box<[CharacterIdentity]>); impl Visits { - pub(crate) const fn new(visits: Box<[CharacterIdentity]>) -> Self { + pub const fn new(visits: Box<[CharacterIdentity]>) -> Self { Self(visits) } } diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs index 101e4e2..bdfe77e 100644 --- a/werewolves-proto/src/role.rs +++ b/werewolves-proto/src/role.rs @@ -15,7 +15,7 @@ use core::{fmt::Display, num::NonZeroU8, ops::Not}; use serde::{Deserialize, Serialize}; -use werewolves_macros::{ChecksAs, Titles}; +use werewolves_macros::{All, ChecksAs, Titles}; use crate::{ character::CharacterId, @@ -455,7 +455,7 @@ impl RoleTitle { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, All)] pub enum Alignment { Village, Wolves, diff --git a/werewolves-server/src/host.rs b/werewolves-server/src/host.rs index 3fe048d..c7f96e1 100644 --- a/werewolves-server/src/host.rs +++ b/werewolves-server/src/host.rs @@ -106,10 +106,6 @@ impl Host { let slice: &[u8] = &bytes; ciborium::from_reader(slice)? }; - if let HostMessage::Echo(echo) = &msg { - self.send_message(echo).await.log_warn(); - return Ok(()); - } log::debug!( "{} {}", "[host::incoming::message]".bold(), diff --git a/werewolves/img/adjudicator.svg b/werewolves/img/adjudicator.svg index 0d0becc..8d876b4 100644 --- a/werewolves/img/adjudicator.svg +++ b/werewolves/img/adjudicator.svg @@ -2,95 +2,27 @@ + d="m 372.267,496.383 v 16.689 h 8.224 v -16.689 z" /> diff --git a/werewolves/img/heart.svg b/werewolves/img/heart.svg index 3793fd8..6def52f 100644 --- a/werewolves/img/heart.svg +++ b/werewolves/img/heart.svg @@ -3,114 +3,26 @@ + transform="translate(50.270833,-38.1)" /> diff --git a/werewolves/index.scss b/werewolves/index.scss index 809f5ac..3c0b5b8 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -1194,6 +1194,11 @@ input { } .village { + --faction-color: $village_color; + --faction-border: $village_border; + --faction-color-faint: $village_color_faint; + --faction-border-faint: $village_border_faint; + background-color: $village_color; border: 1px solid $village_border; @@ -1213,6 +1218,11 @@ input { } .wolves { + --faction-color: $wolves_color; + --faction-border: $wolves_border; + --faction-color-faint: $wolves_color_faint; + --faction-border-faint: $wolves_border_faint; + background-color: $wolves_color; border: 1px solid $wolves_border; @@ -1232,6 +1242,11 @@ input { } .intel { + --faction-color: $intel_color; + --faction-border: $intel_border; + --faction-color-faint: $intel_color_faint; + --faction-border-faint: $intel_border_faint; + background-color: $intel_color; border: 1px solid $intel_border; @@ -1251,6 +1266,11 @@ input { } .defensive { + --faction-color: $defensive_color; + --faction-border: $defensive_border; + --faction-color-faint: $defensive_color_faint; + --faction-border-faint: $defensive_border_faint; + background-color: $defensive_color; border: 1px solid $defensive_border; @@ -1270,6 +1290,11 @@ input { } .offensive { + --faction-color: $offensive_color; + --faction-border: $offensive_border; + --faction-color-faint: $offensive_color_faint; + --faction-border-faint: $offensive_border_faint; + background-color: $offensive_color; border: 1px solid $offensive_border; @@ -1289,6 +1314,11 @@ input { } .starts-as-villager { + --faction-color: $starts_as_villager_color; + --faction-border: $starts_as_villager_border; + --faction-color-faint: $starts_as_villager_color_faint; + --faction-border-faint: $starts_as_villager_border_faint; + background-color: $starts_as_villager_color; border: 1px solid $starts_as_villager_border; @@ -1308,6 +1338,11 @@ input { } .traitor { + --faction-color: $traitor_color; + --faction-border: $traitor_border; + --faction-color-faint: $traitor_color_faint; + --faction-border-faint: $traitor_border_faint; + background-color: $traitor_color; border: 1px solid $traitor_border; @@ -1919,9 +1954,41 @@ li.choice { &.masons, &.large { font-size: 2rem; + flex-wrap: wrap; } } +.info-player-boxes { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + justify-content: center; + gap: 10px; + overflow-y: hidden; + + .identity { + // flex-grow: 1; + font-size: auto; + flex-direction: row; + align-items: center; + padding: 5px 10px 5px 10px; + + .number { + color: rgba(255, 255, 0, 0.7); + font-size: 2em; + padding-right: 1cm; + } + + .pronouns { + display: none; + } + } + + flex-wrap: wrap; + text-align: center; +} + .two-column { display: grid; grid-template-columns: 3fr 2fr; @@ -2241,3 +2308,107 @@ li.choice { .dimmed { filter: opacity(70%); } + +.test-screen { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 10px; + align-items: center; + + width: 80vw; + margin-left: 10vw; + margin-right: 10vw; + + .test-screen-selector { + .category { + .selected { + background-color: rgba(0, 255, 0, 0.7); + border: 1px solid rgba(0, 255, 0, 1.0); + color: black; + } + + label { + font-size: 2em; + } + + button { + color: white; + } + + gap: 10px; + + display: flex; + flex-direction: column; + flex-wrap: nowrap; + max-width: 80vw; + + .test-screens { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0; + padding-left: 0; + + li { + margin: 0; + list-style: none; + } + } + } + } +} + + +.prompt-test, +.result-test { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-width: 40vw; + align-items: center; + + .close { + width: 100%; + } + + font-size: 2em; + + .result-number, + .prompt-number { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 20px; + } + + .result-options, + .prompt-options { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-items: center; + gap: 5px; + margin-top: 10px; + + .option-set { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 5px; + } + + .result-option, + .prompt-option { + width: 100%; + padding: 5px; + border: 1px solid white; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 5px; + } + } +} diff --git a/werewolves/src/clients/host/host.rs b/werewolves/src/clients/host/host.rs index 46f122f..995ea16 100644 --- a/werewolves/src/clients/host/host.rs +++ b/werewolves/src/clients/host/host.rs @@ -190,6 +190,7 @@ async fn worker(mut recv: Receiver, scope: Scope) { } } +#[derive(Debug)] pub enum HostEvent { SetErrorCallback(Callback>), SetBigScreenState(bool), @@ -488,11 +489,13 @@ impl Component for Host { })); }) }; + html! { <> {screen} + } @@ -520,6 +523,7 @@ impl Component for Host { } fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + log::debug!("update: {msg:?}, current: {:?}", self.state); match msg { HostEvent::QrMode(mode) => { self.qr_mode = mode; diff --git a/werewolves/src/clients/mod.rs b/werewolves/src/clients/mod.rs index 6ed4bd8..d355bd3 100644 --- a/werewolves/src/clients/mod.rs +++ b/werewolves/src/clients/mod.rs @@ -22,6 +22,7 @@ pub mod host { pub mod story_test; pub use host::*; } +pub mod test_remote; // mod socket; const DEBUG_URL: &str = "ws://192.168.1.162:8080/connect/"; diff --git a/werewolves/src/clients/test_remote.rs b/werewolves/src/clients/test_remote.rs new file mode 100644 index 0000000..656d0d2 --- /dev/null +++ b/werewolves/src/clients/test_remote.rs @@ -0,0 +1,70 @@ +// 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 . + +use futures::{SinkExt, StreamExt, TryStreamExt, lock::Mutex}; +use gloo::net::websocket::{Message, futures::WebSocket}; +use werewolves_proto::message::host::{HostLobbyMessage, HostMessage, ServerToHostMessage}; +use yew::prelude::*; + +use crate::test_util::TestScreens; + +fn url() -> String { + format!( + "{}host", + option_env!("LOCAL") + .map(|_| crate::clients::DEBUG_URL) + .unwrap_or(crate::clients::LIVE_URL) + ) +} + +#[function_component] +pub fn TestClientRemote() -> Html { + gloo::utils::document().set_title("werewolves — remote test"); + let send = use_memo((), |_| Mutex::new(WebSocket::open(url().as_str()).unwrap())); + let send_cb = { + let send = send.clone(); + Callback::from(move |msg: ServerToHostMessage| { + let send = send.clone(); + yew::platform::spawn_local(async move { + send.lock() + .await + .send(gloo::net::websocket::Message::Bytes({ + let mut v = Vec::new(); + ciborium::into_writer(&HostMessage::Echo(msg.clone()), &mut v).unwrap(); + v + })) + .await + .unwrap(); + loop { + match send.lock().await.try_next().await { + Ok(Some(Message::Bytes(b))) => { + let recv: ServerToHostMessage = + ciborium::from_reader(b.as_slice()).unwrap(); + if recv == msg { + log::debug!("recv'd echo"); + return; + } + } + Ok(_) => panic!("got text message"), + Err(err) => panic!("{err}"), + } + } + }); + }) + }; + html! { + + } +} diff --git a/werewolves/src/components/character.rs b/werewolves/src/components/character.rs index 12ddb05..2a8ecc7 100644 --- a/werewolves/src/components/character.rs +++ b/werewolves/src/components/character.rs @@ -72,14 +72,18 @@ pub fn CharacterCard(CharacterCardProps { faint, char, dead }: &CharacterCardPro #[derive(Debug, Clone, PartialEq, Properties)] pub struct CharacterTargetCardProps { pub ident: CharacterIdentity, + #[prop_or_default] + pub classes: Classes, } #[function_component] -pub fn CharacterTargetCard(CharacterTargetCardProps { ident }: &CharacterTargetCardProps) -> Html { +pub fn CharacterTargetCard( + CharacterTargetCardProps { ident, classes }: &CharacterTargetCardProps, +) -> Html { let name = ident.name.clone(); html! { - + {ident.number.get()} {name} diff --git a/werewolves/src/components/victory.rs b/werewolves/src/components/victory.rs index 12f86fe..f5eb36c 100644 --- a/werewolves/src/components/victory.rs +++ b/werewolves/src/components/victory.rs @@ -1,3 +1,17 @@ +// 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 . use werewolves_proto::game::GameOver; use yew::prelude::*; diff --git a/werewolves/src/main.rs b/werewolves/src/main.rs index ab13265..ceda047 100644 --- a/werewolves/src/main.rs +++ b/werewolves/src/main.rs @@ -16,6 +16,7 @@ mod assets; mod class; mod clients; mod storage; +mod test_util; mod components { werewolves_macros::include_path!("werewolves/src/components"); pub mod attributes { @@ -45,9 +46,13 @@ const BUILD_ID_LONG: &str = werewolves_macros::build_id_long!(); const BUILD_DIRTY: bool = werewolves_macros::build_dirty!(); const BUILD_TIME: &str = werewolves_macros::build_time!(); -use crate::clients::{ - client::{Client2, ClientContext}, - host::{Host, HostEvent}, +use crate::{ + clients::{ + client::{Client2, ClientContext}, + host::{Host, HostEvent}, + test_remote::TestClientRemote, + }, + test_util::TestScreens, }; fn main() { @@ -65,7 +70,9 @@ fn main() { let error_callback = Callback::from(move |err: Option| cb_clone.send_message(err)); - if path.starts_with("/host") { + if path.starts_with("/host/test") { + yew::Renderer::::with_root(app_element).render(); + } else if path.starts_with("/host") { let host = yew::Renderer::::with_root(app_element).render(); if path.starts_with("/host/big") { host.send_message(HostEvent::SetBigScreenState(true)); diff --git a/werewolves/src/pages/role_page/beholder.rs b/werewolves/src/pages/role_page/beholder.rs index ada5da0..4375165 100644 --- a/werewolves/src/pages/role_page/beholder.rs +++ b/werewolves/src/pages/role_page/beholder.rs @@ -37,7 +37,9 @@ pub fn BeholderSawNothing() -> Html {

{"BEHOLDER"}

{"YOUR TARGET HAS DIED"}

-

+
+ +

{"BUT SAW NOTHING"}

diff --git a/werewolves/src/pages/role_page/guardian.rs b/werewolves/src/pages/role_page/guardian.rs index cfe4408..2955771 100644 --- a/werewolves/src/pages/role_page/guardian.rs +++ b/werewolves/src/pages/role_page/guardian.rs @@ -58,6 +58,9 @@ pub fn GuardianPagePreviousProtect1(GuardianPageProps { previous }: &GuardianPag

{"GUARDIAN"}

{"LAST TIME YOU PROTECTED"}

+
+ +
@@ -74,6 +77,9 @@ pub fn GuardianPagePreviousProtect2(GuardianPageProps { previous }: &GuardianPag

{"GUARDIAN"}

{"LAST TIME YOU PROTECTED"}

+
+ +
diff --git a/werewolves/src/pages/role_page/insomniac.rs b/werewolves/src/pages/role_page/insomniac.rs index 992ee32..bddd87e 100644 --- a/werewolves/src/pages/role_page/insomniac.rs +++ b/werewolves/src/pages/role_page/insomniac.rs @@ -15,7 +15,7 @@ use werewolves_proto::message::night::Visits; use yew::prelude::*; -use crate::components::{CharacterTargetCard, Icon, IconSource}; +use crate::components::{CharacterTargetCard, Icon, IconSource, Identity}; #[function_component] pub fn InsomniacPage1() -> Html { @@ -46,7 +46,10 @@ pub fn InsomniacResult(InsomniacResultProps { visits }: &InsomniacResultProps) - .iter() .map(|visitor| { html! { - +
+ {visitor.number.get()} + {visitor.name.clone()} +
} }) .collect::(); @@ -55,7 +58,7 @@ pub fn InsomniacResult(InsomniacResultProps { visits }: &InsomniacResultProps) -

{"INSOMNIAC"}

{"YOU WERE VISITED IN THE NIGHT BY:"}

-
+
{visitors}
diff --git a/werewolves/src/pages/role_page/mortician.rs b/werewolves/src/pages/role_page/mortician.rs index ef85cf6..3008846 100644 --- a/werewolves/src/pages/role_page/mortician.rs +++ b/werewolves/src/pages/role_page/mortician.rs @@ -28,8 +28,8 @@ pub fn MorticianPage1() -> Html { {"DEAD"} {" PLAYER"} -
- +
+

{"YOU WILL LEARN THE CAUSE "} @@ -70,12 +70,9 @@ pub fn MorticianResultPage(

{"MORTICIAN"}

{"YOUR TARGET DIED TO"}

-

- -

+
+ +

{text}

diff --git a/werewolves/src/test_util/mod.rs b/werewolves/src/test_util/mod.rs new file mode 100644 index 0000000..e67d664 --- /dev/null +++ b/werewolves/src/test_util/mod.rs @@ -0,0 +1,449 @@ +// 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 . +mod prompt; +mod result; +use core::num::NonZeroU8; + +use werewolves_proto::{ + character::CharacterId, + diedto::DiedToTitle, + message::{ + CharacterIdentity, + host::{HostMessage, ServerToHostMessage}, + night::{ + ActionPrompt, ActionPromptTitle, ActionResult, ActionResultTitle, ActionType, Visits, + }, + }, + role::{Alignment, AlignmentEq, Killer, Powerful, RoleTitle}, +}; +use yew::prelude::*; + +use crate::{ + components::Button, + test_util::{prompt::PromptScreenTest, result::ResultScreenTest}, +}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct TestScreensProps { + pub send: Callback, +} + +#[function_component] +pub fn TestScreens(TestScreensProps { send }: &TestScreensProps) -> Html { + let screen: UseStateHandle> = use_state(|| None); + let page = use_state(|| 0usize); + + let back = { + let screen = screen.setter(); + Callback::from(move |_| screen.set(None)) + }; + + let send = { + let send = send.clone(); + let page = page.clone(); + let screen = screen.clone(); + Callback::from(move |msg: ServerToHostMessage| { + match &msg { + ServerToHostMessage::ActionPrompt(prompt, new_page) => { + if *page != *new_page { + page.set(*new_page); + } + if let Some(TestScreen::Prompt(current)) = &*screen + && current != prompt + { + screen.set(Some(TestScreen::Prompt(prompt.clone()))); + } + } + ServerToHostMessage::ActionResult(_, result) => { + screen.set(Some(TestScreen::Result(result.clone()))); + } + _ => {} + } + send.emit(msg); + }) + }; + + let screen_settings = screen.as_ref().map(|screen| match screen { + TestScreen::Prompt(p) => html! { + + }, + TestScreen::Result(result) => html! { + + }, + }); + + html! { +
+ + {screen_settings} +
+ } +} + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct TestScreenSelectorProps { + pub screen: UseStateHandle>, + pub send: Callback, +} + +#[function_component] +pub fn TestScreenSelector( + TestScreenSelectorProps { screen, send }: &TestScreenSelectorProps, +) -> Html { + let prompts = ActionPromptTitle::ALL + .into_iter() + .map(|title| { + let TestScreen::Prompt(prompt) = Into::::into(title) else { + unreachable!() + }; + let picked_class = if let Some(TestScreen::Prompt(current)) = &**screen + && current.title() == title + { + Some("selected") + } else { + None + }; + let callback = { + let screen = screen.clone(); + let send = send.clone(); + Callback::from(move |_| { + let TestScreen::Prompt(prompt) = Into::::into(title) else { + unreachable!() + }; + screen.set(Some(TestScreen::Prompt(prompt.clone()))); + send.emit(ServerToHostMessage::ActionPrompt(prompt, 0)); + }) + }; + let class = prompt_class(&prompt); + html! { +
  • + +
  • + } + }) + .collect::(); + + let results = ActionResultTitle::ALL + .into_iter() + .filter(|title| !matches!(title, ActionResultTitle::Continue)) + .map(|title| { + let TestScreen::Result(result) = Into::::into(title) else { + unreachable!() + }; + let picked_class = if let Some(TestScreen::Result(current)) = &**screen + && current.title() == title + { + Some("selected") + } else { + None + }; + let callback = { + let screen = screen.clone(); + let send = send.clone(); + Callback::from(move |_| { + let TestScreen::Result(result) = Into::::into(title) else { + unreachable!() + }; + screen.set(Some(TestScreen::Result(result.clone()))); + send.emit(ServerToHostMessage::ActionResult(None, result)); + }) + }; + let class = result_class(&result); + html! { +
  • + +
  • + } + }) + .collect::(); + html! { +
    +
    + +
      + {prompts} +
    +
    +
    + +
      + {results} +
    +
    +
    + } +} + +fn identities(num: usize) -> Box<[CharacterIdentity]> { + (1..=num) + .map(|num| { + CharacterIdentity::new( + CharacterId::from_u128(num as _), + format!("Player {num}"), + Some("they/them".into()), + NonZeroU8::new(num as _).unwrap(), + ) + }) + .collect() +} + +fn identity() -> CharacterIdentity { + identities(1).into_iter().next().unwrap() +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TestScreen { + Prompt(ActionPrompt), + Result(ActionResult), +} + +impl From for TestScreen { + fn from(value: ActionResultTitle) -> Self { + TestScreen::Result(match value { + ActionResultTitle::RoleBlocked => ActionResult::RoleBlocked, + ActionResultTitle::Drunk => ActionResult::Drunk, + ActionResultTitle::Seer => ActionResult::Seer(Alignment::Village), + ActionResultTitle::PowerSeer => ActionResult::PowerSeer { + powerful: Powerful::Powerful, + }, + ActionResultTitle::Adjudicator => ActionResult::Adjudicator { + killer: Killer::Killer, + }, + ActionResultTitle::Arcanist => ActionResult::Arcanist(AlignmentEq::Same), + ActionResultTitle::GraveDigger => ActionResult::GraveDigger(None), + ActionResultTitle::Mortician => ActionResult::Mortician(DiedToTitle::Execution), + ActionResultTitle::Insomniac => ActionResult::Insomniac(Visits::new(identities(2))), + ActionResultTitle::Empath => ActionResult::Empath { scapegoat: true }, + ActionResultTitle::BeholderSawNothing => ActionResult::BeholderSawNothing, + ActionResultTitle::BeholderSawEverything => ActionResult::BeholderSawEverything, + ActionResultTitle::GoBackToSleep => ActionResult::GoBackToSleep, + ActionResultTitle::ShiftFailed => ActionResult::ShiftFailed, + ActionResultTitle::Continue => ActionResult::Continue, + }) + } +} + +impl From for TestScreen { + fn from(value: ActionPromptTitle) -> Self { + Self::Prompt(match value { + ActionPromptTitle::CoverOfDarkness => ActionPrompt::CoverOfDarkness, + ActionPromptTitle::WolvesIntro => ActionPrompt::WolvesIntro { + wolves: identities(5) + .into_iter() + .zip([ + RoleTitle::Werewolf, + RoleTitle::AlphaWolf, + RoleTitle::DireWolf, + RoleTitle::LoneWolf, + RoleTitle::Bloodletter, + ]) + .collect(), + }, + ActionPromptTitle::RoleChange => ActionPrompt::RoleChange { + character_id: identities(1).into_iter().next().unwrap(), + new_role: RoleTitle::Adjudicator, + }, + ActionPromptTitle::ElderReveal => ActionPrompt::ElderReveal { + character_id: identities(1).into_iter().next().unwrap(), + }, + ActionPromptTitle::Seer => ActionPrompt::Seer { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Protector => ActionPrompt::Protector { + character_id: identities(1).into_iter().next().unwrap(), + targets: identities(20), + marked: None, + }, + ActionPromptTitle::Arcanist => ActionPrompt::Arcanist { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: (None, None), + }, + ActionPromptTitle::Gravedigger => ActionPrompt::Gravedigger { + character_id: identities(1).into_iter().next().unwrap(), + dead_players: identities(20), + marked: None, + }, + ActionPromptTitle::Hunter => ActionPrompt::Hunter { + character_id: identities(1).into_iter().next().unwrap(), + current_target: None, + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Militia => ActionPrompt::Militia { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::MapleWolf => ActionPrompt::MapleWolf { + character_id: identities(1).into_iter().next().unwrap(), + kill_or_die: false, + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Guardian => ActionPrompt::Guardian { + character_id: identities(1).into_iter().next().unwrap(), + previous: None, + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Adjudicator => ActionPrompt::Adjudicator { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::PowerSeer => ActionPrompt::PowerSeer { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Mortician => ActionPrompt::Mortician { + character_id: identities(1).into_iter().next().unwrap(), + dead_players: identities(20), + marked: None, + }, + ActionPromptTitle::Beholder => ActionPrompt::Beholder { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::MasonsWake => ActionPrompt::MasonsWake { + leader: identities(1).into_iter().next().unwrap(), + masons: identities(3), + }, + ActionPromptTitle::MasonLeaderRecruit => ActionPrompt::MasonLeaderRecruit { + character_id: identities(1).into_iter().next().unwrap(), + recruits_left: NonZeroU8::new(3).unwrap(), + potential_recruits: identities(20), + marked: None, + }, + ActionPromptTitle::Empath => ActionPrompt::Empath { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Vindicator => ActionPrompt::Vindicator { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::PyreMaster => ActionPrompt::PyreMaster { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::WolfPackKill => ActionPrompt::WolfPackKill { + living_villagers: identities(20), + marked: None, + }, + ActionPromptTitle::Shapeshifter => ActionPrompt::Shapeshifter { + character_id: identities(1).into_iter().next().unwrap(), + }, + ActionPromptTitle::AlphaWolf => ActionPrompt::AlphaWolf { + character_id: identities(1).into_iter().next().unwrap(), + living_villagers: identities(20), + marked: None, + }, + ActionPromptTitle::DireWolf => ActionPrompt::DireWolf { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::LoneWolfKill => ActionPrompt::LoneWolfKill { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::Insomniac => ActionPrompt::Insomniac { + character_id: identities(1).into_iter().next().unwrap(), + }, + ActionPromptTitle::Bloodletter => ActionPrompt::Bloodletter { + character_id: identities(1).into_iter().next().unwrap(), + living_players: identities(20), + marked: None, + }, + ActionPromptTitle::TraitorIntro => ActionPrompt::TraitorIntro { + character_id: identities(1).into_iter().next().unwrap(), + }, + }) + } +} + +fn result_class(result: &ActionResult) -> Option<&'static str> { + match result.title() { + ActionResultTitle::Drunk | ActionResultTitle::RoleBlocked => Some("drunk"), + ActionResultTitle::PowerSeer + | ActionResultTitle::Adjudicator + | ActionResultTitle::Arcanist + | ActionResultTitle::GraveDigger + | ActionResultTitle::Mortician + | ActionResultTitle::Insomniac + | ActionResultTitle::Empath + | ActionResultTitle::BeholderSawNothing + | ActionResultTitle::BeholderSawEverything + | ActionResultTitle::Seer => Some("intel"), + ActionResultTitle::ShiftFailed => Some("wolves"), + ActionResultTitle::GoBackToSleep | ActionResultTitle::Continue => None, + } +} + +fn prompt_class(prompt: &ActionPrompt) -> Option<&'static str> { + match prompt { + ActionPrompt::ElderReveal { .. } + | ActionPrompt::RoleChange { .. } + | ActionPrompt::CoverOfDarkness => None, + ActionPrompt::Seer { .. } + | ActionPrompt::Arcanist { .. } + | ActionPrompt::Gravedigger { .. } + | ActionPrompt::Adjudicator { .. } + | ActionPrompt::PowerSeer { .. } + | ActionPrompt::Mortician { .. } + | ActionPrompt::Beholder { .. } + | ActionPrompt::MasonsWake { .. } + | ActionPrompt::MasonLeaderRecruit { .. } + | ActionPrompt::Empath { .. } => Some("intel"), + ActionPrompt::Protector { .. } + | ActionPrompt::Guardian { .. } + | ActionPrompt::Vindicator { .. } => Some("defensive"), + ActionPrompt::Hunter { .. } + | ActionPrompt::Militia { .. } + | ActionPrompt::MapleWolf { .. } + | ActionPrompt::PyreMaster { .. } => Some("offensive"), + ActionPrompt::WolvesIntro { .. } + | ActionPrompt::WolfPackKill { .. } + | ActionPrompt::Shapeshifter { .. } + | ActionPrompt::AlphaWolf { .. } + | ActionPrompt::DireWolf { .. } + | ActionPrompt::LoneWolfKill { .. } + | ActionPrompt::Insomniac { .. } + | ActionPrompt::Bloodletter { .. } => Some("wolves"), + ActionPrompt::TraitorIntro { .. } => Some("traitor"), + } +} diff --git a/werewolves/src/test_util/prompt.rs b/werewolves/src/test_util/prompt.rs new file mode 100644 index 0000000..d4c6782 --- /dev/null +++ b/werewolves/src/test_util/prompt.rs @@ -0,0 +1,453 @@ +// 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 . +use yew::prelude::*; + +use core::num::NonZeroU8; + +use web_sys::HtmlSelectElement; +use werewolves_proto::{ + message::{ + host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage}, + night::ActionPrompt, + }, + player::RoleChange, + role::{PreviousGuardianAction, RoleTitle}, +}; + +use crate::{components::Button, pages::RolePage, test_util::identities}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct PromptScreenTestProps { + pub prompt: ActionPrompt, + pub page: usize, + pub send: Callback, + pub back: Callback<()>, +} + +#[function_component] +pub fn PromptScreenTest( + PromptScreenTestProps { + prompt, + page, + send, + back, + }: &PromptScreenTestProps, +) -> Html { + let options = match prompt { + ActionPrompt::WolvesIntro { wolves } => { + let dec_disabled = wolves + .is_empty() + .then_some(String::from("already have zero wolves")); + let dec = { + let current = wolves.len(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::WolvesIntro { + wolves: super::identities(current - 1) + .into_iter() + .zip(RoleTitle::ALL.into_iter().filter(|w| w.wolf()).cycle()) + .collect(), + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + let inc_disabled = + (wolves.len() + 1 > 0xFF).then_some(String::from("already at max wolves")); + let inc = { + let current = wolves.len(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::WolvesIntro { + wolves: super::identities(current + 1) + .into_iter() + .zip(RoleTitle::ALL.into_iter().filter(|w| w.wolf()).cycle()) + .collect(), + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + html! { +
    + + + +
    + } + } + ActionPrompt::RoleChange { new_role, .. } => { + let roles = RoleTitle::ALL + .into_iter() + .map(|role| { + html! { + + } + }) + .collect::(); + let on_change_cb = { + let send = send.clone(); + Callback::from(move |ev: Event| { + if let Some(select) = ev.target_dyn_into::() { + let selected = select.selected_index(); + if selected == -1 { + return; + } + if let Some(new_role) = RoleTitle::ALL.into_iter().nth(selected as _) { + let new_prompt = ActionPrompt::RoleChange { + character_id: super::identity(), + new_role, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + } + } + }) + }; + let on_wheel = { + let send = send.clone(); + Callback::from(move |ev: WheelEvent| { + let Some(target) = ev.target_dyn_into::() else { + return; + }; + let index = target.selected_index(); + let new_index = match ev.delta_y().total_cmp(&0.0) { + core::cmp::Ordering::Equal => return, + core::cmp::Ordering::Less => { + if index != 0 { + index - 1 + } else { + (target.children().length() - 1) as i32 + } + } + core::cmp::Ordering::Greater => { + if index + 1 < target.children().length() as i32 { + index + 1 + } else { + 0 + } + } + }; + target.set_selected_index(new_index); + if let Some(new_role) = RoleTitle::ALL.into_iter().nth(new_index as _) { + let new_prompt = ActionPrompt::RoleChange { + character_id: super::identity(), + new_role, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + } + }) + }; + html! { +
    + + +
    + } + } + + ActionPrompt::Hunter { current_target, .. } => { + let toggle_target = current_target.is_none().then_some(super::identity()); + let on_toggle = { + let toggle_target = toggle_target.clone(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::Hunter { + character_id: super::identity(), + current_target: toggle_target.clone(), + living_players: identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + + let button_text = if toggle_target.is_some() { + "remove previous target" + } else { + "set previous target" + }; + html! { +
    + +
    + } + } + ActionPrompt::MapleWolf { kill_or_die, .. } => { + let toggle = !*kill_or_die; + let on_toggle = { + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::MapleWolf { + character_id: super::identity(), + kill_or_die: toggle, + living_players: identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + + let button_text = if toggle { + "turn starving" + } else { + "feed the poor boy" + }; + html! { +
    + +
    + } + } + ActionPrompt::Guardian { previous, .. } => { + let none_disabled = previous.is_none().then_some(String::from("already set")); + let prev_none = { + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::Guardian { + character_id: super::identity(), + previous: None, + living_players: super::identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + + let prot_disabled = previous.as_ref().and_then(|prev| match prev { + PreviousGuardianAction::Protect(_) => Some(String::from("already set")), + PreviousGuardianAction::Guard(_) => None, + }); + let prev_prot = { + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::Guardian { + character_id: super::identity(), + previous: Some(PreviousGuardianAction::Protect( + super::identities(2).into_iter().nth(1).unwrap(), + )), + living_players: super::identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + let guard_disabled = previous.as_ref().and_then(|prev| match prev { + PreviousGuardianAction::Guard(_) => Some(String::from("already set")), + PreviousGuardianAction::Protect(_) => None, + }); + let prev_guard = { + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::Guardian { + character_id: super::identity(), + previous: Some(PreviousGuardianAction::Guard( + super::identities(2).into_iter().nth(1).unwrap(), + )), + living_players: super::identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + + html! { + <> + +
    + + + +
    + + } + } + ActionPrompt::MasonsWake { masons, .. } => { + let dec_disabled = + (masons.len() == 1).then_some(String::from("already at min recruits")); + let dec = { + let current = masons.len(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::MasonsWake { + leader: super::identity(), + masons: super::identities(current.saturating_sub(1)), + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + let inc_disabled = + (masons.len() > 0xFF).then_some(String::from("already at max wolves")); + let inc = { + let current = masons.len(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::MasonsWake { + leader: super::identity(), + masons: super::identities(current.saturating_add(1)), + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + html! { +
    + + + +
    + } + } + ActionPrompt::MasonLeaderRecruit { recruits_left, .. } => { + let dec_disabled = + (recruits_left.get() == 1).then_some(String::from("already at min recruits")); + let dec = { + let current = recruits_left.get(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::MasonLeaderRecruit { + character_id: super::identity(), + recruits_left: NonZeroU8::new(current.saturating_sub(1)).unwrap(), + potential_recruits: identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + let inc_disabled = + (recruits_left.get() == 0xFF).then_some(String::from("already at max wolves")); + let inc = { + let current = recruits_left.get(); + let send = send.clone(); + Callback::from(move |_| { + let new_prompt = ActionPrompt::MasonLeaderRecruit { + character_id: super::identity(), + recruits_left: NonZeroU8::new(current.saturating_add(1)).unwrap(), + potential_recruits: identities(20), + marked: None, + }; + send.emit(ServerToHostMessage::ActionPrompt(new_prompt.clone(), 0)); + }) + }; + html! { +
    + + + +
    + } + } + + ActionPrompt::Protector { .. } + | ActionPrompt::Arcanist { .. } + | ActionPrompt::Gravedigger { .. } + | ActionPrompt::Militia { .. } + | ActionPrompt::Adjudicator { .. } + | ActionPrompt::PowerSeer { .. } + | ActionPrompt::Mortician { .. } + | ActionPrompt::Beholder { .. } + | ActionPrompt::Empath { .. } + | ActionPrompt::Vindicator { .. } + | ActionPrompt::PyreMaster { .. } + | ActionPrompt::WolfPackKill { .. } + | ActionPrompt::AlphaWolf { .. } + | ActionPrompt::DireWolf { .. } + | ActionPrompt::LoneWolfKill { .. } + | ActionPrompt::Bloodletter { .. } + | ActionPrompt::Seer { .. } + | ActionPrompt::ElderReveal { .. } + | ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Insomniac { .. } + | ActionPrompt::Shapeshifter { .. } + | ActionPrompt::CoverOfDarkness => html! {}, + }; + let prev_page_disabled = (*page == 0).then_some(String::from("on page zero")); + let prev_page = { + let send = send.clone(); + let prompt = prompt.clone(); + let page = *page; + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionPrompt( + prompt.clone(), + page.saturating_sub(1), + )); + }) + }; + let next_page_disabled = (*page + 1 >= prompt.role_pages(true).len()) + .then_some(String::from("already at last page")); + let next_page = { + let send = send.clone(); + let prompt = prompt.clone(); + let page = *page; + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionPrompt( + prompt.clone(), + page.saturating_add(1), + )); + }) + }; + html! { +
    + + +
    + + {page.saturating_add(1)}{"/"}{prompt.role_pages(true).len().max(1)} + +
    +
    + {options} +
    +
    + } +} diff --git a/werewolves/src/test_util/result.rs b/werewolves/src/test_util/result.rs new file mode 100644 index 0000000..17146d5 --- /dev/null +++ b/werewolves/src/test_util/result.rs @@ -0,0 +1,371 @@ +// 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 . +use yew::prelude::*; + +use core::num::NonZeroU8; +use std::rc::Rc; + +use web_sys::HtmlSelectElement; +use werewolves_proto::{ + diedto::DiedToTitle, + message::{ + host::{HostGameMessage, HostMessage, HostNightMessage, ServerToHostMessage}, + night::{ActionPrompt, ActionResult, ActionResultTitle, Visits}, + }, + player::RoleChange, + role::{Alignment, PreviousGuardianAction, RoleTitle}, +}; + +use crate::{components::Button, pages::RolePage, test_util::identities}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct ResultScreenTestProps { + pub result: ActionResult, + pub send: Callback, + pub back: Callback<()>, +} + +#[function_component] +pub fn ResultScreenTest( + ResultScreenTestProps { result, send, back }: &ResultScreenTestProps, +) -> Html { + let options = match result { + ActionResult::BeholderSawNothing + | ActionResult::BeholderSawEverything + | ActionResult::GoBackToSleep + | ActionResult::ShiftFailed + | ActionResult::Continue + | ActionResult::Drunk + | ActionResult::RoleBlocked => html! {}, + ActionResult::Seer(alignment) => { + let all = Alignment::ALL + .into_iter() + .map(|align| { + let on_click = { + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Seer(align), + )); + }) + }; + let disabled = (align == *alignment).then_some(String::from("already selected")) ; + html! { + + } + }) + .collect::(); + html! { +
    +
    + {all} +
    +
    + } + } + ActionResult::PowerSeer { powerful } => { + let on_toggle = { + let set = !*powerful; + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::PowerSeer { powerful: set }, + )); + }) + }; + let text = if powerful.powerful() { + "make not powerful" + } else { + "make powerful" + }; + html! { +
    + +
    + } + } + ActionResult::Adjudicator { killer } => { + let on_toggle = { + let set = !*killer; + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Adjudicator { killer: set }, + )); + }) + }; + let text = if killer.killer() { + "make not killer" + } else { + "make killer" + }; + html! { +
    + +
    + } + } + ActionResult::Arcanist(alignment_eq) => { + let on_toggle = { + let set = !*alignment_eq; + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Arcanist(set), + )); + }) + }; + let text = if alignment_eq.same() { + "make different" + } else { + "make same" + }; + html! { +
    + +
    + } + } + ActionResult::GraveDigger(role_title) => { + let possibilities = [None] + .into_iter() + .chain(RoleTitle::ALL.into_iter().map(Some)) + .collect::>(); + let roles = possibilities + .iter() + .map(|role| { + let text = role + .as_ref() + .map(|r| r.to_string()) + .unwrap_or(String::from("empty grave")); + html! { + + } + }) + .collect::(); + let on_change_cb = { + let send = send.clone(); + let possibilities = possibilities.clone(); + Callback::from(move |ev: Event| { + if let Some(select) = ev.target_dyn_into::() { + let selected = select.selected_index(); + if selected == -1 { + return; + } + if let Some(new_role) = possibilities.get(selected as usize) { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::GraveDigger(*new_role), + )); + } + } + }) + }; + let on_wheel = { + let send = send.clone(); + let possibilities = possibilities.clone(); + Callback::from(move |ev: WheelEvent| { + let Some(target) = ev.target_dyn_into::() else { + return; + }; + let index = target.selected_index(); + let new_index = match ev.delta_y().total_cmp(&0.0) { + core::cmp::Ordering::Equal => return, + core::cmp::Ordering::Less => { + if index != 0 { + index - 1 + } else { + (target.children().length() - 1) as i32 + } + } + core::cmp::Ordering::Greater => { + if index + 1 < target.children().length() as i32 { + index + 1 + } else { + 0 + } + } + }; + target.set_selected_index(new_index); + if let Some(new_role) = possibilities.get(new_index as usize) { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::GraveDigger(*new_role), + )); + } + }) + }; + html! { +
    + + +
    + } + } + ActionResult::Mortician(died_to_title) => { + let roles = DiedToTitle::ALL + .into_iter() + .map(|died_to| { + html! { + + } + }) + .collect::(); + let on_change_cb = { + let send = send.clone(); + Callback::from(move |ev: Event| { + if let Some(select) = ev.target_dyn_into::() { + let selected = select.selected_index(); + if selected == -1 { + return; + } + if let Some(died_to) = DiedToTitle::ALL.into_iter().nth(selected as _) { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Mortician(died_to), + )); + } + } + }) + }; + let on_wheel = { + let send = send.clone(); + Callback::from(move |ev: WheelEvent| { + let Some(target) = ev.target_dyn_into::() else { + return; + }; + let index = target.selected_index(); + let new_index = match ev.delta_y().total_cmp(&0.0) { + core::cmp::Ordering::Equal => return, + core::cmp::Ordering::Less => { + if index != 0 { + index - 1 + } else { + (target.children().length() - 1) as i32 + } + } + core::cmp::Ordering::Greater => { + if index + 1 < target.children().length() as i32 { + index + 1 + } else { + 0 + } + } + }; + target.set_selected_index(new_index); + if let Some(died_to) = DiedToTitle::ALL.into_iter().nth(new_index as _) { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Mortician(died_to), + )); + } + }) + }; + html! { +
    + + +
    + } + } + ActionResult::Insomniac(visits) => { + let dec_disabled = + (visits.len() <= 1).then_some(String::from("already have minimum visits")); + let dec = { + let current = visits.len(); + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Insomniac(Visits::new(super::identities( + current.saturating_sub(1).max(1), + ))), + )); + }) + }; + let inc_disabled = + (visits.len() + 1 > 0xFF).then_some(String::from("already at max visits")); + let inc = { + let current = visits.len(); + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Insomniac(Visits::new(super::identities( + current.saturating_add(1).max(1), + ))), + )); + }) + }; + html! { +
    + + + +
    + } + } + ActionResult::Empath { scapegoat } => { + let on_toggle = { + let set = !*scapegoat; + let send = send.clone(); + Callback::from(move |_| { + send.emit(ServerToHostMessage::ActionResult( + None, + ActionResult::Empath { scapegoat: set }, + )); + }) + }; + let text = if *scapegoat { + "make not scapegoat" + } else { + "make scapegoat" + }; + html! { +
    + +
    + } + } + }; + html! { +
    + + + +
    + {options} +
    +
    + } +}