scapegoat auras

This commit is contained in:
emilis 2025-12-03 00:16:20 +00:00
parent 6f0a7bf9f4
commit 38bd545862
No known key found for this signature in database
16 changed files with 954 additions and 47 deletions

View File

@ -36,6 +36,25 @@ pub enum Aura {
Insane, Insane,
#[checks("cleansible")] #[checks("cleansible")]
Bloodlet { night: u8 }, 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 { impl Display for Aura {
@ -45,6 +64,12 @@ impl Display for Aura {
Aura::Drunk(_) => "Drunk", Aura::Drunk(_) => "Drunk",
Aura::Insane => "Insane", Aura::Insane => "Insane",
Aura::Bloodlet { .. } => "Bloodlet", 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 { impl Aura {
pub const fn expired(&self, village: &Village) -> bool { pub const fn expired(&self, village: &Village) -> bool {
match self { 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 { Aura::Bloodlet {
night: applied_night, night: applied_night,
} => match village.time() { } => match village.time() {
@ -138,8 +171,13 @@ impl Auras {
for aura in self.0.iter() { for aura in self.0.iter() {
match aura { match aura {
Aura::Traitor => return Some(Alignment::Traitor), Aura::Traitor => return Some(Alignment::Traitor),
Aura::Bloodlet { .. } => return Some(Alignment::Wolves), Aura::Notorious
Aura::Drunk(_) | Aura::Insane => {} | Aura::RedeemableScapegoat
| Aura::VindictiveScapegoat
| Aura::SpitefulScapegoat
| Aura::Scapegoat
| Aura::Bloodlet { .. } => return Some(Alignment::Wolves),
Aura::InevitableScapegoat | Aura::Drunk(_) | Aura::Insane => {}
} }
} }
None None
@ -169,6 +207,12 @@ impl AuraTitle {
AuraTitle::Drunk => Aura::Drunk(DrunkBag::default()), AuraTitle::Drunk => Aura::Drunk(DrunkBag::default()),
AuraTitle::Insane => Aura::Insane, AuraTitle::Insane => Aura::Insane,
AuraTitle::Bloodlet => Aura::Bloodlet { night: 0 }, 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,
} }
} }
} }

View File

@ -25,7 +25,7 @@ use crate::{
aura::{Aura, AuraTitle, Auras}, aura::{Aura, AuraTitle, Auras},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{GameTime, Village}, game::{GameTime, Village, night::changes::NightChange},
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt}, message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
player::{PlayerId, RoleChange}, player::{PlayerId, RoleChange},
role::{ role::{
@ -316,6 +316,56 @@ impl Character {
self.auras.remove_aura(aura); self.auras.remove_aura(aura);
} }
pub fn night_action_prompts_from_aura(
&self,
village: &Village,
) -> Result<Option<(ActionPrompt, Vec<NightChange>)>> {
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<Box<[ActionPrompt]>> { pub fn night_action_prompts(&self, village: &Village) -> Result<Box<[ActionPrompt]>> {
let mut prompts = Vec::new(); let mut prompts = Vec::new();
if let Role::MasonLeader { .. } = &self.role { if let Role::MasonLeader { .. } = &self.role {

View File

@ -105,4 +105,6 @@ pub enum GameError {
ShapeshiftingIsForShapeshifters, ShapeshiftingIsForShapeshifters,
#[error("must select a target")] #[error("must select a target")]
MustSelectTarget, MustSelectTarget,
#[error("no current prompt in aura handling")]
NoCurrentPromptForAura,
} }

View File

@ -23,6 +23,7 @@ use serde::{Deserialize, Serialize};
use super::Result; use super::Result;
use crate::{ use crate::{
aura::{Aura, AuraTitle},
character::{Character, CharacterId}, character::{Character, CharacterId},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
@ -37,7 +38,7 @@ use crate::{
enum BlockResolvedOutcome { enum BlockResolvedOutcome {
PromptUpdate(ActionPrompt), PromptUpdate(ActionPrompt),
ActionComplete(ActionResult, Option<NightChange>), ActionComplete(ActionResult, Option<NightChange>, Vec<NightChange>),
} }
enum ResponseOutcome { enum ResponseOutcome {
@ -48,6 +49,8 @@ enum ResponseOutcome {
struct ActionComplete { struct ActionComplete {
pub result: ActionResult, pub result: ActionResult,
pub change: Option<NightChange>, pub change: Option<NightChange>,
/// hacking in to get scapegoat auras in faster for wednesday
pub secondary_changes: Vec<NightChange>,
} }
impl From<ActionComplete> for ResponseOutcome { impl From<ActionComplete> for ResponseOutcome {
@ -193,8 +196,9 @@ impl ActionPrompt {
impl Default for ActionComplete { impl Default for ActionComplete {
fn default() -> Self { fn default() -> Self {
Self { Self {
result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
result: ActionResult::GoBackToSleep,
} }
} }
} }
@ -296,16 +300,24 @@ impl Night {
filter::no_filter filter::no_filter
}; };
let mut action_queue = village let (mut action_queue, changes): (Vec<_>, Vec<_>) = village
.characters() .characters()
.into_iter() .into_iter()
.filter(filter) .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::<Box<[_]>>()
})
})
.collect::<Result<Box<[_]>>>()? .collect::<Result<Box<[_]>>>()?
.into_iter() .into_iter()
.flatten() .flatten()
.chain(village.wolf_pack_kill()) .chain(village.wolf_pack_kill().map(|prompt| (prompt, Vec::new())))
.collect::<Vec<_>>(); .unzip();
let current_changes = changes.into_iter().flatten().collect();
action_queue.sort_by(night_sort_order); action_queue.sort_by(night_sort_order);
let mut action_queue = VecDeque::from({ let mut action_queue = VecDeque::from({
@ -390,8 +402,8 @@ impl Night {
action_queue.push_front(prompt); action_queue.push_front(prompt);
} }
let night_state = NightState::Active { let night_state = NightState::Active {
current_changes,
current_prompt: ActionPrompt::CoverOfDarkness, current_prompt: ActionPrompt::CoverOfDarkness,
current_changes: Vec::new(),
current_result: CurrentResult::None, current_result: CurrentResult::None,
current_page: 0, current_page: 0,
}; };
@ -553,11 +565,106 @@ impl Night {
self.action_queue.iter().cloned().collect() self.action_queue.iter().cloned().collect()
} }
pub fn end_of_night_scapegoat_aura_changes(&self) -> Result<Box<[NightChange]>> {
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::<Box<[_]>>();
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::<Box<[_]>>();
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<Box<[NightChange]>> { pub fn collect_changes(&self) -> Result<Box<[NightChange]>> {
if !matches!(self.night_state, NightState::Complete) { if !matches!(self.night_state, NightState::Complete) {
return Err(GameError::NotEndOfNight); 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]> { pub fn current_changes(&self) -> Box<[NightChange]> {
@ -732,7 +839,7 @@ impl Night {
} }
NightState::Complete => Err(GameError::NightOver), 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())?; self.set_current_result(result.clone().into())?;
if let NightChange::Shapeshift { source, .. } = &change { if let NightChange::Shapeshift { source, .. } = &change {
// needs to be resolved _now_ so that the target can be woken // needs to be resolved _now_ so that the target can be woken
@ -766,9 +873,16 @@ impl Night {
} => current_changes.push(change), } => current_changes.push(change),
NightState::Complete => return Err(GameError::InvalidMessageForGameState), NightState::Complete => return Err(GameError::InvalidMessageForGameState),
} }
for ch in additional {
self.append_change(ch)?;
}
Ok(ServerAction::Result(result)) 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 { match &mut self.night_state {
NightState::Active { NightState::Active {
current_prompt: _, current_prompt: _,
@ -804,11 +918,13 @@ impl Night {
ResponseOutcome::ActionComplete(ActionComplete { ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change, change,
secondary_changes,
}), }),
true, true,
) => ResponseOutcome::ActionComplete(ActionComplete { ) => ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Continue, result: ActionResult::Continue,
change, change,
secondary_changes,
}), }),
(act, _) => act, (act, _) => act,
} }
@ -847,6 +963,7 @@ impl Night {
return Ok(ResponseOutcome::ActionComplete(ActionComplete { return Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Continue, result: ActionResult::Continue,
change: None, change: None,
secondary_changes: vec![],
})); }));
} }
@ -860,23 +977,27 @@ impl Night {
ResponseOutcome::ActionComplete(ActionComplete { ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: Some(NightChange::Shapeshift { source, into }), change: Some(NightChange::Shapeshift { source, into }),
secondary_changes,
}), }),
true, true,
_, _,
) => Ok(ResponseOutcome::ActionComplete(ActionComplete { ) => Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Continue, result: ActionResult::Continue,
change: Some(NightChange::Shapeshift { source, into }), change: Some(NightChange::Shapeshift { source, into }),
secondary_changes,
})), })),
( (
ResponseOutcome::ActionComplete(ActionComplete { ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change, change,
secondary_changes,
}), }),
true, true,
true, true,
) => Ok(ResponseOutcome::ActionComplete(ActionComplete { ) => Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Continue, result: ActionResult::Continue,
change, change,
secondary_changes,
})), })),
(outcome, _, _) => Ok(outcome), (outcome, _, _) => Ok(outcome),
} }
@ -888,7 +1009,14 @@ impl Night {
) -> Result<BlockResolvedOutcome> { ) -> Result<BlockResolvedOutcome> {
match self.received_response_consecutive_same_player_no_sleep(resp)? { match self.received_response_consecutive_same_player_no_sleep(resp)? {
ResponseOutcome::PromptUpdate(update) => Ok(BlockResolvedOutcome::PromptUpdate(update)), 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 match self
.current_prompt() .current_prompt()
.ok_or(GameError::NightOver)? .ok_or(GameError::NightOver)?
@ -907,9 +1035,14 @@ impl Night {
Ok(BlockResolvedOutcome::ActionComplete( Ok(BlockResolvedOutcome::ActionComplete(
ActionResult::RoleBlocked, ActionResult::RoleBlocked,
None, None,
secondary_changes,
)) ))
} else { } else {
Ok(BlockResolvedOutcome::ActionComplete(result, change)) Ok(BlockResolvedOutcome::ActionComplete(
result,
change,
secondary_changes,
))
} }
} }
Some(Unless::TargetsBlocked(unless_blocked1, unless_blocked2)) => { Some(Unless::TargetsBlocked(unless_blocked1, unless_blocked2)) => {
@ -924,12 +1057,21 @@ impl Night {
Ok(BlockResolvedOutcome::ActionComplete( Ok(BlockResolvedOutcome::ActionComplete(
ActionResult::RoleBlocked, ActionResult::RoleBlocked,
None, None,
secondary_changes,
)) ))
} else { } 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 /// resolves whether the target [CharacterId] dies tonight with the current
/// state of the night and returns the [DiedTo] cause of death /// state of the night and returns the [DiedTo] cause of death
fn died_to_tonight(&self, character_id: CharacterId) -> Result<Option<DiedTo>> { fn died_to_tonight(&self, character_id: CharacterId) -> Result<Option<DiedTo>> {
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 ch = self.current_changes();
let mut changes = ChangesLookup::new(&ch); let changes: ChangesLookup<'_> = ChangesLookup::new(&ch);
changes.died_to(character_id, self.night, &self.village) 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 /// returns the matching [Character] with the current night's aura changes

View File

@ -15,7 +15,7 @@
use core::num::NonZeroU8; use core::num::NonZeroU8;
use crate::{ use crate::{
aura::Aura, aura::{Aura, AuraTitle},
bag::DrunkRoll, bag::DrunkRoll,
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
@ -24,7 +24,9 @@ use crate::{
}, },
message::night::{ActionPrompt, ActionResponse, ActionResult}, message::night::{ActionPrompt, ActionResponse, ActionResult},
player::Protection, player::Protection,
role::{AlignmentEq, PreviousGuardianAction, RoleBlock, RoleTitle}, role::{
Alignment, AlignmentEq, Killer, Powerful, PreviousGuardianAction, RoleBlock, RoleTitle,
},
}; };
type Result<T> = core::result::Result<T, GameError>; type Result<T> = core::result::Result<T, GameError>;
@ -79,6 +81,7 @@ impl Night {
}) })
.ok_or(GameError::InvalidTarget)?, .ok_or(GameError::InvalidTarget)?,
}), }),
secondary_changes: vec![],
})), })),
_ => Err(GameError::ShapeshiftingIsForShapeshifters), _ => Err(GameError::ShapeshiftingIsForShapeshifters),
}; };
@ -90,6 +93,7 @@ impl Night {
self.get_visits_for(character_id.character_id), self.get_visits_for(character_id.character_id),
), ),
change: None, change: None,
secondary_changes: vec![],
} }
.into()); .into());
} }
@ -104,6 +108,7 @@ impl Night {
character_id.character_id, character_id.character_id,
*new_role, *new_role,
)), )),
secondary_changes: vec![],
})); }));
} }
} }
@ -114,6 +119,7 @@ impl Night {
ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete { ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::Bloodletter { ActionPrompt::Bloodletter {
@ -127,6 +133,7 @@ impl Night {
aura: Aura::Bloodlet { night: self.night }, aura: Aura::Bloodlet { night: self.night },
target: *marked, target: *marked,
}), }),
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::LoneWolfKill { ActionPrompt::LoneWolfKill {
@ -142,6 +149,7 @@ impl Night {
night: self.night, night: self.night,
}, },
}), }),
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::RoleChange { .. } ActionPrompt::RoleChange { .. }
@ -150,6 +158,7 @@ impl Night {
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
})) }))
} }
ActionPrompt::ElderReveal { character_id } => { ActionPrompt::ElderReveal { character_id } => {
@ -158,6 +167,7 @@ impl Night {
change: Some(NightChange::ElderReveal { change: Some(NightChange::ElderReveal {
elder: character_id.character_id, elder: character_id.character_id,
}), }),
secondary_changes: vec![],
})) }))
} }
ActionPrompt::Seer { ActionPrompt::Seer {
@ -168,6 +178,7 @@ impl Night {
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Seer(alignment), result: ActionResult::Seer(alignment),
change: None, change: None,
secondary_changes: vec![],
})) }))
} }
ActionPrompt::Protector { ActionPrompt::Protector {
@ -182,6 +193,7 @@ impl Night {
source: character_id.character_id, source: character_id.character_id,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::Arcanist { ActionPrompt::Arcanist {
marked: (Some(marked1), Some(marked2)), marked: (Some(marked1), Some(marked2)),
@ -193,6 +205,7 @@ impl Night {
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::Arcanist(AlignmentEq::new(same)), result: ActionResult::Arcanist(AlignmentEq::new(same)),
change: None, change: None,
secondary_changes: vec![],
})) }))
} }
ActionPrompt::Gravedigger { ActionPrompt::Gravedigger {
@ -205,6 +218,7 @@ impl Night {
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
result: ActionResult::GraveDigger(dig_role), result: ActionResult::GraveDigger(dig_role),
change: None, change: None,
secondary_changes: vec![],
})) }))
} }
ActionPrompt::Hunter { ActionPrompt::Hunter {
@ -217,6 +231,7 @@ impl Night {
source: character_id.character_id, source: character_id.character_id,
target: *marked, target: *marked,
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::Militia { ActionPrompt::Militia {
character_id, character_id,
@ -232,6 +247,7 @@ impl Night {
.ok_or(GameError::CannotHappenOnNightZero)?, .ok_or(GameError::CannotHappenOnNightZero)?,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::Militia { marked: None, .. } => { ActionPrompt::Militia { marked: None, .. } => {
Ok(ResponseOutcome::ActionComplete(Default::default())) Ok(ResponseOutcome::ActionComplete(Default::default()))
@ -251,6 +267,7 @@ impl Night {
starves_if_fails: *nights_til_starvation == 0, starves_if_fails: *nights_til_starvation == 0,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::MapleWolf { marked: None, .. } => { ActionPrompt::MapleWolf { marked: None, .. } => {
Ok(ResponseOutcome::ActionComplete(Default::default())) Ok(ResponseOutcome::ActionComplete(Default::default()))
@ -269,6 +286,7 @@ impl Night {
guarding: false, guarding: false,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::Guardian { ActionPrompt::Guardian {
character_id, character_id,
@ -288,6 +306,7 @@ impl Night {
guarding: false, guarding: false,
}, },
}), }),
secondary_changes: vec![],
})) }))
} }
ActionPrompt::Guardian { ActionPrompt::Guardian {
@ -304,6 +323,7 @@ impl Night {
guarding: prev_protect.character_id == *marked, guarding: prev_protect.character_id == *marked,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::WolfPackKill { ActionPrompt::WolfPackKill {
marked: Some(marked), marked: Some(marked),
@ -322,6 +342,7 @@ impl Night {
.ok_or(GameError::CannotHappenOnNightZero)?, .ok_or(GameError::CannotHappenOnNightZero)?,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::Shapeshifter { character_id } => { ActionPrompt::Shapeshifter { character_id } => {
Ok(ResponseOutcome::ActionComplete(ActionComplete { Ok(ResponseOutcome::ActionComplete(ActionComplete {
@ -344,6 +365,7 @@ impl Night {
}), }),
_ => return Err(GameError::ShapeshiftingIsForShapeshifters), _ => return Err(GameError::ShapeshiftingIsForShapeshifters),
}, },
secondary_changes: vec![],
})) }))
} }
ActionPrompt::AlphaWolf { ActionPrompt::AlphaWolf {
@ -360,6 +382,7 @@ impl Night {
.ok_or(GameError::CannotHappenOnNightZero)?, .ok_or(GameError::CannotHappenOnNightZero)?,
}, },
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::AlphaWolf { marked: None, .. } => { ActionPrompt::AlphaWolf { marked: None, .. } => {
Ok(ResponseOutcome::ActionComplete(Default::default())) Ok(ResponseOutcome::ActionComplete(Default::default()))
@ -375,6 +398,7 @@ impl Night {
target: *marked, target: *marked,
block_type: RoleBlock::Direwolf, block_type: RoleBlock::Direwolf,
}), }),
secondary_changes: vec![],
})), })),
ActionPrompt::Adjudicator { ActionPrompt::Adjudicator {
marked: Some(marked), marked: Some(marked),
@ -384,6 +408,7 @@ impl Night {
killer: self.character_with_current_auras(*marked)?.killer(), killer: self.character_with_current_auras(*marked)?.killer(),
}, },
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::PowerSeer { ActionPrompt::PowerSeer {
@ -394,6 +419,7 @@ impl Night {
powerful: self.character_with_current_auras(*marked)?.powerful(), powerful: self.character_with_current_auras(*marked)?.powerful(),
}, },
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::Mortician { ActionPrompt::Mortician {
@ -408,6 +434,7 @@ impl Night {
.title(), .title(),
), ),
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::Beholder { ActionPrompt::Beholder {
@ -426,12 +453,14 @@ impl Night {
result.clone() result.clone()
}, },
change: None, change: None,
secondary_changes: vec![],
} }
.into()) .into())
} else { } else {
Ok(ActionComplete { Ok(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
} }
.into()) .into())
} }
@ -439,6 +468,7 @@ impl Night {
ActionPrompt::MasonsWake { .. } => Ok(ActionComplete { ActionPrompt::MasonsWake { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::MasonLeaderRecruit { ActionPrompt::MasonLeaderRecruit {
@ -451,6 +481,7 @@ impl Night {
mason_leader: character_id.character_id, mason_leader: character_id.character_id,
recruiting: *marked, recruiting: *marked,
}), }),
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::Empath { ActionPrompt::Empath {
@ -459,7 +490,23 @@ impl Night {
.. ..
} => { } => {
let marked = self.village.character_by_id(*marked)?; 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 { Ok(ActionComplete {
result: ActionResult::Empath { scapegoat }, result: ActionResult::Empath { scapegoat },
@ -467,6 +514,21 @@ impl Night {
empath: character_id.character_id, empath: character_id.character_id,
scapegoat: marked.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()) .into())
} }
@ -482,11 +544,13 @@ impl Night {
source: character_id.character_id, source: character_id.character_id,
}, },
}), }),
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::Insomniac { .. } => Ok(ActionComplete { ActionPrompt::Insomniac { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
ActionPrompt::PyreMaster { ActionPrompt::PyreMaster {
@ -502,6 +566,7 @@ impl Night {
night, night,
}, },
}), }),
secondary_changes: vec![],
} }
.into()), .into()),
@ -509,6 +574,7 @@ impl Night {
| ActionPrompt::MasonLeaderRecruit { marked: None, .. } => Ok(ActionComplete { | ActionPrompt::MasonLeaderRecruit { marked: None, .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep, result: ActionResult::GoBackToSleep,
change: None, change: None,
secondary_changes: vec![],
} }
.into()), .into()),
@ -574,7 +640,13 @@ impl Night {
}; };
for aura in char.auras() { for aura in char.auras() {
match aura { 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) => { Aura::Drunk(bag) => {
if bag.peek() == DrunkRoll::Drunk { if bag.peek() == DrunkRoll::Drunk {
act.change = None; act.change = None;
@ -587,6 +659,57 @@ impl Night {
act.result = insane_result; 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)) Ok(ResponseOutcome::ActionComplete(act))

View File

@ -160,7 +160,15 @@ impl SetupRoleTitle {
pub fn can_assign_aura(&self, aura: AuraTitle) -> bool { pub fn can_assign_aura(&self, aura: AuraTitle) -> bool {
if self.into_role().title().wolf() { if self.into_role().title().wolf() {
return match aura { 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), AuraTitle::Drunk => !matches!(self, SetupRoleTitle::Werewolf),
}; };
} }
@ -195,6 +203,22 @@ impl SetupRoleTitle {
) )
} }
AuraTitle::Bloodlet => false, 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 { pub fn into_role(self) -> Role {

View File

@ -15,7 +15,7 @@
use core::{num::NonZeroU8, ops::Not}; use core::{num::NonZeroU8, ops::Not};
use crate::{ use crate::{
aura::Aura, aura::{Aura, AuraTitle},
diedto::DiedTo, diedto::DiedTo,
error::GameError, error::GameError,
game::{ game::{
@ -222,9 +222,15 @@ impl Village {
} }
} }
NightChange::EmpathFoundScapegoat { empath, scapegoat } => { NightChange::EmpathFoundScapegoat { empath, scapegoat } => {
new_village {
.character_by_id_mut(*scapegoat)? let scapegoat = new_village.character_by_id_mut(*scapegoat)?;
.role_change(RoleTitle::Villager, GameTime::Night { number: night })?; if scapegoat.role_title() == RoleTitle::Scapegoat {
scapegoat.role_change(
RoleTitle::Villager,
GameTime::Night { number: night },
)?;
}
}
*new_village *new_village
.character_by_id_mut(*empath)? .character_by_id_mut(*empath)?
.empath_mut()? .empath_mut()?

View File

@ -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 <https://www.gnu.org/licenses/>.
mod scapegoat;

View File

@ -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 <https://www.gnu.org/licenses/>.
#[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();
}

View File

@ -12,6 +12,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
mod aura;
mod changes; mod changes;
mod night_order; mod night_order;
mod previous; mod previous;
@ -38,21 +39,22 @@ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use std::io::Write; use std::io::Write;
pub trait SettingsExt { pub trait SettingsExt {
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;
fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId); fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId) -> SetupSlot;
} }
impl SettingsExt for GameSettings { 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 slot_id = self.new_slot(role.clone().into());
let mut slot = self.get_slot_by_id(slot_id).unwrap().clone(); let mut slot = self.get_slot_by_id(slot_id).unwrap().clone();
slot.role = role; slot.role = role;
modify(&mut slot); modify(&mut slot);
self.update_slot(slot); self.update_slot(slot.clone());
slot
} }
fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId) { fn add_and_assign(&mut self, role: SetupRole, assignee: PlayerId) -> SetupSlot {
self.add_role(role, |slot| slot.assign_to = Some(assignee)); self.add_role(role, |slot| slot.assign_to = Some(assignee))
} }
} }

View File

@ -16,10 +16,11 @@
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{ use crate::{
aura::{Aura, AuraTitle},
game::{Game, GameSettings, OrRandom, SetupRole}, game::{Game, GameSettings, OrRandom, SetupRole},
game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players}, game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players},
message::night::{ActionPromptTitle, ActionResult}, message::night::{ActionPromptTitle, ActionResult},
role::Role, role::{Role, RoleTitle},
}; };
#[test] #[test]
@ -151,3 +152,90 @@ fn takes_on_scapegoats_curse() {
game.next_expect_day(); 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
);
}

