Compare commits

..

No commits in common. "3474d20d9b9c13a7d41f15394c62a2f489609b8f" and "0b06c62fd9984c08938e1092a5555bbfb1d88101" have entirely different histories.

22 changed files with 14 additions and 2130 deletions

940
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
[workspace]
resolver = "2"
members = ["client_cli", "bot", "store", "spiel_bot", "client_web"]
members = ["client_cli", "bot", "store", "spiel_bot"]

View file

@ -1,19 +0,0 @@
[package]
name = "client_web"
version = "0.1.0"
edition = "2021"
[dependencies]
trictrac-store = { path = "../store" }
backbone-lib = { path = "../../forks/multiplayer/backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
gloo-storage = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues.
# Must be a direct dependency (not just transitive) for the feature to take effect.
getrandom = { version = "0.3", features = ["wasm_js"] }

View file

@ -1,2 +0,0 @@
[serve]
port = 9092

View file

@ -1,176 +0,0 @@
/* ── Reset & base ──────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: sans-serif;
background: #c8b084;
display: flex;
justify-content: center;
padding: 1.5rem;
min-height: 100vh;
}
/* ── Login / Connecting screens ────────────────────────────────────── */
.login-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 320px;
margin-top: 4rem;
}
.login-container h1 { font-size: 2rem; text-align: center; margin-bottom: 0.5rem; }
input[type="text"] {
padding: 0.5rem 0.75rem;
font-size: 1rem;
border: 1px solid #aaa;
border-radius: 4px;
}
.error-msg { color: #c00; font-size: 0.9rem; }
.connecting { font-size: 1.2rem; margin-top: 4rem; text-align: center; }
/* ── Buttons ────────────────────────────────────────────────────────── */
.btn {
padding: 0.5rem 1.25rem;
font-size: 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-primary { background: #3a6b3a; color: #fff; }
.btn-secondary { background: #5a4a2a; color: #fff; }
.btn:not(:disabled):hover { opacity: 0.85; }
/* ── Game container ─────────────────────────────────────────────────── */
.game-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
max-width: 900px;
}
/* ── Score panel ────────────────────────────────────────────────────── */
.score-panel {
display: flex;
gap: 2rem;
background: #f5edd8;
border-radius: 6px;
padding: 0.5rem 1.5rem;
font-size: 0.95rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}
.score-row { display: flex; gap: 1rem; align-items: center; }
.score-name { font-weight: bold; min-width: 80px; }
/* ── Status / action bars ───────────────────────────────────────────── */
.status-bar {
display: flex;
gap: 1rem;
align-items: center;
font-size: 1.05rem;
font-weight: 500;
}
.dice { font-weight: bold; color: #2a4a8a; }
.action-bar { display: flex; gap: 0.75rem; min-height: 2.5rem; }
/* ── Board ──────────────────────────────────────────────────────────── */
.board {
background: #2e6b2e;
border: 4px solid #1a3d1a;
border-radius: 8px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 4px;
user-select: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.board-row {
display: flex;
gap: 4px;
}
.board-quarter {
display: flex;
gap: 2px;
}
.board-bar {
width: 20px;
background: #1a3d1a;
border-radius: 3px;
}
.board-center-bar {
height: 12px;
background: #1a3d1a;
border-radius: 3px;
}
/* ── Fields ─────────────────────────────────────────────────────────── */
.field {
width: 60px;
height: 110px;
background: #d4a843;
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 4px 2px;
position: relative;
transition: background 0.1s;
}
/* Alternating field colours */
.board-quarter .field:nth-child(odd) { background: #c49030; }
.board-quarter .field:nth-child(even) { background: #d4a843; }
.top-row .field { justify-content: flex-start; }
.field.clickable { cursor: pointer; }
.field.clickable:hover { background: #e8c060 !important; }
.field.selected { background: #88bb44 !important; outline: 2px solid #446622; }
.field.dest { background: #aad060 !important; }
.field-num {
font-size: 0.65rem;
color: rgba(0,0,0,0.45);
position: absolute;
bottom: 2px;
}
.top-row .field-num { bottom: auto; top: 2px; }
/* ── Checkers ───────────────────────────────────────────────────────── */
.checkers {
width: 46px;
height: 46px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: bold;
border: 2px solid rgba(0,0,0,0.3);
box-shadow: inset 0 2px 4px rgba(255,255,255,0.3), 0 2px 4px rgba(0,0,0,0.3);
}
.checkers.white {
background: radial-gradient(circle at 35% 35%, #ffffff, #cccccc);
color: #333;
}
.checkers.black {
background: radial-gradient(circle at 35% 35%, #555555, #111111);
color: #eee;
}

View file

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Trictrac</title>
<link data-trunk rel="rust" />
<link data-trunk rel="css" href="assets/style.css" />
</head>
<body></body>
</html>

View file

@ -1,247 +0,0 @@
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::ViewStateUpdate;
use crate::components::{ConnectingScreen, GameScreen, LoginScreen};
use crate::trictrac::backend::TrictracBackend;
use crate::trictrac::types::{GameDelta, PlayerAction, ViewState};
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,
}
/// 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>>,
},
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>();
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.
let (config, is_reconnect) = loop {
match cmd_rx.next().await {
Some(NetCommand::CreateRoom { room }) => {
break (
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 (
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 (
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.
}
};
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("Host", "Guest");
loop {
futures::select! {
cmd = cmd_rx.next().fuse() => match cmd {
Some(NetCommand::Action(action)) => {
session.send_action(action);
}
_ => {
clear_session();
session.disconnect();
screen.set(Screen::Login { error: None });
break;
}
},
event = session.next_event().fuse() => match event {
Some(SessionEvent::Update(u)) => {
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()),
});
}
screen.set(Screen::Playing(GameUiState {
view_state: vs.clone(),
player_id,
}));
}
Some(SessionEvent::Disconnected(reason)) => {
screen.set(Screen::Login { error: reason });
break;
}
None => {
screen.set(Screen::Login { error: None });
break;
}
}
}
}
}
});
view! {
{move || 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(),
}}
}
}

View file

@ -1,93 +0,0 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use crate::app::NetCommand;
use crate::trictrac::types::{PlayerAction, SerTurnStage, ViewState};
/// Field numbers in visual display order (left-to-right for each quarter).
const TOP_LEFT: [u8; 6] = [13, 14, 15, 16, 17, 18];
const TOP_RIGHT: [u8; 6] = [19, 20, 21, 22, 23, 24];
const BOT_LEFT: [u8; 6] = [12, 11, 10, 9, 8, 7];
const BOT_RIGHT: [u8; 6] = [ 6, 5, 4, 3, 2, 1];
#[component]
pub fn Board(view_state: ViewState, player_id: u16) -> impl IntoView {
let selected: RwSignal<Option<u8>> = RwSignal::new(None);
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id)
&& view_state.turn_stage == SerTurnStage::Move;
// Build a Vec<AnyView> for a slice of field numbers.
// `fields_from` borrows `board`, `cmd_tx` and copies `selected`, `is_move_stage`, `player_id`.
let fields_from = |nums: &[u8]| -> Vec<AnyView> {
nums.iter().map(|&field_num| {
let value: i8 = board[(field_num - 1) as usize];
let count = value.unsigned_abs();
let checker_color = if value > 0 { "white" } else { "black" };
let is_my_checker = if player_id == 0 { value > 0 } else { value < 0 };
let cmd = cmd_tx.clone();
view! {
<div
class=move || {
let sel = selected.get();
let mut cls = "field".to_string();
let clickable = is_move_stage
&& (sel.is_some() || is_my_checker);
if clickable { cls.push_str(" clickable"); }
if sel == Some(field_num) { cls.push_str(" selected"); }
if is_move_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest");
}
cls
}
on:click=move |_| {
if !is_move_stage { return; }
match selected.get() {
Some(origin) if origin == field_num => selected.set(None),
Some(origin) => {
cmd.unbounded_send(NetCommand::Action(
PlayerAction::Move { from: origin, to: field_num },
)).ok();
selected.set(None);
}
None if is_my_checker => selected.set(Some(field_num)),
None => {}
}
}
>
<span class="field-num">{field_num}</span>
{(count > 0).then(|| view! {
<span class=format!("checkers {checker_color}")>{count}</span>
})}
</div>
}
.into_any()
})
.collect()
};
let top_left = fields_from(&TOP_LEFT);
let top_right = fields_from(&TOP_RIGHT);
let bot_left = fields_from(&BOT_LEFT);
let bot_right = fields_from(&BOT_RIGHT);
view! {
<div class="board">
<div class="board-row top-row">
<div class="board-quarter">{top_left}</div>
<div class="board-bar"></div>
<div class="board-quarter">{top_right}</div>
</div>
<div class="board-center-bar"></div>
<div class="board-row bot-row">
<div class="board-quarter">{bot_left}</div>
<div class="board-bar"></div>
<div class="board-quarter">{bot_right}</div>
</div>
</div>
}
}

View file

@ -1,6 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn ConnectingScreen() -> impl IntoView {
view! { <p class="connecting">"Connecting…"</p> }
}

View file

@ -1,72 +0,0 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use crate::app::{GameUiState, NetCommand};
use crate::trictrac::types::{PlayerAction, SerStage, SerTurnStage};
use super::board::Board;
use super::score_panel::ScorePanel;
#[component]
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let vs = state.view_state.clone();
let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id);
let status = match &vs.stage {
SerStage::Ended => "Game over".to_string(),
SerStage::PreGame => "Waiting for opponent…".to_string(),
SerStage::InGame => match (is_my_turn, &vs.turn_stage) {
(true, SerTurnStage::RollDice) => "Your turn — roll the dice".to_string(),
(true, SerTurnStage::HoldOrGoChoice) => "Hold or Go?".to_string(),
(true, SerTurnStage::Move) => "Your turn — move a checker".to_string(),
(true, _) => "Your turn".to_string(),
(false, _) => "Opponent's turn".to_string(),
},
};
let dice_text = if vs.dice != (0, 0) {
format!("Dice: {} & {}", vs.dice.0, vs.dice.1)
} else {
String::new()
};
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let cmd_tx2 = cmd_tx.clone();
let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice;
let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice;
view! {
<div class="game-container">
<ScorePanel scores=vs.scores.clone() player_id=player_id />
<div class="status-bar">
<span>{status}</span>
{(!dice_text.is_empty()).then(|| view! { <span class="dice">{dice_text}</span> })}
</div>
<div class="action-bar">
{show_roll.then(|| view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx.unbounded_send(NetCommand::Action(PlayerAction::Roll)).ok();
}>"Roll dice"</button>
})}
{show_hold_go.then(|| view! {
<button class="btn btn-secondary" on:click=move |_| {
cmd_tx2.unbounded_send(NetCommand::Action(PlayerAction::Mark)).ok();
}>"Hold"</button>
})}
{show_hold_go.then(|| {
let cmd_tx3 = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
view! {
<button class="btn btn-primary" on:click=move |_| {
cmd_tx3.unbounded_send(NetCommand::Action(PlayerAction::Go)).ok();
}>"Go"</button>
}
})}
</div>
<Board view_state=vs player_id=player_id />
</div>
}
}

