Compare commits

...

2 commits

6 changed files with 115 additions and 42 deletions

View file

@ -12,7 +12,9 @@ use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
use crate::i18n::I18nContextProvider; use crate::i18n::I18nContextProvider;
use crate::trictrac::backend::TrictracBackend; use crate::trictrac::backend::TrictracBackend;
use crate::trictrac::bot_local::bot_decide; 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 trictrac_store::CheckerMove;
use std::collections::VecDeque; use std::collections::VecDeque;
@ -194,7 +196,9 @@ pub fn App() -> impl IntoView {
if remote_config.is_none() { if remote_config.is_none() {
loop { loop {
let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await; let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await;
if !restart { break; } if !restart {
break;
}
} }
pending.update(|q| q.clear()); pending.update(|q| q.clear());
screen.set(Screen::Login { error: None }); 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"); let mut vs = ViewState::default_with_names("You", "Bot");
for cmd in backend.drain_commands() { for cmd in backend.drain_commands() {
match cmd { match cmd {
BackendCommand::ResetViewState => { vs = backend.get_view_state().clone(); } BackendCommand::ResetViewState => {
BackendCommand::Delta(delta) => { vs.apply_delta(&delta); } 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) && prev.active_mp_player == Some(player_id)
{ {
// My own roll: positive totals are mine. // My own roll: positive totals are mine.
next.dice_jans.iter().filter(|e| e.total > 0).cloned().collect() next.dice_jans
} else if next.active_mp_player == Some(player_id) .iter()
&& prev.active_mp_player != Some(player_id) .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. // Opponent just moved: negative totals (their penalty) are scored for me.
next.dice_jans next.dice_jans
.iter() .iter()
.filter(|e| e.total < 0) .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() .collect()
} else { } else {
return None; return None;
@ -496,7 +510,10 @@ fn push_or_show(
}); });
// Animation belongs to the buffered confirmation step; clear it on the // Animation belongs to the buffered confirmation step; clear it on the
// fallback live state so it doesn't fire again after the queue drains. // 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 { } else {
// No pause: show scoring directly on the live state. // No pause: show scoring directly on the live state.
screen.set(Screen::Playing(GameUiState { 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); return Some(PauseReason::AfterOpponentRoll);
} }
// Was at HoldOrGoChoice, now Move, opponent still active → opponent went. // Was at HoldOrGoChoice, now Move, opponent still active → opponent went.
if prev.turn_stage == SerTurnStage::HoldOrGoChoice if prev.turn_stage == SerTurnStage::HoldOrGoChoice && next.turn_stage == SerTurnStage::Move
&& next.turn_stage == SerTurnStage::Move
{ {
return Some(PauseReason::AfterOpponentGo); return Some(PauseReason::AfterOpponentGo);
} }
@ -534,14 +550,18 @@ fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Opt
None None
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::trictrac::types::{PlayerScore, SerStage, SerTurnStage}; use crate::trictrac::types::{PlayerScore, SerStage, SerTurnStage};
fn score() -> PlayerScore { 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 { fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option<u16>) -> ViewState {
@ -554,6 +574,7 @@ mod tests {
dice, dice,
dice_jans: Vec::new(), dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()), dice_moves: (CheckerMove::default(), CheckerMove::default()),
message: "".into(),
} }
} }
@ -561,21 +582,30 @@ mod tests {
fn dice_change_is_after_roll() { fn dice_change_is_after_roll() {
let prev = vs((0, 0), SerTurnStage::RollDice, Some(1)); let prev = vs((0, 0), SerTurnStage::RollDice, Some(1));
let next = vs((3, 5), SerTurnStage::Move, 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] #[test]
fn hold_to_move_is_after_go() { fn hold_to_move_is_after_go() {
let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1)); let prev = vs((3, 5), SerTurnStage::HoldOrGoChoice, Some(1));
let next = vs((3, 5), SerTurnStage::Move, 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] #[test]
fn turn_switch_is_after_move() { fn turn_switch_is_after_move() {
let prev = vs((3, 5), SerTurnStage::Move, Some(1)); let prev = vs((3, 5), SerTurnStage::Move, Some(1));
let next = vs((3, 5), SerTurnStage::RollDice, Some(0)); 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] #[test]

View file

@ -167,6 +167,8 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
moves: (m1, m2), moves: (m1, m2),
}; };
if self.game.validate(&event) { if self.game.validate(&event) {
let message = format!("Event {:?} validated on {:?}", event, self.game);
console_log(message);
let _ = self.game.consume(&event); let _ = self.game.consume(&event);
self.drive_automatic_stages(); self.drive_automatic_stages();
} }
@ -330,3 +332,20 @@ mod tests {
.any(|c| matches!(c, BackendCommand::TerminateRoom))); .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) {}

View file

@ -15,7 +15,7 @@ pub fn bot_decide(game: &GameState) -> Option<PlayerAction> {
} }
match game.turn_stage { match game.turn_stage {
TurnStage::RollDice => Some(PlayerAction::Roll), TurnStage::RollDice => Some(PlayerAction::Roll),
TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), TurnStage::HoldOrGoChoice => Some(PlayerAction::Mark),
TurnStage::Move => { TurnStage::Move => {
let rules = MoveRules::new(&Color::Black, &game.board, game.dice); let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
let sequences = rules.get_possible_moves_sequences(true, vec![]); let sequences = rules.get_possible_moves_sequences(true, vec![]);

View file

@ -70,8 +70,18 @@ impl ViewState {
turn_stage: SerTurnStage::RollDice, turn_stage: SerTurnStage::RollDice,
active_mp_player: None, active_mp_player: None,
scores: [ scores: [
PlayerScore { name: host_name.to_string(), points: 0, holes: 0, can_bredouille: false }, PlayerScore {
PlayerScore { name: guest_name.to_string(), points: 0, holes: 0, can_bredouille: false }, 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: (0, 0),
dice_jans: Vec::new(), dice_jans: Vec::new(),
@ -86,11 +96,7 @@ impl ViewState {
/// Convert a store `GameState` to a `ViewState`. /// Convert a store `GameState` to a `ViewState`.
/// `host_store_id` and `guest_store_id` are the trictrac `PlayerId`s assigned /// `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. /// to the host (mp player 0) and guest (mp player 1) respectively.
pub fn from_game_state( pub fn from_game_state(gs: &GameState, host_store_id: u64, guest_store_id: u64) -> Self {
gs: &GameState,
host_store_id: u64,
guest_store_id: u64,
) -> Self {
let board_vec = gs.board.to_vec(); let board_vec = gs.board.to_vec();
let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields"); let board: [i8; 24] = board_vec.try_into().expect("board is always 24 fields");
@ -125,7 +131,12 @@ impl ViewState {
holes: p.holes, holes: p.holes,
can_bredouille: p.can_bredouille, 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). // 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. // Build JanEntry list from the PossibleJans map.
let empty_move = CheckerMove::new(0, 0).unwrap_or_default(); 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() .iter()
.map(|(jan, moves)| { .map(|(jan, moves)| {
// HelplessMan: is_double = true only when *both* dice are unplayable // HelplessMan: is_double = true only when *both* dice are unplayable
// (the moves list contains a single (empty, empty) sentinel). // (the moves list contains a single (empty, empty) sentinel).
let is_double = if *jan == Jan::HelplessMan { 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) .unwrap_or(false)
} else { } else {
dice_are_double dice_are_double

View file

@ -80,6 +80,7 @@ pub struct GameState {
roll_first: bool, roll_first: bool,
// NOTE: add to a Setting struct if other fields needed // NOTE: add to a Setting struct if other fields needed
pub schools_enabled: bool, pub schools_enabled: bool,
pub debug_message: String,
} }
// implement Display trait // implement Display trait
@ -119,6 +120,7 @@ impl Default for GameState {
dice_jans: PossibleJans::default(), dice_jans: PossibleJans::default(),
roll_first: true, roll_first: true,
schools_enabled: false, schools_enabled: false,
debug_message: "".into(),
} }
} }
} }
@ -147,6 +149,11 @@ impl GameState {
game game
} }
pub fn get_debug_message(&self) -> String {
// format!("{:?}", self.history.last())
format!("{:?}", self.debug_message)
}
pub fn mirror(&self) -> GameState { pub fn mirror(&self) -> GameState {
let mirrored_active_player = if self.active_player_id == 1 { 2 } else { 1 }; let mirrored_active_player = if self.active_player_id == 1 { 2 } else { 1 };
let mut mirrored_players = HashMap::new(); let mut mirrored_players = HashMap::new();
@ -171,6 +178,7 @@ impl GameState {
dice_jans: self.dice_jans.mirror(), dice_jans: self.dice_jans.mirror(),
roll_first: self.roll_first, roll_first: self.roll_first,
schools_enabled: self.schools_enabled, schools_enabled: self.schools_enabled,
debug_message: self.debug_message.clone(),
} }
} }
@ -596,6 +604,7 @@ impl GameState {
dice_jans: PossibleJans::default(), dice_jans: PossibleJans::default(),
roll_first: false, // Assume not first roll roll_first: false, // Assume not first roll
schools_enabled: false, // Assume disabled schools_enabled: false, // Assume disabled
debug_message: "".into(), // Assume disabled
}) })
} }

View file

@ -955,15 +955,15 @@ mod tests {
); );
state.board.set_positions( 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 = ( let moves = (
CheckerMove::new(14, 11).unwrap(), CheckerMove::new(15, 14).unwrap().mirror(),
CheckerMove::new(14, 11).unwrap(), CheckerMove::new(14, 10).unwrap().mirror(),
); );
assert_eq!( assert_eq!(
Err(MoveError::OpponentCanFillQuarter), Err(MoveError::OpponentCanFillQuarter),
@ -1277,6 +1277,7 @@ mod tests {
); );
assert!(!state.moves_possible(&moves)); assert!(!state.moves_possible(&moves));
// Chaned moves: can't rest on a field occupied by one opponent's checker
state.board.set_positions( state.board.set_positions(
&Color::White, &Color::White,
[ [
@ -1288,7 +1289,7 @@ mod tests {
CheckerMove::new(10, 15).unwrap(), CheckerMove::new(10, 15).unwrap(),
CheckerMove::new(15, 20).unwrap(), CheckerMove::new(15, 20).unwrap(),
); );
assert!(state.moves_possible(&moves)); assert!(!state.moves_possible(&moves));
// black moves // black moves
let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default()); let state = MoveRules::new(&Color::Black, &Board::default(), Dice::default());