chore: integrate multiplayer code (wip)

This commit is contained in:
Henri Bourcereau 2026-04-22 17:42:05 +02:00
parent 2838d59f30
commit 4f5e21becb
66 changed files with 6423 additions and 18 deletions

21
clients/cli/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "trictrac-client_cli"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "client_cli"
path = "src/main.rs"
[dependencies]
anyhow = "1.0.75"
bincode = "1.3.3"
pico-args = "0.5.0"
pretty_assertions = "1.4.0"
renet = "0.0.13"
trictrac-store = { path = "../../store" }
trictrac-bot = { path = "../../bot" }
spiel_bot = { path = "../../spiel_bot" }
itertools = "0.13.0"
env_logger = "0.11.6"
log = "0.4.20"

357
clients/cli/src/app.rs Normal file
View file

@ -0,0 +1,357 @@
use spiel_bot::strategy::{AzBotStrategy, DqnSpielBotStrategy};
use trictrac_bot::{
BotStrategy, DefaultStrategy, DqnBurnStrategy, ErroneousStrategy, RandomStrategy,
StableBaselines3Strategy,
};
use itertools::Itertools;
use crate::game_runner::GameRunner;
use trictrac_store::{CheckerMove, GameEvent, GameState, Stage, TurnStage};
#[derive(Debug, Default)]
pub struct AppArgs {
pub seed: Option<u32>,
pub bot: Option<String>,
}
// Application.
#[derive(Debug, Default)]
pub struct App {
// should the application exit?
pub should_quit: bool,
pub schools_enabled: bool,
pub game: GameRunner,
}
impl App {
// Constructs a new instance of [`App`].
pub fn new(args: AppArgs) -> Self {
let bot_strategies: Vec<Box<dyn BotStrategy>> =
args.bot
.as_deref()
.map(|str_bots| {
str_bots
.split(",")
.filter_map(|s| match s.trim() {
"dummy" => {
Some(Box::new(DefaultStrategy::default()) as Box<dyn BotStrategy>)
}
"random" => {
Some(Box::new(RandomStrategy::default()) as Box<dyn BotStrategy>)
}
"erroneous" => {
Some(Box::new(ErroneousStrategy::default()) as Box<dyn BotStrategy>)
}
"ai" => Some(Box::new(StableBaselines3Strategy::default())
as Box<dyn BotStrategy>),
"dqnburn" => {
Some(Box::new(DqnBurnStrategy::default()) as Box<dyn BotStrategy>)
}
s if s.starts_with("ai:") => {
let path = s.trim_start_matches("ai:");
Some(Box::new(StableBaselines3Strategy::new(path))
as Box<dyn BotStrategy>)
}
s if s.starts_with("dqnburn:") => {
let path = s.trim_start_matches("dqnburn:");
Some(Box::new(DqnBurnStrategy::new_with_model(&path.to_string()))
as Box<dyn BotStrategy>)
}
"az" => {
Some(Box::new(AzBotStrategy::new_mlp(None)) as Box<dyn BotStrategy>)
}
s if s.starts_with("az:") && !s.starts_with("az-") => {
let path = s.trim_start_matches("az:");
Some(Box::new(AzBotStrategy::new_mlp(Some(path))) as Box<dyn BotStrategy>)
}
"az-resnet" => {
Some(Box::new(AzBotStrategy::new_resnet(None)) as Box<dyn BotStrategy>)
}
s if s.starts_with("az-resnet:") => {
let path = s.trim_start_matches("az-resnet:");
Some(Box::new(AzBotStrategy::new_resnet(Some(path))) as Box<dyn BotStrategy>)
}
"az-dqn" => {
Some(Box::new(DqnSpielBotStrategy::new(None)) as Box<dyn BotStrategy>)
}
s if s.starts_with("az-dqn:") => {
let path = s.trim_start_matches("az-dqn:");
Some(Box::new(DqnSpielBotStrategy::new(Some(path))) as Box<dyn BotStrategy>)
}
_ => None,
})
.collect()
})
.unwrap_or_default();
let schools_enabled = false;
let should_quit = bot_strategies.len() > 1;
Self {
game: GameRunner::new(schools_enabled, bot_strategies, args.seed.map(|s| s as u64)),
should_quit,
schools_enabled,
}
}
pub fn start(&mut self) {
self.game.state = GameState::new(self.schools_enabled);
}
pub fn input(&mut self, input: &str) {
// println!("'{}'", input);
match input {
"state" => self.show_state(),
"history" => self.show_history(),
"quit" => self.quit(),
// run bots game (when two bots)
"bots" => self.bots_all(),
"" => self.bots_next_step(),
// play (when one bot)
"roll" => self.roll_dice(),
"go" => self.go(),
_ => self.add_move(input),
}
println!("{}", self.display());
}
// --- 2 bots game actions
fn bots_all(&mut self) {}
fn bots_next_step(&mut self) {}
// Set running to false to quit the application.
pub fn quit(&mut self) {
self.should_quit = true;
}
pub fn show_state(&self) {
println!("{:?}", self.game.state)
}
pub fn show_history(&self) {
for hist in self.game.state.history.iter() {
println!("{hist:?}\n");
}
}
fn roll_dice(&mut self) {
if self.game.player_id.is_none() {
println!("player_id not set ");
return;
}
if self.game.state.turn_stage != TurnStage::RollDice {
println!("Not in the dice roll stage");
return;
}
let dice = self.game.dice_roller.roll();
// get correct points for these board and dice
// let points_rules = PointsRules::new(
// &self
// .game
// .state
// .player_color_by_id(&self.game.player_id.unwrap())
// .unwrap(),
// &self.game.state.board,
// dice,
// );
self.game.handle_event(&GameEvent::Roll {
player_id: self.game.player_id.unwrap(),
});
self.game.handle_event(&GameEvent::RollResult {
player_id: self.game.player_id.unwrap(),
dice,
});
}
fn go(&mut self) {
if self.game.player_id.is_none() {
println!("player_id not set ");
return;
}
if self.game.state.turn_stage != TurnStage::HoldOrGoChoice {
println!("Not in position to go");
return;
}
self.game.handle_event(&GameEvent::Go {
player_id: self.game.player_id.unwrap(),
});
}
fn add_move(&mut self, input: &str) {
if self.game.player_id.is_none() {
println!("player_id not set ");
return;
}
let positions: Vec<usize> = input
.split(' ')
.map(|str| str.parse().unwrap_or(0))
.collect();
if positions.len() == 2 && positions[0] != 0 && positions[1] != 0 {
if let Ok(checker_move) = CheckerMove::new(positions[0], positions[1]) {
// if checker_move.is_ok() {
if self.game.first_move.is_some() {
let move_event = GameEvent::Move {
player_id: self.game.player_id.unwrap(),
moves: (self.game.first_move.unwrap(), checker_move),
};
if !self.game.state.validate(&move_event) {
println!("Move invalid");
self.game.first_move = None;
return;
}
self.game.handle_event(&move_event);
self.game.first_move = None;
} else {
self.game.first_move = Some(checker_move);
}
return;
}
}
println!("invalid move : {input}");
}
pub fn display(&mut self) -> String {
let winner = self
.game
.state
.determine_winner()
.and_then(|id| self.game.state.players.get(&id));
let str_won: String = winner
.map(|p| {
let mut name = " winner: ".to_owned();
name.push_str(&p.name);
name
})
.unwrap_or("".to_owned());
let mut output = "-------------------------------".to_owned();
output += format!(
"\n{:?}{} > {} > {:?}",
self.game.state.stage,
str_won,
self.game
.state
.who_plays()
.map(|pl| &pl.name)
.unwrap_or(&"?".to_owned()),
self.game.state.turn_stage
)
.as_str();
output = output + "\nRolled dice : " + &self.game.state.dice.to_display_string();
if self.game.state.stage != Stage::PreGame {
output = output + "\nRolled dice jans : " + &format!("{:?}", self.game.state.dice_jans);
output = output
+ "\nLast move : "
+ &self.game.state.dice_moves.0.to_display_string()
+ ", "
+ &self.game.state.dice_moves.1.to_display_string();
// display players points
output += format!("\n\n{:<11} :: {:<5} :: {}", "Player", "holes", "points").as_str();
for player_id in self.game.state.players.keys().sorted() {
let player = &self.game.state.players[player_id];
output += format!(
"\n{}. {:<8} :: {:<5} :: {}",
&player_id, &player.name, &player.holes, &player.points,
)
.as_str();
}
}
output += "\n-------------------------------\n";
output += &self.game.state.board.to_display_grid(9);
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_display() {
let expected = "-------------------------------
PreGame > ? > RollDice
Rolled dice : 0 & 0
-------------------------------
13 14 15 16 17 18 19 20 21 22 23 24
----------------------------------------------------------------
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | 15 |
|------------------------------ | | -----------------------------|
| | | 15 |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
----------------------------------------------------------------
12 11 10 9 8 7 6 5 4 3 2 1
";
let mut app = App::default();
self::assert_eq!(app.display(), expected);
}
#[test]
fn test_move() {
let expected = "-------------------------------
InGame > myself > RollDice
Rolled dice : 4 & 6
Rolled dice jans : {}
Last move : CheckerMove { from: 24, to: 18 } , CheckerMove { from: 24, to: 20 }
Player :: holes :: points
1. myself :: 0 :: 0
2. bot :: 0 :: 0
-------------------------------
13 14 15 16 17 18 19 20 21 22 23 24
----------------------------------------------------------------
| X | | X X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | X |
| | | 13 |
|------------------------------ | | -----------------------------|
| | | 13 |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O |
| | | O O O |
----------------------------------------------------------------
12 11 10 9 8 7 6 5 4 3 2 1
";
let mut app = App::new(AppArgs {
seed: Some(1327),
bot: Some("dummy".into()),
});
println!("avant : {}", app.display());
app.input("roll");
app.input("1 3");
app.input("1 4");
self::assert_eq!(app.display(), expected);
}
}

View file

@ -0,0 +1,131 @@
use log::{debug, error};
use trictrac_bot::{Bot, BotStrategy};
use trictrac_store::{CheckerMove, DiceRoller, GameEvent, GameState, PlayerId, TurnStage};
// Application Game
#[derive(Debug, Default)]
pub struct GameRunner {
pub state: GameState,
pub dice_roller: DiceRoller,
pub first_move: Option<CheckerMove>,
pub player_id: Option<PlayerId>,
bots: Vec<Bot>,
}
impl GameRunner {
// Constructs a new instance of [`App`].
pub fn new(
schools_enabled: bool,
bot_strategies: Vec<Box<dyn BotStrategy>>,
seed: Option<u64>,
) -> Self {
let mut state = GameState::new(schools_enabled);
// local : player
let player_id: Option<PlayerId> = if bot_strategies.len() > 1 {
None
} else {
state.init_player("myself")
};
// bots
let bots: Vec<Bot> = bot_strategies
.into_iter()
.map(|strategy| {
let bot_id: PlayerId = state.init_player("bot").unwrap();
let bot_color = state.player_color_by_id(&bot_id).unwrap();
Bot::new(strategy, bot_color)
})
.collect();
// let bot_strategy = Box::new(DefaultStrategy::default());
// let bot: Bot = Bot::new(bot_strategy, bot_color, schools_enabled);
// let bot: Bot = Bot::new(bot_strategy, bot_color);
let first_player_id = if bots.len() > 1 {
bots[0].player_id
} else {
player_id.unwrap()
};
let mut game = Self {
state,
dice_roller: DiceRoller::new(seed),
first_move: None,
player_id,
bots,
};
game.handle_event(&GameEvent::BeginGame {
goes_first: first_player_id,
});
game
}
pub fn handle_event(&mut self, event: &GameEvent) -> Option<GameEvent> {
if event == &GameEvent::PlayError {
return None;
}
let valid_event = if self.state.validate(event) {
debug!(
"--------------- new valid event {event:?} (stage {:?}) -----------",
self.state.turn_stage
);
let _ = self.state.consume(event).inspect_err(|e| error!("{}", e));
debug!(
" --> stage {:?} ; active player points {:?}",
self.state.turn_stage,
self.state.who_plays().map(|p| p.points)
);
event
} else {
debug!("{}", self.state);
error!("event not valid : {event:?}");
// panic!("crash and burn {} \nevt not valid {event:?}", self.state);
&GameEvent::PlayError
};
// chain all successive bot actions
if self.bots.is_empty() {
return None;
}
// Collect bot actions to avoid borrow conflicts
let bot_events: Vec<GameEvent> = self
.bots
.iter_mut()
.filter_map(|bot| bot.handle_event(valid_event))
.collect();
// if bot_events.len() > 1 {
// println!(
// "There might be a problem : 2 bots events : {:?}",
// bot_events
// );
// }
let mut next_event = None;
for bot_event in bot_events {
let bot_result_event = self.handle_event(&bot_event);
if let Some(bot_id) = bot_event.player_id() {
next_event = if self.bot_needs_dice_roll(bot_id) {
let dice = self.dice_roller.roll();
self.handle_event(&GameEvent::RollResult {
player_id: bot_id,
dice,
})
} else {
bot_result_event
};
}
}
if let Some(winner) = self.state.determine_winner() {
next_event = Some(trictrac_store::GameEvent::EndGame {
reason: trictrac_store::EndGameReason::PlayerWon { winner },
});
}
next_event
}
fn bot_needs_dice_roll(&self, bot_id: PlayerId) -> bool {
self.state.active_player_id == bot_id && self.state.turn_stage == TurnStage::RollWaiting
}
}

95
clients/cli/src/main.rs Normal file
View file

@ -0,0 +1,95 @@
// Application.
pub mod app;
mod game_runner;
use anyhow::Result;
use app::{App, AppArgs};
use std::io;
// see pico-args example at https://github.com/RazrFalcon/pico-args/blob/master/examples/app.rs
const HELP: &str = "\
Trictrac CLI
USAGE:
trictrac-cli [OPTIONS]
FLAGS:
-h, --help Prints help information
OPTIONS:
--seed SEED Sets the random generator seed
--bot STRATEGY_BOT Add a bot player with strategy STRATEGY, a second bot may be added to play against the first : --bot STRATEGY_BOT1,STRATEGY_BOT2
Available strategies:
- dummy: Default strategy selecting the first valid move
- ai: AI strategy using the default model at models/trictrac_ppo.zip
- ai:/path/to/model.zip: AI strategy using a custom model
- dqnburn: DQN strategy (burn-rl backend)
- dqnburn:/path/to/model: DQN strategy (burn-rl backend) with custom model
- az: AlphaZero MlpNet (random weights)
- az:/path/to/model.mpk: AlphaZero MlpNet checkpoint
- az-resnet: AlphaZero ResNet (random weights)
- az-resnet:/path/to/model.mpk: AlphaZero ResNet checkpoint
- az-dqn: DQN QNet (random weights, first-legal-move fallback)
- az-dqn:/path/to/model.mpk: DQN QNet checkpoint
ARGS:
<INPUT>
";
fn main() -> Result<()> {
env_logger::init();
let args = match parse_args() {
Ok(v) => v,
Err(e) => {
eprintln!("Error: {e}.");
std::process::exit(1);
}
};
// println!("{:#?}", args);
// Create an application.
let mut app = App::new(args);
// Start the main loop.
while !app.should_quit {
println!("whot?>");
let mut input = String::new();
let _bytecount = io::stdin().read_line(&mut input)?;
app.input(input.trim());
}
// display app final state
println!("{}", app.display());
Ok(())
}
fn parse_args() -> Result<AppArgs, pico_args::Error> {
let mut pargs = pico_args::Arguments::from_env();
// Help has a higher priority and should be handled separately.
if pargs.contains(["-h", "--help"]) {
print!("{HELP}");
std::process::exit(0);
}
let args = AppArgs {
// Parses an optional value that implements `FromStr`.
seed: pargs.opt_value_from_str("--seed")?,
bot: pargs.opt_value_from_str("--bot")?,
// Parses an optional value from `&str` using a specified function.
// width: pargs.opt_value_from_fn("--width", parse_width)?.unwrap_or(10),
};
// It's up to the caller what to do with the remaining arguments.
let remaining = pargs.finish();
if !remaining.is_empty() {
eprintln!("Warning: unused arguments left: {remaining:?}.");
}
Ok(args)
}
// fn parse_width(s: &str) -> Result<u32, &'static str> {
// s.parse().map_err(|_| "not a number")
// }