fix: integrate multiplayer (wip)

This commit is contained in:
Henri Bourcereau 2026-04-23 17:37:10 +02:00
parent 03b614c62e
commit 3f3f4598f6
7 changed files with 65 additions and 12 deletions

2
.gitignore vendored
View file

@ -15,3 +15,5 @@ profile.json
bot/models bot/models
client_web/dist client_web/dist
var var
deploy

1
Cargo.lock generated
View file

@ -1456,6 +1456,7 @@ dependencies = [
"backbone-lib", "backbone-lib",
"futures", "futures",
"getrandom 0.3.4", "getrandom 0.3.4",
"gloo-net 0.5.0",
"gloo-storage", "gloo-storage",
"gloo-timers", "gloo-timers",
"leptos", "leptos",

View file

@ -20,11 +20,13 @@ gloo-storage = "0.3"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
gloo-net = { version = "0.5", features = ["http"] }
gloo-timers = { version = "0.3", features = ["futures"] } gloo-timers = { version = "0.3", features = ["futures"] }
# getrandom 0.3 requires an explicit WASM backend; "wasm_js" uses window.crypto.getRandomValues. # 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. # Must be a direct dependency (not just transitive) for the feature to take effect.
getrandom = { version = "0.3", features = ["wasm_js"] } getrandom = { version = "0.3", features = ["wasm_js"] }
web-sys = { version = "0.3", features = [ web-sys = { version = "0.3", features = [
"RequestCredentials",
"AudioContext", "AudioContext",
"AudioParam", "AudioParam",
"AudioNode", "AudioNode",

View file

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

View file

@ -23,6 +23,13 @@ const RELAY_URL: &str = "ws://127.0.0.1:8080/ws";
const GAME_ID: &str = "trictrac"; const GAME_ID: &str = "trictrac";
const STORAGE_KEY: &str = "trictrac_session"; 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. /// The state the UI needs to render the game screen.
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub struct GameUiState { pub struct GameUiState {
@ -93,6 +100,11 @@ struct StoredSession {
view_state: Option<ViewState>, view_state: Option<ViewState>,
} }
#[derive(Deserialize)]
struct MeResponse {
username: String,
}
fn save_session(session: &StoredSession) { fn save_session(session: &StoredSession) {
LocalStorage::set(STORAGE_KEY, session).ok(); LocalStorage::set(STORAGE_KEY, session).ok();
} }
@ -105,6 +117,31 @@ fn clear_session() {
LocalStorage::delete(STORAGE_KEY); 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] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
let stored = load_session(); 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 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. /// 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. /// `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 { if prev.board == next.board {
return None; return None;
} }
@ -436,7 +477,9 @@ fn compute_last_moves(prev: &ViewState, next: &ViewState, own_move: bool) -> Opt
} }
if own_move { if own_move {
// m1 was already shown via the staged-moves overlay; only animate m2. // 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())); return Some((m2, CheckerMove::default()));
} }
Some((m1, m2)) Some((m1, m2))

View file

@ -16,10 +16,10 @@ dev-game:
[working-directory: 'clients/web-game'] [working-directory: 'clients/web-game']
build-game: build-game:
trunk build --release trunk build --release
cp dist/index.html deploy/trictrac.html cp dist/index.html ../../deploy/trictrac.html
cp dist/*.wasm deploy/ cp dist/*.wasm ../../deploy/
cp dist/*.js deploy/ cp dist/*.js ../../deploy/
cp dist/*.css deploy/ cp dist/*.css ../../deploy/
[working-directory: 'deploy'] [working-directory: 'deploy']
run-relay: run-relay:
@ -41,6 +41,7 @@ build-relay:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
mkdir -p deploy mkdir -p deploy
cp target/release/relay-server deploy cp target/release/relay-server deploy
cp -u service/relay-server/GameConfig.json deploy/
runclibots: runclibots:
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk

View file

@ -15,6 +15,7 @@ use crate::message_relay::{handle_client_logic, handle_server_logic};
use axum::Router; use axum::Router;
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use axum::extract::{State, WebSocketUpgrade}; use axum::extract::{State, WebSocketUpgrade};
use axum::http::{HeaderName, Method};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::get; use axum::routing::get;
use axum_login::{AuthManagerLayerBuilder, AuthSession}; use axum_login::{AuthManagerLayerBuilder, AuthSession};
@ -25,11 +26,10 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use time::Duration as TimeDuration; use time::Duration as TimeDuration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use axum::http::{HeaderName, Method};
use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::services::{ServeDir, ServeFile}; use tower_http::services::{ServeDir, ServeFile};
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions::MemoryStore; use tower_sessions::MemoryStore;
use tower_sessions::{Expiry, SessionManagerLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main] #[tokio::main]
@ -81,7 +81,7 @@ async fn main() {
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list([ .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 "http://localhost:9092".parse().unwrap(), // portal dev server
])) ]))
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
@ -96,7 +96,10 @@ async fn main() {
.route("/enlist", get(enlist_handler)) .route("/enlist", get(enlist_handler))
.route("/ws", get(websocket_handler)) .route("/ws", get(websocket_handler))
.merge(http::router()) .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) .with_state(app_state)
.layer(auth_layer) .layer(auth_layer)
.layer(cors) .layer(cors)
@ -180,7 +183,8 @@ async fn websocket(stream: WebSocket, state: Arc<AppState>, user_id: Option<i64>
// By splitting, we can send and receive at the same time. // By splitting, we can send and receive at the same time.
let (mut sender, mut receiver) = stream.split(); 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() { if handshake_result.is_none() {
// We quit here, as the handshake did not work out. // We quit here, as the handshake did not work out.
return; return;