From 98493d34beb28bdd8de861a365830594dd5be450 Mon Sep 17 00:00:00 2001 From: emilis Date: Tue, 7 Oct 2025 01:47:59 +0100 Subject: [PATCH] qr mode + fix mortician --- Cargo.lock | 7 +++ werewolves-proto/src/character.rs | 8 ++- werewolves-proto/src/game_test/role/mason.rs | 44 ++++++++++++++ werewolves-proto/src/game_test/role/mod.rs | 1 + .../src/game_test/role/mortician.rs | 32 ++++++++++ werewolves-proto/src/message/host.rs | 2 + werewolves-server/Cargo.toml | 1 + werewolves-server/src/lobby.rs | 14 +++-- werewolves-server/src/main.rs | 32 +++++++++- werewolves/index.scss | 40 ++++++++++++- werewolves/src/clients/host/host.rs | 47 ++++++++++++++- werewolves/src/components/host/setup.rs | 2 +- werewolves/src/components/settings.rs | 59 +++++++++++-------- werewolves/src/main.rs | 6 +- werewolves/src/storage.rs | 6 +- 15 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 werewolves-proto/src/game_test/role/mortician.rs diff --git a/Cargo.lock b/Cargo.lock index b8367a4..bdbdc2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,6 +464,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fast_qr" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5ea9788036fedaf55f43a2db0ba01eedf47d26fd6852f01e5cf51952571d57" + [[package]] name = "find-msvc-tools" version = "0.1.2" @@ -2447,6 +2453,7 @@ dependencies = [ "chrono", "ciborium", "colored", + "fast_qr", "futures", "log", "mime-sniffer", diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs index ef1d1b5..8857ae0 100644 --- a/werewolves-proto/src/character.rs +++ b/werewolves-proto/src/character.rs @@ -400,7 +400,13 @@ impl Character { }, Role::Mortician => ActionPrompt::Mortician { character_id: self.identity(), - dead_players: village.dead_targets(), + dead_players: { + let dead = village.dead_targets(); + if dead.is_empty() { + return Ok(Box::new([])); + } + dead + }, marked: None, }, Role::Beholder => ActionPrompt::Beholder { diff --git a/werewolves-proto/src/game_test/role/mason.rs b/werewolves-proto/src/game_test/role/mason.rs index 34e7e01..e6b0869 100644 --- a/werewolves-proto/src/game_test/role/mason.rs +++ b/werewolves-proto/src/game_test/role/mason.rs @@ -3,6 +3,7 @@ use core::num::NonZeroU8; use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; use crate::{ + diedto::DiedTo, game::{Game, GameSettings, SetupRole}, game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players}, message::night::{ActionPrompt, ActionPromptTitle}, @@ -67,3 +68,46 @@ fn recruits_decrement() { game.next_expect_day(); } + +#[test] +fn dies_recruiting_wolf() { + let players = gen_players(1..10); + let mason_leader_player_id = players[0].player_id; + let wolf_player_id = players[1].player_id; + let mut settings = GameSettings::empty(); + settings.add_and_assign( + SetupRole::MasonLeader { + recruits_available: NonZeroU8::new(1).unwrap(), + }, + mason_leader_player_id, + ); + settings.add_and_assign(SetupRole::Werewolf, wolf_player_id); + settings.fill_remaining_slots_with_villagers(9); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().sleep(); + game.next_expect_day(); + + assert_eq!(game.execute().title(), ActionPromptTitle::WolfPackKill); + game.mark(game.living_villager().character_id()); + game.r#continue().sleep(); + + assert_eq!(game.next().title(), ActionPromptTitle::MasonLeaderRecruit); + game.mark(game.character_by_player_id(wolf_player_id).character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(mason_leader_player_id) + .died_to() + .cloned(), + Some(DiedTo::MasonLeaderRecruitFail { + tried_recruiting: game.character_by_player_id(wolf_player_id).character_id(), + night: 1, + }) + ); +} + +// todo: masons wake even if leader dead diff --git a/werewolves-proto/src/game_test/role/mod.rs b/werewolves-proto/src/game_test/role/mod.rs index 0adcefa..fd46ea2 100644 --- a/werewolves-proto/src/game_test/role/mod.rs +++ b/werewolves-proto/src/game_test/role/mod.rs @@ -4,6 +4,7 @@ mod diseased; mod elder; mod empath; mod mason; +mod mortician; mod pyremaster; mod scapegoat; mod shapeshifter; diff --git a/werewolves-proto/src/game_test/role/mortician.rs b/werewolves-proto/src/game_test/role/mortician.rs new file mode 100644 index 0000000..b2d12f7 --- /dev/null +++ b/werewolves-proto/src/game_test/role/mortician.rs @@ -0,0 +1,32 @@ +use core::num::NonZeroU8; +#[allow(unused)] +use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; + +use crate::{ + diedto::DiedTo, + game::{Game, GameSettings, SetupRole}, + game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players}, + message::night::{ActionPrompt, ActionPromptTitle}, +}; + +#[test] +fn no_dead_doesnt_prompt() { + let players = gen_players(1..10); + let mortician_player_id = players[0].player_id; + let wolf_player_id = players[1].player_id; + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Mortician, mortician_player_id); + settings.add_and_assign(SetupRole::Werewolf, wolf_player_id); + settings.fill_remaining_slots_with_villagers(9); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().sleep(); + game.next_expect_day(); + + assert_eq!(game.execute().title(), ActionPromptTitle::WolfPackKill); + game.mark(game.living_villager().character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); +} diff --git a/werewolves-proto/src/message/host.rs b/werewolves-proto/src/message/host.rs index f5ea91c..8b593fe 100644 --- a/werewolves-proto/src/message/host.rs +++ b/werewolves-proto/src/message/host.rs @@ -58,6 +58,7 @@ pub enum HostLobbyMessage { SetPlayerNumber(PlayerId, NonZeroU8), GetGameSettings, SetGameSettings(GameSettings), + SetQrMode(bool), Start, } @@ -72,6 +73,7 @@ pub enum ServerToHostMessage { ActionPrompt(ActionPrompt), ActionResult(Option, ActionResult), Lobby(Box<[PlayerState]>), + QrMode(bool), GameSettings(GameSettings), Error(GameError), GameOver(GameOver), diff --git a/werewolves-server/Cargo.toml b/werewolves-server/Cargo.toml index f4fe802..649aded 100644 --- a/werewolves-server/Cargo.toml +++ b/werewolves-server/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1.0", features = ["derive"] } thiserror = { version = "2" } ciborium = { version = "0.2", optional = true } colored = { version = "3.0" } +fast_qr = { version = "0.13", features = ["svg"] } [features] default = ["cbor"] diff --git a/werewolves-server/src/lobby.rs b/werewolves-server/src/lobby.rs index 24c5a57..5690ae8 100644 --- a/werewolves-server/src/lobby.rs +++ b/werewolves-server/src/lobby.rs @@ -24,6 +24,7 @@ pub struct Lobby { settings: GameSettings, joined_players: JoinedPlayers, comms: Option, + qr_mode: bool, } impl Lobby { @@ -33,6 +34,7 @@ impl Lobby { comms: Some(comms), settings: GameSettings::default(), players_in_lobby: LobbyPlayers(Vec::new()), + qr_mode: false, } } @@ -79,10 +81,10 @@ impl Lobby { pub async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> { let players = self.get_lobby_player_list().await; - self.comms()? - .host() - .send(ServerToHostMessage::Lobby(players)) - .map_err(|err| GameError::GenericError(err.to_string())) + let qr_mode = self.qr_mode; + let host = self.comms()?.host(); + host.send(ServerToHostMessage::Lobby(players))?; + host.send(ServerToHostMessage::QrMode(qr_mode)) } pub async fn next(&mut self) -> Option { @@ -150,6 +152,10 @@ impl Lobby { async fn next_inner(&mut self, msg: Message) -> Result, GameError> { match msg { + Message::Host(HostMessage::Lobby(HostLobbyMessage::SetQrMode(mode))) => { + self.qr_mode = mode; + self.send_lobby_info_to_host().await.log_debug(); + } Message::Host(HostMessage::InGame(_)) | Message::Host(HostMessage::ForceRoleAckFor(_)) => { return Err(GameError::InvalidMessageForGameState); diff --git a/werewolves-server/src/main.rs b/werewolves-server/src/main.rs index 7c1898a..a8ad5d4 100644 --- a/werewolves-server/src/main.rs +++ b/werewolves-server/src/main.rs @@ -9,7 +9,7 @@ mod saver; use axum::{ Router, - http::{Request, header}, + http::{Request, StatusCode, header}, response::IntoResponse, routing::{any, get}, }; @@ -17,6 +17,7 @@ use axum_extra::headers; use communication::lobby::LobbyComms; use connection::JoinedPlayers; use core::{fmt::Display, net::SocketAddr, str::FromStr}; +use fast_qr::convert::{Builder, Shape, svg::SvgBuilder}; use runner::IdentifiedClientMessage; use std::{env, io::Write, path::Path}; use tokio::sync::{broadcast, mpsc}; @@ -29,6 +30,7 @@ use crate::{ const DEFAULT_PORT: u16 = 8080; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_SAVE_DIR: &str = "werewolves-saves/"; +const DEFAULT_QRCODE_URL: &str = "https://wolf.emilis.dev/"; #[tokio::main] async fn main() { @@ -136,6 +138,7 @@ async fn main() { let app = Router::new() .route("/connect/client", any(client::handler)) .route("/connect/host", any(host::handler)) + .route("/qrcode", get(handle_qr_code)) .with_state(state) .fallback(get(handle_http_static)); let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap(); @@ -165,6 +168,33 @@ impl Clone for AppState { } } +async fn handle_qr_code() -> impl IntoResponse { + const QRCODE: &str = const { + match option_env!("QRCODE_URL") { + Some(qrcode) => qrcode, + None => DEFAULT_QRCODE_URL, + } + }; + const EMPTY: &[u8] = &[]; + let qr_str = match fast_qr::QRBuilder::new(QRCODE).build() { + Ok(qr) => SvgBuilder::default().shape(Shape::Square).to_str(&qr), + Err(err) => { + log::error!("generating qr code from [{QRCODE}]: {err}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(header::CONTENT_TYPE, "application/octet-stream")], + EMPTY.to_vec(), + ); + } + }; + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "image/svg+xml")], + qr_str.as_bytes().to_vec(), + ) +} + async fn handle_http_static(req: Request) -> impl IntoResponse { use mime_sniffer::MimeTypeSniffer; const INDEX_FILE: &[u8] = include_bytes!("../../werewolves/dist/index.html"); diff --git a/werewolves/index.scss b/werewolves/index.scss index b4f7151..396723a 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -355,6 +355,17 @@ button.confirm { font-size: 2rem; } +.roles-in-setup { + border: 1px solid rgba(255, 255, 255, 0.6); + padding: 10px; + + &>h3 { + margin: 0; + text-align: center; + color: rgba(255, 255, 255, 0.6); + } +} + .role-list { list-style: none; @@ -980,6 +991,7 @@ input { text-align: center; & button label { + color: white; cursor: pointer; } } @@ -1058,10 +1070,11 @@ input { gap: 10px; .assignment { + color: white; text-align: center; padding-left: 10px; padding-right: 10px; - border: 1px solid white; + // border: 1px solid white; &>* { cursor: pointer; @@ -1169,3 +1182,28 @@ input { } } } + +.qrcode { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + position: fixed; + top: 0; + left: 0; + margin: 5vw; + width: 90vw; + height: 90vh; + gap: 1cm; + + img { + height: 100%; + width: 100%; + } + + .details { + // height: 100%; + // width: 100%; + border: 1px solid $village_border; + background-color: color.change($village_color, $alpha: 0.3); + } +} diff --git a/werewolves/src/clients/host/host.rs b/werewolves/src/clients/host/host.rs index 5a6ff0c..9c555a2 100644 --- a/werewolves/src/clients/host/host.rs +++ b/werewolves/src/clients/host/host.rs @@ -31,6 +31,7 @@ use crate::{ action::{ActionResultView, Prompt}, host::{DaytimePlayerList, Setup}, }, + storage::StorageKey, }; use crate::WerewolfError; @@ -182,6 +183,7 @@ pub enum HostEvent { PlayerList(Box<[PlayerState]>), Settings(GameSettings), Error(GameError), + QrMode(bool), } #[derive(Debug, Clone)] pub enum HostState { @@ -209,6 +211,7 @@ pub enum HostState { impl From for HostEvent { fn from(msg: ServerToHostMessage) -> Self { match msg { + ServerToHostMessage::QrMode(mode) => HostEvent::QrMode(mode), ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected), ServerToHostMessage::Daytime { characters, @@ -244,6 +247,7 @@ pub struct Host { state: HostState, error_callback: Callback>, big_screen: bool, + qr_mode: bool, debug: bool, } @@ -269,6 +273,7 @@ impl Component for Host { state: HostState::Disconnected, debug: option_env!("DEBUG").is_some(), big_screen: false, + qr_mode: false, error_callback: Callback::from(|err| { if let Some(err) = err { log::error!("{err}") @@ -453,6 +458,10 @@ impl Component for Host { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { + HostEvent::QrMode(mode) => { + self.qr_mode = mode; + true + } HostEvent::PlayerList(mut players) => { const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap(); players.sort_by(|l, r| { @@ -582,18 +591,50 @@ impl Component for Host { impl Host { fn lobby_big_screen_show_setup(&self, _: Rc<[PlayerState]>, settings: GameSettings) -> Html { + if !self.qr_mode { + return html! { + + }; + } + let qrcode_url = if option_env!("LOCAL").is_some() { + String::from("http://192.168.1.162:8080/qrcode") + } else { + String::from("/qrcode") + }; html! { - +
+ qr code to join +
+

{"scan the qrcode to join"}

+
+
} } fn lobby_setup(&self, players: Rc<[PlayerState]>, settings: GameSettings) -> Html { let on_error = self.error_callback.clone(); + let qr_mode_toggle = { + let on_click = crate::callback::send_message( + HostMessage::Lobby(HostLobbyMessage::SetQrMode(!self.qr_mode)), + self.send.clone(), + ); + let text = if self.qr_mode { + "disable qr mode" + } else { + "enable qr mode" + }; + html! { + + } + }; let settings = self.big_screen.not().then(|| { let send = self.send.clone(); - let on_changed = Callback::from(move |s| { + let on_changed = Callback::from(move |s: GameSettings| { let send = send.clone(); + if let Err(err) = s.save_to_storage() { + log::error!("saving settings to local storage: {err}"); + } yew::platform::spawn_local(async move { let mut send = send.clone(); if let Err(err) = send @@ -614,6 +655,7 @@ impl Host { let on_start = Callback::from(move |_| { let send = send.clone(); let on_error = on_error.clone(); + yew::platform::spawn_local(async move { let mut send = send.clone(); if let Err(err) = send.send(HostMessage::Lobby(HostLobbyMessage::Start)).await { @@ -627,6 +669,7 @@ impl Host { on_start={on_start} on_update={on_changed} players_in_lobby={players.clone()} + qr_mode_button={qr_mode_toggle} /> } }); diff --git a/werewolves/src/components/host/setup.rs b/werewolves/src/components/host/setup.rs index 87dccc3..9bc52c9 100644 --- a/werewolves/src/components/host/setup.rs +++ b/werewolves/src/components/host/setup.rs @@ -115,7 +115,7 @@ pub fn SetupCategory( let all_roles = roles_in_category .into_iter() .map(|r| (r, roles.iter().filter(|sr| sr.title() == r).count())) - .filter(|(_, count)| !(matches!(mode, CategoryMode::ShowExactRoleCount) && *count == 0)) + .filter(|(_, count)| !(matches!(category, Category::Wolves) && *count == 0)) .map(|(r, count)| { let as_role = r.into_role(); let wakes = as_role.wakes_night_zero().then_some("wakes"); diff --git a/werewolves/src/components/settings.rs b/werewolves/src/components/settings.rs index 512e748..3638741 100644 --- a/werewolves/src/components/settings.rs +++ b/werewolves/src/components/settings.rs @@ -18,8 +18,7 @@ pub struct SettingsProps { pub players_in_lobby: Rc<[PlayerState]>, pub on_update: Callback, pub on_start: Callback<()>, - #[prop_or_default] - pub on_error: Option>, + pub qr_mode_button: Html, } #[function_component] @@ -29,7 +28,7 @@ pub fn Settings( players_in_lobby, on_update, on_start, - on_error, + qr_mode_button, }: &SettingsProps, ) -> Html { let players = players_in_lobby @@ -83,18 +82,22 @@ pub fn Settings( .collect::(); let add_roles_update = on_update.clone(); - let add_roles_buttons = RoleTitle::ALL - .iter() + let sorted_role_tiles = { + let mut v = RoleTitle::ALL.to_vec(); + v.sort_by_key(|v| Into::::into(*v).category()); + v + }; + let add_roles_buttons = sorted_role_tiles + .into_iter() .map(|r| { let update = add_roles_update.clone(); let settings = settings.clone(); - let role = *r; let on_click = Callback::from(move |_| { let mut settings = (*settings).clone(); - settings.new_slot(role); + settings.new_slot(r); update.emit(settings); }); - let class = Into::::into(*r).category().class(); + let class = Into::::into(r).category().class(); let name = r.to_string().to_case(Case::Title); html! { } }) - }) - .collect::(); + }) + .collect::(); html! { <> @@ -246,20 +251,24 @@ pub fn Settings( html! {
+ {qr_mode_button.clone()} {fill_empty_with_villagers} {clear_setup} {clear_all_assignments} {clear_bad_assigned}
- {assignments}

{format!("min roles for setup: {}", settings.min_players_needed())}

{format!("current role count: {}", settings.slots().len())}

{add_roles_buttons}
-
- {roles} +
+

{"roles in the game"}

+
+ {roles} +
+ {assignments}