insomniac role + mason fixes

also styling improvements
This commit is contained in:
emilis 2025-10-07 17:45:21 +01:00
parent ae71ea4eb0
commit 17f583539d
No known key found for this signature in database
20 changed files with 826 additions and 127 deletions

View File

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::{
diedto::DiedTo,
error::GameError,
game::{DateTime, Village},
game::{DateTime, Village, night::NightChange},
message::{CharacterIdentity, Identification, PublicIdentity, night::ActionPrompt},
modifier::Modifier,
player::{PlayerId, RoleChange},
@ -217,7 +217,58 @@ impl Character {
)
}
fn mason_prompts(&self, village: &Village) -> Result<Box<[ActionPrompt]>> {
if !self.role.wakes(village) {
return Ok(Box::new([]));
}
let (recruits, recruits_available) = match &self.role {
Role::MasonLeader {
recruits,
recruits_available,
} => (recruits, *recruits_available),
_ => {
return Err(GameError::InvalidRole {
expected: RoleTitle::MasonLeader,
got: self.role_title(),
});
}
};
let recruits = recruits
.iter()
.filter_map(|r| village.character_by_id(*r).ok())
.filter_map(|c| c.is_village().then_some(c.identity()))
.chain((self.is_village() && self.alive()).then_some(self.identity()))
.collect::<Box<[_]>>();
Ok(recruits
.is_empty()
.not()
.then_some(ActionPrompt::MasonsWake {
leader: self.character_id(),
masons: recruits.clone(),
})
.into_iter()
.chain(
self.alive()
.then_some(())
.and_then(|_| NonZeroU8::new(recruits_available))
.map(|recruits_available| ActionPrompt::MasonLeaderRecruit {
character_id: self.identity(),
recruits_left: recruits_available,
potential_recruits: village
.living_players_excluding(self.character_id())
.into_iter()
.filter(|c| !recruits.iter().any(|r| r.character_id == c.character_id))
.collect(),
marked: None,
}),
)
.collect())
}
pub fn night_action_prompts(&self, village: &Village) -> Result<Box<[ActionPrompt]>> {
if self.mason_leader().is_ok() {
return self.mason_prompts(village);
}
if !self.alive() || !self.role.wakes(village) {
return Ok(Box::new([]));
}
@ -241,6 +292,11 @@ impl Character {
..
}
| Role::Villager => return Ok(Box::new([])),
Role::Insomniac => ActionPrompt::Insomniac {
character_id: self.identity(),
},
Role::Scapegoat { redeemed: true } => {
let mut dead = village.dead_characters();
dead.shuffle(&mut rand::rng());
@ -414,36 +470,11 @@ impl Character {
living_players: village.living_players_excluding(self.character_id()),
marked: None,
},
Role::MasonLeader {
recruits_available,
recruits,
} => {
return Ok(recruits
.is_empty()
.not()
.then_some(ActionPrompt::MasonsWake {
character_id: self.identity(),
masons: recruits
.iter()
.map(|r| village.character_by_id(*r).map(|c| c.identity()))
.collect::<Result<Box<[CharacterIdentity]>>>()?,
})
.into_iter()
.chain(
NonZeroU8::new(*recruits_available).map(|recruits_available| {
ActionPrompt::MasonLeaderRecruit {
character_id: self.identity(),
recruits_left: recruits_available,
potential_recruits: village
.living_players_excluding(self.character_id())
.into_iter()
.filter(|c| !recruits.contains(&c.character_id))
.collect(),
marked: None,
}
}),
)
.collect());
Role::MasonLeader { .. } => {
log::error!(
"night_action_prompts got to MasonLeader, should be handled before the living check"
);
return Ok(Box::new([]));
}
Role::Empath { cursed: false } => ActionPrompt::Empath {
character_id: self.identity(),

View File

@ -13,7 +13,10 @@ use crate::{
DateTime, Village,
kill::{self, ChangesLookup},
},
message::night::{ActionPrompt, ActionResponse, ActionResult},
message::{
CharacterIdentity,
night::{ActionPrompt, ActionResponse, ActionResult, Visits},
},
player::Protection,
role::{PYREMASTER_VILLAGER_KILLS_TO_DIE, PreviousGuardianAction, RoleBlock, RoleTitle},
};
@ -78,7 +81,8 @@ impl From<ActionComplete> for ResponseOutcome {
impl ActionPrompt {
fn unless(&self) -> Option<Unless> {
match &self {
ActionPrompt::MasonsWake { .. }
ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::Shapeshifter { .. }
@ -513,10 +517,9 @@ impl Night {
) -> Result<ActionResult> {
if self.village.character_by_id(recruiting)?.is_village() {
if let Some(masons) = self.action_queue.iter_mut().find_map(|a| match a {
ActionPrompt::MasonsWake {
character_id,
masons,
} => (character_id.character_id == mason_leader).then_some(masons),
ActionPrompt::MasonsWake { leader, masons, .. } => {
(*leader == mason_leader).then_some(masons)
}
_ => None,
}) {
let mut ext_masons = masons.to_vec();
@ -524,7 +527,7 @@ impl Night {
*masons = ext_masons.into_boxed_slice();
} else {
self.action_queue.push_front(ActionPrompt::MasonsWake {
character_id: self.village.character_by_id(mason_leader)?.identity(),
leader: self.village.character_by_id(mason_leader)?.character_id(),
masons: Box::new([self.village.character_by_id(recruiting)?.identity()]),
});
}
@ -794,6 +797,15 @@ impl Night {
};
}
ActionResponse::Continue => {
if let ActionPrompt::Insomniac { character_id } = current_prompt {
return Ok(ActionComplete {
result: ActionResult::Insomniac(
self.get_visits_for(character_id.character_id),
),
change: None,
}
.into());
}
if let ActionPrompt::RoleChange {
character_id,
new_role,
@ -813,8 +825,8 @@ impl Night {
match current_prompt {
ActionPrompt::LoneWolfKill {
character_id,
living_players,
marked: Some(marked),
..
} => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: Some(NightChange::Kill {
@ -1144,6 +1156,11 @@ impl Night {
}),
}
.into()),
ActionPrompt::Insomniac { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: None,
}
.into()),
ActionPrompt::PyreMaster {
character_id,
marked: Some(marked),
@ -1226,7 +1243,8 @@ impl Night {
current_prompt,
current_result: _,
} => match current_prompt {
ActionPrompt::LoneWolfKill { character_id, .. }
ActionPrompt::Insomniac { character_id, .. }
| ActionPrompt::LoneWolfKill { character_id, .. }
| ActionPrompt::ElderReveal { character_id }
| ActionPrompt::RoleChange { character_id, .. }
| ActionPrompt::Seer { character_id, .. }
@ -1243,13 +1261,14 @@ impl Night {
| ActionPrompt::PowerSeer { character_id, .. }
| ActionPrompt::Mortician { character_id, .. }
| ActionPrompt::Beholder { character_id, .. }
| ActionPrompt::MasonsWake { character_id, .. }
| ActionPrompt::MasonLeaderRecruit { character_id, .. }
| ActionPrompt::Empath { character_id, .. }
| ActionPrompt::Vindicator { character_id, .. }
| ActionPrompt::PyreMaster { character_id, .. }
| ActionPrompt::DireWolf { character_id, .. } => Some(character_id.character_id),
ActionPrompt::WolvesIntro { wolves: _ }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::WolfPackKill { .. }
| ActionPrompt::CoverOfDarkness => None,
},
@ -1282,6 +1301,13 @@ impl Night {
NightState::Complete => return Err(GameError::NightOver),
}
if let Some(prompt) = self.action_queue.pop_front() {
if let ActionPrompt::Insomniac { character_id } = &prompt
&& self.get_visits_for(character_id.character_id).is_empty()
{
// skip!
self.used_actions.pop(); // it will be re-added
return self.next();
}
self.night_state = NightState::Active {
current_prompt: prompt,
current_result: None,
@ -1296,6 +1322,147 @@ impl Night {
pub const fn changes(&self) -> &[NightChange] {
self.changes.as_slice()
}
pub fn get_visits_for(&self, visit_char: CharacterId) -> Visits {
Visits::new(
self.used_actions
.iter()
.filter_map(|(prompt, _)| match prompt {
ActionPrompt::Arcanist {
character_id,
marked: (Some(marked1), Some(marked2)),
..
} => (*marked1 == visit_char || *marked2 == visit_char)
.then_some(character_id.clone()),
ActionPrompt::WolfPackKill {
marked: Some(marked),
..
} => (*marked == visit_char)
.then(|| self.village.killing_wolf().map(|c| c.identity()))
.flatten(),
ActionPrompt::Seer {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Protector {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Gravedigger {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Hunter {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Militia {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::MapleWolf {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Guardian {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Adjudicator {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::PowerSeer {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Mortician {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Beholder {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::MasonLeaderRecruit {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Empath {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::Vindicator {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::PyreMaster {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::AlphaWolf {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::DireWolf {
character_id,
marked: Some(marked),
..
}
| ActionPrompt::LoneWolfKill {
character_id,
marked: Some(marked),
..
} => (*marked == visit_char).then(|| character_id.clone()),
ActionPrompt::WolfPackKill { marked: None, .. }
| ActionPrompt::Arcanist { marked: _, .. }
| ActionPrompt::LoneWolfKill { marked: None, .. }
| ActionPrompt::Seer { marked: None, .. }
| ActionPrompt::Protector { marked: None, .. }
| ActionPrompt::Gravedigger { marked: None, .. }
| ActionPrompt::Hunter { marked: None, .. }
| ActionPrompt::Militia { marked: None, .. }
| ActionPrompt::MapleWolf { marked: None, .. }
| ActionPrompt::Guardian { marked: None, .. }
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. }
| ActionPrompt::Beholder { marked: None, .. }
| ActionPrompt::MasonLeaderRecruit { marked: None, .. }
| ActionPrompt::Empath { marked: None, .. }
| ActionPrompt::Vindicator { marked: None, .. }
| ActionPrompt::PyreMaster { marked: None, .. }
| ActionPrompt::AlphaWolf { marked: None, .. }
| ActionPrompt::DireWolf { marked: None, .. }
| ActionPrompt::CoverOfDarkness
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::Insomniac { .. } => None,
})
.collect(),
)
}
}
pub enum ServerAction {

View File

@ -117,6 +117,8 @@ pub enum SetupRole {
#[checks(Category::Intel)]
Adjudicator,
#[checks(Category::Intel)]
Insomniac,
#[checks(Category::Intel)]
PowerSeer,
#[checks(Category::Intel)]
Mortician,
@ -141,6 +143,7 @@ pub enum SetupRole {
impl SetupRoleTitle {
pub fn into_role(self) -> Role {
match self {
SetupRoleTitle::Insomniac => Role::Insomniac,
SetupRoleTitle::LoneWolf => Role::LoneWolf,
SetupRoleTitle::Villager => Role::Villager,
SetupRoleTitle::Scapegoat => Role::Scapegoat { redeemed: false },
@ -191,6 +194,7 @@ impl SetupRoleTitle {
impl Display for SetupRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SetupRole::Insomniac => "Insomniac",
SetupRole::LoneWolf => "Lone Wolf",
SetupRole::Villager => "Villager",
SetupRole::Scapegoat { .. } => "Scapegoat",
@ -226,6 +230,7 @@ impl Display for SetupRole {
impl SetupRole {
pub fn into_role(self, roles_in_game: &[RoleTitle]) -> Result<Role, GameError> {
Ok(match self {
SetupRole::Insomniac => Role::Insomniac,
SetupRole::LoneWolf => Role::LoneWolf,
SetupRole::Villager => Role::Villager,
SetupRole::Scapegoat { redeemed } => Role::Scapegoat {
@ -289,34 +294,12 @@ impl SetupRole {
impl From<SetupRole> 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,
SetupRole::Arcanist => RoleTitle::Arcanist,
SetupRole::Gravedigger => RoleTitle::Gravedigger,
SetupRole::Hunter => RoleTitle::Hunter,
SetupRole::Militia => RoleTitle::Militia,
SetupRole::MapleWolf => RoleTitle::MapleWolf,
SetupRole::Guardian => RoleTitle::Guardian,
SetupRole::Protector => RoleTitle::Protector,
SetupRole::Apprentice { .. } => RoleTitle::Apprentice,
SetupRole::Elder { .. } => RoleTitle::Elder,
SetupRole::Werewolf => RoleTitle::Werewolf,
SetupRole::AlphaWolf => RoleTitle::AlphaWolf,
SetupRole::DireWolf => RoleTitle::DireWolf,
SetupRole::Shapeshifter => RoleTitle::Shapeshifter,
SetupRole::Adjudicator => RoleTitle::Adjudicator,
SetupRole::PowerSeer => RoleTitle::PowerSeer,
SetupRole::Mortician => RoleTitle::Mortician,
SetupRole::Beholder => RoleTitle::Beholder,
SetupRole::MasonLeader { .. } => RoleTitle::MasonLeader,
SetupRole::Empath => RoleTitle::Empath,
SetupRole::Vindicator => RoleTitle::Vindicator,
SetupRole::Diseased => RoleTitle::Diseased,
SetupRole::BlackKnight => RoleTitle::BlackKnight,
SetupRole::Weightlifter => RoleTitle::Weightlifter,
SetupRole::PyreMaster => RoleTitle::PyreMaster,
other => other
.into_role(&[])
.map(|r| r.title())
.unwrap_or(RoleTitle::Villager),
}
}
}
@ -324,6 +307,7 @@ impl From<SetupRole> for RoleTitle {
impl From<RoleTitle> for SetupRole {
fn from(value: RoleTitle) -> Self {
match value {
RoleTitle::Insomniac => SetupRole::Insomniac,
RoleTitle::LoneWolf => SetupRole::LoneWolf,
RoleTitle::Villager => SetupRole::Villager,
RoleTitle::Scapegoat => SetupRole::Scapegoat {

View File

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

View File

@ -8,7 +8,7 @@ use crate::{
message::{
CharacterState, Identification, PublicIdentity,
host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage},
night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult},
night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits},
},
player::PlayerId,
role::{Alignment, RoleTitle},
@ -63,6 +63,7 @@ pub trait ActionPromptTitleExt {
fn empath(&self);
fn adjudicator(&self);
fn lone_wolf(&self);
fn insomniac(&self);
}
impl ActionPromptTitleExt for ActionPromptTitle {
@ -135,12 +136,17 @@ impl ActionPromptTitleExt for ActionPromptTitle {
fn lone_wolf(&self) {
assert_eq!(*self, ActionPromptTitle::LoneWolfKill)
}
fn insomniac(&self) {
assert_eq!(*self, ActionPromptTitle::Insomniac)
}
}
pub trait ActionResultExt {
fn sleep(&self);
fn r#continue(&self);
fn seer(&self) -> Alignment;
fn insomniac(&self) -> Visits;
fn arcanist(&self) -> bool;
}
impl ActionResultExt for ActionResult {
@ -158,6 +164,20 @@ impl ActionResultExt for ActionResult {
_ => panic!("expected a seer result"),
}
}
fn arcanist(&self) -> bool {
match self {
ActionResult::Arcanist { same } => *same,
_ => panic!("expected an arcanist result"),
}
}
fn insomniac(&self) -> Visits {
match self {
ActionResult::Insomniac(v) => v.clone(),
_ => panic!("expected an insomniac result"),
}
}
}
pub trait AlignmentExt {
@ -304,7 +324,8 @@ impl GameExt for Game {
fn mark_and_check(&mut self, mark: CharacterId) {
let prompt = self.mark(mark);
match prompt {
ActionPrompt::MasonsWake { .. }
ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::CoverOfDarkness
| ActionPrompt::WolvesIntro { .. }

View File

@ -0,0 +1,76 @@
#[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, SettingsExt, gen_players,
},
message::night::{ActionPromptTitle, Visits},
role::{Role, RoleTitle},
};
#[test]
fn is_told_theyre_villager() {
assert_eq!(Role::Insomniac.initial_shown_role(), RoleTitle::Villager);
}
#[test]
fn sees_visits() {
let players = gen_players(1..21);
let insomniac_player_id = players[0].player_id;
let wolf_player_id = players[1].player_id;
let seer_player_id = players[2].player_id;
let arcanist_player_id = players[3].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Insomniac, insomniac_player_id);
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.add_and_assign(SetupRole::Seer, seer_player_id);
settings.add_and_assign(SetupRole::Arcanist, arcanist_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().title().seer();
game.mark(game.living_villager().character_id());
game.r#continue().seer().village();
game.next().title().arcanist();
let mut villagers = game.villager_character_ids().into_iter();
game.mark(villagers.next().unwrap());
game.mark(villagers.next().unwrap());
assert_eq!(game.r#continue().arcanist(), true);
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(
game.character_by_player_id(insomniac_player_id)
.character_id(),
);
game.r#continue().seer().village();
game.next().title().arcanist();
game.mark(game.character_by_player_id(seer_player_id).character_id());
game.mark(
game.character_by_player_id(insomniac_player_id)
.character_id(),
);
assert_eq!(game.r#continue().arcanist(), true);
game.next().title().insomniac();
assert_eq!(
game.r#continue().insomniac(),
Visits::new(Box::new([
game.character_by_player_id(seer_player_id).identity(),
game.character_by_player_id(arcanist_player_id).identity()
]))
);
}

View File

@ -45,12 +45,15 @@ fn recruits_decrement() {
assert_eq!(
game.next(),
ActionPrompt::MasonsWake {
character_id: game
leader: game
.character_by_player_id(mason_leader_player_id)
.character_id(),
masons: Box::new([
game.character_by_player_id(mason_leader_player_id)
.identity(),
masons: Box::new([game
.character_by_player_id(recruited.player_id())
.identity()])
game.character_by_player_id(recruited.player_id())
.identity(),
])
}
);
game.r#continue().sleep();
@ -97,6 +100,19 @@ fn dies_recruiting_wolf() {
game.mark(game.character_by_player_id(wolf_player_id).character_id());
game.r#continue().sleep();
assert_eq!(
game.next(),
ActionPrompt::MasonsWake {
leader: game
.character_by_player_id(mason_leader_player_id)
.character_id(),
masons: Box::new([game
.character_by_player_id(mason_leader_player_id)
.identity()])
}
);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
@ -111,3 +127,105 @@ fn dies_recruiting_wolf() {
}
// todo: masons wake even if leader dead
#[test]
fn masons_wake_even_if_leader_died() {
let players = gen_players(1..10);
let mason_leader_player_id = players[0].player_id;
let wolf_player_id = players[1].player_id;
let black_knight_player_id = players[2].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(
SetupRole::MasonLeader {
recruits_available: NonZeroU8::new(3).unwrap(),
},
mason_leader_player_id,
);
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.add_and_assign(SetupRole::BlackKnight, black_knight_player_id);
settings.fill_remaining_slots_with_villagers(9);
let mut game = Game::new(&players, settings).unwrap();
let blk_knight_cid = game
.character_by_player_id(black_knight_player_id)
.character_id();
game.r#continue().r#continue();
assert_eq!(game.next().title(), ActionPromptTitle::WolvesIntro);
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(game.execute().title(), ActionPromptTitle::WolfPackKill);
game.mark(blk_knight_cid);
game.r#continue().sleep();
assert_eq!(game.next().title(), ActionPromptTitle::MasonLeaderRecruit);
game.mark(blk_knight_cid);
game.r#continue().r#continue();
assert_eq!(
game.next(),
ActionPrompt::MasonsWake {
leader: game
.character_by_player_id(mason_leader_player_id)
.character_id(),
masons: Box::new([
game.character_by_player_id(mason_leader_player_id)
.identity(),
game.character_by_player_id(black_knight_player_id)
.identity()
])
}
);
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark(blk_knight_cid);
game.r#continue().sleep();
let second_recruit = game.living_villager();
assert_eq!(game.next().title(), ActionPromptTitle::MasonLeaderRecruit);
game.mark(second_recruit.character_id());
game.r#continue().r#continue();
assert_eq!(
game.next(),
ActionPrompt::MasonsWake {
leader: game
.character_by_player_id(mason_leader_player_id)
.character_id(),
masons: Box::new([
game.character_by_player_id(black_knight_player_id)
.identity(),
game.character_by_player_id(mason_leader_player_id)
.identity(),
second_recruit.identity()
])
}
);
game.r#continue().sleep();
game.next_expect_day();
game.mark_for_execution(
game.character_by_player_id(mason_leader_player_id)
.character_id(),
);
game.execute().title().wolf_pack_kill();
game.mark(blk_knight_cid);
game.r#continue().sleep();
assert_eq!(
game.next(),
ActionPrompt::MasonsWake {
leader: game
.character_by_player_id(mason_leader_player_id)
.character_id(),
masons: Box::new([
game.character_by_player_id(black_knight_player_id)
.identity(),
second_recruit.identity()
])
}
);
}

View File

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

View File

@ -1,4 +1,4 @@
use core::num::NonZeroU8;
use core::{num::NonZeroU8, ops::Deref};
use serde::{Deserialize, Serialize};
use werewolves_macros::{ChecksAs, Titles};
@ -27,6 +27,7 @@ pub enum ActionType {
Other,
MasonRecruit,
MasonsWake,
Insomniac,
Beholder,
RoleChange,
}
@ -136,7 +137,7 @@ pub enum ActionPrompt {
},
#[checks(ActionType::MasonsWake)]
MasonsWake {
character_id: CharacterIdentity,
leader: CharacterId,
masons: Box<[CharacterIdentity]>,
},
#[checks(ActionType::MasonRecruit)]
@ -190,12 +191,15 @@ pub enum ActionPrompt {
living_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>,
},
#[checks(ActionType::Insomniac)]
Insomniac { character_id: CharacterIdentity },
}
impl ActionPrompt {
pub(crate) fn matches_beholding(&self, target: CharacterId) -> bool {
match self {
ActionPrompt::Seer { character_id, .. }
ActionPrompt::Insomniac { character_id, .. }
| ActionPrompt::Seer { character_id, .. }
| ActionPrompt::Arcanist { character_id, .. }
| ActionPrompt::Gravedigger { character_id, .. }
| ActionPrompt::Adjudicator { character_id, .. }
@ -227,7 +231,8 @@ impl ActionPrompt {
pub(crate) fn with_mark(&self, mark: CharacterId) -> Result<ActionPrompt> {
let mut prompt = self.clone();
match &mut prompt {
ActionPrompt::MasonsWake { .. }
ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
@ -435,7 +440,25 @@ pub enum ActionResult {
Arcanist { same: bool },
GraveDigger(Option<RoleTitle>),
Mortician(DiedToTitle),
Insomniac(Visits),
Empath { scapegoat: bool },
GoBackToSleep,
Continue,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Visits(Box<[CharacterIdentity]>);
impl Visits {
pub(crate) const fn new(visits: Box<[CharacterIdentity]>) -> Self {
Self(visits)
}
}
impl Deref for Visits {
type Target = [CharacterIdentity];
fn deref(&self) -> &Self::Target {
&self.0
}
}

View File

@ -44,14 +44,12 @@ pub enum Role {
Beholder,
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
MasonLeader {
recruits_available: u8,
recruits: Box<[CharacterId]>,
},
#[checks(Alignment::Village)]
#[checks("powerful")]
#[checks("is_mentor")]
Empath { cursed: bool },
#[checks(Alignment::Village)]
#[checks("powerful")]
@ -115,6 +113,9 @@ pub enum Role {
woken_for_reveal: bool,
lost_protection_night: Option<NonZeroU8>,
},
#[checks(Alignment::Village)]
#[checks("powerful")]
Insomniac,
#[checks(Alignment::Wolves)]
#[checks("killer")]
@ -147,14 +148,15 @@ impl Role {
/// [RoleTitle] as shown to the player on role assignment
pub const fn initial_shown_role(&self) -> RoleTitle {
match self {
Role::Apprentice(_) | Role::Elder { .. } => RoleTitle::Villager,
Role::Apprentice(_) | Role::Elder { .. } | Role::Insomniac => RoleTitle::Villager,
_ => self.title(),
}
}
pub const fn wakes_night_zero(&self) -> bool {
match self {
Role::PowerSeer
Role::Insomniac
| Role::PowerSeer
| Role::Beholder
| Role::Adjudicator
| Role::DireWolf
@ -211,7 +213,8 @@ impl Role {
.map(|execs| execs.iter().any(|e| e.is_wolf()))
.unwrap_or_default(),
Role::PowerSeer
Role::Insomniac
| Role::PowerSeer
| Role::Mortician
| Role::Beholder
| Role::MasonLeader { .. }

View File

@ -228,7 +228,24 @@ impl GameRunner {
}
pub async fn next(&mut self) -> Option<GameOver> {
let msg = self.comms.host().recv().await.expect("host channel closed");
let msg = match self.comms.message().await {
Ok(Message::ConnectedList(_)) => return None,
Ok(Message::Client(IdentifiedClientMessage {
identity: Identification { player_id, .. },
..
})) => {
if let Some(send) = self.joined_players.get_sender(player_id).await {
send.send(ServerMessage::GameInProgress).log_debug();
}
return None;
}
Ok(Message::Host(msg)) => msg,
Err(err) => {
log::error!("game next message: {err}");
return None;
}
};
match self.host_message(msg) {
Ok(resp) => {
self.comms.host().send(resp).log_warn();

View File

@ -67,6 +67,19 @@ body {
background: black;
}
.big-screen {
align-content: center;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
position: fixed;
left: 0;
top: 0;
margin: 0;
font-size: 2rem;
}
$link_color: #432054;
$link_hover_color: hsl(280, 55%, 61%);
$link_bg_color: #fff6d5;
@ -936,6 +949,11 @@ input {
}
&.dead {
filter: saturate(0%);
border: 1px solid rgba(255, 255, 255, 0.05);
}
&.recent-death {
$bg: rgba(128, 128, 128, 0.5);
background-color: $bg;
border: 1px solid color.change($bg, $alpha: 1.0);
@ -955,14 +973,6 @@ input {
}
}
.big-screen {
align-content: center;
align-items: center;
justify-content: center;
height: 100vh;
position: fixed;
}
.align-start {
align-self: flex-start;
}
@ -994,6 +1004,18 @@ input {
color: white;
cursor: pointer;
}
&>.submenu {
min-width: 5cm;
.assign-list {
min-width: 5cm;
& .submenu button {
width: inherit;
}
}
}
}
.add-role {
@ -1105,6 +1127,7 @@ input {
position: fixed;
left: 10%;
top: 10%;
font-size: 1rem;
.setup {
display: flex;
@ -1122,6 +1145,10 @@ input {
display: flex;
flex-direction: column;
&.final {
margin-top: 1cm;
}
& .title {
margin-bottom: 10px;
}
@ -1162,10 +1189,6 @@ input {
filter: contrast(120%) brightness(120%);
}
}
.inactive {
filter: grayscale(100%) brightness(30%);
}
}
.role {
@ -1183,10 +1206,15 @@ input {
}
}
.inactive {
filter: grayscale(100%) brightness(30%);
}
.qrcode {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
z-index: 100;
position: fixed;
top: 0;
left: 0;
@ -1196,7 +1224,7 @@ input {
gap: 1cm;
img {
height: 100%;
height: 70%;
width: 100%;
}
@ -1205,5 +1233,74 @@ input {
// width: 100%;
border: 1px solid $village_border;
background-color: color.change($village_color, $alpha: 0.3);
text-align: center;
// width: fit-content;
&>* {
margin-top: 0.5cm;
margin-bottom: 0.5cm;
// padding: 0;
}
}
}
.result {
display: flex;
justify-content: center;
align-content: center;
align-items: center;
flex-direction: column;
width: 100%;
height: 100%;
}
.result-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
justify-content: space-evenly;
row-gap: 0.5cm;
.identity {
padding: 1cm;
border: 1px solid white;
font-size: 2em;
text-align: center;
}
}
.check-icon {
width: 40vw;
height: 40vh;
// margin-top: 10%;
align-self: center;
}
.insomniac {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
justify-content: center;
align-items: center;
&.prompt {
font-size: 2em;
}
}
.arcanist-result {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
gap: 10px;
img {
// flex-shrink: 1 !important;
width: max-content !important;
height: max-content !important;
}
}

View File

@ -550,10 +550,8 @@ impl Component for Host {
}
HostEvent::SetBigScreenState(state) => {
self.big_screen = state;
if self.big_screen
&& let Ok(Some(root)) = gloo::utils::document().query_selector(".content")
{
root.set_class_name("content big-screen")
if self.big_screen {
gloo::utils::document_element().set_class_name("big-screen")
}
if state {

View File

@ -88,6 +88,15 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
</div>
};
}
ActionPrompt::Insomniac { character_id } => {
return html! {
<div class="insomniac prompt">
{identity_html(props, Some(character_id))}
<h2>{"you are the insomniac"}</h2>
{cont}
</div>
};
}
ActionPrompt::RoleChange {
character_id,
new_role,
@ -102,22 +111,19 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
};
}
ActionPrompt::MasonsWake {
character_id,
masons,
} => {
ActionPrompt::MasonsWake { leader, masons } => {
let masons = masons
.into_iter()
.map(|c| {
let leader = (c.character_id == *leader).then_some("leader");
let ident: PublicIdentity = c.into();
html! {
<Identity ident={ident}/>
<Identity class={classes!(leader)} ident={ident}/>
}
})
.collect::<Html>();
return html! {
<div class="masons">
{identity_html(props, Some(character_id))}
<h2>{"these are the masons"}</h2>
<div class="mason-list">
{masons}

View File

@ -11,7 +11,7 @@ use werewolves_proto::{
};
use yew::prelude::*;
use crate::components::{Button, CoverOfDarkness, Identity};
use crate::components::{Button, CoverOfDarkness, Icon, IconSource, Identity};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct ActionResultProps {
@ -56,12 +56,16 @@ pub fn ActionResultView(props: &ActionResultProps) -> Html {
}
}
ActionResult::Adjudicator { killer } => {
let inactive = killer.not().then_some("inactive");
let text = if *killer { "killer" } else { "not a killer" };
let text = if *killer {
"is a killer"
} else {
"is NOT a killer"
};
html! {
<>
<img src="/img/killer.svg" class={classes!(inactive)}/>
<h3>{text}</h3>
<h1>{"your target..."}</h1>
<Icon source={IconSource::Killer} inactive={!*killer}/>
<h2>{text}</h2>
</>
}
}
@ -77,6 +81,25 @@ pub fn ActionResultView(props: &ActionResultProps) -> Html {
ActionResult::Empath { scapegoat: false } => html! {
<h2>{"not the scapegoat"}</h2>
},
ActionResult::Insomniac(visits) => {
let visits = visits
.iter()
.map(|v| {
let ident: PublicIdentity = v.clone().into();
html! {
<Identity ident={ident}/>
}
})
.collect::<Html>();
html! {
<>
<h1>{"tonight you were visited by..."}</h1>
<div class="result-list">
{visits}
</div>
</>
}
}
ActionResult::RoleBlocked => {
html! {
<h2>{"you were role blocked"}</h2>
@ -85,17 +108,49 @@ pub fn ActionResultView(props: &ActionResultProps) -> Html {
ActionResult::Seer(alignment) => html! {
<>
<h2>{"the alignment was"}</h2>
<p>{match alignment {
Alignment::Village => "village",
Alignment::Wolves => "wolfpack",
}}</p>
{match alignment {
Alignment::Village => html!{
<>
<Icon source={IconSource::Village}/>
<h3>{"village"}</h3>
</>
},
Alignment::Wolves => html!{
<>
<Icon source={IconSource::Wolves}/>
<h3>{"wolves"}</h3>
</>
},
}}
</>
},
ActionResult::Arcanist { same } => {
let outcome = if *same { "same" } else { "different" };
let outcome = if *same {
html! {
<>
<h2>{"the alignments are:"}</h2>
<div class="arcanist-result">
<Icon source={IconSource::Village}/>
<Icon source={IconSource::Village}/>
<Icon source={IconSource::Wolves}/>
<Icon source={IconSource::Wolves}/>
</div>
<h2>{"the same"}</h2>
</>
}
} else {
html! {
<>
<div class="arcanist-result">
<Icon source={IconSource::Village}/>
<Icon source={IconSource::Wolves}/>
</div>
<h2>{"different"}</h2>
</>
}
};
html! {
<>
<h1>{"the alignments are:"}</h1>
<p>{outcome}</p>
</>
}

View File

@ -2,6 +2,7 @@ use core::{num::NonZeroU8, ops::Not};
use werewolves_proto::{
character::CharacterId,
game::DateTime,
message::{CharacterState, PublicIdentity},
};
use yew::prelude::*;
@ -35,10 +36,25 @@ pub fn DaytimePlayerList(
let chars = characters
.iter()
.map(|c| {
let mark_state = match c.died_to.as_ref() {
None => marked
.contains(&c.identity.character_id)
.then_some(MarkState::Marked),
Some(died_to) => match died_to.date_time() {
DateTime::Day { .. } => Some(MarkState::Dead),
DateTime::Night { number } => {
if number == day.get() - 1 {
Some(MarkState::DiedLastNight)
} else {
Some(MarkState::Dead)
}
}
},
};
html! {
<DaytimePlayer
character={c.clone()}
on_the_block={marked.contains(&c.identity.character_id)}
mark_state={mark_state}
on_select={on_select.clone()}
/>
}
@ -66,11 +82,28 @@ pub fn DaytimePlayerList(
</div>
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MarkState {
Marked,
DiedLastNight,
Dead,
}
impl MarkState {
pub const fn class(&self) -> &'static str {
match self {
MarkState::Marked => "marked",
MarkState::DiedLastNight => "recent-death",
MarkState::Dead => "dead",
}
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct DaytimePlayerProps {
pub character: CharacterState,
pub on_the_block: bool,
#[prop_or_default]
pub mark_state: Option<MarkState>,
pub on_select: Option<Callback<CharacterId>>,
}
@ -78,7 +111,7 @@ pub struct DaytimePlayerProps {
pub fn DaytimePlayer(
DaytimePlayerProps {
on_select,
on_the_block,
mark_state,
character:
CharacterState {
player_id: _,
@ -88,8 +121,7 @@ pub fn DaytimePlayer(
},
}: &DaytimePlayerProps,
) -> Html {
let dead = died_to.is_some().then_some("dead");
let marked = on_the_block.then_some("marked");
let class = mark_state.as_ref().map(|s| s.class());
let character_id = identity.character_id;
let on_click: Callback<_> = died_to
.is_none()
@ -102,7 +134,7 @@ pub fn DaytimePlayer(
.unwrap_or_default();
let identity: PublicIdentity = identity.into();
html! {
<Button on_click={on_click} classes={classes!(marked, dead, "character")}>
<Button on_click={on_click} classes={classes!(class, "character")}>
<Identity ident={identity}/>
</Button>
}

View File

@ -67,7 +67,7 @@ pub fn Setup(SetupProps { settings }: &SetupProps) -> Html {
<div class="setup">
{categories}
</div>
<div class="category village">
<div class="category village final">
<span class="count">{power_roles_count}</span>
<span class="title">{"Power roles from..."}</span>
</div>

View File

@ -0,0 +1,67 @@
use yew::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum IconSource {
Village,
Wolves,
Killer,
Powerful,
}
impl IconSource {
pub const fn source(&self) -> &'static str {
match self {
IconSource::Village => "/img/village.svg",
IconSource::Wolves => "/img/wolf.svg",
IconSource::Killer => "/img/killer.svg",
IconSource::Powerful => "/img/powerful.svg",
}
}
pub const fn class(&self) -> Option<&'static str> {
match self {
IconSource::Village | IconSource::Wolves => None,
IconSource::Killer => Some("killer"),
IconSource::Powerful => Some("powerful"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum IconType {
SetupList,
#[default]
RoleCheck,
}
impl IconType {
pub const fn class(&self) -> &'static str {
match self {
IconType::SetupList => "icon",
IconType::RoleCheck => "check-icon",
}
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct IconProps {
pub source: IconSource,
#[prop_or_default]
pub inactive: bool,
#[prop_or_default]
pub icon_type: IconType,
}
#[function_component]
pub fn Icon(
IconProps {
source,
inactive,
icon_type,
}: &IconProps,
) -> Html {
html! {
<img
src={source.source()}
class={classes!(source.class(), icon_type.class(), inactive.then_some("inactive"))}
/>
}
}

View File

@ -5,7 +5,7 @@ use yew::prelude::*;
pub struct IdentityProps {
pub ident: PublicIdentity,
#[prop_or_default]
pub class: Option<String>,
pub class: Classes,
}
#[function_component]
@ -29,7 +29,7 @@ pub fn Identity(props: &IdentityProps) -> Html {
.map(|n| n.to_string())
.unwrap_or_else(|| String::from("???"));
html! {
<div class={classes!("identity", class)}>
<div class={classes!("identity", class.clone())}>
<p class={classes!("number", not_set)}><b>{number}</b></p>
<p>{name}</p>
{pronouns}

View File

@ -47,6 +47,8 @@ fn main() {
let error_callback =
Callback::from(move |err: Option<WerewolfError>| cb_clone.send_message(err));
gloo::utils::document().set_title("werewolves");
if path.starts_with("/host") {
let host = yew::Renderer::<Host>::with_root(app_element).render();
host.send_message(HostEvent::SetErrorCallback(error_callback));