host gets dead chat too

This commit is contained in:
emilis 2026-02-04 00:46:31 +00:00
parent 27752727a3
commit 78ecb6c164
No known key found for this signature in database
12 changed files with 389 additions and 101 deletions

View File

@ -83,6 +83,13 @@ impl Game {
} }
} }
pub fn dead_chats_since(&mut self, since: DateTime<Utc>) -> Vec<DeadChatMessage> {
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( pub fn process_dead_chat_request(
&mut self, &mut self,
player_id: PlayerId, player_id: PlayerId,
@ -106,7 +113,10 @@ impl Game {
GameState::Day { village, .. } => { GameState::Day { village, .. } => {
village.send_dead_chat_message(msg.clone())? 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)) Ok(ServerToClientMessage::DeadChatMessage(msg))
} }
@ -138,6 +148,28 @@ impl Game {
pub fn process(&mut self, message: HostGameMessage) -> Result<ServerToHostMessage> { pub fn process(&mut self, message: HostGameMessage) -> Result<ServerToHostMessage> {
match (&mut self.state, message) { 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) => { (GameState::Night { night }, HostGameMessage::SeePlayersWithRoles) => {
Ok(ServerToHostMessage::PlayerStates( Ok(ServerToHostMessage::PlayerStates(
night night

View File

@ -33,7 +33,7 @@ use crate::{
night::changes::{ChangesLookup, NightChange}, night::changes::{ChangesLookup, NightChange},
}, },
message::{ message::{
dead::DeadChatMessage, dead::{DeadChat, DeadChatMessage},
night::{ night::{
ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits, ActionPrompt, ActionPromptTitle, ActionResponse, ActionResult, ActionType, Visits,
}, },
@ -1224,8 +1224,12 @@ impl Night {
&self.village &self.village
} }
pub fn send_dead_chat_message(&mut self, msg: DeadChatMessage) -> Result<()> { pub fn dead_chat(&self) -> &DeadChat {
self.village.send_dead_chat_message(msg) self.village.dead_chat()
}
pub fn dead_chat_mut(&mut self) -> &mut DeadChat {
self.village.dead_chat_mut()
} }
#[cfg(test)] #[cfg(test)]

View File

@ -15,6 +15,7 @@
mod apply; mod apply;
use core::num::NonZeroU8; use core::num::NonZeroU8;
use chrono::{DateTime, Utc};
use rand::Rng; use rand::Rng;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -65,10 +66,22 @@ impl Village {
&self.dead_chat &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<()> { pub fn send_dead_chat_message(&mut self, msg: DeadChatMessage) -> Result<()> {
self.dead_chat.add(self.time, msg) 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<Utc>) -> Vec<DeadChatMessage> {
self.dead_chat.host_get_since(since)
}
pub fn settings(&self) -> GameSettings { pub fn settings(&self) -> GameSettings {
self.settings.clone() self.settings.clone()
} }

View File

@ -15,7 +15,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -33,6 +33,7 @@ type Result<T> = core::result::Result<T, GameError>;
pub struct DeadChat { pub struct DeadChat {
deaths: Vec<(GameTime, CharacterId)>, deaths: Vec<(GameTime, CharacterId)>,
messages: HashMap<GameTime, Vec<DeadChatMessage>>, messages: HashMap<GameTime, Vec<DeadChatMessage>>,
new_messages_from: Option<DateTime<Utc>>,
} }
impl DeadChat { impl DeadChat {
@ -49,6 +50,7 @@ impl DeadChat {
Self { Self {
messages, messages,
deaths: Vec::new(), deaths: Vec::new(),
new_messages_from: None,
} }
} }
@ -63,6 +65,10 @@ impl DeadChat {
} }
pub fn add(&mut self, time: GameTime, msg: DeadChatMessage) -> Result<()> { 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 if !self
.deaths .deaths
.iter() .iter()
@ -75,9 +81,28 @@ impl DeadChat {
} else { } else {
self.messages.insert(time, vec![msg]); self.messages.insert(time, vec![msg]);
} }
self.new_messages_from.get_or_insert(start);
Ok(()) 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( pub fn set_dead(
&mut self, &mut self,
dead: impl Iterator<Item = (GameTime, Character)> + Clone, dead: impl Iterator<Item = (GameTime, Character)> + Clone,
@ -129,6 +154,23 @@ impl DeadChat {
) { ) {
log::warn!("replaced: {existing:?}"); log::warn!("replaced: {existing:?}");
} }
self.new_messages_from.get_or_insert(Utc::now());
}
pub fn new_messages_since(&mut self) -> Option<DateTime<Utc>> {
self.new_messages_from.take()
}
pub fn host_get_since(&self, t: DateTime<Utc>) -> Vec<DeadChatMessage> {
let mut messages = self
.messages
.clone()
.into_iter()
.flat_map(|(_, msgs)| msgs.into_iter())
.filter(|m| m.timestamp >= t)
.collect::<Vec<_>>();
messages.sort_by_key(|m| m.timestamp);
messages
} }
pub fn get_since(&self, t: DateTime<Utc>, character: CharacterId) -> Box<[DeadChatMessage]> { pub fn get_since(&self, t: DateTime<Utc>, character: CharacterId) -> Box<[DeadChatMessage]> {
@ -173,6 +215,7 @@ pub enum DeadChatContent {
cause: DiedTo, cause: DiedTo,
}, },
TimeChange(GameTime), TimeChange(GameTime),
HostMessage(String),
} }
impl DeadChatContent { impl DeadChatContent {
@ -180,13 +223,14 @@ impl DeadChatContent {
match self { match self {
DeadChatContent::PlayerMessage { from, .. } => from.character_id == character_id, DeadChatContent::PlayerMessage { from, .. } => from.character_id == character_id,
DeadChatContent::Death { character, .. } => character.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> { pub const fn message(&self) -> Option<&str> {
match self { match self {
DeadChatContent::PlayerMessage { message, .. } => Some(message.as_str()), DeadChatContent::HostMessage(message)
| DeadChatContent::PlayerMessage { message, .. } => Some(message.as_str()),
_ => None, _ => None,
} }
} }

View File

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::num::NonZeroU8; use core::num::NonZeroU8;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
@ -22,6 +23,7 @@ use crate::{
game::{GameOver, GameSettings, story::GameStory}, game::{GameOver, GameSettings, story::GameStory},
message::{ message::{
CharacterIdentity, CharacterIdentity,
dead::DeadChatMessage,
night::{ActionPrompt, ActionResponse, ActionResult}, night::{ActionPrompt, ActionResponse, ActionResult},
}, },
player::PlayerId, player::PlayerId,
@ -48,6 +50,8 @@ pub enum PostGameMessage {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum HostGameMessage { pub enum HostGameMessage {
SendChatMessage(String),
GetDeadChatSince(DateTime<Utc>),
Day(HostDayMessage), Day(HostDayMessage),
Night(HostNightMessage), Night(HostNightMessage),
PreviousState, PreviousState,
@ -113,4 +117,6 @@ pub enum ServerToHostMessage {
story: GameStory, story: GameStory,
page: usize, page: usize,
}, },
DeadChat(Vec<DeadChatMessage>),
DeadChatMessage(DeadChatMessage),
} }

View File

@ -21,6 +21,7 @@ use crate::{
lobby::{Lobby, LobbyPlayers}, lobby::{Lobby, LobbyPlayers},
runner::{ClientUpdate, IdentifiedClientMessage, Message}, runner::{ClientUpdate, IdentifiedClientMessage, Message},
}; };
use chrono::Utc;
use tokio::time::Instant; use tokio::time::Instant;
use werewolves_proto::{ use werewolves_proto::{
character::Character, character::Character,
@ -259,6 +260,7 @@ impl GameRunner {
} }
pub async fn next(&mut self) -> Option<GameOver> { pub async fn next(&mut self) -> Option<GameOver> {
let start = Utc::now();
let msg = match self.comms.message().await { let msg = match self.comms.message().await {
Ok(Message::Client(IdentifiedClientMessage { Ok(Message::Client(IdentifiedClientMessage {
update: ClientUpdate::ConnectStateUpdate, update: ClientUpdate::ConnectStateUpdate,
@ -343,30 +345,48 @@ impl GameRunner {
}; };
let pre_time = self.game.village().time(); let pre_time = self.game.village().time();
let mut is_host_message = false;
match self.host_message(msg) { match self.host_message(msg) {
Ok(ServerToHostMessage::DeadChatMessage(msg)) => {
self.comms
.host()
.send(ServerToHostMessage::DeadChatMessage(msg))
.log_err();
is_host_message = true;
}
Ok(resp) => { Ok(resp) => {
self.comms.host().send(resp).log_warn(); self.comms.host().send(resp).log_err();
} }
Err(err) => { Err(err) => {
self.comms self.comms
.host() .host()
.send(ServerToHostMessage::Error(err)) .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(); let post_time = self.game.village().time();
if let Some(game_over) = self.game.game_over() { if let Some(game_over) = self.game.game_over() {
return Some(game_over); return Some(game_over);
} }
if pre_time != post_time { if pre_time != post_time || is_host_message {
let newly_dead = self let dead = self
.game .game
.village() .village()
.dead_characters() .dead_characters()
.into_iter() .into_iter()
.filter_map(|c| c.died_to().map(|_| (c.character_id(), c.player_id()))); .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 let msgs = self
.game .game
.village() .village()
@ -381,6 +401,13 @@ impl GameRunner {
} }
pub async fn send_dead_message(&mut self, msg: &DeadChatMessage) -> Result<()> { 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 let player_ids = self
.game .game
.village() .village()

View File

@ -15,6 +15,7 @@ web-sys = { version = "0.3", features = [
"HtmlSelectElement", "HtmlSelectElement",
"HtmlDialogElement", "HtmlDialogElement",
"DomRect", "DomRect",
"WheelEvent",
] } ] }
wasm-bindgen = { version = "=0.2.100" } wasm-bindgen = { version = "=0.2.100" }
log = "0.4" log = "0.4"

View File

@ -89,6 +89,9 @@ body {
user-select: none; user-select: none;
color: rgba(255, 255, 255, 1); color: rgba(255, 255, 255, 1);
background: black; background: black;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.7) black;
} }
app { 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_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); $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 { nav.host-nav {
position: sticky; position: sticky;
@ -140,7 +147,10 @@ nav.host-nav {
padding-left: 5vw; padding-left: 5vw;
padding-right: 5vw; padding-right: 5vw;
gap: 10px; gap: 10px;
height: $host_nav_height;
overflow-x: scroll;
scrollbar-width: none;
white-space: nowrap;
} }
@ -2093,7 +2103,6 @@ li.choice {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 30px; gap: 30px;
max-height: 40%;
@media only screen and (min-width : 1600px) { @media only screen and (min-width : 1600px) {
width: 100%; width: 100%;
@ -2956,6 +2965,10 @@ dialog {
gap: 3px; gap: 3px;
.chat-messages { .chat-messages {
&:first-child {
margin-top: auto;
}
user-select: text; user-select: text;
padding-inline-start: 0px; padding-inline-start: 0px;
overflow-y: scroll; overflow-y: scroll;
@ -2965,10 +2978,7 @@ dialog {
flex-grow: 1; flex-grow: 1;
max-width: 100%; max-width: 100%;
max-height: 95vh; max-height: 95vh;
justify-content: flex-end;
// scrollbar-width: thin;
scrollbar-width: none;
scrollbar-color: rgba(255, 255, 255, 0.7) black;
} }
.message { .message {
@ -3127,3 +3137,8 @@ dialog {
flex-wrap: wrap; flex-wrap: wrap;
gap: 1ch; gap: 1ch;
} }
.host-dead-chat {
height: calc(100vh - $host_nav_total_height);
width: 100%;
}

View File

@ -29,6 +29,7 @@ use werewolves_proto::{
game::{GameOver, GameSettings, story::GameStory}, game::{GameOver, GameSettings, story::GameStory},
message::{ message::{
CharacterIdentity, CharacterState, PlayerState, PublicIdentity, CharacterIdentity, CharacterState, PlayerState, PublicIdentity,
dead::DeadChatMessage,
host::{ host::{
HostDayMessage, HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage, HostDayMessage, HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage,
PostGameMessage, ServerToHostMessage, PostGameMessage, ServerToHostMessage,
@ -44,11 +45,13 @@ use crate::{
components::{ components::{
Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Victory, Button, Footer, Lobby, LobbyPlayerAction, RoleReveal, Victory,
action::{ActionResultView, Prompt}, action::{ActionResultView, Prompt},
chat::DeadChat,
host::{CharacterStatesReadOnly, DaytimePlayerList, Setup, VotingMode}, host::{CharacterStatesReadOnly, DaytimePlayerList, Setup, VotingMode},
settings::Settings, settings::Settings,
story::Story, story::Story,
}, },
pages::RolePage, pages::RolePage,
scroll::vertical_scroll_to_horizontal,
storage::StorageKey, storage::StorageKey,
test_util::TestScreens, test_util::TestScreens,
}; };
@ -204,6 +207,8 @@ pub enum HostEvent {
ToOverrideView, ToOverrideView,
ReturnFromOverride, ReturnFromOverride,
ExpectEcho(Box<HostEvent>), ExpectEcho(Box<HostEvent>),
DeadChat(Vec<DeadChatMessage>),
DeadChatMessage(DeadChatMessage),
} }
#[derive(Debug, Clone, PartialEq, Titles)] #[derive(Debug, Clone, PartialEq, Titles)]
pub enum HostState { pub enum HostState {
@ -240,11 +245,16 @@ pub enum HostState {
page: usize, page: usize,
}, },
CharacterStates(Box<[CharacterState]>), CharacterStates(Box<[CharacterState]>),
InChat {
previously: Box<HostState>,
},
} }
impl From<ServerToHostMessage> for HostEvent { impl From<ServerToHostMessage> for HostEvent {
fn from(msg: ServerToHostMessage) -> Self { fn from(msg: ServerToHostMessage) -> Self {
match msg { match msg {
ServerToHostMessage::DeadChatMessage(msg) => HostEvent::DeadChatMessage(msg),
ServerToHostMessage::DeadChat(msgs) => HostEvent::DeadChat(msgs),
ServerToHostMessage::PlayerStates(states) => HostEvent::CharacterList(states), ServerToHostMessage::PlayerStates(states) => HostEvent::CharacterList(states),
ServerToHostMessage::QrMode(mode) => HostEvent::QrMode(mode), ServerToHostMessage::QrMode(mode) => HostEvent::QrMode(mode),
ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected), ServerToHostMessage::Disconnect => HostEvent::SetState(HostState::Disconnected),
@ -292,6 +302,7 @@ pub struct Host {
qr_mode: bool, qr_mode: bool,
debug: bool, debug: bool,
expecting_echo: Option<HostEvent>, expecting_echo: Option<HostEvent>,
dead_chat: Option<Vec<DeadChatMessage>>,
} }
impl Component for Host { impl Component for Host {
@ -323,11 +334,67 @@ impl Component for Host {
} }
}), }),
expecting_echo: None, expecting_echo: None,
dead_chat: None,
} }
} }
fn view(&self, _ctx: &Context<Self>) -> Html { fn view(&self, _ctx: &Context<Self>) -> 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() { 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! {
<div class="host-dead-chat">
<DeadChat on_send={on_send} messages={messages}/>
</div>
}
}
HostState::VotingMode { characters, .. } => { HostState::VotingMode { characters, .. } => {
html! { html! {
<VotingMode characters={characters.clone()}/> <VotingMode characters={characters.clone()}/>
@ -508,56 +575,66 @@ impl Component for Host {
} }
} }
}; };
let voting_mode_btn = { let mut nav_buttons = Vec::<Html>::new();
match &self.state { let dead_chat_btn = {
HostState::Day { characters, .. } => { let on_dead_chat = {
let on_vote_mode = { let scope = _ctx.link().clone();
let scope = _ctx.link().clone(); let state = self.state.clone();
let state = self.state.clone(); move |_| {
let characters = characters.clone(); scope.send_message(HostEvent::SetState(HostState::InChat {
move |_| { previously: Box::new(state.clone()),
scope.send_message(HostEvent::SetState(HostState::VotingMode { }))
return_to: Box::new(state.clone()),
characters: characters.clone(),
}))
}
};
Some(html! {
<Button on_click={on_vote_mode}>{"voting mode"}</Button>
})
} }
HostState::VotingMode { .. } => { };
let back = html! {
crate::callback::send_message(HostMessage::GetState, self.send.clone()); <Button on_click={on_dead_chat}>{"chat"}</Button>
Some(html! {
<Button on_click={back}>{"back"}</Button>
})
}
_ => None,
} }
}; };
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! {
<Button on_click={on_vote_mode}>{"voting mode"}</Button>
});
nav_buttons.push(dead_chat_btn);
}
HostState::VotingMode { .. } => {
let back = crate::callback::send_message(HostMessage::GetState, self.send.clone());
nav_buttons.push(html! {
<Button on_click={back}>{"back"}</Button>
});
}
HostState::Prompt(_, _) | HostState::Result(_, _) => { HostState::Prompt(_, _) | HostState::Result(_, _) => {
let on_prev_click = callback::send_message(
HostMessage::InGame(HostGameMessage::PreviousState),
self.send.clone(),
);
nav_buttons.push(html! {
<Button on_click={on_prev_click}>{"previous"}</Button>
});
let on_view_click = crate::callback::send_message( let on_view_click = crate::callback::send_message(
HostMessage::InGame(HostGameMessage::SeePlayersWithRoles), HostMessage::InGame(HostGameMessage::SeePlayersWithRoles),
self.send.clone(), self.send.clone(),
); );
Some(html! { nav_buttons.push(html! {
<Button on_click={on_view_click}>{"view players"}</Button> <Button on_click={on_view_click}>{"view players"}</Button>
}) });
}
HostState::CharacterStates(_) => {
let back = crate::callback::send_message(HostMessage::GetState, self.send.clone());
Some(html! {
<Button on_click={back}>{"back"}</Button>
})
}
_ => None,
};
let override_screens_btn = match &self.state {
HostState::Prompt(_, _) | HostState::Result(_, _) => {
let overrides_click = { let overrides_click = {
let scope = _ctx.link().clone(); let scope = _ctx.link().clone();
Callback::from(move |_| { Callback::from(move |_| {
@ -565,39 +642,12 @@ impl Component for Host {
}) })
}; };
Some(html! { nav_buttons.push(html! {
<Button on_click={overrides_click}>{"overrides"}</Button> <Button on_click={overrides_click}>{"overrides"}</Button>
}) });
}
HostState::ScreenOverrides { .. } => {
let return_click = {
let scope = _ctx.link().clone();
Callback::from(move |_| {
scope.send_message(HostEvent::ReturnFromOverride);
})
};
Some(html! { nav_buttons.push(dead_chat_btn);
<Button on_click={return_click}>{"back"}</Button>
})
}
_ => 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(),
);
Some(html! {
<Button on_click={on_prev_click}>{"previous"}</Button>
})
}
_ => None,
};
let skip_btn = match &self.state {
HostState::Prompt(_, _) | HostState::Result(_, _) => {
let on_skip_click = callback::send_message( let on_skip_click = callback::send_message(
HostMessage::InGame(HostGameMessage::Night(HostNightMessage::SkipAction)), HostMessage::InGame(HostGameMessage::Night(HostNightMessage::SkipAction)),
self.send.clone(), self.send.clone(),
@ -609,7 +659,7 @@ impl Component for Host {
}) })
}; };
Some(html! { nav_buttons.push(html! {
<crate::components::modal::Dialog <crate::components::modal::Dialog
id={"skip-button"} id={"skip-button"}
button={html!{{"skip"}}} button={html!{{"skip"}}}
@ -619,18 +669,47 @@ impl Component for Host {
<p>{"if this is the final prompt of the night, you may not be able to go back"}</p> <p>{"if this is the final prompt of the night, you may not be able to go back"}</p>
<Button on_click={on_skip_click}>{"skip prompt"}</Button> <Button on_click={on_skip_click}>{"skip prompt"}</Button>
</crate::components::modal::Dialog> </crate::components::modal::Dialog>
}) });
} }
_ => None, HostState::CharacterStates(_) => {
}; let back = crate::callback::send_message(HostMessage::GetState, self.send.clone());
nav_buttons.push(html! {
<Button on_click={back}>{"back"}</Button>
});
}
HostState::ScreenOverrides { .. } => {
let return_click = {
let scope = _ctx.link().clone();
Callback::from(move |_| {
scope.send_message(HostEvent::ReturnFromOverride);
})
};
nav_buttons.push(html! {
<Button on_click={return_click}>{"back"}</Button>
});
}
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! {
<Button on_click={return_click}>{"back"}</Button>
});
}
_ => {}
}
let on_wheel = vertical_scroll_to_horizontal(".host-nav");
let nav = self.big_screen.not().then(|| { let nav = self.big_screen.not().then(|| {
html! { html! {
<nav class="host-nav" style="z-index: 3;"> <nav class="host-nav" style="z-index: 3;" onwheel={on_wheel}>
{previous_btn} {nav_buttons}
{view_roles_btn}
{override_screens_btn}
{skip_btn}
{voting_mode_btn}
</nav> </nav>
} }
}); });
@ -736,8 +815,41 @@ impl Component for Host {
} }
impl Host { impl Host {
fn message_to_new_state(&self, msg: HostEvent) -> (Option<HostState>, bool) { fn message_to_new_state(&mut self, msg: HostEvent) -> (Option<HostState>, bool) {
match msg { match msg {
HostEvent::DeadChatMessage(msg) => {
match self.dead_chat.as_mut() {
Some(dc) => {
dc.push(msg);
dc.sort_by_key(|d| d.timestamp);
}
None => {
self.dead_chat.replace(vec![msg]);
}
}
if let HostState::InChat { .. } = &self.state {
(None, true)
} else {
(None, false)
}
}
HostEvent::DeadChat(mut msgs) => {
match self.dead_chat.as_mut() {
Some(chat) => {
chat.append(&mut msgs);
chat.sort_by_key(|k| k.timestamp);
chat.dedup_by_key(|k| k.id);
}
None => {
self.dead_chat.replace(msgs);
}
}
if let HostState::InChat { .. } = &self.state {
(None, true)
} else {
(None, false)
}
}
HostEvent::ExpectEcho(_) => (None, false), HostEvent::ExpectEcho(_) => (None, false),
HostEvent::ReturnFromOverride => { HostEvent::ReturnFromOverride => {
if let HostState::ScreenOverrides { return_to } = &self.state { if let HostState::ScreenOverrides { return_to } = &self.state {
@ -776,6 +888,7 @@ impl Host {
.unwrap_or(LAST) .unwrap_or(LAST)
.cmp(&r.identification.public.number.unwrap_or(LAST)) .cmp(&r.identification.public.number.unwrap_or(LAST))
}); });
self.dead_chat = None;
( (
Some(HostState::Lobby { Some(HostState::Lobby {
settings, settings,

View File

@ -23,7 +23,7 @@ use werewolves_proto::message::{
dead::{DeadChatContent, DeadChatMessage}, dead::{DeadChatContent, DeadChatMessage},
}; };
use crate::components::{Icon, IconSource, IconType, attributes::DiedToSpan}; use crate::components::{Icon, IconSource, IconType};
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct DeadChatProperties { pub struct DeadChatProperties {
@ -185,6 +185,18 @@ pub fn ChatMessage(
}: &ChatMessageProps, }: &ChatMessageProps,
) -> Html { ) -> Html {
match message { match message {
DeadChatContent::HostMessage(message) => {
html! {
<li class="message">
<Timestamp timestamp={*timestamp} />
<span class="red">
// <Icon source={IconSource::Adjudicator} icon_type={IconType::Fit}/>
<strong>{"Host"}</strong>
</span>
<span class="message-content">{message.clone()}</span>
</li>
}
}
DeadChatContent::PlayerMessage { from, message } => { DeadChatContent::PlayerMessage { from, message } => {
html! { html! {
<li class="message"> <li class="message">
@ -202,7 +214,6 @@ pub fn ChatMessage(
<DeadChatIdent ident={character.clone().into_public()}/> <DeadChatIdent ident={character.clone().into_public()}/>
<span class="message-content"> <span class="message-content">
{"died to "} {"died to "}
// <DiedToSpan died_to={cause.title()}/>
{cause.title().to_string()} {cause.title().to_string()}
</span> </span>
</li> </li>

View File

@ -15,6 +15,7 @@
mod assets; mod assets;
mod class; mod class;
mod clients; mod clients;
mod scroll;
mod storage; mod storage;
mod test_util; mod test_util;
mod components { mod components {

21
werewolves/src/scroll.rs Normal file
View File

@ -0,0 +1,21 @@
use yew::WheelEvent;
pub fn vertical_scroll_to_horizontal(selector: &str) -> impl Fn(WheelEvent) {
let selector = selector.to_string();
move |ev: WheelEvent| {
let scroll_y = ev.delta_y();
if scroll_y == 0.0 {
return;
}
let Some(target) = gloo::utils::document()
.query_selector(&selector)
.ok()
.flatten()
else {
return;
};
target.set_scroll_left(target.scroll_left() + ((scroll_y + ev.delta_x()) as i32));
ev.prevent_default();
}
}