chore: integrate multiplayer code (wip)

This commit is contained in:
Henri Bourcereau 2026-04-22 17:42:05 +02:00
parent 2838d59f30
commit 4f5e21becb
66 changed files with 6423 additions and 18 deletions

656
clients/web-game/src/app.rs Normal file
View file

@ -0,0 +1,656 @@
use futures::channel::mpsc;
use futures::{FutureExt, StreamExt};
use gloo_storage::{LocalStorage, Storage};
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde::{Deserialize, Serialize};
use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate};
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, SerStage, SerTurnStage, ViewState,
};
use trictrac_store::CheckerMove;
use std::collections::VecDeque;
const RELAY_URL: &str = "ws://127.0.0.1:8080/ws";
const GAME_ID: &str = "trictrac";
const STORAGE_KEY: &str = "trictrac_session";
/// The state the UI needs to render the game screen.
#[derive(Clone, PartialEq)]
pub struct GameUiState {
pub view_state: ViewState,
/// 0 = host, 1 = guest
pub player_id: u16,
pub room_id: String,
pub is_bot_game: bool,
/// True when this state is a buffered snapshot awaiting player confirmation.
pub waiting_for_confirm: bool,
/// Why we are paused — drives the status-bar message in GameScreen.
pub pause_reason: Option<PauseReason>,
/// Points scored by this player in the transition to this state (if any).
pub my_scored_event: Option<ScoredEvent>,
pub opp_scored_event: Option<ScoredEvent>,
/// Checker moves to animate on this render. None when board is unchanged.
pub last_moves: Option<(CheckerMove, CheckerMove)>,
}
/// Reason the UI is paused waiting for the player to click Continue.
#[derive(Clone, Debug, PartialEq)]
pub enum PauseReason {
AfterOpponentRoll,
AfterOpponentGo,
AfterOpponentMove,
/// Opponent rolled their die in the pre-game ceremony.
AfterOpponentPreGameRoll,
}
/// Which screen is currently shown.
#[derive(Clone, PartialEq)]
pub enum Screen {
Login { error: Option<String> },
Connecting,
Playing(GameUiState),
}
/// Commands sent from UI event handlers into the network task.
pub enum NetCommand {
CreateRoom {
room: String,
},
JoinRoom {
room: String,
},
Reconnect {
relay_url: String,
game_id: String,
room_id: String,
token: u64,
host_state: Option<Vec<u8>>,
},
PlayVsBot,
Action(PlayerAction),
Disconnect,
}
/// Stored in localStorage to reconnect after a page refresh.
#[derive(Serialize, Deserialize)]
struct StoredSession {
relay_url: String,
game_id: String,
room_id: String,
token: u64,
#[serde(default)]
is_host: bool,
#[serde(default)]
view_state: Option<ViewState>,
}
fn save_session(session: &StoredSession) {
LocalStorage::set(STORAGE_KEY, session).ok();
}
fn load_session() -> Option<StoredSession> {
LocalStorage::get::<StoredSession>(STORAGE_KEY).ok()
}
fn clear_session() {
LocalStorage::delete(STORAGE_KEY);
}
#[component]
pub fn App() -> impl IntoView {
let stored = load_session();
let initial_screen = if stored.is_some() {
Screen::Connecting
} else {
Screen::Login { error: None }
};
let screen = RwSignal::new(initial_screen);
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
let pending: RwSignal<VecDeque<GameUiState>> = RwSignal::new(VecDeque::new());
provide_context(pending);
provide_context(cmd_tx.clone());
if let Some(s) = stored {
let host_state = s
.view_state
.as_ref()
.and_then(|vs| serde_json::to_vec(vs).ok());
cmd_tx
.unbounded_send(NetCommand::Reconnect {
relay_url: s.relay_url,
game_id: s.game_id,
room_id: s.room_id,
token: s.token,
host_state,
})
.ok();
}
spawn_local(async move {
loop {
// Wait for a connect/reconnect command (or PlayVsBot).
// None means "play vs bot"; Some((config, is_reconnect)) means "connect to relay".
let remote_config: Option<(RoomConfig, bool)> = loop {
match cmd_rx.next().await {
Some(NetCommand::PlayVsBot) => break None,
Some(NetCommand::CreateRoom { room }) => {
break Some((
RoomConfig {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room,
rule_variation: 0,
role: RoomRole::Create,
reconnect_token: None,
host_state: None,
},
false,
));
}
Some(NetCommand::JoinRoom { room }) => {
break Some((
RoomConfig {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room,
rule_variation: 0,
role: RoomRole::Join,
reconnect_token: None,
host_state: None,
},
false,
));
}
Some(NetCommand::Reconnect {
relay_url,
game_id,
room_id,
token,
host_state,
}) => {
break Some((
RoomConfig {
relay_url,
game_id,
room_id,
rule_variation: 0,
role: RoomRole::Join,
reconnect_token: Some(token),
host_state,
},
true,
));
}
_ => {} // Ignore game commands while disconnected.
}
};
if remote_config.is_none() {
loop {
let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await;
if !restart {
break;
}
}
pending.update(|q| q.clear());
screen.set(Screen::Login { error: None });
continue;
}
let (config, is_reconnect) = remote_config.unwrap();
screen.set(Screen::Connecting);
let room_id_for_storage = config.room_id.clone();
let mut session: GameSession<PlayerAction, GameDelta, ViewState> =
match GameSession::connect::<TrictracBackend>(config).await {
Ok(s) => s,
Err(ConnectError::WebSocket(e) | ConnectError::Handshake(e)) => {
if is_reconnect {
clear_session();
}
screen.set(Screen::Login { error: Some(e) });
continue;
}
};
if !session.is_host {
save_session(&StoredSession {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room_id_for_storage.clone(),
token: session.reconnect_token,
is_host: false,
view_state: None,
});
}
let is_host = session.is_host;
let player_id = session.player_id;
let reconnect_token = session.reconnect_token;
let mut vs = ViewState::default_with_names("Blancs", "Noirs");
loop {
futures::select! {
cmd = cmd_rx.next().fuse() => match cmd {
Some(NetCommand::Action(action)) => {
session.send_action(action);
}
_ => {
clear_session();
session.disconnect();
pending.update(|q| q.clear());
screen.set(Screen::Login { error: None });
break;
}
},
event = session.next_event().fuse() => match event {
Some(SessionEvent::Update(u)) => {
let prev_vs = vs.clone();
match u {
ViewStateUpdate::Full(state) => vs = state,
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
}
if is_host {
save_session(&StoredSession {
relay_url: RELAY_URL.to_string(),
game_id: GAME_ID.to_string(),
room_id: room_id_for_storage.clone(),
token: reconnect_token,
is_host: true,
view_state: Some(vs.clone()),
});
}
let is_own_move = prev_vs.active_mp_player == Some(player_id);
push_or_show(
&prev_vs,
GameUiState {
view_state: vs.clone(),
player_id,
room_id: room_id_for_storage.clone(),
is_bot_game: false,
waiting_for_confirm: false,
pause_reason: None,
my_scored_event: None,
opp_scored_event: None,
last_moves: compute_last_moves(&prev_vs, &vs, is_own_move),
},
pending,
screen,
);
}
Some(SessionEvent::Disconnected(reason)) => {
pending.update(|q| q.clear());
screen.set(Screen::Login { error: reason });
break;
}
None => {
pending.update(|q| q.clear());
screen.set(Screen::Login { error: None });
break;
}
}
}
}
}
});
view! {
<I18nContextProvider>
{move || {
let q = pending.get();
if let Some(front) = q.front() {
view! { <GameScreen state=front.clone() /> }.into_any()
} else {
match screen.get() {
Screen::Login { error } => view! { <LoginScreen error=error /> }.into_any(),
Screen::Connecting => view! { <ConnectingScreen /> }.into_any(),
Screen::Playing(state) => view! { <GameScreen state=state /> }.into_any(),
}
}
}}
</I18nContextProvider>
}
}
/// Runs one local bot game. Returns `true` if the player wants to play again.
async fn run_local_bot_game(
screen: RwSignal<Screen>,
cmd_rx: &mut futures::channel::mpsc::UnboundedReceiver<NetCommand>,
pending: RwSignal<VecDeque<GameUiState>>,
) -> bool {
let mut backend = TrictracBackend::new(0);
backend.player_arrival(0);
backend.player_arrival(1);
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);
}
_ => {}
}
}
screen.set(Screen::Playing(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: None,
}));
loop {
match cmd_rx.next().await {
Some(NetCommand::Action(action)) => {
let prev_vs = vs.clone();
backend.inform_rpc(0, action);
for cmd in backend.drain_commands() {
if let BackendCommand::Delta(delta) = cmd {
vs.apply_delta(&delta);
}
}
let scored = compute_scored_event(&prev_vs, &vs, 0);
let opp_scored = compute_scored_event(&prev_vs, &vs, 1);
screen.set(Screen::Playing(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: scored,
opp_scored_event: opp_scored,
last_moves: compute_last_moves(&prev_vs, &vs, true),
}));
}
Some(NetCommand::PlayVsBot) => return true,
_ => return false,
}
loop {
let pgr = backend.get_view_state().pre_game_roll.clone();
match bot_decide(backend.get_game(), pgr.as_ref()) {
None => break,
Some(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() {
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, false),
},
pending,
screen,
);
}
}
}
}
}
}
}
/// 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.
/// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2.
fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Option<(CheckerMove, CheckerMove)> {
if prev.board == next.board {
return None;
}
let (m1, m2) = next.dice_moves;
if m1 == CheckerMove::default() && m2 == CheckerMove::default() {
// Relies on the engine invariant: dice_moves is updated atomically with the board
// change in the Move event handler. Any future engine path that mutates the board
// without setting dice_moves would bypass this guard and replay stale animation.
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))
}
/// Computes a scoring event for `player_id` by comparing the previous and next
/// ViewState. Returns `None` when no points changed for that player.
fn compute_scored_event(prev: &ViewState, next: &ViewState, player_id: u16) -> Option<ScoredEvent> {
let prev_score = &prev.scores[player_id as usize];
let next_score = &next.scores[player_id as usize];
let holes_gained = next_score.holes.saturating_sub(prev_score.holes);
if holes_gained == 0 && prev_score.points == next_score.points {
return None;
}
let bredouille = holes_gained > 0 && prev_score.can_bredouille;
// Determine which dice_jans are "mine" depending on who was the active roller.
let my_jans: Vec<JanEntry> = if next.active_mp_player == Some(player_id)
&& 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) {
// 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()
})
.collect()
} else {
return None;
};
let points_earned: u8 = my_jans
.iter()
.fold(0u8, |acc, e| acc.saturating_add(e.total.unsigned_abs()));
if points_earned == 0 && holes_gained == 0 {
return None;
}
Some(ScoredEvent {
points_earned,
holes_gained,
holes_total: next_score.holes,
bredouille,
jans: my_jans,
})
}
/// Either queues the state as a buffered confirmation step (when the transition
/// warrants a pause) or shows it immediately. Always updates `screen` to the
/// live state so the UI falls through to the right content once pending drains.
fn push_or_show(
prev_vs: &ViewState,
new_state: GameUiState,
pending: RwSignal<VecDeque<GameUiState>>,
screen: RwSignal<Screen>,
) {
let scored = compute_scored_event(prev_vs, &new_state.view_state, new_state.player_id);
let opp_scored = compute_scored_event(prev_vs, &new_state.view_state, 1 - new_state.player_id);
if let Some(reason) = infer_pause_reason(prev_vs, &new_state.view_state, new_state.player_id) {
// Scoring notifications go on the buffered (paused) state only.
pending.update(|q| {
q.push_back(GameUiState {
waiting_for_confirm: true,
pause_reason: Some(reason),
my_scored_event: scored,
opp_scored_event: opp_scored,
..new_state.clone()
});
});
// 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
}));
} else {
// No pause: show scoring directly on the live state.
screen.set(Screen::Playing(GameUiState {
my_scored_event: scored,
opp_scored_event: opp_scored,
..new_state
}));
}
}
/// Compares the previous and next ViewState to decide whether the transition
/// warrants a confirmation pause. Returns None when it is the local player's
/// own action (no pause needed).
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 {
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
{
return Some(PauseReason::AfterOpponentGo);
}
}
// Turn switched to us → opponent moved.
if next.active_mp_player == Some(player_id) && prev.active_mp_player == Some(opponent_id) {
return Some(PauseReason::AfterOpponentMove);
}
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,
}
}
fn vs(dice: (u8, u8), turn_stage: SerTurnStage, active: Option<u16>) -> ViewState {
ViewState {
board: [0i8; 24],
stage: SerStage::InGame,
turn_stage,
active_mp_player: active,
scores: [score(), score()],
dice,
dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
pre_game_roll: None,
}
}
#[test]
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)
);
}
#[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)
);
}
#[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)
);
}
#[test]
fn own_action_returns_none() {
let prev = vs((0, 0), SerTurnStage::RollDice, Some(0));
let next = vs((2, 4), SerTurnStage::Move, Some(0));
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
}
#[test]
fn no_active_player_returns_none() {
let mut prev = vs((0, 0), SerTurnStage::RollDice, None);
prev.stage = SerStage::PreGame;
let mut next = prev.clone();
next.active_mp_player = Some(0);
assert_eq!(infer_pause_reason(&prev, &next, 0), None);
}
}

