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

View file

@ -0,0 +1,17 @@
[package]
name = "backbone-lib"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
postcard = { version = "1.1", features = ["use-std"] }
bytes = "1.11"
ewebsock = "0.8"
protocol = { path = "../../server/protocol" }
futures = "0.3"
web-time = "1.1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
gloo-timers = { version = "0.3", features = ["futures"] }

View file

@ -0,0 +1,84 @@
//! Background task for the client (non-host) side of a session.
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::platform::sleep_ms;
use crate::protocol::{parse_client_update, send_disconnect, send_rpc};
use crate::session::{BackendMsg, SessionEvent};
use crate::traits::SerializationCap;
pub(crate) async fn client_loop<A, D, VS>(
mut ws_sender: WsSender,
ws_receiver: WsReceiver,
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
event_tx: UnboundedSender<SessionEvent<D, VS>>,
) where
A: SerializationCap,
D: SerializationCap,
VS: SerializationCap,
{
loop {
// 1. Drain outbound actions.
loop {
match action_rx.try_next() {
Ok(Some(BackendMsg::Action(action))) => {
send_rpc(&mut ws_sender, &action);
}
Ok(Some(BackendMsg::Disconnect)) => {
send_disconnect(&mut ws_sender, false);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
Ok(None) => {
send_disconnect(&mut ws_sender, false);
return;
}
Err(_) => break,
}
}
// 2. Drain inbound state updates.
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
match parse_client_update::<VS, D>(data) {
Ok(updates) => {
for u in updates {
event_tx
.unbounded_send(SessionEvent::Update(u))
.ok();
}
}
Err(e) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
}
}
Some(WsEvent::Closed) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(
"Connection closed".to_string(),
)))
.ok();
return;
}
Some(WsEvent::Error(e)) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
Some(_) => continue,
None => break,
}
}
sleep_ms(2).await;
}
}

View file

@ -0,0 +1,211 @@
//! Background task for the host (game server) side of a session.
use std::collections::HashSet;
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use web_time::{Duration, Instant};
use crate::platform::sleep_ms;
use crate::protocol::{
ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state,
send_kick, send_reset,
};
use crate::session::{BackendMsg, SessionEvent};
use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
struct Timer {
id: u16,
fire_at: Instant,
}
pub(crate) async fn host_loop<A, D, VS, Backend>(
mut ws_sender: WsSender,
ws_receiver: WsReceiver,
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
event_tx: UnboundedSender<SessionEvent<D, VS>>,
rule_variation: u16,
host_state: Option<Vec<u8>>,
) where
A: SerializationCap,
D: SerializationCap + Clone,
VS: SerializationCap + Clone,
Backend: BackEndArchitecture<A, D, VS>,
{
let mut backend = host_state
.as_deref()
.and_then(|b| Backend::from_bytes(rule_variation, b))
.unwrap_or_else(|| Backend::new(rule_variation));
backend.player_arrival(0);
// Push initial state to UI immediately.
let initial = backend.get_view_state().clone();
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial)))
.ok();
let mut timers: Vec<Timer> = Vec::new();
let mut cancelled_timers: HashSet<u16> = HashSet::new();
let mut remote_player_count: u16 = 0;
loop {
let mut client_joined = false;
// 1. Drain local actions / detect session drop or disconnect request.
loop {
match action_rx.try_next() {
Ok(Some(BackendMsg::Action(action))) => {
backend.inform_rpc(0, action);
}
Ok(Some(BackendMsg::Disconnect)) => {
send_disconnect(&mut ws_sender, true);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
Ok(None) => {
// All senders dropped — session was dropped without calling disconnect().
send_disconnect(&mut ws_sender, true);
return;
}
Err(_) => break, // Channel empty; nothing pending.
}
}
// 2. Drain WebSocket events from the relay.
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
match parse_server_command::<A>(data) {
ToServerCommand::ClientJoin(id) => {
backend.player_arrival(id);
remote_player_count += 1;
client_joined = true;
}
ToServerCommand::ClientLeft(id) => {
backend.player_departure(id);
remote_player_count = remote_player_count.saturating_sub(1);
}
ToServerCommand::Rpc(id, payload) => {
backend.inform_rpc(id, payload);
}
ToServerCommand::Error(e) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
}
}
Some(WsEvent::Closed) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(
"Connection closed".to_string(),
)))
.ok();
return;
}
Some(WsEvent::Error(e)) => {
event_tx
.unbounded_send(SessionEvent::Disconnected(Some(e)))
.ok();
return;
}
Some(_) => continue, // Ignore Opened / text messages.
None => break, // No more events this iteration.
}
}
// 3. Fire elapsed timers.
let now = Instant::now();
let mut fired = Vec::new();
timers.retain(|t| {
if t.fire_at <= now {
fired.push(t.id);
false
} else {
true
}
});
for id in fired {
if !cancelled_timers.remove(&id) {
backend.timer_triggered(id);
}
}
// 4. Drain and process backend commands.
let commands = backend.drain_commands();
if commands.is_empty() && !client_joined {
sleep_ms(2).await;
continue;
}
let mut delta_batch: Vec<D> = Vec::new();
let mut reset = false;
for cmd in commands {
match cmd {
BackendCommand::TerminateRoom => {
send_disconnect(&mut ws_sender, true);
event_tx
.unbounded_send(SessionEvent::Disconnected(None))
.ok();
return;
}
BackendCommand::SetTimer { timer_id, duration } => {
// Cancel any existing timer with the same id, then re-arm.
timers.retain(|t| t.id != timer_id);
cancelled_timers.remove(&timer_id);
timers.push(Timer {
id: timer_id,
fire_at: Instant::now() + Duration::from_secs_f32(duration),
});
}
BackendCommand::CancelTimer { timer_id } => {
cancelled_timers.insert(timer_id);
}
BackendCommand::KickPlayer { player } => {
if remote_player_count > 0 {
send_kick(&mut ws_sender, player);
}
}
BackendCommand::ResetViewState => {
reset = true;
}
BackendCommand::Delta(d) => {
delta_batch.push(d);
}
}
}
if reset {
// Reset supersedes all pending deltas: send fresh full state.
let state = backend.get_view_state().clone();
if remote_player_count > 0 {
send_reset(&mut ws_sender, &state);
}
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state)))
.ok();
} else {
// Broadcast deltas, then notify local UI.
if remote_player_count > 0 && !delta_batch.is_empty() {
send_delta(&mut ws_sender, &delta_batch);
}
for d in delta_batch {
event_tx
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d)))
.ok();
}
}
// Send full state to clients that joined this iteration.
if client_joined {
send_full_state(&mut ws_sender, backend.get_view_state());
}
sleep_ms(2).await;
}
}

View file

@ -0,0 +1,10 @@
pub mod session;
pub mod traits;
mod client;
mod host;
mod platform;
mod protocol;
pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};

View file

