From d779f7415a59498e8302f299a1b155427216288e Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 12 Apr 2026 21:02:59 +0200 Subject: [PATCH 1/2] feat(web_client): browser console.log from backend --- client_web/src/app.rs | 64 ++++++++++++++++++++++-------- client_web/src/trictrac/backend.rs | 19 +++++++++ client_web/src/trictrac/types.rs | 46 +++++++++++++-------- store/src/game.rs | 13 +++++- 4 files changed, 107 insertions(+), 35 deletions(-) diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 4ae4ad1..5ade2c3 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -12,7 +12,9 @@ use crate::components::{ConnectingScreen, GameScreen, LoginScreen}; 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}; +use crate::trictrac::types::{ + GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState, +}; use trictrac_store::CheckerMove; use std::collections::VecDeque; @@ -194,7 +196,9 @@ pub fn App() -> impl IntoView { if remote_config.is_none() { loop { let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await; - if !restart { break; } + if !restart { + break; + } } pending.update(|q| q.clear()); screen.set(Screen::Login { error: None }); @@ -328,8 +332,12 @@ async fn run_local_bot_game( let mut vs = ViewState::default_with_names("You", "Bot"); for cmd in backend.drain_commands() { match cmd { - BackendCommand::ResetViewState => { vs = backend.get_view_state().clone(); } - BackendCommand::Delta(delta) => { vs.apply_delta(&delta); } + BackendCommand::ResetViewState => { + vs = backend.get_view_state().clone(); + } + BackendCommand::Delta(delta) => { + vs.apply_delta(&delta); + } _ => {} } } @@ -440,15 +448,21 @@ fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> O && prev.active_mp_player == Some(player_id) { // My own roll: positive totals are mine. - next.dice_jans.iter().filter(|e| e.total > 0).cloned().collect() - } else if next.active_mp_player == Some(player_id) - && prev.active_mp_player != Some(player_id) - { + next.dice_jans + .iter() + .filter(|e| e.total > 0) + .cloned() + .collect() + } else if next.active_mp_player == Some(player_id) && prev.active_mp_player != Some(player_id) { // Opponent just moved: negative totals (their penalty) are scored for me. next.dice_jans .iter() .filter(|e| e.total < 0) - .map(|e| JanEntry { total: -e.total, points_per: -e.points_per, ..e.clone() }) + .map(|e| JanEntry { + total: -e.total, + points_per: -e.points_per, + ..e.clone() + }) .collect() } else { return None; @@ -496,7 +510,10 @@ fn push_or_show( }); // Animation belongs to the buffered confirmation step; clear it on the // fallback live state so it doesn't fire again after the queue drains. - screen.set(Screen::Playing(GameUiState { last_moves: None, ..new_state })); + screen.set(Screen::Playing(GameUiState { + last_moves: None, + ..new_state + })); } else { // No pause: show scoring directly on the live state. screen.set(Screen::Playing(GameUiState { @@ -519,8 +536,7 @@ fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Opt return Some(PauseReason::AfterOpponentRoll); } // Was at HoldOrGoChoice, now Move, opponent still active → opponent went. - if prev.turn_stage == SerTurnStage::HoldOrGoChoice - && next.turn_stage == SerTurnStage::Move + if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move { return Some(PauseReason::AfterOpponentGo); } @@ -534,14 +550,18 @@ fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Opt None } - #[cfg(test)] mod tests { use super::*; use crate::trictrac::types::{PlayerScore, SerStage, SerTurnStage}; fn score() -> PlayerScore { - PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false } + PlayerScore { + name: String::new(), + points: 0, + holes: 0, + can_bredouille: false, + } } fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option) -> ViewState { @@ -554,6 +574,7 @@ mod tests { dice, dice_jans: Vec::new(), dice_moves: (CheckerMove::default(), CheckerMove::default()), + message: "".into(), } } @@ -561,21 +582,30 @@ mod tests { fn dice_change_is_after_roll() { let prev = vs((0, 0), SerTurnStage::RollDice, Some(1)); let next = vs((3, 5), SerTurnStage::Move, Some(1)); - assert_eq!(infer_pause_reason(&prev, &next, 0), Some(PauseReason::AfterOpponentRoll)); + assert_eq!( + infer_pause_reason(&prev, &next, 0), + Some(PauseReason::AfterOpponentRoll) + ); } #[test] fn hold_to_move_is_after_go() { let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1)); let next = vs((3, 5), SerTurnStage::Move, Some(1)); - assert_eq!(infer_pause_reason(&prev, &next, 0), Some(PauseReason::AfterOpponentGo)); + assert_eq!( + infer_pause_reason(&prev, &next, 0), + Some(PauseReason::AfterOpponentGo) + ); } #[test] fn turn_switch_is_after_move() { let prev = vs((3, 5), SerTurnStage::Move, Some(1)); let next = vs((3, 5), SerTurnStage::RollDice, Some(0)); - assert_eq!(infer_pause_reason(&prev, &next, 0), Some(PauseReason::AfterOpponentMove)); + assert_eq!( + infer_pause_reason(&prev, &next, 0), + Some(PauseReason::AfterOpponentMove) + ); } #[test] diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index e96f080..486c3b9 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -167,6 +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 _ = self.game.consume(&event); self.drive_automatic_stages(); } @@ -330,3 +332,20 @@ mod tests { .any(|c| matches!(c, BackendCommand::TerminateRoom))); } } + +// ── Public API: WASM delegates to `inner`, other targets are no-ops ─────────── + +#[cfg(target_arch = "wasm32")] +mod inner { + use web_sys::console; + + pub fn console_log(message: String) { + console::log_1(&message.into()); + } +} + +#[cfg(target_arch = "wasm32")] +pub use inner::console_log; + +#[cfg(not(target_arch = "wasm32"))] +pub fn console_log(message: String) {} diff --git a/client_web/src/trictrac/types.rs b/client_web/src/trictrac/types.rs index 82f9a2d..f431482 100644 --- a/client_web/src/trictrac/types.rs +++ b/client_web/src/trictrac/types.rs @@ -70,8 +70,18 @@ impl ViewState { turn_stage: SerTurnStage::RollDice, active_mp_player: None, scores: [ - PlayerScore { name: host_name.to_string(), points: 0, holes: 0, can_bredouille: false }, - PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false }, + PlayerScore { + name: host_name.to_string(), + points: 0, + holes: 0, + can_bredouille: false, + }, + PlayerScore { + name: guest_name.to_string(), + points: 0, + holes: 0, + can_bredouille: false, + }, ], dice: (0, 0), dice_jans: Vec::new(), @@ -86,25 +96,21 @@ impl ViewState { /// Convert a store `GameState` to a `ViewState`. /// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned /// to the host (mp player 0) and guest (mp player 1) respectively. - pub fn from_game_state( - gs: &GameState, - host_store_id: u64, - guest_store_id: u64, - ) -> Self { + pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self { let board_vec = gs.board.to_vec(); let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields"); let stage = match gs.stage { Stage::PreGame => SerStage::PreGame, - Stage::InGame => SerStage::InGame, - Stage::Ended => SerStage::Ended, + Stage::InGame => SerStage::InGame, + Stage::Ended => SerStage::Ended, }; let turn_stage = match gs.turn_stage { - TurnStage::RollDice => SerTurnStage::RollDice, - TurnStage::RollWaiting => SerTurnStage::RollWaiting, - TurnStage::MarkPoints => SerTurnStage::MarkPoints, + TurnStage::RollDice => SerTurnStage::RollDice, + TurnStage::RollWaiting => SerTurnStage::RollWaiting, + TurnStage::MarkPoints => SerTurnStage::MarkPoints, TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice, - TurnStage::Move => SerTurnStage::Move, + TurnStage::Move => SerTurnStage::Move, TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints, }; @@ -125,7 +131,12 @@ impl ViewState { holes: p.holes, can_bredouille: p.can_bredouille, }) - .unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0, can_bredouille: false }) + .unwrap_or_else(|| PlayerScore { + name: String::new(), + points: 0, + holes: 0, + can_bredouille: false, + }) }; // is_double for scoring: dice show the same value (both dice identical). @@ -134,13 +145,16 @@ impl ViewState { // Build JanEntry list from the PossibleJans map. let empty_move = CheckerMove::new(0, 0).unwrap_or_default(); - let mut dice_jans: Vec = gs.dice_jans + let mut dice_jans: Vec = gs + .dice_jans .iter() .map(|(jan, moves)| { // HelplessMan: is_double = true only when *both* dice are unplayable // (the moves list contains a single (empty, empty) sentinel). let is_double = if *jan == Jan::HelplessMan { - moves.first().map(|&(m1, m2)| m1 == empty_move && m2 == empty_move) + moves + .first() + .map(|&(m1, m2)| m1 == empty_move && m2 == empty_move) .unwrap_or(false) } else { dice_are_double diff --git a/store/src/game.rs b/store/src/game.rs index 9c5233f..da074b9 100644 --- a/store/src/game.rs +++ b/store/src/game.rs @@ -80,6 +80,7 @@ pub struct GameState { roll_first: bool, // NOTE: add to a Setting struct if other fields needed pub schools_enabled: bool, + pub debug_message: String, } // implement Display trait @@ -119,6 +120,7 @@ impl Default for GameState { dice_jans: PossibleJans::default(), roll_first: true, schools_enabled: false, + debug_message: "".into(), } } } @@ -147,6 +149,11 @@ impl GameState { game } + pub fn get_debug_message(&self) -> String { + // format!("{:?}", self.history.last()) + format!("{:?}", self.debug_message) + } + pub fn mirror(&self) -> GameState { let mirrored_active_player = if self.active_player_id == 1 { 2 } else { 1 }; let mut mirrored_players = HashMap::new(); @@ -171,6 +178,7 @@ impl GameState { dice_jans: self.dice_jans.mirror(), roll_first: self.roll_first, schools_enabled: self.schools_enabled, + debug_message: self.debug_message.clone(), } } @@ -594,8 +602,9 @@ impl GameState { dice_points: (0, 0), dice_moves: (CheckerMove::default(), CheckerMove::default()), dice_jans: PossibleJans::default(), - roll_first: false, // Assume not first roll - schools_enabled: false, // Assume disabled + roll_first: false, // Assume not first roll + schools_enabled: false, // Assume disabled + debug_message: "".into(), // Assume disabled }) } From 7e8d0a18c1e74f26e3b7faa29eb2e768d78cefd5 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 12 Apr 2026 21:03:43 +0200 Subject: [PATCH 2/2] chore(web_client): refact --- client_web/src/trictrac/bot_local.rs | 2 +- store/src/game_rules_moves.rs | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs index 8941a09..9b379c8 100644 --- a/client_web/src/trictrac/bot_local.rs +++ b/client_web/src/trictrac/bot_local.rs @@ -15,7 +15,7 @@ pub fn bot_decide(game: &GameState) -> Option { } match game.turn_stage { TurnStage::RollDice => Some(PlayerAction::Roll), - TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), + TurnStage::HoldOrGoChoice => Some(PlayerAction::Mark), TurnStage::Move => { let rules = MoveRules::new(&Color::Black, &game.board, game.dice); let sequences = rules.get_possible_moves_sequences(true, vec![]); diff --git a/store/src/game_rules_moves.rs b/store/src/game_rules_moves.rs index 1759c62..7554034 100644 --- a/store/src/game_rules_moves.rs +++ b/store/src/game_rules_moves.rs @@ -955,15 +955,15 @@ mod tests { ); state.board.set_positions( - &Color::White, + &Color::Black, [ - 6, 0, 0, 0, 0, 0, 2, 2, 1, 2, 0, 2, 0, -5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 10, 0, 0, 0, -1, 0, 2, 0, 0, 0, 1, 2, 0, -1, -1, 0, 2, 0, 0, 0, 0, 0, 0, -10, ], ); - state.dice.values = (3, 3); + state.dice.values = (4, 1); let moves = ( - CheckerMove::new(14, 11).unwrap(), - CheckerMove::new(14, 11).unwrap(), + CheckerMove::new(15, 14).unwrap().mirror(), + CheckerMove::new(14, 10).unwrap().mirror(), ); assert_eq!( Err(MoveError::OpponentCanFillQuarter), @@ -1277,6 +1277,7 @@ mod tests { ); assert!(!state.moves_possible(&moves)); + // Chaned moves: can't rest on a field occupied by one opponent's checker state.board.set_positions( &Color::White, [ @@ -1288,7 +1289,7 @@ mod tests { CheckerMove::new(10, 15).unwrap(), CheckerMove::new(15, 20).unwrap(), ); - assert!(state.moves_possible(&moves)); + assert!(!state.moves_possible(&moves)); // black moves let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default());