beholder: now wakes twice in the night

once to pick their target, another time to see what they saw (if any)
also fixes host screen (including /big) not responding to some lobby
changes
This commit is contained in:
emilis 2025-12-11 18:14:35 +00:00
parent 6dd78aa1b5
commit 01c61c143e
No known key found for this signature in database
22 changed files with 405 additions and 215 deletions

View File

@ -589,11 +589,16 @@ impl Character {
});
}
}
Role::Beholder => prompts.push(ActionPrompt::Beholder {
Role::Beholder => {
prompts.push(ActionPrompt::BeholderChooses {
character_id: self.identity(),
living_players: village.living_players_excluding(self.character_id()),
marked: None,
}),
});
prompts.push(ActionPrompt::BeholderWakes {
character_id: self.identity(),
})
}
Role::MasonLeader { .. } => {
log::error!(
"night_action_prompts got to MasonLeader, should be handled before the living check"

View File

@ -99,8 +99,8 @@ pub enum GameError {
NoPreviousDuringDay,
#[error("militia already spent")]
MilitiaSpent,
#[error("this role doesn't mark anyone")]
RoleDoesntMark,
#[error("this prompt doesn't mark anyone")]
PromptDoesntMark,
#[error("cannot shapeshift on a non-shapeshifter prompt")]
ShapeshiftingIsForShapeshifters,
#[error("must select a target")]

View File

@ -32,7 +32,9 @@ use crate::{
kill::{self, KillOutcome},
night::changes::{ChangesLookup, NightChange},
},
message::night::{ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, Visits},
message::night::{
ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits,
},
role::RoleTitle,
};
@ -62,7 +64,8 @@ impl From<ActionComplete> for ResponseOutcome {
impl ActionPrompt {
fn unless(&self) -> Option<Unless> {
match &self {
ActionPrompt::TraitorIntro { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::WolvesIntro { .. }
@ -124,7 +127,7 @@ impl ActionPrompt {
marked: Some(marked),
..
}
| ActionPrompt::Beholder {
| ActionPrompt::BeholderChooses {
marked: Some(marked),
..
}
@ -169,7 +172,7 @@ impl ActionPrompt {
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. }
| ActionPrompt::Beholder { marked: None, .. }
| ActionPrompt::BeholderChooses { marked: None, .. }
| ActionPrompt::MasonLeaderRecruit { marked: None, .. }
| ActionPrompt::Empath { marked: None, .. }
| ActionPrompt::Vindicator { marked: None, .. }
@ -1003,10 +1006,13 @@ impl Night {
if is_shifted {
return None;
}
self.action_queue
.iter()
.next()
.map(|n| n.character_id() == Some(curr))
self.action_queue.iter().next().map(|n| match n {
ActionPrompt::BeholderWakes { character_id } => !matches!(
self.get_what_beholder_saw(character_id.character_id),
None | Some(ActionResult::GoBackToSleep)
),
_ => n.character_id() == Some(curr),
})
})
.unwrap_or_default();
@ -1175,6 +1181,25 @@ impl Night {
}
}
fn get_what_beholder_saw(&self, beholder_id: CharacterId) -> Option<ActionResult> {
self.get_actions_for(beholder_id)
.into_iter()
.find_map(|(p, _)| match p {
ActionPrompt::BeholderChooses { marked, .. } => marked,
_ => None,
})
.and_then(|beholder_target| {
self.died_to_tonight(beholder_target)
.ok()
.flatten()
.and_then(|_| {
self.get_actions_for(beholder_target)
.into_iter()
.find_map(|(prompt, result)| prompt.is_beholdable().then_some(result))
})
})
}
pub const fn village(&self) -> &Village {
&self.village
}
@ -1265,6 +1290,43 @@ impl Night {
ChangesLookup::new(&self.current_changes()).died_to(character_id, self.night, &self.village)
}
fn get_actions_for(&self, char_id: CharacterId) -> Box<[(ActionPrompt, ActionResult)]> {
self.used_actions()
.into_iter()
.chain(match &self.night_state {
NightState::Active {
current_prompt,
current_result,
..
} => match current_result {
CurrentResult::None => None,
CurrentResult::GoBackToSleepAfterShown {
result_with_data: action_result,
}
| CurrentResult::Result(action_result) => {
Some((current_prompt.clone(), action_result.clone()))
}
},
NightState::Complete => None,
})
.filter(|(p, _)| {
p.character_id()
.map(|cid| cid == char_id)
.unwrap_or_default()
})
.collect()
}
fn beholder_picked(&self) -> Box<[CharacterId]> {
self.used_actions
.iter()
.filter_map(|(p, _, _)| match p {
ActionPrompt::BeholderChooses { marked, .. } => *marked,
_ => None,
})
.collect()
}
fn currently_dying(&self) -> Box<[DiedTo]> {
let ch = self.current_changes();
let changes: ChangesLookup<'_> = ChangesLookup::new(&ch);
@ -1390,7 +1452,7 @@ impl Night {
marked: Some(marked),
..
}
| ActionPrompt::Beholder {
| ActionPrompt::BeholderChooses {
character_id,
marked: Some(marked),
..
@ -1431,7 +1493,8 @@ impl Night {
..
} => (*marked == visit_char).then(|| character_id.clone()),
ActionPrompt::TraitorIntro { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::WolfPackKill { marked: None, .. }
| ActionPrompt::Arcanist { marked: _, .. }
@ -1446,7 +1509,7 @@ impl Night {
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. }
| ActionPrompt::Beholder { marked: None, .. }
| ActionPrompt::BeholderChooses { marked: None, .. }
| ActionPrompt::MasonLeaderRecruit { marked: None, .. }
| ActionPrompt::Empath { marked: None, .. }
| ActionPrompt::Vindicator { marked: None, .. }

View File

@ -87,6 +87,24 @@ impl Night {
}
loop {
if let Some(prompt) = self.pull_next_prompt_with_dead_ignore()? {
if let ActionPrompt::BeholderWakes { character_id } = &prompt
&& let Some((target, _)) = self
.get_actions_for(character_id.character_id)
.into_iter()
.find_map(|(prompt, _)| prompt.marked())
{
let has_beholdable_actions = self
.get_actions_for(target)
.into_iter()
.any(|(p, _)| p.is_beholdable());
let died = self.died_to_tonight(target)?.is_some();
if !died || !has_beholdable_actions {
log::debug!(
"skipping beholder wake as their target did not die or wasn't chosen"
);
continue;
}
}
if let ActionPrompt::Insomniac { character_id } = &prompt
&& self.get_visits_for(character_id.character_id).is_empty()
{
@ -237,11 +255,12 @@ impl Night {
}
fn pull_next_prompt_with_dead_ignore(&mut self) -> Result<Option<ActionPrompt>> {
let has_living_beholder = self
let beholder_picked = self.beholder_picked();
let has_insomniac = self
.village
.characters()
.into_iter()
.any(|c| matches!(c.role_title(), RoleTitle::Beholder | RoleTitle::Insomniac));
.any(|c| matches!(c.role_title(), RoleTitle::Insomniac));
while let Some(prompt) = self.action_queue.pop_front() {
if !matches!(
prompt.action_type(),
@ -252,7 +271,10 @@ impl Night {
let Some(char_id) = prompt.character_id() else {
return Ok(Some(prompt));
};
match (self.died_to_tonight(char_id)?, has_living_beholder) {
match (
self.died_to_tonight(char_id)?,
beholder_picked.contains(&char_id) || has_insomniac,
) {
(Some(_), false) => {}
(Some(DiedTo::Shapeshift { .. }), _) | (Some(_), true) | (None, _) => {
return Ok(Some(prompt));

View File

@ -22,7 +22,10 @@ use crate::{
game::night::{
ActionComplete, CurrentResult, Night, NightState, ResponseOutcome, changes::NightChange,
},
message::night::{ActionPrompt, ActionResponse, ActionResult},
message::{
CharacterIdentity,
night::{ActionPrompt, ActionResponse, ActionResult, ActionType},
},
player::Protection,
role::{
Alignment, AlignmentEq, Killer, Powerful, PreviousGuardianAction, RoleBlock, RoleTitle,
@ -116,6 +119,21 @@ impl Night {
};
match current_prompt {
ActionPrompt::BeholderWakes { character_id } => Ok(ActionComplete {
result: self
.get_what_beholder_saw(character_id.character_id)
.map(|saw| {
if matches!(saw, ActionResult::RoleBlocked | ActionResult::Drunk) {
ActionResult::BeholderSawNothing
} else {
saw
}
})
.unwrap_or(ActionResult::BeholderSawNothing),
change: None,
secondary_changes: vec![],
}
.into()),
ActionPrompt::TraitorIntro { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: None,
@ -437,34 +455,14 @@ impl Night {
secondary_changes: vec![],
}
.into()),
ActionPrompt::Beholder {
marked: Some(marked),
..
} => {
if let Some(result) = self.used_actions.iter().find_map(|(prompt, result, _)| {
prompt.matches_beholding(*marked).then_some(result)
}) && self.died_to_tonight(*marked)?.is_some()
{
Ok(ActionComplete {
result: if matches!(result, ActionResult::RoleBlocked | ActionResult::Drunk)
{
ActionResult::BeholderSawNothing
} else {
result.clone()
},
change: None,
secondary_changes: vec![],
}
.into())
} else {
Ok(ActionComplete {
ActionPrompt::BeholderChooses {
marked: Some(_), ..
} => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: None,
secondary_changes: vec![],
}
.into())
}
}
.into()),
ActionPrompt::MasonsWake { .. } => Ok(ActionComplete {
result: ActionResult::GoBackToSleep,
change: None,
@ -582,7 +580,7 @@ impl Night {
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. }
| ActionPrompt::Beholder { marked: None, .. }
| ActionPrompt::BeholderChooses { marked: None, .. }
| ActionPrompt::Empath { marked: None, .. }
| ActionPrompt::Vindicator { marked: None, .. }
| ActionPrompt::Protector { marked: None, .. }

View File

@ -214,6 +214,7 @@ pub enum StoryActionPrompt {
impl StoryActionPrompt {
pub fn new(prompt: ActionPrompt) -> Option<Self> {
Some(match prompt {
ActionPrompt::BeholderWakes { .. } => return None, // TODO: rework story anyway
ActionPrompt::Bloodletter {
character_id,
marked: Some(marked),
@ -317,7 +318,7 @@ impl StoryActionPrompt {
character_id: character_id.character_id,
chosen: marked,
},
ActionPrompt::Beholder {
ActionPrompt::BeholderChooses {
character_id,
marked: Some(marked),
..
@ -407,7 +408,7 @@ impl StoryActionPrompt {
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::Beholder { .. }
| ActionPrompt::BeholderChooses { .. }
| ActionPrompt::MasonLeaderRecruit { .. }
| ActionPrompt::Empath { .. }
| ActionPrompt::Vindicator { .. }

View File

@ -77,7 +77,8 @@ pub trait ActionPromptTitleExt {
fn direwolf(&self);
fn masons_wake(&self);
fn masons_leader_recruit(&self);
fn beholder(&self);
fn beholder_chooses(&self);
fn beholder_wakes(&self);
fn vindicator(&self);
fn pyremaster(&self);
fn empath(&self);
@ -151,8 +152,11 @@ impl ActionPromptTitleExt for ActionPromptTitle {
fn masons_leader_recruit(&self) {
assert_eq!(*self, ActionPromptTitle::MasonLeaderRecruit)
}
fn beholder(&self) {
assert_eq!(*self, ActionPromptTitle::Beholder)
fn beholder_chooses(&self) {
assert_eq!(*self, ActionPromptTitle::BeholderChooses)
}
fn beholder_wakes(&self) {
assert_eq!(*self, ActionPromptTitle::BeholderWakes)
}
fn vindicator(&self) {
assert_eq!(*self, ActionPromptTitle::Vindicator)
@ -414,7 +418,8 @@ impl GameExt for Game {
fn mark_and_check(&mut self, mark: CharacterId) {
let prompt = self.mark(mark);
match prompt {
ActionPrompt::TraitorIntro { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. }
@ -448,7 +453,7 @@ impl GameExt for Game {
marked: Some(marked),
..
}
| ActionPrompt::Beholder {
| ActionPrompt::BeholderChooses {
marked: Some(marked),
..
}
@ -509,7 +514,7 @@ impl GameExt for Game {
| ActionPrompt::Adjudicator { marked: None, .. }
| ActionPrompt::PowerSeer { marked: None, .. }
| ActionPrompt::Mortician { marked: None, .. }
| ActionPrompt::Beholder { marked: None, .. }
| ActionPrompt::BeholderChooses { marked: None, .. }
| ActionPrompt::MasonLeaderRecruit { marked: None, .. }
| ActionPrompt::Empath { marked: None, .. }
| ActionPrompt::Vindicator { marked: None, .. }
@ -960,6 +965,10 @@ fn big_game_test_based_on_story_test() {
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(power_seer).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(werewolf).character_id());
game.r#continue().seer();
@ -1000,10 +1009,6 @@ fn big_game_test_based_on_story_test() {
game.r#continue().insomniac();
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(power_seer).character_id());
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
@ -1040,6 +1045,10 @@ fn big_game_test_based_on_story_test() {
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(power_seer).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(werewolf).character_id());
game.r#continue().seer();
@ -1080,10 +1089,6 @@ fn big_game_test_based_on_story_test() {
game.r#continue().insomniac();
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(power_seer).character_id());
game.r#continue().sleep();
game.next_expect_day();
game.mark_for_execution(
game.living_villager_excl(protect.player_id())
@ -1121,6 +1126,10 @@ fn big_game_test_based_on_story_test() {
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(gravedigger).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(shapeshifter).character_id());
game.r#continue().seer();
@ -1156,10 +1165,6 @@ fn big_game_test_based_on_story_test() {
game.r#continue().insomniac();
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(gravedigger).character_id());
game.r#continue().sleep();
game.next_expect_day();
game.mark_for_execution(game.character_by_player_id(vindicator).character_id());
game.execute().title().wolf_pack_kill();
@ -1174,6 +1179,10 @@ fn big_game_test_based_on_story_test() {
game.mark(game.character_by_player_id(empath).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(mortician).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().seer();
@ -1212,8 +1221,7 @@ fn big_game_test_based_on_story_test() {
game.r#continue().insomniac();
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(mortician).character_id());
game.next().title().beholder_wakes();
assert_eq!(
game.r#continue().mortician(),
DiedToTitle::GuardianProtecting

View File

@ -372,6 +372,10 @@ fn previous_prompt() {
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(power_seer).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(werewolf).character_id());
game.r#continue().seer();
@ -412,9 +416,5 @@ fn previous_prompt() {
game.r#continue().insomniac();
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(power_seer).character_id());
game.r#continue().sleep();
game.next_expect_day();
}

View File

@ -50,7 +50,7 @@ fn beholder_appropriate_prompt_position() {
game.mark(game.character_by_player_id(beholder).character_id());
game.r#continue().sleep();
game.next().title().beholder();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(wolf_player_id).character_id());
game.r#continue().sleep();
@ -67,5 +67,5 @@ fn beholder_appropriate_prompt_position() {
}
);
game.r#continue().r#continue();
game.next().title().beholder();
game.next().title().beholder_chooses();
}

View File

@ -51,33 +51,85 @@ fn beholding_seer() {
game.mark(game.living_villager().character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(seer_player_id).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(wolf_player_id).character_id());
game.r#continue().seer().wolves();
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(seer_player_id).character_id());
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark(game.character_by_player_id(seer_player_id).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(seer_player_id).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(wolf_player_id).character_id());
assert_eq!(game.r#continue().seer(), Alignment::Wolves);
game.r#continue().sleep();
game.next().title().beholder();
game.mark(game.character_by_player_id(seer_player_id).character_id());
game.next().title().beholder_wakes();
assert_eq!(game.r#continue().seer(), Alignment::Wolves);
game.r#continue().sleep();
game.next_expect_day();
}
#[test]
#[allow(non_snake_case)]
fn beholding_seer_but_arcanist_dies__arcanist_shouldnt_wake() {
init_log();
let players = gen_players(1..21);
let mut player_ids = players.iter().map(|p| p.player_id);
let seer = player_ids.next().unwrap();
let wolf = player_ids.next().unwrap();
let beholder = player_ids.next().unwrap();
let arcanist = player_ids.next().unwrap();
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Seer, seer);
settings.add_and_assign(SetupRole::Werewolf, wolf);
settings.add_and_assign(SetupRole::Beholder, beholder);
settings.add_and_assign(SetupRole::Arcanist, arcanist);
settings.fill_remaining_slots_with_villagers(players.len());
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.character_by_player_id(wolf).character_id());
game.r#continue().seer().wolves();
game.r#continue().sleep();
game.next().title().arcanist();
game.mark(game.character_by_player_id(seer).character_id());
game.mark_villager();
game.r#continue().arcanist();
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark(game.character_by_player_id(arcanist).character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(seer).character_id());
game.r#continue().sleep();
game.next().title().seer();
game.mark(game.character_by_player_id(wolf).character_id());
game.r#continue().seer().wolves();
game.r#continue().sleep();
game.next_expect_day();
}
#[test]
fn beholding_wolf() {
let players = gen_players(1..10);
@ -97,9 +149,36 @@ fn beholding_wolf() {
game.mark(game.living_villager_excl(beholder_player_id).character_id());
game.r#continue().sleep();
game.next().title().beholder();
game.next().title().beholder_chooses();
game.mark(game.character_by_player_id(wolf_player_id).character_id());
game.r#continue().sleep();
game.next_expect_day();
}
#[test]
fn beholding_villager() {
let players = gen_players(1..10);
let wolf_player_id = players[1].player_id;
let beholder_player_id = players[2].player_id;
let mut settings = GameSettings::empty();
settings.add_and_assign(SetupRole::Werewolf, wolf_player_id);
settings.add_and_assign(SetupRole::Beholder, beholder_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();
let target = game.living_villager();
game.mark(target.character_id());
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark(target.character_id());
game.r#continue().sleep();
game.next_expect_day();
}

View File

@ -20,7 +20,7 @@ use crate::{
diedto::DiedTo,
game::{Game, GameSettings, OrRandom, SetupRole},
game_test::{ActionPromptTitleExt, ActionResultExt, GameExt, SettingsExt, gen_players},
message::night::{ActionPrompt, ActionPromptTitle},
message::night::{ActionPrompt, ActionPromptTitle, ActionResult},
};
#[test]
@ -278,6 +278,10 @@ fn masons_get_go_back_to_sleep() {
game.mark_villager();
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark_villager();
game.r#continue().sleep();
game.next().title().masons_leader_recruit();
game.mark(game.character_by_player_id(scapegoat).character_id());
game.r#continue().r#continue();
@ -285,21 +289,17 @@ fn masons_get_go_back_to_sleep() {
game.next().title().masons_wake();
game.r#continue().sleep();
game.next().title().beholder();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.mark_villager();
game.r#continue().sleep();
game.next_expect_day();
game.execute().title().wolf_pack_kill();
game.next().title().beholder_chooses();
game.mark_villager();
game.r#continue().sleep();
game.next().title().masons_wake();
game.r#continue().sleep();
game.next().title().beholder();
game.mark_villager();
game.r#continue().sleep();
game.next_expect_day();
}

View File

@ -381,7 +381,7 @@ fn shapeshift_removes_village_prompt_but_previous_can_bring_it_back() {
);
game.r#continue().sleep();
game.next().title().beholder();
game.next().title().beholder_chooses();
assert_eq!(
game.prev(),
@ -411,6 +411,18 @@ fn shapeshift_removes_village_prompt_but_previous_can_bring_it_back() {
ChangesLookup::new(&current_changes).shapeshift_change(),
None
);
assert!(matches!(
game.prev(),
ServerToHostMessage::ActionPrompt(ActionPrompt::WolfPackKill { .. }, _)
));
game.mark_villager();
game.r#continue().r#continue();
game.next().title().shapeshifter();
game.r#continue().sleep();
game.next().title().beholder_chooses();
game.mark_villager();
game.r#continue().sleep();
game.next().title().gravedigger();
@ -418,10 +430,6 @@ fn shapeshift_removes_village_prompt_but_previous_can_bring_it_back() {
assert_eq!(game.r#continue().gravedigger(), Some(RoleTitle::Villager));
game.r#continue().sleep();
game.next().title().beholder();
game.mark_villager();
game.r#continue().sleep();
game.next_expect_day();
assert_eq!(
game.character_by_player_id(gravedigger).role_title(),

View File

@ -80,7 +80,6 @@ pub enum HostLobbyMessage {
ManufacturePlayer(PublicIdentity),
Kick(PlayerId),
SetPlayerNumber(PlayerId, NonZeroU8),
GetGameSettings,
SetGameSettings(GameSettings),
SetQrMode(bool),
Start,
@ -98,9 +97,11 @@ pub enum ServerToHostMessage {
PlayerStates(Box<[CharacterState]>),
ActionPrompt(ActionPrompt, usize),
ActionResult(Option<CharacterIdentity>, ActionResult),
Lobby(Box<[PlayerState]>),
Lobby {
players: Box<[PlayerState]>,
settings: GameSettings,
},
QrMode(bool),
GameSettings(GameSettings),
Error(GameError),
GameOver(GameOver),
WaitingForRoleRevealAcks {

View File

@ -48,12 +48,12 @@ pub enum ActionType {
LoneWolfKill,
Block,
VillageKill,
BeholderChooses,
Intel,
Other,
MasonRecruit,
MasonsWake,
Insomniac,
Beholder,
BeholderWakes,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ChecksAs, Titles, Extract)]
@ -140,8 +140,8 @@ pub enum ActionPrompt {
dead_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>,
},
#[checks(ActionType::Beholder)]
Beholder {
#[checks(ActionType::BeholderChooses)]
BeholderChooses {
character_id: CharacterIdentity,
living_players: Box<[CharacterIdentity]>,
marked: Option<CharacterId>,
@ -212,9 +212,46 @@ pub enum ActionPrompt {
},
#[checks(ActionType::TraitorIntro)]
TraitorIntro { character_id: CharacterIdentity },
#[checks(ActionType::BeholderWakes)]
BeholderWakes { character_id: CharacterIdentity },
}
impl ActionPrompt {
pub const fn is_beholdable(&self) -> bool {
match self {
ActionPrompt::BeholderChooses { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::Protector { .. }
| ActionPrompt::Hunter { .. }
| ActionPrompt::Militia { .. }
| ActionPrompt::MapleWolf { .. }
| ActionPrompt::Guardian { .. }
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::MasonLeaderRecruit { .. }
| ActionPrompt::Vindicator { .. }
| ActionPrompt::PyreMaster { .. }
| ActionPrompt::WolfPackKill { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::AlphaWolf { .. }
| ActionPrompt::DireWolf { .. }
| ActionPrompt::LoneWolfKill { .. }
| ActionPrompt::Bloodletter { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::CoverOfDarkness => false,
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::Seer { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::Empath { .. }
| ActionPrompt::Insomniac { .. } => true,
}
}
pub(crate) const fn marked(&self) -> Option<(CharacterId, Option<CharacterId>)> {
match self {
ActionPrompt::Seer { marked, .. }
@ -227,7 +264,7 @@ impl ActionPrompt {
| ActionPrompt::Adjudicator { marked, .. }
| ActionPrompt::PowerSeer { marked, .. }
| ActionPrompt::Mortician { marked, .. }
| ActionPrompt::Beholder { marked, .. }
| ActionPrompt::BeholderChooses { marked, .. }
| ActionPrompt::MasonLeaderRecruit { marked, .. }
| ActionPrompt::Empath { marked, .. }
| ActionPrompt::Vindicator { marked, .. }
@ -257,6 +294,7 @@ impl ActionPrompt {
marked: (None, None),
..
}
| ActionPrompt::BeholderWakes { .. }
| ActionPrompt::CoverOfDarkness
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
@ -269,7 +307,8 @@ impl ActionPrompt {
}
pub(crate) const fn character_id(&self) -> Option<CharacterId> {
match self {
ActionPrompt::TraitorIntro { character_id }
ActionPrompt::BeholderWakes { character_id }
| ActionPrompt::TraitorIntro { character_id }
| ActionPrompt::Insomniac { character_id, .. }
| ActionPrompt::LoneWolfKill { character_id, .. }
| ActionPrompt::ElderReveal { character_id }
@ -287,7 +326,7 @@ impl ActionPrompt {
| ActionPrompt::Adjudicator { character_id, .. }
| ActionPrompt::PowerSeer { character_id, .. }
| ActionPrompt::Mortician { character_id, .. }
| ActionPrompt::Beholder { character_id, .. }
| ActionPrompt::BeholderChooses { character_id, .. }
| ActionPrompt::MasonLeaderRecruit { character_id, .. }
| ActionPrompt::Empath { character_id, .. }
| ActionPrompt::Vindicator { character_id, .. }
@ -313,8 +352,9 @@ impl ActionPrompt {
| ActionPrompt::Mortician { character_id, .. }
| ActionPrompt::Vindicator { character_id, .. } => character_id.character_id == target,
ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Beholder { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::BeholderChooses { .. }
| ActionPrompt::CoverOfDarkness
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
@ -341,7 +381,7 @@ impl ActionPrompt {
Self::Shapeshifter { .. } => true,
_ => !matches!(
self.with_mark(CharacterId::new()),
Err(GameError::RoleDoesntMark)
Err(GameError::PromptDoesntMark)
),
}
}
@ -349,14 +389,15 @@ impl ActionPrompt {
pub(crate) fn with_mark(&self, mark: CharacterId) -> Result<ActionPrompt> {
let mut prompt = self.clone();
match &mut prompt {
ActionPrompt::TraitorIntro { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::TraitorIntro { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::CoverOfDarkness => Err(GameError::RoleDoesntMark),
| ActionPrompt::CoverOfDarkness => Err(GameError::PromptDoesntMark),
ActionPrompt::Guardian {
previous,
@ -446,7 +487,7 @@ impl ActionPrompt {
marked,
..
}
| ActionPrompt::Beholder {
| ActionPrompt::BeholderChooses {
living_players: targets,
marked,
..

View File

@ -423,11 +423,13 @@ impl GameEnd {
return self.process(Message::Host(HostMessage::GetState));
}
Message::Host(HostMessage::PostGame(PostGameMessage::NewLobby)) => {
self.game()
.ok()?
.comms
let game = self.game().ok()?;
game.comms
.host()
.send(ServerToHostMessage::Lobby(Box::new([])))
.send(ServerToHostMessage::Lobby {
players: Box::new([]),
settings: game.game.village().settings(),
})
.log_debug();
let lobby = self.game.take()?.into_lobby();
return Some(ProcessOutcome::Lobby(lobby));

View File

@ -57,12 +57,6 @@ impl Lobby {
pub fn set_settings(&mut self, settings: GameSettings) {
self.settings = settings.clone();
if let Ok(comms) = self.comms() {
comms
.host()
.send(ServerToHostMessage::GameSettings(settings))
.log_debug();
}
}
pub fn set_players_in_lobby(&mut self, players_in_lobby: LobbyPlayers) {
@ -94,7 +88,7 @@ impl Lobby {
.await;
}
async fn get_lobby_player_list(&self) -> Box<[PlayerState]> {
async fn get_lobby_info(&self) -> Box<[PlayerState]> {
let mut players = Vec::new();
for (player, _) in self.players_in_lobby.iter() {
players.push(PlayerState {
@ -107,10 +101,11 @@ impl Lobby {
}
pub async fn send_lobby_info_to_host(&mut self) -> Result<(), GameError> {
let players = self.get_lobby_player_list().await;
let players = self.get_lobby_info().await;
let qr_mode = self.qr_mode;
let settings = self.settings.clone();
let host = self.comms()?.host();
host.send(ServerToHostMessage::Lobby(players))?;
host.send(ServerToHostMessage::Lobby { players, settings })?;
host.send(ServerToHostMessage::QrMode(qr_mode))
}
@ -212,18 +207,10 @@ impl Lobby {
Message::Host(HostMessage::Lobby(HostLobbyMessage::GetState))
| Message::Host(HostMessage::GetState) => {
self.send_lobby_info_to_host().await?;
let settings = self.settings.clone();
self.comms()?
.host()
.send(ServerToHostMessage::GameSettings(settings))
.log_warn();
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::GetGameSettings)) => {
let msg = ServerToHostMessage::GameSettings(self.settings.clone());
let _ = self.comms().unwrap().host().send(msg);
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(settings))) => {
self.settings = settings;
self.send_lobby_info_to_host().await?;
}
Message::Host(HostMessage::Lobby(HostLobbyMessage::SetPlayerNumber(pid, num))) => {
self.joined_players

View File

@ -194,9 +194,11 @@ pub enum HostEvent {
SetBigScreenState(bool),
SetState(HostState),
Continue,
PlayerList(Box<[PlayerState]>),
Lobby {
players: Box<[PlayerState]>,
settings: GameSettings,
},
CharacterList(Box<[CharacterState]>),
Settings(GameSettings),
Error(GameError),
QrMode(bool),
ToOverrideView,
@ -253,8 +255,9 @@ impl From<ServerToHostMessage> for HostEvent {
marked_for_execution,
settings,
}),
ServerToHostMessage::Lobby(players) => HostEvent::PlayerList(players),
ServerToHostMessage::GameSettings(settings) => HostEvent::Settings(settings),
ServerToHostMessage::Lobby { players, settings } => {
HostEvent::Lobby { players, settings }
}
ServerToHostMessage::Error(err) => HostEvent::Error(err),
ServerToHostMessage::GameOver(game_over) => {
HostEvent::SetState(HostState::GameOver { result: game_over })
@ -695,7 +698,10 @@ impl Host {
(Some(HostState::CharacterStates(char)), true)
}
HostEvent::QrMode(mode) => (None, true),
HostEvent::PlayerList(mut players) => {
HostEvent::Lobby {
mut players,
settings,
} => {
const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap();
players.sort_by(|l, r| {
l.identification
@ -704,69 +710,16 @@ impl Host {
.unwrap_or(LAST)
.cmp(&r.identification.public.number.unwrap_or(LAST))
});
match &self.state {
HostState::Lobby { settings, .. } => (
(
Some(HostState::Lobby {
settings,
players: players.into_iter().collect(),
settings: settings.clone(),
}),
true,
),
HostState::Story { .. }
| HostState::Disconnected
| HostState::GameOver { .. } => {
let mut send = self.send.clone();
let on_err = self.error_callback.clone();
let state = Some(HostState::Lobby {
players: players.into_iter().collect(),
settings: Default::default(),
});
yew::platform::spawn_local(async move {
if let Err(err) = send
.send(HostMessage::Lobby(HostLobbyMessage::GetGameSettings))
.await
{
on_err.emit(Some(err.into()))
}
});
(state, true)
}
HostState::ScreenOverrides { .. }
| HostState::CharacterStates(_)
| HostState::Prompt(_, _)
| HostState::Result(_, _)
| HostState::RoleReveal { .. }
| HostState::Day { .. } => (None, false),
}
)
}
HostEvent::SetErrorCallback(callback) => (None, false),
HostEvent::SetState(state) => (Some(state), true),
HostEvent::Settings(settings) => match &self.state {
HostState::Lobby { players, .. } => (
Some(HostState::Lobby {
settings,
players: players.clone(),
}),
true,
),
HostState::ScreenOverrides { .. }
| HostState::CharacterStates(_)
| HostState::Story { .. }
| HostState::Prompt(_, _)
| HostState::Result(_, _)
| HostState::Disconnected
| HostState::RoleReveal {
ackd: _,
waiting: _,
}
| HostState::GameOver { result: _ }
| HostState::Day { .. } => {
log::info!("ignoring settings get");
(None, false)
}
},
HostEvent::Error(_) => (None, false),
HostEvent::SetBigScreenState(_) => (None, true),
HostEvent::Continue => (None, false),
@ -825,12 +778,6 @@ impl Host {
{
log::error!("sending game settings update: {err}");
}
if let Err(err) = send
.send(HostMessage::Lobby(HostLobbyMessage::GetGameSettings))
.await
{
log::error!("sending game settings get: {err}");
}
});
});
let send = self.send.clone();

View File

@ -119,6 +119,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
}
});
let (character_id, targets, marked, role_info) = match &props.prompt {
ActionPrompt::BeholderWakes { .. } => return html! {},
ActionPrompt::TraitorIntro { character_id } => {
return html! {
<div class="prompt">
@ -221,7 +222,7 @@ pub fn Prompt(props: &ActionPromptProps) -> Html {
marked.iter().cloned().collect::<Box<[CharacterId]>>(),
html! {{"adjudicator"}},
),
ActionPrompt::Beholder {
ActionPrompt::BeholderChooses {
character_id,
living_players,
marked,

View File

@ -41,7 +41,7 @@ impl RolePage for ActionPrompt {
})
};
match self {
ActionPrompt::Beholder { character_id, .. } => Rc::new([html! {
ActionPrompt::BeholderChooses { character_id, .. } => Rc::new([html! {
<>
{ident(character_id)}
<BeholderPage1 />
@ -247,6 +247,12 @@ impl RolePage for ActionPrompt {
<BloodletterPage1 />
</>
}]),
ActionPrompt::BeholderWakes { character_id } => Rc::new([html! {
<>
{ident(character_id)}
<BeholderWakePage1 />
</>
}]),
_ => Rc::new([]),
}
}

View File

@ -14,7 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType};
use crate::components::{Icon, IconSource};
#[function_component]
pub fn BeholderPage1() -> Html {
@ -30,6 +30,22 @@ pub fn BeholderPage1() -> Html {
}
}
#[function_component]
pub fn BeholderWakePage1() -> Html {
html! {
<div class="role-page">
<h1 class="intel">{"BEHOLDER"}</h1>
<div class="information intel faint">
<h2>{"YOUR TARGET HAS DIED"}</h2>
<div class="info-icon-grow">
<Icon source={IconSource::Beholder}/>
</div>
<h2 class="yellow">{"THIS IS THE LAST PIECE OF INFORMATION THEY SAW"}</h2>
</div>
</div>
}
}
#[function_component]
pub fn BeholderSawNothing() -> Html {
html! {

View File

@ -249,6 +249,9 @@ impl From<ActionResultTitle> for TestScreen {
impl From<ActionPromptTitle> for TestScreen {
fn from(value: ActionPromptTitle) -> Self {
Self::Prompt(match value {
ActionPromptTitle::BeholderWakes => ActionPrompt::BeholderWakes {
character_id: identity(),
},
ActionPromptTitle::CoverOfDarkness => ActionPrompt::CoverOfDarkness,
ActionPromptTitle::WolvesIntro => ActionPrompt::WolvesIntro {
wolves: identities(5)
@ -327,7 +330,7 @@ impl From<ActionPromptTitle> for TestScreen {
dead_players: identities(20),
marked: None,
},
ActionPromptTitle::Beholder => ActionPrompt::Beholder {
ActionPromptTitle::BeholderChooses => ActionPrompt::BeholderChooses {
character_id: identities(1).into_iter().next().unwrap(),
living_players: identities(20),
marked: None,
@ -417,13 +420,14 @@ fn prompt_class(prompt: &ActionPrompt) -> Option<&'static str> {
ActionPrompt::ElderReveal { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::CoverOfDarkness => None,
ActionPrompt::Seer { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::Seer { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::Beholder { .. }
| ActionPrompt::BeholderChooses { .. }
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::MasonLeaderRecruit { .. }
| ActionPrompt::Empath { .. } => Some("intel"),

View File

@ -304,14 +304,15 @@ pub fn PromptScreenTest(
}
}
ActionPrompt::Protector { .. }
ActionPrompt::BeholderWakes { .. }
| ActionPrompt::Protector { .. }
| ActionPrompt::Arcanist { .. }
| ActionPrompt::Gravedigger { .. }
| ActionPrompt::Militia { .. }
| ActionPrompt::Adjudicator { .. }
| ActionPrompt::PowerSeer { .. }
| ActionPrompt::Mortician { .. }
| ActionPrompt::Beholder { .. }
| ActionPrompt::BeholderChooses { .. }
| ActionPrompt::Empath { .. }
| ActionPrompt::Vindicator { .. }
| ActionPrompt::PyreMaster { .. }