diff --git a/Cargo.lock b/Cargo.lock index 20ead26..16cecd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db05ffb6856bf0ecdf6367558a76a0e8a77b1713044eb92845c692100ed50190" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -2415,7 +2424,7 @@ version = "0.1.0" dependencies = [ "chrono", "ciborium", - "convert_case", + "convert_case 0.8.0", "futures", "getrandom 0.3.3", "gloo 0.11.0", @@ -2441,7 +2450,8 @@ dependencies = [ name = "werewolves-macros" version = "0.1.0" dependencies = [ - "convert_case", + "chrono", + "convert_case 0.9.0", "proc-macro2", "quote", "syn 2.0.106", diff --git a/werewolves-macros/Cargo.toml b/werewolves-macros/Cargo.toml index 6545f89..a2a0fe5 100644 --- a/werewolves-macros/Cargo.toml +++ b/werewolves-macros/Cargo.toml @@ -10,4 +10,5 @@ proc-macro = true proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "extra-traits"] } -convert_case = { version = "0.8" } +convert_case = { version = "0.9" } +chrono = { version = "0.4" } diff --git a/werewolves-macros/src/lib.rs b/werewolves-macros/src/lib.rs index 7e1a5d2..274ebc8 100644 --- a/werewolves-macros/src/lib.rs +++ b/werewolves-macros/src/lib.rs @@ -16,6 +16,7 @@ use core::error::Error; use std::{ io, path::{Path, PathBuf}, + process::Command, }; use convert_case::Casing; @@ -574,3 +575,73 @@ pub fn ref_and_mut(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let ref_and_mut = parse_macro_input!(input as RefAndMut); quote! {#ref_and_mut}.into() } +#[proc_macro] +pub fn build_dirty(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + if !input.is_empty() { + panic!("build_dirty doesn't take arguments"); + } + + let git_state = Command::new("git") + .arg("diff") + .arg("--stat") + .output() + .unwrap(); + if !git_state.status.success() { + panic!("git diff --stat failed"); + } + let dirty = !git_state.stdout.is_empty(); + + quote! {#dirty}.into() +} + +#[proc_macro] +pub fn build_id(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + if !input.is_empty() { + panic!("build_id doesn't take arguments"); + } + let output = Command::new("git") + .arg("rev-parse") + .arg("--short") + .arg("HEAD") + .output() + .unwrap(); + if !output.status.success() { + panic!("git rev-parse --short HEAD failed"); + } + + let output = String::from_utf8(output.stdout).unwrap(); + let git_ref = output.trim(); + + quote! {#git_ref}.into() +} + +#[proc_macro] +pub fn build_id_long(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + if !input.is_empty() { + panic!("build_id doesn't take arguments"); + } + let output = Command::new("git") + .arg("rev-parse") + .arg("HEAD") + .output() + .unwrap(); + if !output.status.success() { + panic!("git rev-parse --short HEAD failed"); + } + + let output = String::from_utf8(output.stdout).unwrap(); + let git_ref = output.trim(); + + quote! {#git_ref}.into() +} + +#[proc_macro] +pub fn build_time(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + if !input.is_empty() { + panic!("build_time doesn't take arguments"); + } + + let time = chrono::Utc::now().format("%d %b %Y %T UTC").to_string(); + + quote! {#time}.into() +} diff --git a/werewolves-proto/src/game/kill.rs b/werewolves-proto/src/game/kill.rs index 60a7314..28f5f96 100644 --- a/werewolves-proto/src/game/kill.rs +++ b/werewolves-proto/src/game/kill.rs @@ -122,7 +122,7 @@ fn resolve_protection( } pub fn resolve_kill( - changes: &mut ChangesLookup<'_>, + changes: &ChangesLookup<'_>, target: CharacterId, died_to: &DiedTo, night: u8, @@ -133,7 +133,7 @@ pub fn resolve_kill( night, starves_if_fails: true, } = died_to - && let Some(protection) = changes.protected_take(target) + && let Some(protection) = changes.protected(target) { return Ok(Some( resolve_protection(*source, died_to, target, &protection, *night).unwrap_or( @@ -149,7 +149,7 @@ pub fn resolve_kill( { let killing_wolf = village.character_by_id(*killing_wolf)?; - match changes.protected_take(target) { + match changes.protected(target) { Some(protection) => { return Ok(resolve_protection( killing_wolf.character_id(), @@ -162,7 +162,7 @@ pub fn resolve_kill( None => { // Wolf kill went through -- can kill shifter return Ok(Some(KillOutcome::Single( - *ss_source, + ss_source, DiedTo::Shapeshift { into: target, night: *night, @@ -172,7 +172,7 @@ pub fn resolve_kill( }; } - let protection = match changes.protected_take(target) { + let protection = match changes.protected(target) { Some(prot) => prot, None => return Ok(Some(KillOutcome::Single(target, died_to.clone()))), }; @@ -187,7 +187,7 @@ pub fn resolve_kill( .ok_or(GameError::GuardianInvalidOriginalKill)?, original_target: target, original_kill: died_to.clone(), - guardian: source, + guardian: *source, night: NonZeroU8::new(night).unwrap(), })), Protection::Guardian { diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index e94e396..3982a4d 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -28,7 +28,7 @@ use crate::{ error::GameError, game::{ GameTime, Village, - kill::{self}, + kill::{self, KillOutcome}, night::changes::{ChangesLookup, NightChange}, }, message::night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits}, @@ -1019,20 +1019,7 @@ impl Night { fn died_to_tonight(&self, character_id: CharacterId) -> Result> { let ch = self.current_changes(); let mut changes = ChangesLookup::new(&ch); - if let Some(died_to) = changes.killed(character_id) - && kill::resolve_kill( - &mut changes, - character_id, - died_to, - self.night, - &self.village, - )? - .is_some() - { - Ok(Some(died_to.clone())) - } else { - Ok(None) - } + changes.died_to(character_id, self.night, &self.village) } /// returns the matching [Character] with the current night's aura changes @@ -1057,6 +1044,16 @@ impl Night { .iter() .flat_map(|(_, _, act)| act.iter()) .cloned() + .chain( + match &self.night_state { + NightState::Active { + current_changes, .. + } => Some(current_changes.iter().cloned()), + NightState::Complete => None, + } + .into_iter() + .flatten(), + ) .collect() } diff --git a/werewolves-proto/src/game/night/changes.rs b/werewolves-proto/src/game/night/changes.rs index c90d154..4cfa197 100644 --- a/werewolves-proto/src/game/night/changes.rs +++ b/werewolves-proto/src/game/night/changes.rs @@ -12,7 +12,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use core::ops::Not; +use core::{num::NonZeroU8, ops::Not}; use super::Result; use serde::{Deserialize, Serialize}; @@ -22,7 +22,10 @@ use crate::{ aura::Aura, character::CharacterId, diedto::DiedTo, - game::{Village, kill}, + game::{ + Village, + kill::{self, KillOutcome}, + }, player::Protection, role::{RoleBlock, RoleTitle}, }; @@ -112,19 +115,56 @@ impl<'a> ChangesLookup<'a> { .collect() } + pub fn kill_outcomes(&self, night: u8, village: &Village) -> Result> { + self.0 + .iter() + .filter_map(|c| match c { + NightChange::Kill { target, died_to } => Some((*target, died_to.clone())), + _ => None, + }) + .map(|(kill_target, died_to)| { + kill::resolve_kill(self, kill_target, &died_to, night, village) + }) + .filter_map(|result| match result { + Ok(Some(outcome)) => Some(Ok(outcome)), + Ok(None) => None, + Err(err) => Some(Err(err)), + }) + .collect::>>() + } + pub fn died_to( - &mut self, + &self, character_id: CharacterId, night: u8, village: &Village, ) -> Result> { - if let Some(died_to) = self.killed(character_id) - && kill::resolve_kill(self, character_id, died_to, night, village)?.is_some() - { - Ok(Some(died_to.clone())) - } else { - Ok(None) - } + let kill_outcomes = self.kill_outcomes(night, village)?; + + Ok(kill_outcomes.into_iter().find_map(|outcome| match outcome { + KillOutcome::Single(target, died_to) => (target == character_id).then_some(died_to), + KillOutcome::Guarding { + original_killer, + original_target, + original_kill, + guardian, + night, + } => { + if original_killer == character_id { + Some(DiedTo::GuardianProtecting { + source: guardian, + protecting: original_target, + protecting_from: original_killer, + protecting_from_cause: Box::new(original_kill), + night, + }) + } else if guardian == character_id { + Some(original_kill) + } else { + None + } + } + })) } pub fn killed(&self, target: CharacterId) -> Option<&'a DiedTo> { @@ -160,7 +200,7 @@ impl<'a> ChangesLookup<'a> { None } } - pub fn protected(&self, target: &CharacterId) -> Option<&'a Protection> { + pub fn protected(&self, target: CharacterId) -> Option<&'a Protection> { self.0.iter().enumerate().find_map(|(idx, c)| { self.1 .contains(&idx) @@ -169,20 +209,27 @@ impl<'a> ChangesLookup<'a> { NightChange::Protection { target: t, protection, - } => (t == target).then_some(protection), + } => (*t == target).then_some(protection), _ => None, }) .flatten() }) } - pub fn shapeshifter(&self) -> Option<&'a CharacterId> { + pub fn shapeshifter(&self) -> Option { + self.shapeshift_change().and_then(|c| match c { + NightChange::Shapeshift { source, .. } => Some(source), + _ => None, + }) + } + + pub fn shapeshift_change(&self) -> Option { self.0.iter().enumerate().find_map(|(idx, c)| { self.1 .contains(&idx) .not() .then_some(match c { - NightChange::Shapeshift { source, .. } => Some(source), + NightChange::Shapeshift { .. } => Some(c.clone()), _ => None, }) .flatten() diff --git a/werewolves-proto/src/game/night/next.rs b/werewolves-proto/src/game/night/next.rs index 107e9ea..2895464 100644 --- a/werewolves-proto/src/game/night/next.rs +++ b/werewolves-proto/src/game/night/next.rs @@ -21,7 +21,7 @@ use crate::{ error::GameError, game::night::{CurrentResult, Night, NightState, changes::NightChange}, message::night::{ActionPrompt, ActionResult}, - role::RoleBlock, + role::{RoleBlock, RoleTitle}, }; use super::Result; @@ -82,7 +82,7 @@ impl Night { } => return Err(GameError::AwaitingResponse), NightState::Complete => return Err(GameError::NightOver), } - if let Some(prompt) = self.action_queue.pop_front() { + if let Some(prompt) = self.pull_next_prompt_with_dead_ignore()? { if let ActionPrompt::Insomniac { character_id } = &prompt && self.get_visits_for(character_id.character_id).is_empty() { @@ -188,4 +188,25 @@ impl Night { *result = ActionResult::RoleBlocked; }); } + + fn pull_next_prompt_with_dead_ignore(&mut self) -> Result> { + let has_living_beholder = self + .village + .characters() + .into_iter() + .any(|c| matches!(c.role_title(), RoleTitle::Beholder)); + while let Some(prompt) = self.action_queue.pop_front() { + let Some(char_id) = prompt.character_id() else { + return Ok(Some(prompt)); + }; + match (self.died_to_tonight(char_id)?, has_living_beholder) { + (Some(_), false) => {} + (Some(DiedTo::Shapeshift { .. }), _) | (Some(_), true) | (None, _) => { + return Ok(Some(prompt)); + } + } + } + + Ok(None) + } } diff --git a/werewolves-proto/src/game/village/apply.rs b/werewolves-proto/src/game/village/apply.rs index 3bc4a57..3087181 100644 --- a/werewolves-proto/src/game/village/apply.rs +++ b/werewolves-proto/src/game/village/apply.rs @@ -52,7 +52,7 @@ impl Village { GameTime::Day { .. } => return Err(GameError::NotNight), GameTime::Night { number } => number, }; - let mut changes = ChangesLookup::new(all_changes); + let changes = ChangesLookup::new(all_changes); let mut new_village = self.clone(); @@ -93,7 +93,7 @@ impl Village { .died_to(hunter_character.character_id(), night, self)? .is_some() && let Some(kill) = kill::resolve_kill( - &mut changes, + &changes, *target, &DiedTo::Hunter { killer: *source, @@ -108,8 +108,7 @@ impl Village { } } NightChange::Kill { target, died_to } => { - if let Some(kill) = - kill::resolve_kill(&mut changes, *target, died_to, night, self)? + if let Some(kill) = kill::resolve_kill(&changes, *target, died_to, night, self)? { if let KillOutcome::Guarding { guardian, @@ -135,7 +134,7 @@ impl Village { } NightChange::Shapeshift { source, into } => { if let Some(target) = changes.wolf_pack_kill_target() - && changes.protected(target).is_none() + && changes.protected(*target).is_none() { if *target != *into { log::error!("shapeshift into({into}) != target({target})"); diff --git a/werewolves-proto/src/game_test/changes.rs b/werewolves-proto/src/game_test/changes.rs new file mode 100644 index 0000000..6e523e2 --- /dev/null +++ b/werewolves-proto/src/game_test/changes.rs @@ -0,0 +1,311 @@ +// 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 core::num::NonZeroU8; + +#[allow(unused)] +use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; + +use crate::{ + diedto::DiedTo, + game::{ + Game, GameSettings, SetupRole, + night::changes::{ChangesLookup, NightChange}, + }, + game_test::{GameExt, SettingsExt, gen_players, init_log}, + player::Protection, +}; + +#[test] +fn shapeshift() { + init_log(); + let players = gen_players(1..10); + let mut player_ids = players.iter().map(|p| p.player_id); + let target = player_ids.next().unwrap(); + let shapeshifter = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Villager, target); + settings.add_and_assign(SetupRole::Shapeshifter, shapeshifter); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(9); + let game = Game::new(&players, settings).unwrap(); + + let all_changes = [ + NightChange::Kill { + target: game.character_by_player_id(target).character_id(), + died_to: DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap(), + }, + }, + NightChange::Shapeshift { + source: game.character_by_player_id(shapeshifter).character_id(), + into: game.character_by_player_id(target).character_id(), + }, + ]; + let changes = ChangesLookup::new(&all_changes); + assert_eq!( + changes.died_to( + game.character_by_player_id(target).character_id(), + 1, + game.village() + ), + Ok(None) + ); + assert_eq!( + changes.died_to( + game.character_by_player_id(shapeshifter).character_id(), + 1, + game.village() + ), + Ok(Some(DiedTo::Shapeshift { + into: game.character_by_player_id(target).character_id(), + night: NonZeroU8::new(1).unwrap() + })) + ); +} + +#[test] +fn guardian_protect() { + init_log(); + let players = gen_players(1..10); + let mut player_ids = players.iter().map(|p| p.player_id); + let target = player_ids.next().unwrap(); + let guardian = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Villager, target); + settings.add_and_assign(SetupRole::Guardian, guardian); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(9); + let game = Game::new(&players, settings).unwrap(); + + let all_changes = [ + NightChange::Kill { + target: game.character_by_player_id(target).character_id(), + died_to: DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap(), + }, + }, + NightChange::Protection { + target: game.character_by_player_id(target).character_id(), + protection: Protection::Guardian { + source: game.character_by_player_id(guardian).character_id(), + guarding: false, + }, + }, + ]; + let changes = ChangesLookup::new(&all_changes); + assert_eq!( + changes.died_to( + game.character_by_player_id(target).character_id(), + 1, + game.village() + ), + Ok(None) + ); + assert_eq!( + changes.died_to( + game.character_by_player_id(guardian).character_id(), + 1, + game.village() + ), + Ok(None) + ); +} + +#[test] +fn guardian_guard() { + init_log(); + let players = gen_players(1..10); + let mut player_ids = players.iter().map(|p| p.player_id); + let target = player_ids.next().unwrap(); + let guardian = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Villager, target); + settings.add_and_assign(SetupRole::Guardian, guardian); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(9); + let game = Game::new(&players, settings).unwrap(); + + let all_changes = [ + NightChange::Kill { + target: game.character_by_player_id(target).character_id(), + died_to: DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap(), + }, + }, + NightChange::Protection { + target: game.character_by_player_id(target).character_id(), + protection: Protection::Guardian { + source: game.character_by_player_id(guardian).character_id(), + guarding: true, + }, + }, + ]; + let changes = ChangesLookup::new(&all_changes); + assert_eq!( + changes.died_to( + game.character_by_player_id(target).character_id(), + 1, + game.village() + ), + Ok(None) + ); + assert_eq!( + changes.died_to( + game.character_by_player_id(guardian).character_id(), + 1, + game.village() + ), + Ok(Some(DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap() + })) + ); + assert_eq!( + changes.died_to( + game.character_by_player_id(wolf).character_id(), + 1, + game.village() + ), + Ok(Some(DiedTo::GuardianProtecting { + source: game.character_by_player_id(guardian).character_id(), + protecting: game.character_by_player_id(target).character_id(), + protecting_from: game.character_by_player_id(wolf).character_id(), + protecting_from_cause: Box::new(DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap(), + }), + night: NonZeroU8::new(1).unwrap(), + })) + ); +} + +#[test] +fn guardian_protect_from_multiple_attackers() { + init_log(); + let players = gen_players(1..10); + let mut player_ids = players.iter().map(|p| p.player_id); + let target = player_ids.next().unwrap(); + let guardian = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let militia = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Villager, target); + settings.add_and_assign(SetupRole::Villager, militia); + settings.add_and_assign(SetupRole::Guardian, guardian); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(9); + let game = Game::new(&players, settings).unwrap(); + + let all_changes = [ + NightChange::Kill { + target: game.character_by_player_id(target).character_id(), + died_to: DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap(), + }, + }, + NightChange::Kill { + target: game.character_by_player_id(target).character_id(), + died_to: DiedTo::Militia { + killer: game.character_by_player_id(militia).character_id(), + night: NonZeroU8::new(1).unwrap(), + }, + }, + NightChange::Protection { + target: game.character_by_player_id(target).character_id(), + protection: Protection::Guardian { + source: game.character_by_player_id(guardian).character_id(), + guarding: false, + }, + }, + ]; + let changes = ChangesLookup::new(&all_changes); + assert_eq!( + changes.died_to( + game.character_by_player_id(target).character_id(), + 1, + game.village() + ), + Ok(None) + ); + assert_eq!( + changes.died_to( + game.character_by_player_id(guardian).character_id(), + 1, + game.village() + ), + Ok(None) + ); +} + +#[test] +fn guardian_protect_someone_else_unprotected_dies() { + init_log(); + let players = gen_players(1..10); + let mut player_ids = players.iter().map(|p| p.player_id); + let target = player_ids.next().unwrap(); + let guardian = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Seer, target); + settings.add_and_assign(SetupRole::Guardian, guardian); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(9); + let game = Game::new(&players, settings).unwrap(); + + let all_changes = [ + NightChange::Kill { + target: game.character_by_player_id(target).character_id(), + died_to: DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap(), + }, + }, + NightChange::Protection { + target: game.living_villager().character_id(), + protection: Protection::Guardian { + source: game.character_by_player_id(guardian).character_id(), + guarding: false, + }, + }, + ]; + let changes = ChangesLookup::new(&all_changes); + assert_eq!( + changes.died_to( + game.character_by_player_id(target).character_id(), + 1, + game.village() + ), + Ok(Some(DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf).character_id(), + night: NonZeroU8::new(1).unwrap() + })) + ); + assert_eq!( + changes.died_to( + game.character_by_player_id(guardian).character_id(), + 1, + game.village() + ), + Ok(None) + ); +} diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index 40c94d6..007f6d0 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -12,6 +12,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod changes; mod night_order; mod previous; mod revert; diff --git a/werewolves-proto/src/game_test/role/scapegoat.rs b/werewolves-proto/src/game_test/role/scapegoat.rs index 3409e77..aaffd24 100644 --- a/werewolves-proto/src/game_test/role/scapegoat.rs +++ b/werewolves-proto/src/game_test/role/scapegoat.rs @@ -100,10 +100,6 @@ fn redeemed_scapegoat_role_changes() { game.mark_and_check(seer); game.r#continue().sleep(); - assert_eq!(game.next().title(), ActionPromptTitle::Seer); - game.mark_and_check(wolf_char_id); - assert_eq!(game.r#continue().seer(), Alignment::Wolves); - game.r#continue().sleep(); game.next_expect_day(); assert_eq!( diff --git a/werewolves/index.scss b/werewolves/index.scss index 8d9f6a8..6036aea 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -820,12 +820,9 @@ clients { } .client-nav { - // position: absolute; - // left: 0; - // top: 0; max-width: 100%; padding: 10px; - // background-color: rgba(255, 107, 255, 0.2); + height: 37px; display: flex; flex-direction: row; justify-content: baseline; @@ -957,6 +954,11 @@ input { font-size: 2rem; align-content: stretch; margin: 0; + position: absolute; + left: 0; + z-index: 3; + background-color: #000; + min-width: 2cm; & * { margin: 0; @@ -2106,3 +2108,105 @@ li.choice { .story-text { font-size: 1.7em; } + + +.dialog { + z-index: 5; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + position: fixed; + top: 0; + left: 0; + align-items: center; + justify-content: center; + + .dialog-box { + border: 1px solid white; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: center; + padding-left: 30px; + padding-right: 30px; + padding-top: 10px; + padding-bottom: 10px; + background-color: black; + + .options { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 20px; + + &>button { + min-width: 4cm; + font-size: 1em; + } + } + } +} + +.about { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: center; + gap: 10px; + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + } + + p { + margin: 0; + text-align: center; + } + + a { + text-decoration: underline dotted; + color: white; + } + + .links { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: center; + width: 100%; + gap: 10px; + } + + .build-info { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: center; + text-align: center; + + label { + color: hsl(280, 65%, 43%); + } + } +} + +.footer { + width: 100vw; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow: hidden; + max-height: 1cm; + justify-content: center; + background-color: #000; + z-index: 3; + padding: 10px 0 10px 0; + border-top: 1px solid white; +} diff --git a/werewolves/src/clients/client/client.rs b/werewolves/src/clients/client/client.rs index c54ac9d..3bd9e0c 100644 --- a/werewolves/src/clients/client/client.rs +++ b/werewolves/src/clients/client/client.rs @@ -30,7 +30,7 @@ use crate::{ clients::client::connection::{Connection2, ConnectionError}, components::{ Button, CoverOfDarkness, Identity, Story, - client::{ClientNav, Signin}, + client::{ClientFooter, ClientNav, Signin}, }, storage::StorageKey, }; @@ -251,8 +251,9 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { html! { <> - {nav} - {content} + {nav} + {content} + } } diff --git a/werewolves/src/components/client/footer.rs b/werewolves/src/components/client/footer.rs new file mode 100644 index 0000000..080b712 --- /dev/null +++ b/werewolves/src/components/client/footer.rs @@ -0,0 +1,110 @@ +// 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 crate::components::{Dialog, WithConfirmation}; + +const SOURCE_CODE_URL: &str = "https://sectorinf.com/emilis/werewolves"; + +#[function_component] +pub fn ClientFooter() -> Html { + let about_dialog_state = use_state(|| false); + let about_dialog = about_dialog_state.then(|| { + let cancel_signout = { + let dialog = about_dialog_state.clone(); + Callback::from(move |_| { + dialog.set(false); + }) + }; + let callback = Callback::from(move |_| ()); + let options: Box<[String]> = Box::new([]); + html! { + + + + } + }); + let about_click = { + let dialog_set = about_dialog_state.setter(); + move |_| { + dialog_set.set(true); + } + }; + + html! { + + } +} + +#[function_component] +pub fn About() -> Html { + let confirm_state = use_state(|| false); + let source_code_confirm = { + let confirm_callback = { + move |_| { + let _ = gloo::utils::window().location().set_href(SOURCE_CODE_URL); + } + }; + let message = html! { + <> +

