From 9fa5843b5a1bc935a8712b24eb8025d83efefbf6 Mon Sep 17 00:00:00 2001 From: emilis Date: Mon, 6 Oct 2025 21:59:44 +0100 Subject: [PATCH] black knight role and tests --- werewolves-proto/src/character.rs | 62 +++++++++++-- werewolves-proto/src/diedto.rs | 18 ++++ werewolves-proto/src/game/kill.rs | 20 ++--- werewolves-proto/src/game/night.rs | 18 ++-- .../src/game/settings/settings_role.rs | 4 +- werewolves-proto/src/game/village.rs | 36 +++++++- .../src/game_test/role/black_knight.rs | 90 +++++++++++++++++++ .../src/game_test/role/diseased.rs | 78 ++++++++++++++++ werewolves-proto/src/game_test/role/elder.rs | 6 +- werewolves-proto/src/game_test/role/empath.rs | 4 +- werewolves-proto/src/game_test/role/mason.rs | 2 +- werewolves-proto/src/game_test/role/mod.rs | 2 + .../src/game_test/role/scapegoat.rs | 2 +- werewolves-proto/src/role.rs | 3 +- 14 files changed, 312 insertions(+), 33 deletions(-) create mode 100644 werewolves-proto/src/game_test/role/black_knight.rs create mode 100644 werewolves-proto/src/game_test/role/diseased.rs diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs index a42603f..ef1d1b5 100644 --- a/werewolves-proto/src/character.rs +++ b/werewolves-proto/src/character.rs @@ -72,11 +72,7 @@ impl Character { } pub const fn is_power_role(&self) -> bool { - match &self.role { - Role::Scapegoat { .. } | Role::Villager => false, - - _ => true, - } + !matches!(&self.role, Role::Scapegoat { .. } | Role::Villager) } pub fn identity(&self) -> CharacterIdentity { @@ -103,7 +99,14 @@ impl Character { } pub fn kill(&mut self, died_to: DiedTo) { + if self.died_to.is_some() { + return; + } match (&mut self.role, died_to.date_time()) { + (Role::BlackKnight { attacked }, DateTime::Night { .. }) => { + attacked.replace(died_to); + return; + } ( Role::Elder { lost_protection_night: Some(_), @@ -606,6 +609,40 @@ impl Character { } } + pub const fn black_knight<'a>(&'a self) -> Result> { + match &self.role { + Role::BlackKnight { attacked } => Ok(BlackKnight(attacked)), + _ => Err(GameError::InvalidRole { + expected: RoleTitle::BlackKnight, + got: self.role_title(), + }), + } + } + + pub const fn black_knight_kill<'a>(&'a mut self) -> Result> { + let title = self.role.title(); + match &self.role { + Role::BlackKnight { attacked } => Ok(BlackKnightKill { + attacked, + died_to: &mut self.died_to, + }), + _ => Err(GameError::InvalidRole { + expected: RoleTitle::BlackKnight, + got: title, + }), + } + } + pub const fn black_knight_mut<'a>(&'a mut self) -> Result> { + let title = self.role.title(); + match &mut self.role { + Role::BlackKnight { attacked } => Ok(BlackKnightMut(attacked)), + _ => Err(GameError::InvalidRole { + expected: RoleTitle::BlackKnight, + got: title, + }), + } + } + pub const fn initial_shown_role(&self) -> RoleTitle { self.role.initial_shown_role() } @@ -644,8 +681,23 @@ decl_ref_and_mut!( Shapeshifter, ShapeshifterMut: Option; Scapegoat, ScapegoatMut: bool; Empath, EmpathMut: bool; + BlackKnight, BlackKnightMut: Option; ); +pub struct BlackKnightKill<'a> { + attacked: &'a Option, + died_to: &'a mut Option, +} +impl BlackKnightKill<'_> { + pub fn kill(self) { + if let Some(attacked) = self.attacked.as_ref().and_then(|a| a.next_night()) + && self.died_to.is_none() + { + self.died_to.replace(attacked.clone()); + } + } +} + pub struct MasonLeader<'a>(&'a u8, &'a [CharacterId]); impl MasonLeader<'_> { pub const fn remaining_recruits(&self) -> u8 { diff --git a/werewolves-proto/src/diedto.rs b/werewolves-proto/src/diedto.rs index 2779880..7b784d3 100644 --- a/werewolves-proto/src/diedto.rs +++ b/werewolves-proto/src/diedto.rs @@ -56,6 +56,24 @@ pub enum DiedTo { } impl DiedTo { + pub fn next_night(&self) -> Option { + let mut next = self.clone(); + match &mut next { + DiedTo::Execution { .. } => return None, + DiedTo::MapleWolf { night, .. } + | DiedTo::MapleWolfStarved { night } + | DiedTo::Militia { night, .. } + | DiedTo::Wolfpack { night, .. } + | DiedTo::AlphaWolf { night, .. } + | DiedTo::Shapeshift { night, .. } + | DiedTo::Hunter { night, .. } + | DiedTo::GuardianProtecting { night, .. } + | DiedTo::PyreMaster { night, .. } => *night = NonZeroU8::new(night.get() + 1)?, + DiedTo::MasonLeaderRecruitFail { night, .. } => *night = *night + 1, + } + + Some(next) + } pub const fn killer(&self) -> Option { match self { DiedTo::Execution { .. } diff --git a/werewolves-proto/src/game/kill.rs b/werewolves-proto/src/game/kill.rs index 538d1c9..62771b9 100644 --- a/werewolves-proto/src/game/kill.rs +++ b/werewolves-proto/src/game/kill.rs @@ -57,7 +57,7 @@ impl KillOutcome { fn resolve_protection( killer: CharacterId, killed_with: &DiedTo, - target: &CharacterId, + target: CharacterId, protection: &Protection, night: NonZeroU8, ) -> Option { @@ -68,7 +68,7 @@ fn resolve_protection( } => Some(KillOutcome::Guarding { original_killer: killer, guardian: *source, - original_target: *target, + original_target: target, original_kill: killed_with.clone(), night, }), @@ -83,7 +83,7 @@ fn resolve_protection( pub fn resolve_kill( changes: &mut ChangesLookup<'_>, - target: &CharacterId, + target: CharacterId, died_to: &DiedTo, night: u8, village: &Village, @@ -124,7 +124,7 @@ pub fn resolve_kill( return Ok(Some(KillOutcome::Single( *ss_source, DiedTo::Shapeshift { - into: *target, + into: target, night: *night, }, ))); @@ -134,7 +134,7 @@ pub fn resolve_kill( let protection = match changes.protected_take(target) { Some(prot) => prot, - None => return Ok(Some(KillOutcome::Single(*target, died_to.clone()))), + None => return Ok(Some(KillOutcome::Single(target, died_to.clone()))), }; match protection { @@ -145,7 +145,7 @@ pub fn resolve_kill( original_killer: died_to .killer() .ok_or(GameError::GuardianInvalidOriginalKill)?, - original_target: *target, + original_target: target, original_kill: died_to.clone(), guardian: source, night: NonZeroU8::new(night).unwrap(), @@ -165,20 +165,20 @@ impl<'a> ChangesLookup<'a> { Self(changes, Vec::new()) } - pub fn killed(&self, target: &CharacterId) -> Option<&'a DiedTo> { + pub fn killed(&self, target: CharacterId) -> Option<&'a DiedTo> { self.0.iter().enumerate().find_map(|(idx, c)| { self.1 .contains(&idx) .not() .then(|| match c { - NightChange::Kill { target: t, died_to } => (t == target).then_some(died_to), + NightChange::Kill { target: t, died_to } => (*t == target).then_some(died_to), _ => None, }) .flatten() }) } - pub fn protected_take(&mut self, target: &CharacterId) -> Option { + pub fn protected_take(&mut self, target: CharacterId) -> Option { if let Some((idx, c)) = self.0.iter().enumerate().find_map(|(idx, c)| { self.1 .contains(&idx) @@ -187,7 +187,7 @@ impl<'a> ChangesLookup<'a> { NightChange::Protection { target: t, protection, - } => (t == target).then_some((idx, protection)), + } => (*t == target).then_some((idx, protection)), _ => None, }) .flatten() diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index 00eb66a..ad1b85f 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -261,10 +261,7 @@ impl Night { .collect::>>()? .into_iter() .flatten() - .chain((night > 0).then(|| ActionPrompt::WolfPackKill { - marked: None, - living_villagers: village.living_villagers(), - })) + .chain(village.wolf_pack_kill()) .collect::>(); action_queue.sort_by(|left_prompt, right_prompt| { left_prompt @@ -388,7 +385,7 @@ impl Night { NightChange::HunterTarget { source, target } => { let hunter_character = new_village.character_by_id_mut(*source).unwrap(); hunter_character.hunter_mut()?.replace(*target); - if changes.killed(source).is_some() + if changes.killed(*source).is_some() && changes.protected(source).is_none() && changes.protected(target).is_none() { @@ -404,7 +401,7 @@ impl Night { NightChange::Kill { target, died_to } => { if let Some(kill) = kill::resolve_kill( &mut changes, - target, + *target, died_to, self.night, &self.village, @@ -460,6 +457,15 @@ impl Night { } } } + for knight in new_village + .characters_mut() + .into_iter() + .filter(|k| k.black_knight().ok().and_then(|t| (*t).clone()).is_some()) + .filter(|k| changes.killed(k.character_id()).is_none()) + { + knight.black_knight_kill()?.kill(); + } + if new_village.is_game_over().is_none() { new_village.to_day()?; } diff --git a/werewolves-proto/src/game/settings/settings_role.rs b/werewolves-proto/src/game/settings/settings_role.rs index 29a5ea1..0930fff 100644 --- a/werewolves-proto/src/game/settings/settings_role.rs +++ b/werewolves-proto/src/game/settings/settings_role.rs @@ -176,7 +176,7 @@ impl SetupRoleTitle { SetupRoleTitle::Empath => Role::Empath { cursed: false }, SetupRoleTitle::Vindicator => Role::Vindicator, SetupRoleTitle::Diseased => Role::Diseased, - SetupRoleTitle::BlackKnight => Role::BlackKnight { attacked: false }, + SetupRoleTitle::BlackKnight => Role::BlackKnight { attacked: None }, SetupRoleTitle::Weightlifter => Role::Weightlifter, SetupRoleTitle::PyreMaster => Role::PyreMaster { villagers_killed: 0, @@ -272,7 +272,7 @@ impl SetupRole { SetupRole::Empath => Role::Empath { cursed: false }, SetupRole::Vindicator => Role::Vindicator, SetupRole::Diseased => Role::Diseased, - SetupRole::BlackKnight => Role::BlackKnight { attacked: false }, + SetupRole::BlackKnight => Role::BlackKnight { attacked: None }, SetupRole::Weightlifter => Role::Weightlifter, SetupRole::PyreMaster => Role::PyreMaster { villagers_killed: 0, diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs index da8e689..aea809e 100644 --- a/werewolves-proto/src/game/village.rs +++ b/werewolves-proto/src/game/village.rs @@ -9,7 +9,7 @@ use crate::{ diedto::DiedTo, error::GameError, game::{DateTime, GameOver, GameSettings}, - message::{CharacterIdentity, Identification}, + message::{CharacterIdentity, Identification, night::ActionPrompt}, player::PlayerId, role::{Role, RoleTitle}, }; @@ -59,6 +59,27 @@ impl Village { Some(wolves[rand::random_range(0..wolves.len())]) } } + pub fn wolf_pack_kill(&self) -> Option { + let night = match self.date_time { + DateTime::Day { .. } => return None, + DateTime::Night { number } => number, + }; + let no_kill_due_to_disease = self + .characters + .iter() + .filter(|d| matches!(d.role_title(), RoleTitle::Diseased)) + .any(|d| match d.died_to() { + Some(DiedTo::Wolfpack { + night: diseased_death_night, + .. + }) => (diseased_death_night.get() + 1) == night, + _ => false, + }); + (night > 0 && !no_kill_due_to_disease).then_some(ActionPrompt::WolfPackKill { + marked: None, + living_villagers: self.living_villagers(), + }) + } pub const fn date_time(&self) -> DateTime { self.date_time @@ -222,10 +243,21 @@ impl Village { .collect() } + pub fn living_characters_by_role_mut(&mut self, role: RoleTitle) -> Box<[&mut Character]> { + self.characters + .iter_mut() + .filter(|c| c.role_title() == role) + .collect() + } + pub fn characters(&self) -> Box<[Character]> { self.characters.iter().cloned().collect() } + pub fn characters_mut(&mut self) -> Box<[&mut Character]> { + self.characters.iter_mut().collect() + } + pub fn character_by_id_mut(&mut self, character_id: CharacterId) -> Result<&mut Character> { self.characters .iter_mut() @@ -287,7 +319,7 @@ impl RoleTitle { RoleTitle::Empath => Role::Empath { cursed: false }, RoleTitle::Vindicator => Role::Vindicator, RoleTitle::Diseased => Role::Diseased, - RoleTitle::BlackKnight => Role::BlackKnight { attacked: false }, + RoleTitle::BlackKnight => Role::BlackKnight { attacked: None }, RoleTitle::Weightlifter => Role::Weightlifter, RoleTitle::PyreMaster => Role::PyreMaster { villagers_killed: 0, diff --git a/werewolves-proto/src/game_test/role/black_knight.rs b/werewolves-proto/src/game_test/role/black_knight.rs new file mode 100644 index 0000000..5e3a099 --- /dev/null +++ b/werewolves-proto/src/game_test/role/black_knight.rs @@ -0,0 +1,90 @@ +use core::num::NonZeroU8; +#[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, AlignmentExt, GameExt, ServerToHostMessageExt, + SettingsExt, gen_players, + }, + message::{ + host::{HostDayMessage, HostGameMessage}, + night::{ActionPrompt, ActionPromptTitle, ActionResult}, + }, + role::Role, +}; + +#[test] +fn dies_day_after() { + let players = gen_players(1..10); + let black_knight = players[0].player_id; + let wolf_player_id = players[1].player_id; + + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::BlackKnight, black_knight); + settings.add_and_assign(SetupRole::Werewolf, wolf_player_id); + + settings.fill_remaining_slots_with_villagers(9); + 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.character_by_player_id(black_knight).character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!(game.character_by_player_id(black_knight).died_to(), None); + + game.execute().title().wolf_pack_kill(); + game.mark(game.villager_character_ids().into_iter().next().unwrap()); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(black_knight).died_to().cloned(), + Some(DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf_player_id).character_id(), + night: NonZeroU8::new(2).unwrap() + }) + ); +} + +#[test] +fn you_can_just_keep_whacking_it() { + let players = gen_players(1..10); + let black_knight = players[0].player_id; + let wolf_player_id = players[1].player_id; + + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::BlackKnight, black_knight); + settings.add_and_assign(SetupRole::Werewolf, wolf_player_id); + + settings.fill_remaining_slots_with_villagers(9); + 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.character_by_player_id(black_knight).character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + for _ in 0..100 { + assert_eq!(game.character_by_player_id(black_knight).died_to(), None); + + game.execute().title().wolf_pack_kill(); + game.mark(game.character_by_player_id(black_knight).character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + } +} diff --git a/werewolves-proto/src/game_test/role/diseased.rs b/werewolves-proto/src/game_test/role/diseased.rs new file mode 100644 index 0000000..3f15c20 --- /dev/null +++ b/werewolves-proto/src/game_test/role/diseased.rs @@ -0,0 +1,78 @@ +use core::num::NonZeroU8; +#[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, AlignmentExt, GameExt, ServerToHostMessageExt, + SettingsExt, gen_players, + }, + message::{ + host::{HostDayMessage, HostGameMessage}, + night::{ActionPrompt, ActionPromptTitle, ActionResult}, + }, + role::Role, +}; + +#[test] +fn wolf_kill_prevented() { + let players = gen_players(1..10); + let diseased_player_id = players[0].player_id; + let wolf_player_id = players[1].player_id; + + let mut settings = GameSettings::empty(); + settings.add_and_assign(SetupRole::Diseased, diseased_player_id); + settings.add_and_assign(SetupRole::Werewolf, wolf_player_id); + + settings.fill_remaining_slots_with_villagers(9); + 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.character_by_player_id(diseased_player_id) + .character_id(), + ); + game.r#continue().sleep(); + + game.next_expect_day(); + + assert_eq!( + game.character_by_player_id(diseased_player_id) + .died_to() + .cloned(), + Some(DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf_player_id).character_id(), + night: NonZeroU8::new(1).unwrap() + }) + ); + + assert_eq!( + game.process(HostGameMessage::Day(HostDayMessage::Execute)) + .unwrap() + .prompt(), + ActionPrompt::CoverOfDarkness + ); + game.r#continue().r#continue(); + game.next_expect_day(); + game.execute().title().wolf_pack_kill(); + let target = game.living_villager_excl(wolf_player_id); + game.mark(target.character_id()); + game.r#continue().sleep(); + + game.next_expect_day(); + assert_eq!( + game.character_by_player_id(target.player_id()) + .died_to() + .cloned(), + Some(DiedTo::Wolfpack { + killing_wolf: game.character_by_player_id(wolf_player_id).character_id(), + night: NonZeroU8::new(3).unwrap() + }) + ); +} diff --git a/werewolves-proto/src/game_test/role/elder.rs b/werewolves-proto/src/game_test/role/elder.rs index 3c72f19..ae08b34 100644 --- a/werewolves-proto/src/game_test/role/elder.rs +++ b/werewolves-proto/src/game_test/role/elder.rs @@ -11,7 +11,7 @@ use crate::{ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; #[test] -fn elder_doesnt_die_first_try_night_doesnt_know() { +fn doesnt_die_first_try_night_doesnt_know() { let players = gen_players(1..10); let elder_player_id = players[0].player_id; let wolf_player_id = players[2].player_id; @@ -62,7 +62,7 @@ fn elder_doesnt_die_first_try_night_doesnt_know() { } #[test] -fn elder_doesnt_die_first_try_night_knows() { +fn doesnt_die_first_try_night_knows() { let players = gen_players(1..10); let elder_player_id = players[0].player_id; let wolf_player_id = players[2].player_id; @@ -121,7 +121,7 @@ fn elder_doesnt_die_first_try_night_knows() { } #[test] -fn elder_executed_doesnt_know() { +fn executed_doesnt_know() { let players = gen_players(1..10); let elder_player_id = players[0].player_id; let seer_player_id = players[1].player_id; diff --git a/werewolves-proto/src/game_test/role/empath.rs b/werewolves-proto/src/game_test/role/empath.rs index 067f998..8c1c557 100644 --- a/werewolves-proto/src/game_test/role/empath.rs +++ b/werewolves-proto/src/game_test/role/empath.rs @@ -12,7 +12,7 @@ use crate::{ }; #[test] -fn empath_nothing_on_wolf() { +fn nothing_on_wolf() { let players = gen_players(1..10); let empath_player_id = players[0].player_id; let wolf_player_id = players[1].player_id; @@ -44,7 +44,7 @@ fn empath_nothing_on_wolf() { } #[test] -fn empath_takes_on_scapegoats_curse() { +fn takes_on_scapegoats_curse() { let players = gen_players(1..10); let empath_player_id = players[0].player_id; let wolf_player_id = players[1].player_id; diff --git a/werewolves-proto/src/game_test/role/mason.rs b/werewolves-proto/src/game_test/role/mason.rs index b76512d..34e7e01 100644 --- a/werewolves-proto/src/game_test/role/mason.rs +++ b/werewolves-proto/src/game_test/role/mason.rs @@ -9,7 +9,7 @@ use crate::{ }; #[test] -fn mason_recruits_decrement() { +fn recruits_decrement() { let players = gen_players(1..10); let mason_leader_player_id = players[0].player_id; let wolf_player_id = players[1].player_id; diff --git a/werewolves-proto/src/game_test/role/mod.rs b/werewolves-proto/src/game_test/role/mod.rs index 8c2d1ea..fc9920d 100644 --- a/werewolves-proto/src/game_test/role/mod.rs +++ b/werewolves-proto/src/game_test/role/mod.rs @@ -1,4 +1,6 @@ mod beholder; +mod black_knight; +mod diseased; mod elder; mod empath; mod mason; diff --git a/werewolves-proto/src/game_test/role/scapegoat.rs b/werewolves-proto/src/game_test/role/scapegoat.rs index 0496447..5146230 100644 --- a/werewolves-proto/src/game_test/role/scapegoat.rs +++ b/werewolves-proto/src/game_test/role/scapegoat.rs @@ -151,7 +151,7 @@ fn redeemed_scapegoat_role_changes() { } #[test] -fn redeemed_scapegoat_cannot_redeem_into_wolf() { +fn cannot_redeem_into_wolf() { let players = gen_players(1..10); let scapegoat_player_id = players[0].player_id; let wolf_player_id = players[1].player_id; diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs index 8b548e2..3263992 100644 --- a/werewolves-proto/src/role.rs +++ b/werewolves-proto/src/role.rs @@ -5,6 +5,7 @@ use werewolves_macros::{ChecksAs, Titles}; use crate::{ character::CharacterId, + diedto::DiedTo, game::{DateTime, Village}, message::CharacterIdentity, }; @@ -63,7 +64,7 @@ pub enum Role { #[checks(Alignment::Village)] #[checks("powerful")] #[checks("is_mentor")] - BlackKnight { attacked: bool }, + BlackKnight { attacked: Option }, #[checks(Alignment::Village)] #[checks("powerful")] #[checks("is_mentor")]