black knight role and tests

This commit is contained in:
emilis 2025-10-06 21:59:44 +01:00
parent 153d39f0bb
commit 9fa5843b5a
No known key found for this signature in database
14 changed files with 312 additions and 33 deletions

View File

@ -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<BlackKnight<'a>> {
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<BlackKnightKill<'a>> {
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<BlackKnightMut<'a>> {
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<CharacterId>;
Scapegoat, ScapegoatMut: bool;
Empath, EmpathMut: bool;
BlackKnight, BlackKnightMut: Option<DiedTo>;
);
pub struct BlackKnightKill<'a> {
attacked: &'a Option<DiedTo>,
died_to: &'a mut Option<DiedTo>,
}
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 {

View File

@ -56,6 +56,24 @@ pub enum DiedTo {
}
impl DiedTo {
pub fn next_night(&self) -> Option<DiedTo> {
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<CharacterId> {
match self {
DiedTo::Execution { .. }

View File

@ -57,7 +57,7 @@ impl KillOutcome {
fn resolve_protection(
killer: CharacterId,
killed_with: &DiedTo,
target: &CharacterId,
target: CharacterId,
protection: &Protection,
night: NonZeroU8,
) -> Option<KillOutcome> {
@ -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<Protection> {
pub fn protected_take(&mut self, target: CharacterId) -> Option<Protection> {
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()

View File

@ -261,10 +261,7 @@ impl Night {
.collect::<Result<Box<[_]>>>()?
.into_iter()
.flatten()
.chain((night > 0).then(|| ActionPrompt::WolfPackKill {
marked: None,
living_villagers: village.living_villagers(),
}))
.chain(village.wolf_pack_kill())
.collect::<Vec<_>>();
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()?;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
mod beholder;
mod black_knight;
mod diseased;
mod elder;
mod empath;
mod mason;

View File

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

View File

@ -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<DiedTo> },
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]