feat: record bot games results
This commit is contained in:
parent
592dfe52af
commit
203825e28f
8 changed files with 98 additions and 14 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -293,12 +293,19 @@ 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(
|
||||||
.await
|
screen,
|
||||||
|
&mut cmd_rx,
|
||||||
|
pending,
|
||||||
|
player_name.clone(),
|
||||||
|
auth_username,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !restart {
|
if !restart {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue