From 8705cc418b7553e23daaff86b7227d74eb34f51f Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 7 May 2026 10:58:22 +0200 Subject: [PATCH 1/5] feat(web client): add action button icons --- clients/web/assets/style.css | 2 ++ clients/web/src/portal/lobby.rs | 36 ++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index d00a72a..49e9e3c 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -52,6 +52,8 @@ body { width: 1.2em; height: 1.2em; color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; } /* ── Site navigation ─────────────────────────────────────────────── */ diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index 9902039..ac91a43 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -21,12 +21,12 @@ fn generate_nickname() -> String { use rand::Rng; let mut rng = rand::rng(); const ADJ: &[&str] = &[ - "swift", "brave", "noble", "fierce", "clever", "bold", "cunning", - "agile", "sharp", "golden", "iron", "silver", "quick", "daring", "wild", + "swift", "brave", "noble", "fierce", "clever", "bold", "cunning", "agile", "sharp", + "golden", "iron", "silver", "quick", "daring", "wild", ]; const NOUN: &[&str] = &[ - "fox", "hawk", "wolf", "lion", "bear", "rook", "knight", - "duke", "earl", "lance", "blade", "crown", "dame", "ace", "star", + "fox", "hawk", "wolf", "lion", "bear", "rook", "knight", "duke", "earl", "lance", "blade", + "crown", "dame", "ace", "star", ]; let adj = ADJ[rng.random_range(0..ADJ.len())]; let noun = NOUN[rng.random_range(0..NOUN.len())]; @@ -124,7 +124,9 @@ pub fn LobbyPage() -> impl IntoView { }; join_processed.set_value(true); if auth_username.get_untracked().is_some() { - cmd_tx_q.unbounded_send(NetCommand::JoinRoom { room: code }).ok(); + cmd_tx_q + .unbounded_send(NetCommand::JoinRoom { room: code }) + .ok(); } else { pending_action.set(Some(PendingLobbyAction::Join { code })); } @@ -203,7 +205,9 @@ fn IdleCard( let on_create = move |_: leptos::ev::MouseEvent| { let code = generate_room_code(); if auth_username.get_untracked().is_some() { - cmd_create.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok(); + cmd_create + .unbounded_send(NetCommand::CreateRoom { room: code.clone() }) + .ok(); view_state.set(LobbyView::Waiting { code }); } else { pending_action.set(Some(PendingLobbyAction::Create { code })); @@ -216,9 +220,15 @@ fn IdleCard( class="login-btn login-btn-bot" on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); } > + + + {t!(i18n, play_vs_bot)} @@ -283,15 +293,23 @@ fn NicknameModal( let on_play = move |_: leptos::ev::MouseEvent| { let chosen = nick.get().trim().to_string(); - let chosen = if chosen.is_empty() { generate_nickname() } else { chosen }; + let chosen = if chosen.is_empty() { + generate_nickname() + } else { + chosen + }; anon_nickname.set(Some(chosen)); match &pending { PendingLobbyAction::Create { code } => { - cmd_tx.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok(); + cmd_tx + .unbounded_send(NetCommand::CreateRoom { room: code.clone() }) + .ok(); view_state.set(LobbyView::Waiting { code: code.clone() }); } PendingLobbyAction::Join { code } => { - cmd_tx.unbounded_send(NetCommand::JoinRoom { room: code.clone() }).ok(); + cmd_tx + .unbounded_send(NetCommand::JoinRoom { room: code.clone() }) + .ok(); } } pending_action.set(None); From 7395d140cce1b2d7c032b8b7dd4f746fcac807a4 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 7 May 2026 13:24:05 +0200 Subject: [PATCH 2/5] fix(web client): show toss winner --- clients/web/assets/style.css | 8 +++++++ clients/web/locales/en.json | 3 +++ clients/web/locales/fr.json | 11 +++++---- .../web/src/game/components/game_screen.rs | 24 +++++++++++++++++++ clients/web/src/portal/lobby.rs | 2 +- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 49e9e3c..428d693 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1859,6 +1859,14 @@ a:hover { text-decoration: underline; } font-style: italic; } +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + /* ── Nickname modal (anonymous player name chooser) ─────────────────── */ .nickname-backdrop { position: fixed; diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 6899303..130d62b 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -15,6 +15,7 @@ "roll_dice": "Roll dice", "go": "Go", "empty_move": "Empty move", + "cancel_move": "Cancel move", "you_suffix": " (you)", "points_label": "Points", "holes_label": "Holes", @@ -46,6 +47,8 @@ "pre_game_roll_title": "Who goes first?", "pre_game_roll_btn": "Roll", "pre_game_roll_tie": "Tie! Roll again", + "toss_you_first": "You go first!", + "toss_opp_first": "{{ name }} goes first!", "pre_game_roll_your_die": "Your die", "pre_game_roll_opp_die": "Opponent's die", "continue_btn": "Continue", diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 7ef3257..7700940 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -1,6 +1,6 @@ { "room_name_placeholder": "Nom de la salle", - "create_room": "Créer une salle", + "create_room": "Inviter un adversaire", "join_room": "Rejoindre", "connecting": "Connexion en cours…", "game_over": "Partie terminée", @@ -15,6 +15,7 @@ "roll_dice": "Lancer les dés", "go": "S'en aller", "empty_move": "Mouvement impossible", + "cancel_move": "Annuler le déplacement", "you_suffix": " (vous)", "points_label": "Points", "holes_label": "Trous", @@ -36,8 +37,8 @@ "jan_helpless_man": "Dame impuissante", "play_vs_bot": "Jouer contre le bot", "vs_bot_label": "contre le bot", - "you_win": "Vous avez gagné !", - "opp_wins": "{{ name }} gagne !", + "you_win": "Vous avez gagné !", + "opp_wins": "{{ name }} a gagné !", "play_again": "Rejouer", "after_opponent_roll": "L'adversaire a lancé les dés", "after_opponent_go": "L'adversaire s'en va", @@ -46,6 +47,8 @@ "pre_game_roll_title": "Qui joue en premier ?", "pre_game_roll_btn": "Lancer", "pre_game_roll_tie": "Égalité ! Relancez", + "toss_you_first": "Vous commencez !", + "toss_opp_first": "{{ name }} commence !", "pre_game_roll_your_die": "Votre dé", "pre_game_roll_opp_die": "Dé adverse", "continue_btn": "Continuer", @@ -119,7 +122,7 @@ "copy_link": "Copier le lien", "link_copied": "Copié !", "scan_qr": "ou scannez le QR code", - "join_code_label": "Rejoindre par code", + "join_code_label": "Rejoindre avec un code", "join_code_placeholder": "Code de la salle", "share_btn": "Partager", "nickname_modal_title": "Choisissez votre pseudo", diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 181ad78..4e1ce38 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -424,6 +424,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { >{t!(i18n, empty_move)} }) }} + {move || { + (is_move_stage && staged_moves.get().len() == 1).then(|| view! { + + }) + }} @@ -442,6 +453,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die }; let can_roll = my_die.is_none() && !waiting_for_confirm; let show_tie = pgr.tie_count > 0; + let toss_result: Option = match (my_die, opp_die) { + (Some(m), Some(o)) if m != o => Some(m > o), + _ => None, + }; + let opp_name_toss = opp_name_ceremony.clone(); view! {
@@ -459,6 +475,14 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
+ {toss_result.map(|i_win| { + let text = move || if i_win { + t_string!(i18n, toss_you_first).to_owned() + } else { + t_string!(i18n, toss_opp_first, name = opp_name_toss.as_str()).to_owned() + }; + view! {

{text}

} + })} {waiting_for_confirm.then(|| { let pending_c = pending; view! { diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index ac91a43..c3dbf24 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -217,7 +217,7 @@ fn IdleCard( view! {
{label_bl}
From fbc6a3c43259928e0cc9c0b817b661e712f9e471 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 7 May 2026 15:30:24 +0200 Subject: [PATCH 4/5] 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)} +

+