role reveal for host

This commit is contained in:
emilis 2026-02-19 22:50:05 +00:00
parent f280f35305
commit d3d2819e12
No known key found for this signature in database
15 changed files with 310 additions and 26 deletions

View File

@ -784,3 +784,80 @@ form {
user-select: none; user-select: none;
} }
} }
.start-game-status {
min-height: 6ch;
display: flex;
justify-content: center;
align-items: center;
&:has(.error) {
border: 1px solid rgb(128, 0, 0);
background-color: rgba(255, 0, 0, 0.1);
}
.error {
font-size: 1.5em;
&::before {
content: '⚠️';
margin-right: 1ch;
}
}
.start-game {
font-size: 1.5em;
}
}
.start-game-dialog-info {
word-wrap: normal;
margin-bottom: 1ch;
}
.host-role-reveal {
display: flex;
flex-direction: column;
gap: 0.25ch;
.force-all {
font-size: 1.25em;
margin-top: 1ch;
margin-bottom: 1ch;
}
}
.role-reveal-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.25ch;
.player {
@media only screen and (min-width : 1199px) {
width: 20%;
}
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 0.25ch;
padding: 0.5ch;
.identity {
align-self: center;
user-select: none;
}
&.ready {
background-color: $village_color_faint;
border: 1px solid $village_color;
button {
opacity: 0%;
}
}
}
}

View File

@ -51,6 +51,10 @@ pub enum GameError {
HostChannelClosed, HostChannelClosed,
#[error("too many players: there's {got} players but only {need} roles")] #[error("too many players: there's {got} players but only {need} roles")]
TooManyPlayers { got: u8, need: u8 }, TooManyPlayers { got: u8, need: u8 },
#[error(
"too few players: there's {got} players and this setup would require {need} roles to not be at instant parity"
)]
TooFewPlayers { got: u32, need: u32 },
#[error("it's already daytime")] #[error("it's already daytime")]
AlreadyDaytime, AlreadyDaytime,
#[error("it's not the end of the night yet")] #[error("it's not the end of the night yet")]

View File