View file

@ -0,0 +1,594 @@
use leptos::prelude::*;
use trictrac_store::CheckerMove;
use super::die::Die;
use crate::trictrac::types::{SerTurnStage, ViewState};
/// Field numbers in visual display order (left-to-right for each quarter), white's perspective.
const TOP_LEFT_W: [u8; 6] = [13, 14, 15, 16, 17, 18];
const TOP_RIGHT_W: [u8; 6] = [19, 20, 21, 22, 23, 24];
const BOT_LEFT_W: [u8; 6] = [12, 11, 10, 9, 8, 7];
const BOT_RIGHT_W: [u8; 6] = [6, 5, 4, 3, 2, 1];
/// 180° rotation of white's layout: black's pieces (field 24) appear at the bottom.
const TOP_LEFT_B: [u8; 6] = [1, 2, 3, 4, 5, 6];
const TOP_RIGHT_B: [u8; 6] = [7, 8, 9, 10, 11, 12];
const BOT_LEFT_B: [u8; 6] = [24, 23, 22, 21, 20, 19];
const BOT_RIGHT_B: [u8; 6] = [18, 17, 16, 15, 14, 13];
/// The rest corner is field 12 (White) or field 13 (Black) in the store's coordinate system.
/// Returns true when `field_num` is the rest corner for this perspective.
#[allow(dead_code)]
fn is_rest_corner(field_num: u8, is_white: bool) -> bool {
if is_white {
field_num == 12
} else {
field_num == 13
}
}
/// Zone CSS class for a field number (field coordinates are always White's 1-24).
fn field_zone_class(field_num: u8) -> &'static str {
match field_num {
1..=6 => "zone-petit",
7..=12 => "zone-grand",
13..=18 => "zone-opponent",
19..=24 => "zone-retour",
_ => "",
}
}
/// Returns (d0_used, d1_used) for the bar dice display.
fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) {
let mut d0 = false;
let mut d1 = false;
for &(from, to) in staged {
let dist = if from < to {
to.saturating_sub(from)
} else {
from.saturating_sub(to)
};
if !d0 && dist == dice.0 {
d0 = true;
} else if !d1 && dist == dice.1 {
d1 = true;
} else if !d0 {
d0 = true;
} else {
d1 = true;
}
}
(d0, d1)
}
/// Returns the displayed board value for `field_num` after applying `staged_moves`.
/// Field numbers are always in white's coordinate system (124).
fn displayed_value(
base_board: [i8; 24],
staged_moves: &[(u8, u8)],
is_white: bool,
field_num: u8,
) -> i8 {
let mut val = base_board[(field_num - 1) as usize];
let delta: i8 = if is_white { 1 } else { -1 };
for &(from, to) in staged_moves {
if from == field_num {
val -= delta;
}
if to == field_num {
val += delta;
}
}
val
}
/// Fields whose checkers may be selected as the next origin given already-staged moves.
fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs
.iter()
.map(|(m1, _)| m1.get_from() as u8)
.filter(|&f| f != 0)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.map(|(_, m2)| m2.get_from() as u8)
.filter(|&f| f != 0)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
/// Pixel center of a board field in the SVG overlay coordinate space.
/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px.
/// With triangular flèches, arrows target the WIDE BASE of each triangle —
/// that is where the checker stack actually sits.
fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
if f == 0 || f > 24 {
return None;
}
let (qi, right, top): (usize, bool, bool) = if is_white {
match f {
13..=18 => (f - 13, false, true),
19..=24 => (f - 19, true, true),
7..=12 => (12 - f, false, false),
1..=6 => (6 - f, true, false),
_ => return None,
}
} else {
match f {
1..=6 => (f - 1, false, true),
7..=12 => (f - 7, true, true),
19..=24 => (24 - f, false, false),
13..=18 => (18 - f, true, false),
_ => return None,
}
};
// Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i
// Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i
let x = if right {
480.0 + qi as f32 * 62.0
} else {
34.0 + qi as f32 * 62.0
};
// Top row triangle base (wide end) ≈ y=30; bot row triangle base ≈ y=358.
// (Top base: 4pad + 4field-pad + 20half-checker ≈ 28; Bot base: 388 4pad 4field-pad 20 ≈ 360)
let y = if top { 30.0 } else { 358.0 };
Some((x, y))
}
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let (x1, y1) = fp;
let (x2, y2) = tp;
let dx = x2 - x1;
let dy = y2 - y1;
let len = (dx * dx + dy * dy).sqrt();
if len < 10.0 {
return view! { <g /> }.into_any();
}
let nx = dx / len;
let ny = dy / len;
let px = -ny;
let py = nx;
// Shrink line ends so arrows don't overlap the checker stack
let lx1 = x1 + nx * 20.0;
let ly1 = y1 + ny * 20.0;
let lx2 = x2 - nx * 15.0;
let ly2 = y2 - ny * 15.0;
// Arrowhead triangle at (x2, y2)
let ah = 15.0_f32;
let aw = 7.0_f32;
let bx = x2 - nx * ah;
let bary = y2 - ny * ah;
let pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2,
y2,
bx + px * aw,
bary + py * aw,
bx - px * aw,
bary - py * aw,
);
let shadow_pts = format!(
"{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
x2,
y2,
bx + px * (aw + 1.5),
bary + py * (aw + 1.5),
bx - px * (aw + 1.5),
bary - py * (aw + 1.5),
);
view! {
<g>
// Drop-shadow for readability on coloured fields
<line
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
style="stroke:rgba(0,0,0,0.45);stroke-width:5;stroke-linecap:round"
/>
<polygon points=shadow_pts style="fill:rgba(0,0,0,0.45)" />
// Gold arrow
<line
x1=format!("{lx1:.1}") y1=format!("{ly1:.1}")
x2=format!("{lx2:.1}") y2=format!("{ly2:.1}")
style="stroke:rgba(255,215,0,0.9);stroke-width:3;stroke-linecap:round"
/>
<polygon points=pts style="fill:rgba(255,215,0,0.9)" />
</g>
}
.into_any()
}
/// Valid destinations for a selected origin given already-staged moves.
/// May include 0 (exit); callers handle that case.
fn valid_dests_for(
seqs: &[(CheckerMove, CheckerMove)],
staged: &[(u8, u8)],
origin: u8,
) -> Vec<u8> {
let mut v: Vec<u8> = match staged.len() {
0 => seqs
.iter()
.filter(|(m1, _)| m1.get_from() as u8 == origin)
.map(|(m1, _)| m1.get_to() as u8)
.collect(),
1 => {
let (f0, t0) = staged[0];
seqs.iter()
.filter(|(m1, m2)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
&& m2.get_from() as u8 == origin
})
.map(|(_, m2)| m2.get_to() as u8)
.collect()
}
_ => vec![],
};
v.sort_unstable();
v.dedup();
v
}
#[component]
pub fn Board(
view_state: ViewState,
player_id: u16,
/// Pending origin selection (first click of a move pair).
selected_origin: RwSignal<Option<u8>>,
/// Moves staged so far this turn (max 2). Each entry is (from, to), 0 = empty move.
staged_moves: RwSignal<Vec<(u8, u8)>>,
/// All valid two-move sequences for this turn (empty when not in move stage).
valid_sequences: Vec<(CheckerMove, CheckerMove)>,
/// Dice to display in the center bars; None means dice not yet rolled (cups shown upright).
#[prop(default = None)]
bar_dice: Option<(u8, u8)>,
/// Whether we're in the move stage (determines used/unused die appearance).
#[prop(default = false)]
bar_is_move: bool,
#[prop(default = false)] is_my_turn: bool,
/// Whether the dice are a double (golden glow).
#[prop(default = false)]
bar_is_double: bool,
/// Checker moves to animate on mount (None when board unchanged).
#[prop(default = None)]
last_moves: Option<(CheckerMove, CheckerMove)>,
/// Fields where a hit (battue) was scored this turn — show ripple animation.
#[prop(default = vec![])]
hit_fields: Vec<u8>,
) -> impl IntoView {
let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id)
&& matches!(
view_state.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let is_white = player_id == 0;
let hovered_moves = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// Exit-eligible (§8c): all the player's checkers are in their last jan.
// White last jan = fields 19-24 (board indices 18-23, positive values).
// Black last jan = fields 1-6 (board indices 0-5, negative values).
let board_snapshot = view_state.board;
let all_in_exit: bool;
let exit_field_test: fn(u8) -> bool;
if is_white {
let in_exit: i8 = board_snapshot[18..24].iter().map(|&v| v.max(0)).sum();
let total: i8 = board_snapshot.iter().map(|&v| v.max(0)).sum();
all_in_exit = total > 0 && in_exit == total;
exit_field_test = |f| matches!(f, 19..=24);
} else {
let in_exit: i8 = board_snapshot[0..6].iter().map(|&v| (-v).max(0)).sum();
let total: i8 = board_snapshot.iter().map(|&v| (-v).max(0)).sum();
all_in_exit = total > 0 && in_exit == total;
exit_field_test = |f| matches!(f, 1..=6);
}
// `valid_sequences` is cloned per field (the Vec is small; Send-safe unlike Rc).
let fields_from = |nums: &[u8], is_top_row: bool| -> Vec<AnyView> {
nums.iter()
.map(|&field_num| {
// Each reactive closure gets its own owned clone — Vec<(CheckerMove,CheckerMove)>
// is Send, which Leptos requires for reactive attribute functions.
let seqs_c = valid_sequences.clone();
let seqs_k = valid_sequences.clone();
let corner_title = if is_rest_corner(field_num, is_white) {
Some("Coin de repos — must enter and leave with 2 checkers")
} else {
None
};
// §4a — slide delta for the arriving checker at this field.
// Computed once per field at render time; Option<(f32,f32)> is Copy.
let slide_delta: Option<(f32, f32)> = last_moves.and_then(|(m1, m2)| {
[m1, m2].iter().find_map(|m| {
if m.get_to() != field_num as usize || m.get_from() == m.get_to() {
return None;
}
let (fx, fy) = field_center(m.get_from(), is_white)?;
let (tx, ty) = field_center(m.get_to(), is_white)?;
let dx = fx - tx;
let dy = fy - ty;
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
})
});
// §6e — ripple on hit fields (battue).
let is_hit_field = hit_fields.contains(&field_num);
view! {
<div
id={format!("field-{field_num}")}
title=corner_title
class=move || {
let staged = staged_moves.get();
let val = displayed_value(board, &staged, is_white, field_num);
let is_mine = if is_white { val > 0 } else { val < 0 };
let can_stage = is_move_stage && staged.len() < 2;
let sel = selected_origin.get();
let mut cls = format!("field {}", field_zone_class(field_num));
if is_rest_corner(field_num, is_white) {
cls.push_str(" corner");
// Pulse when the corner can be reached this turn
if !seqs_c.is_empty() && seqs_c.iter().any(|(m1, m2)| {
m1.get_to() as u8 == field_num
|| m2.get_to() as u8 == field_num
}) {
cls.push_str(" corner-available");
}
}
if is_rest_corner(field_num, !is_white) {
cls.push_str(" corner");
}
if all_in_exit && exit_field_test(field_num) {
cls.push_str(" exit-eligible");
}
if seqs_c.is_empty() {
// No restriction (dice not rolled or not move stage)
if can_stage && (sel.is_some() || is_mine) {
cls.push_str(" clickable");
}
if sel == Some(field_num) { cls.push_str(" selected"); }
if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest");
}
} else if can_stage {
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = valid_dests_for(&seqs_c, &staged, origin);
// Only highlight non-exit destinations (field 0 = exit has no tile)
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = valid_origins_for(&seqs_c, &staged);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
}
// §6c: highlight fields touched by the hovered jan
if let Some(hm) = hovered_moves {
let pairs = hm.get();
let f = field_num as usize;
let highlighted = pairs.iter().any(|(m1, m2)| {
(m1.get_from() != 0 && m1.get_from() == f)
|| (m1.get_to() != 0 && m1.get_to() == f)
|| (m2.get_from() != 0 && m2.get_from() == f)
|| (m2.get_to() != 0 && m2.get_to() == f)
});
if highlighted {
cls.push_str(" jan-hovered");
}
}
cls
}
on:click=move |_| {
if !is_move_stage { return; }
let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; }
match selected_origin.get_untracked() {
Some(origin) if origin == field_num => {
selected_origin.set(None);
}
Some(origin) => {
let valid = if seqs_k.is_empty() {
true
} else {
valid_dests_for(&seqs_k, &staged, origin)
.iter()
.any(|&d| d == field_num)
};
if valid {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
}
None => {
if seqs_k.is_empty() {
let val = displayed_value(board, &staged, is_white, field_num);
if is_white && val > 0 || !is_white && val < 0 {
selected_origin.set(Some(field_num));
}
} else {
let origins = valid_origins_for(&seqs_k, &staged);
if origins.iter().any(|&o| o == field_num) {
let dests = valid_dests_for(&seqs_k, &staged, field_num);
if !dests.is_empty() && dests.iter().all(|&d| d == 0) {
// All destinations are exits: auto-stage
staged_moves.update(|v| v.push((field_num, 0)));
} else {
selected_origin.set(Some(field_num));
}
}
}
}
}
}
>
<span class="field-num">{field_num}</span>
{move || {
let moves = staged_moves.get();
let val = displayed_value(board, &moves, is_white, field_num);
let count = val.unsigned_abs();
// §6e — ripple on hit (battue) fields; must be inside the
// reactive closure so Leptos uses the same direct rendering
// path as .arriving (avoids node-move that resets animation).
let ripple = is_hit_field.then(|| {
let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
view! { <div class=cls></div> }.into_any()
});
let stack = (count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" };
let display_n = (count as usize).min(4);
// outermost index: last for top rows, first for bottom rows.
let outer_idx = if is_top_row { display_n - 1 } else { 0 };
let chips: Vec<AnyView> = (0..display_n).map(|i| {
let label = if i == outer_idx && count >= 5 {
count.to_string()
} else {
String::new()
};
if i == outer_idx {
if let Some((dx, dy)) = slide_delta {
return view! {
<div
class=format!("checker {color} arriving")
style=format!("--slide-dx:{dx:.1}px;--slide-dy:{dy:.1}px")
>{label}</div>
}.into_any();
}
}
view! {
<div class=format!("checker {color}")>{label}</div>
}.into_any()
}).collect();
view! { <div class="checker-stack">{chips}</div> }.into_any()
});
(ripple, stack)
}}
</div>
}
.into_any()
})
.collect()
};
// ── Bar content: die in the center bar (die_idx 0 = top bar, 1 = bottom bar) ──
let bar_content = move |die_idx: u8| -> AnyView {
match bar_dice {
None => view! { <div class="bar-die-slot"></div> }.into_any(),
Some(dice_vals) => {
let die_val = if die_idx == 0 {
dice_vals.0
} else {
dice_vals.1
};
view! {
<div class="bar-die-slot">
{move || {
let staged = staged_moves.get();
let (u0, u1) = if bar_is_move {
bar_matched_dice_used(&staged, dice_vals)
} else if is_my_turn {
(true, true)
} else {
(false, false)
};
let used = if die_idx == 0 { u0 } else { u1 };
view! { <Die value=die_val used=used is_double=bar_is_double /> }
}}
</div>
}
.into_any()
}
}
};
let (tl, tr, bl, br) = if is_white {
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
} else {
(&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B)
};
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective.
let (label_tl, label_tr, label_bl, label_br) = if is_white {
("", "jan de retour", "grand jan", "petit jan")
} else {
("petit jan", "grand jan", "jan de retour", "")
};
view! {
// board-wrapper keeps zone labels outside .board so the SVG overlay
// inside .board stays correctly positioned (position:absolute top:0 left:0
// is relative to .board, not the wrapper).
<div class="board-wrapper">
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_tl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_tr}</div>
</div>
<div class="board">
<div class="board-row top-row">
<div class="board-quarter">{fields_from(tl, true)}</div>
<div class="board-bar">{bar_content(0)}</div>
<div class="board-quarter">{fields_from(tr, true)}</div>
</div>
<div class="board-center-bar"></div>
<div class="board-row bot-row">
<div class="board-quarter">{fields_from(bl, false)}</div>
<div class="board-bar">{bar_content(1)}</div>
<div class="board-quarter">{fields_from(br, false)}</div>
</div>
// SVG overlay: arrows for hovered jan moves
<svg
width="824" height="388"
style="position:absolute;top:0;left:0;pointer-events:none;overflow:visible"
>
{move || {
let Some(hm) = hovered_moves else { return vec![]; };
let pairs = hm.get();
if pairs.is_empty() { return vec![]; }
// Collect unique individual (from, to) moves; skip empty/exit.
let mut moves: Vec<(usize, usize)> = pairs.iter()
.flat_map(|(m1, m2)| [
(m1.get_from(), m1.get_to()),
(m2.get_from(), m2.get_to()),
])
.filter(|&(f, t)| f != 0 && t != 0)
.collect();
moves.sort_unstable();
moves.dedup();
moves.into_iter()
.filter_map(|(from, to)| {
let p1 = field_center(from, is_white)?;
let p2 = field_center(to, is_white)?;
Some(arrow_svg(p1, p2))
})
.collect()
}}
</svg>
</div>
<div class="zone-labels-row">
<div class="zone-label zone-label-quarter">{label_bl}</div>
<div class="zone-label zone-label-bar"></div>
<div class="zone-label zone-label-quarter">{label_br}</div>
</div>
</div>
}
}

