From e414e280478d93e6ec3fe76831e26df1a9581c0e Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sun, 29 Mar 2026 19:19:33 +0200 Subject: [PATCH] feat(client_web): local bot (random strategy) --- Cargo.lock | 1 + client_web/Cargo.toml | 1 + client_web/locales/en.json | 4 +- client_web/locales/fr.json | 4 +- client_web/src/app.rs | 88 ++++++++++++++++++++--- client_web/src/components/game_screen.rs | 7 +- client_web/src/components/login_screen.rs | 12 +++- client_web/src/trictrac/backend.rs | 6 ++ client_web/src/trictrac/bot_local.rs | 30 ++++++++ client_web/src/trictrac/mod.rs | 1 + 10 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 client_web/src/trictrac/bot_local.rs diff --git a/Cargo.lock b/Cargo.lock index 39ea432..94e366c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1400,6 +1400,7 @@ dependencies = [ "gloo-storage", "leptos", "leptos_i18n", + "rand 0.9.2", "serde", "serde_json", "trictrac-store", diff --git a/client_web/Cargo.toml b/client_web/Cargo.toml index c20f16c..3e648ea 100644 --- a/client_web/Cargo.toml +++ b/client_web/Cargo.toml @@ -15,6 +15,7 @@ leptos = { version = "0.7", features = ["csr"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" futures = "0.3" +rand = "0.9" gloo-storage = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/client_web/locales/en.json b/client_web/locales/en.json index 8d3e766..0898aee 100644 --- a/client_web/locales/en.json +++ b/client_web/locales/en.json @@ -33,5 +33,7 @@ "jan_false_hit_big": "False hit (big jan)", "jan_contre_two": "Contre two tables", "jan_contre_mezeas": "Contre mezeas", - "jan_helpless_man": "Helpless man" + "jan_helpless_man": "Helpless man", + "play_vs_bot": "Play vs Bot", + "vs_bot_label": "vs Bot" } diff --git a/client_web/locales/fr.json b/client_web/locales/fr.json index 189f122..b41b8ed 100644 --- a/client_web/locales/fr.json +++ b/client_web/locales/fr.json @@ -33,5 +33,7 @@ "jan_false_hit_big": "Battage à faux (grand jan)", "jan_contre_two": "Contre deux tables", "jan_contre_mezeas": "Contre mezeas", - "jan_helpless_man": "Dame impuissante" + "jan_helpless_man": "Dame impuissante", + "play_vs_bot": "Jouer contre le bot", + "vs_bot_label": "contre le bot" } diff --git a/client_web/src/app.rs b/client_web/src/app.rs index 0f26e47..7e605cb 100644 --- a/client_web/src/app.rs +++ b/client_web/src/app.rs @@ -6,11 +6,12 @@ use leptos::task::spawn_local; use serde::{Deserialize, Serialize}; use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent}; -use backbone_lib::traits::ViewStateUpdate; +use backbone_lib::traits::{BackEndArchitecture, BackendCommand, ViewStateUpdate}; use crate::components::{ConnectingScreen, GameScreen, LoginScreen}; use crate::i18n::I18nContextProvider; use crate::trictrac::backend::TrictracBackend; +use crate::trictrac::bot_local::bot_decide; use crate::trictrac::types::{GameDelta, PlayerAction, ViewState}; const RELAY_URL: &str = "ws://127.0.0.1:8080/ws"; @@ -24,6 +25,7 @@ pub struct GameUiState { /// 0 = host, 1 = guest pub player_id: u16, pub room_id: String, + pub is_bot_game: bool, } /// Which screen is currently shown. @@ -49,6 +51,7 @@ pub enum NetCommand { token: u64, host_state: Option>, }, + PlayVsBot, Action(PlayerAction), Disconnect, } @@ -109,11 +112,13 @@ pub fn App() -> impl IntoView { spawn_local(async move { loop { - // Wait for a connect/reconnect command. - let (config, is_reconnect) = loop { + // Wait for a connect/reconnect command (or PlayVsBot). + // None means "play vs bot"; Some((config, is_reconnect)) means "connect to relay". + let remote_config: Option<(RoomConfig, bool)> = loop { match cmd_rx.next().await { + Some(NetCommand::PlayVsBot) => break None, Some(NetCommand::CreateRoom { room }) => { - break ( + break Some(( RoomConfig { relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), @@ -124,10 +129,10 @@ pub fn App() -> impl IntoView { host_state: None, }, false, - ); + )); } Some(NetCommand::JoinRoom { room }) => { - break ( + break Some(( RoomConfig { relay_url: RELAY_URL.to_string(), game_id: GAME_ID.to_string(), @@ -138,7 +143,7 @@ pub fn App() -> impl IntoView { host_state: None, }, false, - ); + )); } Some(NetCommand::Reconnect { relay_url, @@ -147,7 +152,7 @@ pub fn App() -> impl IntoView { token, host_state, }) => { - break ( + break Some(( RoomConfig { relay_url, game_id, @@ -158,12 +163,19 @@ pub fn App() -> impl IntoView { host_state, }, true, - ); + )); } _ => {} // Ignore game commands while disconnected. } }; + if remote_config.is_none() { + run_local_bot_game(screen, &mut cmd_rx).await; + screen.set(Screen::Login { error: None }); + continue; + } + let (config, is_reconnect) = remote_config.unwrap(); + screen.set(Screen::Connecting); let room_id_for_storage = config.room_id.clone(); @@ -228,6 +240,7 @@ pub fn App() -> impl IntoView { view_state: vs.clone(), player_id, room_id: room_id_for_storage.clone(), + is_bot_game: false, })); } Some(SessionEvent::Disconnected(reason)) => { @@ -254,3 +267,60 @@ pub fn App() -> impl IntoView { } } + +async fn run_local_bot_game( + screen: RwSignal, + cmd_rx: &mut futures::channel::mpsc::UnboundedReceiver, +) { + let mut backend = TrictracBackend::new(0); + backend.player_arrival(0); + backend.player_arrival(1); + + let mut vs = ViewState::default_with_names("You", "Bot"); + drain_and_update(&mut backend, &mut vs, screen); + + loop { + match cmd_rx.next().await { + Some(NetCommand::Action(action)) => { + backend.inform_rpc(0, action); + } + _ => break, + } + + drain_and_update(&mut backend, &mut vs, screen); + + loop { + match bot_decide(backend.get_game()) { + None => break, + Some(action) => { + backend.inform_rpc(1, action); + drain_and_update(&mut backend, &mut vs, screen); + } + } + } + } +} + +fn drain_and_update( + backend: &mut TrictracBackend, + vs: &mut ViewState, + screen: RwSignal, +) { + for cmd in backend.drain_commands() { + match cmd { + BackendCommand::ResetViewState => { + *vs = backend.get_view_state().clone(); + } + BackendCommand::Delta(delta) => { + vs.apply_delta(&delta); + } + _ => {} + } + } + screen.set(Screen::Playing(GameUiState { + view_state: vs.clone(), + player_id: 0, + room_id: String::new(), + is_bot_game: true, + })); +} diff --git a/client_web/src/components/game_screen.rs b/client_web/src/components/game_screen.rs index b3631d1..51747a8 100644 --- a/client_web/src/components/game_screen.rs +++ b/client_web/src/components/game_screen.rs @@ -115,12 +115,17 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let stage = vs.stage.clone(); let turn_stage = vs.turn_stage.clone(); let room_id = state.room_id.clone(); + let is_bot_game = state.is_bot_game; view! {
// ── Top bar ──────────────────────────────────────────────────────
- {move || t_string!(i18n, room_label, id = room_id.as_str())} + {move || if is_bot_game { + t_string!(i18n, vs_bot_label).to_owned() + } else { + t_string!(i18n, room_label, id = room_id.as_str()) + }}
+ +
} } diff --git a/client_web/src/trictrac/backend.rs b/client_web/src/trictrac/backend.rs index 3a06ae4..3e57935 100644 --- a/client_web/src/trictrac/backend.rs +++ b/client_web/src/trictrac/backend.rs @@ -60,6 +60,12 @@ impl TrictracBackend { } } +impl TrictracBackend { + pub fn get_game(&self) -> &GameState { + &self.game + } +} + impl BackEndArchitecture for TrictracBackend { fn new(_rule_variation: u16) -> Self { let mut game = GameState::new(false); diff --git a/client_web/src/trictrac/bot_local.rs b/client_web/src/trictrac/bot_local.rs new file mode 100644 index 0000000..537ffcb --- /dev/null +++ b/client_web/src/trictrac/bot_local.rs @@ -0,0 +1,30 @@ +use rand::prelude::IndexedRandom; +use trictrac_store::{CheckerMove, Color, GameState, MoveRules, TurnStage}; + +use crate::trictrac::types::PlayerAction; + +const GUEST_PLAYER_ID: u64 = 2; + +/// Returns the next action for the bot (mp_player 1 / guest), or None if it is not the bot's turn. +pub fn bot_decide(game: &GameState) -> Option { + if game.active_player_id != GUEST_PLAYER_ID { + return None; + } + match game.turn_stage { + TurnStage::RollDice => Some(PlayerAction::Roll), + TurnStage::HoldOrGoChoice => Some(PlayerAction::Go), + TurnStage::Move => { + let rules = MoveRules::new(&Color::Black, &game.board, game.dice); + let sequences = rules.get_possible_moves_sequences(true, vec![]); + let mut rng = rand::rng(); + let (m1, m2) = sequences + .choose(&mut rng) + .cloned() + .unwrap_or((CheckerMove::default(), CheckerMove::default())); + // MoveRules with Color::Black mirrors the board internally, so + // returned move coordinates are in mirrored (White) space — mirror back. + Some(PlayerAction::Move(m1.mirror(), m2.mirror())) + } + _ => None, + } +} diff --git a/client_web/src/trictrac/mod.rs b/client_web/src/trictrac/mod.rs index e59217a..38d05bb 100644 --- a/client_web/src/trictrac/mod.rs +++ b/client_web/src/trictrac/mod.rs @@ -1,2 +1,3 @@ pub mod backend; +pub mod bot_local; pub mod types;