@ -182,6 +182,12 @@ impl GameSettings {
pub fn check_with_player_list(&self, players: &[Identification]) -> Result<()> { pub fn check_with_player_list(&self, players: &[Identification]) -> Result<()> {
self.check()?; self.check()?;
if self.min_players_needed() > players.len() {
return Err(GameError::TooFewPlayers {
got: players.len() as _,
need: self.min_players_needed() as _,
});
}
let (p_len, r_len) = (players.len(), self.roles.len()); let (p_len, r_len) = (players.len(), self.roles.len());
if p_len > r_len { if p_len > r_len {
return Err(GameError::TooManyPlayers { return Err(GameError::TooManyPlayers {

View File

@ -37,6 +37,8 @@ pub enum HostMessage {
Lobby(HostLobbyMessage), Lobby(HostLobbyMessage),
InGame(HostGameMessage), InGame(HostGameMessage),
ForceRoleAckFor(CharacterId), ForceRoleAckFor(CharacterId),
#[cfg(debug_assertions)]
ForceAllRoleAcks,
PostGame(PostGameMessage), PostGame(PostGameMessage),
Echo(ServerToHostMessage), Echo(ServerToHostMessage),
CancelGame, CancelGame,

View File

@ -68,7 +68,7 @@ pub fn ChangePlayerNumber(
on:input:target=update on:input:target=update
value=move || { number.get().map(|n| n.get().to_string()).unwrap_or_default() } value=move || { number.get().map(|n| n.get().to_string()).unwrap_or_default() }
/> />
<input value="submit" type="submit" on:click=submit_click/> <input value="submit" type="submit" on:click=submit_click />
</form> </form>
} }
} }

View File

@ -19,6 +19,7 @@ enum HostPage {
#[default] #[default]
None, None,
Settings, Settings,
RoleRevealAcks,
} }
#[component] #[component]
@ -33,6 +34,8 @@ pub fn HostGamePage(
let qr_mode = RwSignal::new(false); let qr_mode = RwSignal::new(false);
let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([])); let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([]));
let dialog_open = RwSignal::new(HashMap::new()); let dialog_open = RwSignal::new(HashMap::new());
let acks: RwSignal<Box<[RoleRevealCharacter]>> = RwSignal::new(Box::new([]));
let open_categories = RwSignal::new( let open_categories = RwSignal::new(
Category::ALL Category::ALL
.into_iter() .into_iter()
@ -74,6 +77,22 @@ pub fn HostGamePage(
Srv2Host::Error(err) => { Srv2Host::Error(err) => {
error.set(Some(err.to_string())); error.set(Some(err.to_string()));
} }
Srv2Host::WaitingForRoleRevealAcks { ackd, waiting } => {
let mut reveals = ackd
.into_iter()
.map(|a| RoleRevealCharacter {
char: a,
acknowledged: true,
})
.chain(waiting.into_iter().map(|w| RoleRevealCharacter {
char: w,
acknowledged: false,
}))
.collect::<Box<_>>();
reveals.sort_by_key(|r| r.char.number);
acks.set(reveals);
page.set(HostPage::RoleRevealAcks);
}
_ => log::error!("{message:#?}"), _ => log::error!("{message:#?}"),
} }
} }
@ -98,6 +117,9 @@ pub fn HostGamePage(
/> />
} }
.into_any(), .into_any(),
HostPage::RoleRevealAcks => {
view! { <RoleRevealAcks reply=reply error=error acks=acks.read_only() /> }.into_any()
}
}; };
view! { view! {
{cancel} {cancel}

View File

@ -78,7 +78,7 @@ pub fn HostPlayerList(
<IdentificationInline ident=sample_no_number /> <IdentificationInline ident=sample_no_number />
</button> </button>
<Equals /> <Equals />
<span class="ok">"no number assigned"</span> <span class="ok">"no seat assigned"</span>
</Sample> </Sample>
</TutorialBox> </TutorialBox>
} }

View File

@ -0,0 +1,57 @@
use core::ops::Not;
use leptos::{ev::MouseEvent, prelude::*};
use werewolves_proto::message::{CharacterIdentity, host::HostMessage};
use crate::app::components::IdentityInline;
#[derive(Debug, Clone)]
pub struct RoleRevealCharacter {
pub char: CharacterIdentity,
pub acknowledged: bool,
}
#[component]
pub fn RoleRevealAcks(
reply: WriteSignal<Option<HostMessage>>,
acks: ReadSignal<Box<[RoleRevealCharacter]>>,
error: WriteSignal<Option<String>>,
) -> impl IntoView {
let acks = move || {
acks.get()
.into_iter()
.map(|ackd| {
let RoleRevealCharacter { char, acknowledged } = ackd;
let char_id = char.character_id;
let ident = RwSignal::new(char.into_public()).read_only();
let force_ready = move |ev: MouseEvent| {
ev.prevent_default();
reply.set(Some(HostMessage::ForceRoleAckFor(char_id)));
};
view! {
<div class="player" class:ready=acknowledged>
<IdentityInline ident=ident />
<button disabled=acknowledged on:click=force_ready>
"force ready"
</button>
</div>
}
})
.collect_view()
};
let force_all = move || {
(option_env!("LEPTOS_WATCH") == Some("true")).then_some({
let force = move |ev: MouseEvent| {
ev.prevent_default();
#[cfg(debug_assertions)]
reply.set(Some(HostMessage::ForceAllRoleAcks));
};
view! { <button on:click=force class="force-all">"force all"</button> }
})
};
move || {
view! { <div class="host-role-reveal">{force_all} <div class="role-reveal-list">{acks}</div></div> }
}
}

View File

@ -37,13 +37,32 @@ pub fn Settings(
.cloned() .cloned()
.map(move |s| { .map(move |s| {
let signal = RwSignal::new(s); let signal = RwSignal::new(s);
Effect::new(move || { let remove = RwSignal::new(false);
let mut s = settings.get(); Effect::new(move || {
s.update_slot(signal.get()); let mut s = settings.get();
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(s)))); s.update_slot(signal.get());
}); reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(
s,
))));
});
Effect::new(move || {
if remove.get() {
let mut s = settings.get();
s.remove_slot(signal.read().slot_id);
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(
s,
))));
}
});
view! { <SettingsSetupSlot setup_slot=signal players=players dialog_open=dialog_open /> } view! {
<SettingsSetupSlot
remove=remove.write_only()
setup_slot=signal
players=players
dialog_open=dialog_open
/>
}
}) })
.collect_view() .collect_view()
}; };
@ -124,9 +143,67 @@ pub fn Settings(
}) })
.collect_view(); .collect_view();
let fill_villagers = move |ev: MouseEvent| {
ev.prevent_default();
let mut s = settings.get();
s.fill_remaining_slots_with_villagers(players.read().len());
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(
s,
))));
};
let clear_setup = move |ev: MouseEvent| {
ev.prevent_default();
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(
GameSettings::empty(),
))));
};
let fill_disabled = move || settings.read().slots().len() >= players.read().len();
let clear_disabled = move || settings.read().slots().is_empty();
let start_content = move || {
if let Err(err) = settings.read().check_with_player_list(
&players
.read()
.iter()
.map(|p| p.identification.clone())
.collect::<Box<_>>(),
) {
return view! { <span class="error">{err.to_string()}</span> }.into_any();
}
let start = move |ev: MouseEvent| {
ev.prevent_default();
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::Start)));
};
view! {
<DialogModal
button_class="start-game".into()
button_content="start game"
mode=DialogMode::Box
>
<h3>"start game"</h3>
<span class="start-game-dialog-info">
"once the game starts, all players will recieve their role on their phones"
</span>
<span class="start-game-dialog-info">
"instruct them to ensure no other player can see their screen at this time"
</span>
<button on:click=start>"start game"</button>
</DialogModal>
}
.into_any()
// }.into_any()
};
view! { view! {
<div class="game-settings"> <div class="game-settings">
<div class="top-bar">{qr_mode_btn}</div> <div class="top-bar">
{qr_mode_btn} <button on:click=fill_villagers disabled=fill_disabled>
"fill slots with villagers"
</button> <button on:click=clear_setup disabled=clear_disabled>
"clear setup"
</button>
</div>
<div class="start-game-status">{start_content}</div>
<div class="role-add-list">{categories}</div> <div class="role-add-list">{categories}</div>
<div class="setup-slots">{slots}</div> <div class="setup-slots">{slots}</div>
</div> </div>
@ -138,6 +215,7 @@ fn SettingsSetupSlot(
setup_slot: RwSignal<SetupSlot>, setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>, players: ReadSignal<Box<[PlayerState]>>,
dialog_open: RwSignal<HashMap<SlotId, bool>>, dialog_open: RwSignal<HashMap<SlotId, bool>>,
remove: WriteSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let auras = move || { let auras = move || {
let slot = setup_slot.read(); let slot = setup_slot.read();
@ -215,7 +293,7 @@ fn SettingsSetupSlot(
button_content=setup_slot.read().role.title().to_string().to_case(Case::Title) button_content=setup_slot.read().role.title().to_string().to_case(Case::Title)
close_backdrop=true close_backdrop=true
> >
<SlotSettingsDialogBody setup_slot=setup_slot players=players /> <SlotSettingsDialogBody remove=remove setup_slot=setup_slot players=players />
</DialogModal> </DialogModal>
{assigned_to} {assigned_to}
{auras} {auras}
@ -228,6 +306,7 @@ fn SettingsSetupSlot(
fn SlotSettingsDialogBody( fn SlotSettingsDialogBody(
setup_slot: RwSignal<SetupSlot>, setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>, players: ReadSignal<Box<[PlayerState]>>,
remove: WriteSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum OpenTab { enum OpenTab {
@ -257,6 +336,10 @@ fn SlotSettingsDialogBody(
.map(|p| p.identification.public) .map(|p| p.identification.public)
.map(|id| view! { <IdentityInline ident=RwSignal::new(id).read_only() /> }.into_any()) .map(|id| view! { <IdentityInline ident=RwSignal::new(id).read_only() /> }.into_any())
.unwrap_or_else(|| view! { "none" }.into_any()); .unwrap_or_else(|| view! { "none" }.into_any());
let remove = move |ev: MouseEvent| {
ev.prevent_default();
remove.set(true);
};
view! { view! {
<span class=[ <span class=[
"role-title", "role-title",
@ -288,6 +371,7 @@ fn SlotSettingsDialogBody(
</div> </div>
</div> </div>
<div class="tab-content">{tab_view}</div> <div class="tab-content">{tab_view}</div>
<button on:click=remove>"remove role"</button>
} }
} }
} }

View File

@ -68,7 +68,11 @@ pub fn PlayerLobby(
form_hidden.set(true); form_hidden.set(true);
}; };
view! { view! {
<form class="number-update bigger" on:submit=submit hidden=move || form_hidden.get()> <form
class="number-update bigger"
on:submit=submit
hidden=move || form_hidden.get()
>
<label for="player-number">"change seat number"</label> <label for="player-number">"change seat number"</label>
<input <input
id="player-number" id="player-number"

View File

@ -80,7 +80,7 @@ impl GameDatabase {
let new_game = let new_game =
Game::new_with_assigned_character_ids(&with_char_ids, settings.clone())?; Game::new_with_assigned_character_ids(&with_char_ids, settings.clone())?;
record.game_state = GameRecordState::Started(new_game); record.game_state = GameRecordState::RoleReveal(new_game);
} }
GameRecordState::GameOver(_) GameRecordState::GameOver(_)
| GameRecordState::RoleReveal(_) | GameRecordState::RoleReveal(_)
@ -136,7 +136,10 @@ impl GameDatabase {
E: ::sqlx::Executor<'a, Database = Postgres>, E: ::sqlx::Executor<'a, Database = Postgres>,
{ {
let game_state_json = leptos::serde_json::to_value(&record.game_state) let game_state_json = leptos::serde_json::to_value(&record.game_state)
.map_err(|err| DatabaseError::Serialization(err.to_string()))?; .map_err(|err| DatabaseError::Serialization(err.to_string()))
.inspect_err(|err| {
log::error!("serializing game state: {:?}: {err}", record.game_state)
})?;
let game_status = match &record.game_state { let game_status = match &record.game_state {
GameRecordState::Lobby(_) => "Lobby", GameRecordState::Lobby(_) => "Lobby",
GameRecordState::RoleReveal(_) => "RoleReveal", GameRecordState::RoleReveal(_) => "RoleReveal",

View File

@ -16,6 +16,7 @@ mod ssr {
use futures::StreamExt; use futures::StreamExt;
use leptos::prelude::RenderHtml; use leptos::prelude::RenderHtml;
use tokio::io::AsyncReadExt;
use werewolves::db::AppState; use werewolves::db::AppState;
pub async fn server_fn_handler( pub async fn server_fn_handler(
@ -50,7 +51,24 @@ mod ssr {
None None
} }
}) { }) {
file if option_env!("LEPTOS_WATCH") == Some("true") {
let file_path = std::path::PathBuf::from(".")
.join("target")
.join("site")
.join(path);
if let Ok(mut f) = tokio::fs::File::open(file_path).await {
let mut content = Vec::new();
if f.read_to_end(&mut content).await.is_ok() {
content
} else {
file.to_vec()
}
} else {
file.to_vec()
}
} else {
file.to_vec()
}
} else { } else {
leptos_meta::provide_meta_context(); leptos_meta::provide_meta_context();
let opts = state.leptos_options.clone(); let opts = state.leptos_options.clone();

View File

@ -32,18 +32,12 @@ type Result<T> = core::result::Result<T, GameError>;
pub struct GameRunner<'a> { pub struct GameRunner<'a> {
game_id: GameId, game_id: GameId,
game: &'a mut Game, game: &'a mut Game,
roles_revealed: bool,
db: Database, db: Database,
} }
impl<'a> GameRunner<'a> { impl<'a> GameRunner<'a> {
pub fn new(game_id: GameId, game: &'a mut Game, db: Database) -> Self { pub fn new(game_id: GameId, game: &'a mut Game, db: Database) -> Self {
Self { Self { db, game, game_id }
db,
game,
game_id,
roles_revealed: false,
}
} }
pub fn game_over(&self) -> Option<GameOver> { pub fn game_over(&self) -> Option<GameOver> {
@ -232,13 +226,11 @@ impl<'a> GameRunner<'a> {
} }
pub fn host_message(&mut self, message: HostMessage) -> Result<ServerToHostMessage> { pub fn host_message(&mut self, message: HostMessage) -> Result<ServerToHostMessage> {
if !self.roles_revealed {
return Err(GameError::NeedRoleReveal);
}
match message { match message {
HostMessage::GetState => self.game.process(HostGameMessage::GetState), HostMessage::GetState => self.game.process(HostGameMessage::GetState),
HostMessage::InGame(msg) => self.game.process(msg), HostMessage::InGame(msg) => self.game.process(msg),
#[cfg(debug_assertions)]
HostMessage::ForceAllRoleAcks => Err(GameError::GameOngoing),
HostMessage::Lobby(_) | HostMessage::PostGame(_) | HostMessage::ForceRoleAckFor(_) => { HostMessage::Lobby(_) | HostMessage::PostGame(_) | HostMessage::ForceRoleAckFor(_) => {
Err(GameError::GameOngoing) Err(GameError::GameOngoing)
} }

View File

@ -219,6 +219,10 @@ impl<'a> Lobby<'a> {
self.qr_mode = mode; self.qr_mode = mode;
self.send_lobby_info_to_host().await.log_debug(loc!()); self.send_lobby_info_to_host().await.log_debug(loc!());
} }
#[cfg(debug_assertions)]
HostOrClientMessage::Host(HostMessage::ForceAllRoleAcks) => {
return Err(GameError::InvalidMessageForGameState.into());
}
HostOrClientMessage::Host(HostMessage::InGame(_)) HostOrClientMessage::Host(HostMessage::InGame(_))
| HostOrClientMessage::Host(HostMessage::ForceRoleAckFor(_)) => { | HostOrClientMessage::Host(HostMessage::ForceRoleAckFor(_)) => {
return Err(GameError::InvalidMessageForGameState.into()); return Err(GameError::InvalidMessageForGameState.into());

View File

@ -99,6 +99,17 @@ impl<'a> RoleReveal<'a> {
/// returns `true` once every player has ack'd /// returns `true` once every player has ack'd
pub async fn process(&mut self, message: HostOrClientMessage) -> bool { pub async fn process(&mut self, message: HostOrClientMessage) -> bool {
match message { match message {
#[cfg(debug_assertions)]
HostOrClientMessage::Host(HostMessage::ForceAllRoleAcks) => {
let mut to_notify = Vec::new();
for (c, ackd) in self.acks.iter_mut().filter(|(_, ackd)| !*ackd) {
*ackd = true;
to_notify.push(c.player_id());
}
for pid in to_notify {
self.notify_of_role(pid).await;
}
}
HostOrClientMessage::Host(HostMessage::ForceRoleAckFor(char_id)) => { HostOrClientMessage::Host(HostMessage::ForceRoleAckFor(char_id)) => {
if let Some((c, ackd)) = self if let Some((c, ackd)) = self
.acks .acks