View file

@ -0,0 +1,9 @@
use leptos::prelude::*;
use crate::i18n::*;
#[component]
pub fn ConnectingScreen() -> impl IntoView {
let i18n = use_i18n();
view! { <p class="connecting">{t!(i18n, connecting)}</p> }
}

View file

@ -0,0 +1,53 @@
use leptos::prelude::*;
/// (cx, cy) positions for dots on a 48×48 die face.
fn dot_positions(value: u8) -> &'static [(&'static str, &'static str)] {
match value {
1 => &[("24", "24")],
2 => &[("35", "13"), ("13", "35")],
3 => &[("35", "13"), ("24", "24"), ("13", "35")],
4 => &[("13", "13"), ("35", "13"), ("13", "35"), ("35", "35")],
5 => &[("13", "13"), ("35", "13"), ("24", "24"), ("13", "35"), ("35", "35")],
6 => &[("13", "13"), ("35", "13"), ("13", "24"), ("35", "24"), ("13", "35"), ("35", "35")],
_ => &[],
}
}
/// A single die face rendered as SVG.
/// `value` 16 shows dots; 0 shows an empty face (not-yet-rolled).
/// `used` dims the die.
/// `is_double` applies a golden glow (both dice same value).
#[component]
pub fn Die(
value: u8,
used: bool,
#[prop(default = false)] is_double: bool,
) -> AnyView {
let mut cls = if used {
"die-face die-used".to_string()
} else {
"die-face".to_string()
};
if is_double && !used {
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)
.iter()
.map(|&(cx, cy)| view! { <circle cx=cx cy=cy r="4.5" /> }.into_any())
.collect();
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" />
{dots}
</svg>
}.into_any()
}

