game cancelling by host

This commit is contained in:
emilis 2026-02-18 00:16:57 +00:00
parent 6850da7555
commit 6b3cce5e37
No known key found for this signature in database
13 changed files with 227 additions and 47 deletions

View File

@ -732,3 +732,16 @@ dialog .tab-content {
} }
} }
.dialog-modal:has(.cancel-game-button) {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 1ch;
}
.scary {
color: red;
border: 1px solid red;
background-color: black;
}

View File

@ -149,6 +149,10 @@ pub enum ServerError {
InvalidRequest(String), InvalidRequest(String),
#[error("you're already in an active game: {0}")] #[error("you're already in an active game: {0}")]
AlreadyInActiveGame(GameId), AlreadyInActiveGame(GameId),
#[error("not your game")]
NotYourGame,
#[error("this game is already over")]
GameAlreadyOver,
#[error("{0}")] #[error("{0}")]
GameError(#[from] GameError), GameError(#[from] GameError),
} }
@ -236,11 +240,11 @@ impl axum::response::IntoResponse for ServerError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
use axum::{Json, http::StatusCode}; use axum::{Json, http::StatusCode};
( (
match self { match self {
ServerError::AlreadyInActiveGame(_) ServerError::NotYourGame
| ServerError::GameAlreadyOver
| ServerError::AlreadyInActiveGame(_)
| ServerError::GameError(_) | ServerError::GameError(_)
| ServerError::InvalidCredentials | ServerError::InvalidCredentials
| ServerError::InvalidRequest(_) | ServerError::InvalidRequest(_)

View File

@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
character::CharacterId, character::CharacterId,
error::GameError, error::{GameError, ServerError},
game::{GameOver, GameSettings, story::GameStory}, game::{GameOver, GameSettings, story::GameStory},
message::{ message::{
CharacterIdentity, CharacterIdentity,
@ -39,6 +39,7 @@ pub enum HostMessage {
ForceRoleAckFor(CharacterId), ForceRoleAckFor(CharacterId),
PostGame(PostGameMessage), PostGame(PostGameMessage),
Echo(ServerToHostMessage), Echo(ServerToHostMessage),
CancelGame,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -107,7 +108,7 @@ pub enum ServerToHostMessage {
settings: GameSettings, settings: GameSettings,
qr_mode: bool, qr_mode: bool,
}, },
Error(GameError), Error(ServerError),
GameOver(GameOver), GameOver(GameOver),
WaitingForRoleRevealAcks { WaitingForRoleRevealAcks {
ackd: Box<[CharacterIdentity]>, ackd: Box<[CharacterIdentity]>,

View File

@ -56,18 +56,22 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
} }
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
pub struct TutorialSettings { pub struct Preferences {
pub enabled: bool, pub tutorials_enabled: bool,
pub show_cancel_game: bool,
} }
impl Default for TutorialSettings { impl Default for Preferences {
fn default() -> Self { fn default() -> Self {
Self { enabled: true } Self {
tutorials_enabled: true,
show_cancel_game: true,
}
} }
} }
impl Stored for TutorialSettings { impl Stored for Preferences {
const STORAGE_KEY: &str = "tutorial-settings"; const STORAGE_KEY: &str = "preferences";
} }
#[component] #[component]
@ -81,9 +85,9 @@ pub fn App() -> impl IntoView {
Effect::new(move || auth_store.init_or_update()); Effect::new(move || auth_store.init_or_update());
Effect::new(move || session_store.init_or_update()); Effect::new(move || session_store.init_or_update());
let (tut_read, tut_write, _) = let (pref_read, pref_write, _) =
use_local_storage::<TutorialSettings, JsonSerdeCodec>(TutorialSettings::STORAGE_KEY); use_local_storage::<Preferences, JsonSerdeCodec>(Preferences::STORAGE_KEY);
provide_context((tut_read, tut_write)); provide_context((pref_read, pref_write));
let is_logged_in = move || { let is_logged_in = move || {
auth_store auth_store

View File

@ -6,7 +6,7 @@ use werewolves_proto::{
player::PlayerId, player::PlayerId,
}; };
use crate::app::TutorialSettings; use crate::app::Preferences;
pub trait Sample { pub trait Sample {
fn sample() -> Self; fn sample() -> Self;
@ -51,11 +51,10 @@ impl Sample for Identification {
#[component] #[component]
pub fn TutorialBox(children: Children) -> impl IntoView { pub fn TutorialBox(children: Children) -> impl IntoView {
let sample_hidden = RwSignal::new(false); let sample_hidden = RwSignal::new(false);
let (tut_read, _) = let (tut_read, _) = expect_context::<(Signal<Preferences>, WriteSignal<Preferences>)>();
expect_context::<(Signal<TutorialSettings>, WriteSignal<TutorialSettings>)>();
view! { view! {
<div class="tutorial-box" hidden=move || !tut_read.read().enabled> <div class="tutorial-box" hidden=move || !tut_read.read().tutorials_enabled>
<button <button
class="hide" class="hide"
hidden=move || sample_hidden.get() hidden=move || sample_hidden.get()

View File

@ -11,6 +11,10 @@ use werewolves_proto::{
}, },
}; };
#[cfg(feature = "hydrate")]
use crate::ConsoleLogError;
use crate::app::{Preferences, components::DialogModal};
#[component] #[component]
pub fn HostGamePage( pub fn HostGamePage(
message: Signal<Option<Srv2Host>>, message: Signal<Option<Srv2Host>>,
@ -72,5 +76,42 @@ pub fn HostGamePage(
} }
}; };
view! { {content} } view! {
<CancelGame reply=reply />
{content}
}
}
#[component]
fn CancelGame(reply: WriteSignal<Option<HostMessage>>) -> impl IntoView {
let open = RwSignal::new(false);
let prefs = expect_context::<(Signal<Preferences>, WriteSignal<Preferences>)>().0;
let cancel = move |_| {
open.set(false);
reply.set(Some(HostMessage::CancelGame));
#[cfg(feature = "hydrate")]
gloo::utils::window()
.location()
.replace("/")
.console_log_warn();
};
let content = move || match prefs.get().show_cancel_game {
true => view! {
<DialogModal
button_class="cancel-game-button".into()
text="cancel game".into()
open=open
>
<h1>"this will cancel the game. are you sure?"</h1>
<button on:click=cancel class="scary">
"i'm sure, cancel the game."
</button>
</DialogModal>
}
.into_any(),
false => ().into_any(),
};
view! {
{content}
}
} }

View File

@ -56,7 +56,6 @@ pub fn PlayerLobby(
number.set(Some(nz)); number.set(Some(nz));
} else { } else {
e.target().set_value(default.as_str()); e.target().set_value(default.as_str());
return;
} }
}; };
let submit = move |ev: SubmitEvent| { let submit = move |ev: SubmitEvent| {

View File

@ -3,7 +3,7 @@ use leptos::{ev::MouseEvent, prelude::*};
use reactive_stores::Store; use reactive_stores::Store;
use crate::app::{ use crate::app::{
TutorialSettings, Preferences,
storage::user::{AuthContext, AuthContextStoreFields}, storage::user::{AuthContext, AuthContextStoreFields},
}; };
@ -18,14 +18,35 @@ pub fn UserSettings() -> impl IntoView {
}; };
view! { <button on:click=click>"log out"</button> } view! { <button on:click=click>"log out"</button> }
}; };
let (tut_read, tut_write) = let (prefs_read, prefs_write) =
expect_context::<(Signal<TutorialSettings>, WriteSignal<TutorialSettings>)>(); expect_context::<(Signal<Preferences>, WriteSignal<Preferences>)>();
let tutorial_toggle_button = move || { let tutorial_toggle_button = move || match prefs_read.read().tutorials_enabled {
match tut_read.read().enabled { true => view! {
true => view! { <button on:click=move |_| tut_write.write().enabled = false>"disable tutorials"</button> } <button on:click=move |_| {
prefs_write.write().tutorials_enabled = false;
}>"disable tutorials"</button>
}
.into_any(),
false => view! {
<button on:click=move |_| {
prefs_write.write().tutorials_enabled = true;
}>"enable tutorials"</button>
}
.into_any(),
};
let cancel_game_toggle_button = move || match prefs_read.read().show_cancel_game {
true => view! {
<button on:click=move |_| {
prefs_write.write().show_cancel_game = false;
}>"disable \"cancel game\" button"</button>
}
.into_any(),
false => view! {
<button on:click=move |_| {
prefs_write.write().show_cancel_game = true;
}>"enable \"cancel game\" button"</button>
}
.into_any(), .into_any(),
false => view! { <button on:click=move |_| tut_write.write().enabled = true>"enable tutorials"</button> }.into_any(),
}
}; };
view! { view! {
<ul class="user-settings-list"> <ul class="user-settings-list">
@ -36,6 +57,7 @@ pub fn UserSettings() -> impl IntoView {
<ChangePasswordButton /> <ChangePasswordButton />
</li> </li>
<li>{tutorial_toggle_button}</li> <li>{tutorial_toggle_button}</li>
<li>{cancel_game_toggle_button}</li>
<li class="logout">{log_out}</li> <li class="logout">{log_out}</li>
</ul> </ul>
} }

View File

@ -109,7 +109,7 @@ impl GameDatabase {
from from
games games
where where
id = $1 id = $1 and game_status != 'Cancelled'::game_status
"#, "#,
game.into_uuid(), game.into_uuid(),
) )
@ -372,6 +372,32 @@ impl GameDatabase {
Ok(()) Ok(())
} }
pub async fn cancel_game(&self, user: PlayerId, game_id: GameId) -> ServerResult<()> {
let game = self.get_game(game_id).await?;
if user != game.host {
return Err(ServerError::NotYourGame);
}
if matches!(game.game_state, GameRecordState::GameOver(_)) {
return Err(ServerError::GameAlreadyOver);
}
query!(
r#"
update
games
set
game_status = 'Cancelled'::game_status
where
id = $1"#,
game_id.into_uuid(),
)
.execute(&self.pool)
.await
.into_db_result()?;
Ok(())
}
pub async fn get_dead_chat(&self, game_id: GameId) -> ServerResult<Box<[DeadChatMessage]>> { pub async fn get_dead_chat(&self, game_id: GameId) -> ServerResult<Box<[DeadChatMessage]>> {
Ok(query!( Ok(query!(
r#" r#"

View File

@ -13,9 +13,10 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// 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 crate::db::Database;
use crate::server::runner::{ClientUpdate, HostOrClientMessage, IdentifiedClientMessage}; use crate::server::runner::{ClientUpdate, HostOrClientMessage, IdentifiedClientMessage};
use werewolves_proto::game::GameId;
use chrono::Utc; use chrono::Utc;
use werewolves_proto::game::GameId;
use werewolves_proto::{ use werewolves_proto::{
error::GameError, error::GameError,
game::{Game, GameOver}, game::{Game, GameOver},
@ -32,11 +33,13 @@ pub struct GameRunner<'a> {
game_id: GameId, game_id: GameId,
game: &'a mut Game, game: &'a mut Game,
roles_revealed: bool, roles_revealed: bool,
db: Database,
} }
impl<'a> GameRunner<'a> { impl<'a> GameRunner<'a> {
pub fn new(game_id: GameId, game: &'a mut Game) -> Self { pub fn new(game_id: GameId, game: &'a mut Game, db: Database) -> Self {
Self { Self {
db,
game, game,
game_id, game_id,
roles_revealed: false, roles_revealed: false,
@ -136,6 +139,24 @@ impl<'a> GameRunner<'a> {
let pre_time = self.game.village().time(); let pre_time = self.game.village().time();
let mut is_host_message = false; let mut is_host_message = false;
if let HostMessage::CancelGame = &msg {
if let Err(err) = self
.db
.game()
.cancel_game(
self.db.game().get_game(self.game_id).await.ok()?.host,
self.game_id,
)
.await
{
super::send_host(self.game_id, ServerToHostMessage::Error(err)).await;
return None;
}
let game_id = self.game_id;
tokio::spawn(async move {
crate::server::delete_game(game_id).await;
});
}
match self.host_message(msg) { match self.host_message(msg) {
Ok(ServerToHostMessage::DeadChatMessage(msg)) => { Ok(ServerToHostMessage::DeadChatMessage(msg)) => {
super::send_host(self.game_id, ServerToHostMessage::DeadChatMessage(msg)).await; super::send_host(self.game_id, ServerToHostMessage::DeadChatMessage(msg)).await;
@ -145,7 +166,7 @@ impl<'a> GameRunner<'a> {
super::send_host(self.game_id, resp).await; super::send_host(self.game_id, resp).await;
} }
Err(err) => { Err(err) => {
super::send_host(self.game_id, ServerToHostMessage::Error(err)).await; super::send_host(self.game_id, ServerToHostMessage::Error(err.into())).await;
} }
} }
let messages_for_host = self.game.dead_chats_since(start); let messages_for_host = self.game.dead_chats_since(start);
@ -222,6 +243,12 @@ impl<'a> GameRunner<'a> {
Err(GameError::GameOngoing) Err(GameError::GameOngoing)
} }
HostMessage::Echo(echo) => Ok(echo), HostMessage::Echo(echo) => Ok(echo),
HostMessage::CancelGame => {
log::error!("CancelGame should be handled above");
Ok(ServerToHostMessage::Error(
GameError::GenericError("CancelGame should be handled above".into()).into(),
))
}
} }
} }
} }

View File

@ -134,7 +134,7 @@ impl<'a> Lobby<'a> {
| Ok(None) => None, | Ok(None) => None,
Ok(Some(game)) => Some(game), Ok(Some(game)) => Some(game),
Err((HostOrClientMessage::Host(_), ServerError::GameError(err))) => { Err((HostOrClientMessage::Host(_), ServerError::GameError(err))) => {
super::send_host(self.game_id, ServerToHostMessage::Error(err)).await; super::send_host(self.game_id, ServerToHostMessage::Error(err.into())).await;
None None
} }
Err((HostOrClientMessage::Host(_), err)) => { Err((HostOrClientMessage::Host(_), err)) => {
@ -142,11 +142,7 @@ impl<'a> Lobby<'a> {
"server error from host request for game({}): {err}", "server error from host request for game({}): {err}",
self.game_id self.game_id
); );
super::send_host( super::send_host(self.game_id, ServerToHostMessage::Error(err)).await;
self.game_id,
ServerToHostMessage::Error(GameError::GenericError(err.to_string())),
)
.await;
None None
} }
Err(( Err((
@ -183,6 +179,19 @@ impl<'a> Lobby<'a> {
msg: HostOrClientMessage, msg: HostOrClientMessage,
) -> Result<Option<GameRecord>, ServerError> { ) -> Result<Option<GameRecord>, ServerError> {
match msg { match msg {
HostOrClientMessage::Host(HostMessage::CancelGame) => {
self.db
.game()
.cancel_game(
self.db.game().get_game(self.game_id).await?.host,
self.game_id,
)
.await?;
let game_id = self.game_id;
tokio::spawn(async move {
crate::server::delete_game(game_id).await;
});
}
HostOrClientMessage::Client(IdentifiedClientMessage { HostOrClientMessage::Client(IdentifiedClientMessage {
update: ClientUpdate::Message(ClientMessage::DeadChat(_)), update: ClientUpdate::Message(ClientMessage::DeadChat(_)),
.. ..

View File

@ -8,10 +8,7 @@ pub mod qr;
pub mod role_reveal; pub mod role_reveal;
pub mod runner; pub mod runner;
use core::{ use core::{fmt::Display, hash::BuildHasherDefault};
fmt::Display,
hash::BuildHasherDefault,
};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
hash::DefaultHasher, hash::DefaultHasher,
@ -382,3 +379,19 @@ pub async fn clear_abandoned_game_runners() {
"[clear_abandoned_game_runners]".bold() "[clear_abandoned_game_runners]".bold()
) )
} }
pub async fn delete_game(game: GameId) {
let mut hosts = HOST_CHANNELS.write().await;
let mut players = PLAYER_CHANNELS.write().await;
if let Some(GameChannels { runner_handle, .. }) = hosts.remove(&game) {
runner_handle.abort();
}
let player_keys = players
.keys()
.filter(|(gid, _)| *gid == game)
.copied()
.collect::<Box<_>>();
for key in player_keys {
players.remove(&key);
}
}

View File

@ -40,6 +40,7 @@ async fn next_message(
game_id: GameId, game_id: GameId,
client_recv: &mut UnboundedReceiver<IdentifiedClientMessage>, client_recv: &mut UnboundedReceiver<IdentifiedClientMessage>,
host_recv: &mut UnboundedReceiver<HostMessage>, host_recv: &mut UnboundedReceiver<HostMessage>,
db: &Database,
) -> Option<HostOrClientMessage> { ) -> Option<HostOrClientMessage> {
tokio::select! { tokio::select! {
client_msg = client_recv.recv() => { client_msg = client_recv.recv() => {
@ -53,6 +54,11 @@ async fn next_message(
} }
host_msg = host_recv.recv() => { host_msg = host_recv.recv() => {
match host_msg { match host_msg {
Some(HostMessage::CancelGame) => {
cancel_game(game_id, db).await;
log::info!("got game cancellation request for {game_id}");
None
}
Some(msg) => Some(HostOrClientMessage::Host(msg)), Some(msg) => Some(HostOrClientMessage::Host(msg)),
None => { None => {
log::warn!("host recv for game {game_id} got None; exiting"); log::warn!("host recv for game {game_id} got None; exiting");
@ -63,6 +69,21 @@ async fn next_message(
} }
} }
async fn cancel_game(game_id: GameId, db: &Database) {
let game = match db.game().get_game(game_id).await {
Ok(g) => g,
Err(err) => {
log::error!("get game {game_id} for cancellation: {err}");
return;
}
};
if let Err(err) = db.game().cancel_game(game.host, game_id).await {
log::error!("error cancelling game: {err}");
return;
}
tokio::spawn(crate::server::delete_game(game_id));
}
async fn add_dummies(game_id: GameId, db: &Database, dummy_count: usize) { async fn add_dummies(game_id: GameId, db: &Database, dummy_count: usize) {
let Ok(joined) = db.game().get_joined_players(game_id).await else { let Ok(joined) = db.game().get_joined_players(game_id).await else {
return; return;
@ -107,7 +128,7 @@ pub async fn run_game(
if let Some(new_record) = lobby if let Some(new_record) = lobby
.process( .process(
if let Some(msg) = if let Some(msg) =
next_message(game_id, &mut client_recv, &mut host_recv).await next_message(game_id, &mut client_recv, &mut host_recv, &db).await
{ {
msg msg
} else { } else {
@ -133,7 +154,7 @@ pub async fn run_game(
if role_reveal if role_reveal
.process( .process(
if let Some(msg) = if let Some(msg) =
next_message(game_id, &mut client_recv, &mut host_recv).await next_message(game_id, &mut client_recv, &mut host_recv, &db).await
{ {
msg msg
} else { } else {
@ -152,13 +173,14 @@ pub async fn run_game(
} }
} }
GameRecordState::Started(mut current_game) => { GameRecordState::Started(mut current_game) => {
let mut runner = GameRunner::new(game_id, &mut current_game); let mut runner = GameRunner::new(game_id, &mut current_game, db.clone());
loop { loop {
if runner.game_over().is_some() if runner.game_over().is_some()
|| runner || runner
.process( .process(
if let Some(msg) = if let Some(msg) =
next_message(game_id, &mut client_recv, &mut host_recv).await next_message(game_id, &mut client_recv, &mut host_recv, &db)
.await
{ {
msg msg
} else { } else {
@ -184,7 +206,7 @@ pub async fn run_game(
story story
.process( .process(
if let Some(msg) = if let Some(msg) =
next_message(game_id, &mut client_recv, &mut host_recv).await next_message(game_id, &mut client_recv, &mut host_recv, &db).await
{ {
msg msg
} else { } else {