From 6995f9c888f4d6b8a7d2b85992b401a9a0c88b44 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 18 Apr 2026 16:10:09 +0200 Subject: [PATCH 1/6] feat(client_web): show a '?' when a die is not yet rolled --- client_web/assets/style.css | 3 +++ client_web/src/components/die.rs | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 9b80fbb..3034f38 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -520,6 +520,9 @@ body { .die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } .die-face.die-used circle { fill: #9a8a70; } +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + /* ── Jan panel ──────────────────────────────────────────────────────── */ .jan-panel { display: flex; diff --git a/client_web/src/components/die.rs b/client_web/src/components/die.rs index 7b701e7..7576280 100644 --- a/client_web/src/components/die.rs +++ b/client_web/src/components/die.rs @@ -22,7 +22,7 @@ pub fn Die( value: u8, used: bool, #[prop(default = false)] is_double: bool, -) -> impl IntoView { +) -> AnyView { let mut cls = if used { "die-face die-used".to_string() } else { @@ -31,6 +31,15 @@ pub fn Die( if is_double && !used { cls.push_str(" die-double"); } + if value == 0 { + return view! { + + + {"?"} + + }.into_any(); + } let dots: Vec = dot_positions(value) .iter() .map(|&(cx, cy)| view! { }.into_any()) @@ -40,5 +49,5 @@ pub fn Die( {dots} - } + }.into_any() } From 87677a09b0652935cf2bfdebb9935c8fe43c0912 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 18 Apr 2026 16:12:38 +0200 Subject: [PATCH 2/6] fix(client_web): pre-game : allow guest to roll die without waiting for host --- client_web/src/components/game_screen.rs | 11 +++--- client_web/src/trictrac/backend.rs | 48 +++++++++++------------- client_web/src/trictrac/types.rs | 2 +- 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index a94194f..909e266 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -5,10 +5,10 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; +use super::die::Die; use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::i18n::*; use crate::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage}; -use super::die::Die; use super::board::Board; use super::score_panel::PlayerScorePanel; @@ -81,9 +81,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // wait until the buffer is drained and the live screen state is shown. // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay // has its own Roll button for PlayerAction::PreGameRoll). - let show_roll = is_my_turn - && vs.turn_stage == SerTurnStage::RollDice - && vs.stage != SerStage::PreGameRoll; + let show_roll = + is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll; if show_roll && !waiting_for_confirm { let cmd_tx_auto = cmd_tx.clone(); Effect::new(move |_| { @@ -374,7 +373,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { }); let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die }; let opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die }; - let can_roll = is_my_turn && !waiting_for_confirm; + let can_roll = my_die.is_none() && !waiting_for_confirm; let show_tie = pgr.tie_count > 0; view! {
@@ -385,7 +384,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { })}
- {my_name_ceremony} + {my_name_ceremony}{t!(i18n, you_suffix)}
diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 288f5e7..7f3b1a6 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -32,12 +32,8 @@ impl TrictracBackend { guest_die: self.pre_game_dice[1], tie_count: self.tie_count, }); - // The active mp player is whoever hasn't rolled yet (host rolls first). - vs.active_mp_player = match self.pre_game_dice { - [None, _] => Some(0), - [Some(_), None] => Some(1), - _ => None, - }; + // Both players roll independently; no single "active" player. + vs.active_mp_player = None; } self.view_state = vs; } @@ -52,16 +48,11 @@ impl TrictracBackend { /// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest). fn handle_pre_game_roll(&mut self, mp_player: u16) { - // Enforce turn order: host rolls first, then guest. - let expected: u16 = match self.pre_game_dice { - [None, _] => 0, - [Some(_), None] => 1, - _ => return, // both already rolled (shouldn't happen) - }; - if mp_player != expected { + let idx = mp_player as usize; + // Ignore if this player already rolled. + if self.pre_game_dice[idx].is_some() { return; } - let idx = mp_player as usize; let single = self.dice_roller.roll().values.0; self.pre_game_dice[idx] = Some(single); @@ -132,8 +123,8 @@ impl TrictracBackend { impl BackEndArchitecture for TrictracBackend { fn new(_rule_variation: u16) -> Self { let mut game = GameState::new(false); - game.init_player("Host"); - game.init_player("Guest"); + game.init_player("Blancs"); + game.init_player("Noirs"); let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); @@ -309,11 +300,14 @@ mod tests { if b.get_view_state().stage != SerStage::PreGameRoll { break; } - match b.get_view_state().active_mp_player { - Some(0) => b.inform_rpc(0, PlayerAction::PreGameRoll), - Some(1) => b.inform_rpc(1, PlayerAction::PreGameRoll), - _ => break, + let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default(); + let host_needs = pgr.host_die.is_none(); + let guest_needs = pgr.guest_die.is_none(); + if !host_needs && !guest_needs { + break; // both rolled but stage not yet resolved — shouldn't happen } + if host_needs { b.inform_rpc(0, PlayerAction::PreGameRoll); } + if guest_needs { b.inform_rpc(1, PlayerAction::PreGameRoll); } b.drain_commands(); } } @@ -349,19 +343,19 @@ mod tests { } #[test] - fn ceremony_wrong_order_ignored() { + fn ceremony_any_order_allowed() { let mut b = make_backend(); b.player_arrival(0); b.player_arrival(1); b.drain_commands(); - // Guest tries to roll before host (host goes first in ceremony). + // Guest may roll before host. b.inform_rpc(1, PlayerAction::PreGameRoll); - let cmds = b.drain_commands(); - assert!( - cmds.is_empty(), - "guest PreGameRoll should be ignored when it is host's turn" - ); + let states = drain_deltas(&mut b); + assert!(!states.is_empty(), "guest PreGameRoll should broadcast a state"); + let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap(); + assert!(pgr.guest_die.is_some(), "guest die should be set after guest rolls"); + assert!(pgr.host_die.is_none(), "host die should still be blank"); } #[test] diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index b6f43da..3c0dfe2 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -31,7 +31,7 @@ pub struct GameDelta { /// State of the pre-game ceremony where each player rolls one die to decide /// who goes first. Present only when `stage == SerStage::PreGameRoll`. -#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] pub struct PreGameRollState { /// Die value (1–6) rolled by the host; `None` = not yet rolled this round. pub host_die: Option, From 89916c63caca41273c798c55fbfe68f2df9452c3 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 18 Apr 2026 16:13:45 +0200 Subject: [PATCH 3/6] fix(bot_local): always hold on point gain --- client_web/src/app.rs | 2 +- client_web/src/trictrac/bot_local.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client_web/src/app.rs b/client_web/src/app.rs index cebdb17..0f35f49 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -237,7 +237,7 @@ pub fn App() -> impl IntoView { let is_host = session.is_host; let player_id = session.player_id; let reconnect_token = session.reconnect_token; - let mut vs = ViewState::default_with_names("Host", "Guest"); + let mut vs = ViewState::default_with_names("Blancs", "Noirs"); loop { futures::select! { diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs index 73658ca..f94bfc9 100644 --- a/client_web/src/trictrac/bot_local.rs +++ b/client_web/src/trictrac/bot_local.rs @@ -25,8 +25,8 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option Some(PlayerAction::Roll), - TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), - TurnStage::Move => { + // TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), + TurnStage::Move | TurnStage::HoldOrGoChoice => { let rules = MoveRules::new(&Color::Black, &game.board, game.dice); let sequences = rules.get_possible_moves_sequences(true, vec![]); let mut rng = rand::rng(); From 1562ed1e40eae027bb37d5bca94ac1bf08177453 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 18 Apr 2026 16:21:38 +0200 Subject: [PATCH 4/6] fix(doc): rules: opponent's big jan != return jan --- doc/trictrac_rules.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/trictrac_rules.md b/doc/trictrac_rules.md index 9d16e5d..4aaa59e 100644 --- a/doc/trictrac_rules.md +++ b/doc/trictrac_rules.md @@ -9,7 +9,7 @@ French terms follow the mapping in [vocabulary.md](refs/vocabulary.md). ## 1. Board and Starting Position - 24 triangular fields (_flèches_ / _cases_), numbered 1–24 from each player's perspective. -- 4 quarters of 6 fields: **small jan** (1–6), **big jan** (7–12), **return jan** (13–18), **last jan** (19–24, exit zone). +- 4 quarters of 6 fields: **small jan** (1–6), **big jan** (7–12), **opponent's big jan** (13–18), **return jan** (19–24, exit zone). - Field 12 (White) / 13 (Black) is the **rest corner** (_coin de repos_). - Each player starts with all 15 checkers in a stack (_talon_) on field 1. - Checkers always move in the same direction (White: 1→24; Black: mirror of that). @@ -98,7 +98,7 @@ Ways to hit: ### 5d. Exit -- When all 15 checkers are in the last jan (fields 19–24), the player may exit. +- When all 15 checkers are in the return jan (fields 19–24), the player may exit. - The exit rail counts as one additional field value. - **Exact exit**: die value brings the checker directly to the exit rail — allowed. - **Overflow** (_nombre excédant_): die value would carry the farthest checker past the rail — must exit. From 00326cd645db7d86edf70c3e4b232f71e32bd0ba Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 18 Apr 2026 16:55:49 +0200 Subject: [PATCH 5/6] feat(backend): use pre-game roll result for the first move --- client_web/src/trictrac/backend.rs | 55 ++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 7f3b1a6..04b2a36 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -1,5 +1,5 @@ use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; -use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage}; +use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage}; use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState}; @@ -66,9 +66,21 @@ impl TrictracBackend { self.broadcast_state(); } else { // Highest die goes first. - let goes_first = if h > g { HOST_PLAYER_ID } else { GUEST_PLAYER_ID }; + let goes_first = if h > g { + HOST_PLAYER_ID + } else { + GUEST_PLAYER_ID + }; self.ceremony_started = false; let _ = self.game.consume(&GameEvent::BeginGame { goes_first }); + // Use pre-game dice roll for the first move + let _ = self.game.consume(&GameEvent::Roll { + player_id: goes_first, + }); + let _ = self.game.consume(&GameEvent::RollResult { + player_id: goes_first, + dice: Dice { values: (g, h) }, + }); self.broadcast_state(); } } else { @@ -162,7 +174,9 @@ impl BackEndArchitecture for TrictracBackend }); // Start the ceremony once both players have arrived. - if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame + if self.arrived[0] + && self.arrived[1] + && self.game.stage == trictrac_store::Stage::PreGame && !self.ceremony_started { self.ceremony_started = true; @@ -275,8 +289,8 @@ impl BackEndArchitecture for TrictracBackend #[cfg(test)] mod tests { use super::*; - use backbone_lib::traits::BackEndArchitecture; use crate::trictrac::types::{SerStage, SerTurnStage}; + use backbone_lib::traits::BackEndArchitecture; fn make_backend() -> TrictracBackend { TrictracBackend::new(0) @@ -306,8 +320,12 @@ mod tests { if !host_needs && !guest_needs { break; // both rolled but stage not yet resolved — shouldn't happen } - if host_needs { b.inform_rpc(0, PlayerAction::PreGameRoll); } - if guest_needs { b.inform_rpc(1, PlayerAction::PreGameRoll); } + if host_needs { + b.inform_rpc(0, PlayerAction::PreGameRoll); + } + if guest_needs { + b.inform_rpc(1, PlayerAction::PreGameRoll); + } b.drain_commands(); } } @@ -324,7 +342,10 @@ mod tests { let has_reset = cmds .iter() .any(|c| matches!(c, BackendCommand::ResetViewState)); - assert!(has_reset, "expected ResetViewState after both players arrive"); + assert!( + has_reset, + "expected ResetViewState after both players arrive" + ); // Stage should now be PreGameRoll, not InGame. assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll); @@ -352,9 +373,15 @@ mod tests { // Guest may roll before host. b.inform_rpc(1, PlayerAction::PreGameRoll); let states = drain_deltas(&mut b); - assert!(!states.is_empty(), "guest PreGameRoll should broadcast a state"); + assert!( + !states.is_empty(), + "guest PreGameRoll should broadcast a state" + ); let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap(); - assert!(pgr.guest_die.is_some(), "guest die should be set after guest rolls"); + assert!( + pgr.guest_die.is_some(), + "guest die should be set after guest rolls" + ); assert!(pgr.host_die.is_none(), "host die should still be blank"); } @@ -379,7 +406,10 @@ mod tests { complete_ceremony(&mut b); // Roll for whoever won the ceremony (either player could go first). - let first_player = b.get_view_state().active_mp_player.expect("someone should be active"); + let first_player = b + .get_view_state() + .active_mp_player + .expect("someone should be active"); b.inform_rpc(first_player, PlayerAction::Roll); let states = drain_deltas(&mut b); assert!(!states.is_empty(), "expected a state broadcast after roll"); @@ -411,10 +441,7 @@ mod tests { let wrong_player = if active == Some(0) { 1u16 } else { 0u16 }; b.inform_rpc(wrong_player, PlayerAction::Roll); let cmds = b.drain_commands(); - assert!( - cmds.is_empty(), - "wrong player roll should be ignored" - ); + assert!(cmds.is_empty(), "wrong player roll should be ignored"); } #[test] From 2838d59f30887dea4b8245c157c5709e2821aee5 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 18 Apr 2026 17:11:47 +0200 Subject: [PATCH 6/6] fix(client_web): only animate 2nd checker on 2nd move --- client_web/src/app.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 0f35f49..196a43a 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -270,6 +270,7 @@ pub fn App() -> impl IntoView { view_state: Some(vs.clone()), }); } + let is_own_move = prev_vs.active_mp_player == Some(player_id); push_or_show( &prev_vs, GameUiState { @@ -281,7 +282,7 @@ pub fn App() -> impl IntoView { pause_reason: None, my_scored_event: None, opp_scored_event: None, - last_moves: compute_last_moves(&prev_vs, &vs), + last_moves: compute_last_moves(&prev_vs, &vs, is_own_move), }, pending, screen, @@ -376,7 +377,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: scored, opp_scored_event: opp_scored, - last_moves: compute_last_moves(&prev_vs, &vs), + last_moves: compute_last_moves(&prev_vs, &vs, true), })); } Some(NetCommand::PlayVsBot) => return true, @@ -406,7 +407,7 @@ async fn run_local_bot_game( pause_reason: None, my_scored_event: None, opp_scored_event: None, - last_moves: compute_last_moves(&delta_prev_vs, &vs), + last_moves: compute_last_moves(&delta_prev_vs, &vs, false), }, pending, screen, @@ -421,7 +422,8 @@ async fn run_local_bot_game( /// Returns the checker moves to animate when the board changed between two ViewStates. /// Returns `None` when the board is unchanged or no real moves were recorded. -fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> { +/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2. +fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Option<(CheckerMove, CheckerMove)> { if prev.board == next.board { return None; } @@ -432,6 +434,11 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove // without setting dice_moves would bypass this guard and replay stale animation. return None; } + if own_move { + // m1 was already shown via the staged-moves overlay; only animate m2. + if m2 == CheckerMove::default() { return None; } + return Some((m2, CheckerMove::default())); + } Some((m1, m2)) }