diff --git a/werewolves-proto/src/aura.rs b/werewolves-proto/src/aura.rs index dcd99e2..6732a0b 100644 --- a/werewolves-proto/src/aura.rs +++ b/werewolves-proto/src/aura.rs @@ -36,6 +36,25 @@ pub enum Aura { Insane, #[checks("cleansible")] Bloodlet { night: u8 }, + #[checks("assignable")] + Scapegoat, + /// the first village power role that dies is passed to the scapegoat, + /// but they remain the scapegoat + #[checks("assignable")] + RedeemableScapegoat, + /// upon execution, passes the scapegoat trait onto a random living + /// village-aligned player + #[checks("assignable")] + VindictiveScapegoat, + /// the scapegoat trait is passed to the first player to select them at night + #[checks("assignable")] + SpitefulScapegoat, + /// the first village role to act will create the scapegoat - thus always + /// getting a first result of wolf/killer/powerful if seer/adjudicator/power seer + #[checks("assignable")] + InevitableScapegoat, + #[checks("assignable")] + Notorious, } impl Display for Aura { @@ -45,6 +64,12 @@ impl Display for Aura { Aura::Drunk(_) => "Drunk", Aura::Insane => "Insane", Aura::Bloodlet { .. } => "Bloodlet", + Aura::Scapegoat => "Scapegoat", + Aura::RedeemableScapegoat => "Redeemable Scapegoat", + Aura::VindictiveScapegoat => "Vindictive Scapegoat", + Aura::SpitefulScapegoat => "Spiteful Scapegoat", + Aura::InevitableScapegoat => "Inevitable Scapegoat", + Aura::Notorious => "Notorious", }) } } @@ -52,7 +77,15 @@ impl Display for Aura { impl Aura { pub const fn expired(&self, village: &Village) -> bool { match self { - Aura::Traitor | Aura::Drunk(_) | Aura::Insane => false, + Aura::Scapegoat + | Aura::RedeemableScapegoat + | Aura::VindictiveScapegoat + | Aura::SpitefulScapegoat + | Aura::InevitableScapegoat + | Aura::Notorious + | Aura::Traitor + | Aura::Drunk(_) + | Aura::Insane => false, Aura::Bloodlet { night: applied_night, } => match village.time() { @@ -138,8 +171,13 @@ impl Auras { for aura in self.0.iter() { match aura { Aura::Traitor => return Some(Alignment::Traitor), - Aura::Bloodlet { .. } => return Some(Alignment::Wolves), - Aura::Drunk(_) | Aura::Insane => {} + Aura::Notorious + | Aura::RedeemableScapegoat + | Aura::VindictiveScapegoat + | Aura::SpitefulScapegoat + | Aura::Scapegoat + | Aura::Bloodlet { .. } => return Some(Alignment::Wolves), + Aura::InevitableScapegoat | Aura::Drunk(_) | Aura::Insane => {} } } None @@ -169,6 +207,12 @@ impl AuraTitle { AuraTitle::Drunk => Aura::Drunk(DrunkBag::default()), AuraTitle::Insane => Aura::Insane, AuraTitle::Bloodlet => Aura::Bloodlet { night: 0 }, + AuraTitle::Scapegoat => Aura::Scapegoat, + AuraTitle::RedeemableScapegoat => Aura::RedeemableScapegoat, + AuraTitle::VindictiveScapegoat => Aura::VindictiveScapegoat, + AuraTitle::SpitefulScapegoat => Aura::SpitefulScapegoat, + AuraTitle::InevitableScapegoat => Aura::InevitableScapegoat, + AuraTitle::Notorious => Aura::Notorious, } } } diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs index e434fcd..c2ff442 100644 --- a/werewolves-proto/src/character.rs +++ b/werewolves-proto/src/character.rs @@ -25,7 +25,7 @@ use crate::{ aura::{Aura, AuraTitle, Auras}, diedto::DiedTo, error::GameError, - game::{GameTime, Village}, + game::{GameTime, Village, night::changes::NightChange}, message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt}, player::{PlayerId, RoleChange}, role::{ @@ -316,6 +316,56 @@ impl Character { self.auras.remove_aura(aura); } + pub fn night_action_prompts_from_aura( + &self, + village: &Village, + ) -> Result)>> { + if let Some(day) = NonZeroU8::new(match village.time() { + GameTime::Day { number: _ } => return Err(GameError::NotNight), + GameTime::Night { number } => number, + }) && let Some(aura) = self + .auras + .list() + .iter() + .find(|a| matches!(a, Aura::RedeemableScapegoat)) + .cloned() + && let Some(redeem_from) = village.characters().into_iter().find(|c| { + c.died_to() + .map(|died| match died.date_time() { + GameTime::Day { number } => number.get().saturating_add(1) == day.get(), + GameTime::Night { number } => number.saturating_add(1) == day.get(), + }) + .unwrap_or_default() + && c.scapegoat_can_redeem_into() + }) + { + log::info!( + "scapegoat {} is redeeming into {}", + self.identity(), + redeem_from.role_title() + ); + + return Ok(Some(( + ActionPrompt::RoleChange { + character_id: self.identity(), + new_role: redeem_from.role_title(), + }, + vec![ + NightChange::LostAura { + aura, + character: self.character_id(), + }, + NightChange::ApplyAura { + source: self.character_id(), + target: self.character_id(), + aura: Aura::Scapegoat, + }, + ], + ))); + } + Ok(None) + } + pub fn night_action_prompts(&self, village: &Village) -> Result> { let mut prompts = Vec::new(); if let Role::MasonLeader { .. } = &self.role { diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs index 4fe3983..9bd4d35 100644 --- a/werewolves-proto/src/error.rs +++ b/werewolves-proto/src/error.rs @@ -105,4 +105,6 @@ pub enum GameError { ShapeshiftingIsForShapeshifters, #[error("must select a target")] MustSelectTarget, + #[error("no current prompt in aura handling")] + NoCurrentPromptForAura, } diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index 6da4299..35c9ff3 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize}; use super::Result; use crate::{ + aura::{Aura, AuraTitle}, character::{Character, CharacterId}, diedto::DiedTo, error::GameError, @@ -37,7 +38,7 @@ use crate::{ enum BlockResolvedOutcome { PromptUpdate(ActionPrompt), - ActionComplete(ActionResult, Option), + ActionComplete(ActionResult, Option, Vec), } enum ResponseOutcome { @@ -48,6 +49,8 @@ enum ResponseOutcome { struct ActionComplete { pub result: ActionResult, pub change: Option, + /// hacking in to get scapegoat auras in faster for wednesday + pub secondary_changes: Vec, } impl From for ResponseOutcome { @@ -193,8 +196,9 @@ impl ActionPrompt { impl Default for ActionComplete { fn default() -> Self { Self { - result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], + result: ActionResult::GoBackToSleep, } } } @@ -296,16 +300,24 @@ impl Night { filter::no_filter }; - let mut action_queue = village + let (mut action_queue, changes): (Vec<_>, Vec<_>) = village .characters() .into_iter() .filter(filter) - .map(|c| c.night_action_prompts(&village)) + .map(|c| { + c.night_action_prompts(&village).map(|acts| { + acts.into_iter() + .map(|prompt| (prompt, Vec::new())) + .chain(c.night_action_prompts_from_aura(&village).ok().flatten()) + .collect::>() + }) + }) .collect::>>()? .into_iter() .flatten() - .chain(village.wolf_pack_kill()) - .collect::>(); + .chain(village.wolf_pack_kill().map(|prompt| (prompt, Vec::new()))) + .unzip(); + let current_changes = changes.into_iter().flatten().collect(); action_queue.sort_by(night_sort_order); let mut action_queue = VecDeque::from({ @@ -390,8 +402,8 @@ impl Night { action_queue.push_front(prompt); } let night_state = NightState::Active { + current_changes, current_prompt: ActionPrompt::CoverOfDarkness, - current_changes: Vec::new(), current_result: CurrentResult::None, current_page: 0, }; @@ -553,11 +565,106 @@ impl Night { self.action_queue.iter().cloned().collect() } + pub fn end_of_night_scapegoat_aura_changes(&self) -> Result> { + let mut changes = Vec::new(); + let source_target = self + .used_actions + .iter() + .filter_map(|(prompt, _, _)| { + prompt + .character_id() + .and_then(|cid| prompt.marked().map(|marked| (cid, marked.0))) + }) + .collect::>(); + for char in self.village.characters() { + if let Some(aura) = char + .auras() + .iter() + .find(|a| matches!(a.title(), AuraTitle::SpitefulScapegoat)) + && let Some(picked_by) = source_target.iter().find_map(|(source, target)| { + (*target == char.character_id()).then_some(*source) + }) + { + changes.push(NightChange::LostAura { + character: char.character_id(), + aura: aura.clone(), + }); + changes.push(NightChange::ApplyAura { + source: char.character_id(), + target: picked_by, + aura: Aura::Scapegoat, + }); + } + if let Some(DiedTo::Execution { day }) = char.died_to() + && let GameTime::Night { number: night } = self.village.time() + && day.get() == night + && let Some(aura) = char + .auras() + .iter() + .find(|a| matches!(a.title(), AuraTitle::VindictiveScapegoat)) + { + changes.push(NightChange::LostAura { + character: char.character_id(), + aura: aura.clone(), + }); + let chars = self + .village + .characters() + .into_iter() + .filter(|c| { + c.character_id() != char.character_id() + && c.alive() + && c.is_village() + && self // didn't die tonight + .died_to_tonight(c.character_id()) + .ok() + .map(|c| c.is_none()) + .unwrap_or_default() + }) + .collect::>(); + if !chars.is_empty() { + let new_scapegoat = chars[rand::random_range(0..chars.len())].character_id(); + changes.push(NightChange::ApplyAura { + source: char.character_id(), + target: new_scapegoat, + aura: Aura::Scapegoat, + }); + } + } + if let Some(aura) = char + .auras() + .iter() + .find(|a| matches!(a.title(), AuraTitle::RedeemableScapegoat)) + .cloned() + && changes.iter().any(|c| match c { + NightChange::RoleChange(cid, _) => char.character_id() == *cid, + _ => false, + }) + { + // omg they changed. change the aura to scapegoat + changes.push(NightChange::LostAura { + aura, + character: char.character_id(), + }); + changes.push(NightChange::ApplyAura { + source: char.character_id(), + target: char.character_id(), + aura: Aura::Scapegoat, + }); + } + } + Ok(changes.into_boxed_slice()) + } + pub fn collect_changes(&self) -> Result> { if !matches!(self.night_state, NightState::Complete) { return Err(GameError::NotEndOfNight); } - Ok(self.current_changes()) + Ok(self + .current_changes() + .into_iter() + .chain(self.end_of_night_scapegoat_aura_changes()?) + .collect()) } pub fn current_changes(&self) -> Box<[NightChange]> { @@ -732,7 +839,7 @@ impl Night { } NightState::Complete => Err(GameError::NightOver), }, - BlockResolvedOutcome::ActionComplete(mut result, Some(change)) => { + BlockResolvedOutcome::ActionComplete(mut result, Some(change), additional) => { 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 @@ -766,9 +873,16 @@ impl Night { } => current_changes.push(change), NightState::Complete => return Err(GameError::InvalidMessageForGameState), } + + for ch in additional { + self.append_change(ch)?; + } Ok(ServerAction::Result(result)) } - BlockResolvedOutcome::ActionComplete(result, None) => { + BlockResolvedOutcome::ActionComplete(result, None, additional) => { + for ch in additional { + self.append_change(ch)?; + } match &mut self.night_state { NightState::Active { current_prompt: _, @@ -804,11 +918,13 @@ impl Night { ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GoBackToSleep, change, + secondary_changes, }), true, ) => ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Continue, change, + secondary_changes, }), (act, _) => act, } @@ -847,6 +963,7 @@ impl Night { return Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Continue, change: None, + secondary_changes: vec![], })); } @@ -860,23 +977,27 @@ impl Night { ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GoBackToSleep, change: Some(NightChange::Shapeshift { source, into }), + secondary_changes, }), true, _, ) => Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Continue, change: Some(NightChange::Shapeshift { source, into }), + secondary_changes, })), ( ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GoBackToSleep, change, + secondary_changes, }), true, true, ) => Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Continue, change, + secondary_changes, })), (outcome, _, _) => Ok(outcome), } @@ -888,7 +1009,14 @@ impl Night { ) -> Result { match self.received_response_consecutive_same_player_no_sleep(resp)? { ResponseOutcome::PromptUpdate(update) => Ok(BlockResolvedOutcome::PromptUpdate(update)), - ResponseOutcome::ActionComplete(ActionComplete { result, change }) => { + ResponseOutcome::ActionComplete(ActionComplete { + result, + change, + secondary_changes, + }) => { + if !secondary_changes.is_empty() { + log::info!("secondary changes: {secondary_changes:?}"); + } match self .current_prompt() .ok_or(GameError::NightOver)? @@ -907,9 +1035,14 @@ impl Night { Ok(BlockResolvedOutcome::ActionComplete( ActionResult::RoleBlocked, None, + secondary_changes, )) } else { - Ok(BlockResolvedOutcome::ActionComplete(result, change)) + Ok(BlockResolvedOutcome::ActionComplete( + result, + change, + secondary_changes, + )) } } Some(Unless::TargetsBlocked(unless_blocked1, unless_blocked2)) => { @@ -924,12 +1057,21 @@ impl Night { Ok(BlockResolvedOutcome::ActionComplete( ActionResult::RoleBlocked, None, + secondary_changes, )) } else { - Ok(BlockResolvedOutcome::ActionComplete(result, change)) + Ok(BlockResolvedOutcome::ActionComplete( + result, + change, + secondary_changes, + )) } } - None => Ok(BlockResolvedOutcome::ActionComplete(result, change)), + None => Ok(BlockResolvedOutcome::ActionComplete( + result, + change, + secondary_changes, + )), } } } @@ -1022,9 +1164,21 @@ impl Night { /// resolves whether the target [CharacterId] dies tonight with the current /// state of the night and returns the [DiedTo] cause of death fn died_to_tonight(&self, character_id: CharacterId) -> Result> { + ChangesLookup::new(&self.current_changes()).died_to(character_id, self.night, &self.village) + } + + fn currently_dying(&self) -> Box<[DiedTo]> { let ch = self.current_changes(); - let mut changes = ChangesLookup::new(&ch); - changes.died_to(character_id, self.night, &self.village) + let changes: ChangesLookup<'_> = ChangesLookup::new(&ch); + ch.iter() + .filter_map(|c| match c { + NightChange::Kill { target, .. } => changes + .died_to(*target, self.night, &self.village) + .ok() + .flatten(), + _ => None, + }) + .collect() } /// returns the matching [Character] with the current night's aura changes diff --git a/werewolves-proto/src/game/night/process.rs b/werewolves-proto/src/game/night/process.rs index 652f10d..69fd795 100644 --- a/werewolves-proto/src/game/night/process.rs +++ b/werewolves-proto/src/game/night/process.rs @@ -15,7 +15,7 @@ use core::num::NonZeroU8; use crate::{ - aura::Aura, + aura::{Aura, AuraTitle}, bag::DrunkRoll, diedto::DiedTo, error::GameError, @@ -24,7 +24,9 @@ use crate::{ }, message::night::{ActionPrompt, ActionResponse, ActionResult}, player::Protection, - role::{AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle}, + role::{ + Alignment, AlignmentEq, Killer, Powerful, PreviousGuardianAction, RoleBlock, RoleTitle, + }, }; type Result = core::result::Result; @@ -79,6 +81,7 @@ impl Night { }) .ok_or(GameError::InvalidTarget)?, }), + secondary_changes: vec![], })), _ => Err(GameError::ShapeshiftingIsForShapeshifters), }; @@ -90,6 +93,7 @@ impl Night { self.get_visits_for(character_id.character_id), ), change: None, + secondary_changes: vec![], } .into()); } @@ -104,6 +108,7 @@ impl Night { character_id.character_id, *new_role, )), + secondary_changes: vec![], })); } } @@ -114,6 +119,7 @@ impl Night { ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete { result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], } .into()), ActionPrompt::Bloodletter { @@ -127,6 +133,7 @@ impl Night { aura: Aura::Bloodlet { night: self.night }, target: *marked, }), + secondary_changes: vec![], } .into()), ActionPrompt::LoneWolfKill { @@ -142,6 +149,7 @@ impl Night { night: self.night, }, }), + secondary_changes: vec![], } .into()), ActionPrompt::RoleChange { .. } @@ -150,6 +158,7 @@ impl Night { Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], })) } ActionPrompt::ElderReveal { character_id } => { @@ -158,6 +167,7 @@ impl Night { change: Some(NightChange::ElderReveal { elder: character_id.character_id, }), + secondary_changes: vec![], })) } ActionPrompt::Seer { @@ -168,6 +178,7 @@ impl Night { Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Seer(alignment), change: None, + secondary_changes: vec![], })) } ActionPrompt::Protector { @@ -182,6 +193,7 @@ impl Night { source: character_id.character_id, }, }), + secondary_changes: vec![], })), ActionPrompt::Arcanist { marked: (Some(marked1), Some(marked2)), @@ -193,6 +205,7 @@ impl Night { Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::Arcanist(AlignmentEq::new(same)), change: None, + secondary_changes: vec![], })) } ActionPrompt::Gravedigger { @@ -205,6 +218,7 @@ impl Night { Ok(ResponseOutcome::ActionComplete(ActionComplete { result: ActionResult::GraveDigger(dig_role), change: None, + secondary_changes: vec![], })) } ActionPrompt::Hunter { @@ -217,6 +231,7 @@ impl Night { source: character_id.character_id, target: *marked, }), + secondary_changes: vec![], })), ActionPrompt::Militia { character_id, @@ -232,6 +247,7 @@ impl Night { .ok_or(GameError::CannotHappenOnNightZero)?, }, }), + secondary_changes: vec![], })), ActionPrompt::Militia { marked: None, .. } => { Ok(ResponseOutcome::ActionComplete(Default::default())) @@ -251,6 +267,7 @@ impl Night { starves_if_fails: *nights_til_starvation == 0, }, }), + secondary_changes: vec![], })), ActionPrompt::MapleWolf { marked: None, .. } => { Ok(ResponseOutcome::ActionComplete(Default::default())) @@ -269,6 +286,7 @@ impl Night { guarding: false, }, }), + secondary_changes: vec![], })), ActionPrompt::Guardian { character_id, @@ -288,6 +306,7 @@ impl Night { guarding: false, }, }), + secondary_changes: vec![], })) } ActionPrompt::Guardian { @@ -304,6 +323,7 @@ impl Night { guarding: prev_protect.character_id == *marked, }, }), + secondary_changes: vec![], })), ActionPrompt::WolfPackKill { marked: Some(marked), @@ -322,6 +342,7 @@ impl Night { .ok_or(GameError::CannotHappenOnNightZero)?, }, }), + secondary_changes: vec![], })), ActionPrompt::Shapeshifter { character_id } => { Ok(ResponseOutcome::ActionComplete(ActionComplete { @@ -344,6 +365,7 @@ impl Night { }), _ => return Err(GameError::ShapeshiftingIsForShapeshifters), }, + secondary_changes: vec![], })) } ActionPrompt::AlphaWolf { @@ -360,6 +382,7 @@ impl Night { .ok_or(GameError::CannotHappenOnNightZero)?, }, }), + secondary_changes: vec![], })), ActionPrompt::AlphaWolf { marked: None, .. } => { Ok(ResponseOutcome::ActionComplete(Default::default())) @@ -375,6 +398,7 @@ impl Night { target: *marked, block_type: RoleBlock::Direwolf, }), + secondary_changes: vec![], })), ActionPrompt::Adjudicator { marked: Some(marked), @@ -384,6 +408,7 @@ impl Night { killer: self.character_with_current_auras(*marked)?.killer(), }, change: None, + secondary_changes: vec![], } .into()), ActionPrompt::PowerSeer { @@ -394,6 +419,7 @@ impl Night { powerful: self.character_with_current_auras(*marked)?.powerful(), }, change: None, + secondary_changes: vec![], } .into()), ActionPrompt::Mortician { @@ -408,6 +434,7 @@ impl Night { .title(), ), change: None, + secondary_changes: vec![], } .into()), ActionPrompt::Beholder { @@ -426,12 +453,14 @@ impl Night { result.clone() }, change: None, + secondary_changes: vec![], } .into()) } else { Ok(ActionComplete { result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], } .into()) } @@ -439,6 +468,7 @@ impl Night { ActionPrompt::MasonsWake { .. } => Ok(ActionComplete { result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], } .into()), ActionPrompt::MasonLeaderRecruit { @@ -451,6 +481,7 @@ impl Night { mason_leader: character_id.character_id, recruiting: *marked, }), + secondary_changes: vec![], } .into()), ActionPrompt::Empath { @@ -459,7 +490,23 @@ impl Night { .. } => { let marked = self.village.character_by_id(*marked)?; - let scapegoat = marked.role_title() == RoleTitle::Scapegoat; + let scapegoat_aura = marked + .auras() + .iter() + .find(|a| { + matches!( + a, + Aura::Scapegoat + | Aura::SpitefulScapegoat + | Aura::InevitableScapegoat + | Aura::RedeemableScapegoat + | Aura::VindictiveScapegoat + ) + }) + .cloned(); + let is_scapegoat_aura = + marked.role_title() != RoleTitle::Scapegoat && scapegoat_aura.is_some(); + let scapegoat = marked.role_title() == RoleTitle::Scapegoat || is_scapegoat_aura; Ok(ActionComplete { result: ActionResult::Empath { scapegoat }, @@ -467,6 +514,21 @@ impl Night { empath: character_id.character_id, scapegoat: marked.character_id(), }), + secondary_changes: if let Some(aura) = scapegoat_aura { + vec![ + NightChange::LostAura { + aura, + character: marked.character_id(), + }, + NightChange::ApplyAura { + source: marked.character_id(), + target: character_id.character_id, + aura: Aura::Scapegoat, + }, + ] + } else { + vec![] + }, } .into()) } @@ -482,11 +544,13 @@ impl Night { source: character_id.character_id, }, }), + secondary_changes: vec![], } .into()), ActionPrompt::Insomniac { .. } => Ok(ActionComplete { result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], } .into()), ActionPrompt::PyreMaster { @@ -502,6 +566,7 @@ impl Night { night, }, }), + secondary_changes: vec![], } .into()), @@ -509,6 +574,7 @@ impl Night { | ActionPrompt::MasonLeaderRecruit { marked: None, .. } => Ok(ActionComplete { result: ActionResult::GoBackToSleep, change: None, + secondary_changes: vec![], } .into()), @@ -574,7 +640,13 @@ impl Night { }; for aura in char.auras() { match aura { - Aura::Traitor | Aura::Bloodlet { .. } => continue, + Aura::RedeemableScapegoat + | Aura::VindictiveScapegoat + | Aura::SpitefulScapegoat + | Aura::Notorious + | Aura::Scapegoat + | Aura::Traitor + | Aura::Bloodlet { .. } => continue, Aura::Drunk(bag) => { if bag.peek() == DrunkRoll::Drunk { act.change = None; @@ -587,6 +659,57 @@ impl Night { act.result = insane_result; } } + Aura::InevitableScapegoat => { + match &act.result { + ActionResult::Adjudicator { .. } => { + act.result = ActionResult::Adjudicator { + killer: Killer::Killer, + }; + } + ActionResult::PowerSeer { .. } => { + act.result = ActionResult::PowerSeer { + powerful: Powerful::Powerful, + } + } + ActionResult::Arcanist(_) => { + let (_, target2) = self + .current_prompt() + .ok_or(GameError::NoCurrentPromptForAura)? + .0 + .marked() + .ok_or(GameError::MustSelectTarget)?; + let target2_align = self + .village + .character_by_id(target2.ok_or(GameError::MustSelectTarget)?)? + .alignment(); + let target1_align = Alignment::Wolves; + act.result = + ActionResult::Arcanist(if target1_align == target2_align { + AlignmentEq::Same + } else { + AlignmentEq::Different + }); + } + ActionResult::Seer(_) => act.result = ActionResult::Seer(Alignment::Wolves), + _ => {} + } + if let Some((marked, _)) = self + .current_prompt() + .ok_or(GameError::NoCurrentPromptForAura)? + .0 + .marked() + { + act.secondary_changes.push(NightChange::ApplyAura { + source: char.character_id(), + target: marked, + aura: Aura::Scapegoat, + }); + act.secondary_changes.push(NightChange::LostAura { + character: char.character_id(), + aura: Aura::InevitableScapegoat, + }); + } + } } } 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 7ab219e..8561de5 100644 --- a/werewolves-proto/src/game/settings/settings_role.rs +++ b/werewolves-proto/src/game/settings/settings_role.rs @@ -160,7 +160,15 @@ 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::Scapegoat + | AuraTitle::RedeemableScapegoat + | AuraTitle::VindictiveScapegoat + | AuraTitle::SpitefulScapegoat + | AuraTitle::InevitableScapegoat + | AuraTitle::Notorious + | AuraTitle::Traitor + | AuraTitle::Bloodlet + | AuraTitle::Insane => false, AuraTitle::Drunk => !matches!(self, SetupRoleTitle::Werewolf), }; } @@ -195,6 +203,22 @@ impl SetupRoleTitle { ) } AuraTitle::Bloodlet => false, + AuraTitle::InevitableScapegoat => { + matches!(self.category(), Category::Intel) + && !matches!( + self, + SetupRoleTitle::Mortician + | SetupRoleTitle::Gravedigger + | SetupRoleTitle::Empath + | SetupRoleTitle::Insomniac + ) + } + AuraTitle::Notorious => { + !matches!(self, SetupRoleTitle::Villager | SetupRoleTitle::Scapegoat) + } + AuraTitle::RedeemableScapegoat => matches!(self, SetupRoleTitle::Villager), + AuraTitle::VindictiveScapegoat | AuraTitle::SpitefulScapegoat => true, + AuraTitle::Scapegoat => matches!(self, SetupRoleTitle::Villager), } } pub fn into_role(self) -> Role { diff --git a/werewolves-proto/src/game/village/apply.rs b/werewolves-proto/src/game/village/apply.rs index 46bab86..a8f0fd4 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, ops::Not}; use crate::{ - aura::Aura, + aura::{Aura, AuraTitle}, diedto::DiedTo, error::GameError, game::{ @@ -222,9 +222,15 @@ impl Village { } } NightChange::EmpathFoundScapegoat { empath, scapegoat } => { - new_village - .character_by_id_mut(*scapegoat)? - .role_change(RoleTitle::Villager, GameTime::Night { number: night })?; + { + let scapegoat = new_village.character_by_id_mut(*scapegoat)?; + if scapegoat.role_title() == RoleTitle::Scapegoat { + scapegoat.role_change( + RoleTitle::Villager, + GameTime::Night { number: night }, + )?; + } + } *new_village .character_by_id_mut(*empath)? .empath_mut()? diff --git a/werewolves-proto/src/game_test/aura/mod.rs b/werewolves-proto/src/game_test/aura/mod.rs new file mode 100644 index 0000000..fb8b6a6 --- /dev/null +++ b/werewolves-proto/src/game_test/aura/mod.rs @@ -0,0 +1,16 @@ +// Copyright (C) 2025 Emilis Bliūdžius +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +mod scapegoat; diff --git a/werewolves-proto/src/game_test/aura/scapegoat.rs b/werewolves-proto/src/game_test/aura/scapegoat.rs new file mode 100644 index 0000000..d34bb54 --- /dev/null +++ b/werewolves-proto/src/game_test/aura/scapegoat.rs @@ -0,0 +1,325 @@ +// 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, AuraTitle}, + game::{Game, GameSettings, SetupRole}, + game_test::{ + ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players, init_log, + }, + message::night::{ActionPrompt, ActionPromptTitle}, + player::RoleChange, + role::{Alignment, Killer, Role, RoleTitle}, +}; + +#[test] +fn redeemable_scapegoat() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let seer = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let scapegoat = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Werewolf, wolf); + settings.add_and_assign(SetupRole::Seer, seer); + let mut scapegoat_slot = settings.add_and_assign(SetupRole::Villager, scapegoat); + scapegoat_slot.auras.push(AuraTitle::RedeemableScapegoat); + settings.update_slot(scapegoat_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + 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().title().seer(); + game.mark(game.character_by_player_id(scapegoat).character_id()); + assert!(game.r#continue().seer().wolves()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(scapegoat).auras(), + &[Aura::RedeemableScapegoat] + ); + + game.mark_for_execution(game.character_by_player_id(seer).character_id()); + game.execute().title().wolf_pack_kill(); + game.mark(game.living_villager_excl(scapegoat).character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + game.execute().title().wolf_pack_kill(); + game.mark(game.living_villager_excl(scapegoat).character_id()); + game.r#continue().sleep(); + + assert_eq!( + game.next(), + ActionPrompt::RoleChange { + character_id: game.character_by_player_id(scapegoat).identity(), + new_role: RoleTitle::Seer + } + ); + game.r#continue().r#continue(); + + game.next().title().seer(); + game.mark(game.character_by_player_id(wolf).character_id()); + assert!(game.r#continue().seer().wolves()); + game.r#continue().sleep(); + + game.next_expect_day(); + + let scapegoat = game.character_by_player_id(scapegoat); + assert_eq!( + scapegoat.role_changes(), + &[RoleChange { + role: Role::Villager, + new_role: RoleTitle::Seer, + changed_on_night: 2, + }] + ); + assert_eq!(scapegoat.auras(), &[Aura::Scapegoat]); + assert_eq!(scapegoat.role(), &Role::Seer); +} + +#[test] +fn inevitable_scapegoat_targeting_black_knight() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let seer = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let black_knight = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Werewolf, wolf); + + settings.add_and_assign(SetupRole::BlackKnight, black_knight); + let mut seer_slot = settings.add_and_assign(SetupRole::Seer, seer); + seer_slot.auras.push(AuraTitle::InevitableScapegoat); + settings.update_slot(seer_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().sleep(); + + assert_eq!( + game.character_by_player_id(seer).auras(), + &[Aura::InevitableScapegoat] + ); + + game.next().title().seer(); + game.mark(game.character_by_player_id(black_knight).character_id()); + assert!(game.r#continue().seer().wolves()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(black_knight).auras(), + &[Aura::Scapegoat] + ); + + assert_eq!(game.character_by_player_id(seer).auras(), &[]); + + game.execute().title().wolf_pack_kill(); + let next_target = game.village().characters().into_iter().last().unwrap(); + game.mark( + game.living_villager_excl(next_target.player_id()) + .character_id(), + ); + game.r#continue().sleep(); + + game.next().title().seer(); + game.mark(next_target.character_id()); + assert!(game.r#continue().seer().village()); + game.r#continue().sleep(); + game.next_expect_day(); + + assert_eq!(game.character_by_player_id(seer).auras(), &[]); + assert_eq!( + game.character_by_player_id(next_target.player_id()).auras(), + &[] + ); +} + +#[test] +fn vindictive_scapegoat() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let scapegoat = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Werewolf, wolf); + let mut scapegoat_slot = settings.add_and_assign(SetupRole::Villager, scapegoat); + scapegoat_slot.auras.push(AuraTitle::VindictiveScapegoat); + settings.update_slot(scapegoat_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + 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.mark_for_execution(game.character_by_player_id(scapegoat).character_id()); + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!(game.character_by_player_id(scapegoat).auras(), &[]); + assert!( + game.village() + .characters() + .into_iter() + .filter(|c| c.alive() && c.is_village()) + .any(|c| c.auras() == [Aura::Scapegoat]) + ); +} + +#[test] +fn spiteful_scapegoat_chosen_by_adjudicator() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let seer = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let adjudicator = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Werewolf, wolf); + + settings.add_and_assign(SetupRole::Adjudicator, adjudicator); + let mut seer_slot = settings.add_and_assign(SetupRole::Seer, seer); + seer_slot.auras.push(AuraTitle::SpitefulScapegoat); + settings.update_slot(seer_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().sleep(); + + assert_eq!( + game.character_by_player_id(seer).auras(), + &[Aura::SpitefulScapegoat] + ); + + game.next().title().seer(); + game.mark(game.character_by_player_id(adjudicator).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(seer).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::NotKiller); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!(game.character_by_player_id(seer).auras(), &[]); + + assert_eq!( + game.character_by_player_id(adjudicator).auras(), + &[Aura::Scapegoat] + ); + + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next().title().seer(); + game.mark(game.character_by_player_id(adjudicator).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(seer).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::NotKiller); + game.r#continue().sleep(); + + game.next_expect_day(); +} + +#[test] +fn inevitable_scapegoat_chosen_by_adjudicator() { + init_log(); + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let seer = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let adjudicator = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Werewolf, wolf); + + settings.add_and_assign(SetupRole::Adjudicator, adjudicator); + let mut seer_slot = settings.add_and_assign(SetupRole::Seer, seer); + seer_slot.auras.push(AuraTitle::InevitableScapegoat); + settings.update_slot(seer_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + let mut game = Game::new(&players, settings).unwrap(); + game.r#continue().r#continue(); + assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro); + game.r#continue().sleep(); + + assert_eq!( + game.character_by_player_id(seer).auras(), + &[Aura::InevitableScapegoat] + ); + + game.next().title().seer(); + game.mark(game.character_by_player_id(adjudicator).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(seer).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::NotKiller); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!(game.character_by_player_id(seer).auras(), &[]); + + assert_eq!( + game.character_by_player_id(adjudicator).auras(), + &[Aura::Scapegoat] + ); + + game.execute().title().wolf_pack_kill(); + game.mark_villager(); + game.r#continue().sleep(); + + game.next().title().seer(); + game.mark(game.character_by_player_id(adjudicator).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(seer).character_id()); + assert_eq!(game.r#continue().adjudicator(), Killer::NotKiller); + game.r#continue().sleep(); + + game.next_expect_day(); +} diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index d163a54..04a9859 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -12,6 +12,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +mod aura; mod changes; mod night_order; mod previous; @@ -38,21 +39,22 @@ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; use std::io::Write; pub trait SettingsExt { - fn add_role(&mut self, role: SetupRole, modify: impl FnOnce(&mut SetupSlot)); - fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId); + fn add_role(&mut self, role: SetupRole, modify: impl FnOnce(&mut SetupSlot)) -> SetupSlot; + fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId) -> SetupSlot; } impl SettingsExt for GameSettings { - fn add_role(&mut self, role: SetupRole, modify: impl FnOnce(&mut SetupSlot)) { + fn add_role(&mut self, role: SetupRole, modify: impl FnOnce(&mut SetupSlot)) -> SetupSlot { let slot_id = self.new_slot(role.clone().into()); let mut slot = self.get_slot_by_id(slot_id).unwrap().clone(); slot.role = role; modify(&mut slot); - self.update_slot(slot); + self.update_slot(slot.clone()); + slot } - fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId) { - self.add_role(role, |slot| slot.assign_to = Some(assignee)); + fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId) -> SetupSlot { + self.add_role(role, |slot| slot.assign_to = Some(assignee)) } } diff --git a/werewolves-proto/src/game_test/role/empath.rs b/werewolves-proto/src/game_test/role/empath.rs index 797965a..fa74c3c 100644 --- a/werewolves-proto/src/game_test/role/empath.rs +++ b/werewolves-proto/src/game_test/role/empath.rs @@ -16,10 +16,11 @@ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; use crate::{ + aura::{Aura, AuraTitle}, game::{Game, GameSettings, OrRandom, SetupRole}, game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players}, message::night::{ActionPromptTitle, ActionResult}, - role::Role, + role::{Role, RoleTitle}, }; #[test] @@ -151,3 +152,90 @@ fn takes_on_scapegoats_curse() { game.next_expect_day(); } + +#[test] +fn takes_on_scapegoat_aura_curse() { + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let empath = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let scapegoat = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Empath, empath); + settings.add_and_assign(SetupRole::Werewolf, wolf); + let mut scapegoat_slot = settings.add_and_assign(SetupRole::Villager, scapegoat); + scapegoat_slot.auras.push(AuraTitle::Scapegoat); + settings.update_slot(scapegoat_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + 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(game.living_villager_excl(scapegoat).character_id()); + game.r#continue().sleep(); + + game.next().title().empath(); + game.mark(game.character_by_player_id(scapegoat).character_id()); + assert_eq!(game.r#continue(), ActionResult::Empath { scapegoat: true }); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(empath).auras(), + &[Aura::Scapegoat] + ); + + assert_eq!(game.character_by_player_id(scapegoat).auras(), &[]); +} + +#[test] +fn takes_on_scapegoat_aura_curse_no_role_change() { + let players = gen_players(1..21); + let mut player_ids = players.iter().map(|p| p.player_id); + let empath = player_ids.next().unwrap(); + let wolf = player_ids.next().unwrap(); + let scapegoat = player_ids.next().unwrap(); + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Empath, empath); + settings.add_and_assign(SetupRole::Werewolf, wolf); + let mut scapegoat_slot = settings.add_and_assign(SetupRole::BlackKnight, scapegoat); + scapegoat_slot.auras.push(AuraTitle::Scapegoat); + settings.update_slot(scapegoat_slot); + + settings.fill_remaining_slots_with_villagers(players.len()); + 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(game.living_villager_excl(scapegoat).character_id()); + game.r#continue().sleep(); + + game.next().title().empath(); + game.mark(game.character_by_player_id(scapegoat).character_id()); + assert_eq!(game.r#continue(), ActionResult::Empath { scapegoat: true }); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(empath).auras(), + &[Aura::Scapegoat] + ); + + assert_eq!(game.character_by_player_id(scapegoat).auras(), &[]); + assert_eq!(game.character_by_player_id(scapegoat).role_changes(), &[]); + assert_eq!( + game.character_by_player_id(scapegoat).role_title(), + RoleTitle::BlackKnight + ); +} diff --git a/werewolves/index.scss b/werewolves/index.scss index f4e9a26..dda6a8b 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -1368,6 +1368,10 @@ input { } } +.scapegoat { + @extend .wolves; +} + .assignments { display: flex; flex-direction: row; @@ -1420,6 +1424,11 @@ input { gap: 5%; } + .defensive { + background-color: color.change($defensive_color, $lightness: 30%); + border: 1px solid color.change($defensive_border, $lightness: 40%); + } + .category { margin-bottom: 30px; width: 30%; @@ -1444,6 +1453,12 @@ input { width: 0; height: 0; + + .scapegoats { + color: rgba(255, 0, 255, 0.7); + font-size: 2em; + position: absolute; + } } .category-list { diff --git a/werewolves/src/components/aura.rs b/werewolves/src/components/aura.rs index 7c305be..983c48f 100644 --- a/werewolves/src/components/aura.rs +++ b/werewolves/src/components/aura.rs @@ -12,7 +12,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use werewolves_proto::aura::{self, Aura, AuraTitle}; +use werewolves_proto::aura::{Aura, AuraTitle}; use yew::prelude::*; use crate::{ @@ -23,10 +23,16 @@ use crate::{ 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", + AuraTitle::Traitor => "traitor", + AuraTitle::Drunk => "drunk", + AuraTitle::Insane => "insane", + AuraTitle::Bloodlet => "wolves", + AuraTitle::RedeemableScapegoat + | AuraTitle::SpitefulScapegoat + | AuraTitle::VindictiveScapegoat + | AuraTitle::Notorious + | AuraTitle::InevitableScapegoat + | AuraTitle::Scapegoat => "scapegoat", }) } } @@ -39,7 +45,7 @@ impl Class for Aura { #[derive(Debug, Clone, PartialEq, Properties)] pub struct AuraSpanProps { - pub aura: aura::AuraTitle, + pub aura: AuraTitle, } #[function_component] diff --git a/werewolves/src/components/host/setup.rs b/werewolves/src/components/host/setup.rs index aecef47..55a44e4 100644 --- a/werewolves/src/components/host/setup.rs +++ b/werewolves/src/components/host/setup.rs @@ -16,7 +16,10 @@ use core::ops::Not; use std::collections::HashMap; use convert_case::{Case, Casing}; -use werewolves_proto::game::{Category, GameSettings, SetupRole, SetupRoleTitle}; +use werewolves_proto::{ + aura::AuraTitle, + game::{Category, GameSettings, SetupRole, SetupRoleTitle}, +}; use yew::prelude::*; use crate::components::{AssociatedIcon, Icon, IconSource, IconType}; @@ -49,7 +52,7 @@ pub fn Setup(SetupProps { settings }: &SetupProps) -> Html { let categories = categories .into_iter() - .map(|(cat, members)| { + .map(|(cat, mut members)| { let hide = match cat { Category::Wolves => CategoryMode::ShowExactRoleCount, Category::Villager => CategoryMode::ShowExactRoleCount, @@ -58,11 +61,29 @@ pub fn Setup(SetupProps { settings }: &SetupProps) -> Html { | Category::Offensive | Category::StartsAsVillager => CategoryMode::HideAllInfo, }; + let scapegoat_auras = settings + .slots() + .iter() + .filter(|s| { + !matches!(s.role, SetupRole::Scapegoat { .. }) + && s.auras.iter().any(|a| { + matches!( + a, + AuraTitle::Scapegoat + | AuraTitle::SpitefulScapegoat + | AuraTitle::InevitableScapegoat + | AuraTitle::RedeemableScapegoat + | AuraTitle::VindictiveScapegoat + ) + }) + }) + .count(); html! { } }) @@ -102,6 +123,8 @@ pub struct SetupCategoryProps { pub roles: Box<[SetupRole]>, #[prop_or_default] pub mode: CategoryMode, + #[prop_or_default] + pub scapegoat_auras: usize, } #[function_component] @@ -110,6 +133,7 @@ pub fn SetupCategory( category, roles, mode, + scapegoat_auras, }: &SetupCategoryProps, ) -> Html { let roles_count = match mode { @@ -131,15 +155,37 @@ pub fn SetupCategory( .map(|(r, count)| { let as_role = r.into_role(); let wakes = as_role.wakes_night_zero().then_some("wakes"); - let count = + let count = if matches!(r, SetupRoleTitle::Scapegoat) { + (matches!(mode, CategoryMode::ShowExactRoleCount) && count + *scapegoat_auras > 0) + .then(|| { + let auras = if *scapegoat_auras != 0 { + html! { + {"?"} + } + } else { + html! {} + }; + html! { + {count + *scapegoat_auras}{auras} + } + }) + } else { (matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| { html! { {count} } - }); + }) + }; + // let count = + // (matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| { + // html! { + // {count} + // } + // }); let killer_inactive = as_role.killer().killer().not().then_some("inactive"); let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive"); let alignment = as_role.alignment().icon(); + html! {
{count} diff --git a/werewolves/src/components/icon.rs b/werewolves/src/components/icon.rs index 065fd6e..5ace27f 100644 --- a/werewolves/src/components/icon.rs +++ b/werewolves/src/components/icon.rs @@ -237,6 +237,12 @@ impl PartialAssociatedIcon for AuraTitle { AuraTitle::Drunk => Some(IconSource::Drunk), AuraTitle::Insane => Some(IconSource::Insane), AuraTitle::Bloodlet => Some(IconSource::Bloodlet), + AuraTitle::Scapegoat + | AuraTitle::RedeemableScapegoat + | AuraTitle::VindictiveScapegoat + | AuraTitle::SpitefulScapegoat + | AuraTitle::InevitableScapegoat + | AuraTitle::Notorious => Some(IconSource::Scapegoat), } } } diff --git a/werewolves/src/components/settings.rs b/werewolves/src/components/settings.rs index dae065d..3f0a7b5 100644 --- a/werewolves/src/components/settings.rs +++ b/werewolves/src/components/settings.rs @@ -533,7 +533,7 @@ fn setup_options_for_slot( > {icon} - {aura.to_string()} + {aura.to_string().to_case(Case::Title)}