View file

@ -1,54 +0,0 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*;
use crate::app::NetCommand;
#[component]
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
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;
view! {
<div class="login-container">
<h1>"Trictrac"</h1>
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
<input
type="text"
placeholder="Room name"
prop:value=move || room_name.get()
on:input=move |ev| set_room_name.set(event_target_value(&ev))
/>
<button
class="btn btn-primary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_create
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
.ok();
}
>
"Create Room"
</button>
<button
class="btn btn-secondary"
disabled=move || room_name.get().is_empty()
on:click=move |_| {
cmd_tx_join
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
.ok();
}
>
"Join Room"
</button>
</div>
}
}

View file

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

View file

@ -1,25 +0,0 @@
use leptos::prelude::*;
use crate::trictrac::types::PlayerScore;
#[component]
pub fn ScorePanel(scores: [PlayerScore; 2], player_id: u16) -> impl IntoView {
let rows: Vec<_> = scores
.into_iter()
.enumerate()
.map(|(i, score)| {
let label = if i as u16 == player_id { " (you)" } else { "" };
view! {
<div class="score-row">
<span class="score-name">{score.name}{label}</span>
<span class="score-points">"Points: "{score.points}</span>
<span class="score-holes">"Holes: "{score.holes}</span>
</div>
}
})
.collect();
view! {
<div class="score-panel">{rows}</div>
}
}

