From 3f3f4598f6556a71e779726077303996a7384e54 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 23 Apr 2026 17:37:10 +0200 Subject: [PATCH] fix: integrate multiplayer (wip) --- .gitignore | 2 ++ Cargo.lock | 1 + clients/web-game/Cargo.toml | 2 ++ clients/web-game/Trunk.toml | 2 +- clients/web-game/src/app.rs | 47 +++++++++++++++++++++++++++++++-- justfile | 9 ++++--- server/relay-server/src/main.rs | 14 ++++++---- 7 files changed, 65 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index fa83e0e..33bd037 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ profile.json bot/models client_web/dist var + +deploy diff --git a/Cargo.lock b/Cargo.lock index 59140f7..fcb2626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1456,6 +1456,7 @@ dependencies = [ "backbone-lib", "futures", "getrandom 0.3.4", + "gloo-net 0.5.0", "gloo-storage", "gloo-timers", "leptos", diff --git a/clients/web-game/Cargo.toml b/clients/web-game/Cargo.toml index 2103eda..578be7c 100644 --- a/clients/web-game/Cargo.toml +++ b/clients/web-game/Cargo.toml @@ -20,11 +20,13 @@ gloo-storage = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" +gloo-net = { version = "0.5", features = ["http"] } gloo-timers = { version = "0.3", features = ["futures"] } # getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # Must be a direct dependency (not just transitive) for the feature to take effect. getrandom = { version = "0.3", features = ["wasm_js"] } web-sys = { version = "0.3", features = [ + "RequestCredentials", "AudioContext", "AudioParam", "AudioNode", diff --git a/clients/web-game/Trunk.toml b/clients/web-game/Trunk.toml index 57a2aaa..bae5297 100644 --- a/clients/web-game/Trunk.toml +++ b/clients/web-game/Trunk.toml @@ -1,2 +1,2 @@ [serve] -port = 9092 +port = 9091 diff --git a/clients/web-game/src/app.rs b/clients/web-game/src/app.rs index 196a43a..a518fcb 100644 --- a/clients/web-game/src/app.rs +++ b/clients/web-game/src/app.rs @@ -23,6 +23,13 @@ const RELAY_URL: &str = "ws://127.0.0.1:8080/ws"; const GAME_ID: &str = "trictrac"; const STORAGE_KEY: &str = "trictrac_session"; +// In debug builds trunk serves on 9091, relay is on 8080. +// In release the game is served by the relay itself — use relative paths. +#[cfg(debug_assertions)] +const HTTP_BASE: &str = "http://localhost:8080"; +#[cfg(not(debug_assertions))] +const HTTP_BASE: &str = ""; + /// The state the UI needs to render the game screen. #[derive(Clone, PartialEq)] pub struct GameUiState { @@ -93,6 +100,11 @@ struct StoredSession { view_state: Option, } +#[derive(Deserialize)] +struct MeResponse { + username: String, +} + fn save_session(session: &StoredSession) { LocalStorage::set(STORAGE_KEY, session).ok(); } @@ -105,6 +117,31 @@ fn clear_session() { LocalStorage::delete(STORAGE_KEY); } +/// Fire-and-forget: tell the relay server who won. Only called by the host. +async fn submit_game_result(room_code: String, game_state: ViewState) { + let [score_pl1, score_pl2] = game_state.scores; + let result_str = format!("{:?} - {:?}", score_pl1.holes, score_pl2.holes); + let outcomes = if score_pl1.holes < score_pl2.holes { + [("0", "loss"), ("1", "win")] + } else if score_pl2.holes < score_pl1.holes { + [("0", "win"), ("1", "loss")] + } else { + [("0", "draw"), ("1", "draw")] + }; + let body = serde_json::json!({ + "room_code": room_code, + "game_id": GAME_ID, + "result": result_str, + "outcomes": std::collections::HashMap::from(outcomes), + }); + let _ = gloo_net::http::Request::post(&format!("{HTTP_BASE}/games/result")) + .credentials(web_sys::RequestCredentials::Include) + .json(&body) + .unwrap() + .send() + .await; +} + #[component] pub fn App() -> impl IntoView { let stored = load_session(); @@ -423,7 +460,11 @@ async fn run_local_bot_game( /// Returns the checker moves to animate when the board changed between two ViewStates. /// Returns `None` when the board is unchanged or no real moves were recorded. /// `own_move`: when true, m1 was already shown via staged-moves UI, so only animate m2. -fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Option<(CheckerMove, CheckerMove)> { +fn compute_last_moves( + prev: &ViewState, + next: &ViewState, + own_move: bool, +) -> Option<(CheckerMove, CheckerMove)> { if prev.board == next.board { return None; } @@ -436,7 +477,9 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Opt } if own_move { // m1 was already shown via the staged-moves overlay; only animate m2. - if m2 == CheckerMove::default() { return None; } + if m2 == CheckerMove::default() { + return None; + } return Some((m2, CheckerMove::default())); } Some((m1, m2)) diff --git a/justfile b/justfile index e1a9b6d..75a3de4 100644 --- a/justfile +++ b/justfile @@ -16,10 +16,10 @@ dev-game: [working-directory: 'clients/web-game'] build-game: trunk build --release - cp dist/index.html deploy/trictrac.html - cp dist/*.wasm deploy/ - cp dist/*.js deploy/ - cp dist/*.css deploy/ + cp dist/index.html ../../deploy/trictrac.html + cp dist/*.wasm ../../deploy/ + cp dist/*.js ../../deploy/ + cp dist/*.css ../../deploy/ [working-directory: 'deploy'] run-relay: @@ -41,6 +41,7 @@ build-relay: CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release mkdir -p deploy cp target/release/relay-server deploy + cp -u service/relay-server/GameConfig.json deploy/ runclibots: cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs index 34acdda..2c11b44 100644 --- a/server/relay-server/src/main.rs +++ b/server/relay-server/src/main.rs @@ -15,6 +15,7 @@ use crate::message_relay::{handle_client_logic, handle_server_logic}; use axum::Router; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{State, WebSocketUpgrade}; +use axum::http::{HeaderName, Method}; use axum::response::IntoResponse; use axum::routing::get; use axum_login::{AuthManagerLayerBuilder, AuthSession}; @@ -25,11 +26,10 @@ use std::sync::Arc; use std::time::Duration; use time::Duration as TimeDuration; use tokio::sync::Mutex; -use axum::http::{HeaderName, Method}; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; -use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions::MemoryStore; +use tower_sessions::{Expiry, SessionManagerLayer}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -81,7 +81,7 @@ async fn main() { let cors = CorsLayer::new() .allow_origin(AllowOrigin::list([ - "http://localhost:9091".parse().unwrap(), // tic-tac-toe dev server + "http://localhost:9091".parse().unwrap(), // game dev server "http://localhost:9092".parse().unwrap(), // portal dev server ])) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) @@ -96,7 +96,10 @@ async fn main() { .route("/enlist", get(enlist_handler)) .route("/ws", get(websocket_handler)) .merge(http::router()) - .nest_service("/portal", ServeDir::new("portal").not_found_service(ServeFile::new("portal/index.html"))) + .nest_service( + "/portal", + ServeDir::new("portal").not_found_service(ServeFile::new("portal/index.html")), + ) .with_state(app_state) .layer(auth_layer) .layer(cors) @@ -180,7 +183,8 @@ async fn websocket(stream: WebSocket, state: Arc, user_id: Option // By splitting, we can send and receive at the same time. let (mut sender, mut receiver) = stream.split(); - let handshake_result = init_and_connect(&mut sender, &mut receiver, state.clone(), user_id).await; + let handshake_result = + init_and_connect(&mut sender, &mut receiver, state.clone(), user_id).await; if handshake_result.is_none() { // We quit here, as the handshake did not work out. return;