Compare commits

...

3 commits

12 changed files with 366 additions and 50 deletions

View file

@ -34,4 +34,5 @@ web-sys = { version = "0.3", features = [
"OscillatorNode", "OscillatorNode",
"OscillatorType", "OscillatorType",
"BaseAudioContext", "BaseAudioContext",
"HtmlAudioElement",
] } ] }

Binary file not shown.

View file

@ -1131,3 +1131,63 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
min-height: 2rem; /* reserve height so layout doesn't shift when buttons appear */ 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;
}

View file

@ -6,6 +6,7 @@
<title>Trictrac</title> <title>Trictrac</title>
<link data-trunk rel="rust" /> <link data-trunk rel="rust" />
<link data-trunk rel="css" href="assets/style.css" /> <link data-trunk rel="css" href="assets/style.css" />
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
</head> </head>
<body></body> <body></body>
</html> </html>

View file

@ -42,6 +42,12 @@
"after_opponent_roll": "Opponent rolled", "after_opponent_roll": "Opponent rolled",
"after_opponent_go": "Opponent chose to continue", "after_opponent_go": "Opponent chose to continue",
"after_opponent_move": "Opponent moved — your turn", "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", "continue_btn": "Continue",
"scored_pts": "+{{ n }} pts", "scored_pts": "+{{ n }} pts",
"hole_made": "Hole! {{ holes }}/12", "hole_made": "Hole! {{ holes }}/12",

View file

@ -42,6 +42,12 @@
"after_opponent_roll": "L'adversaire a lancé les dés", "after_opponent_roll": "L'adversaire a lancé les dés",
"after_opponent_go": "L'adversaire s'en va", "after_opponent_go": "L'adversaire s'en va",
"after_opponent_move": "L'adversaire a joué — à vous", "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", "continue_btn": "Continuer",
"scored_pts": "+{{ n }} pts", "scored_pts": "+{{ n }} pts",
"hole_made": "Trou ! {{ holes }}/12", "hole_made": "Trou ! {{ holes }}/12",

View file