@ -0,0 +1,48 @@
use std::future::Future;
/// Spawns a background task.
/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required)
/// - Native: spawns an OS thread running `futures::executor::block_on`
#[cfg(target_arch = "wasm32")]
pub fn spawn_task<F>(fut: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(fut);
}
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_task<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
std::thread::spawn(move || {
futures::executor::block_on(fut);
});
}
/// Yields for approximately `ms` milliseconds.
/// - WASM: non-blocking yield via browser timer
/// - Native: blocks the current thread (safe on a dedicated background thread)
#[cfg(target_arch = "wasm32")]
pub async fn sleep_ms(ms: u32) {
gloo_timers::future::TimeoutFuture::new(ms).await;
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep_ms(ms: u32) {
std::thread::sleep(std::time::Duration::from_millis(ms as u64));
}
/// Platform-agnostic bound for types that can be moved into a background task.
/// - WASM: only requires `'static` (single-threaded, no Send needed)
/// - Native: requires `Send + 'static`
#[cfg(target_arch = "wasm32")]
pub trait TaskBound: 'static {}
#[cfg(target_arch = "wasm32")]
impl<T: 'static> TaskBound for T {}
#[cfg(not(target_arch = "wasm32"))]
pub trait TaskBound: Send + 'static {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + 'static> TaskBound for T {}

View file

@ -0,0 +1,159 @@
//! Wire protocol encoding/decoding helpers.
//!
//! Translates between raw WebSocket binary frames and typed Rust values using
//! postcard serialization and the message-type constants from the `protocol` crate.
use crate::traits::{SerializationCap, ViewStateUpdate};
use bytes::{Buf, BufMut, Bytes, BytesMut};
use ewebsock::{WsMessage, WsSender};
use postcard::{from_bytes, take_from_bytes, to_stdvec};
use protocol::{
CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE,
FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS,
SERVER_ERROR, SERVER_RPC,
};
// ---------------------------------------------------------------------------
// Inbound command types (relay → host)
// ---------------------------------------------------------------------------
pub enum ToServerCommand<A> {
ClientJoin(u16),
ClientLeft(u16),
Rpc(u16, A),
Error(String),
}
// ---------------------------------------------------------------------------
// Send helpers
// ---------------------------------------------------------------------------
fn send_binary(sender: &mut WsSender, data: &[u8]) {
sender.send(WsMessage::Binary(data.to_vec()));
}
pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> {
let bytes = to_stdvec(req).map_err(|e| e.to_string())?;
send_binary(sender, &bytes);
Ok(())
}
pub fn send_rpc<A: SerializationCap>(sender: &mut WsSender, action: &A) {
let raw = to_stdvec(action).expect("Failed to serialize RPC");
let mut buf = BytesMut::with_capacity(1 + raw.len());
buf.put_u8(SERVER_RPC);
buf.put_slice(&raw);
send_binary(sender, &buf);
}
pub fn send_delta<D: SerializationCap>(sender: &mut WsSender, deltas: &[D]) {
let serialized: Vec<u8> = deltas
.iter()
.flat_map(|d| to_stdvec(d).expect("Failed to serialize delta"))
.collect();
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(DELTA_UPDATE);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_full_state<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
let serialized = to_stdvec(state).expect("Failed to serialize full state");
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(FULL_UPDATE);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_reset<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
let serialized = to_stdvec(state).expect("Failed to serialize reset state");
let mut buf = BytesMut::with_capacity(1 + serialized.len());
buf.put_u8(RESET);
buf.put_slice(&serialized);
send_binary(sender, &buf);
}
pub fn send_kick(sender: &mut WsSender, player_id: u16) {
let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE);
buf.put_u8(CLIENT_GETS_KICKED);
buf.put_u16(player_id);
send_binary(sender, &buf);
}
pub fn send_disconnect(sender: &mut WsSender, as_host: bool) {
let msg = if as_host {
SERVER_DISCONNECTS
} else {
CLIENT_DISCONNECTS_SELF
};
send_binary(sender, &[msg]);
}
// ---------------------------------------------------------------------------
// Receive / parse helpers
// ---------------------------------------------------------------------------
/// Parses the relay's handshake response.
///
/// Returns `(player_id, rule_variation, reconnect_token)`.
pub fn parse_handshake_response(data: Vec<u8>) -> Result<(u16, u16, u64), String> {
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
HAND_SHAKE_RESPONSE => {
let player_id = bytes.get_u16();
let rule_variation = bytes.get_u16();
let token = bytes.get_u64();
Ok((player_id, rule_variation, token))
}
other => Err(format!("Unexpected handshake message id: {other}")),
}
}
pub fn parse_server_command<A: SerializationCap>(data: Vec<u8>) -> ToServerCommand<A> {
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()),
NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()),
CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()),
SERVER_RPC => {
let client_id = bytes.get_u16();
let payload: A =
from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload");
ToServerCommand::Rpc(client_id, payload)
}
other => ToServerCommand::Error(format!("Unknown server message id: {other}")),
}
}
pub fn parse_client_update<VS, D>(
data: Vec<u8>,
) -> Result<Vec<ViewStateUpdate<VS, D>>, String>
where
VS: SerializationCap,
D: SerializationCap,
{
let mut bytes = Bytes::from(data);
let msg = bytes.get_u8();
match msg {
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
DELTA_UPDATE => {
let mut result = Vec::new();
let mut remaining: &[u8] = &bytes;
while !remaining.is_empty() {
let (delta, rest): (D, &[u8]) =
take_from_bytes(remaining).map_err(|e| e.to_string())?;
remaining = rest;
result.push(ViewStateUpdate::Incremental(delta));
}
Ok(result)
}
FULL_UPDATE | RESET => {
let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?;
Ok(vec![ViewStateUpdate::Full(state)])
}
other => Err(format!("Unknown client message id: {other}")),
}
}

View file

@ -0,0 +1,266 @@
//! The public-facing session API.
//!
//! # Usage
//!
//! ```ignore
//! // Connect (async, returns after handshake completes)
//! let mut session: GameSession<MyAction, MyDelta, MyState> =
//! GameSession::connect::<MyBackend>(RoomConfig {
//! relay_url: "ws://localhost:8080/ws".to_string(),
//! game_id: "my-game".to_string(),
//! room_id: "room-42".to_string(),
//! rule_variation: 0,
//! role: RoomRole::Create,
//! reconnect_token: None,
//! })
//! .await?;
//!
//! // In a loop (e.g. Dioxus coroutine with futures::select!):
//! loop {
//! futures::select! {
//! cmd = ui_rx.next().fuse() => session.send_action(cmd),
//! event = session.next_event().fuse() => match event {
//! Some(SessionEvent::Update(u)) => view_state.apply(u),
//! Some(SessionEvent::Disconnected(reason)) | None => break,
//! }
//! }
//! }
//! ```
use ewebsock::{WsEvent, WsMessage};
use futures::StreamExt;
use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
use protocol::JoinRequest;
use crate::client::client_loop;
use crate::host::host_loop;
use crate::platform::{TaskBound, sleep_ms, spawn_task};
use crate::protocol::{parse_handshake_response, send_join_request};
use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate};
// ---------------------------------------------------------------------------
// Public configuration types
// ---------------------------------------------------------------------------
/// Whether to create a new room (host) or join an existing one (client).
pub enum RoomRole {
Create,
Join,
}
/// Configuration required to connect to a game session.
pub struct RoomConfig {
/// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`).
pub relay_url: String,
/// Game identifier registered on the relay (e.g. `"tic-tac-toe"`).
pub game_id: String,
/// Room identifier shared between host and clients.
pub room_id: String,
/// Game mode/variant. Only used when `role` is `Create`.
pub rule_variation: u16,
pub role: RoomRole,
/// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh.
/// The value is the token returned by a previous successful handshake.
pub reconnect_token: Option<u64>,
/// Serialized backend state for host reconnect.
///
/// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in
/// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host
/// reconnects so the game can resume from the last known state.
/// Ignored for non-host reconnects and normal connections.
pub host_state: Option<Vec<u8>>,
}
/// Error returned by [`GameSession::connect`].
#[derive(Debug)]
pub enum ConnectError {
WebSocket(String),
Handshake(String),
}
impl std::fmt::Display for ConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"),
ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"),
}
}
}
// ---------------------------------------------------------------------------
// Internal message type (UI → background task)
// ---------------------------------------------------------------------------
pub(crate) enum BackendMsg<A> {
Action(A),
Disconnect,
}
// ---------------------------------------------------------------------------
// Session event (background task → UI)
// ---------------------------------------------------------------------------
/// Events emitted by the session to the UI.
pub enum SessionEvent<Delta, ViewState> {
/// A state update arrived from the host backend.
Update(ViewStateUpdate<ViewState, Delta>),
/// The session ended. `None` = clean disconnect, `Some(reason)` = error.
Disconnected(Option<String>),
}
// ---------------------------------------------------------------------------
// GameSession
// ---------------------------------------------------------------------------
/// A connected game session.
///
/// Created by [`GameSession::connect`]. Holds channels to the background task
/// that owns the WebSocket connection and (on host) the game backend.
pub struct GameSession<Action, Delta, ViewState> {
/// The player ID assigned by the relay server. Always `0` for the host.
pub player_id: u16,
/// The game mode/variant selected by the host.
pub rule_variation: u16,
/// `true` if this client is hosting the game (runs the backend).
pub is_host: bool,
/// Token to persist in localStorage for reconnect on page refresh.
/// Only meaningful for non-host players (player_id > 0).
pub reconnect_token: u64,
action_tx: UnboundedSender<BackendMsg<Action>>,
event_rx: UnboundedReceiver<SessionEvent<Delta, ViewState>>,
}
impl<A, D, VS> GameSession<A, D, VS>
where
A: SerializationCap + TaskBound,
D: SerializationCap + Clone + TaskBound,
VS: SerializationCap + Clone + TaskBound,
{
/// Connects to the relay server and performs the handshake.
///
/// Returns after the relay confirms the player ID and rule variation.
/// Spawns a background task that drives the WebSocket connection for the
/// lifetime of the session.
///
/// # Errors
/// Returns `Err` if the WebSocket cannot be opened or the handshake fails.
pub async fn connect<Backend>(config: RoomConfig) -> Result<Self, ConnectError>
where
Backend: BackEndArchitecture<A, D, VS> + TaskBound,
{
let create_room = matches!(config.role, RoomRole::Create);
// 1. Open WebSocket.
let (mut ws_sender, ws_receiver) =
ewebsock::connect(&config.relay_url, ewebsock::Options::default())
.map_err(|e| ConnectError::WebSocket(e.to_string()))?;
// 2. Wait for the Opened event (WASM WebSocket is async).
loop {
match ws_receiver.try_recv() {
Some(WsEvent::Opened) => break,
Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)),
Some(WsEvent::Closed) => {
return Err(ConnectError::WebSocket("Connection closed".to_string()));
}
Some(_) => continue,
None => sleep_ms(1).await,
}
}
// 3. Send the join request.
let req = JoinRequest {
game_id: config.game_id,
room_id: config.room_id,
rule_variation: config.rule_variation,
create_room,
reconnect_token: config.reconnect_token,
};
send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?;
// 4. Wait for the handshake response.
let (player_id, rule_variation, reconnect_token) = loop {
match ws_receiver.try_recv() {
Some(WsEvent::Message(WsMessage::Binary(data))) => {
break parse_handshake_response(data).map_err(ConnectError::Handshake)?;
}
Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)),
Some(WsEvent::Closed) => {
// The relay may have sent a binary error frame just before
// closing. ewebsock can deliver Closed before that frame,
// so drain one more message to catch it.
if let Some(WsEvent::Message(WsMessage::Binary(data))) =
ws_receiver.try_recv()
{
break parse_handshake_response(data)
.map_err(ConnectError::Handshake)?;
}
return Err(ConnectError::Handshake(
"Connection closed during handshake".to_string(),
));
}
Some(_) => continue,
None => sleep_ms(1).await,
}
};
// The relay assigns player_id == 0 exclusively to the host.
let is_host = player_id == 0;
// 5. Set up channels between the UI and the background task.
let (action_tx, action_rx) = mpsc::unbounded::<BackendMsg<A>>();
let (event_tx, event_rx) = mpsc::unbounded::<SessionEvent<D, VS>>();
// 6. Spawn the background event loop.
if is_host {
spawn_task(host_loop::<A, D, VS, Backend>(
ws_sender,
ws_receiver,
action_rx,
event_tx,
rule_variation,
config.host_state,
));
} else {
spawn_task(client_loop::<A, D, VS>(
ws_sender,
ws_receiver,
action_rx,
event_tx,
));
}
Ok(GameSession {
player_id,
rule_variation,
is_host,
reconnect_token,
action_tx,
event_rx,
})
}
/// Sends a game action to the backend (fire-and-forget).
pub fn send_action(&self, action: A) {
self.action_tx
.unbounded_send(BackendMsg::Action(action))
.ok();
}
/// Awaits the next session event.
///
/// Returns `None` if the background task has exited (i.e. the session is
/// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))`
/// before the channel closes.
pub async fn next_event(&mut self) -> Option<SessionEvent<D, VS>> {
self.event_rx.next().await
}
/// Signals the background task to send a graceful disconnect message and
/// shut down. Consumes the session.
pub fn disconnect(self) {
self.action_tx
.unbounded_send(BackendMsg::Disconnect)
.ok();
}
}

