From fbc6a3c43259928e0cc9c0b817b661e712f9e471 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 7 May 2026 15:30:24 +0200 Subject: [PATCH] feat(web client): take & replay game snapshot --- clients/web/locales/en.json | 8 ++ clients/web/locales/fr.json | 8 ++ clients/web/src/app.rs | 127 ++++++++++++++++++++++- clients/web/src/game/session.rs | 38 +++++++ clients/web/src/game/trictrac/backend.rs | 61 ++++++++++- 5 files changed, 239 insertions(+), 3 deletions(-) diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 130d62b..03ba37c 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -16,6 +16,14 @@ "go": "Go", "empty_move": "Empty move", "cancel_move": "Cancel move", + "debug_section": "Debug", + "take_snapshot": "Take snapshot", + "snapshot_copied": "Copied!", + "replay_snapshot": "Replay snapshot", + "replay_paste_hint": "Paste a snapshot JSON to start a bot game from that position.", + "replay_start": "Start", + "replay_invalid_state": "Invalid snapshot — paste the JSON copied by Take snapshot.", + "cancel": "Cancel", "you_suffix": " (you)", "points_label": "Points", "holes_label": "Holes", diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 7700940..aae9c52 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -16,6 +16,14 @@ "go": "S'en aller", "empty_move": "Mouvement impossible", "cancel_move": "Annuler le déplacement", + "debug_section": "Debug", + "take_snapshot": "Prendre un instantané", + "snapshot_copied": "Copié !", + "replay_snapshot": "Rejouer un instantané", + "replay_paste_hint": "Collez un instantané JSON pour démarrer une partie contre le bot depuis cette position.", + "replay_start": "Démarrer", + "replay_invalid_state": "Instantané invalide — collez le JSON copié par « Prendre un instantané ».", + "cancel": "Annuler", "you_suffix": " (vous)", "points_label": "Points", "holes_label": "Trous", diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 521a0c1..3819b61 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -15,6 +15,7 @@ use crate::api; use crate::game::components::{ConnectingScreen, GameScreen}; use crate::game::session::{ compute_last_moves, patch_player_name, push_or_show, run_local_bot_game, + run_local_bot_game_with_backend, }; use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; @@ -83,6 +84,8 @@ pub enum NetCommand { host_state: Option>, }, PlayVsBot, + /// Start a bot game with the board/score position from a previously taken snapshot. + ReplaySnapshot(ViewState), Action(PlayerAction), Disconnect, } @@ -190,9 +193,14 @@ pub fn App() -> impl IntoView { spawn_local(async move { loop { + let mut snapshot_init: Option = None; let remote_config: Option<(RoomConfig, bool)> = loop { match cmd_rx.next().await { Some(NetCommand::PlayVsBot) => break None, + Some(NetCommand::ReplaySnapshot(vs)) => { + snapshot_init = Some(vs); + break None; + } Some(NetCommand::CreateRoom { room }) => { break Some(( RoomConfig { @@ -251,8 +259,23 @@ pub fn App() -> impl IntoView { .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string())); loop { - let restart = - run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await; + let restart = match snapshot_init.take() { + Some(vs) => { + let backend = TrictracBackend::from_view_state(vs, &player_name); + run_local_bot_game_with_backend( + screen, + &mut cmd_rx, + pending, + player_name.clone(), + backend, + ) + .await + } + None => { + run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()) + .await + } + }; if !restart { break; } @@ -446,8 +469,14 @@ fn SiteHamburger() -> impl IntoView { .expect("cmd_tx not found in context"); let sidebar_open = RwSignal::new(false); + let snapshot_copied = RwSignal::new(false); + let replay_open = RwSignal::new(false); + let replay_text = RwSignal::new(String::new()); + let replay_error = RwSignal::new(false); let cmd_tx_newgame = cmd_tx.clone(); + let cmd_tx_snapshot = cmd_tx.clone(); + let cmd_tx_replay = cmd_tx.clone(); view! { // ── Hamburger button (☰ → ✕ animation) ─────────────────────────────── @@ -543,6 +572,100 @@ fn SiteHamburger() -> impl IntoView { }.into_any(), }} + + // ── Debug section ───────────────────────────────────────────────── +
+ {t!(i18n, debug_section)} + + // "Take snapshot" — only visible while a game is in progress + {move || { + let Screen::Playing(ref state) = screen.get() else { return None; }; + let vs = state.view_state.clone(); + let tx = cmd_tx_snapshot.clone(); + Some(view! { + + }) + }} + + // "Replay snapshot" — always visible + +
+ + + // ── Replay snapshot modal ───────────────────────────────────────────── +
+
+

{t!(i18n, replay_snapshot)}

+

+ {t!(i18n, replay_paste_hint)} +

+