From 9af672823e518acb5187c71ccb696290689f0964 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 17 Apr 2026 20:04:40 +0200 Subject: [PATCH 1/3] feat(client_web): use a mp3 file for dice roll sound --- client_web/Cargo.toml | 1 + client_web/assets/diceroll.mp3 | Bin 0 -> 32896 bytes client_web/index.html | 1 + client_web/src/components/game_screen.rs | 2 +- client_web/src/sound.rs | 12 +++++++++++- 5 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 client_web/assets/diceroll.mp3 diff --git a/client_web/Cargo.toml b/client_web/Cargo.toml index e911eaf..10d91b8 100644 --- a/client_web/Cargo.toml +++ b/client_web/Cargo.toml @@ -34,4 +34,5 @@ web-sys = { version = "0.3", features = [ "OscillatorNode", "OscillatorType", "BaseAudioContext", + "HtmlAudioElement", ] } diff --git a/client_web/assets/diceroll.mp3 b/client_web/assets/diceroll.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b16adff42da3003b35d59566b353a5db2fa0c7b7 GIT binary patch literal 32896 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 literal 0 HcmV?d00001 diff --git a/client_web/index.html b/client_web/index.html index b661d76..7399dbc 100644 --- a/client_web/index.html +++ b/client_web/index.html @@ -6,6 +6,7 @@ Trictrac + diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 24042be..4be98b8 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -179,7 +179,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Sound effects (fire once on mount = once per state snapshot) ────────── // Dice roll: dice just appeared (no preceding moves in this snapshot). if show_dice && last_moves.is_none() { - crate::sound::play_dice_roll_cinematic(); + crate::sound::play_dice_roll(); } // Checker move: moves were committed in the preceding action. if last_moves.is_some() { diff --git a/client_web/src/sound.rs b/client_web/src/sound.rs index 4e2c815..2de584a 100644 --- a/client_web/src/sound.rs +++ b/client_web/src/sound.rs @@ -128,6 +128,13 @@ mod inner { }); } + /// Play the pre-recorded dice-roll MP3 asset. + pub fn play_dice_roll() { + if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") { + let _ = audio.play(); + } + } + /// Ascending three-note chime (C5 – E5 – G5). pub fn play_points_scored() { with_ctx(|ctx| { @@ -158,12 +165,15 @@ mod inner { #[cfg(target_arch = "wasm32")] pub use inner::{ - play_checker_move, play_dice_roll_cinematic, play_hole_scored, play_points_scored, + play_checker_move, play_dice_roll, play_dice_roll_cinematic, play_hole_scored, + play_points_scored, }; #[cfg(not(target_arch = "wasm32"))] pub fn play_checker_move() {} #[cfg(not(target_arch = "wasm32"))] +pub fn play_dice_roll() {} +#[cfg(not(target_arch = "wasm32"))] pub fn play_dice_roll_cinematic() {} #[cfg(not(target_arch = "wasm32"))] pub fn play_points_scored() {} From b68881fc38591003ce29ec155132636515b671e9 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 17 Apr 2026 20:46:07 +0200 Subject: [PATCH 2/3] fix(client_web): when "holding" bot sends "Move" instead of "Mark" --- client_web/src/trictrac/backend.rs | 4 ++-- client_web/src/trictrac/bot_local.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 486c3b9..2e51bd8 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -167,8 +167,8 @@ impl BackEndArchitecture for TrictracBackend moves: (m1, m2), }; if self.game.validate(&event) { - let message = format!("Event {:?} validated on {:?}", event, self.game); - console_log(message); + // let message = format!("Event {:?} validated on {:?}", event, self.game); + // console_log(message); let _ = self.game.consume(&event); self.drive_automatic_stages(); } diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs index 9b379c8..d2a0ca3 100644 --- a/client_web/src/trictrac/bot_local.rs +++ b/client_web/src/trictrac/bot_local.rs @@ -15,8 +15,8 @@ pub fn bot_decide(game: &GameState) -> Option { } match game.turn_stage { TurnStage::RollDice => Some(PlayerAction::Roll), - TurnStage::HoldOrGoChoice => Some(PlayerAction::Mark), - 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 24f5dba0656a97977908c421080642465c36c9f3 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Fri, 17 Apr 2026 22:22:50 +0200 Subject: [PATCH 3/3] feat(client_web): pre-game roll decide first player --- client_web/assets/style.css | 60 +++++++++ client_web/locales/en.json | 6 + client_web/locales/fr.json | 6 + client_web/src/app.rs | 62 ++++++--- client_web/src/components/game_screen.rs | 68 +++++++++- client_web/src/trictrac/backend.rs | 161 +++++++++++++++++++---- client_web/src/trictrac/bot_local.rs | 18 ++- client_web/src/trictrac/types.rs | 21 +++ 8 files changed, 353 insertions(+), 49 deletions(-) diff --git a/client_web/assets/style.css b/client_web/assets/style.css index 3691894..9b80fbb 100644 --- a/client_web/assets/style.css +++ b/client_web/assets/style.css @@ -1131,3 +1131,63 @@ body { flex-wrap: wrap; min-height: 2rem; /* reserve height so layout doesn't shift when buttons appear */ } + +/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ +.ceremony-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} diff --git a/client_web/locales/en.json b/client_web/locales/en.json index f390ce4..c29121d 100644 --- a/client_web/locales/en.json +++ b/client_web/locales/en.json @@ -42,6 +42,12 @@ "after_opponent_roll": "Opponent rolled", "after_opponent_go": "Opponent chose to continue", "after_opponent_move": "Opponent moved — your turn", + "after_opponent_pre_game_roll": "Opponent rolled — your turn", + "pre_game_roll_title": "Who goes first?", + "pre_game_roll_btn": "Roll", + "pre_game_roll_tie": "Tie! Roll again", + "pre_game_roll_your_die": "Your die", + "pre_game_roll_opp_die": "Opponent's die", "continue_btn": "Continue", "scored_pts": "+{{ n }} pts", "hole_made": "Hole! {{ holes }}/12", diff --git a/client_web/locales/fr.json b/client_web/locales/fr.json index 910d7c0..93f76e5 100644 --- a/client_web/locales/fr.json +++ b/client_web/locales/fr.json @@ -42,6 +42,12 @@ "after_opponent_roll": "L'adversaire a lancé les dés", "after_opponent_go": "L'adversaire s'en va", "after_opponent_move": "L'adversaire a joué — à vous", + "after_opponent_pre_game_roll": "L'adversaire a lancé — à vous", + "pre_game_roll_title": "Qui joue en premier ?", + "pre_game_roll_btn": "Lancer", + "pre_game_roll_tie": "Égalité ! Relancez", + "pre_game_roll_your_die": "Votre dé", + "pre_game_roll_opp_die": "Dé adverse", "continue_btn": "Continuer", "scored_pts": "+{{ n }} pts", "hole_made": "Trou ! {{ holes }}/12", diff --git a/client_web/src/app.rs b/client_web/src/app.rs index aa86ca8..cebdb17 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -13,7 +13,7 @@ use crate::i18n::I18nContextProvider; use crate::trictrac::backend::TrictracBackend; use crate::trictrac::bot_local::bot_decide; use crate::trictrac::types::{ - GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState, + GameDelta, JanEntry, PlayerAction, ScoredEvent, SerStage, SerTurnStage, ViewState, }; use trictrac_store::CheckerMove; @@ -48,6 +48,8 @@ pub enum PauseReason { AfterOpponentRoll, AfterOpponentGo, AfterOpponentMove, + /// Opponent rolled their die in the pre-game ceremony. + AfterOpponentPreGameRoll, } /// Which screen is currently shown. @@ -382,32 +384,35 @@ async fn run_local_bot_game( } loop { - match bot_decide(backend.get_game()) { + let pgr = backend.get_view_state().pre_game_roll.clone(); + match bot_decide(backend.get_game(), pgr.as_ref()) { None => break, Some(action) => { - let prev_vs = vs.clone(); backend.inform_rpc(1, action); + // Process each delta individually so intermediate ceremony + // states (both dice shown) can trigger a pause via push_or_show. for cmd in backend.drain_commands() { if let BackendCommand::Delta(delta) = cmd { + let delta_prev_vs = vs.clone(); vs.apply_delta(&delta); + push_or_show( + &delta_prev_vs, + GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + waiting_for_confirm: false, + pause_reason: None, + my_scored_event: None, + opp_scored_event: None, + last_moves: compute_last_moves(&delta_prev_vs, &vs), + }, + pending, + screen, + ); } } - push_or_show( - &prev_vs, - GameUiState { - view_state: vs.clone(), - player_id: 0, - room_id: String::new(), - is_bot_game: true, - waiting_for_confirm: false, - pause_reason: None, - my_scored_event: None, - opp_scored_event: None, - last_moves: compute_last_moves(&prev_vs, &vs), - }, - pending, - screen, - ); } } } @@ -530,6 +535,24 @@ fn push_or_show( fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option { let opponent_id = 1 - player_id; + // Pre-game ceremony: pause when both dice are revealed simultaneously + // (i.e. the second die was just rolled). Both players see this pause. + if next.stage == SerStage::PreGameRoll { + if let (Some(prev_pgr), Some(next_pgr)) = (&prev.pre_game_roll, &next.pre_game_roll) { + let both_now = next_pgr.host_die.is_some() && next_pgr.guest_die.is_some(); + let both_before = prev_pgr.host_die.is_some() && prev_pgr.guest_die.is_some(); + if both_now && !both_before { + return Some(PauseReason::AfterOpponentPreGameRoll); + } + } + return None; + } + + // Don't fire normal pause rules on the PreGameRoll → InGame transition. + if prev.stage == SerStage::PreGameRoll { + return None; + } + if next.active_mp_player == Some(opponent_id) { // Dice changed → opponent just rolled. if next.dice != prev.dice { @@ -574,6 +597,7 @@ mod tests { dice, dice_jans: Vec::new(), dice_moves: (CheckerMove::default(), CheckerMove::default()), + pre_game_roll: None, } } diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index 4be98b8..a94194f 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -7,7 +7,8 @@ use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::i18n::*; -use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage}; +use crate::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage}; +use super::die::Die; use super::board::Board; use super::score_panel::PlayerScorePanel; @@ -78,7 +79,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove // buffered state shows the human's RollDice turn but the auto-roll must // wait until the buffer is drained and the live screen state is shown. - let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice; + // 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; if show_roll && !waiting_for_confirm { let cmd_tx_auto = cmd_tx.clone(); Effect::new(move |_| { @@ -132,6 +137,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let my_score = vs.scores[player_id as usize].clone(); let opp_score = vs.scores[1 - player_id as usize].clone(); + // ── Ceremony state (extracted before vs is moved into Board) ──────────────── + let is_ceremony = vs.stage == SerStage::PreGameRoll; + let pre_game_roll_data: Option = vs.pre_game_roll.clone(); + let my_name_ceremony = my_score.name.clone(); + let opp_name_ceremony = opp_score.name.clone(); + let cmd_tx_ceremony = cmd_tx.clone(); + // ── Scoring notifications ────────────────────────────────────────────────── let my_scored_event = state.my_scored_event.clone(); let opp_scored_event = state.opp_scored_event.clone(); @@ -246,6 +258,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), + PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll), }); } let n = staged_moves.get().len(); @@ -254,7 +267,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } else { String::from(match (&stage, is_my_turn, &turn_stage) { (SerStage::Ended, _, _) => t_string!(i18n, game_over), - (SerStage::PreGame, _, _) => t_string!(i18n, waiting_for_opponent), + (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent), (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), (SerStage::InGame, true, _) => t_string!(i18n, your_turn), @@ -352,6 +365,55 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { // ── Player score (below board) ──────────────────────────────────── + // ── Pre-game ceremony overlay ───────────────────────────────────── + {is_ceremony.then(|| { + let pgr = pre_game_roll_data.unwrap_or(PreGameRollState { + host_die: None, + guest_die: None, + tie_count: 0, + }); + 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 show_tie = pgr.tie_count > 0; + view! { +
+
+