View file

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

View file

@ -1,302 +0,0 @@
use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use trictrac_store::{CheckerMove, DiceRoller, GameEvent, GameState, TurnStage};
use crate::trictrac::types::{GameDelta, PlayerAction, 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],
/// First move of the current pair, waiting for the second.
pending_first_move: Option<CheckerMove>,
}
impl TrictracBackend {
fn sync_view_state(&mut self) {
self.view_state =
ViewState::from_game_state(&self.game, HOST_PLAYER_ID, GUEST_PLAYER_ID);
}
fn broadcast_state(&mut self) {
self.sync_view_state();
let delta = GameDelta { state: self.view_state.clone() };
self.commands.push(BackendCommand::Delta(delta));
}
/// 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 {
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 BackEndArchitecture<PlayerAction, GameDelta, ViewState> for TrictracBackend {
fn new(_rule_variation: u16) -> Self {
let mut game = GameState::new(false);
game.init_player("Host");
game.init_player("Guest");
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],
pending_first_move: None,
}
}
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 game once both players have arrived.
if self.arrived[0] && self.arrived[1] && self.game.stage == trictrac_store::Stage::PreGame
{
let _ = self.game.consume(&GameEvent::BeginGame { goes_first: HOST_PLAYER_ID });
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) {
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 { from, to } => {
if self.game.turn_stage != TurnStage::Move {
return;
}
let Ok(cmove) = CheckerMove::new(from as usize, to as usize) else {
return;
};
if let Some(first) = self.pending_first_move.take() {
let event = GameEvent::Move {
player_id: store_id,
moves: (first, cmove),
};
if self.game.validate(&event) {
let _ = self.game.consume(&event);
self.drive_automatic_stages();
}
// Whether valid or not, clear pending so the player can retry.
} else {
self.pending_first_move = Some(cmove);
// No state broadcast yet — wait for the second move.
return;
}
}
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();
}
}
}
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 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()
}
#[test]
fn both_players_arrive_starts_game() {
let mut b = make_backend();
b.player_arrival(0); // host
b.drain_commands();
b.player_arrival(1); // guest
let cmds = b.drain_commands();
// ResetViewState should have been issued after BeginGame.
let has_reset = cmds.iter().any(|c| matches!(c, BackendCommand::ResetViewState));
assert!(has_reset, "expected ResetViewState after both players arrive");
// Game should now be InGame.
use crate::trictrac::types::SerStage;
assert_eq!(b.get_view_state().stage, SerStage::InGame);
}
#[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();
// Host rolls (player_id 0, whose store id == HOST_PLAYER_ID == active after BeginGame).
b.inform_rpc(0, PlayerAction::Roll);
let states = drain_deltas(&mut b);
assert!(!states.is_empty(), "expected a state broadcast after roll");
use crate::trictrac::types::SerTurnStage;
let last = states.last().unwrap();
assert!(
matches!(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();
// Guest tries to roll when it's the host's turn.
b.inform_rpc(1, PlayerAction::Roll);
let cmds = b.drain_commands();
assert!(cmds.is_empty(), "guest roll should be ignored when it's host's turn");
}
#[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)));
}
}

