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,
#[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,
}
}
}

View File

@ -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<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]>> {
let mut prompts = Vec::new();
if let Role::MasonLeader { .. } = &self.role {

View File

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

View File

@ -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<NightChange>),
ActionComplete(ActionResult, Option<NightChange>, Vec<NightChange>),
}
enum ResponseOutcome {
@ -48,6 +49,8 @@ enum ResponseOutcome {
struct ActionComplete {
pub result: ActionResult,
pub change: Option<NightChange>,
/// hacking in to get scapegoat auras in faster for wednesday
pub secondary_changes: Vec<NightChange>,
}
impl From<ActionComplete> 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::<Box<[_]>>()
})
})
.collect::<Result<Box<[_]>>>()?
.into_iter()
.flatten()
.chain(village.wolf_pack_kill())
.collect::<Vec<_>>();
.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<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]>> {
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<BlockResolvedOutcome> {
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<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 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

View File

@ -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<T> = core::result::Result<T, GameError>;
@ -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))

View File

@ -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 {

View File

@ -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()?

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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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))
}
}

View File

@ -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
);
}

View File

@ -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 {

View File

@ -12,7 +12,7 @@
//
// 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/>.
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]

View File

@ -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! {
<SetupCategory
category={cat}
roles={members.into_boxed_slice()}
mode={hide}
scapegoat_auras={scapegoat_auras}
/>
}
})
@ -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! {
<span class="scapegoats">{"?"}</span>
}
} else {
html! {}
};
html! {
<span class="count">{count + *scapegoat_auras}{auras}</span>
}
})
} else {
(matches!(mode, CategoryMode::ShowExactRoleCount) && count > 0).then(|| {
html! {
<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 powerful_inactive = as_role.powerful().powerful().not().then_some("inactive");
let alignment = as_role.alignment().icon();
html! {
<div class={classes!("slot")}>
{count}

View File

@ -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),
}
}
}

View File

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