diff --git a/Cargo.toml b/Cargo.toml index 72e3f08..8730e14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,20 @@ [workspace] resolver = "2" -members = ["client_cli", "bot", "store", "spiel_bot", "client_web"] +members = [ + "store", + "clients/cli", + "clients/backbone-lib", + "clients/web-game", + "clients/web-user-portal", + "server/protocol", + "server/relay-server", + "bot", + "spiel_bot", +] + +# For the server we will need opt-level='3' +[profile.release] +opt-level = 'z' # Minimum space +lto = "fat" # Aggressive Link Time Optimization +codegen-units = 1 diff --git a/README.md b/README.md index e74fb69..1145c33 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,20 @@ Training of AI bots is the work in progress. ## Usage -`cargo run --bin=client_cli -- --bot random` +Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. + +```bash +# Run the relay server +just build-relay +just run-relay # listens on :8080 + +# Run the game (separate terminal) +just dev-game +``` + +Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name. + +Playing with the cli against the 'random' bot: `cargo run --bin=client_cli -- --bot random` ## Roadmap @@ -23,19 +36,102 @@ Training of AI bots is the work in progress. ## Code structure - game rules and game state are implemented in the _store/_ folder. -- the command-line application is implemented in _client_cli/_; it allows you to play against a bot, or to have two bots play against each other +- the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other - the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. ### _store_ package The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented. -### _client_cli_ package +### _clients/cli_ package -`client_cli/src/game_runner.rs` contains the logic to make two bots play against each other. +`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other. ### _bot_ package - `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list. - `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (). - `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC). + +### multiplayer game + +Pagckages "clients/backbone-lib", "clients/web-game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). + +The system consists of: + +- A **relay server** (Axum/Tokio) that routes messages between players and manages rooms, without knowing anything about game rules. +- A **backbone library** that handles WebSocket connection, handshake, and message routing, exposing an async API to the game frontend. +- Game-specific **backend logic** implementing the `BackEndArchitecture` trait, which runs only on the hosting client. +- A **Leptos frontend** that connects to a session and reacts to state updates. + +There is no dedicated game server. One of the players acts as the host: their browser runs the game backend locally. The relay server only forwards messages — it never touches game state. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Host Client │ +│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │ +│ │ Leptos UI │◄──►│ GameSession │◄──►│ Backend │ │ +│ └─────────────┘ └────────┬─────────┘ └────────────┘ │ +└───────────────────────────── │ ────────────────────────────┘ + │ WebSocket + ┌──────▼──────┐ + │ Relay Server│ + └──────┬──────┘ + │ WebSocket +┌───────────────────────────────│────────────────────────────┐ +│ ┌─────────────┐ ┌─────────▼────────┐ │ +│ │ Leptos UI │◄──►│ GameSession │ (no backend) │ +│ └─────────────┘ └──────────────────┘ │ +│ Remote Client │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Data flow + +- **Actions** (e.g. "place stone at B3") flow from the UI to the host backend via `GameSession::send_action()`. +- **State updates** flow back as `ViewStateUpdate::Full` (full snapshot, on join or reset) or `ViewStateUpdate::Incremental` (delta, for animations). +- **Timers** are managed by the host's background task (wall-clock, no polling required from the game). + +#### backbone-lib session API + +The key design choice: `backbone-lib` owns a background async task per session. The Leptos app never drives a loop — it just awaits on events. + +#### Workspace + +**server/protocol** + +Shared message-type constants and the `JoinRequest` struct used during the WebSocket handshake. + +**server/relay-server** + +Listens on port 8080. Loads `GameConfig.json` on startup to know which games exist and their player limits: + +```json +[{ "name": "trictrac", "max_players": 10 }] +``` + +Games can be added at runtime via the `/reload` endpoint. `/enlist` lists active rooms. A watchdog cleans up inactive rooms every 20 minutes. + +For production, put it behind a reverse proxy with SSL (the browser requires `wss://` on HTTPS pages). Example Caddy config: + +``` +your-domain.com { + handle_path /api/* { + reverse_proxy localhost:8080 + } + file_server +} +``` + +**clients/backbone-lib** + +Modules: + +| Module | Purpose | +| ---------- | ---------------------------------------------------------------------------------------------------------- | +| `session` | `GameSession`, `connect()`, `SessionEvent`, `RoomConfig` | +| `host` | Background async task for the hosting client (drives `BackEndArchitecture`, manages timers) | +| `client` | Background async task for non-hosting clients | +| `protocol` | Wire encoding/decoding helpers (postcard + message-type bytes) | +| `platform` | `spawn_task` / `sleep_ms` abstractions (WASM: `spawn_local` + gloo-timers; native: thread + thread::sleep) | +| `traits` | `BackEndArchitecture`, `BackendCommand`, `ViewStateUpdate`, `SerializationCap` | diff --git a/clients/backbone-lib/Cargo.toml b/clients/backbone-lib/Cargo.toml new file mode 100644 index 0000000..1e57d93 --- /dev/null +++ b/clients/backbone-lib/Cargo.toml @@ -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"] } diff --git a/clients/backbone-lib/src/client.rs b/clients/backbone-lib/src/client.rs new file mode 100644 index 0000000..65c7fdb --- /dev/null +++ b/clients/backbone-lib/src/client.rs @@ -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( + mut ws_sender: WsSender, + ws_receiver: WsReceiver, + mut action_rx: UnboundedReceiver>, + event_tx: UnboundedSender>, +) 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::(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; + } +} diff --git a/clients/backbone-lib/src/host.rs b/clients/backbone-lib/src/host.rs new file mode 100644 index 0000000..c78e228 --- /dev/null +++ b/clients/backbone-lib/src/host.rs @@ -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( + mut ws_sender: WsSender, + ws_receiver: WsReceiver, + mut action_rx: UnboundedReceiver>, + event_tx: UnboundedSender>, + rule_variation: u16, + host_state: Option>, +) where + A: SerializationCap, + D: SerializationCap + Clone, + VS: SerializationCap + Clone, + Backend: BackEndArchitecture, +{ + 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 = Vec::new(); + let mut cancelled_timers: HashSet = 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::(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 = 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; + } +} diff --git a/clients/backbone-lib/src/lib.rs b/clients/backbone-lib/src/lib.rs new file mode 100644 index 0000000..d67a96c --- /dev/null +++ b/clients/backbone-lib/src/lib.rs @@ -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}; diff --git a/clients/backbone-lib/src/platform.rs b/clients/backbone-lib/src/platform.rs new file mode 100644 index 0000000..92f2414 --- /dev/null +++ b/clients/backbone-lib/src/platform.rs @@ -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(fut: F) +where + F: Future + 'static, +{ + wasm_bindgen_futures::spawn_local(fut); +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn_task(fut: F) +where + F: Future + 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 TaskBound for T {} + +#[cfg(not(target_arch = "wasm32"))] +pub trait TaskBound: Send + 'static {} +#[cfg(not(target_arch = "wasm32"))] +impl TaskBound for T {} diff --git a/clients/backbone-lib/src/protocol.rs b/clients/backbone-lib/src/protocol.rs new file mode 100644 index 0000000..65f972a --- /dev/null +++ b/clients/backbone-lib/src/protocol.rs @@ -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 { + 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(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(sender: &mut WsSender, deltas: &[D]) { + let serialized: Vec = 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(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(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) -> 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(data: Vec) -> ToServerCommand { + 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( + data: Vec, +) -> Result>, 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}")), + } +} diff --git a/clients/backbone-lib/src/session.rs b/clients/backbone-lib/src/session.rs new file mode 100644 index 0000000..24314f7 --- /dev/null +++ b/clients/backbone-lib/src/session.rs @@ -0,0 +1,266 @@ +//! The public-facing session API. +//! +//! # Usage +//! +//! ```ignore +//! // Connect (async, returns after handshake completes) +//! let mut session: GameSession = +//! GameSession::connect::(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, + /// 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>, +} + +/// 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 { + Action(A), + Disconnect, +} + +// --------------------------------------------------------------------------- +// Session event (background task → UI) +// --------------------------------------------------------------------------- + +/// Events emitted by the session to the UI. +pub enum SessionEvent { + /// A state update arrived from the host backend. + Update(ViewStateUpdate), + /// The session ended. `None` = clean disconnect, `Some(reason)` = error. + Disconnected(Option), +} + +// --------------------------------------------------------------------------- +// 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 { + /// 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>, + event_rx: UnboundedReceiver>, +} + +impl GameSession +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(config: RoomConfig) -> Result + where + Backend: BackEndArchitecture + 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::>(); + let (event_tx, event_rx) = mpsc::unbounded::>(); + + // 6. Spawn the background event loop. + if is_host { + spawn_task(host_loop::( + ws_sender, + ws_receiver, + action_rx, + event_tx, + rule_variation, + config.host_state, + )); + } else { + spawn_task(client_loop::( + 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> { + 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(); + } +} diff --git a/clients/backbone-lib/src/traits.rs b/clients/backbone-lib/src/traits.rs new file mode 100644 index 0000000..1ec50f7 --- /dev/null +++ b/clients/backbone-lib/src/traits.rs @@ -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 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 { + /// 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 +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 +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 + 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>; +} diff --git a/client_cli/Cargo.toml b/clients/cli/Cargo.toml similarity index 71% rename from client_cli/Cargo.toml rename to clients/cli/Cargo.toml index 52318cb..0149b1b 100644 --- a/client_cli/Cargo.toml +++ b/clients/cli/Cargo.toml @@ -13,9 +13,9 @@ 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" } +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" diff --git a/client_cli/src/app.rs b/clients/cli/src/app.rs similarity index 100% rename from client_cli/src/app.rs rename to clients/cli/src/app.rs diff --git a/client_cli/src/game_runner.rs b/clients/cli/src/game_runner.rs similarity index 100% rename from client_cli/src/game_runner.rs rename to clients/cli/src/game_runner.rs diff --git a/client_cli/src/main.rs b/clients/cli/src/main.rs similarity index 100% rename from client_cli/src/main.rs rename to clients/cli/src/main.rs diff --git a/client_web/Cargo.toml b/clients/web-game/Cargo.toml similarity index 90% rename from client_web/Cargo.toml rename to clients/web-game/Cargo.toml index 10d91b8..2103eda 100644 --- a/client_web/Cargo.toml +++ b/clients/web-game/Cargo.toml @@ -9,8 +9,8 @@ locales = ["en", "fr"] [dependencies] leptos_i18n = { version = "0.5", features = ["csr", "interpolate_display"] } -trictrac-store = { path = "../store" } -backbone-lib = { path = "../../forks/multiplayer/backbone-lib" } +trictrac-store = { path = "../../store" } +backbone-lib = { path = "../backbone-lib" } leptos = { version = "0.7", features = ["csr"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" diff --git a/client_web/Trunk.toml b/clients/web-game/Trunk.toml similarity index 100% rename from client_web/Trunk.toml rename to clients/web-game/Trunk.toml diff --git a/client_web/assets/diceroll.mp3 b/clients/web-game/assets/diceroll.mp3 similarity index 100% rename from client_web/assets/diceroll.mp3 rename to clients/web-game/assets/diceroll.mp3 diff --git a/client_web/assets/style.css b/clients/web-game/assets/style.css similarity index 100% rename from client_web/assets/style.css rename to clients/web-game/assets/style.css diff --git a/clients/web-game/dist/client_web-4248a2b78bb5a03.js b/clients/web-game/dist/client_web-4248a2b78bb5a03.js new file mode 100644 index 0000000..16381a5 --- /dev/null +++ b/clients/web-game/dist/client_web-4248a2b78bb5a03.js @@ -0,0 +1,1173 @@ +export class IntoUnderlyingByteSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, controller); + return ret; + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, controller); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +export class IntoUnderlyingSink { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} reason + * @returns {Promise} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, reason); + return ret; + } + /** + * @returns {Promise} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return ret; + } + /** + * @param {any} chunk + * @returns {Promise} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, chunk); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +export class IntoUnderlyingSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, controller); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_debug_string_ab4b34d23d6778bd: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_in_a5d8b22e52b24dd1: function(arg0, arg1) { + const ret = arg0 in arg1; + return ret; + }, + __wbg___wbindgen_is_falsy_c07bb72123e65555: function(arg0) { + const ret = !arg0; + return ret; + }, + __wbg___wbindgen_is_function_3baa9db1a987f47d: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_null_52ff4ec04186736f: function(arg0) { + const ret = arg0 === null; + return ret; + }, + __wbg___wbindgen_is_string_6df3bf7ef1164ed3: function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }, + __wbg___wbindgen_is_undefined_29a43b4d42920abd: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_string_get_7ed5322991caaec5: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_6b64449b9b9ed33c: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_b46c9b5a9f08ec37: function(arg0) { + arg0._wbg_cb_unref(); + }, + __wbg_addEventListener_79f868f51ae88579: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4); + }, arguments); }, + __wbg_addEventListener_8176dab41b09531c: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_add_0cfb2ab24caa9888: function() { return handleError(function (arg0, arg1, arg2) { + arg0.add(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_body_c7b35a55457167ba: function(arg0) { + const ret = arg0.body; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_buffer_d0f5ea0926a691fd: function(arg0) { + const ret = arg0.buffer; + return ret; + }, + __wbg_byobRequest_dc6aed9db01b12c6: function(arg0) { + const ret = arg0.byobRequest; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_byteLength_3e660e5661f3327e: function(arg0) { + const ret = arg0.byteLength; + return ret; + }, + __wbg_byteOffset_ecd62abe44dd28d4: function(arg0) { + const ret = arg0.byteOffset; + return ret; + }, + __wbg_call_a24592a6f349a97e: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); }, + __wbg_cancelBubble_56aa5b315d711482: function(arg0) { + const ret = arg0.cancelBubble; + return ret; + }, + __wbg_classList_a4e8d7553b666e6d: function(arg0) { + const ret = arg0.classList; + return ret; + }, + __wbg_clearTimeout_113b1cde814ec762: function(arg0) { + const ret = clearTimeout(arg0); + return ret; + }, + __wbg_clearTimeout_1a62f3563b1611b3: function(arg0, arg1) { + arg0.clearTimeout(arg1); + }, + __wbg_cloneNode_50658ff5fec44693: function() { return handleError(function (arg0, arg1) { + const ret = arg0.cloneNode(arg1 !== 0); + return ret; + }, arguments); }, + __wbg_cloneNode_eb01fe238729dac4: function() { return handleError(function (arg0) { + const ret = arg0.cloneNode(); + return ret; + }, arguments); }, + __wbg_close_0aa6756f298a2c2d: function(arg0) { + arg0.close(); + }, + __wbg_close_88106990eea7f544: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_close_e6c8977a002e9e13: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_close_fb954dfaf67b5732: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_composedPath_e2b9e0f5161335eb: function(arg0) { + const ret = arg0.composedPath(); + return ret; + }, + __wbg_connect_301bfaee317657e7: function() { return handleError(function (arg0, arg1) { + const ret = arg0.connect(arg1); + return ret; + }, arguments); }, + __wbg_content_13d0cb7e0ea91c39: function(arg0) { + const ret = arg0.content; + return ret; + }, + __wbg_cookie_d587a65145c1f3ba: function() { return handleError(function (arg0, arg1) { + const ret = arg1.cookie; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_createComment_592a0c17b1cf8cad: function(arg0, arg1, arg2) { + const ret = arg0.createComment(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_createElementNS_e0e4bbb6e664f948: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.createElementNS(arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + return ret; + }, arguments); }, + __wbg_createElement_bbd4c90086fe6f7b: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_createGain_9f1346a2369109e0: function() { return handleError(function (arg0) { + const ret = arg0.createGain(); + return ret; + }, arguments); }, + __wbg_createOscillator_d891cd791ce0b814: function() { return handleError(function (arg0) { + const ret = arg0.createOscillator(); + return ret; + }, arguments); }, + __wbg_createTextNode_7949043038fd9f7b: function(arg0, arg1, arg2) { + const ret = arg0.createTextNode(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_currentTime_8e9bfa251075a7d7: function(arg0) { + const ret = arg0.currentTime; + return ret; + }, + __wbg_data_bb9dffdd1e99cf2d: function(arg0) { + const ret = arg0.data; + return ret; + }, + __wbg_deleteProperty_d5f7bd763acbdb44: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.deleteProperty(arg0, arg1); + return ret; + }, arguments); }, + __wbg_destination_7aa167ec1225162d: function(arg0) { + const ret = arg0.destination; + return ret; + }, + __wbg_documentElement_08ce5ecd9e8b21e1: function(arg0) { + const ret = arg0.documentElement; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_document_7a41071f2f439323: function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_enqueue_4767ce322820c94d: function() { return handleError(function (arg0, arg1) { + arg0.enqueue(arg1); + }, arguments); }, + __wbg_error_2001591ad2463697: function(arg0) { + console.error(arg0); + }, + __wbg_exponentialRampToValueAtTime_00c2f1771a4804bd: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.exponentialRampToValueAtTime(arg1, arg2); + return ret; + }, arguments); }, + __wbg_firstChild_d4bf03999a23e79a: function(arg0) { + const ret = arg0.firstChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_firstElementChild_f67647a589d437a2: function(arg0) { + const ret = arg0.firstElementChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_frequency_4f71c695f55b6a54: function(arg0) { + const ret = arg0.frequency; + return ret; + }, + __wbg_gain_af09f4ecb5d66a4e: function(arg0) { + const ret = arg0.gain; + return ret; + }, + __wbg_getItem_7fe1351b9ea3b2f3: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg1.getItem(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_getRandomValues_3f44b700395062e5: function() { return handleError(function (arg0, arg1) { + globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); + }, arguments); }, + __wbg_get_6011fa3a58f61074: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_get_8360291721e2339f: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_head_77bab63b2165751c: function(arg0) { + const ret = arg0.head; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_host_207aa9237088c9e9: function(arg0) { + const ret = arg0.host; + return ret; + }, + __wbg_insertBefore_38c7d835a2dcac23: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.insertBefore(arg1, arg2); + return ret; + }, arguments); }, + __wbg_instanceof_ArrayBuffer_7c8433c6ed14ffe3: function(arg0) { + let result; + try { + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Blob_10148a11a16aee87: function(arg0) { + let result; + try { + result = arg0 instanceof Blob; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Element_56c8d987654f359e: function(arg0) { + let result; + try { + result = arg0 instanceof Element; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Error_6872d63ba7922898: function(arg0) { + let result; + try { + result = arg0 instanceof Error; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_ShadowRoot_d26d95cd2363a2c1: function(arg0) { + let result; + try { + result = arg0 instanceof ShadowRoot; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Window_cc64c86c8ef9e02b: function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_languages_53e97082086045ce: function(arg0) { + const ret = arg0.languages; + return ret; + }, + __wbg_length_9f1775224cf1d815: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_localStorage_f5f66b1ffd2486bc: function() { return handleError(function (arg0) { + const ret = arg0.localStorage; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_log_7e1aa9064a1dbdbd: function(arg0) { + console.log(arg0); + }, + __wbg_message_cb4f84ee66e5e341: function(arg0) { + const ret = arg0.message; + return ret; + }, + __wbg_name_d3c35622d14bb080: function(arg0) { + const ret = arg0.name; + return ret; + }, + __wbg_navigator_bc077756492232c5: function(arg0) { + const ret = arg0.navigator; + return ret; + }, + __wbg_new_0a8d011ad814b95a: function() { return handleError(function () { + const ret = new FileReader(); + return ret; + }, arguments); }, + __wbg_new_0c7403db6e782f19: function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }, + __wbg_new_2a6e9133304ae2bf: function() { return handleError(function (arg0, arg1) { + const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_5e360d2ff7b9e1c3: function(arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_new_aa8d0fa9762c29bd: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_aadb2b3f13e701cf: function() { return handleError(function (arg0, arg1) { + const ret = new BroadcastChannel(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_df431d05bd05ed26: function() { return handleError(function () { + const ret = new lAudioContext(); + return ret; + }, arguments); }, + __wbg_new_typed_323f37fd55ab048d: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h42c7f62e44be4353(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_new_with_byte_offset_and_length_01848e8d6a3d49ad: function(arg0, arg1, arg2) { + const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_nextSibling_58f635df24be0787: function(arg0) { + const ret = arg0.nextSibling; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_nodeType_1e98f026e15a17e5: function(arg0) { + const ret = arg0.nodeType; + return ret; + }, + __wbg_now_a9b7df1cbee90986: function() { + const ret = Date.now(); + return ret; + }, + __wbg_now_e7c6795a7f81e10f: function(arg0) { + const ret = arg0.now(); + return ret; + }, + __wbg_parentNode_e94744054a57a837: function(arg0) { + const ret = arg0.parentNode; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_performance_3fcf6e32a7e1ed0a: function(arg0) { + const ret = arg0.performance; + return ret; + }, + __wbg_postMessage_f9ee88e3c733baf9: function() { return handleError(function (arg0, arg1) { + arg0.postMessage(arg1); + }, arguments); }, + __wbg_preventDefault_f55c01cb5fd2bcc0: function(arg0) { + arg0.preventDefault(); + }, + __wbg_prototypesetcall_a6b02eb00b0f4ce2: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_queueMicrotask_5d15a957e6aa920e: function(arg0) { + queueMicrotask(arg0); + }, + __wbg_queueMicrotask_f8819e5ffc402f36: function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }, + __wbg_readAsArrayBuffer_7f1359e61bc15108: function() { return handleError(function (arg0, arg1) { + arg0.readAsArrayBuffer(arg1); + }, arguments); }, + __wbg_removeAttribute_c75ac657c944b3f1: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeAttribute(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_removeEventListener_61405fc9de7dfd6b: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4); + }, arguments); }, + __wbg_removeEventListener_7bdf07404d9b24bd: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_removeItem_487c385a3066a8ed: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeItem(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_remove_48cb93cf7a6c4260: function(arg0) { + arg0.remove(); + }, + __wbg_remove_8aa602fc502f0448: function() { return handleError(function (arg0, arg1, arg2) { + arg0.remove(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_remove_9ffcfa2a5664fa43: function(arg0) { + arg0.remove(); + }, + __wbg_resolve_e6c466bc1052f16c: function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }, + __wbg_respond_008ca9525ae22847: function() { return handleError(function (arg0, arg1) { + arg0.respond(arg1 >>> 0); + }, arguments); }, + __wbg_result_cadfbcadd3b04647: function() { return handleError(function (arg0) { + const ret = arg0.result; + return ret; + }, arguments); }, + __wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) { + try { + var state0 = {a: arg1, b: arg2}; + var cb0 = () => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__hf0f977607302985a(a, state0.b, ); + } finally { + state0.a = a; + } + }; + const ret = arg0.run(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_send_15358dbe221c6258: function() { return handleError(function (arg0, arg1, arg2) { + arg0.send(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_send_186c85704c7f2d00: function() { return handleError(function (arg0, arg1, arg2) { + arg0.send(getArrayU8FromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_setAttribute_6fde4098d274155c: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_setItem_e6399d3faae141dc: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setItem(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_setTimeout_d8786dd31f90da0f: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.setTimeout(arg1, arg2); + return ret; + }, arguments); }, + __wbg_setTimeout_ef24d2fc3ad97385: function() { return handleError(function (arg0, arg1) { + const ret = setTimeout(arg0, arg1); + return ret; + }, arguments); }, + __wbg_setValueAtTime_f2282afd259bb493: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.setValueAtTime(arg1, arg2); + return ret; + }, arguments); }, + __wbg_set_022bee52d0b05b19: function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(arg0, arg1, arg2); + return ret; + }, arguments); }, + __wbg_set_3d484eb794afec82: function(arg0, arg1, arg2) { + arg0.set(getArrayU8FromWasm0(arg1, arg2)); + }, + __wbg_set_binaryType_770e68648ca5e83d: function(arg0, arg1) { + arg0.binaryType = __wbindgen_enum_BinaryType[arg1]; + }, + __wbg_set_capture_6a782955ea62ac61: function(arg0, arg1) { + arg0.capture = arg1 !== 0; + }, + __wbg_set_cookie_b230bb282b0c6f43: function() { return handleError(function (arg0, arg1, arg2) { + arg0.cookie = getStringFromWasm0(arg1, arg2); + }, arguments); }, + __wbg_set_innerHTML_a3c82996073b31ea: function(arg0, arg1, arg2) { + arg0.innerHTML = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_nodeValue_f39ed00fc286b285: function(arg0, arg1, arg2) { + arg0.nodeValue = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }, + __wbg_set_once_e747a93482f65a72: function(arg0, arg1) { + arg0.once = arg1 !== 0; + }, + __wbg_set_onclose_17fa3bbcc4ba3541: function(arg0, arg1) { + arg0.onclose = arg1; + }, + __wbg_set_onerror_da99c4232662a084: function(arg0, arg1) { + arg0.onerror = arg1; + }, + __wbg_set_onloadend_353e1e891cf72c27: function(arg0, arg1) { + arg0.onloadend = arg1; + }, + __wbg_set_onmessage_c1db358b9c38e3f1: function(arg0, arg1) { + arg0.onmessage = arg1; + }, + __wbg_set_onopen_cd47b8fb1d92dee9: function(arg0, arg1) { + arg0.onopen = arg1; + }, + __wbg_set_passive_69f5c7d4e21e69c3: function(arg0, arg1) { + arg0.passive = arg1 !== 0; + }, + __wbg_set_type_e10300c35573ac85: function(arg0, arg1) { + arg0.type = __wbindgen_enum_OscillatorType[arg1]; + }, + __wbg_set_value_701931da23e8bae7: function(arg0, arg1) { + arg0.value = arg1; + }, + __wbg_slice_5fffd132e3ff5262: function(arg0, arg1) { + const ret = arg1.slice(); + const ptr1 = passArrayJsValueToWasm0(ret, wasm.__wbindgen_malloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_start_ccf4b52f03e06e0a: function() { return handleError(function (arg0, arg1) { + arg0.start(arg1); + }, arguments); }, + __wbg_static_accessor_CREATE_TASK_f3ab6a6954bda493: function() { + const ret = typeof console === 'undefined' ? null : console?.createTask; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_THIS_602256ae5c8f42cf: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_e445c1c7484aecc3: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_f20e8576ef1e0f17: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_stopPropagation_e088fca8231e68c4: function(arg0) { + arg0.stopPropagation(); + }, + __wbg_stop_b0230bbe32583fd2: function() { return handleError(function (arg0, arg1) { + arg0.stop(arg1); + }, arguments); }, + __wbg_target_6d97e221d11b71b6: function(arg0) { + const ret = arg0.target; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_textContent_1f28330a124ec047: function(arg0, arg1) { + const ret = arg1.textContent; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_then_8e16ee11f05e4827: function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }, + __wbg_toString_6dc1a94e0bdba378: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_value_6079dd28568d83c9: function(arg0, arg1) { + const ret = arg1.value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_view_701664ffb3b1ce67: function(arg0) { + const ret = arg0.view; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_warn_3cc416af27dbdc02: function(arg0) { + console.warn(arg0); + }, + __wbg_warn_bd0f407277b102f4: function(arg0, arg1, arg2) { + console.warn(arg0, arg1, arg2); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1089, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h631ca3ab15db92ac); + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1146, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__heaa42c2d614740c5); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 987, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h6c85d08fbcdf5b5a); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("ErrorEvent")], shim_idx: 985, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h8604f84d7f7e1085); + return ret; + }, + __wbindgen_cast_0000000000000005: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1090, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdb75aae9aec73406); + return ret; + }, + __wbindgen_cast_0000000000000006: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 988, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0ba230b615670f92); + return ret; + }, + __wbindgen_cast_0000000000000007: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("ProgressEvent")], shim_idx: 986, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h662b7d6967267d18); + return ret; + }, + __wbindgen_cast_0000000000000008: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1024, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3420984fa2fe04e3); + return ret; + }, + __wbindgen_cast_0000000000000009: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 965, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd6fed3fae7865fee); + return ret; + }, + __wbindgen_cast_000000000000000a: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./client_web_bg.js": import0, + }; +} + +const lAudioContext = (typeof AudioContext !== 'undefined' ? AudioContext : (typeof webkitAudioContext !== 'undefined' ? webkitAudioContext : undefined)); +function wasm_bindgen__convert__closures_____invoke__h3420984fa2fe04e3(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h3420984fa2fe04e3(arg0, arg1); +} + +function wasm_bindgen__convert__closures_____invoke__hd6fed3fae7865fee(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__hd6fed3fae7865fee(arg0, arg1); +} + +function wasm_bindgen__convert__closures_____invoke__hf0f977607302985a(arg0, arg1) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf0f977607302985a(arg0, arg1); + return ret !== 0; +} + +function wasm_bindgen__convert__closures_____invoke__h631ca3ab15db92ac(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h631ca3ab15db92ac(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h6c85d08fbcdf5b5a(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h6c85d08fbcdf5b5a(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h8604f84d7f7e1085(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h8604f84d7f7e1085(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__hdb75aae9aec73406(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__hdb75aae9aec73406(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h0ba230b615670f92(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h0ba230b615670f92(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h662b7d6967267d18(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h662b7d6967267d18(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__heaa42c2d614740c5(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__heaa42c2d614740c5(arg0, arg1, arg2); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + +function wasm_bindgen__convert__closures_____invoke__h42c7f62e44be4353(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h42c7f62e44be4353(arg0, arg1, arg2, arg3); +} + + +const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; + + +const __wbindgen_enum_OscillatorType = ["sine", "square", "sawtooth", "triangle", "custom"]; + + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 1)); +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr >>> 0, 1)); +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr >>> 0, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_destroy_closure(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passArrayJsValueToWasm0(array, malloc) { + const ptr = malloc(array.length * 4, 4) >>> 0; + for (let i = 0; i < array.length; i++) { + const add = addToExternrefTable0(array[i]); + getDataViewMemory0().setUint32(ptr + 4 * i, add, true); + } + WASM_VECTOR_LEN = array.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('client_web_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/clients/web-game/dist/client_web-4248a2b78bb5a03_bg.wasm b/clients/web-game/dist/client_web-4248a2b78bb5a03_bg.wasm new file mode 100644 index 0000000..e3117d4 Binary files /dev/null and b/clients/web-game/dist/client_web-4248a2b78bb5a03_bg.wasm differ diff --git a/clients/web-game/dist/index.html b/clients/web-game/dist/index.html new file mode 100644 index 0000000..19b4ed4 --- /dev/null +++ b/clients/web-game/dist/index.html @@ -0,0 +1,151 @@ + + + + + + Trictrac + + + + + + diff --git a/clients/web-game/dist/style-398501cc5e039e60.css b/clients/web-game/dist/style-398501cc5e039e60.css new file mode 100644 index 0000000..3691894 --- /dev/null +++ b/clients/web-game/dist/style-398501cc5e039e60.css @@ -0,0 +1,1133 @@ +/* ── Google Fonts ───────────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + justify-content: center; + padding: 1.5rem; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + width: 340px; + margin-top: 5vh; + border-radius: 8px; + overflow: hidden; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + background: var(--ui-parchment); +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + /* Alternating burgundy/ivory triangles pointing down from the top */ + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + /* Clip into downward-pointing triangles */ + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +/* No width: 100% — let it size to content (the board wrapper, ~832px). + This keeps the board pinned at the same horizontal position whether or + not the side panel is visible, and aligns the status bar / score panels + with the board rather than with the viewport edge. */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +/* Horizontal banner: name on the left, score bars expanding to fill the + board width — no more empty right half on large screens. */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; +} + +/* Bars sit side-by-side (points | holes) filling remaining width */ +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +/* .board-and-panel is sized to the board wrapper only; the side panel is + positioned absolutely so it floats to the right without pushing the + board and breaking its horizontal alignment. */ +.board-and-panel { + position: relative; +} + +/* The side panel is anchored to the board's RIGHT edge. Scoring panel + wrappers inside it initially overlap the board; they slide to a peek + strip after a few seconds, and reveal fully on hover. */ +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; /* pass board clicks through the empty area */ +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ + +/* §5a — vigorous tumble: die bounces in from a random rotation */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +/* Bar die slot — centered in the board bar */ +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +/* Double glow (§5c) */ +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +/* Final score ledger */ +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ + +/* ── Wrapper: handles slide-in → peek → reveal lifecycle ────────────── + The wrapper starts off-screen right (translateX(100%)), slides in on + mount via animation, then Leptos adds .peeked after 3.4s to slide it + back to a 28px peek strip. */ +@keyframes scoring-panel-enter { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.scoring-panel-wrapper { + /* width: 290px; */ + pointer-events: auto; + animation: scoring-panel-enter 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94); + transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + filter: drop-shadow(-4px 0 14px rgba(0,0,0,0.38)); +} + +/* Peeked: slide right by the full panel width so the board is 100% clear. + The panel's left portion stays visible in whatever free space exists to + the right of the board. */ +.scoring-panel-wrapper.peeked { + transform: translateX(100%); +} + +/* Click on the visible left strip → .revealed slides it back over the board. + A second click removes .revealed and returns to the peeked position. */ +.scoring-panel-wrapper.revealed { + transform: translateX(0); +} + +/* Pointer cursor on the peeked (clickable) strip */ +.scoring-panel-wrapper.peeked:not(.revealed) { + cursor: pointer; +} + +/* ── Inner panel card ─────────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +/* ── Large-screen layout: panel in free space, no peek needed ───────── + Threshold: board (832) + body-padding (48) + panel-gap (16) + panel (290) + + symmetric left margin = 1492 px. + At this width the panel fits entirely to the right of the board. */ +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); /* outside board, no overlap */ + } + /* Already fully visible in free space — peeked/revealed are no-ops. */ + .scoring-panel-wrapper.peeked, + .scoring-panel-wrapper.revealed { + transform: none; + cursor: default; + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) — aligned with board quarters ───────────────── */ +/* Board border(4) + padding(4) = 8px inset each side */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +/* + * Each field is a transparent rectangle over the felt. + * The triangular flèche is drawn by ::before using clip-path. + * --fc controls the triangle colour; z-index:-1 keeps the triangle + * behind checkers; isolation:isolate confines the negative z-index. + */ +.field { + --fc: var(--field-ivory); /* default triangle colour */ + width: 60px; + height: 180px; + background: transparent; /* felt shows through between triangle tips */ + isolation: isolate; /* stacking context for z-index:-1 ::before */ + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +/* Bot-row triangle: wide base at bottom, tip at top */ +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; /* behind checkers & corner crown */ + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +/* Top-row triangle: wide base at top, tip at bottom */ +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours (§2b) ────────────────────────────────── */ +/* petit-jan and grand-jan: burgundy / ivory */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +/* Opponent's grand-jan — deep slate-blue / silvery-green ivory. + Previously #1e3d32 was nearly identical to the felt (#1d3d28); now using + a clearly distinguishable cool blue that reads well against the green. */ +.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; } +.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; } + +/* Jan de retour — warmer: amber-brown / warm amber ivory */ +.board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; } +.board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; } + +/* ── Rest corner — before .clickable so green wins when interactive ── */ +/* .field.corner { --fc: var(--field-corner) !important; } */ + +/* Crown glyph sits behind checkers (z-index:-1) so it shows only on empty corners */ +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +/* Corner pulse (§8d) — filter respects the triangle shape */ +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +/* ── Exit-eligible highlight (§8c) — filter glow on triangle ───────── */ +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── §6c — Jan hover field highlight ────────────────────────────────── */ +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */ +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +/* ── Interactive states — after .corner to take visual priority ─────── */ +.field.clickable { + cursor: pointer; + --fc: #8fc840 !important; +} +.field.clickable:hover { --fc: #74aa28 !important; } +.field.selected { + --fc: #5a8a18 !important; + outline: 2px solid rgba(255,255,255,0.3); + outline-offset: -2px; +} + +/* ── Field numbers ──────────────────────────────────────────────────── */ +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } + +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +/* ── Bredouille toast variant (§6d) — gold shimmer, larger entrance ─── */ +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { + font-size: 3.75rem; +} +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── §4a — Checker slide animation ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +/* Only the arriving (outermost) checker animates; --slide-dx/dy are set + as inline styles on that element at render time, so no flash occurs. */ +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +/* Lift the field that owns an arriving checker above its siblings so the + checker doesn't slide under adjacent fields (isolation:isolate traps + z-index within each field's stacking context). */ +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + min-height: 2rem; /* reserve height so layout doesn't shift when buttons appear */ +} diff --git a/client_web/index.html b/clients/web-game/index.html similarity index 100% rename from client_web/index.html rename to clients/web-game/index.html diff --git a/client_web/locales/en.json b/clients/web-game/locales/en.json similarity index 100% rename from client_web/locales/en.json rename to clients/web-game/locales/en.json diff --git a/client_web/locales/fr.json b/clients/web-game/locales/fr.json similarity index 100% rename from client_web/locales/fr.json rename to clients/web-game/locales/fr.json diff --git a/client_web/src/app.rs b/clients/web-game/src/app.rs similarity index 100% rename from client_web/src/app.rs rename to clients/web-game/src/app.rs diff --git a/client_web/src/components/board.rs b/clients/web-game/src/components/board.rs similarity index 100% rename from client_web/src/components/board.rs rename to clients/web-game/src/components/board.rs diff --git a/client_web/src/components/connecting_screen.rs b/clients/web-game/src/components/connecting_screen.rs similarity index 100% rename from client_web/src/components/connecting_screen.rs rename to clients/web-game/src/components/connecting_screen.rs diff --git a/client_web/src/components/die.rs b/clients/web-game/src/components/die.rs similarity index 100% rename from client_web/src/components/die.rs rename to clients/web-game/src/components/die.rs diff --git a/client_web/src/components/game_screen.rs b/clients/web-game/src/components/game_screen.rs similarity index 100% rename from client_web/src/components/game_screen.rs rename to clients/web-game/src/components/game_screen.rs diff --git a/client_web/src/components/login_screen.rs b/clients/web-game/src/components/login_screen.rs similarity index 100% rename from client_web/src/components/login_screen.rs rename to clients/web-game/src/components/login_screen.rs diff --git a/client_web/src/components/mod.rs b/clients/web-game/src/components/mod.rs similarity index 100% rename from client_web/src/components/mod.rs rename to clients/web-game/src/components/mod.rs diff --git a/client_web/src/components/score_panel.rs b/clients/web-game/src/components/score_panel.rs similarity index 100% rename from client_web/src/components/score_panel.rs rename to clients/web-game/src/components/score_panel.rs diff --git a/client_web/src/components/scoring.rs b/clients/web-game/src/components/scoring.rs similarity index 100% rename from client_web/src/components/scoring.rs rename to clients/web-game/src/components/scoring.rs diff --git a/client_web/src/main.rs b/clients/web-game/src/main.rs similarity index 100% rename from client_web/src/main.rs rename to clients/web-game/src/main.rs diff --git a/client_web/src/sound.rs b/clients/web-game/src/sound.rs similarity index 100% rename from client_web/src/sound.rs rename to clients/web-game/src/sound.rs diff --git a/client_web/src/trictrac/backend.rs b/clients/web-game/src/trictrac/backend.rs similarity index 100% rename from client_web/src/trictrac/backend.rs rename to clients/web-game/src/trictrac/backend.rs diff --git a/client_web/src/trictrac/bot_local.rs b/clients/web-game/src/trictrac/bot_local.rs similarity index 100% rename from client_web/src/trictrac/bot_local.rs rename to clients/web-game/src/trictrac/bot_local.rs diff --git a/client_web/src/trictrac/mod.rs b/clients/web-game/src/trictrac/mod.rs similarity index 100% rename from client_web/src/trictrac/mod.rs rename to clients/web-game/src/trictrac/mod.rs diff --git a/client_web/src/trictrac/types.rs b/clients/web-game/src/trictrac/types.rs similarity index 100% rename from client_web/src/trictrac/types.rs rename to clients/web-game/src/trictrac/types.rs diff --git a/clients/web-user-portal/Cargo.toml b/clients/web-user-portal/Cargo.toml new file mode 100644 index 0000000..6afa767 --- /dev/null +++ b/clients/web-user-portal/Cargo.toml @@ -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"] } diff --git a/clients/web-user-portal/Trunk.toml b/clients/web-user-portal/Trunk.toml new file mode 100644 index 0000000..57a2aaa --- /dev/null +++ b/clients/web-user-portal/Trunk.toml @@ -0,0 +1,2 @@ +[serve] +port = 9092 diff --git a/clients/web-user-portal/assets/style.css b/clients/web-user-portal/assets/style.css new file mode 100644 index 0000000..3e7462a --- /dev/null +++ b/clients/web-user-portal/assets/style.css @@ -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; } diff --git a/clients/web-user-portal/index.html b/clients/web-user-portal/index.html new file mode 100644 index 0000000..135091c --- /dev/null +++ b/clients/web-user-portal/index.html @@ -0,0 +1,11 @@ + + + + + + Player Portal + + + + + diff --git a/clients/web-user-portal/src/api.rs b/clients/web-user-portal/src/api.rs new file mode 100644 index 0000000..b6dced9 --- /dev/null +++ b/clients/web-user-portal/src/api.rs @@ -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, + pub result: Option, + pub outcome: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GamesResponse { + pub games: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Participant { + pub player_id: i64, + pub outcome: Option, + pub username: Option, +} + +#[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, + pub result: Option, + pub participants: Vec, +} + +// ── 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 { + 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::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn post_login(username: &str, password: &str) -> Result { + 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::().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 { + 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::().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 { + 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::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_user_games(username: &str, page: i64) -> Result { + 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::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + +pub async fn get_game_detail(id: i64) -> Result { + 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::().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() +} diff --git a/clients/web-user-portal/src/app.rs b/clients/web-user-portal/src/app.rs new file mode 100644 index 0000000..92a121a --- /dev/null +++ b/clients/web-user-portal/src/app.rs @@ -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>, +} + +#[component] +pub fn App() -> impl IntoView { + let user = RwSignal::new(None::); + provide_context(AuthState { user }); + + // Probe session on load. + let auth = use_context::().unwrap(); + let _ = LocalResource::new(move || async move { + if let Ok(me) = api::get_me().await { + auth.user.set(Some(me)); + } + }); + + view! { + +