View file

@ -0,0 +1,463 @@
use std::cell::Cell;
use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
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::i18n::*;
use crate::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
use super::board::Board;
use super::score_panel::PlayerScorePanel;
use super::scoring::ScoringPanel;
#[component]
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
let vs = state.view_state.clone();
let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn
&& matches!(
vs.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
);
let waiting_for_confirm = state.waiting_for_confirm;
let pause_reason = state.pause_reason.clone();
// ── Hovered jan moves (shown as arrows on the board) ──────────────────────
let hovered_jan_moves: RwSignal<Vec<(CheckerMove, CheckerMove)>> = RwSignal::new(vec![]);
provide_context(hovered_jan_moves);
// ── Staged move state ──────────────────────────────────────────────────────
let selected_origin: RwSignal<Option<u8>> = RwSignal::new(None);
let staged_moves: RwSignal<Vec<(u8, u8)>> = RwSignal::new(Vec::new());
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let pending =
use_context::<RwSignal<VecDeque<GameUiState>>>().expect("pending not found in context");
let cmd_tx_effect = cmd_tx.clone();
// Non-reactive counter so we can detect when staged_moves grows without
// returning a value from the Effect (which causes a Leptos reactive loop
// when the Effect also writes to the same signal it reads).
let prev_staged_len = Cell::new(0usize);
Effect::new(move |_| {
let moves = staged_moves.get();
let n = moves.len();
// Play checker sound whenever a move is added (own moves, immediate feedback).
if n > prev_staged_len.get() {
crate::sound::play_checker_move();
}
prev_staged_len.set(n);
if n == 2 {
let to_cm = |&(from, to): &(u8, u8)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
};
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(
to_cm(&moves[0]),
to_cm(&moves[1]),
)))
.ok();
staged_moves.set(vec![]);
selected_origin.set(None);
// Reset the counter so the next turn starts clean.
prev_staged_len.set(0);
}
});
// ── Auto-roll effect ─────────────────────────────────────────────────────
// GameScreen is fully re-mounted on every ViewState update (state is a
// plain prop, not a signal), so this effect fires exactly once per
// RollDice phase entry and will not double-send.
// 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.
// 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 |_| {
cmd_tx_auto
.unbounded_send(NetCommand::Action(PlayerAction::Roll))
.ok();
});
}
let dice = vs.dice;
let show_dice = dice != (0, 0);
// ── Button senders ─────────────────────────────────────────────────────────
let cmd_tx_go = cmd_tx.clone();
let cmd_tx_quit = cmd_tx.clone();
let cmd_tx_end_quit = cmd_tx.clone();
let cmd_tx_end_replay = cmd_tx.clone();
// Only show the fallback Go button when there is no ScoringPanel showing it.
let show_hold_go = is_my_turn
&& vs.turn_stage == SerTurnStage::HoldOrGoChoice
&& state.my_scored_event.is_none();
// ── Valid move sequences for this turn ─────────────────────────────────────
// Computed once per ViewState snapshot; used by Board (highlighting) and the
// empty-move button (visibility).
let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) {
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs.board);
let store_dice = StoreDice { values: dice };
let color = if player_id == 0 {
Color::White
} else {
Color::Black
};
let rules = MoveRules::new(&color, &store_board, store_dice);
let raw = rules.get_possible_moves_sequences(true, vec![]);
if player_id == 0 {
raw
} else {
raw.into_iter()
.map(|(m1, m2)| (m1.mirror(), m2.mirror()))
.collect()
}
} else {
vec![]
};
// Clone for the empty-move button reactive closure (Board consumes the original).
let valid_seqs_empty = valid_sequences.clone();
// ── Scores ─────────────────────────────────────────────────────────────────
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();
let hole_toast_info = my_scored_event
.as_ref()
.filter(|e| e.holes_gained > 0)
.map(|e| (e.holes_total, e.bredouille));
let is_double_dice = dice.0 == dice.1 && dice.0 != 0;
let last_moves = state.last_moves;
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
let hit_fields: Vec<u8> = {
let is_hit_jan = |jan: &Jan| {
matches!(
jan,
Jan::TrueHitSmallJan
| Jan::TrueHitBigJan
| Jan::TrueHitOpponentCorner
| Jan::FalseHitSmallJan
| Jan::FalseHitBigJan
)
};
let mut fields: Vec<u8> = vec![];
for event_opt in [&my_scored_event, &opp_scored_event] {
if let Some(event) = event_opt {
for entry in &event.jans {
if is_hit_jan(&entry.jan) {
for (m1, m2) in &entry.moves {
for m in [m1, m2] {
let to = m.get_to() as u8;
if to != 0 && !fields.contains(&to) {
fields.push(to);
}
}
}
}
}
}
}
fields
};
// ── 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();
}
// Checker move: moves were committed in the preceding action.
if last_moves.is_some() {
crate::sound::play_checker_move();
}
// Scoring: hole takes priority over plain points.
if let Some(ref ev) = my_scored_event {
if ev.holes_gained > 0 {
crate::sound::play_hole_scored();
} else {
crate::sound::play_points_scored();
}
}
// ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone();
let turn_stage = vs.turn_stage.clone();
let turn_stage_for_panel = turn_stage.clone();
let turn_stage_for_sub = turn_stage.clone();
let room_id = state.room_id.clone();
let is_bot_game = state.is_bot_game;
// ── Game-over info ─────────────────────────────────────────────────────────
let stage_is_ended = stage == SerStage::Ended;
let winner_is_me = my_score.holes >= 12;
let my_name_end = my_score.name.clone();
let my_holes_end = my_score.holes;
let opp_name_end = opp_score.name.clone();
let opp_holes_end = opp_score.holes;
view! {
<div class="game-container">
// ── Top bar ──────────────────────────────────────────────────────
<div class="top-bar">
<span>{move || if is_bot_game {
t_string!(i18n, vs_bot_label).to_owned()
} else {
t_string!(i18n, room_label, id = room_id.as_str())
}}</span>
<div class="lang-switcher">
<button
class:lang-active=move || i18n.get_locale() == Locale::en
on:click=move |_| i18n.set_locale(Locale::en)
>"EN"</button>
<button
class:lang-active=move || i18n.get_locale() == Locale::fr
on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button>
</div>
<a class="quit-link" href="#" on:click=move |e| {
e.prevent_default();
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();
}>{t!(i18n, quit)}</a>
</div>
// ── Opponent score (above board) ─────────────────────────────────
<PlayerScorePanel score=opp_score is_you=false />
// ── Status bar — full width, above board (§10b) ──────────────────
<div class="game-status">
{move || {
if let Some(ref reason) = pause_reason {
return String::from(match reason {
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();
if is_move_stage {
t_string!(i18n, select_move, n = n + 1)
} else {
String::from(match (&stage, is_my_turn, &turn_stage) {
(SerStage::Ended, _, _) => t_string!(i18n, game_over),
(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),
(SerStage::InGame, false, _) => t_string!(i18n, opponent_turn),
})
}
}}
</div>
// ── Contextual sub-prompt (§8a) ──────────────────────────────────
{move || {
let hint: String = if waiting_for_confirm {
t_string!(i18n, hint_continue).to_owned()
} else if is_move_stage {
t_string!(i18n, hint_move).to_owned()
} else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice {
t_string!(i18n, hint_hold_or_go).to_owned()
} else {
String::new()
};
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}}
// ── Board + side panel ───────────────────────────────────────────
<div class="board-and-panel">
<Board
view_state=vs
player_id=player_id
selected_origin=selected_origin
staged_moves=staged_moves
valid_sequences=valid_sequences
bar_dice=show_dice.then_some(dice)
bar_is_move=is_move_stage
is_my_turn=is_my_turn
bar_is_double=is_double_dice
last_moves=last_moves
hit_fields=hit_fields
/>
// ── Side panel (scoring panels only) ─────────────────────────
<div class="side-panel">
{my_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=turn_stage_for_panel />
})}
{opp_scored_event.map(|event| view! {
<ScoringPanel event=event turn_stage=SerTurnStage::RollDice is_opponent=true />
})}
</div>
</div>
// ── Action buttons below board (§10c) ────────────────────────────
<div class="board-actions">
{waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
pending.update(|q| { q.pop_front(); });
}>{t!(i18n, continue_btn)}</button>
})}
// Fallback Go button when no scoring panel (e.g. after reconnect)
{show_hold_go.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_go.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>{t!(i18n, go)}</button>
})}
{move || {
// Show the empty-move button only when (0,0) is a valid
// first or second move given what has already been staged.
let staged = staged_moves.get();
let show = is_move_stage && staged.len() < 2 && (
valid_seqs_empty.is_empty() || match staged.len() {
0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0),
1 => {
let (f0, t0) = staged[0];
valid_seqs_empty.iter()
.filter(|(m1, _)| {
m1.get_from() as u8 == f0
&& m1.get_to() as u8 == t0
})
.any(|(_, m2)| m2.get_from() == 0)
}
_ => false,
}
);
show.then(|| view! {
<button
class="btn btn-secondary"
on:click=move |_| {
selected_origin.set(None);
staged_moves.update(|v| v.push((0, 0)));
}
>{t!(i18n, empty_move)}</button>
})
}}
</div>
// ── 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 = my_die.is_none() && !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}{t!(i18n, you_suffix)}</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();
let winner_text = move || if winner_is_me {
t_string!(i18n, you_win).to_owned()
} else {
t_string!(i18n, opp_wins, name = opp_name_end_clone.as_str())
};
view! {
<div class="game-over-overlay">
<div class="game-over-box">
<h2>{t!(i18n, game_over)}</h2>
<p class="game-over-winner">{winner_text}</p>
<div class="game-over-score">
<span class="game-over-score-name">{my_name_end}</span>
<span class="game-over-score-nums">
{format!("{my_holes_end}{opp_holes_end}")}
</span>
<span class="game-over-score-name">{opp_name_end.clone()}</span>
</div>
<div class="game-over-actions">
<button class="btn btn-secondary" on:click=move |_| {
cmd_tx_end_quit.unbounded_send(NetCommand::Disconnect).ok();
}>{t!(i18n, quit)}</button>
{is_bot_game.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx_end_replay.unbounded_send(NetCommand::PlayVsBot).ok();
}>{t!(i18n, play_again)}</button>
})}
</div>
</div>
</div>
}
})}
// ── Hole toast (§6a) — board-centered overlay when a hole is won ──
{hole_toast_info.map(|(holes_total, bredouille)| view! {
<div class="hole-toast" class:hole-toast-bredouille=bredouille>
<div class="hole-toast-title">"Trou !"</div>
<div class="hole-toast-count">{format!("{holes_total} / 12")}</div>
{bredouille.then(|| view! {
<div class="hole-toast-bredouille">"× 2 bredouille"</div>
})}
</div>
})}
</div>
}
}

View file

@ -0,0 +1,95 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use crate::app::NetCommand;
use crate::i18n::*;
#[component]
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
let i18n = use_i18n();
let (room_name, set_room_name) = signal(String::new());
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let cmd_tx_create = cmd_tx.clone();
let cmd_tx_join = cmd_tx.clone();
let cmd_tx_bot = cmd_tx;
view! {
<div class="login-card">
// ── Decorative board header ─────────────────────────────────────
<div class="login-card-header">
<div class="login-board-stripe"></div>
</div>
// ── Card body ──────────────────────────────────────────────────
<div class="login-card-body">
<div class="login-lang-switcher">
<div class="lang-switcher">
<button
class:lang-active=move || i18n.get_locale() == Locale::en
on:click=move |_| i18n.set_locale(Locale::en)
>"EN"</button>
<button
class:lang-active=move || i18n.get_locale() == Locale::fr
on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button>
</div>
</div>
<h1 class="login-title">"Trictrac"</h1>
<p class="login-subtitle">
<em>"Une interprétation numérique"</em>
</p>
<div class="login-ornament">""</div>
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
<input
class="login-input"
type="text"
placeholder=move || t_string!(i18n, room_name_placeholder)
prop:value=move || room_name.get()
on:input=move |ev| set_room_name.set(event_target_value(&ev))
/>
<div class="login-actions">
<button
class="login-btn login-btn-primary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_create
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
.ok();
}
>
{t!(i18n, create_room)}
</button>
<button
class="login-btn login-btn-secondary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_join
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
.ok();
}
>
{t!(i18n, join_room)}
</button>
<button
class="login-btn login-btn-bot"
on:click=move |_| {
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
}
>
{t!(i18n, play_vs_bot)}
</button>
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,11 @@
mod board;
mod connecting_screen;
mod die;
mod game_screen;
mod login_screen;
mod score_panel;
mod scoring;
pub use connecting_screen::ConnectingScreen;
pub use game_screen::GameScreen;
pub use login_screen::LoginScreen;

View file