View File

@ -1368,6 +1368,10 @@ input {
} }
} }
.scapegoat {
@extend .wolves;
}
.assignments { .assignments {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -1420,6 +1424,11 @@ input {
gap: 5%; gap: 5%;
} }
.defensive {
background-color: color.change($defensive_color, $lightness: 30%);
border: 1px solid color.change($defensive_border, $lightness: 40%);
}
.category { .category {
margin-bottom: 30px; margin-bottom: 30px;
width: 30%; width: 30%;
@ -1444,6 +1453,12 @@ input {
width: 0; width: 0;
height: 0; height: 0;
.scapegoats {
color: rgba(255, 0, 255, 0.7);
font-size: 2em;
position: absolute;
}
} }
.category-list { .category-list {

View File

@ -12,7 +12,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::aura::{self, Aura, AuraTitle}; use werewolves_proto::aura::{Aura, AuraTitle};
use yew::prelude::*; use yew::prelude::*;
use crate::{ use crate::{
@ -23,10 +23,16 @@ use crate::{
impl Class for AuraTitle { impl Class for AuraTitle {
fn class(&self) -> Option<&'static str> { fn class(&self) -> Option<&'static str> {
Some(match self { Some(match self {
aura::AuraTitle::Traitor => "traitor", AuraTitle::Traitor => "traitor",
aura::AuraTitle::Drunk => "drunk", AuraTitle::Drunk => "drunk",
aura::AuraTitle::Insane => "insane", AuraTitle::Insane => "insane",
aura::AuraTitle::Bloodlet => "wolves", 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)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct AuraSpanProps { pub struct AuraSpanProps {
pub aura: aura::AuraTitle, pub aura: AuraTitle,
} }
#[function_component] #[function_component]

View File

@ -16,7 +16,10 @@ use core::ops::Not;
use std::collections::HashMap; use std::collections::HashMap;
use convert_case::{Case, Casing}; 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 yew::prelude::*;
use crate::components::{AssociatedIcon, Icon, IconSource, IconType}; use crate::components::{AssociatedIcon, Icon, IconSource, IconType};
@ -49,7 +52,7 @@ pub fn Setup(SetupProps { settings }: &SetupProps) -> Html {
let categories = categories let categories = categories
.into_iter() .into_iter()
.map(|(cat, members)| { .map(|(cat, mut members)| {
let hide = match cat { let hide = match cat {
Category::Wolves => CategoryMode::ShowExactRoleCount, Category::Wolves => CategoryMode::ShowExactRoleCount,
Category::Villager => CategoryMode::ShowExactRoleCount, Category::Villager => CategoryMode::ShowExactRoleCount,
@ -58,11 +61,29 @@ pub fn Setup(SetupProps { settings }: &SetupProps) -> Html {
| Category::Offensive | Category::Offensive
| Category::StartsAsVillager => CategoryMode::HideAllInfo, | 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! { html! {
<SetupCategory <SetupCategory
category={cat} category={cat}
roles={members.into_boxed_slice()} roles={members.into_boxed_slice()}
mode={hide} mode={hide}
scapegoat_auras={scapegoat_auras}
/> />
} }
}) })
@ -102,6 +123,8 @@ pub struct SetupCategoryProps {
pub roles: Box<[SetupRole]>, pub roles: Box<[SetupRole]>,
#[prop_or_default] #[prop_or_default]
pub mode: CategoryMode, pub mode: CategoryMode,
#[prop_or_default]
pub scapegoat_auras: usize,
} }
#[function_component] #[function_component]
@ -110,6 +133,7 @@ pub fn SetupCategory(
category, category,
roles, roles,
mode, mode,
scapegoat_auras,
}: &SetupCategoryProps, }: &SetupCategoryProps,
) -> Html { ) -> Html {
let roles_count = match mode { let roles_count = match mode {
@ -131,15 +155,37 @@ pub fn SetupCategory(
.map(|(r, count)| { .map(|(r, count)| {
let as_role = r.into_role(); let as_role = r.into_role();
let wakes = as_role.wakes_night_zero().then_some("wakes"); 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! {
<span class="scapegoats">{"?"}</span>
}
} else {
html! {}
};
html! {
<span class="count">{count + *scapegoat_auras}{auras}</span>
}
})
} else {
(matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| { (matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| {
html! { html! {
<span class="count">{count}</span> <span class="count">{count}</span>
} }
}); })
};
// let count =
// (matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| {
// html! {
// <span class="count">{count}</span>
// }
// });
let killer_inactive = as_role.killer().killer().not().then_some("inactive"); let killer_inactive = as_role.killer().killer().not().then_some("inactive");
let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive"); let powerful_inactive = as_role.powerful().powerful().not().then_some("inactive");
let alignment = as_role.alignment().icon(); let alignment = as_role.alignment().icon();
html! { html! {
<div class={classes!("slot")}> <div class={classes!("slot")}>
{count} {count}

View File

@ -237,6 +237,12 @@ impl PartialAssociatedIcon for AuraTitle {
AuraTitle::Drunk => Some(IconSource::Drunk), AuraTitle::Drunk => Some(IconSource::Drunk),
AuraTitle::Insane => Some(IconSource::Insane), AuraTitle::Insane => Some(IconSource::Insane),
AuraTitle::Bloodlet => Some(IconSource::Bloodlet), AuraTitle::Bloodlet => Some(IconSource::Bloodlet),
AuraTitle::Scapegoat
| AuraTitle::RedeemableScapegoat
| AuraTitle::VindictiveScapegoat
| AuraTitle::SpitefulScapegoat
| AuraTitle::InevitableScapegoat
| AuraTitle::Notorious => Some(IconSource::Scapegoat),
} }
} }
} }

View File

@ -533,7 +533,7 @@ fn setup_options_for_slot(
> >
<span class="aura-title"> <span class="aura-title">
{icon} {icon}
<span class="title">{aura.to_string()}</span> <span class="title">{aura.to_string().to_case(Case::Title)}</span>
<div class="icon inactive"/> <div class="icon inactive"/>
</span> </span>
</Button> </Button>