diff --git a/Cargo.lock b/Cargo.lock index 29f3627..606bb5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,10 +191,20 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] +[[package]] +name = "chrono-humanize" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799627e6b4d27827a814e837b9d8a504832086081806d45b1afa34dc982b023b" +dependencies = [ + "chrono", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -1960,6 +1970,7 @@ name = "werewolves" version = "0.1.0" dependencies = [ "chrono", + "chrono-humanize", "ciborium", "convert_case 0.10.0", "futures", @@ -1998,6 +2009,7 @@ dependencies = [ name = "werewolves-proto" version = "0.1.0" dependencies = [ + "chrono", "colored", "log", "pretty_assertions", diff --git a/werewolves-proto/Cargo.toml b/werewolves-proto/Cargo.toml index 1413ec8..ea68606 100644 --- a/werewolves-proto/Cargo.toml +++ b/werewolves-proto/Cargo.toml @@ -11,6 +11,7 @@ serde = { version = "1.0", features = ["derive"] } uuid = { version = "1.17", features = ["v4", "serde"] } rand = { version = "0.9", features = ["std_rng"] } werewolves-macros = { path = "../werewolves-macros" } +chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] pretty_assertions = { version = "1" } diff --git a/werewolves-proto/src/error.rs b/werewolves-proto/src/error.rs index d1e6335..ecd68fc 100644 --- a/werewolves-proto/src/error.rs +++ b/werewolves-proto/src/error.rs @@ -107,4 +107,6 @@ pub enum GameError { MustSelectTarget, #[error("no current prompt in aura handling")] NoCurrentPromptForAura, + #[error("you're not dead")] + NotDead, } diff --git a/werewolves-proto/src/game/mod.rs b/werewolves-proto/src/game/mod.rs index e7bcf9f..11ed4c7 100644 --- a/werewolves-proto/src/game/mod.rs +++ b/werewolves-proto/src/game/mod.rs @@ -25,8 +25,10 @@ use core::{ ops::{Deref, Range, RangeBounds}, }; +use chrono::{DateTime, Utc}; use rand::{Rng, seq::SliceRandom}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{ character::CharacterId, @@ -36,10 +38,12 @@ use crate::{ story::{DayDetail, GameActions, GameStory, NightDetails}, }, message::{ - CharacterState, Identification, + CharacterState, ClientDeadChat, Identification, ServerToClientMessage, + dead::DeadChatMessage, host::{HostDayMessage, HostGameMessage, HostNightMessage, ServerToHostMessage}, night::ActionResponse, }, + player::PlayerId, }; pub use { @@ -51,6 +55,7 @@ type Result = core::result::Result; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Game { + started: DateTime, history: GameStory, state: GameState, } @@ -59,6 +64,7 @@ impl Game { pub fn new(players: &[Identification], settings: GameSettings) -> Result { let village = Village::new(players, settings)?; Ok(Self { + started: Utc::now(), history: GameStory::new(village.clone()), state: GameState::Night { night: Night::new(village)?, @@ -73,6 +79,48 @@ impl Game { } } + pub fn process_dead_chat_request( + &mut self, + player_id: PlayerId, + message: ClientDeadChat, + ) -> Result { + let char = self + .village() + .character_by_player_id(player_id) + .ok_or(GameError::NoMatchingCharacterFound)?; + match message { + ClientDeadChat::Send(message) => { + let msg = DeadChatMessage { + message, + id: Uuid::new_v4(), + from: char.identity(), + timestamp: Utc::now(), + }; + match &mut self.state { + GameState::Day { village, .. } => { + village.send_dead_chat_message(msg.clone())? + } + GameState::Night { night } => night.send_dead_chat_message(msg.clone())?, + } + Ok(ServerToClientMessage::DeadChatMessage(msg)) + } + ClientDeadChat::GetHistory => { + let messages = self + .village() + .dead_chat() + .get_since(self.started, char.character_id()); + Ok(ServerToClientMessage::DeadChat(messages)) + } + ClientDeadChat::GetSince(since) => { + let messages = self + .village() + .dead_chat() + .get_since(since, char.character_id()); + Ok(ServerToClientMessage::DeadChat(messages)) + } + } + } + #[cfg(test)] #[doc(hidden)] pub const fn village_mut(&mut self) -> &mut Village { @@ -145,6 +193,7 @@ impl Game { .collect(), ), )?; + self.state = GameState::Night { night }; self.process(HostGameMessage::GetState) } @@ -403,7 +452,7 @@ impl Ord for GameTime { } } (GameTime::Night { number: l }, GameTime::Day { number: r }) => { - if *l > r.get() { + if *l >= r.get() { Ordering::Greater } else { Ordering::Less diff --git a/werewolves-proto/src/game/night.rs b/werewolves-proto/src/game/night.rs index 572eaf0..e0cf0d0 100644 --- a/werewolves-proto/src/game/night.rs +++ b/werewolves-proto/src/game/night.rs @@ -32,8 +32,11 @@ use crate::{ kill::{self, KillOutcome}, night::changes::{ChangesLookup, NightChange}, }, - message::night::{ - ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits, + message::{ + dead::DeadChatMessage, + night::{ + ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits, + }, }, role::RoleTitle, }; @@ -1221,6 +1224,10 @@ impl Night { &self.village } + pub fn send_dead_chat_message(&mut self, msg: DeadChatMessage) -> Result<()> { + self.village.send_dead_chat_message(msg) + } + #[cfg(test)] #[doc(hidden)] pub const fn village_mut(&mut self) -> &mut Village { diff --git a/werewolves-proto/src/game/village.rs b/werewolves-proto/src/game/village.rs index 97b7b76..1010322 100644 --- a/werewolves-proto/src/game/village.rs +++ b/werewolves-proto/src/game/village.rs @@ -24,7 +24,11 @@ use crate::{ diedto::DiedTo, error::GameError, game::{GameOver, GameSettings, GameTime}, - message::{CharacterIdentity, Identification, night::ActionPrompt}, + message::{ + CharacterIdentity, Identification, + dead::{DeadChat, DeadChatMessage}, + night::ActionPrompt, + }, player::PlayerId, role::{Role, RoleTitle}, }; @@ -33,6 +37,7 @@ use crate::{ pub struct Village { characters: Box<[Character]>, time: GameTime, + dead_chat: DeadChat, settings: GameSettings, } @@ -52,9 +57,18 @@ impl Village { settings, characters, time: GameTime::Night { number: 0 }, + dead_chat: DeadChat::new(), }) } + pub const fn dead_chat(&self) -> &DeadChat { + &self.dead_chat + } + + pub fn send_dead_chat_message(&mut self, msg: DeadChatMessage) -> Result<()> { + self.dead_chat.add(self.time, msg) + } + pub fn settings(&self) -> GameSettings { self.settings.clone() } @@ -170,14 +184,24 @@ impl Village { return Ok(Some(game_over)); } self.time = self.time.next(); + self.set_dead_chat_dead(); Ok(None) } + fn set_dead_chat_dead(&mut self) { + self.dead_chat + .set_dead(self.characters.iter().filter_map(|c| { + c.died_to() + .map(|died_to| (died_to.date_time(), c.character_id())) + })); + } + pub fn to_day(&mut self) -> Result { if self.time.is_day() { return Err(GameError::AlreadyDaytime); } self.time = self.time.next(); + self.set_dead_chat_dead(); Ok(self.time) } diff --git a/werewolves-proto/src/game_test/mod.rs b/werewolves-proto/src/game_test/mod.rs index 880eef3..95f4d78 100644 --- a/werewolves-proto/src/game_test/mod.rs +++ b/werewolves-proto/src/game_test/mod.rs @@ -19,6 +19,7 @@ mod previous; mod revert; mod role; mod skip; +mod time; use crate::{ character::{Character, CharacterId}, diff --git a/werewolves-proto/src/game_test/time.rs b/werewolves-proto/src/game_test/time.rs new file mode 100644 index 0000000..455eb1d --- /dev/null +++ b/werewolves-proto/src/game_test/time.rs @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Emilis Bliūdžius +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +use core::{cmp::Ordering, num::NonZeroU8}; +#[allow(unused)] +use pretty_assertions::{assert_eq, assert_ne, assert_str_eq}; + +use crate::game::GameTime; + +#[test] +pub fn game_time_test() { + for (l, r, exp) in &[ + ( + GameTime::Day { + number: NonZeroU8::new(1).unwrap(), + }, + GameTime::Night { number: 0 }, + Ordering::Greater, + ), + ( + GameTime::Night { number: 1 }, + GameTime::Day { + number: NonZeroU8::new(1).unwrap(), + }, + Ordering::Greater, + ), + ( + GameTime::Night { number: 0 }, + GameTime::Day { + number: NonZeroU8::new(1).unwrap(), + }, + Ordering::Less, + ), + ] { + assert_eq!(l.cmp(r), *exp); + } +} diff --git a/werewolves-proto/src/message.rs b/werewolves-proto/src/message.rs index 41d1106..31b50d4 100644 --- a/werewolves-proto/src/message.rs +++ b/werewolves-proto/src/message.rs @@ -12,18 +12,22 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +pub mod dead; pub mod host; mod ident; pub mod night; use core::num::NonZeroU8; +use chrono::{DateTime, Utc}; pub use ident::*; use serde::{Deserialize, Serialize}; use crate::{ character::CharacterId, + error::GameError, game::{GameOver, story::GameStory}, + message::dead::DeadChatMessage, role::RoleTitle, }; @@ -34,6 +38,14 @@ pub enum ClientMessage { GetState, RoleAck, UpdateSelf(UpdateSelf), + DeadChat(ClientDeadChat), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ClientDeadChat { + Send(String), + GetHistory, + GetSince(DateTime), } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -51,7 +63,7 @@ pub struct DayCharacter { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ServerMessage { +pub enum ServerToClientMessage { Disconnect, LobbyInfo { joined: bool, @@ -66,8 +78,11 @@ pub enum ServerMessage { GameOver(GameOver), Story(GameStory), Update(PlayerUpdate), + DeadChat(Box<[DeadChatMessage]>), + DeadChatMessage(DeadChatMessage), Sleep, Reset, + Error(GameError), } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/werewolves-proto/src/message/dead.rs b/werewolves-proto/src/message/dead.rs new file mode 100644 index 0000000..0555a15 --- /dev/null +++ b/werewolves-proto/src/message/dead.rs @@ -0,0 +1,104 @@ +// Copyright (C) 2025-2026 Emilis Bliūdžius +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{character::CharacterId, error::GameError, game::GameTime, message::CharacterIdentity}; + +type Result = core::result::Result; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DeadChat { + deaths: Vec<(GameTime, CharacterId)>, + messages: HashMap>, +} + +impl DeadChat { + pub fn new() -> Self { + Self { + deaths: Vec::new(), + messages: HashMap::new(), + } + } + + pub fn all_character_ids(&self) -> Box<[CharacterId]> { + self.deaths.iter().map(|c| c.1).collect() + } + + pub fn sort(&mut self) { + self.messages + .iter_mut() + .for_each(|(_, v)| v.sort_by_key(|s| s.timestamp)); + } + + pub fn add(&mut self, time: GameTime, msg: DeadChatMessage) -> Result<()> { + if !self + .deaths + .iter() + .any(|(t, ch)| time >= *t && *ch == msg.from.character_id) + { + return Err(GameError::NotDead); + } + if let Some(msgs) = self.messages.get_mut(&time) { + msgs.push(msg); + } else { + self.messages.insert(time, vec![msg]); + } + Ok(()) + } + + pub fn set_dead(&mut self, dead: impl Iterator) { + self.deaths = dead.collect(); + } + + pub fn get_since(&self, t: DateTime, character: CharacterId) -> Box<[DeadChatMessage]> { + let times_applicable = self + .deaths + .iter() + .filter_map(|(dt, c)| (*c == character).then_some(*dt)) + .collect::>(); + let mut messages = self + .messages + .iter() + .filter_map(|(gt, c)| { + times_applicable + .iter() + .any(|t| t <= gt) + .then_some(c.iter().filter(|c| c.timestamp >= t)) + }) + .flatten() + .cloned() + .collect::>(); + #[cfg(debug_assertions)] + let orig_msg = messages.clone(); + messages.sort_by_key(|m| m.timestamp); + #[cfg(debug_assertions)] + assert_eq!(orig_msg, messages); + + messages + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DeadChatMessage { + pub id: Uuid, + pub from: CharacterIdentity, + pub timestamp: DateTime, + pub message: String, +} diff --git a/werewolves-server/src/client.rs b/werewolves-server/src/client.rs index ce47b17..ad54f89 100644 --- a/werewolves-server/src/client.rs +++ b/werewolves-server/src/client.rs @@ -30,7 +30,7 @@ use axum_extra::TypedHeader; use chrono::Utc; use colored::Colorize; use tokio::sync::broadcast::{Receiver, Sender}; -use werewolves_proto::message::{ClientMessage, Identification, ServerMessage, UpdateSelf}; +use werewolves_proto::message::{ClientMessage, Identification, ServerToClientMessage, UpdateSelf}; pub async fn handler( ws: WebSocketUpgrade, @@ -142,7 +142,7 @@ struct Client { socket: WebSocket, who: String, sender: Sender, - receiver: Receiver, + receiver: Receiver, } impl Client { @@ -152,7 +152,7 @@ impl Client { socket: WebSocket, who: String, sender: Sender, - receiver: Receiver, + receiver: Receiver, ) -> Self { Self { ident, @@ -240,7 +240,7 @@ impl Client { Ok(()) } - async fn handle_message(&mut self, message: ServerMessage) -> Result<(), anyhow::Error> { + async fn handle_message(&mut self, message: ServerToClientMessage) -> Result<(), anyhow::Error> { self.socket .send({ #[cfg(not(feature = "cbor"))] diff --git a/werewolves-server/src/connection.rs b/werewolves-server/src/connection.rs index 0b191c1..e058881 100644 --- a/werewolves-server/src/connection.rs +++ b/werewolves-server/src/connection.rs @@ -23,7 +23,7 @@ use tokio::{ time::Instant, }; use werewolves_proto::{ - message::{PublicIdentity, ServerMessage}, + message::{PublicIdentity, ServerToClientMessage}, player::PlayerId, }; @@ -44,8 +44,8 @@ impl ConnectionId { #[derive(Debug)] pub struct JoinedPlayer { - sender: Sender, - receiver: Receiver, + sender: Sender, + receiver: Receiver, active_connection: ConnectionId, in_game: bool, pub name: String, @@ -55,8 +55,8 @@ pub struct JoinedPlayer { impl JoinedPlayer { pub const fn new( - sender: Sender, - receiver: Receiver, + sender: Sender, + receiver: Receiver, active_connection: ConnectionId, name: String, number: Option, @@ -72,7 +72,7 @@ impl JoinedPlayer { in_game: false, } } - pub fn resubscribe_reciever(&self) -> Receiver { + pub fn resubscribe_reciever(&self) -> Receiver { self.receiver.resubscribe() } } @@ -91,7 +91,7 @@ impl JoinedPlayers { pub async fn send_to_all_filter( &self, - message: ServerMessage, + message: ServerToClientMessage, filter: impl Fn(PlayerId) -> bool, ) { let players: tokio::sync::MutexGuard<'_, HashMap> = @@ -107,7 +107,7 @@ impl JoinedPlayers { } } - pub async fn send_to(&self, player_ids: &[PlayerId], message: ServerMessage) { + pub async fn send_to(&self, player_ids: &[PlayerId], message: ServerToClientMessage) { let players: tokio::sync::MutexGuard<'_, HashMap> = self.players.lock().await; let senders = players @@ -130,7 +130,7 @@ impl JoinedPlayers { .collect::>(); core::mem::drop(players); for (pid, send) in senders { - send.send(ServerMessage::LobbyInfo { + send.send(ServerToClientMessage::LobbyInfo { joined: in_lobby_ids.contains(&pid), players: in_lobby.clone(), }) @@ -181,7 +181,7 @@ impl JoinedPlayers { None } - pub async fn get_sender(&self, player_id: PlayerId) -> Option> { + pub async fn get_sender(&self, player_id: PlayerId) -> Option> { self.players .lock() .await @@ -193,7 +193,7 @@ impl JoinedPlayers { &self, player_id: PlayerId, player: JoinedPlayer, - ) -> Receiver { + ) -> Receiver { let mut map = self.players.lock().await; if let Some(old) = map.insert(player_id, player) { diff --git a/werewolves-server/src/game.rs b/werewolves-server/src/game.rs index feaba4e..047b7b9 100644 --- a/werewolves-server/src/game.rs +++ b/werewolves-server/src/game.rs @@ -27,7 +27,8 @@ use werewolves_proto::{ error::GameError, game::{Game, GameOver, Village}, message::{ - ClientMessage, Identification, ServerMessage, + ClientDeadChat, ClientMessage, Identification, ServerToClientMessage, + dead::DeadChatMessage, host::{HostGameMessage, HostMessage, PostGameMessage, ServerToHostMessage}, }, player::PlayerId, @@ -79,7 +80,7 @@ impl GameRunner { for char in characters.iter() { match self.player_sender.send_if_present( char.player_id(), - ServerMessage::GameStart { + ServerToClientMessage::GameStart { role: char.initial_shown_role(), }, ) { @@ -97,7 +98,7 @@ impl GameRunner { } } self.joined_players - .send_to_all_filter(ServerMessage::GameInProgress, |pid| { + .send_to_all_filter(ServerToClientMessage::GameInProgress, |pid| { !characters.iter().any(|c| c.player_id() == pid) }) .await; @@ -131,7 +132,7 @@ impl GameRunner { && let Some(sender) = sender.get_sender(player_id).await { sender - .send(ServerMessage::GameStart { + .send(ServerToClientMessage::GameStart { role: char.initial_shown_role(), }) .log_debug(); @@ -197,7 +198,7 @@ impl GameRunner { }; if acks.iter().any(|(c, d)| c.player_id() == player_id && *d) { // already ack'd just sleep - sender.send(ServerMessage::Sleep).log_debug(); + sender.send(ServerToClientMessage::Sleep).log_debug(); continue; } if let Some(char) = self @@ -208,13 +209,15 @@ impl GameRunner { .find(|c| c.player_id() == player_id) { sender - .send(ServerMessage::GameStart { + .send(ServerToClientMessage::GameStart { role: char.initial_shown_role(), }) .log_debug(); } else { log::info!("game in progress for {player_id}"); - sender.send(ServerMessage::GameInProgress).log_debug(); + sender + .send(ServerToClientMessage::GameInProgress) + .log_debug(); } log::info!("player {player_id} end"); } @@ -231,12 +234,12 @@ impl GameRunner { { *ackd = true; self.player_sender - .send_if_present(player_id, ServerMessage::Sleep) + .send_if_present(player_id, ServerToClientMessage::Sleep) .log_debug(); } (update_host)(&acks, &mut self.comms); if let Some(sender) = self.joined_players.get_sender(player_id).await { - sender.send(ServerMessage::Sleep).log_debug(); + sender.send(ServerToClientMessage::Sleep).log_debug(); } } Message::Client(IdentifiedClientMessage { @@ -248,7 +251,7 @@ impl GameRunner { for char in self.game.village().characters() { if let Some(sender) = self.joined_players.get_sender(char.player_id()).await { - let _ = sender.send(ServerMessage::Sleep); + let _ = sender.send(ServerToClientMessage::Sleep); } } @@ -261,13 +264,74 @@ impl GameRunner { update: ClientUpdate::ConnectStateUpdate, .. })) => return None, + Ok(Message::Client(IdentifiedClientMessage { + identity: Identification { player_id, .. }, + update: ClientUpdate::Message(ClientMessage::DeadChat(chat_request)), + })) => { + if let ClientDeadChat::Send(msg) = &chat_request + && msg.trim().is_empty() + { + return None; + } + let reply = match self.game.process_dead_chat_request(player_id, chat_request) { + Ok(msg) => msg, + Err(err) => ServerToClientMessage::Error(err), + }; + match reply { + ServerToClientMessage::DeadChatMessage(msg) => { + if let Err(err) = self.send_dead_message(&msg).await { + log::warn!("sending message {msg:?} to dead chat: {err}"); + } + } + other => { + if let Some(sender) = self.joined_players.get_sender(player_id).await + && let Err(err) = sender.send(other.clone()) + { + log::warn!("sending message {other:?} to [{player_id}]: {err}"); + } + } + } + return None; + } + Ok(Message::Client(IdentifiedClientMessage { + identity: Identification { player_id, public }, + update: ClientUpdate::Message(ClientMessage::GetState), + })) => { + let Some(char) = self.game.village().character_by_player_id(player_id) else { + if let Some(send) = self.joined_players.get_sender(player_id).await { + send.send(ServerToClientMessage::GameInProgress).log_debug(); + } + return None; + }; + if !self + .game + .village() + .dead_chat() + .all_character_ids() + .contains(&char.character_id()) + { + if let Some(send) = self.joined_players.get_sender(player_id).await { + send.send(ServerToClientMessage::GameInProgress).log_debug(); + } + return None; + } + let msg = match self + .game + .process_dead_chat_request(player_id, ClientDeadChat::GetHistory) + { + Ok(msg) => msg, + Err(err) => ServerToClientMessage::Error(err), + }; + self.joined_players.send_to(&[player_id], msg).await; + return None; + } Ok(Message::Client(IdentifiedClientMessage { identity: Identification { player_id, .. }, .. })) => { log::info!("client message from player {player_id}"); if let Some(send) = self.joined_players.get_sender(player_id).await { - send.send(ServerMessage::GameInProgress).log_debug(); + send.send(ServerToClientMessage::GameInProgress).log_debug(); } return None; } @@ -278,6 +342,7 @@ impl GameRunner { } }; + let pre_time = self.game.village().time(); match self.host_message(msg) { Ok(resp) => { self.comms.host().send(resp).log_warn(); @@ -289,7 +354,49 @@ impl GameRunner { .log_warn(); } } - self.game.game_over() + 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 + .game + .village() + .dead_characters() + .into_iter() + .filter_map(|c| { + c.died_to() + .and_then(|d| (d.date_time() == pre_time).then_some(c.player_id())) + }) + .collect::>(); + self.joined_players + .send_to(&newly_dead, ServerToClientMessage::DeadChat(Box::new([]))) + .await; + } + None + } + + pub async fn send_dead_message(&mut self, msg: &DeadChatMessage) -> Result<()> { + let player_ids = self + .game + .village() + .dead_chat() + .all_character_ids() + .into_iter() + .map(|c| { + self.game + .village() + .character_by_id(c) + .map(|c| c.player_id()) + }) + .collect::>>()?; + self.joined_players + .send_to( + &player_ids, + ServerToClientMessage::DeadChatMessage(msg.clone()), + ) + .await; + Ok(()) } pub fn host_message(&mut self, message: HostMessage) -> Result { @@ -310,7 +417,7 @@ impl GameRunner { enum ProcessOutcome { Lobby(Lobby), - SendPlayer(PlayerId, ServerMessage), + SendPlayer(PlayerId, ServerToClientMessage), } pub struct GameEnd { @@ -351,7 +458,7 @@ impl GameEnd { .collect::>(); game.joined_players - .send_to(&player_ids, ServerMessage::Story(story)) + .send_to(&player_ids, ServerToClientMessage::Story(story)) .await; } let msg = match self.game().unwrap().comms.message().await { @@ -452,7 +559,7 @@ impl GameEnd { let story = self.game().ok()?.game.story(); return Some(ProcessOutcome::SendPlayer( identity.player_id, - ServerMessage::Story(story), + ServerToClientMessage::Story(story), )); } } diff --git a/werewolves-server/src/lobby.rs b/werewolves-server/src/lobby.rs index 77453b2..2e0fb53 100644 --- a/werewolves-server/src/lobby.rs +++ b/werewolves-server/src/lobby.rs @@ -22,7 +22,7 @@ use werewolves_proto::{ error::GameError, game::{Game, GameSettings}, message::{ - ClientMessage, Identification, PlayerState, PublicIdentity, ServerMessage, + ClientMessage, Identification, PlayerState, PublicIdentity, ServerToClientMessage, host::{HostLobbyMessage, HostMessage, ServerToHostMessage}, }, player::PlayerId, @@ -149,7 +149,7 @@ impl Lobby { )) => { let _ = self .players_in_lobby - .send_if_present(player_id, ServerMessage::InvalidMessageForGameState); + .send_if_present(player_id, ServerToClientMessage::InvalidMessageForGameState); } Err(( Message::Client(IdentifiedClientMessage { @@ -168,7 +168,7 @@ impl Lobby { log::error!("processing message from {public} [{player_id}]: {err}"); let _ = self .players_in_lobby - .send_if_present(player_id, ServerMessage::Reset); + .send_if_present(player_id, ServerToClientMessage::Reset); } } None @@ -176,6 +176,12 @@ impl Lobby { async fn next_inner(&mut self, msg: Message) -> Result, GameError> { match msg { + Message::Client(IdentifiedClientMessage { + update: ClientUpdate::Message(ClientMessage::DeadChat(_)), + .. + }) => { + log::warn!("dead chat message in lobby? ignoring."); + } Message::Host(HostMessage::Lobby(HostLobbyMessage::ManufacturePlayer(public))) => { log::info!("adding player {public:?} by host request"); self.players_in_lobby.push(( @@ -284,7 +290,7 @@ impl Lobby { identity: Identification { player_id, .. }, update: ClientUpdate::Message(ClientMessage::GetState), }) => { - let msg = ServerMessage::LobbyInfo { + let msg = ServerToClientMessage::LobbyInfo { joined: self .players_in_lobby .iter() @@ -338,10 +344,10 @@ impl Lobby { } #[derive(Clone)] -pub struct LobbyPlayers(Vec<(Identification, Option>)>); +pub struct LobbyPlayers(Vec<(Identification, Option>)>); impl Deref for LobbyPlayers { - type Target = Vec<(Identification, Option>)>; + type Target = Vec<(Identification, Option>)>; fn deref(&self) -> &Self::Target { &self.0 @@ -374,7 +380,7 @@ impl LobbyPlayers { .collect(), ) } - pub fn find(&self, player_id: PlayerId) -> Option<&Sender> { + pub fn find(&self, player_id: PlayerId) -> Option<&Sender> { self.iter() .filter_map(|(id, s)| s.as_ref().map(|s| (id, s))) .find_map(|(id, s)| (id.player_id == player_id).then_some(s)) @@ -383,7 +389,7 @@ impl LobbyPlayers { pub fn send_if_present( &self, player_id: PlayerId, - message: ServerMessage, + message: ServerToClientMessage, ) -> Result { if let Some(sender) = self.find(player_id) { sender diff --git a/werewolves/Cargo.toml b/werewolves/Cargo.toml index a2b48d6..73ce2f8 100644 --- a/werewolves/Cargo.toml +++ b/werewolves/Cargo.toml @@ -37,6 +37,7 @@ wasm-bindgen-futures = "0.4" thiserror = { version = "2" } convert_case = { version = "0.10" } ciborium = { version = "0.2", optional = true } +chrono-humanize = { version = "0.2.3", features = ["wasmbind"] } [features] default = ["cbor"] diff --git a/werewolves/index.scss b/werewolves/index.scss index a37225a..e97093b 100644 --- a/werewolves/index.scss +++ b/werewolves/index.scss @@ -2931,3 +2931,89 @@ dialog { color: $damned_color; } } + + +.dead-chat { + height: 100%; + // width: 100%; + // max-width: 100vw; + padding: 0px 3% 0 3%; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + flex-grow: 1; + overflow-x: hidden; + gap: 3px; + + .chat-messages { + user-select: text; + padding-inline-start: 0px; + overflow-y: scroll; + display: flex; + flex-direction: column; + margin: 0; + 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 { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin-left: 0; + gap: 1ch; + align-items: baseline; + + .dead-ident { + font-weight: bold; + + &[pronouns]:hover::after { + content: attr(pronouns); + overflow-y: hidden; + position: relative; + color: white; + background-color: black; + border: 1px solid white; + padding: 3px; + z-index: 4; + align-self: center; + justify-self: center; + } + } + + .time { + flex-shrink: 1; + opacity: 50%; + font-size: 1em; + } + + .message-content { + flex-grow: 1; + font-size: 1.2em; + } + } + + form { + max-width: 100%; + margin: 0; + flex-grow: 1; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + padding-bottom: 10px; + max-height: 5ch; + } + + #message-input { + padding: 5px 0px 5px 0px; + padding-inline: 0px 3px 0px 3px; + align-self: flex-end; + width: 100%; + flex-grow: 1; + } +} diff --git a/werewolves/src/clients/client/client.rs b/werewolves/src/clients/client/client.rs index 978d5d7..f28ce19 100644 --- a/werewolves/src/clients/client/client.rs +++ b/werewolves/src/clients/client/client.rs @@ -12,7 +12,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use core::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use core::{ + ops::Not, + sync::atomic::{AtomicBool, AtomicI64, Ordering}, +}; use std::rc::Rc; use chrono::{DateTime, TimeDelta, Utc}; @@ -20,7 +23,9 @@ use gloo::storage::errors::StorageError; use wasm_bindgen::{JsCast, prelude::Closure}; use werewolves_proto::{ game::story::GameStory, - message::{ClientMessage, Identification, PublicIdentity}, + message::{ + ClientDeadChat, ClientMessage, Identification, PublicIdentity, dead::DeadChatMessage, + }, player::PlayerId, role::RoleTitle, }; @@ -51,6 +56,9 @@ pub enum ClientEvent2 { }, Story(GameStory), GameInProgress, + DeadChat { + messages: Vec, + }, } #[derive(Default, Clone, PartialEq)] @@ -74,7 +82,11 @@ pub(super) fn time_spent_unfocused() -> Option { #[function_component] pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { - let ident_state = use_state(|| Option::<(PlayerId, PublicIdentity)>::None); + let ident_state = use_state(|| { + PlayerId::load_from_storage() + .and_then(|pid| PublicIdentity::load_from_storage().map(|ident| (pid, ident))) + .ok() + }); if gloo::utils::window().onfocus().is_none() { let on_focus = { @@ -154,6 +166,24 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { }; let content = match &*client_state { + ClientEvent2::DeadChat { messages } => { + let on_send = { + let send = send.clone(); + move |msg: String| { + if let Err(err) = + send.send_now(ClientMessage::DeadChat(ClientDeadChat::Send(msg))) + { + log::error!("sending dead chat message: {err}"); + } + } + }; + html! { + + } + } ClientEvent2::Signin => html! { }, @@ -260,8 +290,9 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { } } }; + let dead_chat = matches!(&*client_state, ClientEvent2::DeadChat { .. }); - let nav = { + let nav = dead_chat.not().then_some({ let send = (*send).clone(); let error_cb = error_cb.clone(); let client_nav_msg_cb = move |msg| { @@ -272,13 +303,16 @@ pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html { html! { } - }; + }); + let footer = dead_chat.not().then_some(html! { +