lone wolf role + tests

This commit is contained in:
emilis 2025-10-07 02:52:06 +01:00
parent 98493d34be
commit ae71ea4eb0
No known key found for this signature in database
12 changed files with 208 additions and 20 deletions

View File

@ -482,6 +482,11 @@ impl Character {
living_players: village.living_players_excluding(self.character_id()), living_players: village.living_players_excluding(self.character_id()),
marked: None, marked: None,
}, },
Role::LoneWolf => ActionPrompt::LoneWolfKill {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
}])) }]))
} }

View File

@ -57,6 +57,10 @@ pub enum DiedTo {
tried_recruiting: CharacterId, tried_recruiting: CharacterId,
night: u8, night: u8,
}, },
LoneWolf {
killer: CharacterId,
night: u8,
},
} }
impl DiedTo { impl DiedTo {
@ -64,6 +68,7 @@ impl DiedTo {
let mut next = self.clone(); let mut next = self.clone();
match &mut next { match &mut next {
DiedTo::Execution { .. } => return None, DiedTo::Execution { .. } => return None,
DiedTo::MapleWolf { night, .. } DiedTo::MapleWolf { night, .. }
| DiedTo::MapleWolfStarved { night } | DiedTo::MapleWolfStarved { night }
| DiedTo::Militia { night, .. } | DiedTo::Militia { night, .. }
@ -74,7 +79,10 @@ impl DiedTo {
| DiedTo::GuardianProtecting { night, .. } | DiedTo::GuardianProtecting { night, .. }
| DiedTo::PyreMasterLynchMob { night, .. } | DiedTo::PyreMasterLynchMob { night, .. }
| DiedTo::PyreMaster { night, .. } => *night = NonZeroU8::new(night.get() + 1)?, | DiedTo::PyreMaster { night, .. } => *night = NonZeroU8::new(night.get() + 1)?,
DiedTo::MasonLeaderRecruitFail { night, .. } => *night = *night + 1,
DiedTo::LoneWolf { night, .. } | DiedTo::MasonLeaderRecruitFail { night, .. } => {
*night = *night + 1
}
} }
Some(next) Some(next)
@ -101,7 +109,8 @@ impl DiedTo {
.. ..
} }
| DiedTo::PyreMasterLynchMob { source: killer, .. } | DiedTo::PyreMasterLynchMob { source: killer, .. }
| DiedTo::PyreMaster { killer, .. } => Some(*killer), | DiedTo::PyreMaster { killer, .. }
| DiedTo::LoneWolf { killer, .. } => Some(*killer),
} }
} }
pub const fn date_time(&self) -> DateTime { pub const fn date_time(&self) -> DateTime {
@ -132,7 +141,9 @@ impl DiedTo {
| DiedTo::Hunter { killer: _, night } => DateTime::Night { | DiedTo::Hunter { killer: _, night } => DateTime::Night {
number: night.get(), number: night.get(),
}, },
DiedTo::MasonLeaderRecruitFail { night, .. } => DateTime::Night { number: *night }, DiedTo::LoneWolf { night, .. } | DiedTo::MasonLeaderRecruitFail { night, .. } => {
DateTime::Night { number: *night }
}
} }
} }
} }

View File