{t!(i18n, pre_game_roll_title)}

+ {show_tie.then(|| view! { +

{t!(i18n, pre_game_roll_tie)}

+ })} +
+
+ {my_name_ceremony} + +
+
+ {opp_name_ceremony} + +
+
+ {waiting_for_confirm.then(|| { + let pending_c = pending; + view! { + + } + })} + {can_roll.then(|| { + let cmd_tx_c = cmd_tx_ceremony.clone(); + view! { + + } + })} +
+
+ } + })} + // ── Game-over overlay ───────────────────────────────────────────── {stage_is_ended.then(|| { let opp_name_end_clone = opp_name_end.clone(); diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 2e51bd8..288f5e7 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -1,7 +1,7 @@ use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage}; -use crate::trictrac::types::{GameDelta, PlayerAction, ViewState}; +use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState}; // Store PlayerId (u64) values used for the two players. const HOST_PLAYER_ID: u64 = 1; @@ -14,11 +14,32 @@ pub struct TrictracBackend { view_state: ViewState, /// Arrival flags: have host (index 0) and guest (index 1) joined? arrived: [bool; 2], + /// Die rolled by each player during the ceremony ([host, guest]). + pre_game_dice: [Option; 2], + /// Number of tied rounds so far. + tie_count: u8, + /// True while the first-player ceremony is running. + ceremony_started: bool, } impl TrictracBackend { fn sync_view_state(&mut self) { - self.view_state = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID); + let mut vs = ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID); + if self.ceremony_started { + vs.stage = SerStage::PreGameRoll; + vs.pre_game_roll = Some(PreGameRollState { + host_die: self.pre_game_dice[0], + 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, + }; + } + self.view_state = vs; } fn broadcast_state(&mut self) { @@ -29,6 +50,42 @@ impl TrictracBackend { self.commands.push(BackendCommand::Delta(delta)); } + /// 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 { + return; + } + let idx = mp_player as usize; + let single = self.dice_roller.roll().values.0; + self.pre_game_dice[idx] = Some(single); + + if let [Some(h), Some(g)] = self.pre_game_dice { + // Both have rolled — broadcast both dice before resolving. + self.broadcast_state(); + if h == g { + // Tie: reset for another round. + self.tie_count += 1; + self.pre_game_dice = [None; 2]; + self.broadcast_state(); + } else { + // Highest die goes first. + 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 }); + self.broadcast_state(); + } + } else { + // Only one die rolled so far — broadcast the partial result. + self.broadcast_state(); + } + } + /// Roll dice using the store's DiceRoller and fire Roll + RollResult events. fn do_roll(&mut self) { let dice = self.dice_roller.roll(); @@ -86,6 +143,9 @@ impl BackEndArchitecture for TrictracBackend commands: Vec::new(), view_state, arrived: [false; 2], + pre_game_dice: [None; 2], + tie_count: 0, + ceremony_started: false, } } @@ -110,11 +170,13 @@ impl BackEndArchitecture for TrictracBackend timer_id: mp_player, }); - // Start the game once both players have arrived. - if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame { - let _ = self.game.consume(&GameEvent::BeginGame { - goes_first: HOST_PLAYER_ID, - }); + // Start the ceremony once both players have arrived. + if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame + && !self.ceremony_started + { + self.ceremony_started = true; + self.pre_game_dice = [None; 2]; + self.tie_count = 0; self.sync_view_state(); self.commands.push(BackendCommand::ResetViewState); } else { @@ -135,6 +197,14 @@ impl BackEndArchitecture for TrictracBackend } fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) { + // During the first-player ceremony only PreGameRoll actions are accepted. + if self.ceremony_started { + if matches!(action, PlayerAction::PreGameRoll) { + self.handle_pre_game_roll(mp_player); + } + return; + } + if self.game.stage == trictrac_store::Stage::Ended { return; } @@ -167,8 +237,6 @@ impl BackEndArchitecture for TrictracBackend moves: (m1, m2), }; if self.game.validate(&event) { - // let message = format!("Event {:?} validated on {:?}", event, self.game); - // console_log(message); let _ = self.game.consume(&event); self.drive_automatic_stages(); } @@ -188,6 +256,7 @@ impl BackEndArchitecture for TrictracBackend self.drive_automatic_stages(); } } + PlayerAction::PreGameRoll => {} // ignored outside ceremony } self.broadcast_state(); @@ -216,6 +285,7 @@ impl BackEndArchitecture for TrictracBackend mod tests { use super::*; use backbone_lib::traits::BackEndArchitecture; + use crate::trictrac::types::{SerStage, SerTurnStage}; fn make_backend() -> TrictracBackend { TrictracBackend::new(0) @@ -233,28 +303,67 @@ mod tests { .collect() } + /// Drive the ceremony to completion (both players roll until one wins). + fn complete_ceremony(b: &mut TrictracBackend) { + loop { + 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, + } + b.drain_commands(); + } + } + #[test] - fn both_players_arrive_starts_game() { + fn both_players_arrive_starts_ceremony() { let mut b = make_backend(); b.player_arrival(0); // host b.drain_commands(); b.player_arrival(1); // guest let cmds = b.drain_commands(); - // ResetViewState should have been issued after BeginGame. + // ResetViewState should have been issued to start the ceremony. 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); + } + + #[test] + fn ceremony_resolves_to_in_game() { + let mut b = make_backend(); + b.player_arrival(0); + b.player_arrival(1); + b.drain_commands(); + + complete_ceremony(&mut b); - // Game should now be InGame. - use crate::trictrac::types::SerStage; assert_eq!(b.get_view_state().stage, SerStage::InGame); } + #[test] + fn ceremony_wrong_order_ignored() { + 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). + 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" + ); + } + #[test] fn unknown_player_kicked() { let mut b = make_backend(); @@ -272,12 +381,15 @@ mod tests { b.player_arrival(1); b.drain_commands(); - // Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame). - b.inform_rpc(0, PlayerAction::Roll); + // Complete ceremony before rolling. + 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"); + b.inform_rpc(first_player, PlayerAction::Roll); let states = drain_deltas(&mut b); assert!(!states.is_empty(), "expected a state broadcast after roll"); - use crate::trictrac::types::SerTurnStage; let last = states.last().unwrap(); assert!( matches!( @@ -298,13 +410,16 @@ mod tests { b.player_arrival(0); b.player_arrival(1); b.drain_commands(); + complete_ceremony(&mut b); - // Guest tries to roll when it's the host's turn. - b.inform_rpc(1, PlayerAction::Roll); + // Identify who goes first and have the OTHER player try to roll. + let active = b.get_view_state().active_mp_player; + 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(), - "guest roll should be ignored when it's host's turn" + "wrong player roll should be ignored" ); } diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs index d2a0ca3..73658ca 100644 --- a/client_web/src/trictrac/bot_local.rs +++ b/client_web/src/trictrac/bot_local.rs @@ -1,12 +1,22 @@ use rand::prelude::IndexedRandom; use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; -use crate::trictrac::types::PlayerAction; +use crate::trictrac::types::{PlayerAction, PreGameRollState}; const GUEST_PLAYER_ID: u64 = 2; /// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn. -pub fn bot_decide(game: &GameState) -> Option { +/// `pgr` is the current pre-game ceremony state if the ceremony is in progress. +pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option { + // During the ceremony, the bot (guest) rolls when its die is missing. + if game.stage == Stage::PreGame { + if let Some(pgr) = pgr { + if pgr.guest_die.is_none() { + return Some(PlayerAction::PreGameRoll); + } + } + return None; + } if game.stage == Stage::Ended { return None; } @@ -15,8 +25,8 @@ pub fn bot_decide(game: &GameState) -> Option { } match game.turn_stage { TurnStage::RollDice => 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 f431482..b6f43da 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -14,6 +14,8 @@ pub enum PlayerAction { Go, /// Acknowledge point marking (hold / advance points). Mark, + /// Roll a single die during the pre-game ceremony to decide who goes first. + PreGameRoll, } // ── Incremental state update broadcast to all clients ──────────────────────── @@ -27,6 +29,18 @@ pub struct GameDelta { // ── Full game snapshot ──────────────────────────────────────────────────────── +/// 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)] +pub struct PreGameRollState { + /// Die value (1–6) rolled by the host; `None` = not yet rolled this round. + pub host_die: Option, + /// Die value (1–6) rolled by the guest; `None` = not yet rolled this round. + pub guest_die: Option, + /// Number of tied rounds so far (0 on the first round). + pub tie_count: u8, +} + #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct ViewState { /// Board positions: index i = field i+1. Positive = white, negative = black. @@ -43,6 +57,9 @@ pub struct ViewState { pub dice_jans: Vec, /// Last two checker moves played; default when no move has occurred yet. pub dice_moves: (CheckerMove, CheckerMove), + /// Present while the pre-game ceremony is in progress. + #[serde(default)] + pub pre_game_roll: Option, } /// One scoring event from a dice roll. @@ -86,6 +103,7 @@ impl ViewState { dice: (0, 0), dice_jans: Vec::new(), dice_moves: (CheckerMove::default(), CheckerMove::default()), + pre_game_roll: None, } } @@ -184,6 +202,7 @@ impl ViewState { dice: (gs.dice.values.0, gs.dice.values.1), dice_jans, dice_moves: gs.dice_moves, + pre_game_roll: None, } } } @@ -220,6 +239,8 @@ pub struct PlayerScore { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SerStage { PreGame, + /// Both players have arrived; ceremony in progress to decide who goes first. + PreGameRoll, InGame, Ended, }