{"this will take you away from the game"}

+

{"make sure this isn't an oopsie"}

+ + }; + html! { + + {"source code"} + + } + }; + let dirty = crate::BUILD_DIRTY.then_some(html! { + <> + {" "} + {"(dirty)"} + + }); + html! { +
+

{"werewolves"}

+
+

+ + + {crate::BUILD_ID} + {dirty} + +

+

+ + {crate::BUILD_TIME} +

+
+ +
+ } +} diff --git a/werewolves/src/components/client/nav.rs b/werewolves/src/components/client/nav.rs index 3e7ef58..d9a244f 100644 --- a/werewolves/src/components/client/nav.rs +++ b/werewolves/src/components/client/nav.rs @@ -53,6 +53,9 @@ pub fn ClientNav( } }); + let number_open = use_state(|| false); + let name_open = use_state(|| false); + let pronouns_open = use_state(|| false); let number = { let current_value = use_state(String::new); let message_callback = message_callback.clone(); @@ -63,8 +66,7 @@ pub fn ClientNav( .number .map(|v| v.to_string()) .unwrap_or_else(|| String::from("???")); - let open = use_state(|| false); - let open_set = open.setter(); + let open_set = number_open.setter(); let on_submit = { let val = current_value.clone(); Callback::from(move |_| { @@ -85,19 +87,27 @@ pub fn ClientNav( open_set.set(false); }) }; + let close_others = { + let name_open = name_open.clone(); + let pronouns_open = pronouns_open.clone(); + Callback::from(move |_| { + name_open.set(false); + pronouns_open.set(false); + }) + }; html! {
{current_num}
} }; let name = { - let open = use_state(|| false); let name = use_state(String::new); let on_submit = { let ident = identity.clone(); @@ -115,20 +125,29 @@ pub fn ClientNav( }) }) }; + let close_others = { + let number_open = number_open.clone(); + let pronouns_open = pronouns_open.clone(); + Callback::from(move |_| { + number_open.set(false); + pronouns_open.set(false); + }) + }; html! {
{identity.1.name.as_str()}
} }; let pronouns = { - let pronouns_state = use_state(String::new); + let pronuns_state = use_state(String::new); let on_submit = { let ident = identity.clone(); @@ -145,14 +164,22 @@ pub fn ClientNav( }) }) }; - let open = use_state(|| false); + let close_others = { + let number_open = number_open.clone(); + let name_open = name_open.clone(); + Callback::from(move |_| { + number_open.set(false); + name_open.set(false); + }) + }; html! { {pronouns} @@ -199,6 +226,8 @@ struct ClickableTextEditProps { pub state: UseStateHandle, #[prop_or(100)] pub max_length: usize, + #[prop_or_default] + pub on_open: Option>, } #[function_component] @@ -211,6 +240,7 @@ fn ClickableTextEdit( on_submit, state, max_length, + on_open, }: &ClickableTextEditProps, ) -> Html { let on_input = crate::components::input_element_string_oninput(value.setter(), *max_length); @@ -237,7 +267,7 @@ fn ClickableTextEdit( }; html! { - + {children.clone()} } diff --git a/werewolves/src/components/dialog.rs b/werewolves/src/components/dialog.rs new file mode 100644 index 0000000..1b4d860 --- /dev/null +++ b/werewolves/src/components/dialog.rs @@ -0,0 +1,139 @@ +// 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 web_sys::Element; +use yew::prelude::*; + +use crate::components::Button; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct DialogProps { + #[prop_or_default] + pub children: Html, + pub options: Box<[String]>, + #[prop_or_default] + pub cancel_callback: Option>, + pub callback: Callback, +} + +#[function_component] +pub fn Dialog( + DialogProps { + children, + options, + cancel_callback, + callback, + }: &DialogProps, +) -> Html { + let options = options + .iter() + .map(|opt| { + let callback = callback.clone(); + let option = opt.clone(); + let cb = Callback::from(move |_| { + callback.emit(option.clone()); + }); + html! { + + } + }) + .collect::(); + let backdrop_click = cancel_callback.clone().map(|cancel_callback| { + Callback::from(move |ev: MouseEvent| { + if let Some(div) = ev.target_dyn_into::() + && div.class_name() == "dialog" + { + ev.stop_propagation(); + cancel_callback.emit(()); + } + }) + }); + + html! { +
+
+
+
+ {children.clone()} +
+
+ {options} +
+
+
+
+ } +} + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct WithConfirmationProps { + pub state: UseStateHandle, + #[prop_or_default] + pub children: Html, + pub confirm_callback: Callback<()>, + pub message: Html, +} + +#[function_component] +pub fn WithConfirmation( + WithConfirmationProps { + state, + children, + confirm_callback, + message, + }: &WithConfirmationProps, +) -> Html { + let about_dialog_state = state.clone(); + let confirmation_dialog = about_dialog_state.then(|| { + let cancel_signout = { + let dialog = about_dialog_state.clone(); + Callback::from(move |_| { + dialog.set(false); + }) + }; + let confirm_callback = confirm_callback.clone(); + let callback = { + let dialog = about_dialog_state.clone(); + Callback::from(move |opt: String| { + if opt == "ok" { + confirm_callback.emit(()); + } else { + dialog.set(false); + } + }) + }; + let options: Box<[String]> = Box::new([String::from("ok"), String::from("take me back")]); + html! { + + {message.clone()} + + } + }); + let confirmation_click = { + let dialog_set = about_dialog_state.setter(); + move |_| { + dialog_set.set(true); + } + }; + html! { + <> + + {confirmation_dialog} + + } +} diff --git a/werewolves/src/components/field.rs b/werewolves/src/components/field.rs index 83876f9..cecd33c 100644 --- a/werewolves/src/components/field.rs +++ b/werewolves/src/components/field.rs @@ -28,6 +28,8 @@ pub struct ClickableFieldProps { #[prop_or_default] pub with_backdrop_exit: bool, pub state: UseStateHandle, + #[prop_or_default] + pub on_open: Option>, } #[function_component] @@ -39,11 +41,20 @@ pub fn ClickableField( button_class, with_backdrop_exit, state, + on_open, }: &ClickableFieldProps, ) -> Html { let open = state.clone(); - let on_click_open = open.clone(); - let open_close = Callback::from(move |_| on_click_open.set(!(*on_click_open))); + let open_close = { + let open = open.clone(); + let on_open = on_open.clone(); + Callback::from(move |_| { + if !*open && let Some(on_open) = on_open.as_ref() { + on_open.emit(()); + } + open.set(!(*open)); + }) + }; let submenu_open_close = open_close.clone(); let submenu = open.clone().then(|| { let backdrop = with_backdrop_exit.then(|| { @@ -79,6 +90,8 @@ pub struct ClickableNumberEditProps { pub on_submit: Callback<()>, pub field_name: &'static str, pub state: UseStateHandle, + #[prop_or_default] + pub on_open: Option>, } #[function_component] @@ -89,6 +102,7 @@ pub fn ClickableNumberEdit( field_name, on_submit, state, + on_open, }: &ClickableNumberEditProps, ) -> Html { let on_input = crate::components::input_element_string_oninput(value.setter(), 20); @@ -101,7 +115,7 @@ pub fn ClickableNumberEdit( }; html! { - + {children.clone()} } diff --git a/werewolves/src/main.rs b/werewolves/src/main.rs index e38b059..ab13265 100644 --- a/werewolves/src/main.rs +++ b/werewolves/src/main.rs @@ -40,6 +40,11 @@ use pages::{ErrorComponent, WerewolfError}; use web_sys::Url; use yew::{context::ContextProviderProps, prelude::*}; +const BUILD_ID: &str = werewolves_macros::build_id!(); +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}, @@ -47,6 +52,7 @@ use crate::clients::{ fn main() { wasm_logger::init(wasm_logger::Config::new(log::Level::Trace)); + log::debug!("starting werewolves build {BUILD_ID}"); let document = gloo::utils::document(); let url = document.document_uri().expect("get uri"); let url_obj = Url::new(&url).unwrap();