@ -90,7 +90,11 @@ impl ActionPrompt {
.. ..
} => Some(Unless::TargetsBlocked(*marked1, *marked2)), } => Some(Unless::TargetsBlocked(*marked1, *marked2)),
ActionPrompt::Seer { ActionPrompt::LoneWolfKill {
marked: Some(marked),
..
}
| ActionPrompt::Seer {
marked: Some(marked), marked: Some(marked),
.. ..
} }
@ -163,7 +167,8 @@ impl ActionPrompt {
.. ..
} => Some(Unless::TargetBlocked(*marked)), } => Some(Unless::TargetBlocked(*marked)),
ActionPrompt::Seer { marked: None, .. } ActionPrompt::LoneWolfKill { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. }
| ActionPrompt::Protector { marked: None, .. } | ActionPrompt::Protector { marked: None, .. }
| ActionPrompt::Gravedigger { marked: None, .. } | ActionPrompt::Gravedigger { marked: None, .. }
| ActionPrompt::Hunter { marked: None, .. } | ActionPrompt::Hunter { marked: None, .. }
@ -806,6 +811,21 @@ impl Night {
}; };
match current_prompt { match current_prompt {
ActionPrompt::LoneWolfKill {
character_id,
living_players,
marked: Some(marked),
} => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
target: *marked,
died_to: DiedTo::LoneWolf {
killer: character_id.character_id,
night: self.night,
},
}),
}
.into()),
ActionPrompt::RoleChange { .. } ActionPrompt::RoleChange { .. }
| ActionPrompt::WolvesIntro { .. } | ActionPrompt::WolvesIntro { .. }
| ActionPrompt::CoverOfDarkness => { | ActionPrompt::CoverOfDarkness => {
@ -1166,6 +1186,7 @@ impl Night {
marked: (Some(_), None), marked: (Some(_), None),
.. ..
} }
| ActionPrompt::LoneWolfKill { marked: None, .. }
| ActionPrompt::Gravedigger { marked: None, .. } | ActionPrompt::Gravedigger { marked: None, .. }
| ActionPrompt::Hunter { marked: None, .. } | ActionPrompt::Hunter { marked: None, .. }
| ActionPrompt::Guardian { marked: None, .. } | ActionPrompt::Guardian { marked: None, .. }
@ -1205,7 +1226,8 @@ impl Night {
current_prompt, current_prompt,
current_result: _, current_result: _,
} => match current_prompt { } => match current_prompt {
ActionPrompt::ElderReveal { character_id } ActionPrompt::LoneWolfKill { character_id, .. }
| ActionPrompt::ElderReveal { character_id }
| ActionPrompt::RoleChange { character_id, .. } | ActionPrompt::RoleChange { character_id, .. }
| ActionPrompt::Seer { character_id, .. } | ActionPrompt::Seer { character_id, .. }
| ActionPrompt::Protector { character_id, .. } | ActionPrompt::Protector { character_id, .. }

View File

@ -111,6 +111,8 @@ pub enum SetupRole {
DireWolf, DireWolf,
#[checks(Category::Wolves)] #[checks(Category::Wolves)]
Shapeshifter, Shapeshifter,
#[checks(Category::Wolves)]
LoneWolf,
#[checks(Category::Intel)] #[checks(Category::Intel)]
Adjudicator, Adjudicator,
@ -139,6 +141,7 @@ pub enum SetupRole {
impl SetupRoleTitle { impl SetupRoleTitle {
pub fn into_role(self) -> Role { pub fn into_role(self) -> Role {
match self { match self {
SetupRoleTitle::LoneWolf => Role::LoneWolf,
SetupRoleTitle::Villager => Role::Villager, SetupRoleTitle::Villager => Role::Villager,
SetupRoleTitle::Scapegoat => Role::Scapegoat { redeemed: false }, SetupRoleTitle::Scapegoat => Role::Scapegoat { redeemed: false },
SetupRoleTitle::Seer => Role::Seer, SetupRoleTitle::Seer => Role::Seer,
@ -188,6 +191,7 @@ impl SetupRoleTitle {
impl Display for SetupRole { impl Display for SetupRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self { f.write_str(match self {
SetupRole::LoneWolf => "Lone Wolf",
SetupRole::Villager => "Villager", SetupRole::Villager => "Villager",
SetupRole::Scapegoat { .. } => "Scapegoat", SetupRole::Scapegoat { .. } => "Scapegoat",
SetupRole::Seer => "Seer", SetupRole::Seer => "Seer",
@ -222,6 +226,7 @@ impl Display for SetupRole {
impl SetupRole { impl SetupRole {
pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result<Role, GameError> { pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result<Role, GameError> {
Ok(match self { Ok(match self {
SetupRole::LoneWolf => Role::LoneWolf,
SetupRole::Villager => Role::Villager, SetupRole::Villager => Role::Villager,
SetupRole::Scapegoat { redeemed } => Role::Scapegoat { SetupRole::Scapegoat { redeemed } => Role::Scapegoat {
redeemed: redeemed.into_concrete(), redeemed: redeemed.into_concrete(),
@ -284,6 +289,7 @@ impl SetupRole {
impl From<SetupRole> for RoleTitle { impl From<SetupRole> for RoleTitle {
fn from(value: SetupRole) -> Self { fn from(value: SetupRole) -> Self {
match value { match value {
SetupRole::LoneWolf => RoleTitle::LoneWolf,
SetupRole::Villager => RoleTitle::Villager, SetupRole::Villager => RoleTitle::Villager,
SetupRole::Scapegoat { .. } => RoleTitle::Scapegoat, SetupRole::Scapegoat { .. } => RoleTitle::Scapegoat,
SetupRole::Seer => RoleTitle::Seer, SetupRole::Seer => RoleTitle::Seer,
@ -318,6 +324,7 @@ impl From<SetupRole> for RoleTitle {
impl From<RoleTitle> for SetupRole { impl From<RoleTitle> for SetupRole {
fn from(value: RoleTitle) -> Self { fn from(value: RoleTitle) -> Self {
match value { match value {
RoleTitle::LoneWolf => SetupRole::LoneWolf,
RoleTitle::Villager => SetupRole::Villager, RoleTitle::Villager => SetupRole::Villager,
RoleTitle::Scapegoat => SetupRole::Scapegoat { RoleTitle::Scapegoat => SetupRole::Scapegoat {
redeemed: Default::default(), redeemed: Default::default(),

View File

@ -286,6 +286,7 @@ impl Village {
impl RoleTitle { impl RoleTitle {
pub fn title_to_role_excl_apprentice(self) -> Role { pub fn title_to_role_excl_apprentice(self) -> Role {
match self { match self {
RoleTitle::LoneWolf => Role::LoneWolf,
RoleTitle::Villager => Role::Villager, RoleTitle::Villager => Role::Villager,
RoleTitle::Scapegoat => Role::Scapegoat { RoleTitle::Scapegoat => Role::Scapegoat {
redeemed: rand::random(), redeemed: rand::random(),

View File

@ -4,7 +4,7 @@ mod role;
use crate::{ use crate::{
character::{Character, CharacterId}, character::{Character, CharacterId},
error::GameError, error::GameError,
game::{Game, GameSettings, SetupRole, SetupSlot}, game::{Game, GameOver, GameSettings, SetupRole, SetupSlot},
message::{ message::{
CharacterState, Identification, PublicIdentity, CharacterState, Identification, PublicIdentity,
host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage}, host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage},
@ -62,6 +62,7 @@ pub trait ActionPromptTitleExt {
fn pyremaster(&self); fn pyremaster(&self);
fn empath(&self); fn empath(&self);
fn adjudicator(&self); fn adjudicator(&self);
fn lone_wolf(&self);
} }
impl ActionPromptTitleExt for ActionPromptTitle { impl ActionPromptTitleExt for ActionPromptTitle {
@ -131,6 +132,9 @@ impl ActionPromptTitleExt for ActionPromptTitle {
fn empath(&self) { fn empath(&self) {
assert_eq!(*self, ActionPromptTitle::Empath) assert_eq!(*self, ActionPromptTitle::Empath)
} }
fn lone_wolf(&self) {
assert_eq!(*self, ActionPromptTitle::LoneWolfKill)
}
} }
pub trait ActionResultExt { pub trait ActionResultExt {
@ -227,9 +231,21 @@ pub trait GameExt {
fn living_villager(&self) -> Character; fn living_villager(&self) -> Character;
#[allow(unused)] #[allow(unused)]
fn get_state(&mut self) -> ServerToHostMessage; fn get_state(&mut self) -> ServerToHostMessage;
fn next_expect_game_over(&mut self) -> GameOver;
} }
impl GameExt for Game { impl GameExt for Game {
fn next_expect_game_over(&mut self) -> GameOver {
match self
.process(HostGameMessage::Night(
crate::message::host::HostNightMessage::Next,
))
.unwrap()
{
ServerToHostMessage::GameOver(outcome) => outcome,
resp => panic!("expected game to be over, got: {resp:?}"),
}
}
fn get_state(&mut self) -> ServerToHostMessage { fn get_state(&mut self) -> ServerToHostMessage {
self.process(HostGameMessage::GetState).unwrap() self.process(HostGameMessage::GetState).unwrap()
} }
@ -295,7 +311,12 @@ impl GameExt for Game {
| ActionPrompt::RoleChange { .. } | ActionPrompt::RoleChange { .. }
| ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"), | ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"),
ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"), ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"),
ActionPrompt::Seer {
ActionPrompt::LoneWolfKill {
marked: Some(marked),
..
}
| ActionPrompt::Seer {
marked: Some(marked), marked: Some(marked),
.. ..
} }
@ -384,7 +405,8 @@ impl GameExt for Game {
| ActionPrompt::Guardian { marked: None, .. } | ActionPrompt::Guardian { marked: None, .. }
| ActionPrompt::WolfPackKill { marked: None, .. } | ActionPrompt::WolfPackKill { marked: None, .. }
| ActionPrompt::AlphaWolf { marked: None, .. } | ActionPrompt::AlphaWolf { marked: None, .. }
| ActionPrompt::DireWolf { marked: None, .. } => panic!("no mark"), | ActionPrompt::DireWolf { marked: None, .. }
| ActionPrompt::LoneWolfKill { marked: None, .. } => panic!("no mark"),
} }
} }

View File

@ -0,0 +1,87 @@
#[allow(unused)]
use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{
diedto::DiedTo,
game::{Game, GameSettings, OrRandom, SetupRole},
game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players},
message::night::ActionPromptTitle,
};
#[test]
fn no_kill_on_executing_wolf_aligned_villager() {
let players = gen_players(1..21);
let lone_wolf_player_id = players[0].player_id;
let wolf_player_id = players[1].player_id;
let scapegoat_player_id = players[2].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::LoneWolf, lone_wolf_player_id);
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.add_and_assign(
SetupRole::Scapegoat {
redeemed: OrRandom::Determined(false),
},
scapegoat_player_id,
);
settings.fill_remaining_slots_with_villagers(20);
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_player_id)
.character_id(),
);
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
game.next_expect_day();
}
#[test]
fn gets_a_kill_after_wolf_execution_and_can_kill_a_wolf() {
let players = gen_players(1..21);
let lone_wolf_player_id = players[0].player_id;
let wolf_player_id = players[1].player_id;
let sacrificial_wolf_player_id = players[2].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::LoneWolf, lone_wolf_player_id);
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.add_and_assign(SetupRole::Werewolf, sacrificial_wolf_player_id);
settings.fill_remaining_slots_with_villagers(20);
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(sacrificial_wolf_player_id)
.character_id(),
);
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
game.next().title().lone_wolf();
game.mark(game.character_by_player_id(wolf_player_id).character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(wolf_player_id)
.died_to()
.cloned(),
Some(DiedTo::LoneWolf {
killer: game
.character_by_player_id(lone_wolf_player_id)
.character_id(),
night: 1
})
);
}

View File

@ -3,6 +3,7 @@ mod black_knight;
mod diseased; mod diseased;
mod elder; mod elder;
mod empath; mod empath;
mod lone_wolf;
mod mason; mod mason;
mod mortician; mod mortician;
mod pyremaster; mod pyremaster;

View File

@ -4,7 +4,7 @@ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq};
use crate::{ use crate::{
diedto::DiedTo, diedto::DiedTo,
game::{Game, GameSettings, OrRandom, SetupRole}, game::{Game, GameOver, GameSettings, OrRandom, SetupRole},
game_test::{ game_test::{
ActionPromptTitleExt, ActionResultExt, AlignmentExt, GameExt, ServerToHostMessageExt, ActionPromptTitleExt, ActionResultExt, AlignmentExt, GameExt, ServerToHostMessageExt,
SettingsExt, gen_players, SettingsExt, gen_players,
@ -37,11 +37,5 @@ fn mayor_win() {
game.mark(game.living_villager().character_id()); game.mark(game.living_villager().character_id());
game.r#continue().sleep(); game.r#continue().sleep();
assert_eq!( assert_eq!(game.next_expect_game_over(), GameOver::VillageWins);
game.process(HostGameMessage::Night(
crate::message::host::HostNightMessage::Next
))
.unwrap(),
ServerToHostMessage::GameOver(crate::game::GameOver::VillageWins)
);
} }

View File

@ -21,6 +21,7 @@ pub enum ActionType {
WolfPackKill, WolfPackKill,
Direwolf, Direwolf,
OtherWolf, OtherWolf,
LoneWolfKill,
Block, Block,
Intel, Intel,
Other, Other,
@ -32,6 +33,7 @@ pub enum ActionType {
impl ActionType { impl ActionType {
const fn is_wolfy(&self) -> bool { const fn is_wolfy(&self) -> bool {
// note: Lone Wolf isn't wolfy, as they don't wake with wolves
matches!( matches!(
self, self,
ActionType::Direwolf ActionType::Direwolf
@ -182,6 +184,12 @@ pub enum ActionPrompt {
living_players: Box<[CharacterIdentity]>, living_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>, marked: Option<CharacterId>,
}, },
#[checks(ActionType::LoneWolfKill)]
LoneWolfKill {
character_id: CharacterIdentity,
living_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>,
},
} }
impl ActionPrompt { impl ActionPrompt {
@ -212,7 +220,8 @@ impl ActionPrompt {
| ActionPrompt::Empath { .. } | ActionPrompt::Empath { .. }
| ActionPrompt::MasonsWake { .. } | ActionPrompt::MasonsWake { .. }
| ActionPrompt::MasonLeaderRecruit { .. } | ActionPrompt::MasonLeaderRecruit { .. }
| ActionPrompt::WolfPackKill { .. } => false, | ActionPrompt::WolfPackKill { .. }
| ActionPrompt::LoneWolfKill { .. } => false,
} }
} }
pub(crate) fn with_mark(&self, mark: CharacterId) -> Result<ActionPrompt> { pub(crate) fn with_mark(&self, mark: CharacterId) -> Result<ActionPrompt> {
@ -288,7 +297,12 @@ impl ActionPrompt {
Ok(prompt) Ok(prompt)
} }
ActionPrompt::Adjudicator { ActionPrompt::LoneWolfKill {
living_players: targets,
marked,
..
}
| ActionPrompt::Adjudicator {
living_players: targets, living_players: targets,
marked, marked,
.. ..

View File

@ -136,6 +136,11 @@ pub enum Role {
#[checks("powerful")] #[checks("powerful")]
#[checks("wolf")] #[checks("wolf")]
Shapeshifter { shifted_into: Option<CharacterId> }, Shapeshifter { shifted_into: Option<CharacterId> },
#[checks(Alignment::Wolves)]
#[checks("killer")]
#[checks("powerful")]
#[checks("wolf")]
LoneWolf,
} }
impl Role { impl Role {
@ -156,7 +161,8 @@ impl Role {
| Role::Arcanist | Role::Arcanist
| Role::Seer => true, | Role::Seer => true,
Role::Shapeshifter { .. } Role::LoneWolf
| Role::Shapeshifter { .. }
| Role::Werewolf | Role::Werewolf
| Role::AlphaWolf { .. } | Role::AlphaWolf { .. }
| Role::Elder { .. } | Role::Elder { .. }
@ -197,6 +203,14 @@ impl Role {
| Role::BlackKnight { .. } | Role::BlackKnight { .. }
| Role::Villager => false, | Role::Villager => false,
Role::LoneWolf => match village.date_time() {
DateTime::Day { number: _ } => return false,
DateTime::Night { number } => NonZeroU8::new(number),
}
.map(|night| village.executions_on_day(night))
.map(|execs| execs.iter().any(|e| e.is_wolf()))
.unwrap_or_default(),
Role::PowerSeer Role::PowerSeer
| Role::Mortician | Role::Mortician
| Role::Beholder | Role::Beholder

View File

@ -166,6 +166,16 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
}; };
} }
ActionPrompt::LoneWolfKill {
character_id,
living_players,
marked,
} => (
Some(character_id),
living_players,
marked.iter().cloned().collect::<Box<[CharacterId]>>(),
html! {{"lone wolf kill"}},
),
ActionPrompt::Adjudicator { ActionPrompt::Adjudicator {
character_id, character_id,
living_players, living_players,