View file

@ -1,2 +0,0 @@
pub mod backend;
pub mod types;

View file

@ -1,143 +0,0 @@
use serde::{Deserialize, Serialize};
use trictrac_store::{GameState, 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,
/// Move one checker from `from` to `to` (field numbers 124, 0 = exit).
Move { from: u8, to: u8 },
/// Choose to "go" (advance) during HoldOrGoChoice.
Go,
/// Acknowledge point marking (hold / advance points).
Mark,
}
// ── 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 ────────────────────────────────────────────────────────
#[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),
}
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 },
PlayerScore { name: guest_name.to_string(), points: 0, holes: 0 },
],
dice: (0, 0),
}
}
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,
})
.unwrap_or_else(|| PlayerScore { name: String::new(), points: 0, holes: 0 })
};
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),
}
}
}
// ── Score snapshot ────────────────────────────────────────────────────────────
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayerScore {
pub name: String,
pub points: u8,
pub holes: u8,
}
// ── Serialisable mirrors of store enums ──────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SerStage {
PreGame,
InGame,
Ended,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SerTurnStage {
RollDice,
RollWaiting,
MarkPoints,
HoldOrGoChoice,
Move,
MarkAdvPoints,
}

View file

@ -5,9 +5,6 @@ let
in
{
packages = [
# for Leptos
pkgs.trunk
# pkgs.wasm-bindgen-cli_0_2_114
# pour burn-rs
pkgs.SDL2_gfx

View file

@ -8,19 +8,6 @@ shell:
devenv shell
runcli:
RUST_LOG=info cargo run --bin=client_cli
[working-directory: 'client_web/']
dev-leptos:
trunk serve
[working-directory: 'client_web']
build-leptos:
trunk build --release
cp dist/index.html /home/henri/travaux/programmes/forks/multiplayer/deploy/trictrac.html
cp dist/*.wasm /home/henri/travaux/programmes/forks/multiplayer/deploy/
cp dist/*.js /home/henri/travaux/programmes/forks/multiplayer/deploy/
cp dist/*.css /home/henri/travaux/programmes/forks/multiplayer/deploy/
runclibots:
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk
#cargo run --bin=client_cli -- --bot dqn:./bot/models/dqn_model_final.json,dummy

View file

@ -15,13 +15,11 @@ crate-type = ["cdylib", "rlib", "staticlib"]
[features]
# Enable Python bindings (required for maturin / AI training). Not available on wasm32.
python = ["pyo3"]
# Enable C++ bridge for OpenSpiel integration. Not available on wasm32.
cpp = ["dep:cxx"]
[dependencies]
anyhow = "1.0"
base64 = "0.21.7"
cxx = { version = "1.0", optional = true }
cxx = "1.0"
# provides macros for creating log messages to be used by a logger (for example env_logger)
log = "0.4.20"
merge = "0.1.0"

View file

@ -1,9 +1,7 @@
fn main() {
if std::env::var("CARGO_FEATURE_CPP").is_ok() {
cxx_build::bridge("src/cxxengine.rs")
.std("c++17")
.compile("trictrac-cxx");
cxx_build::bridge("src/cxxengine.rs")
.std("c++17")
.compile("trictrac-cxx");
println!("cargo:rerun-if-changed=src/cxxengine.rs");
}
println!("cargo:rerun-if-changed=src/cxxengine.rs");
}

View file

@ -24,5 +24,4 @@ pub mod training_common;
mod pyengine;
// C++ interface via cxx.rs (for OpenSpiel C++ integration)
#[cfg(feature = "cpp")]
pub mod cxxengine;