@ -13,7 +13,7 @@ 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::{ use crate::trictrac::types::{
GameDelta, JanEntry, PlayerAction, ScoredEvent, SerTurnStage, ViewState, GameDelta, JanEntry, PlayerAction, ScoredEvent, SerStage, SerTurnStage, ViewState,
}; };
use trictrac_store::CheckerMove; use trictrac_store::CheckerMove;
@ -48,6 +48,8 @@ pub enum PauseReason {
AfterOpponentRoll, AfterOpponentRoll,
AfterOpponentGo, AfterOpponentGo,
AfterOpponentMove, AfterOpponentMove,
/// Opponent rolled their die in the pre-game ceremony.
AfterOpponentPreGameRoll,
} }
/// Which screen is currently shown. /// Which screen is currently shown.
@ -382,18 +384,19 @@ async fn run_local_bot_game(
} }
loop { 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, None => break,
Some(action) => { Some(action) => {
let prev_vs = vs.clone();
backend.inform_rpc(1, action); 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() { for cmd in backend.drain_commands() {
if let BackendCommand::Delta(delta) = cmd { if let BackendCommand::Delta(delta) = cmd {
let delta_prev_vs = vs.clone();
vs.apply_delta(&delta); vs.apply_delta(&delta);
}
}
push_or_show( push_or_show(
&prev_vs, &delta_prev_vs,
GameUiState { GameUiState {
view_state: vs.clone(), view_state: vs.clone(),
player_id: 0, player_id: 0,
@ -403,7 +406,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: None, my_scored_event: None,
opp_scored_event: None, opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs), last_moves: compute_last_moves(&delta_prev_vs, &vs),
}, },
pending, pending,
screen, screen,
@ -412,6 +415,8 @@ async fn run_local_bot_game(
} }
} }
} }
}
}
} }
/// Returns the checker moves to animate when the board changed between two ViewStates. /// Returns the checker moves to animate when the board changed between two ViewStates.
@ -530,6 +535,24 @@ fn push_or_show(
fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> { fn infer_pause_reason(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<PauseReason> {
let opponent_id = 1 - player_id; 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) { if next.active_mp_player == Some(opponent_id) {
// Dice changed → opponent just rolled. // Dice changed → opponent just rolled.
if next.dice != prev.dice { if next.dice != prev.dice {
@ -574,6 +597,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()),
pre_game_roll: None,
} }
} }

View file

@ -7,7 +7,8 @@ use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice,
use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::app::{GameUiState, NetCommand, PauseReason};
use crate::i18n::*; 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::board::Board;
use super::score_panel::PlayerScorePanel; use super::score_panel::PlayerScorePanel;
@ -78,7 +79,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// Guard: suppressed while waiting_for_confirm — the AfterOpponentMove // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove
// buffered state shows the human's RollDice turn but the auto-roll must // 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. // 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 { if show_roll && !waiting_for_confirm {
let cmd_tx_auto = cmd_tx.clone(); let cmd_tx_auto = cmd_tx.clone();
Effect::new(move |_| { 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 my_score = vs.scores[player_id as usize].clone();
let opp_score = vs.scores[1 - 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<PreGameRollState> = 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 ────────────────────────────────────────────────── // ── Scoring notifications ──────────────────────────────────────────────────
let my_scored_event = state.my_scored_event.clone(); let my_scored_event = state.my_scored_event.clone();
let opp_scored_event = state.opp_scored_event.clone(); let opp_scored_event = state.opp_scored_event.clone();
@ -179,7 +191,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Sound effects (fire once on mount = once per state snapshot) ────────── // ── Sound effects (fire once on mount = once per state snapshot) ──────────
// Dice roll: dice just appeared (no preceding moves in this snapshot). // Dice roll: dice just appeared (no preceding moves in this snapshot).
if show_dice && last_moves.is_none() { 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. // Checker move: moves were committed in the preceding action.
if last_moves.is_some() { if last_moves.is_some() {
@ -246,6 +258,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll),
PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go),
PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move),
PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll),
}); });
} }
let n = staged_moves.get().len(); let n = staged_moves.get().len();
@ -254,7 +267,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
} else { } else {
String::from(match (&stage, is_my_turn, &turn_stage) { String::from(match (&stage, is_my_turn, &turn_stage) {
(SerStage::Ended, _, _) => t_string!(i18n, game_over), (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::RollDice) => t_string!(i18n, your_turn_roll),
(SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go),
(SerStage::InGame, true, _) => t_string!(i18n, your_turn), (SerStage::InGame, true, _) => t_string!(i18n, your_turn),
@ -352,6 +365,55 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Player score (below board) ──────────────────────────────────── // ── Player score (below board) ────────────────────────────────────
<PlayerScorePanel score=my_score is_you=true /> <PlayerScorePanel score=my_score is_you=true />
// ── 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! {
<div class="ceremony-overlay">
<div class="ceremony-box">
<h2>{t!(i18n, pre_game_roll_title)}</h2>
{show_tie.then(|| view! {
<p class="ceremony-tie">{t!(i18n, pre_game_roll_tie)}</p>
})}
<div class="ceremony-dice">
<div class="ceremony-die-slot">
<span class="ceremony-die-label">{my_name_ceremony}</span>
<Die value=my_die.unwrap_or(0) used=false />
</div>
<div class="ceremony-die-slot">
<span class="ceremony-die-label">{opp_name_ceremony}</span>
<Die value=opp_die.unwrap_or(0) used=false />
</div>
</div>
{waiting_for_confirm.then(|| {
let pending_c = pending;
view! {
<button class="btn btn-primary" on:click=move |_| {
pending_c.update(|q| { q.pop_front(); });
}>{t!(i18n, continue_btn)}</button>
}
})}
{can_roll.then(|| {
let cmd_tx_c = cmd_tx_ceremony.clone();
view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_c.unbounded_send(NetCommand::Action(PlayerAction::PreGameRoll)).ok();
}>{t!(i18n, pre_game_roll_btn)}</button>
}
})}
</div>
</div>
}
})}
// ── Game-over overlay ───────────────────────────────────────────── // ── Game-over overlay ─────────────────────────────────────────────
{stage_is_ended.then(|| { {stage_is_ended.then(|| {
let opp_name_end_clone = opp_name_end.clone(); let opp_name_end_clone = opp_name_end.clone();

View file

@ -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). /// Ascending three-note chime (C5 E5 G5).
pub fn play_points_scored() { pub fn play_points_scored() {
with_ctx(|ctx| { with_ctx(|ctx| {
@ -158,12 +165,15 @@ mod inner {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use inner::{ 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"))] #[cfg(not(target_arch = "wasm32"))]
pub fn play_checker_move() {} pub fn play_checker_move() {}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn play_dice_roll() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_dice_roll_cinematic() {} pub fn play_dice_roll_cinematic() {}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn play_points_scored() {} pub fn play_points_scored() {}

View file

@ -1,7 +1,7 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage}; 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. // Store PlayerId (u64) values used for the two players.
const HOST_PLAYER_ID: u64 = 1; const HOST_PLAYER_ID: u64 = 1;
@ -14,11 +14,32 @@ pub struct TrictracBackend {
view_state: ViewState, view_state: ViewState,
/// Arrival flags: have host (index 0) and guest (index 1) joined? /// Arrival flags: have host (index 0) and guest (index 1) joined?
arrived: [bool; 2], arrived: [bool; 2],
/// Die rolled by each player during the ceremony ([host, guest]).
pre_game_dice: [Option<u8>; 2],
/// Number of tied rounds so far.
tie_count: u8,
/// True while the first-player ceremony is running.
ceremony_started: bool,
} }
impl TrictracBackend { impl TrictracBackend {
fn sync_view_state(&mut self) { 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) { fn broadcast_state(&mut self) {
@ -29,6 +50,42 @@ impl TrictracBackend {
self.commands.push(BackendCommand::Delta(delta)); 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. /// Roll dice using the store's DiceRoller and fire Roll + RollResult events.
fn do_roll(&mut self) { fn do_roll(&mut self) {
let dice = self.dice_roller.roll(); let dice = self.dice_roller.roll();
@ -86,6 +143,9 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
commands: Vec::new(), commands: Vec::new(),
view_state, view_state,
arrived: [false; 2], arrived: [false; 2],
pre_game_dice: [None; 2],
tie_count: 0,
ceremony_started: false,
} }
} }
@ -110,11 +170,13 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
timer_id: mp_player, timer_id: mp_player,
}); });
// Start the game once both players have arrived. // 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
let _ = self.game.consume(&GameEvent::BeginGame { && !self.ceremony_started
goes_first: HOST_PLAYER_ID, {
}); self.ceremony_started = true;
self.pre_game_dice = [None; 2];
self.tie_count = 0;
self.sync_view_state(); self.sync_view_state();
self.commands.push(BackendCommand::ResetViewState); self.commands.push(BackendCommand::ResetViewState);
} else { } else {
@ -135,6 +197,14 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
} }
fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) { 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 { if self.game.stage == trictrac_store::Stage::Ended {
return; return;
} }
@ -167,8 +237,6 @@ 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();
} }
@ -188,6 +256,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
self.drive_automatic_stages(); self.drive_automatic_stages();
} }
} }
PlayerAction::PreGameRoll => {} // ignored outside ceremony
} }
self.broadcast_state(); self.broadcast_state();
@ -216,6 +285,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
mod tests { mod tests {
use super::*; use super::*;
use backbone_lib::traits::BackEndArchitecture; use backbone_lib::traits::BackEndArchitecture;
use crate::trictrac::types::{SerStage, SerTurnStage};
fn make_backend() -> TrictracBackend { fn make_backend() -> TrictracBackend {
TrictracBackend::new(0) TrictracBackend::new(0)
@ -233,28 +303,67 @@ mod tests {
.collect() .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] #[test]
fn both_players_arrive_starts_game() { fn both_players_arrive_starts_ceremony() {
let mut b = make_backend(); let mut b = make_backend();
b.player_arrival(0); // host b.player_arrival(0); // host
b.drain_commands(); b.drain_commands();
b.player_arrival(1); // guest b.player_arrival(1); // guest
let cmds = b.drain_commands(); 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 let has_reset = cmds
.iter() .iter()
.any(|c| matches!(c, BackendCommand::ResetViewState)); .any(|c| matches!(c, BackendCommand::ResetViewState));
assert!( assert!(has_reset, "expected ResetViewState after both players arrive");
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); 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] #[test]
fn unknown_player_kicked() { fn unknown_player_kicked() {
let mut b = make_backend(); let mut b = make_backend();
@ -272,12 +381,15 @@ mod tests {
b.player_arrival(1); b.player_arrival(1);
b.drain_commands(); b.drain_commands();
// Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame). // Complete ceremony before rolling.
b.inform_rpc(0, PlayerAction::Roll); 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); let states = drain_deltas(&mut b);
assert!(!states.is_empty(), "expected a state broadcast after roll"); assert!(!states.is_empty(), "expected a state broadcast after roll");
use crate::trictrac::types::SerTurnStage;
let last = states.last().unwrap(); let last = states.last().unwrap();
assert!( assert!(
matches!( matches!(
@ -298,13 +410,16 @@ mod tests {
b.player_arrival(0); b.player_arrival(0);
b.player_arrival(1); b.player_arrival(1);
b.drain_commands(); b.drain_commands();
complete_ceremony(&mut b);
// Guest tries to roll when it's the host's turn. // Identify who goes first and have the OTHER player try to roll.
b.inform_rpc(1, PlayerAction::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(); let cmds = b.drain_commands();
assert!( assert!(
cmds.is_empty(), cmds.is_empty(),
"guest roll should be ignored when it's host's turn" "wrong player roll should be ignored"
); );
} }

View file

@ -1,12 +1,22 @@
use rand::prelude::IndexedRandom; use rand::prelude::IndexedRandom;
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; 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; 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. /// 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<PlayerAction> { /// `pgr` is the current pre-game ceremony state if the ceremony is in progress.
pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<PlayerAction> {
// 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 { if game.stage == Stage::Ended {
return None; return None;
} }
@ -15,7 +25,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::Mark), TurnStage::HoldOrGoChoice => Some(PlayerAction::Go),
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

@ -14,6 +14,8 @@ pub enum PlayerAction {
Go, Go,
/// Acknowledge point marking (hold / advance points). /// Acknowledge point marking (hold / advance points).
Mark, Mark,
/// Roll a single die during the pre-game ceremony to decide who goes first.
PreGameRoll,
} }
// ── Incremental state update broadcast to all clients ──────────────────────── // ── Incremental state update broadcast to all clients ────────────────────────
@ -27,6 +29,18 @@ pub struct GameDelta {
// ── Full game snapshot ──────────────────────────────────────────────────────── // ── 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 (16) rolled by the host; `None` = not yet rolled this round.
pub host_die: Option<u8>,
/// Die value (16) rolled by the guest; `None` = not yet rolled this round.
pub guest_die: Option<u8>,
/// Number of tied rounds so far (0 on the first round).
pub tie_count: u8,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewState { pub struct ViewState {
/// Board positions: index i = field i+1. Positive = white, negative = black. /// Board positions: index i = field i+1. Positive = white, negative = black.
@ -43,6 +57,9 @@ pub struct ViewState {
pub dice_jans: Vec<JanEntry>, pub dice_jans: Vec<JanEntry>,
/// Last two checker moves played; default when no move has occurred yet. /// Last two checker moves played; default when no move has occurred yet.
pub dice_moves: (CheckerMove, CheckerMove), pub dice_moves: (CheckerMove, CheckerMove),
/// Present while the pre-game ceremony is in progress.
#[serde(default)]
pub pre_game_roll: Option<PreGameRollState>,
} }
/// One scoring event from a dice roll. /// One scoring event from a dice roll.
@ -86,6 +103,7 @@ impl ViewState {
dice: (0, 0), dice: (0, 0),
dice_jans: Vec::new(), dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()), 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: (gs.dice.values.0, gs.dice.values.1),
dice_jans, dice_jans,
dice_moves: gs.dice_moves, dice_moves: gs.dice_moves,
pre_game_roll: None,
} }
} }
} }
@ -220,6 +239,8 @@ pub struct PlayerScore {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SerStage { pub enum SerStage {
PreGame, PreGame,
/// Both players have arrived; ceremony in progress to decide who goes first.
PreGameRoll,
InGame, InGame,
Ended, Ended,
} }