Compare commits

..

No commits in common. "2838d59f30887dea4b8245c157c5709e2821aee5" and "24f5dba0656a97977908c421080642465c36c9f3" have entirely different histories.

8 changed files with 53 additions and 92 deletions

View file

@ -520,9 +520,6 @@ body {
.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } .die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; }
.die-face.die-used circle { fill: #9a8a70; } .die-face.die-used circle { fill: #9a8a70; }
.die-face .die-question { fill: #1a0a00; font-family: sans-serif; }
.die-face.die-used .die-question { fill: #9a8a70; }
/* ── Jan panel ──────────────────────────────────────────────────────── */ /* ── Jan panel ──────────────────────────────────────────────────────── */
.jan-panel { .jan-panel {
display: flex; display: flex;

View file

@ -237,7 +237,7 @@ pub fn App() -> impl IntoView {
let is_host = session.is_host; let is_host = session.is_host;
let player_id = session.player_id; let player_id = session.player_id;
let reconnect_token = session.reconnect_token; let reconnect_token = session.reconnect_token;
let mut vs = ViewState::default_with_names("Blancs", "Noirs"); let mut vs = ViewState::default_with_names("Host", "Guest");
loop { loop {
futures::select! { futures::select! {
@ -270,7 +270,6 @@ pub fn App() -> impl IntoView {
view_state: Some(vs.clone()), view_state: Some(vs.clone()),
}); });
} }
let is_own_move = prev_vs.active_mp_player == Some(player_id);
push_or_show( push_or_show(
&prev_vs, &prev_vs,
GameUiState { GameUiState {
@ -282,7 +281,7 @@ pub fn App() -> impl IntoView {
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, is_own_move), last_moves: compute_last_moves(&prev_vs, &vs),
}, },
pending, pending,
screen, screen,
@ -377,7 +376,7 @@ async fn run_local_bot_game(
pause_reason: None, pause_reason: None,
my_scored_event: scored, my_scored_event: scored,
opp_scored_event: opp_scored, opp_scored_event: opp_scored,
last_moves: compute_last_moves(&prev_vs, &vs, true), last_moves: compute_last_moves(&prev_vs, &vs),
})); }));
} }
Some(NetCommand::PlayVsBot) => return true, Some(NetCommand::PlayVsBot) => return true,
@ -407,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(&delta_prev_vs, &vs, false), last_moves: compute_last_moves(&delta_prev_vs, &vs),
}, },
pending, pending,
screen, screen,
@ -422,8 +421,7 @@ 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.
/// Returns `None` when the board is unchanged or no real moves were recorded. /// Returns `None` when the board is unchanged or no real moves were recorded.
/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2. fn compute_last_moves(prev: &ViewState, next: &ViewState) -> Option<(CheckerMove, CheckerMove)> {
fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Option<(CheckerMove, CheckerMove)> {
if prev.board == next.board { if prev.board == next.board {
return None; return None;
} }
@ -434,11 +432,6 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Opt
// without setting dice_moves would bypass this guard and replay stale animation. // without setting dice_moves would bypass this guard and replay stale animation.
return None; return None;
} }
if own_move {
// m1 was already shown via the staged-moves overlay; only animate m2.
if m2 == CheckerMove::default() { return None; }
return Some((m2, CheckerMove::default()));
}
Some((m1, m2)) Some((m1, m2))
} }

View file

