feat(web_client): debug message

This commit is contained in:
Henri Bourcereau 2026-04-12 21:02:59 +02:00
parent dd4814e448
commit 05e09fba95
5 changed files with 96 additions and 35 deletions

View file

@ -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<u16>) -> 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]

View file

@ -18,6 +18,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
let vs = state.view_state.clone();
let message = format!("{}", vs.message);
let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn
@ -351,6 +352,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Player score (below board) ────────────────────────────────────
<PlayerScorePanel score=my_score is_you=true />
<div>
{format!("{message}")}
</div>
// ── Game-over overlay ─────────────────────────────────────────────
{stage_is_ended.then(|| {

View file

@ -167,6 +167,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
moves: (m1, m2),
};
if self.game.validate(&event) {
self.game.debug_message = format!("Event {:?} validated", event);
let _ = self.game.consume(&event);
self.drive_automatic_stages();
}

View file

@ -43,6 +43,7 @@ pub struct ViewState {
pub dice_jans: Vec<JanEntry>,
/// Last two checker moves played; default when no move has occurred yet.
pub dice_moves: (CheckerMove, CheckerMove),
pub message: String,
}
/// One scoring event from a dice roll.
@ -70,12 +71,23 @@ 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(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
message: "".into(),
}
}
@ -86,25 +98,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 +133,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 +147,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<JanEntry> = gs.dice_jans
let mut dice_jans: Vec<JanEntry> = 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
@ -170,6 +186,7 @@ impl ViewState {
dice: (gs.dice.values.0, gs.dice.values.1),
dice_jans,
dice_moves: gs.dice_moves,
message: gs.get_debug_message(),
}
}
}