@ -0,0 +1,70 @@
use leptos::prelude::*;
use trictrac_store::Jan;
use crate::i18n::*;
use crate::trictrac::types::PlayerScore;
pub fn jan_label(jan: &Jan) -> String {
let i18n = use_i18n();
match jan {
Jan::FilledQuarter => t_string!(i18n, jan_filled_quarter).to_owned(),
Jan::TrueHitSmallJan => t_string!(i18n, jan_true_hit_small).to_owned(),
Jan::TrueHitBigJan => t_string!(i18n, jan_true_hit_big).to_owned(),
Jan::TrueHitOpponentCorner => t_string!(i18n, jan_true_hit_corner).to_owned(),
Jan::FirstPlayerToExit => t_string!(i18n, jan_first_exit).to_owned(),
Jan::SixTables => t_string!(i18n, jan_six_tables).to_owned(),
Jan::TwoTables => t_string!(i18n, jan_two_tables).to_owned(),
Jan::Mezeas => t_string!(i18n, jan_mezeas).to_owned(),
Jan::FalseHitSmallJan => t_string!(i18n, jan_false_hit_small).to_owned(),
Jan::FalseHitBigJan => t_string!(i18n, jan_false_hit_big).to_owned(),
Jan::ContreTwoTables => t_string!(i18n, jan_contre_two).to_owned(),
Jan::ContreMezeas => t_string!(i18n, jan_contre_mezeas).to_owned(),
Jan::HelplessMan => t_string!(i18n, jan_helpless_man).to_owned(),
}
}
#[component]
pub fn PlayerScorePanel(score: PlayerScore, is_you: bool) -> impl IntoView {
let i18n = use_i18n();
let points_pct = format!("{}%", (score.points as u32 * 100 / 12).min(100));
let points_val = format!("{}/12", score.points);
let holes = score.holes;
let can_bredouille = score.can_bredouille;
// 12 peg holes; filled up to `holes`
let pegs: Vec<AnyView> = (1u8..=12)
.map(|i| {
let cls = if i <= holes { "peg-hole filled" } else { "peg-hole" };
view! { <div class=cls></div> }.into_any()
})
.collect();
view! {
<div class="player-score-panel">
<div class="player-score-header">
<span class="player-name">
{score.name}
{is_you.then(|| t!(i18n, you_suffix))}
</span>
</div>
<div class="score-bars">
<div class="score-bar-row">
<span class="score-bar-label">{t!(i18n, points_label)}</span>
<div class="score-bar">
<div class="score-bar-fill score-bar-points" style=format!("width:{points_pct}")></div>
</div>
<span class="score-bar-value">{points_val}</span>
{can_bredouille.then(|| view! {
<span class="bredouille-badge" title=move || t_string!(i18n, bredouille_title).to_owned()>"B"</span>
})}
</div>
<div class="score-bar-row">
<span class="score-bar-label">{t!(i18n, holes_label)}</span>
<div class="peg-track">{pegs}</div>
<span class="score-bar-value">{format!("{holes}/12")}</span>
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,209 @@
use futures::channel::mpsc::UnboundedSender;
#[cfg(target_arch = "wasm32")]
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;
use trictrac_store::CheckerMove;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(target_arch = "wasm32")]
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use crate::app::NetCommand;
use crate::i18n::*;
use crate::trictrac::types::{JanEntry, PlayerAction, ScoredEvent, SerTurnStage};
use super::score_panel::jan_label;
/// One row in the scoring panel. Sets the hovered-moves context on enter
/// (so board shows arrows for that jan's moves), but does NOT clear on
/// leave — clearing is handled by the outer wrapper's mouseleave so that
/// arrows persist while the pointer moves between rows.
fn scoring_jan_row(entry: JanEntry) -> impl IntoView {
let i18n = use_i18n();
let hovered = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
let jan = entry.jan;
let is_double = entry.is_double;
let ways_tag = format!("×{}", entry.ways);
let pts_str = format!("+{}", entry.total);
let moves_hover = entry.moves.clone();
view! {
<div
class="scoring-jan-row"
on:mouseenter=move |_| {
if let Some(h) = hovered {
h.set(moves_hover.clone());
}
}
>
<span class="jan-label">{move || jan_label(&jan)}</span>
<span class="jan-tag">{move || if is_double {
t_string!(i18n, jan_double).to_owned()
} else {
t_string!(i18n, jan_simple).to_owned()
}}</span>
<span class="jan-tag">{ways_tag}</span>
<span class="jan-pts">{pts_str}</span>
</div>
}
}
#[component]
pub fn ScoringPanel(
event: ScoredEvent,
turn_stage: SerTurnStage,
#[prop(default = false)] is_opponent: bool,
) -> impl IntoView {
let i18n = use_i18n();
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let points_earned = event.points_earned;
let holes_gained = event.holes_gained;
let holes_total = event.holes_total;
let bredouille = event.bredouille;
let show_hold_go = !is_opponent && turn_stage == SerTurnStage::HoldOrGoChoice;
let panel_class = if is_opponent {
"scoring-panel scoring-panel-opp"
} else {
"scoring-panel"
};
// ── Lifecycle signals ──────────────────────────────────────────────────
// peeked: added after 3.4 s (slide to peek strip)
// revealed: added on first hover of the peek strip (stay open permanently)
let peeked = RwSignal::new(false);
let revealed = RwSignal::new(false);
// ── Collect all moves from all jans for automatic arrow display ────────
let all_moves: Vec<(CheckerMove, CheckerMove)> = event
.jans
.iter()
.flat_map(|e| e.moves.iter().cloned())
.collect();
let all_moves_click = all_moves.clone();
let all_moves_enter = all_moves.clone();
let hovered_ctx = use_context::<RwSignal<Vec<(CheckerMove, CheckerMove)>>>();
// On mount: show all this event's moves as board arrows immediately,
// then after 3.4 s slide to peek and clear the arrows.
//
// Two important constraints:
// 1. The initial hm.set() must be deferred (spawn_local, not sync in body)
// to avoid writing a reactive signal mid-render while Board reads it —
// that triggers Leptos's cycle guard → `unreachable` WASM panic.
// 2. The cancellation flag must be Rc<Cell<bool>>, NOT RwSignal<bool>.
// RwSignal is a NodeId into Leptos's arena; the arena slot is freed
// when ScoringPanel's owner drops (on every GameScreen remount). If the
// 3.4 s future outlives the component and calls is_alive.get_untracked()
// on a freed slot, that also panics with `unreachable`. Rc<Cell<bool>>
// is reference-counted outside the arena and stays valid for as long as
// the future holds onto it.
#[cfg(target_arch = "wasm32")]
if let Some(hm) = hovered_ctx {
let is_alive = Arc::new(AtomicBool::new(true));
let is_alive_cleanup = is_alive.clone();
// on_cleanup requires Send + Sync; Arc<AtomicBool> satisfies both.
on_cleanup(move || is_alive_cleanup.store(false, Ordering::Relaxed));
spawn_local(async move {
// Show arrows (runs in the next microtask, after render settles).
hm.set(all_moves);
TimeoutFuture::new(3_400).await;
// Guard: component may have been destroyed while we were waiting.
// is_alive was set to false by on_cleanup, which runs before Leptos
// frees the signal arena slots — so peeked is still valid iff this
// returns true.
if !is_alive.load(Ordering::Relaxed) {
return;
}
hm.set(vec![]);
peeked.set(true);
});
}
let jan_rows: Vec<_> = event.jans.into_iter().map(scoring_jan_row).collect();
view! {
// ── Outer wrapper: owns the slide / peek / reveal animation ───────
// pointer-events are on by default (parent .side-panel sets none,
// and .scoring-panel-wrapper overrides back to auto in CSS).
<div
class="scoring-panel-wrapper"
class:peeked=move || peeked.get()
class:revealed=move || revealed.get()
// Click toggles revealed↔peeked when the panel is in its peeked state.
on:click=move |_| {
if peeked.get_untracked() {
revealed.update(|r| *r = !*r);
}
// Show arrows when clicking to open, clear when clicking to close.
if let Some(hm) = hovered_ctx {
if !revealed.get_untracked() {
hm.set(all_moves_click.clone());
} else {
hm.set(vec![]);
}
}
}
on:mouseenter=move |_| {
// Show all event moves as arrows while the cursor is inside.
if let Some(hm) = hovered_ctx {
hm.set(all_moves_enter.clone());
}
}
on:mouseleave=move |_| {
if let Some(hm) = hovered_ctx {
hm.set(vec![]);
}
}
>
<div class=panel_class>
<div class="scoring-total">
{move || if is_opponent {
t_string!(i18n, opp_scored_pts, n = points_earned)
} else {
t_string!(i18n, scored_pts, n = points_earned)
}}
</div>
{jan_rows}
{(holes_gained > 0).then(|| view! {
<div class="scoring-hole">
<span>{move || if is_opponent {
t_string!(i18n, opp_hole_made, holes = holes_total)
} else {
t_string!(i18n, hole_made, holes = holes_total)
}}</span>
{bredouille.then(|| view! {
<span class="bredouille-badge">
{move || t_string!(i18n, bredouille_applied)}
</span>
})}
</div>
})}
{show_hold_go.then(|| {
let dismissed = RwSignal::new(false);
view! {
<div class="hold-go-buttons" class:hidden=move || dismissed.get()>
// stop_propagation so these buttons don't also toggle the panel
<button class="btn btn-secondary" on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
dismissed.set(true);
}>
{t!(i18n, hold)}
</button>
<button class="btn btn-primary" on:click=move |ev: leptos::web_sys::MouseEvent| {
ev.stop_propagation();
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>
{t!(i18n, go)}
</button>
</div>
}
})}
</div>
</div>
}
}

View file

@ -0,0 +1,13 @@
leptos_i18n::load_locales!();
mod app;
mod components;
mod sound;
mod trictrac;
use app::App;
use leptos::prelude::*;
fn main() {
mount_to_body(|| view! { <App /> })
}

View file

@ -0,0 +1,181 @@
//! Synthesised sound effects using the Web Audio API.
//!
//! All public functions are no-ops on non-WASM targets so callers need no
//! `#[cfg]` guards themselves.
#[cfg(target_arch = "wasm32")]
mod inner {
use std::cell::RefCell;
use web_sys::{AudioContext, OscillatorType};
thread_local! {
static CTX: RefCell<Option<AudioContext>> = const { RefCell::new(None) };
}
fn with_ctx<F: FnOnce(&AudioContext)>(f: F) {
CTX.with(|cell| {
let mut opt = cell.borrow_mut();
if opt.is_none() {
*opt = AudioContext::new().ok();
}
if let Some(ctx) = opt.as_ref() {
f(ctx);
}
});
}
/// Schedule a single oscillator tone with an exponential gain decay.
///
/// - `start_offset`: seconds from `ctx.current_time()` when the tone starts
/// - `duration`: how long (in seconds) until gain reaches ~0
fn play_tone(
ctx: &AudioContext,
freq: f32,
gain: f32,
duration: f64,
start_offset: f64,
wave: OscillatorType,
) {
let t0 = ctx.current_time() + start_offset;
let t1 = t0 + duration;
let Ok(osc) = ctx.create_oscillator() else {
return;
};
let Ok(gain_node) = ctx.create_gain() else {
return;
};
osc.set_type(wave);
osc.frequency().set_value(freq);
let gain_param = gain_node.gain();
let _ = gain_param.set_value_at_time(gain, t0);
// exponential_ramp requires a positive target; 0.001 is inaudible
let _ = gain_param.exponential_ramp_to_value_at_time(0.001, t1);
let dest = ctx.destination();
let _ = osc.connect_with_audio_node(&gain_node);
let _ = gain_node.connect_with_audio_node(&dest);
let _ = osc.start_with_when(t0);
let _ = osc.stop_with_when(t1);
}
/// Short wooden clack: sine fundamental + triangle body resonance, ~80 ms.
pub fn play_checker_move() {
with_ctx(|ctx| {
// Sine at 300 Hz for the clean attack click
play_tone(ctx, 300.0, 0.55, 0.080, 0.000, OscillatorType::Sine);
// Triangle at 150 Hz for the woody body resonance
play_tone(ctx, 150.0, 0.35, 0.070, 0.005, OscillatorType::Triangle);
// Sub at 80 Hz for weight
play_tone(ctx, 80.0, 0.20, 0.060, 0.008, OscillatorType::Triangle);
});
}
/// Cinematic dice roll: ~500 ms of rolling texture + 5 impact transients.
///
/// Two layers:
/// - A dense series of detuned sawtooth bursts that thin out over time,
/// modelling the continuous scrape/rattle of dice tumbling.
/// - Five percussive impacts (square clicks + triangle thuds) whose
/// inter-arrival gap shrinks as the dice decelerate and settle.
pub fn play_dice_roll_cinematic() {
with_ctx(|ctx| {
// ── Continuous rolling texture ─────────────────────────────────
// 16 steps over 440 ms; each step is two detuned sawtooth waves
// (the interference between them produces a noise-like texture).
// Gain fades by ~55 % from first to last step.
const N: u32 = 16;
for i in 0..N {
let t = i as f64 * 0.028;
let g = 0.017 * (1.0 - i as f32 / N as f32 * 0.55);
// Quasi-random frequencies so each step sounds different.
let f1 = 310.0 + (i as f32 * 29.3 % 280.0);
let f2 = 480.0 + (i as f32 * 43.7 % 220.0);
play_tone(ctx, f1, g, 0.028, t, OscillatorType::Sawtooth);
play_tone(ctx, f2, g * 0.70, 0.028, t, OscillatorType::Sawtooth);
}
// ── Impact transients ──────────────────────────────────────────
// Gaps narrow toward the end (0.13 → 0.11 → 0.10 → 0.08 s),
// mimicking dice decelerating and settling.
let impacts: &[(f64, f32)] = &[(0.00, 1.00), (0.13, 0.8), (0.24, 0.54), (0.34, 0.30)];
for &(t_off, amp) in impacts {
// Hard click: bright square partials → percussive attack
for &freq in &[700.0f32, 1_050.0, 1_500.0] {
play_tone(ctx, freq, amp * 0.03, 0.022, t_off, OscillatorType::Square);
}
// Woody body thud: two low triangle partials
play_tone(
ctx,
130.0,
amp * 0.05,
0.070,
t_off,
OscillatorType::Triangle,
);
play_tone(
ctx,
68.0,
amp * 0.07,
0.090,
t_off,
OscillatorType::Triangle,
);
}
});
}
/// 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| {
let notes: [(f32, f64); 3] = [(523.25, 0.0), (659.25, 0.14), (783.99, 0.28)];
for (freq, offset) in notes {
play_tone(ctx, freq, 0.28, 0.30, offset, OscillatorType::Sine);
}
});
}
/// Triumphant four-note fanfare (C5 E5 G5 C6).
pub fn play_hole_scored() {
with_ctx(|ctx| {
let notes: [(f32, f64, f64); 4] = [
(523.25, 0.0, 0.35),
(659.25, 0.17, 0.35),
(783.99, 0.34, 0.35),
(1046.5, 0.51, 0.55),
];
for (freq, offset, dur) in notes {
play_tone(ctx, freq, 0.32, dur, offset, OscillatorType::Sine);
}
});
}
}
// ── Public API: WASM delegates to `inner`, other targets are no-ops ───────────
#[cfg(target_arch = "wasm32")]
pub use inner::{
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() {}
#[cfg(not(target_arch = "wasm32"))]
pub fn play_hole_scored() {}

View file

@ -0,0 +1,487 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{Dice, DiceRoller, GameEvent, GameState, TurnStage};
use crate::trictrac::types::{GameDelta, PlayerAction, PreGameRollState, SerStage, ViewState};
// Store PlayerId (u64) values used for the two players.
const HOST_PLAYER_ID: u64 = 1;
const GUEST_PLAYER_ID: u64 = 2;
pub struct TrictracBackend {
game: GameState,
dice_roller: DiceRoller,
commands: Vec<BackendCommand<GameDelta>>,
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) {
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,
});
// Both players roll independently; no single "active" player.
vs.active_mp_player = None;
}
self.view_state = vs;
}
fn broadcast_state(&mut self) {
self.sync_view_state();
let delta = GameDelta {
state: self.view_state.clone(),
};
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) {
let idx = mp_player as usize;
// Ignore if this player already rolled.
if self.pre_game_dice[idx].is_some() {
return;
}
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 });
// 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();
}
} 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();
let player_id = self.game.active_player_id;
let _ = self.game.consume(&GameEvent::Roll { player_id });
let _ = self
.game
.consume(&GameEvent::RollResult { player_id, dice });
// Drive automatic stages that require no player input.
self.drive_automatic_stages();
}
/// Advance through stages that can be resolved without player input
/// (MarkPoints, MarkAdvPoints).
fn drive_automatic_stages(&mut self) {
loop {
// Stop if the game has already ended (stage transitions to Ended but
// turn_stage may still be MarkPoints when schools_enabled=false, which
// makes consume(Mark) a no-op and would cause an infinite loop).
if self.game.stage == trictrac_store::Stage::Ended {
break;
}
let player_id = self.game.active_player_id;
match self.game.turn_stage {
TurnStage::MarkPoints | TurnStage::MarkAdvPoints => {
let _ = self.game.consume(&GameEvent::Mark {
player_id,
points: self.game.dice_points.0.max(self.game.dice_points.1),
});
}
_ => break,
}
}
}
}
impl TrictracBackend {
pub fn get_game(&self) -> &GameState {
&self.game
}
}
impl BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
fn new(_rule_variation: u16) -> Self {
let mut game = GameState::new(false);
game.init_player("Blancs");
game.init_player("Noirs");
let view_state = ViewState::from_game_state(&game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
TrictracBackend {
game,
dice_roller: DiceRoller::default(),
commands: Vec::new(),
view_state,
arrived: [false; 2],
pre_game_dice: [None; 2],
tie_count: 0,
ceremony_started: false,
}
}
fn from_bytes(_rule_variation: u16, bytes: &[u8]) -> Option<Self> {
let view_state: ViewState = serde_json::from_slice(bytes).ok()?;
// Reconstruct a fresh game; full state restore is not yet implemented.
let mut backend = Self::new(_rule_variation);
backend.view_state = view_state;
Some(backend)
}
fn player_arrival(&mut self, mp_player: u16) {
if mp_player > 1 {
self.commands
.push(BackendCommand::KickPlayer { player: mp_player });
return;
}
self.arrived[mp_player as usize] = true;
// Cancel any reconnect timer for this player.
self.commands.push(BackendCommand::CancelTimer {
timer_id: mp_player,
});
// 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 {
self.broadcast_state();
}
}
fn player_departure(&mut self, mp_player: u16) {
if mp_player > 1 {
return;
}
self.arrived[mp_player as usize] = false;
// Give 60 seconds to reconnect before terminating the room.
self.commands.push(BackendCommand::SetTimer {
timer_id: mp_player,
duration: 60.0,
});
}
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;
}
let store_id = if mp_player == 0 {
HOST_PLAYER_ID
} else {
GUEST_PLAYER_ID
};
// Only the active player may act (except during Chance-like waiting stages).
if self.game.active_player_id != store_id {
return;
}
match action {
PlayerAction::Roll => {
if self.game.turn_stage == TurnStage::RollDice {
self.do_roll();
}
}
PlayerAction::Move(m1, m2) => {
if self.game.turn_stage != TurnStage::Move
&& self.game.turn_stage != TurnStage::HoldOrGoChoice
{
return;
}
let event = GameEvent::Move {
player_id: store_id,
moves: (m1, m2),
};
if self.game.validate(&event) {
let _ = self.game.consume(&event);
self.drive_automatic_stages();
}
}
PlayerAction::Go => {
if self.game.turn_stage == TurnStage::HoldOrGoChoice {
let _ = self.game.consume(&GameEvent::Go {
player_id: store_id,
});
}
}
PlayerAction::Mark => {
if matches!(
self.game.turn_stage,
TurnStage::MarkPoints | TurnStage::MarkAdvPoints
) {
self.drive_automatic_stages();
}
}
PlayerAction::PreGameRoll => {} // ignored outside ceremony
}
self.broadcast_state();
}
fn timer_triggered(&mut self, timer_id: u16) {
match timer_id {
0 | 1 => {
// Reconnect grace period expired for host (0) or guest (1).
self.commands.push(BackendCommand::TerminateRoom);
}
_ => {}
}
}
fn get_view_state(&self) -> &ViewState {
&self.view_state
}
fn drain_commands(&mut self) -> Vec<BackendCommand<GameDelta>> {
std::mem::take(&mut self.commands)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trictrac::types::{SerStage, SerTurnStage};
use backbone_lib::traits::BackEndArchitecture;
fn make_backend() -> TrictracBackend {
TrictracBackend::new(0)
}
/// Helper: drain and return only Delta commands, extracting their ViewStates.
fn drain_deltas(b: &mut TrictracBackend) -> Vec<ViewState> {
b.drain_commands()
.into_iter()
.filter_map(|cmd| match cmd {
BackendCommand::Delta(d) => Some(d.state),
BackendCommand::ResetViewState => Some(b.view_state.clone()),
_ => None,
})
.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;
}
let pgr = b.get_view_state().pre_game_roll.clone().unwrap_or_default();
let host_needs = pgr.host_die.is_none();
let guest_needs = pgr.guest_die.is_none();
if !host_needs && !guest_needs {
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();
}
}
#[test]
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 to start the ceremony.
let has_reset = cmds
.iter()
.any(|c| matches!(c, BackendCommand::ResetViewState));
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);
assert_eq!(b.get_view_state().stage, SerStage::InGame);
}
#[test]
fn ceremony_any_order_allowed() {
let mut b = make_backend();
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
// Guest may roll before host.
b.inform_rpc(1, PlayerAction::PreGameRoll);
let states = drain_deltas(&mut b);
assert!(
!states.is_empty(),
"guest PreGameRoll should broadcast a state"
);
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]
fn unknown_player_kicked() {
let mut b = make_backend();
b.player_arrival(99);
let cmds = b.drain_commands();
assert!(cmds
.iter()
.any(|c| matches!(c, BackendCommand::KickPlayer { player: 99 })));
}
#[test]
fn roll_advances_to_move_or_hold() {
let mut b = make_backend();
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
// 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");
let last = states.last().unwrap();
assert!(
matches!(
last.turn_stage,
SerTurnStage::Move | SerTurnStage::HoldOrGoChoice
),
"expected Move or HoldOrGoChoice after roll, got {:?}",
last.turn_stage
);
assert_eq!(last.dice, b.get_view_state().dice);
assert!(last.dice.0 >= 1 && last.dice.0 <= 6);
assert!(last.dice.1 >= 1 && last.dice.1 <= 6);
}
#[test]
fn wrong_player_roll_ignored() {
let mut b = make_backend();
b.player_arrival(0);
b.player_arrival(1);
b.drain_commands();
complete_ceremony(&mut b);
// 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(), "wrong player roll should be ignored");
}
#[test]
fn departure_sets_reconnect_timer() {
let mut b = make_backend();
b.player_arrival(0);
b.drain_commands();
b.player_departure(0);
let cmds = b.drain_commands();
assert!(
cmds.iter()
.any(|c| matches!(c, BackendCommand::SetTimer { timer_id: 0, .. })),
"expected reconnect timer after host departure"
);
}
#[test]
fn timer_triggers_terminate_room() {
let mut b = make_backend();
b.timer_triggered(0);
let cmds = b.drain_commands();
assert!(cmds
.iter()
.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

@ -0,0 +1,43 @@
use rand::prelude::IndexedRandom;
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
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.
/// `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;
}
if game.active_player_id != GUEST_PLAYER_ID {
return None;
}
match game.turn_stage {
TurnStage::RollDice => Some(PlayerAction::Roll),
// TurnStage::HoldOrGoChoice => Some(PlayerAction::Go),
TurnStage::Move | TurnStage::HoldOrGoChoice => {
let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
let sequences = rules.get_possible_moves_sequences(true, vec![]);
let mut rng = rand::rng();
let (m1, m2) = sequences
.choose(&mut rng)
.cloned()
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
// MoveRules with Color::Black mirrors the board internally, so
// returned move coordinates are in mirrored (White) space — mirror back.
Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
}
_ => None,
}
}