@ -22,7 +22,7 @@ pub fn Die(
value: u8, value: u8,
used: bool, used: bool,
#[prop(default = false)] is_double: bool, #[prop(default = false)] is_double: bool,
) -> AnyView { ) -> impl IntoView {
let mut cls = if used { let mut cls = if used {
"die-face die-used".to_string() "die-face die-used".to_string()
} else { } else {
@ -31,15 +31,6 @@ pub fn Die(
if is_double && !used { if is_double && !used {
cls.push_str(" die-double"); cls.push_str(" die-double");
} }
if value == 0 {
return view! {
<svg class=cls width="48" height="48" viewBox="0 0 48 48">
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
<text x="24" y="32" text-anchor="middle" font-size="24" font-weight="bold"
class="die-question">{"?"}</text>
</svg>
}.into_any();
}
let dots: Vec<AnyView> = dot_positions(value) let dots: Vec<AnyView> = dot_positions(value)
.iter() .iter()
.map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any()) .map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any())
@ -49,5 +40,5 @@ pub fn Die(
<rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" /> <rect x="1.5" y="1.5" width="45" height="45" rx="7" ry="7" />
{dots} {dots}
</svg> </svg>
}.into_any() }
} }

View file

@ -5,10 +5,10 @@ use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
use super::die::Die;
use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::app::{GameUiState, NetCommand, PauseReason};
use crate::i18n::*; use crate::i18n::*;
use crate::trictrac::types::{PlayerAction, PreGameRollState, 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;
@ -81,8 +81,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// 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.
// Guard: never auto-roll during the pre-game ceremony (the ceremony overlay // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay
// has its own Roll button for PlayerAction::PreGameRoll). // has its own Roll button for PlayerAction::PreGameRoll).
let show_roll = let show_roll = is_my_turn
is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll; && 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 |_| {
@ -373,7 +374,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}); });
let my_die = if player_id == 0 { pgr.host_die } else { pgr.guest_die }; 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 opp_die = if player_id == 0 { pgr.guest_die } else { pgr.host_die };
let can_roll = my_die.is_none() && !waiting_for_confirm; let can_roll = is_my_turn && !waiting_for_confirm;
let show_tie = pgr.tie_count > 0; let show_tie = pgr.tie_count > 0;
view! { view! {
<div class="ceremony-overlay"> <div class="ceremony-overlay">
@ -384,7 +385,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
})} })}
<div class="ceremony-dice"> <div class="ceremony-dice">
<div class="ceremony-die-slot"> <div class="ceremony-die-slot">
<span class="ceremony-die-label">{my_name_ceremony}{t!(i18n, you_suffix)}</span> <span class="ceremony-die-label">{my_name_ceremony}</span>
<Die value=my_die.unwrap_or(0) used=false /> <Die value=my_die.unwrap_or(0) used=false />
</div> </div>
<div class="ceremony-die-slot"> <div class="ceremony-die-slot">

View file

