diff --git a/style/main.scss b/style/main.scss index d6803c7..c848c32 100644 --- a/style/main.scss +++ b/style/main.scss @@ -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%; + } + } + } +} diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs index 6a48fab..7f8f5f8 100644 --- a/werewolves-proto/src/error.rs +++ b/werewolves-proto/src/error.rs @@ -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")] diff --git a/werewolves-proto/src/game/settings.rs b/werewolves-proto/src/game/settings.rs index a170671..3f57919 100644 --- a/werewolves-proto/src/game/settings.rs +++ b/werewolves-proto/src/game/settings.rs @@ -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 { diff --git a/werewolves-proto/src/message/host.rs b/werewolves-proto/src/message/host.rs index 4d89aad..ad0e661 100644 --- a/werewolves-proto/src/message/host.rs +++ b/werewolves-proto/src/message/host.rs @@ -37,6 +37,8 @@ pub enum HostMessage { Lobby(HostLobbyMessage), InGame(HostGameMessage), ForceRoleAckFor(CharacterId), + #[cfg(debug_assertions)] + ForceAllRoleAcks, PostGame(PostGameMessage), Echo(ServerToHostMessage), CancelGame, diff --git a/werewolves/src/app/components/input/number.rs b/werewolves/src/app/components/input/number.rs index 48a2d1e..84f097d 100644 --- a/werewolves/src/app/components/input/number.rs +++ b/werewolves/src/app/components/input/number.rs @@ -68,7 +68,7 @@ pub fn ChangePlayerNumber( on:input:target=update value=move || { number.get().map(|n| n.get().to_string()).unwrap_or_default() } /> - + } } diff --git a/werewolves/src/app/pages/game/host.rs b/werewolves/src/app/pages/game/host.rs index f9b5971..4da97b1 100644 --- a/werewolves/src/app/pages/game/host.rs +++ b/werewolves/src/app/pages/game/host.rs @@ -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> = RwSignal::new(Box::new([])); let dialog_open = RwSignal::new(HashMap::new()); + let acks: RwSignal> = 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::>(); + 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! { }.into_any() + } }; view! { {cancel} diff --git a/werewolves/src/app/pages/game/host/players.rs b/werewolves/src/app/pages/game/host/players.rs index 1dea94b..506e7a0 100644 --- a/werewolves/src/app/pages/game/host/players.rs +++ b/werewolves/src/app/pages/game/host/players.rs @@ -78,7 +78,7 @@ pub fn HostPlayerList( - "no number assigned" + "no seat assigned" } diff --git a/werewolves/src/app/pages/game/host/role_reveal_acks.rs b/werewolves/src/app/pages/game/host/role_reveal_acks.rs new file mode 100644 index 0000000..e92a49d --- /dev/null +++ b/werewolves/src/app/pages/game/host/role_reveal_acks.rs @@ -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>, + acks: ReadSignal>, + error: WriteSignal>, +) -> 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! { + + + + "force ready" + + + } + }) + .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! { "force all" } + }) + }; + + move || { + view! { {force_all} {acks} } + } +} diff --git a/werewolves/src/app/pages/game/host/settings.rs b/werewolves/src/app/pages/game/host/settings.rs index fd2fd12..37049fb 100644 --- a/werewolves/src/app/pages/game/host/settings.rs +++ b/werewolves/src/app/pages/game/host/settings.rs @@ -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! { } + view! { + + } }) .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::>(), + ) { + return view! { {err.to_string()} }.into_any(); + } + let start = move |ev: MouseEvent| { + ev.prevent_default(); + reply.set(Some(HostMessage::Lobby(HostLobbyMessage::Start))); + }; + view! { + + "start game" + + "once the game starts, all players will recieve their role on their phones" + + + "instruct them to ensure no other player can see their screen at this time" + + "start game" + + } + .into_any() + // }.into_any() + }; + view! { - {qr_mode_btn} + + {qr_mode_btn} + "fill slots with villagers" + + "clear setup" + + + {start_content} {categories} {slots} @@ -138,6 +215,7 @@ fn SettingsSetupSlot( setup_slot: RwSignal, players: ReadSignal>, dialog_open: RwSignal>, + remove: WriteSignal, ) -> 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 > - + {assigned_to} {auras} @@ -228,6 +306,7 @@ fn SettingsSetupSlot( fn SlotSettingsDialogBody( setup_slot: RwSignal, players: ReadSignal>, + remove: WriteSignal, ) -> 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! { }.into_any()) .unwrap_or_else(|| view! { "none" }.into_any()); + let remove = move |ev: MouseEvent| { + ev.prevent_default(); + remove.set(true); + }; view! { {tab_view} + "remove role" } } } diff --git a/werewolves/src/app/pages/game/player/lobby.rs b/werewolves/src/app/pages/game/player/lobby.rs index dd29746..fe96bc0 100644 --- a/werewolves/src/app/pages/game/player/lobby.rs +++ b/werewolves/src/app/pages/game/player/lobby.rs @@ -68,7 +68,11 @@ pub fn PlayerLobby( form_hidden.set(true); }; view! { - + "change seat number" , { 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", diff --git a/werewolves/src/main.rs b/werewolves/src/main.rs index 4f5aabf..0884635 100644 --- a/werewolves/src/main.rs +++ b/werewolves/src/main.rs @@ -15,7 +15,8 @@ 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(); diff --git a/werewolves/src/server/game.rs b/werewolves/src/server/game.rs index f2bf0e3..dd231ed 100644 --- a/werewolves/src/server/game.rs +++ b/werewolves/src/server/game.rs @@ -32,18 +32,12 @@ type Result = core::result::Result; 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 { @@ -232,13 +226,11 @@ impl<'a> GameRunner<'a> { } pub fn host_message(&mut self, message: HostMessage) -> Result { - 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) } diff --git a/werewolves/src/server/lobby.rs b/werewolves/src/server/lobby.rs index 122892f..950a2a8 100644 --- a/werewolves/src/server/lobby.rs +++ b/werewolves/src/server/lobby.rs @@ -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()); diff --git a/werewolves/src/server/role_reveal.rs b/werewolves/src/server/role_reveal.rs index ef59797..933b50c 100644 --- a/werewolves/src/server/role_reveal.rs +++ b/werewolves/src/server/role_reveal.rs @@ -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