diff --git a/Cargo.lock b/Cargo.lock index 8992cbe..de6765c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.12" +version = "0.2.11" dependencies = [ "bytes", "ewebsock", @@ -2649,7 +2649,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.12" +version = "0.2.11" dependencies = [ "serde", ] @@ -2883,7 +2883,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.12" +version = "0.2.11" dependencies = [ "argon2", "axum", @@ -3893,7 +3893,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.12" +version = "0.2.11" dependencies = [ "anyhow", "base64 0.21.7", @@ -3906,7 +3906,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.12" +version = "0.2.11" dependencies = [ "backbone-lib", "futures", diff --git a/README.md b/README.md index f9485c7..ca4c0de 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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. @@ -15,18 +17,118 @@ just run-relay # listens on :8080 just dev ``` -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. +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 ## 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. This is a work in progress, they are not performant at all. +- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. -## Inspirations +### _store_ package -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/). +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 web client UX/UI is inspired by https://playtiao.com. +### _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` | diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index dcc1b7b..09b21e9 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -2045,7 +2045,6 @@ a:hover { text-decoration: underline; } text-decoration: none; opacity: 0.8; transition: opacity 0.15s; - cursor: pointer; } .game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } @@ -2079,13 +2078,13 @@ a:hover { text-decoration: underline; } } /* Push the version wrapper to the bottom of the sidebar flex column */ -.sidebar-footer { +.game-sidebar > div:has(.site-nav-version) { margin-top: auto; + padding: 0.75rem 1rem; border-top: 1px solid rgba(200,164,72,0.12); } .site-nav-version { - margin: 2em 0 1em; display: block; text-align: center; font-family: var(--font-ui); diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index d429838..569d66b 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -130,12 +130,14 @@ "copy_link": "Copier le lien", "link_copied": "Copié !", "scan_qr": "ou scannez le QR code", + "join_code_label": "Rejoindre avec un code", + "join_code_placeholder": "Code de la salle", "share_btn": "Partager", "nickname_modal_title": "Choisissez votre pseudo", "nickname_modal_hint": "Vous jouerez sous le nom de :", "nickname_modal_play": "Jouer", "nickname_modal_or": "ou", - "nickname_modal_sign_in": "connectez-vous", + "nickname_modal_sign_in": "Se connecter", "nickname_modal_register": "Créer un compte", "new_game": "Nouvelle partie", "language": "Langue" diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 9288be3..5c38d33 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -29,26 +29,7 @@ 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)] - { - "ws://localhost:8080/ws".to_string() - } - #[cfg(not(debug_assertions))] - { - let location = web_sys::window().and_then(|w| Some(w.location())).unwrap(); - let protocol = location.protocol().unwrap_or_default(); - let host = location.host().unwrap_or_default(); - let ws_protocol = if protocol == "https:" { "wss" } else { "ws" }; - format!("{ws_protocol}://{host}/ws") - } -} +const RELAY_URL: &str = "ws://localhost:8080/ws"; const GAME_ID: &str = "trictrac"; const STORAGE_KEY: &str = "trictrac_session"; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -174,14 +155,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(AuthEmailVerified(auth_email_verified)); + provide_context(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(AnonNickname(anon_nickname)); + provide_context(anon_nickname); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); @@ -224,7 +205,7 @@ pub fn App() -> impl IntoView { Some(NetCommand::CreateRoom { room }) => { break Some(( RoomConfig { - relay_url: relay_url(), + relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, @@ -238,7 +219,7 @@ pub fn App() -> impl IntoView { Some(NetCommand::JoinRoom { room }) => { break Some(( RoomConfig { - relay_url: relay_url(), + relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, @@ -323,7 +304,7 @@ pub fn App() -> impl IntoView { if !session.is_host { save_session(&StoredSession { - relay_url: relay_url(), + relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: session.reconnect_token, @@ -377,7 +358,7 @@ pub fn App() -> impl IntoView { if is_host { save_session(&StoredSession { - relay_url: relay_url(), + relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: reconnect_token, @@ -563,60 +544,47 @@ fn SiteHamburger() -> impl IntoView { // Auth +
+ + + + {move || match auth_username.get() { Some(u) => { let href = format!("/profile/{u}"); view! { -
- - - - {u} -
- + }>{t!(i18n, sign_out)} }.into_any() }, None => view! { - }.into_any(), }} +
- // ── Replay snapshot modal ───────────────────────────────────────────── diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs index c986f38..842338d 100644 --- a/clients/web/src/portal/account.rs +++ b/clients/web/src/portal/account.rs @@ -2,7 +2,6 @@ use leptos::prelude::*; use leptos_router::hooks::use_navigate; use crate::api; -use crate::app::AuthEmailVerified; use crate::i18n::*; #[component] @@ -10,8 +9,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").0; + let auth_email_verified = + use_context::>().expect("auth_email_verified context not found"); let navigate = use_navigate(); // Only redirect to profile when the email is actually verified. @@ -108,8 +107,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").0; + let auth_email_verified = + use_context::>().expect("auth_email_verified context not found"); let navigate = use_navigate(); let login = RwSignal::new(String::new()); @@ -178,8 +177,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").0; + let auth_email_verified = + use_context::>().expect("auth_email_verified context not found"); 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 3dcde7e..c3dbf24 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::{AnonNickname, NetCommand, Screen}; +use crate::app::{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").0; + let anon_nickname = use_context::>>().expect("anon_nickname context"); let query = use_query_map(); let view_state: RwSignal = RwSignal::new(LobbyView::Idle); @@ -195,9 +195,12 @@ fn IdleCard( pending_action: RwSignal>, ) -> impl IntoView { let i18n = use_i18n(); + let join_open = RwSignal::new(false); + let join_code = RwSignal::new(String::new()); let cmd_bot = cmd_tx.clone(); let cmd_create = cmd_tx.clone(); + let cmd_join = cmd_tx; let on_create = move |_: leptos::ev::MouseEvent| { let code = generate_room_code(); @@ -229,6 +232,48 @@ fn IdleCard( {t!(i18n, create_room)} + + // Hidden "join by code" fallback +
+ + {move || { + let cmd = cmd_join.clone(); + join_open.get().then(|| view! { +
+ + +
+ }) + }} +
} } @@ -293,6 +338,8 @@ fn NicknameModal( {t!(i18n, nickname_modal_or)} " " {t!(i18n, nickname_modal_sign_in)} + " · " + {t!(i18n, nickname_modal_register)}

diff --git a/clients/web/src/portal/verify_email.rs b/clients/web/src/portal/verify_email.rs index 03736e2..0ce0cae 100644 --- a/clients/web/src/portal/verify_email.rs +++ b/clients/web/src/portal/verify_email.rs @@ -2,7 +2,6 @@ use leptos::prelude::*; use leptos_router::hooks::use_query_map; use crate::api; -use crate::app::AuthEmailVerified; use crate::i18n::*; #[derive(Clone, PartialEq)] @@ -17,8 +16,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").0; + let auth_email_verified = + use_context::>().expect("auth_email_verified context not found"); let query = use_query_map(); let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default()); diff --git a/devenv.lock b/devenv.lock index e6e8ef6..991fcf7 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,11 +3,10 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1779486363, - "narHash": "sha256-6rNmvwTngbmz/j3DGsEYkyakrHHY8N5wgRD9k35HyuM=", + "lastModified": 1776863933, "owner": "cachix", "repo": "devenv", - "rev": "90692720b2ad7a7811204155900bf6bea3a3b420", + "rev": "863b4204725efaeeb73811e376f928232b720646", "type": "github" }, "original": { @@ -19,11 +18,10 @@ }, "nixpkgs": { "locked": { - "lastModified": 1779102034, - "narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=", + "lastModified": 1776734388, "owner": "NixOS", "repo": "nixpkgs", - "rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", + "rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac", "type": "github" }, "original": { @@ -36,7 +34,6 @@ "nixpkgs-cmake3": { "locked": { "lastModified": 1758213207, - "narHash": "sha256-rqoqF0LEi+6ZT59tr+hTQlxVwrzQsET01U4uUdmqRtM=", "owner": "NixOS", "repo": "nixpkgs", "rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde", diff --git a/justfile b/justfile index bc78103..1ac30e4 100644 --- a/justfile +++ b/justfile @@ -55,10 +55,6 @@ build-relay: cp target/release/relay-server deploy cp -u server/relay-server/GameConfig.json deploy/ -# generate web stats report from the current nginx logs -stats: - ssh -t raspberry sudo goaccess /var/log/nginx/trictrac_access.log --log-format=COMBINED -o html > var/stats/report.html - # start a trictrac container with nixos-container # `boot.enableContainers = true` must be set on local nixos system local: diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index c8701fc..9c6071f 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -245,7 +245,6 @@ async fn register( async fn login( mut auth_session: AuthSession, - State(state): State>, Json(body): Json, ) -> Result { let creds = Credentials { @@ -261,18 +260,6 @@ async fn login( auth_session.login(&user).await.map_err(|_| AppError::Internal)?; - if !user.email_verified { - let _ = db::delete_email_tokens(&state.db, user.id, "verify").await; - let token = generate_token(); - let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY; - if db::create_email_token(&state.db, user.id, &token, "verify", expires_at) - .await - .is_ok() - { - state.mailer.send_verification(&user.email, &token).await; - } - } - Ok(Json(MeResponse { id: user.id, username: user.username,