diff --git a/werewolves-proto/Cargo.toml b/werewolves-proto/Cargo.toml index 7984809..1413ec8 100644 --- a/werewolves-proto/Cargo.toml +++ b/werewolves-proto/Cargo.toml @@ -9,7 +9,7 @@ log = { version = "0.4" } serde_json = { version = "1.0" } serde = { version = "1.0", features = ["derive"] } uuid = { version = "1.17", features = ["v4", "serde"] } -rand = { version = "0.9" } +rand = { version = "0.9", features = ["std_rng"] } werewolves-macros = { path = "../werewolves-macros" } [dev-dependencies] diff --git a/werewolves-proto/src/aura.rs b/werewolves-proto/src/aura.rs index 6d35d9c..dcd99e2 100644 --- a/werewolves-proto/src/aura.rs +++ b/werewolves-proto/src/aura.rs @@ -1,5 +1,3 @@ -use core::fmt::Display; - // Copyright (C) 2025 Emilis Bliūdžius // // This program is free software: you can redistribute it and/or modify @@ -14,33 +12,37 @@ use core::fmt::Display; // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use core::fmt::Display; + use serde::{Deserialize, Serialize}; -use werewolves_macros::ChecksAs; +use werewolves_macros::{ChecksAs, Titles}; use crate::{ + bag::DrunkBag, game::{GameTime, Village}, role::{Alignment, Killer, Powerful}, team::Team, }; const BLOODLET_DURATION_DAYS: u8 = 2; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ChecksAs, Titles)] pub enum Aura { + #[checks("assignable")] Traitor, + #[checks("assignable")] #[checks("cleansible")] - Drunk, + Drunk(DrunkBag), + #[checks("assignable")] Insane, #[checks("cleansible")] - Bloodlet { - night: u8, - }, + Bloodlet { night: u8 }, } impl Display for Aura { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Aura::Traitor => "Traitor", - Aura::Drunk => "Drunk", + Aura::Drunk(_) => "Drunk", Aura::Insane => "Insane", Aura::Bloodlet { .. } => "Bloodlet", }) @@ -50,7 +52,7 @@ impl Display for Aura { impl Aura { pub const fn expired(&self, village: &Village) -> bool { match self { - Aura::Traitor | Aura::Drunk | Aura::Insane => false, + Aura::Traitor | Aura::Drunk(_) | Aura::Insane => false, Aura::Bloodlet { night: applied_night, } => match village.time() { @@ -88,8 +90,16 @@ impl Auras { &self.0 } - pub fn remove_aura(&mut self, aura: Aura) { - self.0.retain(|a| *a != aura); + pub fn list_mut(&mut self) -> &mut [Aura] { + &mut self.0 + } + + pub fn remove_aura(&mut self, aura: AuraTitle) { + self.0.retain(|a| a.title() != aura); + } + + pub fn get_mut(&mut self, aura: AuraTitle) -> Option<&mut Aura> { + self.0.iter_mut().find(|a| a.title() == aura) } /// purges expired [Aura]s and returns the ones that were removed @@ -129,7 +139,7 @@ impl Auras { match aura { Aura::Traitor => return Some(Alignment::Traitor), Aura::Bloodlet { .. } => return Some(Alignment::Wolves), - Aura::Drunk | Aura::Insane => {} + Aura::Drunk(_) | Aura::Insane => {} } } None @@ -151,3 +161,14 @@ impl Auras { .then_some(Powerful::Powerful) } } + +impl AuraTitle { + pub fn into_aura(self) -> Aura { + match self { + AuraTitle::Traitor => Aura::Traitor, + AuraTitle::Drunk => Aura::Drunk(DrunkBag::default()), + AuraTitle::Insane => Aura::Insane, + AuraTitle::Bloodlet => Aura::Bloodlet { night: 0 }, + } + } +} diff --git a/werewolves-proto/src/bag.rs b/werewolves-proto/src/bag.rs new file mode 100644 index 0000000..4715455 --- /dev/null +++ b/werewolves-proto/src/bag.rs @@ -0,0 +1,160 @@ +// 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 rand::{SeedableRng, rngs::SmallRng, seq::SliceRandom}; +// #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +// pub enum BagItem { +// Left(T), +// Right(V), +// } +use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Bag(Vec); + +impl Bag { + pub fn new(items: impl IntoIterator) -> Self { + Self(items.into_iter().collect()) + } + + pub fn pull(&mut self) -> Option { + self.0.pop() + } + + pub fn peek(&self) -> Option<&T> { + self.0.last() + } + + pub const fn len(&self) -> usize { + self.0.len() + } +} +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] +pub enum DrunkRoll { + Drunk, + #[default] + Sober, +} +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct DrunkBag { + #[serde(skip)] + rng: SmallRng, + seed: u64, + bag_number: usize, + bag: Bag, +} + +impl<'de> Deserialize<'de> for DrunkBag { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct DrunkBagNoRng { + seed: u64, + bag_number: usize, + bag: Bag, + } + let DrunkBagNoRng { + seed, + bag_number, + bag, + } = DrunkBagNoRng::deserialize(deserializer)?; + let mut rng = SmallRng::seed_from_u64(seed); + // Shuffle the default bag bag_number of times to get the smallrng to the same state + for _ in 0..bag_number { + Self::DEFAULT_BAG + .iter() + .copied() + .collect::>() + .shuffle(&mut rng); + } + Ok(Self { + rng, + seed, + bag_number, + bag, + }) + } +} + +impl Default for DrunkBag { + fn default() -> Self { + Self::new() + } +} + +impl DrunkBag { + const DEFAULT_BAG: &[DrunkRoll] = &[ + DrunkRoll::Drunk, + DrunkRoll::Drunk, + DrunkRoll::Sober, + DrunkRoll::Sober, + DrunkRoll::Sober, + ]; + + #[cfg(test)] + #[doc(hidden)] + pub fn all_drunk() -> Self { + Self { + rng: SmallRng::seed_from_u64(0), + seed: 0, + bag_number: 1, + bag: Bag::new([ + DrunkRoll::Drunk, + DrunkRoll::Drunk, + DrunkRoll::Drunk, + DrunkRoll::Drunk, + DrunkRoll::Drunk, + ]), + } + } + + pub fn new() -> Self { + let seed = rand::random(); + let mut rng = SmallRng::seed_from_u64(seed); + let mut starting_bag = Self::DEFAULT_BAG.iter().copied().collect::>(); + starting_bag.shuffle(&mut rng); + let bag = Bag::new(starting_bag); + + Self { + rng, + seed, + bag, + bag_number: 1, + } + } + + pub fn peek(&self) -> DrunkRoll { + self.bag.peek().copied().unwrap_or_default() + } + + fn next_bag(&mut self) { + let mut starting_bag = Self::DEFAULT_BAG.iter().copied().collect::>(); + starting_bag.shuffle(&mut self.rng); + self.bag = Bag::new(starting_bag); + self.bag_number += 1; + } + + pub fn pull(&mut self) -> DrunkRoll { + if self.bag.len() < 2 { + *self = Self::new(); + } else if self.bag.len() == 2 { + let pulled = self.bag.pull().unwrap_or_default(); + self.next_bag(); + return pulled; + } + self.bag.pull().unwrap_or_default() + } +} diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs index df81bd4..c67e8d7 100644 --- a/werewolves-proto/src/character.rs +++ b/werewolves-proto/src/character.rs @@ -22,7 +22,7 @@ use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use crate::{ - aura::{Aura, Auras}, + aura::{Aura, AuraTitle, Auras}, diedto::DiedTo, error::GameError, game::{GameTime, Village}, @@ -207,6 +207,10 @@ impl Character { self.auras.list() } + pub fn auras_mut(&mut self) -> &mut [Aura] { + self.auras.list_mut() + } + pub fn role_change(&mut self, new_role: RoleTitle, at: GameTime) -> Result<()> { let mut role = new_role.title_to_role_excl_apprentice(); core::mem::swap(&mut role, &mut self.role); @@ -304,7 +308,7 @@ impl Character { self.auras.add(aura); } - pub fn remove_aura(&mut self, aura: Aura) { + pub fn remove_aura(&mut self, aura: AuraTitle) { self.auras.remove_aura(aura); } @@ -319,7 +323,13 @@ impl Character { GameTime::Day { number: _ } => return Err(GameError::NotNight), GameTime::Night { number } => number, }; - Ok(Box::new([match &self.role { + let mut prompts = Vec::new(); + if night == 0 && self.auras.list().contains(&Aura::Traitor) { + prompts.push(ActionPrompt::TraitorIntro { + character_id: self.identity(), + }); + } + match &self.role { Role::Empath { cursed: true } | Role::Diseased | Role::Weightlifter @@ -334,11 +344,11 @@ impl Character { woken_for_reveal: true, .. } - | Role::Villager => return Ok(Box::new([])), + | Role::Villager => {} - Role::Insomniac => ActionPrompt::Insomniac { + Role::Insomniac => prompts.push(ActionPrompt::Insomniac { character_id: self.identity(), - }, + }), Role::Scapegoat { redeemed: true } => { let mut dead = village.dead_characters(); @@ -347,99 +357,94 @@ impl Character { .into_iter() .find_map(|d| (d.is_village() && d.is_power_role()).then_some(d.role_title())) { - ActionPrompt::RoleChange { + prompts.push(ActionPrompt::RoleChange { character_id: self.identity(), new_role: pr, - } - } else { - return Ok(Box::new([])); + }); } } - Role::Bloodletter => ActionPrompt::Bloodletter { + Role::Bloodletter => prompts.push(ActionPrompt::Bloodletter { character_id: self.identity(), living_players: village.living_villagers(), marked: None, - }, - Role::Seer => ActionPrompt::Seer { + }), + Role::Seer => prompts.push(ActionPrompt::Seer { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::Arcanist => ActionPrompt::Arcanist { + }), + Role::Arcanist => prompts.push(ActionPrompt::Arcanist { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: (None, None), - }, + }), Role::Protector { last_protected: Some(last_protected), - } => ActionPrompt::Protector { + } => prompts.push(ActionPrompt::Protector { character_id: self.identity(), targets: village.living_players_excluding(*last_protected), marked: None, - }, + }), Role::Protector { last_protected: None, - } => ActionPrompt::Protector { + } => prompts.push(ActionPrompt::Protector { character_id: self.identity(), - targets: village.living_players_excluding(self.character_id()), + targets: village.living_players(), marked: None, + }), + Role::Apprentice(role) => match village.time() { + GameTime::Day { number: _ } => {} + GameTime::Night { + number: current_night, + } => { + if village + .characters() + .into_iter() + .filter(|c| c.role_title() == *role) + .filter_map(|char| char.died_to) + .any(|died_to| match died_to.date_time() { + GameTime::Day { number } => number.get() + 1 >= current_night, + GameTime::Night { number } => number + 1 >= current_night, + }) + { + prompts.push(ActionPrompt::RoleChange { + character_id: self.identity(), + new_role: *role, + }); + } + } }, - Role::Apprentice(role) => { - let current_night = match village.time() { - GameTime::Day { number: _ } => return Ok(Box::new([])), - GameTime::Night { number } => number, - }; - return Ok(village - .characters() - .into_iter() - .filter(|c| c.role_title() == *role) - .filter_map(|char| char.died_to) - .any(|died_to| match died_to.date_time() { - GameTime::Day { number } => number.get() + 1 >= current_night, - GameTime::Night { number } => number + 1 >= current_night, - }) - .then(|| ActionPrompt::RoleChange { - character_id: self.identity(), - new_role: *role, - }) - .into_iter() - .collect()); - } Role::Elder { knows_on_night, woken_for_reveal: false, .. - } => { - let current_night = match village.time() { - GameTime::Day { number: _ } => return Ok(Box::new([])), - GameTime::Night { number } => number, - }; - return Ok((current_night >= knows_on_night.get()) - .then_some({ - ActionPrompt::ElderReveal { + } => match village.time() { + GameTime::Day { number: _ } => {} + GameTime::Night { number } => { + if number >= knows_on_night.get() { + prompts.push(ActionPrompt::ElderReveal { character_id: self.identity(), - } - }) - .into_iter() - .collect()); - } - Role::Militia { targeted: None } => ActionPrompt::Militia { + }); + } + } + }, + Role::Militia { targeted: None } => prompts.push(ActionPrompt::Militia { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::Werewolf => ActionPrompt::WolfPackKill { + }), + Role::Werewolf => prompts.push(ActionPrompt::WolfPackKill { living_villagers: village.living_players(), marked: None, - }, - Role::AlphaWolf { killed: None } => ActionPrompt::AlphaWolf { + }), + Role::AlphaWolf { killed: None } => prompts.push(ActionPrompt::AlphaWolf { character_id: self.identity(), living_villagers: village.living_players_excluding(self.character_id()), marked: None, - }, + }), Role::DireWolf { last_blocked: Some(last_blocked), - } => ActionPrompt::DireWolf { + } => prompts.push(ActionPrompt::DireWolf { character_id: self.identity(), living_players: village .living_players_excluding(self.character_id()) @@ -447,137 +452,124 @@ impl Character { .filter(|c| c.character_id != *last_blocked) .collect(), marked: None, - }, - Role::DireWolf { .. } => ActionPrompt::DireWolf { + }), + Role::DireWolf { .. } => prompts.push(ActionPrompt::DireWolf { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::Shapeshifter { shifted_into: None } => ActionPrompt::Shapeshifter { + }), + Role::Shapeshifter { shifted_into: None } => prompts.push(ActionPrompt::Shapeshifter { character_id: self.identity(), - }, + }), Role::Gravedigger => { let dead = village.dead_targets(); - if dead.is_empty() { - return Ok(Box::new([])); - } - ActionPrompt::Gravedigger { - character_id: self.identity(), - dead_players: village.dead_targets(), - marked: None, + if !dead.is_empty() { + prompts.push(ActionPrompt::Gravedigger { + character_id: self.identity(), + dead_players: village.dead_targets(), + marked: None, + }); } } - Role::Hunter { target } => ActionPrompt::Hunter { + Role::Hunter { target } => prompts.push(ActionPrompt::Hunter { character_id: self.identity(), current_target: target.as_ref().and_then(|t| village.target_by_id(*t).ok()), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::MapleWolf { last_kill_on_night } => ActionPrompt::MapleWolf { + }), + Role::MapleWolf { last_kill_on_night } => prompts.push(ActionPrompt::MapleWolf { character_id: self.identity(), kill_or_die: last_kill_on_night + MAPLE_WOLF_ABSTAIN_LIMIT.get() == night, living_players: village.living_players_excluding(self.character_id()), marked: None, - }, + }), Role::Guardian { last_protected: Some(PreviousGuardianAction::Guard(prev_target)), - } => ActionPrompt::Guardian { + } => prompts.push(ActionPrompt::Guardian { character_id: self.identity(), previous: Some(PreviousGuardianAction::Guard(prev_target.clone())), living_players: village.living_players_excluding(prev_target.character_id), marked: None, - }, + }), Role::Guardian { last_protected: Some(PreviousGuardianAction::Protect(prev_target)), - } => ActionPrompt::Guardian { + } => prompts.push(ActionPrompt::Guardian { character_id: self.identity(), previous: Some(PreviousGuardianAction::Protect(prev_target.clone())), living_players: village.living_players(), marked: None, - }, + }), Role::Guardian { last_protected: None, - } => ActionPrompt::Guardian { + } => prompts.push(ActionPrompt::Guardian { character_id: self.identity(), previous: None, living_players: village.living_players(), marked: None, - }, - Role::Adjudicator => ActionPrompt::Adjudicator { + }), + Role::Adjudicator => prompts.push(ActionPrompt::Adjudicator { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::PowerSeer => ActionPrompt::PowerSeer { + }), + Role::PowerSeer => prompts.push(ActionPrompt::PowerSeer { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::Mortician => ActionPrompt::Mortician { - character_id: self.identity(), - dead_players: { - let dead = village.dead_targets(); - if dead.is_empty() { - return Ok(Box::new([])); - } - dead - }, - marked: None, - }, - Role::Beholder => ActionPrompt::Beholder { + }), + Role::Mortician => { + let dead = village.dead_targets(); + if !dead.is_empty() { + prompts.push(ActionPrompt::Mortician { + character_id: self.identity(), + dead_players: dead, + marked: None, + }); + } + } + Role::Beholder => prompts.push(ActionPrompt::Beholder { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, + }), Role::MasonLeader { .. } => { log::error!( "night_action_prompts got to MasonLeader, should be handled before the living check" ); - return Ok(Box::new([])); } - Role::Empath { cursed: false } => ActionPrompt::Empath { + Role::Empath { cursed: false } => prompts.push(ActionPrompt::Empath { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, + }), Role::Vindicator => { - let last_day = match village.time() { - GameTime::Day { .. } => { - log::error!( - "vindicator trying to get a prompt during the day? village state: {village:?}" - ); - return Ok(Box::new([])); - } - GameTime::Night { number } => { - if number == 0 { - return Ok(Box::new([])); - } - NonZeroU8::new(number).unwrap() - } - }; - return Ok(village - .executions_on_day(last_day) - .iter() - .any(|c| c.is_village()) - .then(|| ActionPrompt::Vindicator { + if night != 0 + && let Some(last_day) = NonZeroU8::new(night) + && village + .executions_on_day(last_day) + .iter() + .any(|c| c.is_village()) + { + prompts.push(ActionPrompt::Vindicator { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }) - .into_iter() - .collect()); + }); + } } - Role::PyreMaster { .. } => ActionPrompt::PyreMaster { + + Role::PyreMaster { .. } => prompts.push(ActionPrompt::PyreMaster { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - Role::LoneWolf => ActionPrompt::LoneWolfKill { + }), + Role::LoneWolf => prompts.push(ActionPrompt::LoneWolfKill { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), marked: None, - }, - }])) + }), + } + Ok(prompts.into_boxed_slice()) } #[cfg(test)] @@ -830,6 +822,28 @@ impl Character { } } + pub const fn maple_wolf_mut<'a>(&'a mut self) -> Result> { + let title = self.role.title(); + match &mut self.role { + Role::MapleWolf { last_kill_on_night } => Ok(MapleWolfMut(last_kill_on_night)), + _ => Err(GameError::InvalidRole { + expected: RoleTitle::MapleWolf, + got: title, + }), + } + } + + pub const fn protector_mut<'a>(&'a mut self) -> Result> { + let title = self.role.title(); + match &mut self.role { + Role::Protector { last_protected } => Ok(ProtectorMut(last_protected)), + _ => Err(GameError::InvalidRole { + expected: RoleTitle::Protector, + got: title, + }), + } + } + pub const fn initial_shown_role(&self) -> RoleTitle { self.role.initial_shown_role() } @@ -872,6 +886,8 @@ decl_ref_and_mut!( Guardian, GuardianMut: Option; Direwolf, DirewolfMut: Option; Militia, MilitiaMut: Option; + MapleWolf, MapleWolfMut: u8; + Protector, ProtectorMut: Option; ); pub struct BlackKnightKill<'a> { diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs index 575f638..c6280d1 100644 --- a/werewolves-proto/src/error.rs +++ b/werewolves-proto/src/error.rs @@ -97,4 +97,6 @@ pub enum GameError { NoPreviousDuringDay, #[error("militia already spent")] MilitiaSpent, + #[error("this role doesn't mark anyone")] + RoleDoesntMark, } diff --git a/werewolves-proto/src/game/mod.rs b/werewolves-proto/src/game/mod.rs index db60be1..f59b419 100644 --- a/werewolves-proto/src/game/mod.rs +++ b/werewolves-proto/src/game/mod.rs @@ -72,6 +72,15 @@ impl Game { } } + #[cfg(test)] + #[doc(hidden)] + pub const fn village_mut(&mut self) -> &mut Village { + match &mut self.state { + GameState::Day { village, marked: _ } => village, + GameState::Night { night } => night.village_mut(), + } + } + pub fn process(&mut self, message: HostGameMessage) -> Result { match (&mut self.state, message) { (GameState::Night { night }, HostGameMessage::Night(HostNightMessage::NextPage)) => { @@ -102,7 +111,6 @@ impl Game { (GameState::Day { village, marked }, HostGameMessage::Day(HostDayMessage::Execute)) => { let time = village.time(); if let Some(outcome) = village.execute(marked)? { - log::warn!("adding to history for {}", village.time()); self.history.add( village.time(), GameActions::DayDetails( @@ -112,7 +120,6 @@ impl Game { return Ok(ServerToHostMessage::GameOver(outcome)); } let night = Night::new(village.clone())?; - log::warn!("adding to history for {time}"); self.history.add( time, GameActions::DayDetails( @@ -163,7 +170,6 @@ impl Game { Err(GameError::NightOver) => { let changes = night.collect_changes()?; let village = night.village().with_night_changes(&changes)?; - log::warn!("adding to history for {}", night.village().time()); self.history.add( night.village().time(), GameActions::NightDetails(NightDetails::new( diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index 6007191..f20c7c1 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -13,6 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . pub mod changes; +mod next; mod process; use core::num::NonZeroU8; @@ -58,7 +59,8 @@ impl From for ResponseOutcome { impl ActionPrompt { fn unless(&self) -> Option { match &self { - ActionPrompt::Insomniac { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Insomniac { .. } | ActionPrompt::MasonsWake { .. } | ActionPrompt::WolvesIntro { .. } | ActionPrompt::RoleChange { .. } @@ -532,13 +534,7 @@ impl Night { // role change associated with the shapeshift self.action_queue.push_front(last_prompt); } - log::warn!( - "next prompts: {:?}", - self.action_queue - .iter() - .map(ActionPrompt::title) - .collect::>() - ); + *current_result = CurrentResult::None; *current_changes = Vec::new(); Ok(()) @@ -592,7 +588,7 @@ impl Night { } } - fn apply_shapeshift(&mut self, source: &CharacterId) -> Result<()> { + fn apply_shapeshift(&mut self, source: &CharacterId) -> Result> { if let Some(kill_target) = self .changes_from_actions() .into_iter() @@ -616,7 +612,7 @@ impl Night { _ => false, }) { // there is protection, so the kill doesn't happen -> no shapeshift - return Ok(()); + return Ok(Some(ActionResult::ShiftFailed)); } if self.changes_from_actions().into_iter().any(|c| { @@ -667,7 +663,7 @@ impl Night { } } self.action_queue = new_queue; - Ok(()) + Ok(None) } pub const fn page(&self) -> Option { @@ -681,8 +677,8 @@ impl Night { match &mut self.night_state { NightState::Active { current_result, .. } => match current_result { CurrentResult::None => self.received_response(ActionResponse::Continue), - CurrentResult::Result(ActionResult::Continue) - | CurrentResult::GoBackToSleepAfterShown { .. } + CurrentResult::GoBackToSleepAfterShown { .. } + | CurrentResult::Result(ActionResult::Continue) | CurrentResult::Result(ActionResult::GoBackToSleep) => { Err(GameError::NightNeedsNext) } @@ -697,6 +693,18 @@ impl Night { } } + fn set_current_result(&mut self, result: CurrentResult) -> Result<()> { + match &mut self.night_state { + NightState::Active { + current_prompt: _, + current_result, + .. + } => *current_result = result, + NightState::Complete => return Err(GameError::NightOver), + }; + Ok(()) + } + pub fn received_response(&mut self, resp: ActionResponse) -> Result { match self.received_response_with_role_blocks(resp)? { BlockResolvedOutcome::PromptUpdate(prompt) => match &mut self.night_state { @@ -715,18 +723,18 @@ impl Night { NightState::Complete => Err(GameError::NightOver), }, BlockResolvedOutcome::ActionComplete(mut result, Some(change)) => { - match &mut self.night_state { - NightState::Active { - current_prompt: _, - current_result, - .. - } => *current_result = result.clone().into(), - NightState::Complete => return Err(GameError::NightOver), - }; + self.set_current_result(result.clone().into())?; if let NightChange::Shapeshift { source, .. } = &change { // needs to be resolved _now_ so that the target can be woken // for the role change with the wolves - self.apply_shapeshift(source)?; + if let Some(result) = self.apply_shapeshift(source)? { + if let NightState::Active { current_result, .. } = &mut self.night_state { + *current_result = CurrentResult::None; + }; + + return Ok(ServerAction::Result(result)); + } + return Ok(ServerAction::Result( self.action_queue .iter() @@ -766,10 +774,10 @@ impl Night { } } - fn received_response_consecutive_same_player_no_sleep( + fn outcome_consecutive_same_player_no_sleep( &self, - resp: ActionResponse, - ) -> Result { + outcome: ResponseOutcome, + ) -> ResponseOutcome { let same_char = self .current_character_id() .and_then(|curr| { @@ -780,25 +788,31 @@ impl Night { }) .unwrap_or_default(); - match ( - self.received_response_consecutive_wolves_dont_sleep(resp)?, - same_char, - ) { - (ResponseOutcome::PromptUpdate(p), _) => Ok(ResponseOutcome::PromptUpdate(p)), + match (outcome, same_char) { + (ResponseOutcome::PromptUpdate(p), _) => ResponseOutcome::PromptUpdate(p), ( ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GoBackToSleep, change, }), true, - ) => Ok(ResponseOutcome::ActionComplete(ActionComplete { + ) => ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Continue, change, - })), - (act, _) => Ok(act), + }), + (act, _) => act, } } + fn received_response_consecutive_same_player_no_sleep( + &self, + resp: ActionResponse, + ) -> Result { + Ok(self.outcome_consecutive_same_player_no_sleep( + self.received_response_consecutive_wolves_dont_sleep(resp)?, + )) + } + fn received_response_consecutive_wolves_dont_sleep( &self, resp: ActionResponse, @@ -826,7 +840,11 @@ impl Night { })); } - match (self.process(resp)?, current_wolfy, next_wolfy) { + match ( + self.received_response_with_auras(resp)?, + current_wolfy, + next_wolfy, + ) { (ResponseOutcome::PromptUpdate(p), _, _) => Ok(ResponseOutcome::PromptUpdate(p)), ( ResponseOutcome::ActionComplete(ActionComplete { @@ -911,6 +929,12 @@ impl Night { &self.village } + #[cfg(test)] + #[doc(hidden)] + pub const fn village_mut(&mut self) -> &mut Village { + &mut self.village + } + pub const fn current_result(&self) -> Option<&ActionResult> { match &self.night_state { NightState::Active { @@ -957,6 +981,11 @@ impl Night { .and_then(|id| self.village.character_by_id(id).ok()) } + pub fn current_character_mut(&mut self) -> Option<&mut Character> { + self.current_character_id() + .and_then(|id| self.village.character_by_id_mut(id).ok()) + } + pub const fn complete(&self) -> bool { matches!(self.night_state, NightState::Complete) } @@ -968,83 +997,21 @@ impl Night { .collect() } - #[allow(clippy::should_implement_trait)] - pub fn next(&mut self) -> Result<()> { - match &self.night_state { + pub fn append_change(&mut self, change: NightChange) -> Result<()> { + match &mut self.night_state { NightState::Active { - current_prompt, - current_result: CurrentResult::Result(ActionResult::Continue), - current_changes, - .. + current_changes, .. } => { - self.used_actions.push(( - current_prompt.clone(), - ActionResult::Continue, - current_changes.clone(), - )); + current_changes.push(change); + Ok(()) } - NightState::Active { - current_prompt, - current_result: CurrentResult::Result(ActionResult::GoBackToSleep), - current_changes, - .. - } => { - self.used_actions.push(( - current_prompt.clone(), - ActionResult::GoBackToSleep, - current_changes.clone(), - )); - } - NightState::Active { - current_result: CurrentResult::Result(_), - .. - } => { - // needs Continue, not Next - return Err(GameError::AwaitingResponse); - } - NightState::Active { - current_prompt, - current_result: CurrentResult::GoBackToSleepAfterShown { result_with_data }, - current_changes, - .. - } => { - self.used_actions.push(( - current_prompt.clone(), - result_with_data.clone(), - current_changes.clone(), - )); - } - NightState::Active { - current_prompt: _, - current_result: CurrentResult::None, - .. - } => return Err(GameError::AwaitingResponse), - NightState::Complete => return Err(GameError::NightOver), + NightState::Complete => Err(GameError::NightOver), } - if let Some(prompt) = self.action_queue.pop_front() { - if let ActionPrompt::Insomniac { character_id } = &prompt - && self.get_visits_for(character_id.character_id).is_empty() - { - // skip! - self.used_actions.pop(); // it will be re-added - return self.next(); - } - self.night_state = NightState::Active { - current_prompt: prompt, - current_result: CurrentResult::None, - current_changes: Vec::new(), - current_page: 0, - }; - } else { - self.night_state = NightState::Complete; - } - - Ok(()) } /// resolves whether the target [CharacterId] dies tonight with the current - /// state of the night - fn dies_tonight(&self, character_id: CharacterId) -> Result { + /// state of the night and returns the [DiedTo] cause of death + 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) @@ -1057,9 +1024,9 @@ impl Night { )? .is_some() { - Ok(true) + Ok(Some(died_to.clone())) } else { - Ok(false) + Ok(None) } } @@ -1202,7 +1169,8 @@ impl Night { .. } => (*marked == visit_char).then(|| character_id.clone()), - ActionPrompt::Bloodletter { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Bloodletter { .. } | ActionPrompt::WolfPackKill { marked: None, .. } | ActionPrompt::Arcanist { marked: _, .. } | ActionPrompt::LoneWolfKill { marked: None, .. } diff --git a/werewolves-proto/src/game/night/next.rs b/werewolves-proto/src/game/night/next.rs new file mode 100644 index 0000000..0af5d2b --- /dev/null +++ b/werewolves-proto/src/game/night/next.rs @@ -0,0 +1,148 @@ +// 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; + +use crate::{ + diedto::DiedTo, + error::GameError, + game::night::{CurrentResult, Night, NightState, changes::NightChange}, + message::night::{ActionPrompt, ActionResult}, +}; + +use super::Result; +impl Night { + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Result<()> { + self.next_state_process_maple_starving()?; + + match &self.night_state { + NightState::Active { + current_prompt, + current_result: CurrentResult::Result(ActionResult::Continue), + current_changes, + .. + } => { + self.used_actions.push(( + current_prompt.clone(), + ActionResult::Continue, + current_changes.clone(), + )); + } + NightState::Active { + current_prompt, + current_result: CurrentResult::Result(ActionResult::GoBackToSleep), + current_changes, + .. + } => { + self.used_actions.push(( + current_prompt.clone(), + ActionResult::GoBackToSleep, + current_changes.clone(), + )); + } + NightState::Active { + current_result: CurrentResult::Result(_), + .. + } => { + // needs Continue, not Next + return Err(GameError::AwaitingResponse); + } + NightState::Active { + current_prompt, + current_result: CurrentResult::GoBackToSleepAfterShown { result_with_data }, + current_changes, + .. + } => { + self.used_actions.push(( + current_prompt.clone(), + result_with_data.clone(), + current_changes.clone(), + )); + } + NightState::Active { + current_prompt: _, + current_result: CurrentResult::None, + .. + } => return Err(GameError::AwaitingResponse), + NightState::Complete => return Err(GameError::NightOver), + } + if let Some(prompt) = self.action_queue.pop_front() { + if let ActionPrompt::Insomniac { character_id } = &prompt + && self.get_visits_for(character_id.character_id).is_empty() + { + // skip! + self.used_actions.pop(); // it will be re-added + return self.next(); + } + self.night_state = NightState::Active { + current_prompt: prompt, + current_result: CurrentResult::None, + current_changes: Vec::new(), + current_page: 0, + }; + } else { + self.night_state = NightState::Complete; + } + + Ok(()) + } + + fn next_state_process_maple_starving(&mut self) -> Result<()> { + let (maple_id, target) = match self.current_prompt() { + Some(( + ActionPrompt::MapleWolf { + character_id, + kill_or_die, + marked, + .. + }, + _, + )) => { + if *kill_or_die { + (character_id.character_id, *marked) + } else { + return Ok(()); + } + } + Some(_) | None => return Ok(()), + }; + + let starve_change = if let Some(night) = NonZeroU8::new(self.night) { + NightChange::Kill { + target: maple_id, + died_to: DiedTo::MapleWolfStarved { night }, + } + } else { + return Ok(()); + }; + + let Some(target) = target else { + return self.append_change(starve_change); + }; + match self.died_to_tonight(target)? { + Some(DiedTo::MapleWolf { source, .. }) => { + if source != maple_id { + self.append_change(starve_change)?; + } + } + Some(_) | None => { + self.append_change(starve_change)?; + } + } + + Ok(()) + } +} diff --git a/werewolves-proto/src/game/night/process.rs b/werewolves-proto/src/game/night/process.rs index 6afb33d..b80849e 100644 --- a/werewolves-proto/src/game/night/process.rs +++ b/werewolves-proto/src/game/night/process.rs @@ -16,6 +16,7 @@ use core::num::NonZeroU8; use crate::{ aura::Aura, + bag::DrunkRoll, diedto::DiedTo, error::GameError, game::night::{ @@ -23,7 +24,7 @@ use crate::{ }, message::night::{ActionPrompt, ActionResponse, ActionResult}, player::Protection, - role::{AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle}, + role::{Alignment, AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle}, }; type Result = core::result::Result; @@ -109,10 +110,15 @@ impl Night { }; match current_prompt { + ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete { + result: ActionResult::GoBackToSleep, + change: None, + } + .into()), ActionPrompt::Bloodletter { character_id, - living_players, marked: Some(marked), + .. } => Ok(ActionComplete { result: ActionResult::GoBackToSleep, change: Some(NightChange::ApplyAura { @@ -236,12 +242,11 @@ impl Night { .. } => Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GoBackToSleep, - change: Some(NightChange::Kill { + change: NonZeroU8::new(self.night).map(|night| NightChange::Kill { target: *marked, died_to: DiedTo::MapleWolf { + night, source: character_id.character_id, - night: NonZeroU8::new(self.night) - .ok_or(GameError::InvalidMessageForGameState)?, starves_if_fails: *kill_or_die, }, }), @@ -410,10 +415,11 @@ impl Night { } => { if let Some(result) = self.used_actions.iter().find_map(|(prompt, result, _)| { prompt.matches_beholding(*marked).then_some(result) - }) && self.dies_tonight(*marked)? + }) && self.died_to_tonight(*marked)?.is_some() { Ok(ActionComplete { - result: if matches!(result, ActionResult::RoleBlocked) { + result: if matches!(result, ActionResult::RoleBlocked | ActionResult::Drunk) + { ActionResult::BeholderSawNothing } else { result.clone() @@ -534,4 +540,38 @@ impl Night { | ActionPrompt::Seer { marked: None, .. } => Err(GameError::InvalidMessageForGameState), } } + + pub(super) fn received_response_with_auras( + &self, + resp: ActionResponse, + ) -> Result { + let outcome = self.process(resp)?; + let mut act = match outcome { + ResponseOutcome::PromptUpdate(prompt) => { + return Ok(ResponseOutcome::PromptUpdate(prompt)); + } + ResponseOutcome::ActionComplete(act) => act, + }; + let Some(char) = self.current_character() else { + return Ok(ResponseOutcome::ActionComplete(act)); + }; + for aura in char.auras() { + match aura { + Aura::Traitor | Aura::Bloodlet { .. } => continue, + Aura::Drunk(bag) => { + if bag.peek() == DrunkRoll::Drunk { + act.change = None; + act.result = ActionResult::Drunk; + return Ok(ResponseOutcome::ActionComplete(act)); + } + } + Aura::Insane => { + if let Some(insane_result) = act.result.insane() { + act.result = insane_result; + } + } + } + } + Ok(ResponseOutcome::ActionComplete(act)) + } } diff --git a/werewolves-proto/src/game/settings/settings_role.rs b/werewolves-proto/src/game/settings/settings_role.rs index bf3f138..eae920d 100644 --- a/werewolves-proto/src/game/settings/settings_role.rs +++ b/werewolves-proto/src/game/settings/settings_role.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use werewolves_macros::{All, ChecksAs, Titles}; use crate::{ - aura::Aura, + aura::{Aura, AuraTitle}, character::Character, error::GameError, message::Identification, @@ -157,6 +157,39 @@ pub enum SetupRole { } impl SetupRoleTitle { + pub fn can_assign_aura(&self, aura: AuraTitle) -> bool { + if self.into_role().title().wolf() { + return match aura { + AuraTitle::Traitor | AuraTitle::Bloodlet | AuraTitle::Insane => false, + AuraTitle::Drunk => !matches!(self, SetupRoleTitle::Werewolf), + }; + } + match aura { + AuraTitle::Traitor => true, + AuraTitle::Drunk => { + matches!( + self.category(), + Category::StartsAsVillager + | Category::Defensive + | Category::Intel + | Category::Offensive + ) && !matches!( + self, + Self::Elder + | Self::BlackKnight + | Self::Diseased + | Self::Weightlifter + | Self::Insomniac + | Self::Mortician + ) + } + AuraTitle::Insane => { + matches!(self.category(), Category::Intel) + && !matches!(self, Self::MasonLeader | Self::Empath) + } + AuraTitle::Bloodlet => false, + } + } pub fn into_role(self) -> Role { match self { SetupRoleTitle::Bloodletter => Role::Bloodletter, @@ -379,7 +412,7 @@ impl SlotId { pub struct SetupSlot { pub slot_id: SlotId, pub role: SetupRole, - pub auras: Vec, + pub auras: Vec, pub assign_to: Option, pub created_order: u32, } @@ -403,7 +436,10 @@ impl SetupSlot { Character::new( ident.clone(), self.role.into_role(roles_in_game)?, - self.auras, + self.auras + .into_iter() + .map(|aura| aura.into_aura()) + .collect(), ) .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string())) } diff --git a/werewolves-proto/src/game/story.rs b/werewolves-proto/src/game/story.rs index ee6bd7b..646508c 100644 --- a/werewolves-proto/src/game/story.rs +++ b/werewolves-proto/src/game/story.rs @@ -84,12 +84,18 @@ pub enum StoryActionResult { Insomniac { visits: Box<[CharacterId]> }, Empath { scapegoat: bool }, BeholderSawNothing, + BeholderSawEverything, + Drunk, + ShiftFailed, } impl StoryActionResult { pub fn new(result: ActionResult) -> Option { Some(match result { + ActionResult::ShiftFailed => Self::ShiftFailed, ActionResult::BeholderSawNothing => Self::BeholderSawNothing, + ActionResult::BeholderSawEverything => Self::BeholderSawEverything, + ActionResult::Drunk => Self::Drunk, ActionResult::RoleBlocked => Self::RoleBlocked, ActionResult::Seer(alignment) => Self::Seer(alignment), ActionResult::PowerSeer { powerful } => Self::PowerSeer { powerful }, @@ -390,7 +396,8 @@ impl StoryActionPrompt { character_id: character_id.character_id, }, - ActionPrompt::Bloodletter { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Bloodletter { .. } | ActionPrompt::Protector { .. } | ActionPrompt::Gravedigger { .. } | ActionPrompt::Hunter { .. } diff --git a/werewolves-proto/src/game/village/apply.rs b/werewolves-proto/src/game/village/apply.rs index 3fe804b..b01d0e9 100644 --- a/werewolves-proto/src/game/village/apply.rs +++ b/werewolves-proto/src/game/village/apply.rs @@ -15,7 +15,7 @@ use core::num::NonZeroU8; use crate::{ - aura::Aura, + aura::{Aura, AuraTitle}, diedto::DiedTo, error::GameError, game::{ @@ -51,11 +51,27 @@ impl Village { let mut changes = ChangesLookup::new(all_changes); let mut new_village = self.clone(); + + // dispose of the current drunk token for every drunk in the village + new_village + .characters_mut() + .iter_mut() + .filter_map(|c| { + c.auras_mut().iter_mut().find_map(|a| match a { + Aura::Drunk(bag) => Some(bag), + _ => None, + }) + }) + .for_each(|bag| { + // dispose of a token + let _ = bag.pull(); + }); + for change in all_changes { match change { NightChange::ApplyAura { target, aura, .. } => { let target = new_village.character_by_id_mut(*target)?; - target.apply_aura(*aura); + target.apply_aura(aura.clone()); } NightChange::ElderReveal { elder } => { new_village.character_by_id_mut(*elder)?.elder_reveal() @@ -84,6 +100,11 @@ impl Village { kill::resolve_kill(&mut changes, *target, died_to, night, self)? { kill.apply_to_village(&mut new_village)?; + if let DiedTo::MapleWolf { source, .. } = died_to + && let Ok(maple) = new_village.character_by_id_mut(*source) + { + *maple.maple_wolf_mut()? = night; + } } } NightChange::Shapeshift { source, into } => { @@ -157,9 +178,38 @@ impl Village { NightChange::LostAura { character, aura } => { new_village .character_by_id_mut(*character)? - .remove_aura(*aura); + .remove_aura(aura.title()); + } + NightChange::Protection { protection, target } => { + let target_ident = new_village.character_by_id(*target)?.identity(); + match protection { + Protection::Guardian { + source, + guarding: true, + } => { + new_village + .character_by_id_mut(*source)? + .guardian_mut()? + .replace(PreviousGuardianAction::Guard(target_ident)); + } + Protection::Guardian { + source, + guarding: false, + } => { + new_village + .character_by_id_mut(*source)? + .guardian_mut()? + .replace(PreviousGuardianAction::Protect(target_ident)); + } + Protection::Protector { source } => { + new_village + .character_by_id_mut(*source)? + .protector_mut()? + .replace(*target); + } + Protection::Vindicator { .. } => {} + } } - NightChange::Protection { .. } => {} } } // black knights death diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index 5c798e8..31bfd2b 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -186,9 +186,17 @@ pub trait ActionResultExt { fn adjudicator(&self) -> Killer; fn mortician(&self) -> DiedToTitle; fn empath(&self) -> bool; + fn drunk(&self); + fn shapeshift_failed(&self); } impl ActionResultExt for ActionResult { + fn shapeshift_failed(&self) { + assert_eq!(*self, Self::ShiftFailed) + } + fn drunk(&self) { + assert_eq!(*self, Self::Drunk) + } fn empath(&self) -> bool { match self { Self::Empath { scapegoat } => *scapegoat, @@ -322,6 +330,7 @@ pub trait GameExt { fn get_state(&mut self) -> ServerToHostMessage; fn next_expect_game_over(&mut self) -> GameOver; fn prev(&mut self) -> ServerToHostMessage; + fn mark_villager(&mut self) -> ActionPrompt; } impl GameExt for Game { @@ -394,10 +403,15 @@ impl GameExt for Game { .prompt() } + fn mark_villager(&mut self) -> ActionPrompt { + self.mark(self.living_villager().character_id()) + } + fn mark_and_check(&mut self, mark: CharacterId) { let prompt = self.mark(mark); match prompt { - ActionPrompt::Insomniac { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Insomniac { .. } | ActionPrompt::MasonsWake { .. } | ActionPrompt::ElderReveal { .. } | ActionPrompt::CoverOfDarkness @@ -923,7 +937,9 @@ fn big_game_test_based_on_story_test() { game.r#continue().r#continue(); game.next().title().shapeshifter(); - game.response(ActionResponse::Shapeshift).sleep(); + game.response(ActionResponse::Shapeshift) + .shapeshift_failed(); + game.r#continue().sleep(); game.next().title().seer(); game.mark(game.character_by_player_id(werewolf).character_id()); diff --git a/werewolves-proto/src/game_test/previous.rs b/werewolves-proto/src/game_test/previous.rs index 16b6b21..69c0d32 100644 --- a/werewolves-proto/src/game_test/previous.rs +++ b/werewolves-proto/src/game_test/previous.rs @@ -21,7 +21,8 @@ use crate::{ diedto::DiedToTitle, game::{Game, GameSettings, GameState, OrRandom, SetupRole}, game_test::{ - ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log, + ActionPromptTitleExt, ActionResultExt, GameExt, ServerToHostMessageExt, SettingsExt, + gen_players, init_log, }, message::{ Identification, PublicIdentity, @@ -353,7 +354,9 @@ fn previous_prompt() { game.r#continue().r#continue(); game.next().title().shapeshifter(); - game.response(ActionResponse::Shapeshift).sleep(); + game.response(ActionResponse::Shapeshift) + .shapeshift_failed(); + game.r#continue().sleep(); game.next().title().seer(); game.mark(game.character_by_player_id(werewolf).character_id()); diff --git a/werewolves-proto/src/game_test/role/maple_wolf.rs b/werewolves-proto/src/game_test/role/maple_wolf.rs new file mode 100644 index 0000000..11bf0f7 --- /dev/null +++ b/werewolves-proto/src/game_test/role/maple_wolf.rs @@ -0,0 +1,334 @@ +// 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::{NonZero, NonZeroU8}; + +#[allow(unused)] +use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; + +use crate::{ + aura::Aura, + bag::DrunkBag, + diedto::DiedTo, + game::{Game, GameSettings, SetupRole}, + game_test::{ + ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log, + }, + message::night::ActionPromptTitle, + role::{Alignment, Killer, Powerful}, +}; + +#[test] +fn maple_starves() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let maple = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::MapleWolf, maple); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(20); + 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(); + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 0 + ); + + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.r#continue().sleep(); + + game.next_expect_day(); + + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.r#continue().sleep(); + + game.next_expect_day(); + + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(maple).died_to().cloned(), + Some(DiedTo::MapleWolfStarved { + night: NonZeroU8::new(3).unwrap() + }) + ); +} + +#[test] +fn maple_last_eat_counter_increments() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let maple = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::MapleWolf, maple); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(20); + 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.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 0 + ); + + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 1 + ); + + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 2 + ); + + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 3 + ); + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 4 + ); + + assert_eq!(game.character_by_player_id(maple).died_to().cloned(), None); +} + +#[test] +fn drunk_maple_doesnt_eat() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let maple = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::MapleWolf, maple); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(20); + let mut game = Game::new(&players, settings).unwrap(); + game.village_mut() + .characters_mut() + .into_iter() + .find(|c| c.player_id() == maple) + .unwrap() + .apply_aura(Aura::Drunk(DrunkBag::all_drunk())); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 0 + ); + + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().drunk(); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 0 + ); + assert_eq!( + game.village() + .character_by_id(maple_kill) + .unwrap() + .died_to(), + None + ); + + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().drunk(); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 0 + ); + assert_eq!( + game.village() + .character_by_id(maple_kill) + .unwrap() + .died_to(), + None + ); + + let (maple_kill, wolf_kill) = { + let mut cid = game + .villager_character_ids() + .into_iter() + .filter(|c| game.village().character_by_id(*c).unwrap().alive()); + (cid.next().unwrap(), cid.next().unwrap()) + }; + + game.execute().title().wolf_pack_kill(); + game.mark(wolf_kill); + game.r#continue().sleep(); + + game.next().title().maple_wolf(); + game.mark(maple_kill); + game.r#continue().drunk(); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + *game.character_by_player_id(maple).maple_wolf_mut().unwrap(), + 0 + ); + + assert_eq!( + game.character_by_player_id(maple).died_to().cloned(), + Some(DiedTo::MapleWolfStarved { + night: NonZeroU8::new(3).unwrap() + }) + ); +} diff --git a/werewolves-proto/src/game_test/role/mod.rs b/werewolves-proto/src/game_test/role/mod.rs index 3815fef..0675b10 100644 --- a/werewolves-proto/src/game_test/role/mod.rs +++ b/werewolves-proto/src/game_test/role/mod.rs @@ -23,9 +23,11 @@ mod guardian; mod hunter; mod insomniac; mod lone_wolf; +mod maple_wolf; mod mason; mod militia; mod mortician; +mod protector; mod pyremaster; mod scapegoat; mod shapeshifter; diff --git a/werewolves-proto/src/game_test/role/protector.rs b/werewolves-proto/src/game_test/role/protector.rs new file mode 100644 index 0000000..46e8608 --- /dev/null +++ b/werewolves-proto/src/game_test/role/protector.rs @@ -0,0 +1,105 @@ +// 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 . +#[allow(unused)] +use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; + +use crate::{ + game::{Game, GameSettings, SetupRole}, + game_test::{ + ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log, + }, + message::night::{ActionPrompt, ActionPromptTitle}, +}; + +#[test] +fn cannot_protect_same_target() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let protector = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Protector, protector); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(20); + 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(); + game.execute().title().protector(); + let prot = game.living_villager(); + game.mark(prot.character_id()); + game.r#continue().sleep(); + + game.next().title().wolf_pack_kill(); + game.mark(prot.character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(protector) + .protector_mut() + .unwrap() + .clone(), + Some(prot.character_id()) + ); + + match game.execute() { + ActionPrompt::Protector { targets, .. } => { + assert!( + !targets + .into_iter() + .any(|c| c.character_id == prot.character_id()) + ); + } + prompt => panic!("expected protector prompt, got {:?}", prompt.title()), + } +} + +#[test] +fn can_self_protect() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let protector = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Protector, protector); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.fill_remaining_slots_with_villagers(20); + 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(); + game.execute().title().protector(); + game.mark(game.character_by_player_id(protector).character_id()); + game.r#continue().sleep(); + + game.next().title().wolf_pack_kill(); + game.mark(game.character_by_player_id(protector).character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(protector).died_to().cloned(), + None + ); +} diff --git a/werewolves-proto/src/game_test/role/shapeshifter.rs b/werewolves-proto/src/game_test/role/shapeshifter.rs index ce97e93..0cc23c1 100644 --- a/werewolves-proto/src/game_test/role/shapeshifter.rs +++ b/werewolves-proto/src/game_test/role/shapeshifter.rs @@ -130,7 +130,9 @@ fn protect_stops_shapeshift() { assert_eq!(game.next().title(), ActionPromptTitle::Shapeshifter,); - game.response(ActionResponse::Shapeshift); + game.response(ActionResponse::Shapeshift) + .shapeshift_failed(); + game.r#continue().sleep(); game.next_expect_day(); @@ -218,3 +220,60 @@ fn i_would_simply_refuse() { game.next_expect_day(); } + +#[test] +fn shapeshift_fail_can_continue() { + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let shapeshifter = player_ids.next().unwrap(); + let direwolf = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let protector = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Shapeshifter, shapeshifter); + settings.add_and_assign(SetupRole::DireWolf, direwolf); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.add_and_assign(SetupRole::Protector, protector); + settings.fill_remaining_slots_with_villagers(20); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().r#continue(); + + game.next().title().direwolf(); + let dw_target = game.living_villager(); + game.mark(dw_target.character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + + game.execute().title().protector(); + let ss_target = game.living_villager(); + game.mark(ss_target.character_id()); + game.r#continue().sleep(); + + game.next().title().wolf_pack_kill(); + game.mark(ss_target.character_id()); + game.r#continue().r#continue(); + + game.next().title().shapeshifter(); + match game + .process(HostGameMessage::Night(HostNightMessage::ActionResponse( + ActionResponse::Shapeshift, + ))) + .unwrap() + { + ServerToHostMessage::ActionResult(_, ActionResult::ShiftFailed) => {} + other => panic!("expected shift fail, got {other:?}"), + }; + game.r#continue().r#continue(); + + game.next().title().direwolf(); + game.mark( + game.living_villager_excl(dw_target.player_id()) + .character_id(), + ); + game.r#continue().sleep(); + + game.next_expect_day(); +} diff --git a/werewolves-proto/src/lib.rs b/werewolves-proto/src/lib.rs index 7a504b4..572121a 100644 --- a/werewolves-proto/src/lib.rs +++ b/werewolves-proto/src/lib.rs @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . #![allow(clippy::new_without_default)] - pub mod aura; +pub mod bag; pub mod character; pub mod diedto; pub mod error; diff --git a/werewolves-proto/src/message/night.rs b/werewolves-proto/src/message/night.rs index 9ea2b5b..0555d85 100644 --- a/werewolves-proto/src/message/night.rs +++ b/werewolves-proto/src/message/night.rs @@ -32,6 +32,7 @@ pub enum ActionType { Cover, #[checks("is_wolfy")] WolvesIntro, + TraitorIntro, RoleChange, Protect, #[checks("is_wolfy")] @@ -209,12 +210,15 @@ pub enum ActionPrompt { living_players: Box<[CharacterIdentity]>, marked: Option, }, + #[checks(ActionType::TraitorIntro)] + TraitorIntro { character_id: CharacterIdentity }, } impl ActionPrompt { pub(crate) const fn character_id(&self) -> Option { match self { - ActionPrompt::Insomniac { character_id, .. } + ActionPrompt::TraitorIntro { character_id } + | ActionPrompt::Insomniac { character_id, .. } | ActionPrompt::LoneWolfKill { character_id, .. } | ActionPrompt::ElderReveal { character_id } | ActionPrompt::RoleChange { character_id, .. } @@ -257,7 +261,8 @@ impl ActionPrompt { | ActionPrompt::Mortician { character_id, .. } | ActionPrompt::Vindicator { character_id, .. } => character_id.character_id == target, - ActionPrompt::Beholder { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Beholder { .. } | ActionPrompt::CoverOfDarkness | ActionPrompt::WolvesIntro { .. } | ActionPrompt::RoleChange { .. } @@ -281,13 +286,14 @@ impl ActionPrompt { pub(crate) fn with_mark(&self, mark: CharacterId) -> Result { let mut prompt = self.clone(); match &mut prompt { - ActionPrompt::Insomniac { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Insomniac { .. } | ActionPrompt::MasonsWake { .. } | ActionPrompt::ElderReveal { .. } | ActionPrompt::WolvesIntro { .. } | ActionPrompt::RoleChange { .. } | ActionPrompt::Shapeshifter { .. } - | ActionPrompt::CoverOfDarkness => Err(GameError::InvalidMessageForGameState), + | ActionPrompt::CoverOfDarkness => Err(GameError::RoleDoesntMark), ActionPrompt::Guardian { previous, @@ -489,6 +495,7 @@ pub enum ActionResponse { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ActionResult { RoleBlocked, + Drunk, Seer(Alignment), PowerSeer { powerful: Powerful }, Adjudicator { killer: Killer }, @@ -498,10 +505,42 @@ pub enum ActionResult { Insomniac(Visits), Empath { scapegoat: bool }, BeholderSawNothing, + BeholderSawEverything, GoBackToSleep, + ShiftFailed, Continue, } +impl ActionResult { + pub fn insane(&self) -> Option { + Some(match self { + ActionResult::Seer(Alignment::Village) => ActionResult::Seer(Alignment::Wolves), + ActionResult::Seer(Alignment::Traitor) | ActionResult::Seer(Alignment::Wolves) => { + ActionResult::Seer(Alignment::Village) + } + ActionResult::PowerSeer { powerful } => ActionResult::PowerSeer { + powerful: !*powerful, + }, + ActionResult::Adjudicator { killer } => ActionResult::Adjudicator { killer: !*killer }, + ActionResult::Arcanist(alignment_eq) => ActionResult::Arcanist(!*alignment_eq), + ActionResult::Empath { scapegoat } => ActionResult::Empath { + scapegoat: !*scapegoat, + }, + ActionResult::BeholderSawNothing => ActionResult::BeholderSawEverything, + ActionResult::BeholderSawEverything => ActionResult::BeholderSawNothing, + + ActionResult::ShiftFailed + | ActionResult::RoleBlocked + | ActionResult::Drunk + | ActionResult::GraveDigger(_) + | ActionResult::Mortician(_) + | ActionResult::Insomniac(_) + | ActionResult::GoBackToSleep + | ActionResult::Continue => return None, + }) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Visits(Box<[CharacterIdentity]>); diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs index 90c1478..e0ed7fc 100644 --- a/werewolves-proto/src/role.rs +++ b/werewolves-proto/src/role.rs @@ -18,10 +18,12 @@ use serde::{Deserialize, Serialize}; use werewolves_macros::{ChecksAs, Titles}; use crate::{ - character::CharacterId, + aura::Aura, + character::{Character, CharacterId}, diedto::DiedTo, game::{GameTime, Village}, - message::CharacterIdentity, + message::{CharacterIdentity, Identification, PublicIdentity, night::ActionType}, + player::PlayerId, }; #[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Default)] @@ -318,7 +320,7 @@ impl Role { Role::Werewolf => KillingWolfOrder::Werewolf, Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf, - Role::Bloodletter { .. } => KillingWolfOrder::Bloodletter, + Role::Bloodletter => KillingWolfOrder::Bloodletter, Role::DireWolf { .. } => KillingWolfOrder::DireWolf, Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter, Role::LoneWolf => KillingWolfOrder::LoneWolf, @@ -469,7 +471,7 @@ impl Display for Alignment { match self { Alignment::Village => f.write_str("Village"), Alignment::Wolves => f.write_str("Wolves"), - Alignment::Traitor => f.write_str("Damned"), + Alignment::Traitor => f.write_str("Traitor"), } } } diff --git a/werewolves-server/src/communication/host.rs b/werewolves-server/src/communication/host.rs index 718d621..cf9a20b 100644 --- a/werewolves-server/src/communication/host.rs +++ b/werewolves-server/src/communication/host.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 . +use colored::Colorize; use tokio::sync::{broadcast::Sender, mpsc::Receiver}; use werewolves_proto::{ error::GameError, @@ -45,6 +46,10 @@ impl HostComms { } pub fn send(&mut self, message: ServerToHostMessage) -> Result<(), GameError> { + log::debug!( + "sending message to host: {}", + format!("{message:?}").dimmed() + ); self.send .send(message) .map_err(|err| GameError::GenericError(err.to_string()))?; diff --git a/werewolves-server/src/host.rs b/werewolves-server/src/host.rs index 010d141..3fe048d 100644 --- a/werewolves-server/src/host.rs +++ b/werewolves-server/src/host.rs @@ -146,7 +146,6 @@ impl Host { msg = self.server_recv.recv() => { match msg { Ok(msg) => { - log::debug!("sending message to host: {}", format!("{msg:?}").dimmed()); if let Err(err) = self.send_message(&msg).await { log::error!("{} {err}", "[host::outgoing]".bold()) } diff --git a/werewolves/img/drunk.svg b/werewolves/img/drunk.svg new file mode 100644 index 0000000..7dac6ac --- /dev/null +++ b/werewolves/img/drunk.svg @@ -0,0 +1,46 @@ + + + + diff --git a/werewolves/img/insane.svg b/werewolves/img/insane.svg new file mode 100644 index 0000000..e5c405c --- /dev/null +++ b/werewolves/img/insane.svg @@ -0,0 +1,29 @@ + + + + diff --git a/werewolves/index.scss b/werewolves/index.scss index bddb56d..7a03892 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -17,6 +17,10 @@ $offensive_color: color.adjust($village_color, $hue: 30deg); $offensive_border: color.change($offensive_color, $alpha: 1.0); $starts_as_villager_color: color.adjust($offensive_color, $hue: 30deg); $starts_as_villager_border: color.change($starts_as_villager_color, $alpha: 1.0); +$traitor_color: color.adjust($village_color, $hue: 45deg); +$traitor_border: color.change($traitor_color, $alpha: 1.0); +$drunk_color: color.adjust($village_color, $hue: 150deg); +$drunk_border: color.change($drunk_color, $alpha: 1.0); $wolves_border_faint: color.change($wolves_border, $alpha: 0.3); $village_border_faint: color.change($village_border, $alpha: 0.3); @@ -24,6 +28,8 @@ $offensive_border_faint: color.change($offensive_border, $alpha: 0.3); $defensive_border_faint: color.change($defensive_border, $alpha: 0.3); $intel_border_faint: color.change($intel_border, $alpha: 0.3); $starts_as_villager_border_faint: color.change($starts_as_villager_border, $alpha: 0.3); +$traitor_border_faint: color.change($traitor_border, $alpha: 0.3); +$drunk_border_faint: color.change($drunk_border, $alpha: 0.3); $wolves_color_faint: color.change($wolves_color, $alpha: 0.1); $village_color_faint: color.change($village_color, $alpha: 0.1); @@ -31,7 +37,8 @@ $offensive_color_faint: color.change($offensive_color, $alpha: 0.1); $defensive_color_faint: color.change($defensive_color, $alpha: 0.1); $intel_color_faint: color.change($intel_color, $alpha: 0.1); $starts_as_villager_color_faint: color.change($starts_as_villager_color, $alpha: 0.1); - +$traitor_color_faint: color.change($traitor_color, $alpha: 0.1); +$drunk_color_faint: color.change($drunk_color, $alpha: 0.1); @mixin flexbox() { display: -webkit-box; @@ -280,7 +287,6 @@ nav.host-nav { align-self: center; margin-bottom: 30px; font-size: 2rem; - // background-color: hsl(283, 100%, 80%); border: 1px solid rgba(0, 255, 0, 0.7); background-color: black; color: rgba(0, 255, 0, 0.7); @@ -298,6 +304,7 @@ nav.host-nav { border: 1px solid rgba(255, 0, 0, 1); color: rgba(255, 0, 0, 1); filter: none; + background-color: rgba(255, 0, 0, 0.1); &:hover { background-color: rgba(255, 0, 0, 0.3); @@ -1110,15 +1117,20 @@ input { cursor: pointer; } + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: center; + &>.submenu { - min-width: 30vw; + width: 30vw; + // position: absolute; .assign-list { - // min-width: 5cm; gap: 10px; & .submenu button { - width: 5cm; + width: 10vw; } } @@ -1126,6 +1138,7 @@ input { display: flex; flex-direction: row; flex-wrap: wrap; + justify-content: center; gap: 5px; } } @@ -1270,6 +1283,44 @@ input { } } +.traitor { + background-color: $traitor_color; + border: 1px solid $traitor_border; + + &:hover { + color: white; + background-color: $traitor_border; + } + + &.faint { + border: 1px solid $traitor_border_faint; + background-color: $traitor_color_faint; + + &:hover { + background-color: $traitor_border_faint; + } + } +} + +.drunk { + background-color: $drunk_color; + border: 1px solid $drunk_border; + + &:hover { + color: white; + background-color: $drunk_border; + } + + &.faint { + border: 1px solid $drunk_border_faint; + background-color: $drunk_color_faint; + + &:hover { + background-color: $drunk_border_faint; + } + } +} + .assignments { display: flex; flex-direction: row; @@ -1790,6 +1841,18 @@ input { flex-shrink: 1; } +.info-icon-grow { + flex-grow: 1; + width: 100%; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + img { + flex-grow: 1; + } +} + .info-player-list { display: flex; flex-direction: column; @@ -1943,6 +2006,39 @@ input { flex-direction: column; flex-wrap: nowrap; justify-content: center; + align-items: center; height: var(--information-height); } + +.setup-aura { + &.active { + $active_color: color.change($connected_color, $alpha: 0.7); + border: $active_color 1px solid; + color: $active_color; + background-color: color.change($active_color, $alpha: 0.2); + + &:hover { + background-color: $active_color; + color: white; + } + } +} + +.aura-title { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 3px; + // text-align: center; + align-items: center; + + .title { + flex-grow: 1; + } + + img, + .icon { + flex-shrink: 1; + } +} diff --git a/werewolves/src/class.rs b/werewolves/src/class.rs new file mode 100644 index 0000000..5b09853 --- /dev/null +++ b/werewolves/src/class.rs @@ -0,0 +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 . +pub trait Class { + fn class(&self) -> Option<&'static str>; +} diff --git a/werewolves/src/clients/host/story_test.rs b/werewolves/src/clients/host/story_test.rs index 35541b5..926f755 100644 --- a/werewolves/src/clients/host/story_test.rs +++ b/werewolves/src/clients/host/story_test.rs @@ -822,7 +822,8 @@ impl GameExt for Game { fn mark_and_check(&mut self, mark: CharacterId) { let prompt = self.mark(mark); match prompt { - ActionPrompt::Insomniac { .. } + ActionPrompt::TraitorIntro { .. } + | ActionPrompt::Insomniac { .. } | ActionPrompt::MasonsWake { .. } | ActionPrompt::ElderReveal { .. } | ActionPrompt::CoverOfDarkness diff --git a/werewolves/src/components/action/prompt.rs b/werewolves/src/components/action/prompt.rs index 546c4a6..d07cf7b 100644 --- a/werewolves/src/components/action/prompt.rs +++ b/werewolves/src/components/action/prompt.rs @@ -31,7 +31,7 @@ use crate::{ Button, CoverOfDarkness, Identity, action::{BinaryChoice, TargetPicker, WolvesIntro}, }, - pages::MasonsWake, + pages::{MasonsWake, TraitorIntroPage}, }; #[derive(Debug, Clone, PartialEq, Properties)] @@ -113,6 +113,15 @@ pub fn Prompt(props: &ActionPromptProps) -> Html { } }); let (character_id, targets, marked, role_info) = match &props.prompt { + ActionPrompt::TraitorIntro { character_id } => { + return html! { +
+ {identity_html(props, Some(character_id))} + + {cont} +
+ }; + } ActionPrompt::CoverOfDarkness => { return html! { @@ -403,24 +412,26 @@ pub fn Prompt(props: &ActionPromptProps) -> Html { ))); } }); + let choice = props.big_screen.not().then_some(html! { + + }); return html! {
{identity_html(props, Some(character_id))}

{"SHAPESHIFTER"}

-

+

{"WOULD YOU LIKE TO USE YOUR "} {"ONCE PER GAME"} {" SHAPESHIFT ABILITY?"} -

-

+

+

{"YOU WILL DIE"}{", AND THE "} {"TARGET OF THE WOLFPACK KILL"} {" SHALL INSTEAD BECOME A WOLF"} -

+
- - + {choice}
}; } diff --git a/werewolves/src/components/action/result.rs b/werewolves/src/components/action/result.rs index 7c58df5..280b3dd 100644 --- a/werewolves/src/components/action/result.rs +++ b/werewolves/src/components/action/result.rs @@ -25,8 +25,9 @@ use yew::prelude::*; use crate::{ components::{Button, CoverOfDarkness, Icon, IconSource, Identity}, pages::{ - AdjudicatorResult, ArcanistResult, BeholderSawNothing, EmpathResult, GravediggerResultPage, - InsomniacResult, MorticianResultPage, PowerSeerResult, RoleblockPage, SeerResult, + AdjudicatorResult, ArcanistResult, BeholderSawEverything, BeholderSawNothing, DrunkPage, + EmpathResult, GravediggerResultPage, InsomniacResult, MorticianResultPage, PowerSeerResult, + RoleblockPage, SeerResult, ShiftFailed, }, }; @@ -58,6 +59,15 @@ pub fn ActionResultView(props: &ActionResultProps) -> Html { .not() .then(|| html! {}); let body = match &props.result { + ActionResult::ShiftFailed => html! { + + }, + ActionResult::Drunk => html! { + + }, + ActionResult::BeholderSawEverything => html! { + + }, ActionResult::BeholderSawNothing => html! { }, diff --git a/werewolves/src/components/aura.rs b/werewolves/src/components/aura.rs index 0a8ebbb..7c305be 100644 --- a/werewolves/src/components/aura.rs +++ b/werewolves/src/components/aura.rs @@ -12,28 +12,39 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use werewolves_proto::aura; +use werewolves_proto::aura::{self, Aura, AuraTitle}; use yew::prelude::*; -use crate::components::{Icon, IconType, PartialAssociatedIcon}; +use crate::{ + class::Class, + components::{Icon, IconType, PartialAssociatedIcon}, +}; -#[derive(Debug, Clone, PartialEq, Properties)] -pub struct AuraProps { - pub aura: aura::Aura, +impl Class for AuraTitle { + fn class(&self) -> Option<&'static str> { + Some(match self { + aura::AuraTitle::Traitor => "traitor", + aura::AuraTitle::Drunk => "drunk", + aura::AuraTitle::Insane => "insane", + aura::AuraTitle::Bloodlet => "wolves", + }) + } } -fn aura_class(aura: &aura::Aura) -> Option<&'static str> { - Some(match aura { - aura::Aura::Traitor => "traitor", - aura::Aura::Drunk => "drunk", - aura::Aura::Insane => "insane", - aura::Aura::Bloodlet { .. } => "wolves", - }) +impl Class for Aura { + fn class(&self) -> Option<&'static str> { + self.title().class() + } +} + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct AuraSpanProps { + pub aura: aura::AuraTitle, } #[function_component] -pub fn Aura(AuraProps { aura }: &AuraProps) -> Html { - let class = aura_class(aura); +pub fn AuraSpan(AuraSpanProps { aura }: &AuraSpanProps) -> Html { + let class = aura.class(); let icon = aura.icon().map(|icon| { html! {
diff --git a/werewolves/src/components/host/setup.rs b/werewolves/src/components/host/setup.rs index 933ee31..797908a 100644 --- a/werewolves/src/components/host/setup.rs +++ b/werewolves/src/components/host/setup.rs @@ -151,16 +151,13 @@ pub fn SetupCategory(
- // {"alignment"}/
- // killer icon
- // powerful icon
diff --git a/werewolves/src/components/icon.rs b/werewolves/src/components/icon.rs index 2286c94..447302b 100644 --- a/werewolves/src/components/icon.rs +++ b/werewolves/src/components/icon.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . use werewolves_proto::{ - aura::Aura, + aura::{Aura, AuraTitle}, diedto::DiedToTitle, role::{Alignment, Killer, Powerful, RoleTitle}, }; @@ -79,6 +79,8 @@ decl_icon!( RedX: "/img/red-x.svg", Traitor: "/img/traitor.svg", Bloodlet: "/img/bloodlet.svg", + Drunk: "/img/drunk.svg", + Insane: "/img/insane.svg", ); impl IconSource { @@ -95,7 +97,6 @@ impl IconSource { pub enum IconType { List, Small, - RoleAdd, Fit, Icon15Pct, Informational, @@ -110,7 +111,6 @@ impl IconType { IconType::Fit => "icon-fit", IconType::List => "icon-in-list", IconType::Small => "icon", - IconType::RoleAdd => "icon-role-add", IconType::Informational => "icon-info", IconType::RoleCheck => "check-icon", } @@ -227,13 +227,13 @@ impl PartialAssociatedIcon for DiedToTitle { } } -impl PartialAssociatedIcon for Aura { +impl PartialAssociatedIcon for AuraTitle { fn icon(&self) -> Option { match self { - Aura::Traitor => Some(IconSource::Traitor), - Aura::Drunk => todo!(), - Aura::Insane => todo!(), - Aura::Bloodlet { .. } => Some(IconSource::Bloodlet), + AuraTitle::Traitor => Some(IconSource::Traitor), + AuraTitle::Drunk => Some(IconSource::Drunk), + AuraTitle::Insane => Some(IconSource::Insane), + AuraTitle::Bloodlet => Some(IconSource::Bloodlet), } } } diff --git a/werewolves/src/components/settings.rs b/werewolves/src/components/settings.rs index 5160064..ba6e151 100644 --- a/werewolves/src/components/settings.rs +++ b/werewolves/src/components/settings.rs @@ -17,6 +17,7 @@ use std::rc::Rc; use convert_case::{Case, Casing}; use werewolves_proto::{ + aura::{Aura, AuraTitle}, error::GameError, game::{GameSettings, OrRandom, SetupRole, SetupSlot, SlotId}, message::{Identification, PlayerState, PublicIdentity}, @@ -24,9 +25,11 @@ use werewolves_proto::{ }; use yew::prelude::*; -use crate::components::{ - Button, ClickableField, Icon, IconSource, IconType, Identity, PartialAssociatedIcon, - client::Signin, +use crate::{ + class::Class, + components::{ + Button, ClickableField, Icon, IconType, Identity, PartialAssociatedIcon, client::Signin, + }, }; #[derive(Debug, PartialEq, Properties)] @@ -346,6 +349,7 @@ pub fn SettingsSlot( }: &SettingsSlotProps, ) -> Html { let open = use_state(|| false); + let aura_open = use_state(|| false); let open_update = open.setter(); let update = update.clone(); let update = Callback::from(move |act| { @@ -362,8 +366,14 @@ pub fn SettingsSlot( open_update.set(false); }); let assign_to = assign_to_submenu(players_for_assign, slot, &update, &open.setter()); - let options = - setup_options_for_slot(slot, &update, roles_in_setup, apprentice_open, open.clone()); + let options = setup_options_for_slot( + slot, + &update, + roles_in_setup, + apprentice_open, + open.clone(), + aura_open.clone(), + ); let assign_text = slot .assign_to .as_ref() @@ -379,7 +389,7 @@ pub fn SettingsSlot( .unwrap_or_else(|| html! {{"assign"}}); html! { <> - , slot_field_open: UseStateHandle, + open_aura_assign: UseStateHandle, ) -> Html { + let aura_assign = { + let options = AuraTitle::ALL + .into_iter() + .filter(AuraTitle::assignable) + // .map(AuraTitle::into_aura) + .filter(|aura| slot.role.title().can_assign_aura(*aura)) + .map(|aura| { + let aura_active = slot.auras.contains(&aura); + let active_class = aura_active.then_some("active"); + let toggle = { + let slot = slot.clone(); + let update = update.clone(); + Callback::from(move |_| { + let mut slot = slot.clone(); + if aura_active { + slot.auras.retain(|a| *a != aura); + } else { + slot.auras.push(aura); + } + update.emit(SettingSlotAction::Update(slot)) + }) + }; + let icon = aura + .icon() + .map(|icon| { + html! { + //
+ } + }) + .unwrap_or_else(|| { + html! { +
+ } + }); + let aura_class = aura.class(); + + html! { + + } + }) + .collect::>(); + options.is_empty().not().then(|| { + let options = options.into_iter().collect::(); + html! { + + {"auras"} + + } + }) + }; let setup_options_for_role = match &slot.role { SetupRole::MasonLeader { recruits_available } => { let next = { @@ -660,5 +734,10 @@ fn setup_options_for_slot( _ => None, }; - setup_options_for_role.unwrap_or_default() + html! { + <> + {aura_assign} + {setup_options_for_role} + + } } diff --git a/werewolves/src/components/story.rs b/werewolves/src/components/story.rs index f6ba9e0..d705d2b 100644 --- a/werewolves/src/components/story.rs +++ b/werewolves/src/components/story.rs @@ -17,23 +17,20 @@ use std::{collections::HashMap, rc::Rc}; use convert_case::{Case, Casing}; use werewolves_proto::{ - character::{Character, CharacterId}, - game::{ + aura::AuraTitle, character::{Character, CharacterId}, game::{ GameTime, SetupRole, night::changes::NightChange, story::{ DayDetail, GameActions, GameStory, NightChoice, StoryActionPrompt, StoryActionResult, }, - }, - role::Alignment, + }, role::Alignment }; use yew::prelude::*; use crate::components::{ - CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon, - attributes::{ + AuraSpan, CharacterCard, Icon, IconSource, IconType, PartialAssociatedIcon, attributes::{ AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan, - }, + } }; #[derive(Debug, Clone, PartialEq, Properties)] @@ -177,11 +174,12 @@ struct StoryNightChangeProps { #[function_component] fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html { match change { + NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{ <> {"lost the"} - + {"aura"} }).unwrap_or_default(), @@ -190,7 +188,7 @@ fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightCha <> {"gained the"} - + {"aura from"} @@ -278,7 +276,7 @@ fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightCha }) .unwrap_or_default(), - NightChange::HunterTarget { .. } + NightChange::HunterTarget { .. } | NightChange::MasonRecruit { .. } | NightChange::Protection { .. } => html! {}, // sorted in prompt side } @@ -293,6 +291,19 @@ struct StoryNightResultProps { #[function_component] fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html { match result { + StoryActionResult::ShiftFailed => html!{ + {"but it failed"} + }, + StoryActionResult::Drunk => html! { + + {"but got "} + + {" instead"} + + }, + StoryActionResult::BeholderSawEverything => html!{ + {"and saw everything 👁️"} + }, StoryActionResult::BeholderSawNothing => html!{ {"but saw nothing"} }, @@ -396,7 +407,7 @@ struct StoryNightChoiceProps { } #[function_component] -fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightChoiceProps) -> Html { +fn StoryNightChoice(StoryNightChoiceProps { choice, characters}: &StoryNightChoiceProps) -> Html { let generate = |character_id: &CharacterId, chosen: &CharacterId, action: &'static str| { characters .get(character_id) @@ -544,6 +555,9 @@ fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightCho }) } StoryActionPrompt::Shapeshifter { character_id } => { + if choice.result.is_none() { + return html!{}; + } characters.get(character_id).map(|shifter| { html! { <> diff --git a/werewolves/src/main.rs b/werewolves/src/main.rs index dde40e6..e38b059 100644 --- a/werewolves/src/main.rs +++ b/werewolves/src/main.rs @@ -13,6 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . mod assets; +mod class; mod clients; mod storage; mod components { diff --git a/werewolves/src/pages/drunk_page.rs b/werewolves/src/pages/drunk_page.rs new file mode 100644 index 0000000..c0a27f7 --- /dev/null +++ b/werewolves/src/pages/drunk_page.rs @@ -0,0 +1,21 @@ +use werewolves_proto::aura::AuraTitle; +use yew::prelude::*; + +use crate::components::{Icon, IconSource, IconType, PartialAssociatedIcon}; + +#[function_component] +pub fn DrunkPage() -> Html { + let icon = AuraTitle::Drunk.icon().unwrap_or(IconSource::Roleblock); + html! { +
+

{"DRUNK"}

+
+

{"YOU GOT DRUNK INSTEAD"}

+

+ +

+

{"YOUR NIGHT ACTION DID NOT TAKE PLACE"}

+
+
+ } +} diff --git a/werewolves/src/pages/role_change.rs b/werewolves/src/pages/role_change.rs index d839039..83ce8c0 100644 --- a/werewolves/src/pages/role_change.rs +++ b/werewolves/src/pages/role_change.rs @@ -31,19 +31,21 @@ pub fn RoleChangePage(RoleChangePageProps { role }: &RoleChangePageProps) -> Htm let class = Into::::into(*role).category().class(); let icon = role.icon().map(|icon| { html! { -

- -

+
+ +
} }); html! {
-

{"ROLE CHANGE"}

+

{"ROLE CHANGE"}

{"YOUR ROLE HAS CHANGED"}

{icon} -

{"YOUR NEW ROLE IS"}

-

{role.to_string().to_case(Case::Upper)}

+

+ {"YOUR NEW ROLE IS "} + {role.to_string().to_case(Case::Upper)} +

} diff --git a/werewolves/src/pages/role_page/beholder.rs b/werewolves/src/pages/role_page/beholder.rs index b125657..ada5da0 100644 --- a/werewolves/src/pages/role_page/beholder.rs +++ b/werewolves/src/pages/role_page/beholder.rs @@ -43,3 +43,24 @@ pub fn BeholderSawNothing() -> Html {
} } + +#[function_component] +pub fn BeholderSawEverything() -> Html { + html! { +
+

{"BEHOLDER"}

+
+

{"YOUR TARGET HAS DIED"}

+
+ +
+

+ {"BUT SAW "} + + {"EVERYTHING"} + +

+
+
+ } +} diff --git a/werewolves/src/pages/role_page/maple_wolf.rs b/werewolves/src/pages/role_page/maple_wolf.rs index 36c6106..77e2ac6 100644 --- a/werewolves/src/pages/role_page/maple_wolf.rs +++ b/werewolves/src/pages/role_page/maple_wolf.rs @@ -25,10 +25,10 @@ pub struct MapleWolfPage1Props { pub fn MapleWolfPage1(MapleWolfPage1Props { starving }: &MapleWolfPage1Props) -> Html { let starving = starving .then_some(html! { - <> +

{"YOU ARE STARVING"}

{"IF YOU FAIL TO EAT TONIGHT, YOU WILL DIE"}

- +
}) .unwrap_or_else(|| { html! { @@ -45,8 +45,8 @@ pub fn MapleWolfPage1(MapleWolfPage1Props { starving }: &MapleWolfPage1Props) ->

{"YOU CAN CHOOSE TO EAT A PLAYER TONIGHT"}

-
- +
+
{starving}
diff --git a/werewolves/src/pages/role_page/power_seer.rs b/werewolves/src/pages/role_page/power_seer.rs index c53beaa..cc75d45 100644 --- a/werewolves/src/pages/role_page/power_seer.rs +++ b/werewolves/src/pages/role_page/power_seer.rs @@ -12,12 +12,11 @@ // // 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 werewolves_proto::role::Powerful; use yew::prelude::*; -use crate::components::{AssociatedIcon, Icon, IconSource, IconType}; +use crate::components::{Icon, IconSource, IconType}; #[function_component] pub fn PowerSeerPage1() -> Html { @@ -26,10 +25,9 @@ pub fn PowerSeerPage1() -> Html {

{"POWER SEER"}

{"PICK A PLAYER"}

-

- - -

+
+ +

{"YOU WILL CHECK IF THEY ARE POWERFUL"}

diff --git a/werewolves/src/pages/role_page/protector.rs b/werewolves/src/pages/role_page/protector.rs index 7c718d5..1f2f2f8 100644 --- a/werewolves/src/pages/role_page/protector.rs +++ b/werewolves/src/pages/role_page/protector.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . use yew::prelude::*; -use crate::components::{Icon, IconSource, IconType}; +use crate::components::{Icon, IconSource}; #[function_component] pub fn ProtectorPage1() -> Html { @@ -23,9 +23,9 @@ pub fn ProtectorPage1() -> Html {

{"PROTECTOR"}

{"PICK A PLAYER"}

-

- -

+
+ +

{"YOU WILL PROTECT THEM FROM A DEATH TONIGHT"}

diff --git a/werewolves/src/pages/roleblock.rs b/werewolves/src/pages/roleblock.rs index 80fbfab..b6aef2f 100644 --- a/werewolves/src/pages/roleblock.rs +++ b/werewolves/src/pages/roleblock.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . use yew::prelude::*; -use crate::components::{Icon, IconSource, IconType}; +use crate::components::{Icon, IconSource}; #[function_component] pub fn RoleblockPage() -> Html { @@ -23,9 +23,9 @@ pub fn RoleblockPage() -> Html {

{"ROLE BLOCKED"}

{"YOU WERE ROLE BLOCKED"}

-

- -

+
+ +

{"YOUR NIGHT ACTION DID NOT TAKE PLACE"}

diff --git a/werewolves/src/pages/shift_failed.rs b/werewolves/src/pages/shift_failed.rs new file mode 100644 index 0000000..07cc305 --- /dev/null +++ b/werewolves/src/pages/shift_failed.rs @@ -0,0 +1,40 @@ +// 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 convert_case::{Case, Casing}; +use werewolves_proto::{ + game::SetupRole, + role::{Alignment, RoleTitle}, +}; +use yew::prelude::*; + +use crate::components::{AssociatedIcon, Icon, IconSource, IconType, PartialAssociatedIcon}; + +#[function_component] +pub fn ShiftFailed() -> Html { + html! { +
+

{"SHIFT FAILED"}

+
+

{"YOUR SHIFT HAS FAILED"}

+
+ +
+

+ {"YOU RETAIN YOUR SHAPESHIFT ABILITY"} +

+
+
+ } +} diff --git a/werewolves/src/pages/traitor.rs b/werewolves/src/pages/traitor.rs new file mode 100644 index 0000000..e0224b5 --- /dev/null +++ b/werewolves/src/pages/traitor.rs @@ -0,0 +1,18 @@ +use yew::prelude::*; + +#[function_component] +pub fn TraitorIntroPage() -> Html { + html! { +
+

{"TRAITOR"}

+
+

{"YOU ARE A TRAITOR"}

+

{"YOU RETAIN YOUR ROLE AND WIN IF EVIL WINS"}

+

{"HOWEVER"}

+

+ {"YOU CONTRIBUTE TO VILLAGE PARITY"} +

+
+
+ } +}