View file

@ -0,0 +1,97 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
/// Marker trait for types that can be serialized with postcard.
pub trait SerializationCap: Serialize + DeserializeOwned {}
impl<T> SerializationCap for T where T: Serialize + DeserializeOwned {}
/// State updates delivered to the frontend for rendering.
///
/// - [`Full`](Self::Full): Immediately set all visual state, no animation.
/// - [`Incremental`](Self::Incremental): Apply with animation/transition.
pub enum ViewStateUpdate<ViewState, DeltaInformation> {
/// Complete game state snapshot. Received on join or after a reset.
Full(ViewState),
/// Incremental state change for animated transitions.
Incremental(DeltaInformation),
}
/// Commands emitted by the game backend to control the session.
pub enum BackendCommand<DeltaInformation>
where
DeltaInformation: SerializationCap,
{
/// Incremental state change to be broadcast to all frontends.
Delta(DeltaInformation),
/// Signals a complete reset: discard queued deltas, broadcast fresh full state.
ResetViewState,
/// Forcibly removes a player from the session.
KickPlayer { player: u16 },
/// Schedules a callback after `duration` seconds. Overwrites any existing
/// timer with the same `timer_id`.
SetTimer { timer_id: u16, duration: f32 },
/// Cancels a previously scheduled timer. No-op if already fired or not set.
CancelTimer { timer_id: u16 },
/// Shuts down the entire room and disconnects all players.
TerminateRoom,
}
/// The contract for game-specific server logic.
///
/// Implement this on the host side. The session calls these methods in response
/// to network events and drives `drain_commands` to collect outbound messages.
///
/// # Type Parameters
/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`)
/// * `DeltaInformation` — Incremental state changes for animations
/// * `ViewState` — Complete game snapshot for syncing new clients
pub trait BackEndArchitecture<ServerRpcPayload, DeltaInformation, ViewState>
where
ServerRpcPayload: SerializationCap,
DeltaInformation: SerializationCap,
ViewState: SerializationCap + Clone,
{
/// Creates a new game instance. `rule_variation` selects the game mode.
fn new(rule_variation: u16) -> Self;
/// Attempt to restore a previously running game from serialized bytes.
///
/// Called when the host reconnects after a page refresh. The bytes are the
/// game-specific snapshot produced by the app layer (via `serde_json` or
/// similar) and stored in localStorage.
///
/// Return `None` if restoration is not supported or the bytes are invalid —
/// the caller falls back to `new(rule_variation)`.
fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option<Self>
where
Self: Sized,
{
None
}
/// Called when a player connects. Player will receive a full state snapshot
/// automatically after this returns.
fn player_arrival(&mut self, player: u16);
/// Called when a player disconnects.
fn player_departure(&mut self, player: u16);
/// Called when a player sends a game action.
fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload);
/// Called when a previously scheduled timer fires.
fn timer_triggered(&mut self, timer_id: u16);
/// Returns the complete current game state.
fn get_view_state(&self) -> &ViewState;
/// Collects and clears all pending commands since the last drain.
///
/// Implement with `std::mem::take(&mut self.command_list)`.
fn drain_commands(&mut self) -> Vec<BackendCommand<DeltaInformation>>;
}

21
clients/cli/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "trictrac-client_cli"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "client_cli"
path = "src/main.rs"
[dependencies]
anyhow = "1.0.75"
bincode = "1.3.3"
pico-args = "0.5.0"
pretty_assertions = "1.4.0"
renet = "0.0.13"
trictrac-store = { path = "../../store" }
trictrac-bot = { path = "../../bot" }
spiel_bot = { path = "../../spiel_bot" }
itertools = "0.13.0"
env_logger = "0.11.6"
log = "0.4.20"

357
clients/cli/src/app.rs Normal file
View file

@ -0,0 +1,357 @@
use spiel_bot::strategy::{AzBotStrategy, DqnSpielBotStrategy};
use trictrac_bot::{
BotStrategy, DefaultStrategy, DqnBurnStrategy, ErroneousStrategy, RandomStrategy,
StableBaselines3Strategy,
};
use itertools::Itertools;
use crate::game_runner::GameRunner;
use trictrac_store::{CheckerMove, GameEvent, GameState, Stage, TurnStage};
#[derive(Debug, Default)]
pub struct AppArgs {
pub seed: Option<u32>,
pub bot: Option<String>,
}
// Application.
#[derive(Debug, Default)]
pub struct App {
// should the application exit?
pub should_quit: bool,
pub schools_enabled: bool,
pub game: GameRunner,
}
impl App {
// Constructs a new instance of [`App`].
pub fn new(args: AppArgs) -> Self {
let bot_strategies: Vec<Box<dyn BotStrategy>> =
args.bot
.as_deref()
.map(|str_bots| {
str_bots
.split(",")
.filter_map(|s| match s.trim() {
"dummy" => {
Some(Box::new(DefaultStrategy::default()) as Box<dyn BotStrategy>)
}
"random" => {
Some(Box::new(RandomStrategy::default()) as Box<dyn BotStrategy>)
}
"erroneous" => {
Some(Box::new(ErroneousStrategy::default()) as Box<dyn BotStrategy>)
}
"ai" => Some(Box::new(StableBaselines3Strategy::default())
as Box<dyn BotStrategy>),
"dqnburn" => {
Some(Box::new(DqnBurnStrategy::default()) as Box<dyn BotStrategy>)
}
s if s.starts_with("ai:") => {
let path = s.trim_start_matches("ai:");
Some(Box::new(StableBaselines3Strategy::new(path))
as Box<dyn BotStrategy>)
}
s if s.starts_with("dqnburn:") => {
let path = s.trim_start_matches("dqnburn:");
Some(Box::new(DqnBurnStrategy::new_with_model(&path.to_string()))
as Box<dyn BotStrategy>)
}
"az" => {
Some(Box::new(AzBotStrategy::new_mlp(None)) as Box<dyn BotStrategy>)
}
s if s.starts_with("az:") && !s.starts_with("az-") => {
let path = s.trim_start_matches("az:");
Some(Box::new(AzBotStrategy::new_mlp(Some(path))) as Box<dyn BotStrategy>)
}
"az-resnet" => {
Some(Box::new(AzBotStrategy::new_resnet(None)) as Box<dyn BotStrategy>)
}
s if s.starts_with("az-resnet:") => {
let path = s.trim_start_matches("az-resnet:");
Some(Box::new(AzBotStrategy::new_resnet(Some(path))) as Box<dyn BotStrategy>)
}
"az-dqn" => {
Some(Box::new(DqnSpielBotStrategy::new(None)) as Box<dyn BotStrategy>)
}
s if s.starts_with("az-dqn:") => {
let path = s.trim_start_matches("az-dqn:");
Some(Box::new(DqnSpielBotStrategy::new(Some(path))) as Box<dyn BotStrategy>)
}
_ => None,
})
.collect()
})
.unwrap_or_default();
let schools_enabled = false;
let should_quit = bot_strategies.len() > 1;
Self {
game: GameRunner::new(schools_enabled, bot_strategies, args.seed.map(|s| s as u64)),
should_quit,
schools_enabled,
}
}
pub fn start(&mut self) {
self.game.state = GameState::new(self.schools_enabled);
}
pub fn input(&mut self, input: &str) {
// println!("'{}'", input);
match input {
"state" => self.show_state(),
"history" => self.show_history(),
"quit" => self.quit(),
// run bots game (when two bots)
"bots" => self.bots_all(),
"" => self.bots_next_step(),
// play (when one bot)
"roll" => self.roll_dice(),
"go" => self.go(),
_ => self.add_move(input),
}
println!("{}", self.display());
}
// --- 2 bots game actions
fn bots_all(&mut self) {}
fn bots_next_step(&mut self) {}
// Set running to false to quit the application.
pub fn quit(&mut self) {
self.should_quit = true;
}
pub fn show_state(&self) {
println!("{:?}", self.game.state)
}
pub fn show_history(&self) {
for hist in self.game.state.history.iter() {
println!("{hist:?}\n");
}
}
fn roll_dice(&mut self) {
if self.game.player_id.is_none() {
println!("player_id not set ");
return;
}
if self.game.state.turn_stage != TurnStage::RollDice {
println!("Not in the dice roll stage");
return;
}
let dice = self.game.dice_roller.roll();
// get correct points for these board and dice
// let points_rules = PointsRules::new(
// &self
// .game
// .state
// .player_color_by_id(&self.game.player_id.unwrap())
// .unwrap(),
// &self.game.state.board,
// dice,
// );
self.game.handle_event(&GameEvent::Roll {
player_id: self.game.player_id.unwrap(),
});
self.game.handle_event(&GameEvent::RollResult {
player_id: self.game.player_id.unwrap(),
dice,
});
}
fn go(&mut self) {
if self.game.player_id.is_none() {
println!("player_id not set ");
return;
}
if self.game.state.turn_stage != TurnStage::HoldOrGoChoice {
println!("Not in position to go");
return;
}
self.game.handle_event(&GameEvent::Go {
player_id: self.game.player_id.unwrap(),
});
}
fn add_move(&mut self, input: &str) {
if self.game.player_id.is_none() {
println!("player_id not set ");
return;
}
let positions: Vec<usize> = input
.split(' ')
.map(|str| str.parse().unwrap_or(0))
.collect();
if positions.len() == 2 && positions[0] != 0 && positions[1] != 0 {
if let Ok(checker_move) = CheckerMove::new(positions[0], positions[1]) {
// if checker_move.is_ok() {
if self.game.first_move.is_some() {
let move_event = GameEvent::Move {
player_id: self.game.player_id.unwrap(),
moves: (self.game.first_move.unwrap(), checker_move),
};
if !self.game.state.validate(&move_event) {
println!("Move invalid");
self.game.first_move = None;
return;
}
self.game.handle_event(&move_event);
self.game.first_move = None;
} else {
self.game.first_move = Some(checker_move);
}
return;
}
}
println!("invalid move : {input}");
}
pub fn display(&mut self) -> String {
let winner = self
.game
.state
.determine_winner()
.and_then(|id| self.game.state.players.get(&id));
let str_won: String = winner
.map(|p| {
let mut name = " winner: ".to_owned();
name.push_str(&p.name);
name
})
.unwrap_or("".to_owned());
let mut output = "-------------------------------".to_owned();
output += format!(
"\n{:?}{} > {} > {:?}",
self.game.state.stage,
str_won,
self.game
.state
.who_plays()
.map(|pl| &pl.name)
.unwrap_or(&"?".to_owned()),
self.game.state.turn_stage
)
.as_str();
output = output + "\nRolled dice : " + &self.game.state.dice.to_display_string();
if self.game.state.stage != Stage::PreGame {
output = output + "\nRolled dice jans : " + &format!("{:?}", self.game.state.dice_jans);
output = output
+ "\nLast move : "
+ &self.game.state.dice_moves.0.to_display_string()
+ ", "
+ &self.game.state.dice_moves.1.to_display_string();
// display players points
output += format!("\n\n{:<11} :: {:<5} :: {}", "Player", "holes", "points").as_str();
for player_id in self.game.state.players.keys().sorted() {
let player = &self.game.state.players[player_id];
output += format!(
"\n{}. {:<8} :: {:<5} :: {}",
&player_id, &player.name, &player.holes, &player.points,
)
.as_str();
}
}
output += "\n-------------------------------\n";
output += &self.game.state.board.to_display_grid(9);
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_display() {
let expected = "-------------------------------
PreGame > ? > RollDice
Rolled dice : 0 & 0
-------------------------------
13 14 15 16 17 18 19 20 21 22 23 24
----------------------------------------------------------------
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | 15 |
|------------------------------ | | -----------------------------|
| | | 15 |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
----------------------------------------------------------------
12 11 10 9 8 7 6 5 4 3 2 1
";
let mut app = App::default();
self::assert_eq!(app.display(), expected);
}
#[test]
fn test_move() {
let expected = "-------------------------------
InGame > myself > RollDice
Rolled dice : 4 & 6
Rolled dice jans : {}
Last move : CheckerMove { from: 24, to: 18 } , CheckerMove { from: 24, to: 20 }
Player :: holes :: points
1. myself :: 0 :: 0
2. bot :: 0 :: 0
-------------------------------
13 14 15 16 17 18 19 20 21 22 23 24
----------------------------------------------------------------
| X | | X X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | 13 |
|------------------------------ | | -----------------------------|
| | | 13 |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O O O |
----------------------------------------------------------------
12 11 10 9 8 7 6 5 4 3 2 1
";
let mut app = App::new(AppArgs {
seed: Some(1327),
bot: Some("dummy".into()),
});
println!("avant : {}", app.display());
app.input("roll");
app.input("1 3");
app.input("1 4");
self::assert_eq!(app.display(), expected);
}
}

View file

@ -0,0 +1,131 @@
use log::{debug, error};
use trictrac_bot::{Bot, BotStrategy};
use trictrac_store::{CheckerMove, DiceRoller, GameEvent, GameState, PlayerId, TurnStage};
// Application Game
#[derive(Debug, Default)]
pub struct GameRunner {
pub state: GameState,
pub dice_roller: DiceRoller,
pub first_move: Option<CheckerMove>,
pub player_id: Option<PlayerId>,
bots: Vec<Bot>,
}
impl GameRunner {
// Constructs a new instance of [`App`].
pub fn new(
schools_enabled: bool,
bot_strategies: Vec<Box<dyn BotStrategy>>,
seed: Option<u64>,
) -> Self {
let mut state = GameState::new(schools_enabled);
// local : player
let player_id: Option<PlayerId> = if bot_strategies.len() > 1 {
None
} else {
state.init_player("myself")
};
// bots
let bots: Vec<Bot> = bot_strategies
.into_iter()
.map(|strategy| {
let bot_id: PlayerId = state.init_player("bot").unwrap();
let bot_color = state.player_color_by_id(&bot_id).unwrap();
Bot::new(strategy, bot_color)
})
.collect();
// let bot_strategy = Box::new(DefaultStrategy::default());
// let bot: Bot = Bot::new(bot_strategy, bot_color, schools_enabled);
// let bot: Bot = Bot::new(bot_strategy, bot_color);
let first_player_id = if bots.len() > 1 {
bots[0].player_id
} else {
player_id.unwrap()
};
let mut game = Self {
state,
dice_roller: DiceRoller::new(seed),
first_move: None,
player_id,
bots,
};
game.handle_event(&GameEvent::BeginGame {
goes_first: first_player_id,
});
game
}
pub fn handle_event(&mut self, event: &GameEvent) -> Option<GameEvent> {
if event == &GameEvent::PlayError {
return None;
}
let valid_event = if self.state.validate(event) {
debug!(
"--------------- new valid event {event:?} (stage {:?}) -----------",
self.state.turn_stage
);
let _ = self.state.consume(event).inspect_err(|e| error!("{}", e));
debug!(
" --> stage {:?} ; active player points {:?}",
self.state.turn_stage,
self.state.who_plays().map(|p| p.points)
);
event
} else {
debug!("{}", self.state);
error!("event not valid : {event:?}");
// panic!("crash and burn {} \nevt not valid {event:?}", self.state);
&GameEvent::PlayError
};
// chain all successive bot actions
if self.bots.is_empty() {
return None;
}
// Collect bot actions to avoid borrow conflicts
let bot_events: Vec<GameEvent> = self
.bots
.iter_mut()
.filter_map(|bot| bot.handle_event(valid_event))
.collect();
// if bot_events.len() > 1 {
// println!(
// "There might be a problem : 2 bots events : {:?}",
// bot_events
// );
// }
let mut next_event = None;
for bot_event in bot_events {
let bot_result_event = self.handle_event(&bot_event);
if let Some(bot_id) = bot_event.player_id() {
next_event = if self.bot_needs_dice_roll(bot_id) {
let dice = self.dice_roller.roll();
self.handle_event(&GameEvent::RollResult {
player_id: bot_id,
dice,
})
} else {
bot_result_event
};
}
}
if let Some(winner) = self.state.determine_winner() {
next_event = Some(trictrac_store::GameEvent::EndGame {
reason: trictrac_store::EndGameReason::PlayerWon { winner },
});
}
next_event
}
fn bot_needs_dice_roll(&self, bot_id: PlayerId) -> bool {
self.state.active_player_id == bot_id && self.state.turn_stage == TurnStage::RollWaiting
}
}

95
clients/cli/src/main.rs Normal file
View file

@ -0,0 +1,95 @@
// Application.
pub mod app;
mod game_runner;
use anyhow::Result;
use app::{App, AppArgs};
use std::io;
// see pico-args example at https://github.com/RazrFalcon/pico-args/blob/master/examples/app.rs
const HELP: &str = "\
Trictrac CLI
USAGE:
trictrac-cli [OPTIONS]
FLAGS:
-h, --help Prints help information
OPTIONS:
--seed SEED Sets the random generator seed
--bot STRATEGY_BOT Add a bot player with strategy STRATEGY, a second bot may be added to play against the first : --bot STRATEGY_BOT1,STRATEGY_BOT2
Available strategies:
- dummy: Default strategy selecting the first valid move
- ai: AI strategy using the default model at models/trictrac_ppo.zip
- ai:/path/to/model.zip: AI strategy using a custom model
- dqnburn: DQN strategy (burn-rl backend)
- dqnburn:/path/to/model: DQN strategy (burn-rl backend) with custom model
- az: AlphaZero MlpNet (random weights)
- az:/path/to/model.mpk: AlphaZero MlpNet checkpoint
- az-resnet: AlphaZero ResNet (random weights)
- az-resnet:/path/to/model.mpk: AlphaZero ResNet checkpoint
- az-dqn: DQN QNet (random weights, first-legal-move fallback)
- az-dqn:/path/to/model.mpk: DQN QNet checkpoint
ARGS:
<INPUT>
";
fn main() -> Result<()> {
env_logger::init();
let args = match parse_args() {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {e}.");
std::process::exit(1);
}
};
// println!("{:#?}", args);
// Create an application.
let mut app = App::new(args);
// Start the main loop.
while !app.should_quit {
println!("whot?>");
let mut input = String::new();
let _bytecount = io::stdin().read_line(&mut input)?;
app.input(input.trim());
}
// display app final state
println!("{}", app.display());
Ok(())
}
fn parse_args() -> Result<AppArgs, pico_args::Error> {
let mut pargs = pico_args::Arguments::from_env();
// Help has a higher priority and should be handled separately.
if pargs.contains(["-h", "--help"]) {
print!("{HELP}");
std::process::exit(0);
}
let args = AppArgs {
// Parses an optional value that implements `FromStr`.
seed: pargs.opt_value_from_str("--seed")?,
bot: pargs.opt_value_from_str("--bot")?,
// Parses an optional value from `&str` using a specified function.
// width: pargs.opt_value_from_fn("--width", parse_width)?.unwrap_or(10),
};
// It's up to the caller what to do with the remaining arguments.
let remaining = pargs.finish();
if !remaining.is_empty() {
eprintln!("Warning: unused arguments left: {remaining:?}.");
}
Ok(args)
}
// fn parse_width(s: &str) -> Result<u32, &'static str> {
// s.parse().map_err(|_| "not a number")
// }

View file

@ -0,0 +1,38 @@
[package]
name = "client_web"
version = "0.1.0"
edition = "2021"
[package.metadata.leptos-i18n]
default = "en"
locales = ["en", "fr"]
[dependencies]
leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] }
trictrac-store = { path = "../../store" }
backbone-lib = { path = "../backbone-lib" }
leptos = { version = "0.7", features = ["csr"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
futures = "0.3"
rand = "0.9"
gloo-storage = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
gloo-timers = { version = "0.3", features = ["futures"] }
# 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"] }
web-sys = { version = "0.3", features = [
"AudioContext",
"AudioParam",
"AudioNode",
"AudioDestinationNode",
"AudioScheduledSourceNode",
"GainNode",
"OscillatorNode",
"OscillatorType",
"BaseAudioContext",
"HtmlAudioElement",
] }

View file

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

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

151
clients/web-game/dist/index.html vendored Normal file
View file

@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Trictrac</title>
<script type="module">
import init, * as bindings from '/client_web-4248a2b78bb5a03.js';
const wasm = await init({ module_or_path: '/client_web-4248a2b78bb5a03_bg.wasm' });
window.wasmBindings = bindings;
dispatchEvent(new CustomEvent("TrunkApplicationStarted", {detail: {wasm}}));
</script>
<link rel="stylesheet" href="/style-398501cc5e039e60.css" integrity="sha384-2ThBpr9kkUyidGty3yoW5Ko7K/BgvwfBPFT7LeUlz6UN1/i79c/hWGkSxyb2jQEy"/>
<link rel="modulepreload" href="/client_web-4248a2b78bb5a03.js" crossorigin="anonymous" integrity="sha384-RV1mp1Eo8sE16ldXkdWXd7O5F6KPrauzW++dBoewVErNRRjIDRP1Qec4gg8eEwbn"><link rel="preload" href="/client_web-4248a2b78bb5a03_bg.wasm" crossorigin="anonymous" integrity="sha384-cv/IN4k4mpulOZfHv2tWRwdZEY/Fw5cj/wWch7btaCRmegCbsiYD392KpUMFUTLF" as="fetch" type="application/wasm"></head>
<body><script>"use strict";
(function () {
const address = '{{__TRUNK_ADDRESS__}}';
const base = '{{__TRUNK_WS_BASE__}}';
let protocol = '';
protocol =
protocol
? protocol
: window.location.protocol === 'https:'
? 'wss'
: 'ws';
const url = protocol + '://' + address + base + '.well-known/trunk/ws';
class Overlay {
constructor() {
// create an overlay
this._overlay = document.createElement("div");
const style = this._overlay.style;
style.height = "100vh";
style.width = "100vw";
style.position = "fixed";
style.top = "0";
style.left = "0";
style.backgroundColor = "rgba(222, 222, 222, 0.5)";
style.fontFamily = "sans-serif";
// not sure that's the right approach
style.zIndex = "1000000";
style.backdropFilter = "blur(1rem)";
const container = document.createElement("div");
// center it
container.style.position = "absolute";
container.style.top = "30%";
container.style.left = "15%";
container.style.maxWidth = "85%";
this._title = document.createElement("div");
this._title.innerText = "Build failure";
this._title.style.paddingBottom = "2rem";
this._title.style.fontSize = "2.5rem";
this._message = document.createElement("div");
this._message.style.whiteSpace = "pre-wrap";
const icon= document.createElement("div");
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#dc3545" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>';
this._title.prepend(icon);
container.append(this._title, this._message);
this._overlay.append(container);
this._inject();
window.setInterval(() => {
this._inject();
}, 250);
}
set reason(reason) {
this._message.textContent = reason;
}
_inject() {
if (!this._overlay.isConnected) {
// prepend it
document.body?.prepend(this._overlay);
}
}
}
class Client {
constructor(url) {
this.url = url;
this.poll_interval = 5000;
this._overlay = null;
}
start() {
const ws = new WebSocket(this.url);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case "reload":
this.reload();
break;
case "buildFailure":
this.buildFailure(msg.data)
break;
}
};
ws.onclose = () => this.onclose();
}
onclose() {
window.setTimeout(
() => {
// when we successfully reconnect, we'll force a
// reload (since we presumably lost connection to
// trunk due to it being killed, so it will have
// rebuilt on restart)
const ws = new WebSocket(this.url);
ws.onopen = () => window.location.reload();
ws.onclose = () => this.onclose();
},
this.poll_interval);
}
reload() {
window.location.reload();
}
buildFailure({reason}) {
// also log the console
console.error("Build failed:", reason);
console.debug("Overlay", this._overlay);
if (!this._overlay) {
this._overlay = new Overlay();
}
this._overlay.reason = reason;
}
}
new Client(url).start();
})()
</script></body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
<!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" />
<link data-trunk rel="copy-file" href="assets/diceroll.mp3" />
</head>
<body></body>
</html>

View file

@ -0,0 +1,61 @@
{
"room_name_placeholder": "Room name",
"create_room": "Create Room",
"join_room": "Join Room",
"connecting": "Connecting…",
"game_over": "Game over",
"waiting_for_opponent": "Waiting for opponent…",
"your_turn_roll": "Your turn — roll the dice",
"hold_or_go": "Hold or Go?",
"select_move": "Move a checker ({{ n }} of 2)",
"your_turn": "Your turn",
"opponent_turn": "Opponent's turn",
"room_label": "Room: {{ id }}",
"quit": "Quit",
"roll_dice": "Roll dice",
"go": "Go",
"empty_move": "Empty move",
"you_suffix": " (you)",
"points_label": "Points",
"holes_label": "Holes",
"bredouille_title": "Can bredouille",
"jan_double": "double",
"jan_simple": "simple",
"jan_filled_quarter": "Quarter filled",
"jan_true_hit_small": "True hit (small jan)",
"jan_true_hit_big": "True hit (big jan)",
"jan_true_hit_corner": "True hit (opp. corner)",
"jan_first_exit": "First to exit",
"jan_six_tables": "Six tables",
"jan_two_tables": "Two tables",
"jan_mezeas": "Mezeas",
"jan_false_hit_small": "False hit (small jan)",
"jan_false_hit_big": "False hit (big jan)",
"jan_contre_two": "Contre two tables",
"jan_contre_mezeas": "Contre mezeas",
"jan_helpless_man": "Helpless man",
"play_vs_bot": "Play vs Bot",
"vs_bot_label": "vs Bot",
"you_win": "You win!",
"opp_wins": "{{ name }} wins!",
"play_again": "Play again",
"after_opponent_roll": "Opponent rolled",
"after_opponent_go": "Opponent chose to continue",
"after_opponent_move": "Opponent moved — your turn",
"after_opponent_pre_game_roll": "Opponent rolled — your turn",
"pre_game_roll_title": "Who goes first?",
"pre_game_roll_btn": "Roll",
"pre_game_roll_tie": "Tie! Roll again",
"pre_game_roll_your_die": "Your die",
"pre_game_roll_opp_die": "Opponent's die",
"continue_btn": "Continue",
"scored_pts": "+{{ n }} pts",
"hole_made": "Hole! {{ holes }}/12",
"bredouille_applied": "Bredouille!",
"hold": "Hold",
"opp_scored_pts": "Opponent +{{ n }} pts",
"opp_hole_made": "Opponent hole! {{ holes }}/12",
"hint_move": "Click a highlighted field to move a checker",
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
"hint_continue": "Click Continue when ready"
}

View file

@ -0,0 +1,61 @@
{
"room_name_placeholder": "Nom de la salle",
"create_room": "Créer une salle",
"join_room": "Rejoindre",
"connecting": "Connexion en cours…",
"game_over": "Partie terminée",
"waiting_for_opponent": "En attente de l'adversaire…",
"your_turn_roll": "À votre tour — lancez les dés",
"hold_or_go": "Tenir ou s'en aller ?",
"select_move": "Déplacez une dame ({{ n }} sur 2)",
"your_turn": "Votre tour",
"opponent_turn": "Tour de l'adversaire",
"room_label": "Salle : {{ id }}",
"quit": "Quitter",
"roll_dice": "Lancer les dés",
"go": "S'en aller",
"empty_move": "Mouvement impossible",
"you_suffix": " (vous)",
"points_label": "Points",
"holes_label": "Trous",
"bredouille_title": "Peut faire bredouille",
"jan_double": "double",
"jan_simple": "simple",
"jan_filled_quarter": "Remplissage",
"jan_true_hit_small": "Battage à vrai (petit jan)",
"jan_true_hit_big": "Battage à vrai (grand jan)",
"jan_true_hit_corner": "Battage coin adverse",
"jan_first_exit": "Premier sorti",
"jan_six_tables": "Jan de six tables",
"jan_two_tables": "Jan de deux tables",
"jan_mezeas": "Jan de mézéas",
"jan_false_hit_small": "Battage à faux (petit jan)",
"jan_false_hit_big": "Battage à faux (grand jan)",
"jan_contre_two": "Contre jan de deux tables",
"jan_contre_mezeas": "Contre jan de mezeas",
"jan_helpless_man": "Dame impuissante",
"play_vs_bot": "Jouer contre le bot",
"vs_bot_label": "contre le bot",
"you_win": "Vous avez gagné !",
"opp_wins": "{{ name }} gagne !",
"play_again": "Rejouer",
"after_opponent_roll": "L'adversaire a lancé les dés",
"after_opponent_go": "L'adversaire s'en va",
"after_opponent_move": "L'adversaire a joué — à vous",
"after_opponent_pre_game_roll": "L'adversaire a lancé — à vous",
"pre_game_roll_title": "Qui joue en premier ?",
"pre_game_roll_btn": "Lancer",
"pre_game_roll_tie": "Égalité ! Relancez",
"pre_game_roll_your_die": "Votre dé",
"pre_game_roll_opp_die": "Dé adverse",
"continue_btn": "Continuer",
"scored_pts": "+{{ n }} pts",
"hole_made": "Trou ! {{ holes }}/12",
"bredouille_applied": "Bredouille !",
"hold": "Tenir",
"opp_scored_pts": "Adversaire +{{ n }} pts",
"opp_hole_made": "Trou adverse ! {{ holes }}/12",
"hint_move": "Cliquez un champ surligné pour déplacer",
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
"hint_continue": "Cliquez Continuer quand vous êtes prêt"
}

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,
}

View file

@ -0,0 +1,17 @@
[package]
name = "web-user-portal"
version = "0.1.0"
edition = "2024"
[dependencies]
leptos = { version = "0.7", features = ["csr"] }
leptos_router = { version = "0.7" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
gloo-net = { version = "0.5", features = ["http"] }
js-sys = "0.3"
web-sys = { version = "0.3", features = ["RequestCredentials"] }

View file

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

View file

@ -0,0 +1,103 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #f5f5f5;
color: #1a1a1a;
min-height: 100vh;
}
nav {
background: #1a1a2e;
color: #fff;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
nav a { color: #ccc; text-decoration: none; }
nav a:hover { color: #fff; }
nav .brand { font-weight: 700; font-size: 1.1rem; color: #fff; }
nav .spacer { flex: 1; }
main { max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.6rem; margin-bottom: 1rem; }
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
.card {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.tabs { display: flex; gap: 0; margin-bottom: 1.5rem; }
.tab-btn {
padding: 0.5rem 1.25rem;
border: 1px solid #ddd;
background: #f5f5f5;
cursor: pointer;
font-size: 0.95rem;
}
.tab-btn:first-child { border-radius: 6px 0 0 6px; }
.tab-btn:last-child { border-radius: 0 6px 6px 0; border-left: none; }
.tab-btn.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #555; }
input[type=text], input[type=email], input[type=password] {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
input:focus { outline: none; border-color: #1a1a2e; }
button[type=submit], .btn {
padding: 0.5rem 1.25rem;
background: #1a1a2e;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.95rem;
}
button[type=submit]:hover, .btn:hover { background: #2d2d5e; }
button[type=submit]:disabled { opacity: 0.6; cursor: not-allowed; }
.error { color: #c0392b; font-size: 0.875rem; margin-top: 0.5rem; }
.success { color: #27ae60; font-size: 0.875rem; margin-top: 0.5rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: #fff;
border-radius: 8px;
padding: 1rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-box .value { font-size: 2rem; font-weight: 700; }
.stat-box .label { font-size: 0.8rem; color: #777; margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #eee; color: #555; }
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f0f0f0; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
a { color: #2c5cc5; text-decoration: none; }
a:hover { text-decoration: underline; }
.outcome-win { color: #27ae60; font-weight: 600; }
.outcome-loss { color: #c0392b; font-weight: 600; }
.outcome-draw { color: #e67e22; font-weight: 600; }
.loading { color: #777; padding: 1rem 0; }
.empty { color: #aaa; font-style: italic; padding: 1rem 0; }

View file

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

View file

@ -0,0 +1,191 @@
use serde::{Deserialize, Serialize};
// In debug builds trunk serves on 9092 while the relay is on 8080 — use full URL.
// In release builds the portal is served by the relay itself — use relative paths.
#[cfg(debug_assertions)]
const BASE: &str = "http://localhost:8080";
#[cfg(not(debug_assertions))]
const BASE: &str = "";
fn url(path: &str) -> String {
format!("{BASE}{path}")
}
// ── Response types ────────────────────────────────────────────────────────────
#[derive(Clone, Debug, Deserialize)]
pub struct MeResponse {
pub id: i64,
pub username: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UserProfile {
pub id: i64,
pub username: String,
pub created_at: i64,
pub total_games: i64,
pub wins: i64,
pub losses: i64,
pub draws: i64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GameSummary {
pub id: i64,
pub game_id: String,
pub room_code: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub result: Option<String>,
pub outcome: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GamesResponse {
pub games: Vec<GameSummary>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Participant {
pub player_id: i64,
pub outcome: Option<String>,
pub username: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GameDetail {
pub id: i64,
pub game_id: String,
pub room_code: String,
pub started_at: i64,
pub ended_at: Option<i64>,
pub result: Option<String>,
pub participants: Vec<Participant>,
}
// ── Request bodies ────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct RegisterBody<'a> {
pub username: &'a str,
pub email: &'a str,
pub password: &'a str,
}
#[derive(Serialize)]
pub struct LoginBody<'a> {
pub username: &'a str,
pub password: &'a str,
}
// ── Fetch helpers ─────────────────────────────────────────────────────────────
pub async fn get_me() -> Result<MeResponse, String> {
let resp = gloo_net::http::Request::get(&url("/auth/me"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn post_login(username: &str, password: &str) -> Result<MeResponse, String> {
let body = LoginBody { username, password };
let resp = gloo_net::http::Request::post(&url("/auth/login"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_register(username: &str, email: &str, password: &str) -> Result<MeResponse, String> {
let body = RegisterBody { username, email, password };
let resp = gloo_net::http::Request::post(&url("/auth/register"))
.credentials(web_sys::RequestCredentials::Include)
.json(&body)
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 201 {
resp.json::<MeResponse>().await.map_err(|e| e.to_string())
} else {
let text = resp.text().await.unwrap_or_default();
Err(text)
}
}
pub async fn post_logout() -> Result<(), String> {
let resp = gloo_net::http::Request::post(&url("/auth/logout"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 204 {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_user_profile(username: &str) -> Result<UserProfile, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<UserProfile>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_user_games(username: &str, page: i64) -> Result<GamesResponse, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}/games?page={page}&per_page=20")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<GamesResponse>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}")))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 200 {
resp.json::<GameDetail>().await.map_err(|e| e.to_string())
} else {
Err(format!("status {}", resp.status()))
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
pub fn format_ts(ts: i64) -> String {
let ms = (ts * 1000) as f64;
let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms));
date.to_locale_string("en-GB", &wasm_bindgen::JsValue::UNDEFINED)
.as_string()
.unwrap_or_default()
}

View file

@ -0,0 +1,67 @@
use leptos::prelude::*;
use leptos_router::{components::{Route, Router, Routes, A}, path};
use crate::api::{self, MeResponse};
use crate::pages::{home::HomePage, profile::ProfilePage, game::GamePage};
#[derive(Clone, Debug)]
pub struct AuthState {
pub user: RwSignal<Option<MeResponse>>,
}
#[component]
pub fn App() -> impl IntoView {
let user = RwSignal::new(None::<MeResponse>);
provide_context(AuthState { user });
// Probe session on load.
let auth = use_context::<AuthState>().unwrap();
let _ = LocalResource::new(move || async move {
if let Ok(me) = api::get_me().await {
auth.user.set(Some(me));
}
});
view! {
<Router>
<Nav />
<main>
<Routes fallback=|| view! { <p class="empty">"Page not found."</p> }>
<Route path=path!("/") view=HomePage />
<Route path=path!("/profile/:username") view=ProfilePage />
<Route path=path!("/games/:id") view=GamePage />
</Routes>
</main>
</Router>
}
}
#[component]
fn Nav() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let logout = move |_| {
wasm_bindgen_futures::spawn_local(async move {
let _ = api::post_logout().await;
auth.user.set(None);
});
};
view! {
<nav>
<A href="/" attr:class="brand">"Player Portal"</A>
<span class="spacer" />
{move || match auth.user.get() {
Some(u) => view! {
<A href=format!("/profile/{}", u.username)>
{ u.username.clone() }
</A>
<button class="btn" on:click=logout style="padding:0.25rem 0.75rem">
"Logout"
</button>
}.into_any(),
None => view! { <A href="/">"Login"</A> }.into_any(),
}}
</nav>
}
}

View file

@ -0,0 +1,7 @@
mod api;
mod app;
mod pages;
fn main() {
leptos::mount::mount_to_body(app::App);
}

View file

@ -0,0 +1,95 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::api::{self, GameDetail, Participant};
#[component]
pub fn GamePage() -> impl IntoView {
let params = use_params_map();
let id_str = move || params.read().get("id").unwrap_or_default();
let detail = LocalResource::new(move || {
let s = id_str();
async move {
let id: i64 = s.parse().map_err(|_| "invalid game id".to_string())?;
api::get_game_detail(id).await
}
});
view! {
<div>
{move || match detail.get().map(|sw| sw.take()) {
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
Some(Ok(g)) => view! { <GameDetailView game=g /> }.into_any(),
}}
</div>
}
}
#[component]
fn GameDetailView(game: GameDetail) -> impl IntoView {
let started = api::format_ts(game.started_at);
let ended = game.ended_at.map(api::format_ts).unwrap_or_else(|| "ongoing".into());
view! {
<div class="card">
<h1 style="margin-bottom:0.25rem">"Game " { game.room_code.clone() }</h1>
<p style="color:#777;margin-bottom:1.5rem">
"Started: " { started.clone() } " · Ended: " { ended }
</p>
<h2>"Players"</h2>
<table>
<thead>
<tr>
<th>"Player"</th>
<th>"Username"</th>
<th>"Outcome"</th>
</tr>
</thead>
<tbody>
{game.participants.iter().map(|p| {
view! { <ParticipantRow participant=p.clone() /> }
}).collect_view()}
</tbody>
</table>
{game.result.as_ref().map(|r| view! {
<div style="margin-top:1.5rem">
<h2>"Result data"</h2>
<pre style="background:#f5f5f5;padding:0.75rem;border-radius:5px;overflow:auto;font-size:0.85rem">
{ r.clone() }
</pre>
</div>
})}
</div>
}
}
#[component]
fn ParticipantRow(participant: Participant) -> impl IntoView {
let outcome_class = match participant.outcome.as_deref() {
Some("win") => "outcome-win",
Some("loss") => "outcome-loss",
Some("draw") => "outcome-draw",
_ => "",
};
let outcome_text = participant.outcome.clone().unwrap_or_else(|| "".into());
let name = participant.username.clone();
view! {
<tr>
<td>"Player " { participant.player_id }</td>
<td>
{match name {
Some(u) => view! {
<A href=format!("/profile/{u}")>{ u }</A>
}.into_any(),
None => view! { <span style="color:#aaa">"anonymous"</span> }.into_any(),
}}
</td>
<td class=outcome_class>{ outcome_text }</td>
</tr>
}
}

View file

@ -0,0 +1,152 @@
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;
use crate::api;
use crate::app::AuthState;
#[component]
pub fn HomePage() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let navigate = use_navigate();
// Redirect to own profile when already logged in.
Effect::new(move |_| {
if let Some(u) = auth.user.get() {
navigate(&format!("/profile/{}", u.username), Default::default());
}
});
let tab = RwSignal::new("login");
view! {
<div class="card" style="max-width:420px;margin:3rem auto">
<div class="tabs">
<button
class=move || if tab.get() == "login" { "tab-btn active" } else { "tab-btn" }
on:click=move |_| tab.set("login")
>"Login"</button>
<button
class=move || if tab.get() == "register" { "tab-btn active" } else { "tab-btn" }
on:click=move |_| tab.set("register")
>"Register"</button>
</div>
{move || if tab.get() == "login" {
view! { <LoginForm /> }.into_any()
} else {
view! { <RegisterForm /> }.into_any()
}}
</div>
}
}
#[component]
fn LoginForm() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let navigate = use_navigate();
let username = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let u = username.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_login(&u, &p).await {
Ok(me) => {
let dest = format!("/profile/{}", me.username);
auth.user.set(Some(me));
navigate(&dest, Default::default());
}
Err(e) => {
error.set(e);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<label>"Username"</label>
<input type="text" required
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label>"Password"</label>
<input type="password" required
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<button type="submit" disabled=move || pending.get()>"Login"</button>
{move || if !error.get().is_empty() {
view! { <p class="error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}
}
#[component]
fn RegisterForm() -> impl IntoView {
let auth = use_context::<AuthState>().unwrap();
let navigate = use_navigate();
let username = RwSignal::new(String::new());
let email = RwSignal::new(String::new());
let password = RwSignal::new(String::new());
let error = RwSignal::new(String::new());
let pending = RwSignal::new(false);
let submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
if pending.get() { return; }
pending.set(true);
error.set(String::new());
let u = username.get();
let e = email.get();
let p = password.get();
let navigate = navigate.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::post_register(&u, &e, &p).await {
Ok(me) => {
let dest = format!("/profile/{}", me.username);
auth.user.set(Some(me));
navigate(&dest, Default::default());
}
Err(err) => {
error.set(err);
pending.set(false);
}
}
});
};
view! {
<form on:submit=submit>
<label>"Username"</label>
<input type="text" required
prop:value=move || username.get()
on:input=move |ev| username.set(event_target_value(&ev)) />
<label>"Email"</label>
<input type="email" required
prop:value=move || email.get()
on:input=move |ev| email.set(event_target_value(&ev)) />
<label>"Password"</label>
<input type="password" required
prop:value=move || password.get()
on:input=move |ev| password.set(event_target_value(&ev)) />
<button type="submit" disabled=move || pending.get()>"Register"</button>
{move || if !error.get().is_empty() {
view! { <p class="error">{ error.get() }</p> }.into_any()
} else {
view! { <span /> }.into_any()
}}
</form>
}
}

View file

@ -0,0 +1,3 @@
pub mod game;
pub mod home;
pub mod profile;

View file

@ -0,0 +1,137 @@
use leptos::prelude::*;
use leptos_router::{components::A, hooks::use_params_map};
use crate::api::{self, GameSummary, UserProfile};
#[component]
pub fn ProfilePage() -> impl IntoView {
let params = use_params_map();
let username = move || params.read().get("username").unwrap_or_default();
let profile = LocalResource::new(move || {
let u = username();
async move { api::get_user_profile(&u).await }
});
view! {
<div>
{move || match profile.get().map(|sw| sw.take()) {
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
Some(Ok(p)) => view! { <ProfileContent profile=p username=username() /> }.into_any(),
}}
</div>
}
}
#[component]
fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView {
let page = RwSignal::new(0i64);
let games = LocalResource::new(move || {
let u = username.clone();
let p = page.get();
async move { api::get_user_games(&u, p).await }
});
let joined = crate::api::format_ts(profile.created_at);
view! {
<h1>{ profile.username.clone() }</h1>
<p style="color:#777;margin-bottom:1.5rem">"Joined: " { joined }</p>
<div class="stats-grid">
<div class="stat-box">
<div class="value">{ profile.total_games }</div>
<div class="label">"Games"</div>
</div>
<div class="stat-box">
<div class="value outcome-win">{ profile.wins }</div>
<div class="label">"Wins"</div>
</div>
<div class="stat-box">
<div class="value outcome-loss">{ profile.losses }</div>
<div class="label">"Losses"</div>
</div>
<div class="stat-box">
<div class="value outcome-draw">{ profile.draws }</div>
<div class="label">"Draws"</div>
</div>
</div>
<div class="card">
<h2>"Game History"</h2>
{move || match games.get().map(|sw| sw.take()) {
None => view! { <p class="loading">"Loading…"</p> }.into_any(),
Some(Err(e)) => view! { <p class="error">{ e }</p> }.into_any(),
Some(Ok(r)) => {
if r.games.is_empty() {
view! { <p class="empty">"No games recorded yet."</p> }.into_any()
} else {
view! { <GamesTable games=r.games page=page /> }.into_any()
}
}
}}
</div>
}
}
#[component]
fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
let rows = games.clone();
let has_next = games.len() == 20;
view! {
<table>
<thead>
<tr>
<th>"Room"</th>
<th>"Started"</th>
<th>"Ended"</th>
<th>"Outcome"</th>
<th>"Detail"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|g| {
let started = crate::api::format_ts(g.started_at);
let ended = g.ended_at.map(crate::api::format_ts).unwrap_or_else(|| "".into());
let outcome_class = match g.outcome.as_deref() {
Some("win") => "outcome-win",
Some("loss") => "outcome-loss",
Some("draw") => "outcome-draw",
_ => "",
};
let outcome_text = g.outcome.clone().unwrap_or_else(|| "".into());
view! {
<tr>
<td>{ g.room_code.clone() }</td>
<td>{ started }</td>
<td>{ ended }</td>
<td class=outcome_class>{ outcome_text }</td>
<td>
<A href=format!("/games/{}", g.id)>"View"</A>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
<div style="display:flex;gap:0.75rem;margin-top:1rem;align-items:center">
{move || if page.get() > 0 {
view! {
<button class="btn" on:click=move |_| page.update(|p| *p -= 1)>"← Prev"</button>
}.into_any()
} else {
view! { <span /> }.into_any()
}}
<span style="color:#777">"Page " { move || page.get() + 1 }</span>
{if has_next {
view! {
<button class="btn" on:click=move |_| page.update(|p| *p += 1)>"Next →"</button>
}.into_any()
} else {
view! { <span /> }.into_any()
}}
</div>
}
}