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

View File

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

View File

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

View File

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

View File

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

View File

@ -109,7 +109,7 @@ impl GameDatabase {
from
games
where
id = $1
id = $1 and game_status != 'Cancelled'::game_status
"#,
game.into_uuid(),
)
@ -372,6 +372,32 @@ impl GameDatabase {
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]>> {
Ok(query!(
r#"

View File

@ -13,9 +13,10 @@
// 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/>.
use crate::db::Database;
use crate::server::runner::{ClientUpdate, HostOrClientMessage, IdentifiedClientMessage};
use werewolves_proto::game::GameId;
use chrono::Utc;
use werewolves_proto::game::GameId;
use werewolves_proto::{
error::GameError,
game::{Game, GameOver},
@ -32,11 +33,13 @@ pub struct GameRunner<'a> {
game_id: GameId,
game: &'a mut Game,
roles_revealed: bool,
db: Database,
}
impl<'a> GameRunner<'a> {
pub fn new(game_id: GameId, game: &'a mut Game) -> Self {
pub fn new(game_id: GameId, game: &'a mut Game, db: Database) -> Self {
Self {
db,
game,
game_id,
roles_revealed: false,
@ -136,6 +139,24 @@ impl<'a> GameRunner<'a> {
let pre_time = self.game.village().time();
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) {
Ok(ServerToHostMessage::DeadChatMessage(msg)) => {
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;
}
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);
@ -222,6 +243,12 @@ impl<'a> GameRunner<'a> {
Err(GameError::GameOngoing)
}
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(Some(game)) => Some(game),
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
}
Err((HostOrClientMessage::Host(_), err)) => {
@ -142,11 +142,7 @@ impl<'a> Lobby<'a> {
"server error from host request for game({}): {err}",
self.game_id
);
super::send_host(
self.game_id,
ServerToHostMessage::Error(GameError::GenericError(err.to_string())),
)
.await;
super::send_host(self.game_id, ServerToHostMessage::Error(err)).await;
None
}
Err((
@ -183,6 +179,19 @@ impl<'a> Lobby<'a> {
msg: HostOrClientMessage,
) -> Result<Option<GameRecord>, ServerError> {
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 {
update: ClientUpdate::Message(ClientMessage::DeadChat(_)),
..

View File

@ -8,10 +8,7 @@ pub mod qr;
pub mod role_reveal;
pub mod runner;
use core::{
fmt::Display,
hash::BuildHasherDefault,
};
use core::{fmt::Display, hash::BuildHasherDefault};
use std::{
collections::{HashMap, HashSet},
hash::DefaultHasher,
@ -382,3 +379,19 @@ pub async fn clear_abandoned_game_runners() {
"[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,
client_recv: &mut UnboundedReceiver<IdentifiedClientMessage>,
host_recv: &mut UnboundedReceiver<HostMessage>,
db: &Database,
) -> Option<HostOrClientMessage> {
tokio::select! {
client_msg = client_recv.recv() => {
@ -53,6 +54,11 @@ async fn next_message(
}
host_msg = host_recv.recv() => {
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)),
None => {
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) {
let Ok(joined) = db.game().get_joined_players(game_id).await else {
return;
@ -107,7 +128,7 @@ pub async fn run_game(
if let Some(new_record) = lobby
.process(
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
} else {
@ -133,7 +154,7 @@ pub async fn run_game(
if role_reveal
.process(
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
} else {
@ -152,13 +173,14 @@ pub async fn run_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 {
if runner.game_over().is_some()
|| runner
.process(
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
} else {
@ -184,7 +206,7 @@ pub async fn run_game(
story
.process(
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
} else {