From f193e4e6912eef4d03d6e75c4894b9db6938bcac Mon Sep 17 00:00:00 2001 From: emilis Date: Sun, 9 Nov 2025 16:40:50 +0000 Subject: [PATCH] bloodletter implementation + aura logic + traitor --- werewolves-proto/src/aura.rs | 153 ++++++++++++++++++ werewolves-proto/src/character.rs | 61 +++++-- werewolves-proto/src/game/night.rs | 133 +++++++-------- werewolves-proto/src/game/night/changes.rs | 10 ++ werewolves-proto/src/game/night/process.rs | 31 +++- .../src/game/settings/settings_role.rs | 20 ++- werewolves-proto/src/game/story.rs | 15 +- werewolves-proto/src/game/village.rs | 2 + werewolves-proto/src/game/village/apply.rs | 12 +- werewolves-proto/src/game_test/mod.rs | 13 +- .../src/game_test/role/bloodletter.rs | 132 +++++++++++++++ werewolves-proto/src/game_test/role/mod.rs | 1 + werewolves-proto/src/lib.rs | 3 +- werewolves-proto/src/message/night.rs | 15 +- werewolves-proto/src/role.rs | 11 +- werewolves-proto/src/{modifier.rs => team.rs} | 15 +- werewolves/img/bloodlet.svg | 29 ++++ werewolves/img/traitor.svg | 22 +++ werewolves/img/wolf.svg | 26 +-- werewolves/index.scss | 69 ++++++-- werewolves/src/clients/host/story_test.rs | 9 +- werewolves/src/components/action/prompt.rs | 10 ++ .../src/components/attributes/align_span.rs | 7 +- werewolves/src/components/aura.rs | 51 ++++++ werewolves/src/components/host/setup.rs | 16 +- werewolves/src/components/icon.rs | 20 +++ werewolves/src/components/story.rs | 20 +++ werewolves/src/pages/role_page.rs | 6 + werewolves/src/pages/role_page/bloodletter.rs | 40 +++++ werewolves/src/pages/role_page/seer.rs | 85 +++++++--- 30 files changed, 874 insertions(+), 163 deletions(-) create mode 100644 werewolves-proto/src/aura.rs create mode 100644 werewolves-proto/src/game_test/role/bloodletter.rs rename werewolves-proto/src/{modifier.rs => team.rs} (80%) create mode 100644 werewolves/img/bloodlet.svg create mode 100644 werewolves/img/traitor.svg create mode 100644 werewolves/src/components/aura.rs create mode 100644 werewolves/src/pages/role_page/bloodletter.rs diff --git a/werewolves-proto/src/aura.rs b/werewolves-proto/src/aura.rs new file mode 100644 index 0000000..6d35d9c --- /dev/null +++ b/werewolves-proto/src/aura.rs @@ -0,0 +1,153 @@ +use core::fmt::Display; + +// 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 serde::{Deserialize, Serialize}; +use werewolves_macros::ChecksAs; + +use crate::{ + game::{GameTime, Village}, + role::{Alignment, Killer, Powerful}, + team::Team, +}; +const BLOODLET_DURATION_DAYS: u8 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)] +pub enum Aura { + Traitor, + #[checks("cleansible")] + Drunk, + Insane, + #[checks("cleansible")] + 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::Insane => "Insane", + Aura::Bloodlet { .. } => "Bloodlet", + }) + } +} + +impl Aura { + pub const fn expired(&self, village: &Village) -> bool { + match self { + Aura::Traitor | Aura::Drunk | Aura::Insane => false, + Aura::Bloodlet { + night: applied_night, + } => match village.time() { + GameTime::Day { .. } => false, + GameTime::Night { + number: current_night, + } => current_night >= applied_night.saturating_add(BLOODLET_DURATION_DAYS), + }, + } + } + + pub const fn refreshes(&self, other: &Aura) -> bool { + matches!( + (self, other), + (Aura::Bloodlet { .. }, Aura::Bloodlet { .. }) + ) + } + + pub fn refresh(&mut self, other: Aura) { + if let (Aura::Bloodlet { night }, Aura::Bloodlet { night: new_night }) = (self, other) { + *night = new_night + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Auras(Vec); + +impl Auras { + pub const fn new(auras: Vec) -> Self { + Self(auras) + } + + pub fn list(&self) -> &[Aura] { + &self.0 + } + + pub fn remove_aura(&mut self, aura: Aura) { + self.0.retain(|a| *a != aura); + } + + /// purges expired [Aura]s and returns the ones that were removed + pub fn purge_expired(&mut self, village: &Village) -> Box<[Aura]> { + let mut auras = Vec::with_capacity(self.0.len()); + core::mem::swap(&mut self.0, &mut auras); + let (expired, retained): (Vec<_>, Vec<_>) = + auras.into_iter().partition(|aura| aura.expired(village)); + + self.0 = retained; + expired.into_boxed_slice() + } + + pub fn add(&mut self, aura: Aura) { + if let Some(existing) = self.0.iter_mut().find(|aura| aura.refreshes(aura)) { + existing.refresh(aura); + } else { + self.0.push(aura); + } + } + + pub fn cleanse(&mut self) { + self.0.retain(|aura| !aura.cleansible()); + } + + /// returns [Some] if the auras override the player's [Team] + pub fn overrides_team(&self) -> Option { + if self.0.iter().any(|a| matches!(a, Aura::Traitor)) { + return Some(Team::AnyEvil); + } + None + } + + /// returns [Some] if the auras override the player's [Alignment] + pub fn overrides_alignment(&self) -> Option { + for aura in self.0.iter() { + match aura { + Aura::Traitor => return Some(Alignment::Traitor), + Aura::Bloodlet { .. } => return Some(Alignment::Wolves), + Aura::Drunk | Aura::Insane => {} + } + } + None + } + + /// returns [Some] if the auras override whether the player is a [Killer] + pub fn overrides_killer(&self) -> Option { + self.0 + .iter() + .any(|a| matches!(a, Aura::Bloodlet { .. })) + .then_some(Killer::Killer) + } + + /// returns [Some] if the auras override whether the player is [Powerful] + pub fn overrides_powerful(&self) -> Option { + self.0 + .iter() + .any(|a| matches!(a, Aura::Bloodlet { .. })) + .then_some(Powerful::Powerful) + } +} diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs index eb92566..df81bd4 100644 --- a/werewolves-proto/src/character.rs +++ b/werewolves-proto/src/character.rs @@ -22,11 +22,11 @@ use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use crate::{ + aura::{Aura, Auras}, diedto::DiedTo, error::GameError, game::{GameTime, Village}, message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt}, - modifier::Modifier, player::{PlayerId, RoleChange}, role::{ Alignment, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Powerful, @@ -59,7 +59,7 @@ pub struct Character { player_id: PlayerId, identity: CharacterIdentity, role: Role, - modifier: Option, + auras: Auras, died_to: Option, role_changes: Vec, } @@ -76,19 +76,20 @@ impl Character { }, }: Identification, role: Role, + auras: Vec, ) -> Option { Some(Self { role, + player_id, + died_to: None, + auras: Auras::new(auras), + role_changes: Vec::new(), identity: CharacterIdentity { character_id: CharacterId::new(), name, pronouns, number: number?, }, - player_id, - modifier: None, - died_to: None, - role_changes: Vec::new(), }) } @@ -189,13 +190,6 @@ impl Character { } } - pub const fn alignment(&self) -> Alignment { - if let Role::Empath { cursed: true } = &self.role { - return Alignment::Wolves; - } - self.role.alignment() - } - pub fn elder_reveal(&mut self) { if let Role::Elder { woken_for_reveal, .. @@ -205,6 +199,14 @@ impl Character { } } + pub fn purge_expired_auras(&mut self, village: &Village) -> Box<[Aura]> { + self.auras.purge_expired(village) + } + + pub fn auras(&self) -> &[Aura] { + self.auras.list() + } + 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); @@ -298,6 +300,14 @@ impl Character { AsCharacter(char) } + pub fn apply_aura(&mut self, aura: Aura) { + self.auras.add(aura); + } + + pub fn remove_aura(&mut self, aura: Aura) { + self.auras.remove_aura(aura); + } + pub fn night_action_prompts(&self, village: &Village) -> Result> { if self.mason_leader().is_ok() { return self.mason_prompts(village); @@ -345,6 +355,11 @@ impl Character { return Ok(Box::new([])); } } + Role::Bloodletter => ActionPrompt::Bloodletter { + character_id: self.identity(), + living_players: village.living_villagers(), + marked: None, + }, Role::Seer => ActionPrompt::Seer { character_id: self.identity(), living_players: village.living_players_excluding(self.character_id()), @@ -574,14 +589,30 @@ impl Character { self.role.killing_wolf_order() } - pub const fn killer(&self) -> Killer { + pub fn alignment(&self) -> Alignment { + if let Some(alignment) = self.auras.overrides_alignment() { + return alignment; + } + if let Role::Empath { cursed: true } = &self.role { + return Alignment::Wolves; + } + self.role.alignment() + } + + pub fn killer(&self) -> Killer { + if let Some(killer) = self.auras.overrides_killer() { + return killer; + } if let Role::Empath { cursed: true } = &self.role { return Killer::Killer; } self.role.killer() } - pub const fn powerful(&self) -> Powerful { + pub fn powerful(&self) -> Powerful { + if let Some(powerful) = self.auras.overrides_powerful() { + return powerful; + } if let Role::Empath { cursed: true } = &self.role { return Powerful::Powerful; } diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index f0bc422..6007191 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -30,7 +30,7 @@ use crate::{ kill::{self}, night::changes::{ChangesLookup, NightChange}, }, - message::night::{ActionPrompt, ActionResponse, ActionResult, Visits}, + message::night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits}, role::RoleTitle, }; @@ -71,7 +71,11 @@ impl ActionPrompt { .. } => Some(Unless::TargetsBlocked(*marked1, *marked2)), - ActionPrompt::LoneWolfKill { + ActionPrompt::Bloodletter { + marked: Some(marked), + .. + } + | ActionPrompt::LoneWolfKill { marked: Some(marked), .. } @@ -148,7 +152,8 @@ impl ActionPrompt { .. } => Some(Unless::TargetBlocked(*marked)), - ActionPrompt::LoneWolfKill { marked: None, .. } + ActionPrompt::Bloodletter { .. } + | ActionPrompt::LoneWolfKill { marked: None, .. } | ActionPrompt::Seer { marked: None, .. } | ActionPrompt::Protector { marked: None, .. } | ActionPrompt::Gravedigger { marked: None, .. } @@ -258,15 +263,29 @@ pub struct Night { night: u8, action_queue: VecDeque, used_actions: Vec<(ActionPrompt, ActionResult, Vec)>, + start_of_night_changes: Vec, night_state: NightState, } impl Night { - pub fn new(village: Village) -> Result { + pub fn new(mut village: Village) -> Result { let night = match village.time() { GameTime::Day { number: _ } => return Err(GameError::NotNight), GameTime::Night { number } => number, }; + let mut start_of_night_changes = Self::start_of_night_changes(&village, night); + // purge expired auras + { + let village_clone = village.clone(); + for char in village.characters_mut() { + for expired_aura in char.purge_expired_auras(&village_clone) { + start_of_night_changes.push(NightChange::LostAura { + character: char.character_id(), + aura: expired_aura, + }); + } + } + } let filter = if village.executed_known_elder() { // there is a lynched elder, remove villager PRs from the prompts @@ -380,6 +399,7 @@ impl Night { village, night_state, action_queue, + start_of_night_changes, used_actions: Vec::new(), }) } @@ -402,77 +422,37 @@ impl Night { // for prompt in action_queue { while let Some(prompt) = action_queue.pop_front() { log::warn!("prompt: {:?}", prompt.title()); - let (wolf_id, prompt) = match prompt { + match prompt { ActionPrompt::WolvesIntro { mut wolves } => { if let Some(w) = wolves.iter_mut().find(|w| w.0.character_id == reverting) { w.1 = reverting_into; } new_queue.push_back(ActionPrompt::WolvesIntro { wolves }); - continue; } - - ActionPrompt::Shapeshifter { character_id } => ( - character_id.character_id, - ActionPrompt::Shapeshifter { character_id }, - ), - ActionPrompt::AlphaWolf { - character_id, - living_villagers, - marked, - } => ( - character_id.character_id, - ActionPrompt::AlphaWolf { - character_id, - living_villagers, - marked, - }, - ), - ActionPrompt::DireWolf { - character_id, - living_players, - marked, - } => ( - character_id.character_id, - ActionPrompt::DireWolf { - character_id, - living_players, - marked, - }, - ), - ActionPrompt::LoneWolfKill { - character_id, - living_players, - marked, - } => ( - character_id.character_id, - ActionPrompt::LoneWolfKill { - character_id, - living_players, - marked, - }, - ), other => { + if let Some(char_id) = other.character_id() + && char_id == reverting + && !matches!(other.title(), ActionPromptTitle::RoleChange) + { + continue; + } new_queue.push_back(other); - continue; } }; - if wolf_id != reverting { - new_queue.push_back(prompt); - } } new_queue } - /// changes that require no input (such as hunter firing) - fn automatic_changes(&self) -> Vec { + /// changes from the beginning of the night that require no input (such as hunter firing) + fn start_of_night_changes(village: &Village, night: u8) -> Vec { let mut changes = Vec::new(); - let night = match NonZeroU8::new(self.night) { + let night = match NonZeroU8::new(night) { Some(night) => night, None => return changes, }; - if !self.village.executed_known_elder() { - self.village + if !village.executed_known_elder() { + village .dead_characters() .into_iter() .filter_map(|c| c.died_to().map(|d| (c, d))) @@ -576,9 +556,13 @@ impl Night { if !matches!(self.night_state, NightState::Complete) { return Err(GameError::NotEndOfNight); } - let mut all_changes = self.automatic_changes(); + Ok(self.current_changes()) + } + + pub fn current_changes(&self) -> Box<[NightChange]> { + let mut all_changes = self.start_of_night_changes.clone(); all_changes.append(&mut self.changes_from_actions().into_vec()); - Ok(all_changes.into_boxed_slice()) + all_changes.into_boxed_slice() } fn apply_mason_recruit( @@ -1061,11 +1045,7 @@ impl Night { /// resolves whether the target [CharacterId] dies tonight with the current /// state of the night fn dies_tonight(&self, character_id: CharacterId) -> Result { - let ch = self - .changes_from_actions() - .into_iter() - .chain(self.automatic_changes()) - .collect::>(); + let ch = self.current_changes(); let mut changes = ChangesLookup::new(&ch); if let Some(died_to) = changes.killed(character_id) && kill::resolve_kill( @@ -1083,6 +1063,23 @@ impl Night { } } + /// returns the matching [Character] with the current night's aura changes + /// applied + fn character_with_current_auras(&self, id: CharacterId) -> Result { + let mut character = self.village.character_by_id(id)?.clone(); + for aura in self + .changes_from_actions() + .into_iter() + .filter_map(|c| match c { + NightChange::ApplyAura { target, aura, .. } => (target == id).then_some(aura), + _ => None, + }) + { + character.apply_aura(aura); + } + Ok(character) + } + fn changes_from_actions(&self) -> Box<[NightChange]> { self.used_actions .iter() @@ -1109,7 +1106,12 @@ impl Night { .then(|| self.village.killing_wolf().map(|c| c.identity())) .flatten(), - ActionPrompt::Seer { + ActionPrompt::Bloodletter { + character_id, + marked: Some(marked), + .. + } + | ActionPrompt::Seer { character_id, marked: Some(marked), .. @@ -1200,7 +1202,8 @@ impl Night { .. } => (*marked == visit_char).then(|| character_id.clone()), - ActionPrompt::WolfPackKill { marked: None, .. } + ActionPrompt::Bloodletter { .. } + | ActionPrompt::WolfPackKill { marked: None, .. } | ActionPrompt::Arcanist { marked: _, .. } | ActionPrompt::LoneWolfKill { marked: None, .. } | ActionPrompt::Seer { marked: None, .. } diff --git a/werewolves-proto/src/game/night/changes.rs b/werewolves-proto/src/game/night/changes.rs index d4febd2..c1752a7 100644 --- a/werewolves-proto/src/game/night/changes.rs +++ b/werewolves-proto/src/game/night/changes.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; use werewolves_macros::Extract; use crate::{ + aura::Aura, character::CharacterId, diedto::DiedTo, player::Protection, @@ -59,6 +60,15 @@ pub enum NightChange { empath: CharacterId, scapegoat: CharacterId, }, + ApplyAura { + source: CharacterId, + target: CharacterId, + aura: Aura, + }, + LostAura { + character: CharacterId, + aura: Aura, + }, } pub struct ChangesLookup<'a>(&'a [NightChange], Vec); diff --git a/werewolves-proto/src/game/night/process.rs b/werewolves-proto/src/game/night/process.rs index d13dc7e..6afb33d 100644 --- a/werewolves-proto/src/game/night/process.rs +++ b/werewolves-proto/src/game/night/process.rs @@ -15,6 +15,7 @@ use core::num::NonZeroU8; use crate::{ + aura::Aura, diedto::DiedTo, error::GameError, game::night::{ @@ -108,6 +109,19 @@ impl Night { }; match current_prompt { + ActionPrompt::Bloodletter { + character_id, + living_players, + marked: Some(marked), + } => Ok(ActionComplete { + result: ActionResult::GoBackToSleep, + change: Some(NightChange::ApplyAura { + source: character_id.character_id, + aura: Aura::Bloodlet { night: self.night }, + target: *marked, + }), + } + .into()), ActionPrompt::LoneWolfKill { character_id, marked: Some(marked), @@ -143,7 +157,7 @@ impl Night { marked: Some(marked), .. } => { - let alignment = self.village.character_by_id(*marked)?.alignment(); + let alignment = self.character_with_current_auras(*marked)?.alignment(); Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Seer(alignment), change: None, @@ -166,8 +180,8 @@ impl Night { marked: (Some(marked1), Some(marked2)), .. } => { - let same = self.village.character_by_id(*marked1)?.alignment() - == self.village.character_by_id(*marked2)?.alignment(); + let same = self.character_with_current_auras(*marked1)?.alignment() + == self.character_with_current_auras(*marked2)?.alignment(); Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Arcanist(AlignmentEq::new(same)), @@ -178,7 +192,9 @@ impl Night { marked: Some(marked), .. } => { - let dig_role = self.village.character_by_id(*marked)?.gravedigger_dig(); + let dig_role = self + .character_with_current_auras(*marked)? + .gravedigger_dig(); Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GraveDigger(dig_role), change: None, @@ -359,7 +375,7 @@ impl Night { .. } => Ok(ActionComplete { result: ActionResult::Adjudicator { - killer: self.village.character_by_id(*marked)?.killer(), + killer: self.character_with_current_auras(*marked)?.killer(), }, change: None, } @@ -369,7 +385,7 @@ impl Night { .. } => Ok(ActionComplete { result: ActionResult::PowerSeer { - powerful: self.village.character_by_id(*marked)?.powerful(), + powerful: self.character_with_current_auras(*marked)?.powerful(), }, change: None, } @@ -489,7 +505,8 @@ impl Night { } .into()), - ActionPrompt::Adjudicator { marked: None, .. } + ActionPrompt::Bloodletter { marked: None, .. } + | ActionPrompt::Adjudicator { marked: None, .. } | ActionPrompt::PowerSeer { marked: None, .. } | ActionPrompt::Mortician { marked: None, .. } | ActionPrompt::Beholder { marked: None, .. } diff --git a/werewolves-proto/src/game/settings/settings_role.rs b/werewolves-proto/src/game/settings/settings_role.rs index 053fed1..bf3f138 100644 --- a/werewolves-proto/src/game/settings/settings_role.rs +++ b/werewolves-proto/src/game/settings/settings_role.rs @@ -23,10 +23,10 @@ use uuid::Uuid; use werewolves_macros::{All, ChecksAs, Titles}; use crate::{ + aura::Aura, character::Character, error::GameError, message::Identification, - modifier::Modifier, player::PlayerId, role::{Role, RoleTitle}, }; @@ -127,6 +127,8 @@ pub enum SetupRole { Shapeshifter, #[checks(Category::Wolves)] LoneWolf, + #[checks(Category::Wolves)] + Bloodletter, #[checks(Category::Intel)] Adjudicator, @@ -157,6 +159,7 @@ pub enum SetupRole { impl SetupRoleTitle { pub fn into_role(self) -> Role { match self { + SetupRoleTitle::Bloodletter => Role::Bloodletter, SetupRoleTitle::Insomniac => Role::Insomniac, SetupRoleTitle::LoneWolf => Role::LoneWolf, SetupRoleTitle::Villager => Role::Villager, @@ -208,6 +211,7 @@ impl SetupRoleTitle { impl Display for SetupRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { + SetupRole::Bloodletter => "Bloodletter", SetupRole::Insomniac => "Insomniac", SetupRole::LoneWolf => "Lone Wolf", SetupRole::Villager => "Villager", @@ -244,6 +248,7 @@ impl Display for SetupRole { impl SetupRole { pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result { Ok(match self { + Self::Bloodletter => Role::Bloodletter, SetupRole::Insomniac => Role::Insomniac, SetupRole::LoneWolf => Role::LoneWolf, SetupRole::Villager => Role::Villager, @@ -321,6 +326,7 @@ impl From for RoleTitle { impl From for SetupRole { fn from(value: RoleTitle) -> Self { match value { + RoleTitle::Bloodletter => SetupRole::Bloodletter, RoleTitle::Insomniac => SetupRole::Insomniac, RoleTitle::LoneWolf => SetupRole::LoneWolf, RoleTitle::Villager => SetupRole::Villager, @@ -373,7 +379,7 @@ impl SlotId { pub struct SetupSlot { pub slot_id: SlotId, pub role: SetupRole, - pub modifiers: Vec, + pub auras: Vec, pub assign_to: Option, pub created_order: u32, } @@ -384,7 +390,7 @@ impl SetupSlot { created_order, assign_to: None, role: title.into(), - modifiers: Vec::new(), + auras: Vec::new(), slot_id: SlotId::new(), } } @@ -394,8 +400,12 @@ impl SetupSlot { ident: Identification, roles_in_game: &[RoleTitle], ) -> Result { - Character::new(ident.clone(), self.role.into_role(roles_in_game)?) - .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string())) + Character::new( + ident.clone(), + self.role.into_role(roles_in_game)?, + self.auras, + ) + .ok_or(GameError::PlayerNotAssignedNumber(ident.to_string())) } } diff --git a/werewolves-proto/src/game/story.rs b/werewolves-proto/src/game/story.rs index de210d5..ee6bd7b 100644 --- a/werewolves-proto/src/game/story.rs +++ b/werewolves-proto/src/game/story.rs @@ -199,11 +199,23 @@ pub enum StoryActionPrompt { Insomniac { character_id: CharacterId, }, + Bloodletter { + character_id: CharacterId, + chosen: CharacterId, + }, } impl StoryActionPrompt { pub fn new(prompt: ActionPrompt) -> Option { Some(match prompt { + ActionPrompt::Bloodletter { + character_id, + marked: Some(marked), + .. + } => Self::Bloodletter { + character_id: character_id.character_id, + chosen: marked, + }, ActionPrompt::Seer { character_id, marked: Some(marked), @@ -378,7 +390,8 @@ impl StoryActionPrompt { character_id: character_id.character_id, }, - ActionPrompt::Protector { .. } + ActionPrompt::Bloodletter { .. } + | ActionPrompt::Protector { .. } | ActionPrompt::Gravedigger { .. } | ActionPrompt::Hunter { .. } | ActionPrompt::Militia { .. } diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs index fd9efc4..6427e0c 100644 --- a/werewolves-proto/src/game/village.rs +++ b/werewolves-proto/src/game/village.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; use super::Result; use crate::{ + aura::Aura, character::{Character, CharacterId}, diedto::DiedTo, error::GameError, @@ -294,6 +295,7 @@ impl Village { impl RoleTitle { pub fn title_to_role_excl_apprentice(self) -> Role { match self { + RoleTitle::Bloodletter => Role::Bloodletter, RoleTitle::Insomniac => Role::Insomniac, RoleTitle::LoneWolf => Role::LoneWolf, RoleTitle::Villager => Role::Villager, diff --git a/werewolves-proto/src/game/village/apply.rs b/werewolves-proto/src/game/village/apply.rs index 71024e7..3fe804b 100644 --- a/werewolves-proto/src/game/village/apply.rs +++ b/werewolves-proto/src/game/village/apply.rs @@ -15,6 +15,7 @@ use core::num::NonZeroU8; use crate::{ + aura::Aura, diedto::DiedTo, error::GameError, game::{ @@ -52,6 +53,10 @@ impl Village { let mut new_village = self.clone(); for change in all_changes { match change { + NightChange::ApplyAura { target, aura, .. } => { + let target = new_village.character_by_id_mut(*target)?; + target.apply_aura(*aura); + } NightChange::ElderReveal { elder } => { new_village.character_by_id_mut(*elder)?.elder_reveal() } @@ -125,7 +130,6 @@ impl Village { .replace(*target); } - NightChange::Protection { .. } => {} NightChange::MasonRecruit { mason_leader, recruiting, @@ -150,6 +154,12 @@ impl Village { .role_change(RoleTitle::Villager, GameTime::Night { number: night })?; *new_village.character_by_id_mut(*empath)?.empath_mut()? = true; } + NightChange::LostAura { character, aura } => { + new_village + .character_by_id_mut(*character)? + .remove_aura(*aura); + } + NightChange::Protection { .. } => {} } } // black knights death diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index 44d3980..5c798e8 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -84,9 +84,13 @@ pub trait ActionPromptTitleExt { fn power_seer(&self); fn mortician(&self); fn elder_reveal(&self); + fn bloodletter(&self); } impl ActionPromptTitleExt for ActionPromptTitle { + fn bloodletter(&self) { + assert_eq!(*self, ActionPromptTitle::Bloodletter); + } fn elder_reveal(&self) { assert_eq!(*self, ActionPromptTitle::ElderReveal); } @@ -402,7 +406,11 @@ impl GameExt for Game { | ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"), ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"), - ActionPrompt::LoneWolfKill { + ActionPrompt::Bloodletter { + marked: Some(marked), + .. + } + | ActionPrompt::LoneWolfKill { marked: Some(marked), .. } @@ -478,7 +486,8 @@ impl GameExt for Game { marked: Some(marked), .. } => assert_eq!(marked, mark, "marked character"), - ActionPrompt::Seer { marked: None, .. } + ActionPrompt::Bloodletter { marked: None, .. } + | ActionPrompt::Seer { marked: None, .. } | ActionPrompt::Adjudicator { marked: None, .. } | ActionPrompt::PowerSeer { marked: None, .. } | ActionPrompt::Mortician { marked: None, .. } diff --git a/werewolves-proto/src/game_test/role/bloodletter.rs b/werewolves-proto/src/game_test/role/bloodletter.rs new file mode 100644 index 0000000..28c5e38 --- /dev/null +++ b/werewolves-proto/src/game_test/role/bloodletter.rs @@ -0,0 +1,132 @@ +// 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::{ + aura::Aura, + game::{Game, GameSettings, SetupRole}, + game_test::{ + ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log, + }, + message::night::ActionPromptTitle, + role::{Alignment, Killer, Powerful}, +}; + +#[test] +fn lasts_2_nights() { + 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 seer = player_ids.next().unwrap(); + let adjudicator = player_ids.next().unwrap(); + let power_seer = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let bloodletter = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Villager, target); + settings.add_and_assign(SetupRole::Seer, seer); + settings.add_and_assign(SetupRole::Adjudicator, adjudicator); + settings.add_and_assign(SetupRole::PowerSeer, power_seer); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.add_and_assign(SetupRole::Bloodletter, bloodletter); + settings.fill_remaining_slots_with_villagers(9); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().r#continue(); + + game.next().title().bloodletter(); + game.mark(game.character_by_player_id(target).character_id()); + game.r#continue().sleep(); + + game.next().title().seer(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().seer(), Alignment::Wolves); + game.r#continue().sleep(); + + game.next().title().adjudicator(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::Killer); + game.r#continue().sleep(); + + game.next().title().power_seer(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().power_seer(), Powerful::Powerful); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(target).auras(), + &[Aura::Bloodlet { night: 0 }] + ); + + game.execute().title().wolf_pack_kill(); + game.mark(game.living_villager_excl(target).character_id()); + game.r#continue().r#continue(); + + game.next().title().bloodletter(); + game.mark(game.character_by_player_id(seer).character_id()); + game.r#continue().sleep(); + + game.next().title().seer(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().seer(), Alignment::Wolves); + game.r#continue().sleep(); + + game.next().title().adjudicator(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::Killer); + game.r#continue().sleep(); + + game.next().title().power_seer(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().power_seer(), Powerful::Powerful); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(target).auras(), + &[Aura::Bloodlet { night: 0 }] + ); + + game.mark_for_execution(game.character_by_player_id(bloodletter).character_id()); + game.execute().title().wolf_pack_kill(); + game.mark(game.living_villager_excl(target).character_id()); + game.r#continue().sleep(); + + game.next().title().seer(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().seer(), Alignment::Village); + game.r#continue().sleep(); + + game.next().title().adjudicator(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::NotKiller); + game.r#continue().sleep(); + + game.next().title().power_seer(); + game.mark(game.character_by_player_id(target).character_id()); + assert_eq!(game.r#continue().power_seer(), Powerful::NotPowerful); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!(game.character_by_player_id(target).auras(), &[]); +} diff --git a/werewolves-proto/src/game_test/role/mod.rs b/werewolves-proto/src/game_test/role/mod.rs index 15f65af..3815fef 100644 --- a/werewolves-proto/src/game_test/role/mod.rs +++ b/werewolves-proto/src/game_test/role/mod.rs @@ -15,6 +15,7 @@ mod apprentice; mod beholder; mod black_knight; +mod bloodletter; mod diseased; mod elder; mod empath; diff --git a/werewolves-proto/src/lib.rs b/werewolves-proto/src/lib.rs index 28415f1..7a504b4 100644 --- a/werewolves-proto/src/lib.rs +++ b/werewolves-proto/src/lib.rs @@ -14,6 +14,7 @@ // along with this program. If not, see . #![allow(clippy::new_without_default)] +pub mod aura; pub mod character; pub mod diedto; pub mod error; @@ -21,7 +22,7 @@ pub mod game; #[cfg(test)] mod game_test; pub mod message; -pub mod modifier; pub mod nonzero; pub mod player; pub mod role; +pub mod team; diff --git a/werewolves-proto/src/message/night.rs b/werewolves-proto/src/message/night.rs index 1185c57..9ea2b5b 100644 --- a/werewolves-proto/src/message/night.rs +++ b/werewolves-proto/src/message/night.rs @@ -203,6 +203,12 @@ pub enum ActionPrompt { }, #[checks(ActionType::Insomniac)] Insomniac { character_id: CharacterIdentity }, + #[checks(ActionType::OtherWolf)] + Bloodletter { + character_id: CharacterIdentity, + living_players: Box<[CharacterIdentity]>, + marked: Option, + }, } impl ActionPrompt { @@ -230,6 +236,7 @@ impl ActionPrompt { | ActionPrompt::Empath { character_id, .. } | ActionPrompt::Vindicator { character_id, .. } | ActionPrompt::PyreMaster { character_id, .. } + | ActionPrompt::Bloodletter { character_id, .. } | ActionPrompt::DireWolf { character_id, .. } => Some(character_id.character_id), ActionPrompt::WolvesIntro { .. } @@ -241,6 +248,7 @@ impl ActionPrompt { pub(crate) fn matches_beholding(&self, target: CharacterId) -> bool { match self { ActionPrompt::Insomniac { character_id, .. } + | ActionPrompt::Bloodletter { character_id, .. } | ActionPrompt::Seer { character_id, .. } | ActionPrompt::Arcanist { character_id, .. } | ActionPrompt::Gravedigger { character_id, .. } @@ -344,7 +352,12 @@ impl ActionPrompt { Ok(prompt) } - ActionPrompt::LoneWolfKill { + ActionPrompt::Bloodletter { + living_players: targets, + marked, + .. + } + | ActionPrompt::LoneWolfKill { living_players: targets, marked, .. diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs index 4d576dd..90c1478 100644 --- a/werewolves-proto/src/role.rs +++ b/werewolves-proto/src/role.rs @@ -271,6 +271,11 @@ pub enum Role { #[checks(Powerful::Powerful)] #[checks("wolf")] LoneWolf, + #[checks(Alignment::Wolves)] + #[checks(Killer::Killer)] + #[checks(Powerful::Powerful)] + #[checks("wolf")] + Bloodletter, } impl Role { @@ -313,6 +318,7 @@ impl Role { Role::Werewolf => KillingWolfOrder::Werewolf, Role::AlphaWolf { .. } => KillingWolfOrder::AlphaWolf, + Role::Bloodletter { .. } => KillingWolfOrder::Bloodletter, Role::DireWolf { .. } => KillingWolfOrder::DireWolf, Role::Shapeshifter { .. } => KillingWolfOrder::Shapeshifter, Role::LoneWolf => KillingWolfOrder::LoneWolf, @@ -325,7 +331,7 @@ impl Role { | Role::Adjudicator | Role::DireWolf { .. } | Role::Arcanist - | Role::Seer => true, + | Role::Seer | Role::Bloodletter => true, Role::Insomniac // has to at least get one good night of sleep, right? | Role::Beholder @@ -400,6 +406,7 @@ impl Role { | Role::Militia { targeted: None } | Role::MapleWolf { .. } | Role::Guardian { .. } + | Role::Bloodletter | Role::Seer => true, Role::Apprentice(title) => village @@ -444,6 +451,7 @@ impl RoleTitle { pub enum Alignment { Village, Wolves, + Traitor, } impl Alignment { @@ -461,6 +469,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"), } } } diff --git a/werewolves-proto/src/modifier.rs b/werewolves-proto/src/team.rs similarity index 80% rename from werewolves-proto/src/modifier.rs rename to werewolves-proto/src/team.rs index ad2aae9..84f12df 100644 --- a/werewolves-proto/src/modifier.rs +++ b/werewolves-proto/src/team.rs @@ -1,3 +1,6 @@ +use serde::{Deserialize, Serialize}; +use werewolves_macros::ChecksAs; + // Copyright (C) 2025 Emilis Bliūdžius // // This program is free software: you can redistribute it and/or modify @@ -12,10 +15,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum Modifier { - Drunk, - Insane, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ChecksAs)] +pub enum Team { + Village, + #[checks("evil")] + Wolves, + AnyEvil, } diff --git a/werewolves/img/bloodlet.svg b/werewolves/img/bloodlet.svg new file mode 100644 index 0000000..d6bf58d --- /dev/null +++ b/werewolves/img/bloodlet.svg @@ -0,0 +1,29 @@ + + + + diff --git a/werewolves/img/traitor.svg b/werewolves/img/traitor.svg new file mode 100644 index 0000000..8cd96cf --- /dev/null +++ b/werewolves/img/traitor.svg @@ -0,0 +1,22 @@ + + + + diff --git a/werewolves/img/wolf.svg b/werewolves/img/wolf.svg index 645761d..47c2547 100644 --- a/werewolves/img/wolf.svg +++ b/werewolves/img/wolf.svg @@ -2,19 +2,19 @@ panic!("expected a prompt with a mark"), ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"), - ActionPrompt::LoneWolfKill { + ActionPrompt::Bloodletter { + marked: Some(marked), + .. + } + | ActionPrompt::LoneWolfKill { marked: Some(marked), .. } @@ -907,7 +911,8 @@ impl GameExt for Game { marked: Some(marked), .. } => assert_eq!(marked, mark, "marked character"), - ActionPrompt::Seer { marked: None, .. } + ActionPrompt::Bloodletter { marked: None, .. } + | ActionPrompt::Seer { marked: None, .. } | ActionPrompt::Adjudicator { marked: None, .. } | ActionPrompt::PowerSeer { marked: None, .. } | ActionPrompt::Mortician { marked: None, .. } diff --git a/werewolves/src/components/action/prompt.rs b/werewolves/src/components/action/prompt.rs index 2e99202..546c4a6 100644 --- a/werewolves/src/components/action/prompt.rs +++ b/werewolves/src/components/action/prompt.rs @@ -188,6 +188,16 @@ pub fn Prompt(props: &ActionPromptProps) -> Html { }; } + ActionPrompt::Bloodletter { + character_id, + living_players, + marked, + } => ( + Some(character_id), + living_players, + marked.iter().cloned().collect::>(), + html! {{"bloodletter"}}, + ), ActionPrompt::LoneWolfKill { character_id, living_players, diff --git a/werewolves/src/components/attributes/align_span.rs b/werewolves/src/components/attributes/align_span.rs index 9995509..fbf3f31 100644 --- a/werewolves/src/components/attributes/align_span.rs +++ b/werewolves/src/components/attributes/align_span.rs @@ -31,6 +31,7 @@ pub fn AlignmentSpan(AlignmentSpanProps { alignment }: &AlignmentSpanProps) -> H let class = match alignment { role::Alignment::Village => "village", role::Alignment::Wolves => "wolves", + role::Alignment::Traitor => "traitor", }; html! { @@ -81,16 +82,18 @@ pub fn CategorySpan( #[derive(Debug, Clone, Copy, PartialEq, Properties)] pub struct RoleTitleSpanProps { pub role: RoleTitle, + #[prop_or(IconType::List)] + pub icon_type: IconType, } #[function_component] -pub fn RoleTitleSpan(RoleTitleSpanProps { role }: &RoleTitleSpanProps) -> Html { +pub fn RoleTitleSpan(RoleTitleSpanProps { role, icon_type }: &RoleTitleSpanProps) -> Html { let class = Into::::into(*role).category().class(); let icon = role.icon().unwrap_or(role.alignment().icon()); let text = role.to_string().to_case(Case::Title); html! { - + {text} } diff --git a/werewolves/src/components/aura.rs b/werewolves/src/components/aura.rs new file mode 100644 index 0000000..0a8ebbb --- /dev/null +++ b/werewolves/src/components/aura.rs @@ -0,0 +1,51 @@ +// Copyright (C) 2025 Emilis Bliūdžius +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +use werewolves_proto::aura; +use yew::prelude::*; + +use crate::components::{Icon, IconType, PartialAssociatedIcon}; + +#[derive(Debug, Clone, PartialEq, Properties)] +pub struct AuraProps { + pub aura: aura::Aura, +} + +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", + }) +} + +#[function_component] +pub fn Aura(AuraProps { aura }: &AuraProps) -> Html { + let class = aura_class(aura); + let icon = aura.icon().map(|icon| { + html! { +
+ +
+ } + }); + + html! { + + {icon} + {aura.to_string()} + + } +} diff --git a/werewolves/src/components/host/setup.rs b/werewolves/src/components/host/setup.rs index 968fdab..933ee31 100644 --- a/werewolves/src/components/host/setup.rs +++ b/werewolves/src/components/host/setup.rs @@ -23,6 +23,8 @@ use werewolves_proto::{ }; use yew::prelude::*; +use crate::components::{AssociatedIcon, Icon, IconSource, IconType}; + #[derive(Debug, Clone, PartialEq, Properties)] pub struct SetupProps { pub settings: GameSettings, @@ -140,10 +142,7 @@ pub fn SetupCategory( }); let killer_inactive = as_role.killer().killer().not().then_some("inactive"); let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive"); - let alignment = match as_role.alignment() { - Alignment::Village => "/img/village.svg", - Alignment::Wolves => "/img/wolf.svg", - }; + let alignment = as_role.alignment().icon(); html! {
{count} @@ -152,13 +151,16 @@ pub fn SetupCategory(
- {"alignment"}/ + // {"alignment"}/ +
- killer icon + + // killer icon
- powerful icon + + // powerful icon
diff --git a/werewolves/src/components/icon.rs b/werewolves/src/components/icon.rs index 73dccca..2286c94 100644 --- a/werewolves/src/components/icon.rs +++ b/werewolves/src/components/icon.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 . use werewolves_proto::{ + aura::Aura, diedto::DiedToTitle, role::{Alignment, Killer, Powerful, RoleTitle}, }; @@ -76,6 +77,8 @@ decl_icon!( NotEqual: "/img/not-equal.svg", Equal: "/img/equal.svg", RedX: "/img/red-x.svg", + Traitor: "/img/traitor.svg", + Bloodlet: "/img/bloodlet.svg", ); impl IconSource { @@ -93,6 +96,8 @@ pub enum IconType { List, Small, RoleAdd, + Fit, + Icon15Pct, Informational, #[default] RoleCheck, @@ -101,6 +106,8 @@ pub enum IconType { impl IconType { pub const fn class(&self) -> &'static str { match self { + IconType::Icon15Pct => "icon-15pct", + IconType::Fit => "icon-fit", IconType::List => "icon-in-list", IconType::Small => "icon", IconType::RoleAdd => "icon-role-add", @@ -147,6 +154,7 @@ impl AssociatedIcon for Alignment { match self { Alignment::Village => IconSource::Village, Alignment::Wolves => IconSource::Wolves, + Alignment::Traitor => IconSource::Traitor, } } } @@ -168,6 +176,7 @@ impl PartialAssociatedIcon for RoleTitle { Some(match self { RoleTitle::AlphaWolf | RoleTitle::DireWolf => return None, + RoleTitle::Bloodletter => IconSource::Bloodlet, RoleTitle::MasonLeader => IconSource::Mason, RoleTitle::BlackKnight => IconSource::BlackKnight, RoleTitle::Insomniac => IconSource::Insomniac, @@ -217,3 +226,14 @@ impl PartialAssociatedIcon for DiedToTitle { } } } + +impl PartialAssociatedIcon for Aura { + fn icon(&self) -> Option { + match self { + Aura::Traitor => Some(IconSource::Traitor), + Aura::Drunk => todo!(), + Aura::Insane => todo!(), + Aura::Bloodlet { .. } => Some(IconSource::Bloodlet), + } + } +} diff --git a/werewolves/src/components/story.rs b/werewolves/src/components/story.rs index c49f30e..f6ba9e0 100644 --- a/werewolves/src/components/story.rs +++ b/werewolves/src/components/story.rs @@ -177,6 +177,25 @@ 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(), + NightChange::ApplyAura { source, target, aura } => characters.get(source).and_then(|source| characters.get(target).map(|target| (source, target))).map(|(source, target)| { + html!{ + <> + + {"gained the"} + + {"aura from"} + + + } + }).unwrap_or_default(), NightChange::RoleChange(character_id, role_title) => characters .get(character_id) .map(|char| { @@ -434,6 +453,7 @@ fn StoryNightChoice(StoryNightChoiceProps { choice, characters }: &StoryNightCho } }), + StoryActionPrompt::Bloodletter { character_id, chosen } => generate(character_id, chosen, "spilt wolf blood on"), StoryActionPrompt::Vindicator { character_id, chosen, diff --git a/werewolves/src/pages/role_page.rs b/werewolves/src/pages/role_page.rs index fb01276..7711fa6 100644 --- a/werewolves/src/pages/role_page.rs +++ b/werewolves/src/pages/role_page.rs @@ -239,6 +239,12 @@ impl RolePage for ActionPrompt { }]), + ActionPrompt::Bloodletter { character_id, .. } => Rc::new([html! { + <> + {ident(character_id)} + + + }]), _ => Rc::new([]), } } diff --git a/werewolves/src/pages/role_page/bloodletter.rs b/werewolves/src/pages/role_page/bloodletter.rs new file mode 100644 index 0000000..ed53863 --- /dev/null +++ b/werewolves/src/pages/role_page/bloodletter.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 yew::prelude::*; + +use crate::components::{Icon, IconSource, IconType}; + +#[function_component] +pub fn BloodletterPage1() -> Html { + html! { +
+

{"BLOODLETTER"}

+
+

{"PICK A PLAYER"}

+

{"THEY WILL BE COVERED IN WOLF BLOOD"}

+

{"AND"}

+

+ {"APPEAR AS A WOLF "} + + {" KILLER "} + + {" AND POWERFUL "} + + {" IN CHECKS"} +

+
+
+ } +} diff --git a/werewolves/src/pages/role_page/seer.rs b/werewolves/src/pages/role_page/seer.rs index f71534c..fcc3be6 100644 --- a/werewolves/src/pages/role_page/seer.rs +++ b/werewolves/src/pages/role_page/seer.rs @@ -46,44 +46,81 @@ pub fn SeerResult(SeerResultProps { alignment }: &SeerResultProps) -> Html { let text = match alignment { Alignment::Village => "VILLAGE", Alignment::Wolves => "WOLFPACK", + Alignment::Traitor => "TRAITOR", + }; + let additional_info = match alignment { + Alignment::Village => html! { + + }, + Alignment::Wolves => html! { + + }, + Alignment::Traitor => html! { +
+

+ {"THIS PERSON IS A "} + {"TRAITOR"} +

+

{"THEY WIN ALONGSIDE EVIL"}

+
+ }, }; - let false_positives = match alignment { - Alignment::Village => RoleTitle::falsely_appear_village(), - Alignment::Wolves => RoleTitle::falsely_appear_wolf(), - } - .into_iter() - .map(|role| { - html! { - - } - }) - .collect::(); html! {

{"SEER"}

-
+

{"YOUR TARGET APPEARS AS"}

-

-

{text}

-
-
- {"ROLES THAT FALSELY APPEAR AS "} - {text} -
-
- {false_positives} -
-
+ {additional_info}
} } + +#[derive(Debug, Clone, PartialEq, Properties)] +struct FalselyAppearsAsProps { + roles: Box<[RoleTitle]>, + alignment_text: &'static str, +} + +#[function_component] +fn FalselyAppearsAs( + FalselyAppearsAsProps { + roles, + alignment_text, + }: &FalselyAppearsAsProps, +) -> Html { + let false_positives = roles + .iter() + .copied() + .map(|role| { + html! { + + } + }) + .collect::(); + html! { +
+
+ {"ROLES THAT FALSELY APPEAR AS "} + {alignment_text} +
+
+ {false_positives} +
+
+ } +}