From 78ecb6c164e4b1c1c8fc0e99a944608367863d0a Mon Sep 17 00:00:00 2001 From: emilis Date: Wed, 4 Feb 2026 00:46:31 +0000 Subject: [PATCH] host gets dead chat too --- werewolves-proto/src/game/mod.rs | 34 ++- werewolves-proto/src/game/night.rs | 10 +- werewolves-proto/src/game/village.rs | 13 + werewolves-proto/src/message/dead.rs | 50 +++- werewolves-proto/src/message/host.rs | 6 + werewolves-server/src/game.rs | 37 ++- werewolves/Cargo.toml | 1 + werewolves/index.scss | 27 +- werewolves/src/clients/host/host.rs | 275 +++++++++++++++------ werewolves/src/components/chat/deadchat.rs | 15 +- werewolves/src/main.rs | 1 + werewolves/src/scroll.rs | 21 ++ 12 files changed, 389 insertions(+), 101 deletions(-) create mode 100644 werewolves/src/scroll.rs diff --git a/werewolves-proto/src/game/mod.rs b/werewolves-proto/src/game/mod.rs index 543cd1d..bf41b1d 100644 --- a/werewolves-proto/src/game/mod.rs +++ b/werewolves-proto/src/game/mod.rs @@ -83,6 +83,13 @@ impl Game { } } + pub fn dead_chats_since(&mut self, since: DateTime) -> Vec { + match &mut self.state { + GameState::Day { village, .. } => village.dead_chat_mut().host_get_since(since), + GameState::Night { night } => night.dead_chat_mut().host_get_since(since), + } + } + pub fn process_dead_chat_request( &mut self, player_id: PlayerId, @@ -106,7 +113,10 @@ impl Game { GameState::Day { village, .. } => { village.send_dead_chat_message(msg.clone())? } - GameState::Night { night } => night.send_dead_chat_message(msg.clone())?, + GameState::Night { night } => { + let time = night.village().time(); + night.dead_chat_mut().add(time, msg.clone())? + } } Ok(ServerToClientMessage::DeadChatMessage(msg)) } @@ -138,6 +148,28 @@ impl Game { pub fn process(&mut self, message: HostGameMessage) -> Result { match (&mut self.state, message) { + (_, HostGameMessage::SendChatMessage(msg)) => { + let msg = match &mut self.state { + GameState::Day { village, .. } => { + let time = village.time(); + village.dead_chat_mut().add_host_message(time, msg) + } + GameState::Night { night } => { + let time = night.village().time(); + night.dead_chat_mut().add_host_message(time, msg) + } + }; + log::info!("host sent dead chat message: {msg:?}"); + + Ok(ServerToHostMessage::DeadChatMessage(msg)) + } + (_, HostGameMessage::GetDeadChatSince(since)) => Ok(ServerToHostMessage::DeadChat( + match &mut self.state { + GameState::Day { village, .. } => village.dead_chat(), + GameState::Night { night } => night.dead_chat(), + } + .host_get_since(since), + )), (GameState::Night { night }, HostGameMessage::SeePlayersWithRoles) => { Ok(ServerToHostMessage::PlayerStates( night diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index e0cf0d0..fe5cc18 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -33,7 +33,7 @@ use crate::{ night::changes::{ChangesLookup, NightChange}, }, message::{ - dead::DeadChatMessage, + dead::{DeadChat, DeadChatMessage}, night::{ ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits, }, @@ -1224,8 +1224,12 @@ impl Night { &self.village } - pub fn send_dead_chat_message(&mut self, msg: DeadChatMessage) -> Result<()> { - self.village.send_dead_chat_message(msg) + pub fn dead_chat(&self) -> &DeadChat { + self.village.dead_chat() + } + + pub fn dead_chat_mut(&mut self) -> &mut DeadChat { + self.village.dead_chat_mut() } #[cfg(test)] diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs index 1446117..aa95f15 100644 --- a/werewolves-proto/src/game/village.rs +++ b/werewolves-proto/src/game/village.rs @@ -15,6 +15,7 @@ mod apply; use core::num::NonZeroU8; +use chrono::{DateTime, Utc}; use rand::Rng; use serde::{Deserialize, Serialize}; @@ -65,10 +66,22 @@ impl Village { &self.dead_chat } + pub const fn dead_chat_mut(&mut self) -> &mut DeadChat { + &mut self.dead_chat + } + pub fn send_dead_chat_message(&mut self, msg: DeadChatMessage) -> Result<()> { self.dead_chat.add(self.time, msg) } + pub fn host_send_dead_chat_message(&mut self, msg: String) -> DeadChatMessage { + self.dead_chat.add_host_message(self.time, msg) + } + + pub fn host_dead_chat_since(&mut self, since: DateTime) -> Vec { + self.dead_chat.host_get_since(since) + } + pub fn settings(&self) -> GameSettings { self.settings.clone() } diff --git a/werewolves-proto/src/message/dead.rs b/werewolves-proto/src/message/dead.rs index 0a76eaf..073279c 100644 --- a/werewolves-proto/src/message/dead.rs +++ b/werewolves-proto/src/message/dead.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -33,6 +33,7 @@ type Result = core::result::Result; pub struct DeadChat { deaths: Vec<(GameTime, CharacterId)>, messages: HashMap>, + new_messages_from: Option>, } impl DeadChat { @@ -49,6 +50,7 @@ impl DeadChat { Self { messages, deaths: Vec::new(), + new_messages_from: None, } } @@ -63,6 +65,10 @@ impl DeadChat { } pub fn add(&mut self, time: GameTime, msg: DeadChatMessage) -> Result<()> { + let start = msg + .timestamp + .checked_sub_signed(TimeDelta::nanoseconds(1)) + .unwrap_or_default(); if !self .deaths .iter() @@ -75,9 +81,28 @@ impl DeadChat { } else { self.messages.insert(time, vec![msg]); } + self.new_messages_from.get_or_insert(start); Ok(()) } + pub fn add_host_message(&mut self, time: GameTime, msg: String) -> DeadChatMessage { + let start = Utc::now(); + let msg = DeadChatMessage { + id: Uuid::new_v4(), + message: DeadChatContent::HostMessage(msg), + timestamp: start + .checked_add_signed(TimeDelta::nanoseconds(1)) + .unwrap_or_else(Utc::now), + }; + if let Some(msgs) = self.messages.get_mut(&time) { + msgs.push(msg.clone()); + } else { + self.messages.insert(time, vec![msg.clone()]); + } + self.new_messages_from.get_or_insert(start); + msg + } + pub fn set_dead( &mut self, dead: impl Iterator + Clone, @@ -129,6 +154,23 @@ impl DeadChat { ) { log::warn!("replaced: {existing:?}"); } + self.new_messages_from.get_or_insert(Utc::now()); + } + + pub fn new_messages_since(&mut self) -> Option> { + self.new_messages_from.take() + } + + pub fn host_get_since(&self, t: DateTime) -> Vec { + let mut messages = self + .messages + .clone() + .into_iter() + .flat_map(|(_, msgs)| msgs.into_iter()) + .filter(|m| m.timestamp >= t) + .collect::>(); + messages.sort_by_key(|m| m.timestamp); + messages } pub fn get_since(&self, t: DateTime, character: CharacterId) -> Box<[DeadChatMessage]> { @@ -173,6 +215,7 @@ pub enum DeadChatContent { cause: DiedTo, }, TimeChange(GameTime), + HostMessage(String), } impl DeadChatContent { @@ -180,13 +223,14 @@ impl DeadChatContent { match self { DeadChatContent::PlayerMessage { from, .. } => from.character_id == character_id, DeadChatContent::Death { character, .. } => character.character_id == character_id, - DeadChatContent::TimeChange(_) => false, + DeadChatContent::TimeChange(_) | DeadChatContent::HostMessage(_) => false, } } pub const fn message(&self) -> Option<&str> { match self { - DeadChatContent::PlayerMessage { message, .. } => Some(message.as_str()), + DeadChatContent::HostMessage(message) + | DeadChatContent::PlayerMessage { message, .. } => Some(message.as_str()), _ => None, } } diff --git a/werewolves-proto/src/message/host.rs b/werewolves-proto/src/message/host.rs index 3e7eff0..62a6ad8 100644 --- a/werewolves-proto/src/message/host.rs +++ b/werewolves-proto/src/message/host.rs @@ -14,6 +14,7 @@ // along with this program. If not, see . use core::num::NonZeroU8; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ @@ -22,6 +23,7 @@ use crate::{ game::{GameOver, GameSettings, story::GameStory}, message::{ CharacterIdentity, + dead::DeadChatMessage, night::{ActionPrompt, ActionResponse, ActionResult}, }, player::PlayerId, @@ -48,6 +50,8 @@ pub enum PostGameMessage { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum HostGameMessage { + SendChatMessage(String), + GetDeadChatSince(DateTime), Day(HostDayMessage), Night(HostNightMessage), PreviousState, @@ -113,4 +117,6 @@ pub enum ServerToHostMessage { story: GameStory, page: usize, }, + DeadChat(Vec), + DeadChatMessage(DeadChatMessage), } diff --git a/werewolves-server/src/game.rs b/werewolves-server/src/game.rs index b1d1dc9..8a2d11f 100644 --- a/werewolves-server/src/game.rs +++ b/werewolves-server/src/game.rs @@ -21,6 +21,7 @@ use crate::{ lobby::{Lobby, LobbyPlayers}, runner::{ClientUpdate, IdentifiedClientMessage, Message}, }; +use chrono::Utc; use tokio::time::Instant; use werewolves_proto::{ character::Character, @@ -259,6 +260,7 @@ impl GameRunner { } pub async fn next(&mut self) -> Option { + let start = Utc::now(); let msg = match self.comms.message().await { Ok(Message::Client(IdentifiedClientMessage { update: ClientUpdate::ConnectStateUpdate, @@ -343,30 +345,48 @@ impl GameRunner { }; let pre_time = self.game.village().time(); + let mut is_host_message = false; match self.host_message(msg) { + Ok(ServerToHostMessage::DeadChatMessage(msg)) => { + self.comms + .host() + .send(ServerToHostMessage::DeadChatMessage(msg)) + .log_err(); + is_host_message = true; + } Ok(resp) => { - self.comms.host().send(resp).log_warn(); + self.comms.host().send(resp).log_err(); } Err(err) => { self.comms .host() .send(ServerToHostMessage::Error(err)) - .log_warn(); + .log_err(); } } + let messages_for_host = self.game.dead_chats_since(start); + let msg_count = messages_for_host.len(); + if !messages_for_host.is_empty() + && let Err(err) = self + .comms + .host() + .send(ServerToHostMessage::DeadChat(messages_for_host)) + { + log::error!("sending {msg_count} dead chat messages to host channel: {err}"); + } let post_time = self.game.village().time(); if let Some(game_over) = self.game.game_over() { return Some(game_over); } - if pre_time != post_time { - let newly_dead = self + if pre_time != post_time || is_host_message { + let dead = self .game .village() .dead_characters() .into_iter() .filter_map(|c| c.died_to().map(|_| (c.character_id(), c.player_id()))); - for (char, player) in newly_dead { + for (char, player) in dead { let msgs = self .game .village() @@ -381,6 +401,13 @@ impl GameRunner { } pub async fn send_dead_message(&mut self, msg: &DeadChatMessage) -> Result<()> { + if let Err(err) = self + .comms + .host() + .send(ServerToHostMessage::DeadChatMessage(msg.clone())) + { + log::error!("sending message {} to host channel: {err}", msg.id); + } let player_ids = self .game .village() diff --git a/werewolves/Cargo.toml b/werewolves/Cargo.toml index 73ce2f8..2f88098 100644 --- a/werewolves/Cargo.toml +++ b/werewolves/Cargo.toml @@ -15,6 +15,7 @@ web-sys = { version = "0.3", features = [ "HtmlSelectElement", "HtmlDialogElement", "DomRect", + "WheelEvent", ] } wasm-bindgen = { version = "=0.2.100" } log = "0.4" diff --git a/werewolves/index.scss b/werewolves/index.scss index cd333af..cb78ec0 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -89,6 +89,9 @@ body { user-select: none; color: rgba(255, 255, 255, 1); background: black; + + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.7) black; } app { @@ -128,6 +131,10 @@ $error_shadow_color: hsla(340, 95%, 61%, 0.7); $error_shadow_color_2: hsla(0, 95%, 61%, 0.7); $error_filter: drop-shadow(5px 5px 0 $error_shadow_color) drop-shadow(5px 5px 0 $error_shadow_color_2); +$host_nav_height: 36px; +$host_nav_top_pad: 10px; +$host_nav_bottom_pad: 10px; +$host_nav_total_height: $host_nav_height + $host_nav_top_pad + $host_nav_bottom_pad; nav.host-nav { position: sticky; @@ -140,7 +147,10 @@ nav.host-nav { padding-left: 5vw; padding-right: 5vw; gap: 10px; - + height: $host_nav_height; + overflow-x: scroll; + scrollbar-width: none; + white-space: nowrap; } @@ -2093,7 +2103,6 @@ li.choice { justify-content: center; align-items: center; gap: 30px; - max-height: 40%; @media only screen and (min-width : 1600px) { width: 100%; @@ -2956,6 +2965,10 @@ dialog { gap: 3px; .chat-messages { + &:first-child { + margin-top: auto; + } + user-select: text; padding-inline-start: 0px; overflow-y: scroll; @@ -2965,10 +2978,7 @@ dialog { flex-grow: 1; max-width: 100%; max-height: 95vh; - justify-content: flex-end; - // scrollbar-width: thin; - scrollbar-width: none; - scrollbar-color: rgba(255, 255, 255, 0.7) black; + } .message { @@ -3127,3 +3137,8 @@ dialog { flex-wrap: wrap; gap: 1ch; } + +.host-dead-chat { + height: calc(100vh - $host_nav_total_height); + width: 100%; +} diff --git a/werewolves/src/clients/host/host.rs b/werewolves/src/clients/host/host.rs index 6160696..d68cc58 100644 --- a/werewolves/src/clients/host/host.rs +++ b/werewolves/src/clients/host/host.rs @@ -29,6 +29,7 @@ use werewolves_proto::{ game::{GameOver, GameSettings, story::GameStory}, message::{ CharacterIdentity, CharacterState, PlayerState, PublicIdentity, + dead::DeadChatMessage, host::{ HostDayMessage, HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage, PostGameMessage, ServerToHostMessage, @@ -44,11 +45,13 @@ use crate::{ components::{ Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Victory, action::{ActionResultView, Prompt}, + chat::DeadChat, host::{CharacterStatesReadOnly, DaytimePlayerList, Setup, VotingMode}, settings::Settings, story::Story, }, pages::RolePage, + scroll::vertical_scroll_to_horizontal, storage::StorageKey, test_util::TestScreens, }; @@ -204,6 +207,8 @@ pub enum HostEvent { ToOverrideView, ReturnFromOverride, ExpectEcho(Box), + DeadChat(Vec), + DeadChatMessage(DeadChatMessage), } #[derive(Debug, Clone, PartialEq, Titles)] pub enum HostState { @@ -240,11 +245,16 @@ pub enum HostState { page: usize, }, CharacterStates(Box<[CharacterState]>), + InChat { + previously: Box, + }, } impl From for HostEvent { fn from(msg: ServerToHostMessage) -> Self { match msg { + ServerToHostMessage::DeadChatMessage(msg) => HostEvent::DeadChatMessage(msg), + ServerToHostMessage::DeadChat(msgs) => HostEvent::DeadChat(msgs), ServerToHostMessage::PlayerStates(states) => HostEvent::CharacterList(states), ServerToHostMessage::QrMode(mode) => HostEvent::QrMode(mode), ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected), @@ -292,6 +302,7 @@ pub struct Host { qr_mode: bool, debug: bool, expecting_echo: Option, + dead_chat: Option>, } impl Component for Host { @@ -323,11 +334,67 @@ impl Component for Host { } }), expecting_echo: None, + dead_chat: None, } } fn view(&self, _ctx: &Context) -> Html { + if self.dead_chat.is_none() + && matches!(&self.state, HostState::Day { .. } | HostState::Prompt(_, _)) + { + let mut send = self.send.clone(); + let on_err = self.error_callback.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send + .send(HostMessage::InGame(HostGameMessage::GetDeadChatSince( + Default::default(), + ))) + .await + { + on_err.emit(Some(WerewolfError::Send(err))); + } + }); + } + let content = match self.state.clone() { + HostState::InChat { .. } => { + let on_send = { + let send = self.send.clone(); + let on_err = self.error_callback.clone(); + move |msg: String| { + let mut send = send.clone(); + let on_err = on_err.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send + .send(HostMessage::InGame(HostGameMessage::SendChatMessage(msg))) + .await + { + on_err.emit(Some(WerewolfError::Send(err))) + } + }); + } + }; + let messages = self.dead_chat.clone().unwrap_or_default(); + if messages.is_empty() { + let mut send = self.send.clone(); + let on_err = self.error_callback.clone(); + yew::platform::spawn_local(async move { + if let Err(err) = send + .send(HostMessage::InGame(HostGameMessage::GetDeadChatSince( + Default::default(), + ))) + .await + { + on_err.emit(Some(WerewolfError::Send(err))); + } + }); + } + html! { +
+ +
+ } + } HostState::VotingMode { characters, .. } => { html! { @@ -508,56 +575,66 @@ impl Component for Host { } } }; - let voting_mode_btn = { - match &self.state { - HostState::Day { characters, .. } => { - let on_vote_mode = { - let scope = _ctx.link().clone(); - let state = self.state.clone(); - let characters = characters.clone(); - move |_| { - scope.send_message(HostEvent::SetState(HostState::VotingMode { - return_to: Box::new(state.clone()), - characters: characters.clone(), - })) - } - }; - - Some(html! { - - }) + let mut nav_buttons = Vec::::new(); + let dead_chat_btn = { + let on_dead_chat = { + let scope = _ctx.link().clone(); + let state = self.state.clone(); + move |_| { + scope.send_message(HostEvent::SetState(HostState::InChat { + previously: Box::new(state.clone()), + })) } - HostState::VotingMode { .. } => { - let back = - crate::callback::send_message(HostMessage::GetState, self.send.clone()); - Some(html! { - - }) - } - _ => None, + }; + html! { + } }; - let view_roles_btn = match &self.state { + match &self.state { + HostState::Day { characters, .. } => { + let on_vote_mode = { + let scope = _ctx.link().clone(); + let state = self.state.clone(); + let characters = characters.clone(); + move |_| { + scope.send_message(HostEvent::SetState(HostState::VotingMode { + return_to: Box::new(state.clone()), + characters: characters.clone(), + })) + } + }; + + nav_buttons.push(html! { + + }); + + nav_buttons.push(dead_chat_btn); + } + HostState::VotingMode { .. } => { + let back = crate::callback::send_message(HostMessage::GetState, self.send.clone()); + nav_buttons.push(html! { + + }); + } HostState::Prompt(_, _) | HostState::Result(_, _) => { + let on_prev_click = callback::send_message( + HostMessage::InGame(HostGameMessage::PreviousState), + self.send.clone(), + ); + + nav_buttons.push(html! { + + }); + let on_view_click = crate::callback::send_message( HostMessage::InGame(HostGameMessage::SeePlayersWithRoles), self.send.clone(), ); - Some(html! { + nav_buttons.push(html! { - }) - } - HostState::CharacterStates(_) => { - let back = crate::callback::send_message(HostMessage::GetState, self.send.clone()); - Some(html! { - - }) - } - _ => None, - }; - let override_screens_btn = match &self.state { - HostState::Prompt(_, _) | HostState::Result(_, _) => { + }); + let overrides_click = { let scope = _ctx.link().clone(); Callback::from(move |_| { @@ -565,39 +642,12 @@ impl Component for Host { }) }; - Some(html! { + nav_buttons.push(html! { - }) - } - HostState::ScreenOverrides { .. } => { - let return_click = { - let scope = _ctx.link().clone(); - Callback::from(move |_| { - scope.send_message(HostEvent::ReturnFromOverride); - }) - }; + }); - Some(html! { - - }) - } - _ => None, - }; - let previous_btn = match &self.state { - HostState::Prompt(_, _) | HostState::Result(_, _) => { - let on_prev_click = callback::send_message( - HostMessage::InGame(HostGameMessage::PreviousState), - self.send.clone(), - ); + nav_buttons.push(dead_chat_btn); - Some(html! { - - }) - } - _ => None, - }; - let skip_btn = match &self.state { - HostState::Prompt(_, _) | HostState::Result(_, _) => { let on_skip_click = callback::send_message( HostMessage::InGame(HostGameMessage::Night(HostNightMessage::SkipAction)), self.send.clone(), @@ -609,7 +659,7 @@ impl Component for Host { }) }; - Some(html! { + nav_buttons.push(html! { {"if this is the final prompt of the night, you may not be able to go back"}

- }) + }); } - _ => None, - }; + HostState::CharacterStates(_) => { + let back = crate::callback::send_message(HostMessage::GetState, self.send.clone()); + nav_buttons.push(html! { + + }); + } + HostState::ScreenOverrides { .. } => { + let return_click = { + let scope = _ctx.link().clone(); + Callback::from(move |_| { + scope.send_message(HostEvent::ReturnFromOverride); + }) + }; + + nav_buttons.push(html! { + + }); + } + HostState::InChat { previously } => { + let return_click = { + let scope = _ctx.link().clone(); + let previously = (**previously).clone(); + Callback::from(move |_| { + scope.send_message(HostEvent::SetState(previously.clone())); + }) + }; + + nav_buttons.push(html! { + + }); + } + _ => {} + } + let on_wheel = vertical_scroll_to_horizontal(".host-nav"); + let nav = self.big_screen.not().then(|| { html! { -