Compare commits
3 commits
43196bcef8
...
24f5dba065
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f5dba065 | |||
| b68881fc38 | |||
| 9af672823e |
12 changed files with 366 additions and 50 deletions
|
|
@ -34,4 +34,5 @@ web-sys = { version = "0.3", features = [
|
|||
"OscillatorNode",
|
||||
"OscillatorType",
|
||||
"BaseAudioContext",
|
||||
"HtmlAudioElement",
|
||||
] }
|
||||
|
|
|
|||
BIN
client_web/assets/diceroll.mp3
Normal file
BIN
client_web/assets/diceroll.mp3
Normal file
Binary file not shown.
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<title>Trictrac</title>
|
||||
<link data-trunk rel="rust" />
|
||||
<link data-trunk rel="css" href="assets/style.css" />
|
||||
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<PauseReason> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 ──────────────────────────────────────────────────
|
||||
let my_scored_event = state.my_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) ──────────
|
||||
// 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() {
|
||||
|
|
@ -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) ────────────────────────────────────
|
||||
<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 ─────────────────────────────────────────────
|
||||
{stage_is_ended.then(|| {
|
||||
let opp_name_end_clone = opp_name_end.clone();
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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<u8>; 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<PlayerAction, GameDelta, ViewState> 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<PlayerAction, GameDelta, ViewState> 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<PlayerAction, GameDelta, ViewState> 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<PlayerAction, GameDelta, ViewState> 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<PlayerAction, GameDelta, ViewState> for TrictracBackend
|
|||
self.drive_automatic_stages();
|
||||
}
|
||||
}
|
||||
PlayerAction::PreGameRoll => {} // ignored outside ceremony
|
||||
}
|
||||
|
||||
self.broadcast_state();
|
||||
|
|
@ -216,6 +285,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> 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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -15,7 +25,7 @@ pub fn bot_decide(game: &GameState) -> Option<PlayerAction> {
|
|||
}
|
||||
match game.turn_stage {
|
||||
TurnStage::RollDice => Some(PlayerAction::Roll),
|
||||
TurnStage::HoldOrGoChoice => Some(PlayerAction::Mark),
|
||||
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![]);
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
/// Die value (1–6) 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)]
|
||||
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<JanEntry>,
|
||||
/// 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<PreGameRollState>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue