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;
}
}
.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,
#[error("too many players: there's {got} players but only {need} roles")]
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")]
AlreadyDaytime,
#[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<()> {
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());
if p_len > r_len {
return Err(GameError::TooManyPlayers {

View File

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

View File

@ -68,7 +68,7 @@ pub fn ChangePlayerNumber(
on:input:target=update
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>
}
}

View File

@ -19,6 +19,7 @@ enum HostPage {
#[default]
None,
Settings,
RoleRevealAcks,
}
#[component]
@ -33,6 +34,8 @@ pub fn HostGamePage(
let qr_mode = RwSignal::new(false);
let players: RwSignal<Box<[PlayerState]>> = RwSignal::new(Box::new([]));
let dialog_open = RwSignal::new(HashMap::new());
let acks: RwSignal<Box<[RoleRevealCharacter]>> = RwSignal::new(Box::new([]));
let open_categories = RwSignal::new(
Category::ALL
.into_iter()
@ -74,6 +77,22 @@ pub fn HostGamePage(
Srv2Host::Error(err) => {
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:#?}"),
}
}
@ -98,6 +117,9 @@ pub fn HostGamePage(
/>
}
.into_any(),
HostPage::RoleRevealAcks => {
view! { <RoleRevealAcks reply=reply error=error acks=acks.read_only() /> }.into_any()
}
};
view! {
{cancel}

View File

@ -78,7 +78,7 @@ pub fn HostPlayerList(
<IdentificationInline ident=sample_no_number />
</button>
<Equals />
<span class="ok">"no number assigned"</span>
<span class="ok">"no seat assigned"</span>
</Sample>
</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()
.map(move |s| {
let signal = RwSignal::new(s);
Effect::new(move || {
let mut s = settings.get();
s.update_slot(signal.get());
reply.set(Some(HostMessage::Lobby(HostLobbyMessage::SetGameSettings(s))));
});
let remove = RwSignal::new(false);
Effect::new(move || {
let mut s = settings.get();
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()
};
@ -124,9 +143,67 @@ pub fn Settings(
})
.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! {
<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="setup-slots">{slots}</div>
</div>
@ -138,6 +215,7 @@ fn SettingsSetupSlot(
setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>,
dialog_open: RwSignal<HashMap<SlotId, bool>>,
remove: WriteSignal<bool>,
) -> impl IntoView {
let auras = move || {
let slot = setup_slot.read();
@ -215,7 +293,7 @@ fn SettingsSetupSlot(
button_content=setup_slot.read().role.title().to_string().to_case(Case::Title)
close_backdrop=true
>
<SlotSettingsDialogBody setup_slot=setup_slot players=players />
<SlotSettingsDialogBody remove=remove setup_slot=setup_slot players=players />
</DialogModal>
{assigned_to}
{auras}
@ -228,6 +306,7 @@ fn SettingsSetupSlot(
fn SlotSettingsDialogBody(
setup_slot: RwSignal<SetupSlot>,
players: ReadSignal<Box<[PlayerState]>>,
remove: WriteSignal<bool>,
) -> impl IntoView {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum OpenTab {
@ -257,6 +336,10 @@ fn SlotSettingsDialogBody(
.map(|p| p.identification.public)
.map(|id| view! { <IdentityInline ident=RwSignal::new(id).read_only() /> }.into_any())
.unwrap_or_else(|| view! { "none" }.into_any());
let remove = move |ev: MouseEvent| {
ev.prevent_default();
remove.set(true);
};
view! {
<span class=[
"role-title",
@ -288,6 +371,7 @@ fn SlotSettingsDialogBody(
</div>
</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);
};
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>
<input
id="player-number"

View File

@ -80,7 +80,7 @@ impl GameDatabase {
let new_game =
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::RoleReveal(_)
@ -136,7 +136,10 @@ impl GameDatabase {
E: ::sqlx::Executor<'a, Database = Postgres>,
{
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 {
GameRecordState::Lobby(_) => "Lobby",
GameRecordState::RoleReveal(_) => "RoleReveal",

View File

@ -16,6 +16,7 @@ mod ssr {
use futures::StreamExt;
use leptos::prelude::RenderHtml;
use tokio::io::AsyncReadExt;
use werewolves::db::AppState;
pub async fn server_fn_handler(
@ -50,7 +51,24 @@ mod ssr {
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 {
leptos_meta::provide_meta_context();
let opts = state.leptos_options.clone();

View File

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

View File

@ -219,6 +219,10 @@ impl<'a> Lobby<'a> {
self.qr_mode = mode;
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::ForceRoleAckFor(_)) => {
return Err(GameError::InvalidMessageForGameState.into());

View File

@ -99,6 +99,17 @@ impl<'a> RoleReveal<'a> {
/// returns `true` once every player has ack'd
pub async fn process(&mut self, message: HostOrClientMessage) -> bool {
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)) => {
if let Some((c, ackd)) = self
.acks