Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
9c13630c7d feat: record bot games results 2026-06-24 21:48:11 +02:00
592dfe52af Merge tag '0.2.18' into develop
0.2.18
2026-06-21 13:49:58 +02:00
9 changed files with 103 additions and 19 deletions

10
Cargo.lock generated
View file

@ -189,7 +189,7 @@ dependencies = [
[[package]] [[package]]
name = "backbone-lib" name = "backbone-lib"
version = "0.2.16" version = "0.2.18"
dependencies = [ dependencies = [
"bytes", "bytes",
"ewebsock", "ewebsock",
@ -2658,7 +2658,7 @@ dependencies = [
[[package]] [[package]]
name = "protocol" name = "protocol"
version = "0.2.16" version = "0.2.18"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "relay-server" name = "relay-server"
version = "0.2.16" version = "0.2.18"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
@ -3921,7 +3921,7 @@ dependencies = [
[[package]] [[package]]
name = "trictrac-store" name = "trictrac-store"
version = "0.2.16" version = "0.2.18"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.21.7", "base64 0.21.7",
@ -3934,7 +3934,7 @@ dependencies = [
[[package]] [[package]]
name = "trictrac-web" name = "trictrac-web"
version = "0.2.16" version = "0.2.18"
dependencies = [ dependencies = [
"backbone-lib", "backbone-lib",
"futures", "futures",

View file

@ -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. 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 ## 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. 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.

View file

@ -342,7 +342,6 @@ a:hover { text-decoration: underline; }
.portal-danger-zone { .portal-danger-zone {
border: 1px solid rgba(122, 30, 42, 0.4); border: 1px solid rgba(122, 30, 42, 0.4);
background: rgba(122, 30, 42, 0.04);
} }
.portal-danger-zone h2 { .portal-danger-zone h2 {
color: var(--ui-red-accent); color: var(--ui-red-accent);

View file

@ -188,6 +188,22 @@ pub async fn get_user_games(username: &str, page: i64) -> Result<GamesResponse,
} }
} }
pub async fn submit_bot_game_result(result: String, outcome: String) -> 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<GameDetail, String> { pub async fn get_game_detail(id: i64) -> Result<GameDetail, String> {
let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}"))) let resp = gloo_net::http::Request::get(&url(&format!("/games/{id}")))
.credentials(web_sys::RequestCredentials::Include) .credentials(web_sys::RequestCredentials::Include)

View file

@ -293,11 +293,18 @@ pub fn App() -> impl IntoView {
pending, pending,
player_name.clone(), player_name.clone(),
backend, backend,
auth_username,
) )
.await .await
} }
None => { None => {
run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()) run_local_bot_game(
screen,
&mut cmd_rx,
pending,
player_name.clone(),
auth_username,
)
.await .await
} }
}; };

View file

@ -1,8 +1,10 @@
use futures::channel::mpsc; use futures::channel::mpsc;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::task::spawn_local;
use backbone_lib::traits::{BackEndArchitecture, BackendCommand}; use backbone_lib::traits::{BackEndArchitecture, BackendCommand};
use crate::api;
use crate::app::{GameUiState, NetCommand, PauseReason, Screen}; use crate::app::{GameUiState, NetCommand, PauseReason, Screen};
use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::backend::TrictracBackend;
use crate::game::trictrac::bot_local::bot_decide; use crate::game::trictrac::bot_local::bot_decide;
@ -18,6 +20,7 @@ pub async fn run_local_bot_game(
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>, cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
pending: RwSignal<VecDeque<GameUiState>>, pending: RwSignal<VecDeque<GameUiState>>,
player_name: String, player_name: String,
auth_username: RwSignal<Option<String>>,
) -> bool { ) -> bool {
let mut backend = TrictracBackend::new(0); let mut backend = TrictracBackend::new(0);
backend.player_arrival(0); backend.player_arrival(0);
@ -49,7 +52,7 @@ pub async fn run_local_bot_game(
suppress_dice_anim: false, 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). /// 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<VecDeque<GameUiState>>, pending: RwSignal<VecDeque<GameUiState>>,
player_name: String, player_name: String,
backend: TrictracBackend, backend: TrictracBackend,
auth_username: RwSignal<Option<String>>,
) -> bool { ) -> bool {
let mut vs = backend.get_view_state().clone(); let mut vs = backend.get_view_state().clone();
patch_bot_names(&mut vs, &player_name); patch_bot_names(&mut vs, &player_name);
@ -76,7 +80,7 @@ pub async fn run_local_bot_game_with_backend(
suppress_dice_anim: false, 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( async fn run_local_bot_game_loop(
@ -86,8 +90,10 @@ async fn run_local_bot_game_loop(
player_name: String, player_name: String,
mut backend: TrictracBackend, mut backend: TrictracBackend,
mut vs: ViewState, mut vs: ViewState,
auth_username: RwSignal<Option<String>>,
) -> bool { ) -> bool {
use futures::StreamExt; use futures::StreamExt;
let mut result_submitted = false;
loop { loop {
match cmd_rx.next().await { match cmd_rx.next().await {
Some(NetCommand::Action(action)) => { 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;
});
}
}
} }
} }

View file

@ -216,6 +216,7 @@ fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
{rows.into_iter().map(|g| { {rows.into_iter().map(|g| {
let started = api::format_ts(g.started_at, locale_tag, &api::DateFormatOptions::date_only()); 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 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() { let outcome_class = match g.outcome.as_deref() {
Some("win") => "outcome-win", Some("win") => "outcome-win",
Some("loss") => "outcome-loss", Some("loss") => "outcome-loss",
@ -230,7 +231,7 @@ fn GamesTable(games: Vec<GameSummary>, page: RwSignal<i64>) -> impl IntoView {
}; };
view! { view! {
<tr> <tr>
<td>{ g.room_code.clone() }</td> <td>{ room_display }</td>
<td>{ started }</td> <td>{ started }</td>
<td>{ ended }</td> <td>{ ended }</td>
<td class=outcome_class>{ outcome_text }</td> <td class=outcome_class>{ outcome_text }</td>

View file

@ -321,6 +321,34 @@ pub async fn insert_participant(
Ok(()) 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. /// 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<UserStats, DbError> { pub async fn get_user_stats(pool: &Pool, user_id: i64) -> Result<UserStats, DbError> {
let client = pool.get().await?; let client = pool.get().await?;

View file

@ -13,6 +13,7 @@
//! GET /users/:username/games?page=0&per_page=20 //! GET /users/:username/games?page=0&per_page=20
//! GET /games/:id //! GET /games/:id
//! POST /games/result //! POST /games/result
//! POST /games/bot-result
use axum::{ use axum::{
Json, Router, Json, Router,
@ -52,6 +53,7 @@ pub fn router() -> Router<Arc<AppState>> {
.route("/users/{username}", get(user_profile)) .route("/users/{username}", get(user_profile))
.route("/users/{username}/games", get(user_games)) .route("/users/{username}/games", get(user_games))
.route("/games/result", post(game_result)) .route("/games/result", post(game_result))
.route("/games/bot-result", post(bot_game_result))
.route("/games/{id}", get(game_detail)) .route("/games/{id}", get(game_detail))
.route("/pages/{slug}", get(get_page)) .route("/pages/{slug}", get(get_page))
} }
@ -548,6 +550,26 @@ async fn game_result(
Ok(Json(GameResultResponse { game_record_id })) 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<AuthBackend>,
State(state): State<Arc<AppState>>,
Json(body): Json<BotGameResultBody>,
) -> Result<impl IntoResponse, AppError> {
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 ────────────────────────────────────────────────────── // ── Static content pages ──────────────────────────────────────────────────────
#[derive(Deserialize)] #[derive(Deserialize)]