From 203825e28fd03ef6a8fab3746eda4de27e6c6dd7 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Wed, 24 Jun 2026 21:47:57 +0200 Subject: [PATCH] feat: record bot games results --- README.md | 8 -------- clients/web/assets/style.css | 1 - clients/web/src/api.rs | 16 ++++++++++++++++ clients/web/src/app.rs | 11 +++++++++-- clients/web/src/game/session.rs | 23 +++++++++++++++++++++-- clients/web/src/portal/profile.rs | 3 ++- server/relay-server/src/db.rs | 28 ++++++++++++++++++++++++++++ server/relay-server/src/http.rs | 22 ++++++++++++++++++++++ 8 files changed, 98 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2094edb..46930e0 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,6 @@ 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. -## 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. - ## Inspirations The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_ and _server/relay-server_ is a [Leptos](https://leptos.dev/)-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 665aac9..b111501 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -342,7 +342,6 @@ a:hover { text-decoration: underline; } .portal-danger-zone { border: 1px solid rgba(122, 30, 42, 0.4); - background: rgba(122, 30, 42, 0.04); } .portal-danger-zone h2 { color: var(--ui-red-accent); diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs index 2452b67..9d20050 100644 --- a/clients/web/src/api.rs +++ b/clients/web/src/api.rs @@ -188,6 +188,22 @@ pub async fn get_user_games(username: &str, page: i64) -> Result Result<(), String> { + let body = serde_json::json!({ "result": result, "outcome": outcome }); + let resp = gloo_net::http::Request::post(&url("/games/bot-result")) + .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 { + Ok(()) + } 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) diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index d490e4c..b5ebe4e 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -293,12 +293,19 @@ pub fn App() -> impl IntoView { pending, player_name.clone(), backend, + auth_username, ) .await } None => { - run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()) - .await + run_local_bot_game( + screen, + &mut cmd_rx, + pending, + player_name.clone(), + auth_username, + ) + .await } }; if !restart { diff --git a/clients/web/src/game/session.rs b/clients/web/src/game/session.rs index aa6e86c..33f5c42 100644 --- a/clients/web/src/game/session.rs +++ b/clients/web/src/game/session.rs @@ -1,8 +1,10 @@ use futures::channel::mpsc; use leptos::prelude::*; +use leptos::task::spawn_local; use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; +use crate::api; use crate::app::{GameUiState, NetCommand, PauseReason, Screen}; use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::bot_local::bot_decide; @@ -18,6 +20,7 @@ pub async fn run_local_bot_game( cmd_rx: &mut mpsc::UnboundedReceiver, pending: RwSignal>, player_name: String, + auth_username: RwSignal>, ) -> bool { let mut backend = TrictracBackend::new(0); backend.player_arrival(0); @@ -49,7 +52,7 @@ pub async fn run_local_bot_game( suppress_dice_anim: false, })); - run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await + run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs, auth_username).await } /// Runs a bot game from a pre-built backend and initial ViewState (used for snapshot replay). @@ -60,6 +63,7 @@ pub async fn run_local_bot_game_with_backend( pending: RwSignal>, player_name: String, backend: TrictracBackend, + auth_username: RwSignal>, ) -> bool { let mut vs = backend.get_view_state().clone(); patch_bot_names(&mut vs, &player_name); @@ -76,7 +80,7 @@ pub async fn run_local_bot_game_with_backend( suppress_dice_anim: false, })); - run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs).await + run_local_bot_game_loop(screen, cmd_rx, pending, player_name, backend, vs, auth_username).await } async fn run_local_bot_game_loop( @@ -86,8 +90,10 @@ async fn run_local_bot_game_loop( player_name: String, mut backend: TrictracBackend, mut vs: ViewState, + auth_username: RwSignal>, ) -> bool { use futures::StreamExt; + let mut result_submitted = false; loop { match cmd_rx.next().await { Some(NetCommand::Action(action)) => { @@ -152,6 +158,19 @@ async fn run_local_bot_game_loop( } } } + + if vs.stage == SerStage::Ended && !result_submitted { + result_submitted = true; + if let Some(_) = auth_username.get_untracked() { + let scores = vs.scores.clone(); + spawn_local(async move { + let (h0, h1) = (scores[0].holes, scores[1].holes); + let outcome = if h0 > h1 { "win" } else if h0 < h1 { "loss" } else { "draw" }; + let result_str = format!("{} - {}", h0, h1); + let _ = api::submit_bot_game_result(result_str, outcome.to_string()).await; + }); + } + } } } diff --git a/clients/web/src/portal/profile.rs b/clients/web/src/portal/profile.rs index ac11bd6..fbdfec1 100644 --- a/clients/web/src/portal/profile.rs +++ b/clients/web/src/portal/profile.rs @@ -216,6 +216,7 @@ fn GamesTable(games: Vec, page: RwSignal) -> impl IntoView { {rows.into_iter().map(|g| { let started = api::format_ts(g.started_at, locale_tag, &api::DateFormatOptions::date_only()); let ended = g.ended_at.map(|ts| api::format_ts(ts, locale_tag, &api::DateFormatOptions::date_only())).unwrap_or_else(|| "—".into()); + let room_display = if g.room_code == "bot" { "vs Bot".to_string() } else { g.room_code.clone() }; let outcome_class = match g.outcome.as_deref() { Some("win") => "outcome-win", Some("loss") => "outcome-loss", @@ -230,7 +231,7 @@ fn GamesTable(games: Vec, page: RwSignal) -> impl IntoView { }; view! { - { g.room_code.clone() } + { room_display } { started } { ended } { outcome_text } diff --git a/server/relay-server/src/db.rs b/server/relay-server/src/db.rs index 0b9c878..31d8d26 100644 --- a/server/relay-server/src/db.rs +++ b/server/relay-server/src/db.rs @@ -321,6 +321,34 @@ pub async fn insert_participant( Ok(()) } +/// Records a completed bot game for a logged-in user in a single transaction. +pub async fn insert_bot_game( + pool: &Pool, + user_id: i64, + result: &str, + outcome: &str, +) -> Result<(), DbError> { + let mut client = pool.get().await?; + let tx = client.transaction().await?; + let now = now_unix(); + let row = tx + .query_one( + "INSERT INTO game_records (game_id, room_code, started_at, ended_at, result) \ + VALUES ('trictrac', 'bot', $1, $1, $2) RETURNING id", + &[&now, &result], + ) + .await?; + let record_id: i64 = row.get(0); + tx.execute( + "INSERT INTO game_participants (game_record_id, user_id, player_id, outcome) \ + VALUES ($1, $2, 0, $3)", + &[&record_id, &user_id, &outcome], + ) + .await?; + tx.commit().await?; + Ok(()) +} + /// Returns win/loss/draw counts for a user. All values are 0 when the user has no games. pub async fn get_user_stats(pool: &Pool, user_id: i64) -> Result { let client = pool.get().await?; diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index 0104c76..77c03fe 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -13,6 +13,7 @@ //! GET /users/:username/games?page=0&per_page=20 //! GET /games/:id //! POST /games/result +//! POST /games/bot-result use axum::{ Json, Router, @@ -52,6 +53,7 @@ pub fn router() -> Router> { .route("/users/{username}", get(user_profile)) .route("/users/{username}/games", get(user_games)) .route("/games/result", post(game_result)) + .route("/games/bot-result", post(bot_game_result)) .route("/games/{id}", get(game_detail)) .route("/pages/{slug}", get(get_page)) } @@ -548,6 +550,26 @@ async fn game_result( Ok(Json(GameResultResponse { game_record_id })) } +// ── Bot game result ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct BotGameResultBody { + result: String, + outcome: String, +} + +/// Called by the WASM client when a logged-in user finishes a bot game. +async fn bot_game_result( + auth_session: AuthSession, + State(state): State>, + Json(body): Json, +) -> Result { + let user = auth_session.user.ok_or(AppError::Unauthorized)?; + db::insert_bot_game(&state.db, user.id, &body.result, &body.outcome).await?; + tracing::info!(user_id = user.id, outcome = body.outcome, "Bot game recorded"); + Ok(StatusCode::OK) +} + // ── Static content pages ────────────────────────────────────────────────────── #[derive(Deserialize)]