@ -1,5 +1,5 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage}; use trictrac_store::{DiceRoller, GameEvent, GameState, TurnStage};
use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState}; use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
@ -32,8 +32,12 @@ impl TrictracBackend {
guest_die: self.pre_game_dice[1], guest_die: self.pre_game_dice[1],
tie_count: self.tie_count, tie_count: self.tie_count,
}); });
// Both players roll independently; no single "active" player. // The active mp player is whoever hasn't rolled yet (host rolls first).
vs.active_mp_player = None; vs.active_mp_player = match self.pre_game_dice {
[None, _] => Some(0),
[Some(_), None] => Some(1),
_ => None,
};
} }
self.view_state = vs; self.view_state = vs;
} }
@ -48,11 +52,16 @@ impl TrictracBackend {
/// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest). /// Process one ceremony die-roll for `mp_player` (0 = host, 1 = guest).
fn handle_pre_game_roll(&mut self, mp_player: u16) { fn handle_pre_game_roll(&mut self, mp_player: u16) {
let idx = mp_player as usize; // Enforce turn order: host rolls first, then guest.
// Ignore if this player already rolled. let expected: u16 = match self.pre_game_dice {
if self.pre_game_dice[idx].is_some() { [None, _] => 0,
[Some(_), None] => 1,
_ => return, // both already rolled (shouldn't happen)
};
if mp_player != expected {
return; return;
} }
let idx = mp_player as usize;
let single = self.dice_roller.roll().values.0; let single = self.dice_roller.roll().values.0;
self.pre_game_dice[idx] = Some(single); self.pre_game_dice[idx] = Some(single);
@ -66,21 +75,9 @@ impl TrictracBackend {
self.broadcast_state(); self.broadcast_state();
} else { } else {
// Highest die goes first. // Highest die goes first.
let goes_first = if h > g { let goes_first = if h > g { HOST_PLAYER_ID } else { GUEST_PLAYER_ID };
HOST_PLAYER_ID
} else {
GUEST_PLAYER_ID
};
self.ceremony_started = false; self.ceremony_started = false;
let _ = self.game.consume(&GameEvent::BeginGame { goes_first }); let _ = self.game.consume(&GameEvent::BeginGame { goes_first });
// Use pre-game dice roll for the first move
let _ = self.game.consume(&GameEvent::Roll {
player_id: goes_first,
});
let _ = self.game.consume(&GameEvent::RollResult {
player_id: goes_first,
dice: Dice { values: (g, h) },
});
self.broadcast_state(); self.broadcast_state();
} }
} else { } else {
@ -135,8 +132,8 @@ impl TrictracBackend {
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend { impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
fn new(_rule_variation: u16) -> Self { fn new(_rule_variation: u16) -> Self {
let mut game = GameState::new(false); let mut game = GameState::new(false);
game.init_player("Blancs"); game.init_player("Host");
game.init_player("Noirs"); game.init_player("Guest");
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID); let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
@ -174,9 +171,7 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
}); });
// Start the ceremony once both players have arrived. // Start the ceremony once both players have arrived.
if self.arrived[0] if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame
&& self.arrived[1]
&& self.game.stage == trictrac_store::Stage::PreGame
&& !self.ceremony_started && !self.ceremony_started
{ {
self.ceremony_started = true; self.ceremony_started = true;
@ -289,8 +284,8 @@ impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::trictrac::types::{SerStage, SerTurnStage};
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)
@ -314,17 +309,10 @@ mod tests {
if b.get_view_state().stage != SerStage::PreGameRoll { if b.get_view_state().stage != SerStage::PreGameRoll {
break; break;
} }
let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default(); match b.get_view_state().active_mp_player {
let host_needs = pgr.host_die.is_none(); Some(0) => b.inform_rpc(0, PlayerAction::PreGameRoll),
let guest_needs = pgr.guest_die.is_none(); Some(1) => b.inform_rpc(1, PlayerAction::PreGameRoll),
if !host_needs && !guest_needs { _ => break,
break; // both rolled but stage not yet resolved — shouldn't happen
}
if host_needs {
b.inform_rpc(0, PlayerAction::PreGameRoll);
}
if guest_needs {
b.inform_rpc(1, PlayerAction::PreGameRoll);
} }
b.drain_commands(); b.drain_commands();
} }
@ -342,10 +330,7 @@ mod tests {
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. // Stage should now be PreGameRoll, not InGame.
assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll); assert_eq!(b.get_view_state().stage, SerStage::PreGameRoll);
@ -364,25 +349,19 @@ mod tests {
} }
#[test] #[test]
fn ceremony_any_order_allowed() { fn ceremony_wrong_order_ignored() {
let mut b = make_backend(); let mut b = make_backend();
b.player_arrival(0); b.player_arrival(0);
b.player_arrival(1); b.player_arrival(1);
b.drain_commands(); b.drain_commands();
// Guest may roll before host. // Guest tries to roll before host (host goes first in ceremony).
b.inform_rpc(1, PlayerAction::PreGameRoll); b.inform_rpc(1, PlayerAction::PreGameRoll);
let states = drain_deltas(&mut b); let cmds = b.drain_commands();
assert!( assert!(
!states.is_empty(), cmds.is_empty(),
"guest PreGameRoll should broadcast a state" "guest PreGameRoll should be ignored when it is host's turn"
); );
let pgr = states.last().unwrap().pre_game_roll.as_ref().unwrap();
assert!(
pgr.guest_die.is_some(),
"guest die should be set after guest rolls"
);
assert!(pgr.host_die.is_none(), "host die should still be blank");
} }
#[test] #[test]
@ -406,10 +385,7 @@ mod tests {
complete_ceremony(&mut b); complete_ceremony(&mut b);
// Roll for whoever won the ceremony (either player could go first). // Roll for whoever won the ceremony (either player could go first).
let first_player = b let first_player = b.get_view_state().active_mp_player.expect("someone should be active");
.get_view_state()
.active_mp_player
.expect("someone should be active");
b.inform_rpc(first_player, PlayerAction::Roll); 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");
@ -441,7 +417,10 @@ mod tests {
let wrong_player = if active == Some(0) { 1u16 } else { 0u16 }; let wrong_player = if active == Some(0) { 1u16 } else { 0u16 };
b.inform_rpc(wrong_player, PlayerAction::Roll); b.inform_rpc(wrong_player, PlayerAction::Roll);
let cmds = b.drain_commands(); let cmds = b.drain_commands();
assert!(cmds.is_empty(), "wrong player roll should be ignored"); assert!(
cmds.is_empty(),
"wrong player roll should be ignored"
);
} }
#[test] #[test]

View file

@ -25,8 +25,8 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<Pl
} }
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::Go),
TurnStage::Move | TurnStage::HoldOrGoChoice => { 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![]);
let mut rng = rand::rng(); let mut rng = rand::rng();

View file

@ -31,7 +31,7 @@ pub struct GameDelta {
/// State of the pre-game ceremony where each player rolls one die to decide /// State of the pre-game ceremony where each player rolls one die to decide
/// who goes first. Present only when `stage == SerStage::PreGameRoll`. /// who goes first. Present only when `stage == SerStage::PreGameRoll`.
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct PreGameRollState { pub struct PreGameRollState {
/// Die value (16) rolled by the host; `None` = not yet rolled this round. /// Die value (16) rolled by the host; `None` = not yet rolled this round.
pub host_die: Option<u8>, pub host_die: Option<u8>,

View file

@ -9,7 +9,7 @@ French terms follow the mapping in [vocabulary.md](refs/vocabulary.md).
## 1. Board and Starting Position ## 1. Board and Starting Position
- 24 triangular fields (_flèches_ / _cases_), numbered 124 from each player's perspective. - 24 triangular fields (_flèches_ / _cases_), numbered 124 from each player's perspective.
- 4 quarters of 6 fields: **small jan** (16), **big jan** (712), **opponent's big jan** (1318), **return jan** (1924, exit zone). - 4 quarters of 6 fields: **small jan** (16), **big jan** (712), **return jan** (1318), **last jan** (1924, exit zone).
- Field 12 (White) / 13 (Black) is the **rest corner** (_coin de repos_). - Field 12 (White) / 13 (Black) is the **rest corner** (_coin de repos_).
- Each player starts with all 15 checkers in a stack (_talon_) on field 1. - Each player starts with all 15 checkers in a stack (_talon_) on field 1.
- Checkers always move in the same direction (White: 1→24; Black: mirror of that). - Checkers always move in the same direction (White: 1→24; Black: mirror of that).
@ -98,7 +98,7 @@ Ways to hit:
### 5d. Exit ### 5d. Exit
- When all 15 checkers are in the return jan (fields 1924), the player may exit. - When all 15 checkers are in the last jan (fields 1924), the player may exit.
- The exit rail counts as one additional field value. - The exit rail counts as one additional field value.
- **Exact exit**: die value brings the checker directly to the exit rail — allowed. - **Exact exit**: die value brings the checker directly to the exit rail — allowed.
- **Overflow** (_nombre excédant_): die value would carry the farthest checker past the rail — must exit. - **Overflow** (_nombre excédant_): die value would carry the farthest checker past the rail — must exit.