scapegoat auras
This commit is contained in:
parent
6f0a7bf9f4
commit
38bd545862
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -105,4 +105,6 @@ pub enum GameError {
|
|||
ShapeshiftingIsForShapeshifters,
|
||||
#[error("must select a target")]
|
||||
MustSelectTarget,
|
||||
#[error("no current prompt in aura handling")]
|
||||
NoCurrentPromptForAura,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()?
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue