diff --git a/werewolves-proto/src/character.rs b/werewolves-proto/src/character.rs index 8857ae0..4459b7e 100644 --- a/werewolves-proto/src/character.rs +++ b/werewolves-proto/src/character.rs @@ -482,6 +482,11 @@ impl Character { living_players: village.living_players_excluding(self.character_id()), marked: None, }, + Role::LoneWolf => ActionPrompt::LoneWolfKill { + character_id: self.identity(), + living_players: village.living_players_excluding(self.character_id()), + marked: None, + }, }])) } diff --git a/werewolves-proto/src/diedto.rs b/werewolves-proto/src/diedto.rs index 3f516a8..264c223 100644 --- a/werewolves-proto/src/diedto.rs +++ b/werewolves-proto/src/diedto.rs @@ -57,6 +57,10 @@ pub enum DiedTo { tried_recruiting: CharacterId, night: u8, }, + LoneWolf { + killer: CharacterId, + night: u8, + }, } impl DiedTo { @@ -64,6 +68,7 @@ impl DiedTo { let mut next = self.clone(); match &mut next { DiedTo::Execution { .. } => return None, + DiedTo::MapleWolf { night, .. } | DiedTo::MapleWolfStarved { night } | DiedTo::Militia { night, .. } @@ -74,7 +79,10 @@ impl DiedTo { | DiedTo::GuardianProtecting { night, .. } | DiedTo::PyreMasterLynchMob { night, .. } | 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) @@ -101,7 +109,8 @@ impl DiedTo { .. } | DiedTo::PyreMasterLynchMob { source: killer, .. } - | DiedTo::PyreMaster { killer, .. } => Some(*killer), + | DiedTo::PyreMaster { killer, .. } + | DiedTo::LoneWolf { killer, .. } => Some(*killer), } } pub const fn date_time(&self) -> DateTime { @@ -132,7 +141,9 @@ impl DiedTo { | DiedTo::Hunter { killer: _, night } => DateTime::Night { number: night.get(), }, - DiedTo::MasonLeaderRecruitFail { night, .. } => DateTime::Night { number: *night }, + DiedTo::LoneWolf { night, .. } | DiedTo::MasonLeaderRecruitFail { night, .. } => { + DateTime::Night { number: *night } + } } } } diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index 92960c9..76cacf4 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -90,7 +90,11 @@ impl ActionPrompt { .. } => Some(Unless::TargetsBlocked(*marked1, *marked2)), - ActionPrompt::Seer { + ActionPrompt::LoneWolfKill { + marked: Some(marked), + .. + } + | ActionPrompt::Seer { marked: Some(marked), .. } @@ -163,7 +167,8 @@ impl ActionPrompt { .. } => Some(Unless::TargetBlocked(*marked)), - ActionPrompt::Seer { marked: None, .. } + ActionPrompt::LoneWolfKill { marked: None, .. } + | ActionPrompt::Seer { marked: None, .. } | ActionPrompt::Protector { marked: None, .. } | ActionPrompt::Gravedigger { marked: None, .. } | ActionPrompt::Hunter { marked: None, .. } @@ -806,6 +811,21 @@ impl Night { }; 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::WolvesIntro { .. } | ActionPrompt::CoverOfDarkness => { @@ -1166,6 +1186,7 @@ impl Night { marked: (Some(_), None), .. } + | ActionPrompt::LoneWolfKill { marked: None, .. } | ActionPrompt::Gravedigger { marked: None, .. } | ActionPrompt::Hunter { marked: None, .. } | ActionPrompt::Guardian { marked: None, .. } @@ -1205,7 +1226,8 @@ impl Night { current_prompt, current_result: _, } => match current_prompt { - ActionPrompt::ElderReveal { character_id } + ActionPrompt::LoneWolfKill { character_id, .. } + | ActionPrompt::ElderReveal { character_id } | ActionPrompt::RoleChange { character_id, .. } | ActionPrompt::Seer { character_id, .. } | ActionPrompt::Protector { character_id, .. } diff --git a/werewolves-proto/src/game/settings/settings_role.rs b/werewolves-proto/src/game/settings/settings_role.rs index 0930fff..29a6fd5 100644 --- a/werewolves-proto/src/game/settings/settings_role.rs +++ b/werewolves-proto/src/game/settings/settings_role.rs @@ -111,6 +111,8 @@ pub enum SetupRole { DireWolf, #[checks(Category::Wolves)] Shapeshifter, + #[checks(Category::Wolves)] + LoneWolf, #[checks(Category::Intel)] Adjudicator, @@ -139,6 +141,7 @@ pub enum SetupRole { impl SetupRoleTitle { pub fn into_role(self) -> Role { match self { + SetupRoleTitle::LoneWolf => Role::LoneWolf, SetupRoleTitle::Villager => Role::Villager, SetupRoleTitle::Scapegoat => Role::Scapegoat { redeemed: false }, SetupRoleTitle::Seer => Role::Seer, @@ -188,6 +191,7 @@ impl SetupRoleTitle { impl Display for SetupRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { + SetupRole::LoneWolf => "Lone Wolf", SetupRole::Villager => "Villager", SetupRole::Scapegoat { .. } => "Scapegoat", SetupRole::Seer => "Seer", @@ -222,6 +226,7 @@ impl Display for SetupRole { impl SetupRole { pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result { Ok(match self { + SetupRole::LoneWolf => Role::LoneWolf, SetupRole::Villager => Role::Villager, SetupRole::Scapegoat { redeemed } => Role::Scapegoat { redeemed: redeemed.into_concrete(), @@ -284,6 +289,7 @@ impl SetupRole { impl From for RoleTitle { fn from(value: SetupRole) -> Self { match value { + SetupRole::LoneWolf => RoleTitle::LoneWolf, SetupRole::Villager => RoleTitle::Villager, SetupRole::Scapegoat { .. } => RoleTitle::Scapegoat, SetupRole::Seer => RoleTitle::Seer, @@ -318,6 +324,7 @@ impl From for RoleTitle { impl From for SetupRole { fn from(value: RoleTitle) -> Self { match value { + RoleTitle::LoneWolf => SetupRole::LoneWolf, RoleTitle::Villager => SetupRole::Villager, RoleTitle::Scapegoat => SetupRole::Scapegoat { redeemed: Default::default(), diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs index 7ee269d..4862b65 100644 --- a/werewolves-proto/src/game/village.rs +++ b/werewolves-proto/src/game/village.rs @@ -286,6 +286,7 @@ impl Village { impl RoleTitle { pub fn title_to_role_excl_apprentice(self) -> Role { match self { + RoleTitle::LoneWolf => Role::LoneWolf, RoleTitle::Villager => Role::Villager, RoleTitle::Scapegoat => Role::Scapegoat { redeemed: rand::random(), diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index 2751ca0..3a8991e 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -4,7 +4,7 @@ mod role; use crate::{ character::{Character, CharacterId}, error::GameError, - game::{Game, GameSettings, SetupRole, SetupSlot}, + game::{Game, GameOver, GameSettings, SetupRole, SetupSlot}, message::{ CharacterState, Identification, PublicIdentity, host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage}, @@ -62,6 +62,7 @@ pub trait ActionPromptTitleExt { fn pyremaster(&self); fn empath(&self); fn adjudicator(&self); + fn lone_wolf(&self); } impl ActionPromptTitleExt for ActionPromptTitle { @@ -131,6 +132,9 @@ impl ActionPromptTitleExt for ActionPromptTitle { fn empath(&self) { assert_eq!(*self, ActionPromptTitle::Empath) } + fn lone_wolf(&self) { + assert_eq!(*self, ActionPromptTitle::LoneWolfKill) + } } pub trait ActionResultExt { @@ -227,9 +231,21 @@ pub trait GameExt { fn living_villager(&self) -> Character; #[allow(unused)] fn get_state(&mut self) -> ServerToHostMessage; + fn next_expect_game_over(&mut self) -> GameOver; } 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 { self.process(HostGameMessage::GetState).unwrap() } @@ -295,7 +311,12 @@ impl GameExt for Game { | ActionPrompt::RoleChange { .. } | ActionPrompt::Shapeshifter { .. } => panic!("expected a prompt with a mark"), ActionPrompt::Arcanist { .. } => panic!("wrong call for arcanist"), - ActionPrompt::Seer { + + ActionPrompt::LoneWolfKill { + marked: Some(marked), + .. + } + | ActionPrompt::Seer { marked: Some(marked), .. } @@ -384,7 +405,8 @@ impl GameExt for Game { | ActionPrompt::Guardian { marked: None, .. } | ActionPrompt::WolfPackKill { marked: None, .. } | ActionPrompt::AlphaWolf { marked: None, .. } - | ActionPrompt::DireWolf { marked: None, .. } => panic!("no mark"), + | ActionPrompt::DireWolf { marked: None, .. } + | ActionPrompt::LoneWolfKill { marked: None, .. } => panic!("no mark"), } } diff --git a/werewolves-proto/src/game_test/role/lone_wolf.rs b/werewolves-proto/src/game_test/role/lone_wolf.rs new file mode 100644 index 0000000..d29d428 --- /dev/null +++ b/werewolves-proto/src/game_test/role/lone_wolf.rs @@ -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 + }) + ); +} diff --git a/werewolves-proto/src/game_test/role/mod.rs b/werewolves-proto/src/game_test/role/mod.rs index fd46ea2..9ac2005 100644 --- a/werewolves-proto/src/game_test/role/mod.rs +++ b/werewolves-proto/src/game_test/role/mod.rs @@ -3,6 +3,7 @@ mod black_knight; mod diseased; mod elder; mod empath; +mod lone_wolf; mod mason; mod mortician; mod pyremaster; diff --git a/werewolves-proto/src/game_test/role/weightlifter.rs b/werewolves-proto/src/game_test/role/weightlifter.rs index 07750ac..3de7804 100644 --- a/werewolves-proto/src/game_test/role/weightlifter.rs +++ b/werewolves-proto/src/game_test/role/weightlifter.rs @@ -4,7 +4,7 @@ use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; use crate::{ diedto::DiedTo, - game::{Game, GameSettings, OrRandom, SetupRole}, + game::{Game, GameOver, GameSettings, OrRandom, SetupRole}, game_test::{ ActionPromptTitleExt, ActionResultExt, AlignmentExt, GameExt, ServerToHostMessageExt, SettingsExt, gen_players, @@ -37,11 +37,5 @@ fn mayor_win() { game.mark(game.living_villager().character_id()); game.r#continue().sleep(); - assert_eq!( - game.process(HostGameMessage::Night( - crate::message::host::HostNightMessage::Next - )) - .unwrap(), - ServerToHostMessage::GameOver(crate::game::GameOver::VillageWins) - ); + assert_eq!(game.next_expect_game_over(), GameOver::VillageWins); } diff --git a/werewolves-proto/src/message/night.rs b/werewolves-proto/src/message/night.rs index 5305369..9808e27 100644 --- a/werewolves-proto/src/message/night.rs +++ b/werewolves-proto/src/message/night.rs @@ -21,6 +21,7 @@ pub enum ActionType { WolfPackKill, Direwolf, OtherWolf, + LoneWolfKill, Block, Intel, Other, @@ -32,6 +33,7 @@ pub enum ActionType { impl ActionType { const fn is_wolfy(&self) -> bool { + // note: Lone Wolf isn't wolfy, as they don't wake with wolves matches!( self, ActionType::Direwolf @@ -182,6 +184,12 @@ pub enum ActionPrompt { living_players: Box<[CharacterIdentity]>, marked: Option, }, + #[checks(ActionType::LoneWolfKill)] + LoneWolfKill { + character_id: CharacterIdentity, + living_players: Box<[CharacterIdentity]>, + marked: Option, + }, } impl ActionPrompt { @@ -212,7 +220,8 @@ impl ActionPrompt { | ActionPrompt::Empath { .. } | ActionPrompt::MasonsWake { .. } | ActionPrompt::MasonLeaderRecruit { .. } - | ActionPrompt::WolfPackKill { .. } => false, + | ActionPrompt::WolfPackKill { .. } + | ActionPrompt::LoneWolfKill { .. } => false, } } pub(crate) fn with_mark(&self, mark: CharacterId) -> Result { @@ -288,7 +297,12 @@ impl ActionPrompt { Ok(prompt) } - ActionPrompt::Adjudicator { + ActionPrompt::LoneWolfKill { + living_players: targets, + marked, + .. + } + | ActionPrompt::Adjudicator { living_players: targets, marked, .. diff --git a/werewolves-proto/src/role.rs b/werewolves-proto/src/role.rs index f9e3856..cca8e47 100644 --- a/werewolves-proto/src/role.rs +++ b/werewolves-proto/src/role.rs @@ -136,6 +136,11 @@ pub enum Role { #[checks("powerful")] #[checks("wolf")] Shapeshifter { shifted_into: Option }, + #[checks(Alignment::Wolves)] + #[checks("killer")] + #[checks("powerful")] + #[checks("wolf")] + LoneWolf, } impl Role { @@ -156,7 +161,8 @@ impl Role { | Role::Arcanist | Role::Seer => true, - Role::Shapeshifter { .. } + Role::LoneWolf + | Role::Shapeshifter { .. } | Role::Werewolf | Role::AlphaWolf { .. } | Role::Elder { .. } @@ -197,6 +203,14 @@ impl Role { | Role::BlackKnight { .. } | 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::Mortician | Role::Beholder diff --git a/werewolves/src/components/action/prompt.rs b/werewolves/src/components/action/prompt.rs index f125cbe..1acbdc3 100644 --- a/werewolves/src/components/action/prompt.rs +++ b/werewolves/src/components/action/prompt.rs @@ -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::>(), + html! {{"lone wolf kill"}}, + ), ActionPrompt::Adjudicator { character_id, living_players,