diff --git a/Cargo.lock b/Cargo.lock index de6765c..8992cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.11" +version = "0.2.12" dependencies = [ "bytes", "ewebsock", @@ -2649,7 +2649,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", ] @@ -2883,7 +2883,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.11" +version = "0.2.12" dependencies = [ "argon2", "axum", @@ -3893,7 +3893,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "base64 0.21.7", @@ -3906,7 +3906,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.11" +version = "0.2.12" dependencies = [ "backbone-lib", "futures", diff --git a/README.md b/README.md index ca4c0de..f9485c7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation. -The project is still on its early stages. - ## Usage Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. @@ -17,118 +15,18 @@ just run-relay # listens on :8080 just dev ``` -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 - -- [x] rules -- [x] command line interface -- [x] basic bot (random play) -- [ ] web client (in progress) -- [ ] network game (in progress) -- [ ] AI bot +Open a browser window at `http://127.0.0.1:9091`. You can play against a very basic bot, or invite an other player to connect at the same address. ## Code structure - game rules and game state are implemented in the _store/_ folder. +- a server for the network game is implemented in _server/relay-server_, which uses _server/protocol_ +- the web client is in _clients/web_, it connects to the server using the _clients/backbone-lib_ library - 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. +- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. This is a work in progress, they are not performant at all. -### _store_ package +## Inspirations -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. +The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_, _server/relay-server_ is 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/). -### _clients/cli_ package - -`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 - -Packages "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` | +The web client UX/UI is inspired by https://playtiao.com. diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 6cfaa54..2749d9c 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -29,6 +29,12 @@ use trictrac_store::CheckerMove; use std::collections::VecDeque; +/// Newtype wrappers so context lookup can distinguish signals of the same inner type. +#[derive(Clone, Copy)] +pub(crate) struct AnonNickname(pub RwSignal>); +#[derive(Clone, Copy)] +pub(crate) struct AuthEmailVerified(pub RwSignal); + fn relay_url() -> String { #[cfg(debug_assertions)] { @@ -170,14 +176,14 @@ pub fn App() -> impl IntoView { let auth_username: RwSignal> = RwSignal::new(None); let auth_email_verified: RwSignal = RwSignal::new(false); provide_context(auth_username); - provide_context(auth_email_verified); + provide_context(AuthEmailVerified(auth_email_verified)); // Set to true once get_me resolves (success or failure) so lobby can // decide immediately whether to show the nickname modal. let auth_loaded: RwSignal = RwSignal::new(false); provide_context(auth_loaded); // Nickname chosen by an anonymous player; used instead of "Anonymous". let anon_nickname: RwSignal> = RwSignal::new(None); - provide_context(anon_nickname); + provide_context(AnonNickname(anon_nickname)); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs index 842338d..c986f38 100644 --- a/clients/web/src/portal/account.rs +++ b/clients/web/src/portal/account.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_navigate; use crate::api; +use crate::app::AuthEmailVerified; use crate::i18n::*; #[component] @@ -9,8 +10,8 @@ pub fn AccountPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let navigate = use_navigate(); // Only redirect to profile when the email is actually verified. @@ -107,8 +108,8 @@ fn LoginForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let navigate = use_navigate(); let login = RwSignal::new(String::new()); @@ -177,8 +178,8 @@ fn RegisterForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let username = RwSignal::new(String::new()); let email = RwSignal::new(String::new()); diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index 686dac7..3dcde7e 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; use leptos_router::components::A; use leptos_router::hooks::use_query_map; -use crate::app::{NetCommand, Screen}; +use crate::app::{AnonNickname, NetCommand, Screen}; use crate::i18n::*; // ── Room/nickname generation ────────────────────────────────────────────────── @@ -103,7 +103,7 @@ pub fn LobbyPage() -> impl IntoView { let cmd_tx = use_context::>().expect("NetCommand sender"); let auth_username = use_context::>>().expect("auth_username context"); let auth_loaded = use_context::>().expect("auth_loaded context"); - let anon_nickname = use_context::>>().expect("anon_nickname context"); + let anon_nickname = use_context::().expect("anon_nickname context").0; let query = use_query_map(); let view_state: RwSignal = RwSignal::new(LobbyView::Idle); diff --git a/clients/web/src/portal/verify_email.rs b/clients/web/src/portal/verify_email.rs index 0ce0cae..03736e2 100644 --- a/clients/web/src/portal/verify_email.rs +++ b/clients/web/src/portal/verify_email.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_query_map; use crate::api; +use crate::app::AuthEmailVerified; use crate::i18n::*; #[derive(Clone, PartialEq)] @@ -16,8 +17,8 @@ pub fn VerifyEmailPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let query = use_query_map(); let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default());