View file

@ -0,0 +1,3 @@
pub mod backend;
pub mod bot_local;
pub mod types;

View file

@ -0,0 +1,256 @@
use serde::{Deserialize, Serialize};
use trictrac_store::{CheckerMove, GameState, Jan, Stage, TurnStage};
// ── Actions sent by a player to the host backend ─────────────────────────────
#[derive(Clone, Serialize, Deserialize)]
pub enum PlayerAction {
/// Active player requests a dice roll.
Roll,
/// Both checker moves for this turn. Use `EMPTY_MOVE` (from=0, to=0) when a die
/// has no valid move.
Move(CheckerMove, CheckerMove),
/// Choose to "go" (advance) during HoldOrGoChoice.
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 ────────────────────────
/// Carries a full state snapshot; `apply_delta` replaces the local state.
/// Simple and correct; can be refined to true diffs later.
#[derive(Clone, Serialize, Deserialize)]
pub struct GameDelta {
pub state: ViewState,
}
// ── 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, Default, 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)]
pub struct ViewState {
/// Board positions: index i = field i+1. Positive = white, negative = black.
pub board: [i8; 24],
pub stage: SerStage,
pub turn_stage: SerTurnStage,
/// Which multiplayer player_id (0 = host, 1 = guest) is the active player.
pub active_mp_player: Option<u16>,
/// Scores indexed by multiplayer player_id (0 = host, 1 = guest).
pub scores: [PlayerScore; 2],
/// Last rolled dice values.
pub dice: (u8, u8),
/// Jans (scoring events) triggered by the last dice roll.
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.
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
pub struct JanEntry {
pub jan: Jan,
/// True when the dice are doubles (both same value) — changes the point value.
/// Special case for HelplessMan: true when *both* dice are unplayable.
pub is_double: bool,
/// Number of distinct move pairs that produce this jan.
pub ways: usize,
/// Points per way (negative = scored against the active player).
pub points_per: i8,
/// Total = points_per × ways.
pub total: i8,
/// The move pairs that produce this jan (for move display).
pub moves: Vec<(CheckerMove, CheckerMove)>,
}
impl ViewState {
pub fn default_with_names(host_name: &str, guest_name: &str) -> Self {
ViewState {
board: [0i8; 24],
stage: SerStage::PreGame,
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,
},
],
dice: (0, 0),
dice_jans: Vec::new(),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
pre_game_roll: None,
}
}
pub fn apply_delta(&mut self, delta: &GameDelta) {
*self = delta.state.clone();
}
/// 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 {
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,
};
let turn_stage = match gs.turn_stage {
TurnStage::RollDice => SerTurnStage::RollDice,
TurnStage::RollWaiting => SerTurnStage::RollWaiting,
TurnStage::MarkPoints => SerTurnStage::MarkPoints,
TurnStage::HoldOrGoChoice => SerTurnStage::HoldOrGoChoice,
TurnStage::Move => SerTurnStage::Move,
TurnStage::MarkAdvPoints => SerTurnStage::MarkAdvPoints,
};
let active_mp_player = if gs.active_player_id == host_store_id {
Some(0)
} else if gs.active_player_id == guest_store_id {
Some(1)
} else {
None
};
let score_for = |store_id: u64| -> PlayerScore {
gs.players
.get(&store_id)
.map(|p| PlayerScore {
name: p.name.clone(),
points: p.points,
holes: p.holes,
can_bredouille: p.can_bredouille,
})
.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).
// Exception: HelplessMan uses a special rule (see below).
let dice_are_double = gs.dice.values.0 == gs.dice.values.1;
// 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
.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)
.unwrap_or(false)
} else {
dice_are_double
};
let points_per = jan.get_points(is_double);
let ways = moves.len();
let total = points_per.saturating_mul(ways as i8);
JanEntry {
jan: jan.clone(),
is_double,
ways,
points_per,
total,
moves: moves.clone(),
}
})
.collect();
// Sort: highest total first, most-negative last.
dice_jans.sort_by_key(|e| std::cmp::Reverse(e.total));
ViewState {
board,
stage,
turn_stage,
active_mp_player,
scores: [score_for(host_store_id), score_for(guest_store_id)],
dice: (gs.dice.values.0, gs.dice.values.1),
dice_jans,
dice_moves: gs.dice_moves,
pre_game_roll: None,
}
}
}
// ── Scored event (notification) ──────────────────────────────────────────
/// Points scored in a single scoring event, used for the notification panel.
#[derive(Clone, PartialEq)]
pub struct ScoredEvent {
/// Raw points earned (sum of jan values; before hole wrapping).
pub points_earned: u8,
/// Number of holes gained (0 = no hole).
pub holes_gained: u8,
/// Total holes after this event.
pub holes_total: u8,
/// Was bredouille active when the hole was made (doubles hole count)?
pub bredouille: bool,
/// Contributing jans from this player's perspective (totals always positive).
pub jans: Vec<JanEntry>,
}
// ── Score snapshot ────────────────────────────────────────────────────────────
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayerScore {
pub name: String,
pub points: u8,
pub holes: u8,
pub can_bredouille: bool,
}
// ── Serialisable mirrors of store enums ──────────────────────────────────────
#[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,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SerTurnStage {
RollDice,
RollWaiting,
MarkPoints,
HoldOrGoChoice,
Move,
MarkAdvPoints,
}