diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 3034f38..9b80fbb 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -520,9 +520,6 @@ 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/app.rs b/client_web/src/app.rs index 196a43a..cebdb17 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("Blancs", "Noirs"); + let mut vs = ViewState::default_with_names("Host", "Guest"); loop { futures::select! { @@ -270,7 +270,6 @@ 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 { @@ -282,7 +281,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, is_own_move), + last_moves: compute_last_moves(&prev_vs, &vs), }, pending, screen, @@ -377,7 +376,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, true), + last_moves: compute_last_moves(&prev_vs, &vs), })); } Some(NetCommand::PlayVsBot) => return true, @@ -407,7 +406,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, false), + last_moves: compute_last_moves(&delta_prev_vs, &vs), }, pending, screen, @@ -422,8 +421,7 @@ 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. -/// `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)> { +fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> { if prev.board == next.board { return None; } @@ -434,11 +432,6 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Opt // 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)) } diff --git a/client_web/src/components/die.rs b/client_web/src/components/die.rs index 7576280..7b701e7 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, -) -> AnyView { +) -> impl IntoView { let mut cls = if used { "die-face die-used".to_string() } else { @@ -31,15 +31,6 @@ 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()) @@ -49,5 +40,5 @@ pub fn Die( {dots} - }.into_any() + } } diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 909e266..a94194f 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,8 +81,9 @@ 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 |_| { @@ -373,7 +374,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 = my_die.is_none() && !waiting_for_confirm; + let can_roll = is_my_turn && !waiting_for_confirm; let show_tie = pgr.tie_count > 0; view! {
@@ -384,7 +385,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { })}
- {my_name_ceremony}{t!(i18n, you_suffix)} + {my_name_ceremony}
diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 04b2a36..288f5e7 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::{Dice, DiceRoller, GameEvent, GameState, TurnStage}; +use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage}; use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState}; @@ -32,8 +32,12 @@ impl TrictracBackend { guest_die: self.pre_game_dice[1], tie_count: self.tie_count, }); - // Both players roll independently; no single "active" player. - vs.active_mp_player = None; + // 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, + }; } self.view_state = vs; } @@ -48,11 +52,16 @@ impl TrictracBackend { /// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest). fn handle_pre_game_roll(&mut self, mp_player: u16) { - let idx = mp_player as usize; - // Ignore if this player already rolled. - if self.pre_game_dice[idx].is_some() { + // 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 { return; } + let idx = mp_player as usize; let single = self.dice_roller.roll().values.0; self.pre_game_dice[idx] = Some(single); @@ -66,21 +75,9 @@ 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 { @@ -135,8 +132,8 @@ impl TrictracBackend { impl BackEndArchitecture for TrictracBackend { fn new(_rule_variation: u16) -> Self { let mut game = GameState::new(false); - game.init_player("Blancs"); - game.init_player("Noirs"); + game.init_player("Host"); + game.init_player("Guest"); let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); @@ -174,9 +171,7 @@ 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; @@ -289,8 +284,8 @@ impl BackEndArchitecture for TrictracBackend #[cfg(test)] mod tests { use super::*; - use crate::trictrac::types::{SerStage, SerTurnStage}; use backbone_lib::traits::BackEndArchitecture; + use crate::trictrac::types::{SerStage, SerTurnStage}; fn make_backend() -> TrictracBackend { TrictracBackend::new(0) @@ -314,17 +309,10 @@ mod tests { if b.get_view_state().stage != SerStage::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); + 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, } b.drain_commands(); } @@ -342,10 +330,7 @@ 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); @@ -364,25 +349,19 @@ mod tests { } #[test] - fn ceremony_any_order_allowed() { + fn ceremony_wrong_order_ignored() { let mut b = make_backend(); b.player_arrival(0); b.player_arrival(1); b.drain_commands(); - // Guest may roll before host. + // Guest tries to roll before host (host goes first in ceremony). b.inform_rpc(1, PlayerAction::PreGameRoll); - let states = drain_deltas(&mut b); + let cmds = b.drain_commands(); assert!( - !states.is_empty(), - "guest PreGameRoll should broadcast a state" + cmds.is_empty(), + "guest PreGameRoll should be ignored when it is host's turn" ); - 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] @@ -406,10 +385,7 @@ 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"); @@ -441,7 +417,10 @@ 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] diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs index f94bfc9..73658ca 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 => { + TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), + TurnStage::Move => { let rules = MoveRules::new(&Color::Black, &game.board, game.dice); let sequences = rules.get_possible_moves_sequences(true, vec![]); let mut rng = rand::rng(); diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 3c0dfe2..b6f43da 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, Default, PartialEq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct PreGameRollState { /// Die value (1–6) rolled by the host; `None` = not yet rolled this round. pub host_die: Option, diff --git a/doc/trictrac_rules.md b/doc/trictrac_rules.md index 4aaa59e..9d16e5d 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), **opponent's big jan** (13–18), **return jan** (19–24, exit zone). +- 4 quarters of 6 fields: **small jan** (1–6), **big jan** (7–12), **return jan** (13–18), **last 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 return jan (fields 19–24), the player may exit. +- When all 15 checkers are in the last 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.