trictrac/store/src/game.rs

1279 lines
45 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! # Play a TricTrac Game
use crate::board::{Board, CheckerMove};
use crate::dice::Dice;
use crate::game_rules_moves::MoveRules;
use crate::game_rules_points::{PointsRules, PossibleJans, PossibleJansMethods};
use crate::player::{Color, Player, PlayerId};
// use anyhow::{Context, Result};
use log::{debug, error};
// use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::{fmt, str};
use base64::{engine::general_purpose, Engine as _};
/// The different stages a game can be in. (not to be confused with the entire "GameState")
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Stage {
PreGame,
InGame,
Ended,
}
/// The different stages a game turn can be in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TurnStage {
RollDice,
RollWaiting,
MarkPoints,
HoldOrGoChoice,
Move,
MarkAdvPoints,
}
impl From<u8> for TurnStage {
fn from(item: u8) -> Self {
match item {
0 => TurnStage::RollWaiting,
1 => TurnStage::RollDice,
2 => TurnStage::MarkPoints,
3 => TurnStage::HoldOrGoChoice,
4 => TurnStage::Move,
5 => TurnStage::MarkAdvPoints,
_ => TurnStage::RollWaiting,
}
}
}
impl From<TurnStage> for u8 {
fn from(stage: TurnStage) -> u8 {
match stage {
TurnStage::RollWaiting => 0,
TurnStage::RollDice => 1,
TurnStage::MarkPoints => 2,
TurnStage::HoldOrGoChoice => 3,
TurnStage::Move => 4,
TurnStage::MarkAdvPoints => 5,
}
}
}
/// Represents a TricTrac game
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameState {
pub stage: Stage,
pub turn_stage: TurnStage,
pub board: Board,
pub active_player_id: PlayerId,
pub players: HashMap<PlayerId, Player>,
pub history: Vec<GameEvent>,
/// last dice pair rolled
pub dice: Dice,
/// players points computed for the last dice pair rolled
pub dice_points: (u8, u8),
pub dice_moves: (CheckerMove, CheckerMove),
pub dice_jans: PossibleJans,
/// true if player needs to roll first
roll_first: bool,
// NOTE: add to a Setting struct if other fields needed
pub schools_enabled: bool,
}
// implement Display trait
impl fmt::Display for GameState {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut s = String::new();
s.push_str(&format!(
"Stage: {:?} / {:?}\n",
self.stage, self.turn_stage
));
s.push_str(&format!("Dice: {:?}\n", self.dice));
let empty_string = String::from("");
s.push_str(&format!(
"Who plays: {}\n",
self.who_plays()
.map(|player| &player.name)
.unwrap_or_else(|| &empty_string)
));
s.push_str(&format!("Board: {:?}\n", self.board));
// s.push_str(&format!("History: {:?}\n", self.history));
write!(f, "{s}")
}
}
impl Default for GameState {
fn default() -> Self {
Self {
stage: Stage::PreGame,
turn_stage: TurnStage::RollDice,
board: Board::default(),
active_player_id: 0,
players: HashMap::new(),
history: Vec::new(),
dice: Dice::default(),
dice_points: (0, 0),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
dice_jans: PossibleJans::default(),
roll_first: true,
schools_enabled: false,
}
}
}
impl Hash for GameState {
fn hash<H: Hasher>(&self, state: &mut H) {
self.to_string_id().hash(state);
}
}
impl GameState {
/// Create a new default game
pub fn new(schools_enabled: bool) -> Self {
let mut gs = GameState::default();
gs.set_schools_enabled(schools_enabled);
gs
}
pub fn new_with_players(p1_name: &str, p2_name: &str) -> Self {
let mut game = Self::default();
if let Some(p1) = game.init_player(p1_name) {
game.init_player(p2_name);
let _ = game
.consume(&GameEvent::BeginGame { goes_first: p1 })
.inspect_err(|e| error!("{}", e));
}
game
}
pub fn mirror(&self) -> GameState {
let mirrored_active_player = if self.active_player_id == 1 { 2 } else { 1 };
let mut mirrored_players = HashMap::new();
if let Some(p2) = self.players.get(&2) {
mirrored_players.insert(1, p2.mirror());
}
if let Some(p1) = self.players.get(&1) {
mirrored_players.insert(2, p1.mirror());
}
let (move1, move2) = self.dice_moves;
GameState {
stage: self.stage,
turn_stage: self.turn_stage,
board: self.board.mirror(),
active_player_id: mirrored_active_player,
// active_player_id: self.active_player_id,
players: mirrored_players,
history: Vec::new(),
dice: self.dice,
dice_points: self.dice_points,
dice_moves: (move1.mirror(), move2.mirror()),
dice_jans: self.dice_jans.mirror(),
roll_first: self.roll_first,
schools_enabled: self.schools_enabled,
}
}
fn set_schools_enabled(&mut self, schools_enabled: bool) {
self.schools_enabled = schools_enabled;
}
fn get_active_player(&self) -> Option<&Player> {
self.players.get(&self.active_player_id)
}
fn get_opponent_id(&self) -> Option<PlayerId> {
self.players
.keys()
.copied()
.filter(|k| k != &self.active_player_id)
.collect::<Vec<PlayerId>>()
.first()
.copied()
}
// -------------------------------------------------------------------------
// accessors
// -------------------------------------------------------------------------
pub fn to_vec_float(&self) -> Vec<f32> {
self.to_vec().iter().map(|&x| x as f32).collect()
}
/// Get state as a tensor for neural network training (Option B, TD-Gammon style).
/// Returns 217 f32 values, all normalized to [0, 1].
///
/// Must be called from the active player's perspective: callers should mirror
/// the GameState for Black before calling so that "own" always means White.
///
/// Layout:
/// [0..95] own (White) checkers: 4 values per field × 24 fields
/// [96..191] opp (Black) checkers: 4 values per field × 24 fields
/// [192..193] dice values / 6
/// [194] active player color (0=White, 1=Black)
/// [195] turn_stage / 5
/// [196..199] White player: points/12, holes/12, can_bredouille, can_big_bredouille
/// [200..203] Black player: same
/// [204..207] own quarter filled (quarters 1-4)
/// [208..211] opp quarter filled (quarters 1-4)
/// [212] own checkers all in exit zone (fields 19-24)
/// [213] opp checkers all in exit zone (fields 1-6)
/// [214] own coin de repos taken (field 12 has ≥2 own checkers)
/// [215] opp coin de repos taken (field 13 has ≥2 opp checkers)
/// [216] own dice_roll_count / 3, clamped to 1
pub fn to_tensor(&self) -> Vec<f32> {
let mut t = Vec::with_capacity(217);
let pos: Vec<i8> = self.board.to_vec(); // 24 elements, positive=White, negative=Black
// [0..95] own (White) checkers, TD-Gammon encoding.
// Each field contributes 4 values:
// (count==1), (count==2), (count==3), (count-3)/12 ← all in [0,1]
// The overflow term is divided by 12 because the maximum excess is
// 15 (all checkers) 3 = 12.
for &c in &pos {
let own = c.max(0) as u8;
t.push((own == 1) as u8 as f32);
t.push((own == 2) as u8 as f32);
t.push((own == 3) as u8 as f32);
t.push(own.saturating_sub(3) as f32 / 12.0);
}
// [96..191] opp (Black) checkers, same encoding.
for &c in &pos {
let opp = (-c).max(0) as u8;
t.push((opp == 1) as u8 as f32);
t.push((opp == 2) as u8 as f32);
t.push((opp == 3) as u8 as f32);
t.push(opp.saturating_sub(3) as f32 / 12.0);
}
// [192..193] dice
t.push(self.dice.values.0 as f32 / 6.0);
t.push(self.dice.values.1 as f32 / 6.0);
// [194] active player color
t.push(
self.who_plays()
.map(|p| if p.color == Color::Black { 1.0f32 } else { 0.0 })
.unwrap_or(0.0),
);
// [195] turn stage
t.push(u8::from(self.turn_stage) as f32 / 5.0);
// [196..199] White player stats
let wp = self.get_white_player();
t.push(wp.map_or(0.0, |p| p.points as f32 / 12.0));
t.push(wp.map_or(0.0, |p| p.holes as f32 / 12.0));
t.push(wp.map_or(0.0, |p| p.can_bredouille as u8 as f32));
t.push(wp.map_or(0.0, |p| p.can_big_bredouille as u8 as f32));
// [200..203] Black player stats
let bp = self.get_black_player();
t.push(bp.map_or(0.0, |p| p.points as f32 / 12.0));
t.push(bp.map_or(0.0, |p| p.holes as f32 / 12.0));
t.push(bp.map_or(0.0, |p| p.can_bredouille as u8 as f32));
t.push(bp.map_or(0.0, |p| p.can_big_bredouille as u8 as f32));
// [204..207] own (White) quarter fill status
for &start in &[1usize, 7, 13, 19] {
t.push(self.board.is_quarter_filled(Color::White, start) as u8 as f32);
}
// [208..211] opp (Black) quarter fill status
for &start in &[1usize, 7, 13, 19] {
t.push(self.board.is_quarter_filled(Color::Black, start) as u8 as f32);
}
// [212] can_exit_own: no own checker in fields 1-18
t.push(pos[0..18].iter().all(|&c| c <= 0) as u8 as f32);
// [213] can_exit_opp: no opp checker in fields 7-24
t.push(pos[6..24].iter().all(|&c| c >= 0) as u8 as f32);
// [214] own coin de repos taken (field 12 = index 11, ≥2 own checkers)
t.push((pos[11] >= 2) as u8 as f32);
// [215] opp coin de repos taken (field 13 = index 12, ≥2 opp checkers)
t.push((pos[12] <= -2) as u8 as f32);
// [216] own dice_roll_count / 3, clamped to 1
t.push((wp.map_or(0, |p| p.dice_roll_count) as f32 / 3.0).min(1.0));
debug_assert_eq!(t.len(), 217, "to_tensor length mismatch");
t
}
/// Get state as a vector (to be used for bot training input) :
/// length = 36
/// i8 for board positions with negative values for blacks
pub fn to_vec(&self) -> Vec<i8> {
let state_len = 36;
let mut state = Vec::with_capacity(state_len);
// length = 24
state.extend(self.board.to_vec());
// active player -> length = 1
// white : 0 (false)
// black : 1 (true)
state.push(
self.who_plays()
.map(|player| if player.color == Color::Black { 1 } else { 0 })
.unwrap_or(0), // White by default
);
// step -> length = 1
let turn_stage: u8 = self.turn_stage.into();
state.push(turn_stage as i8);
// dice roll -> length = 2
state.push(self.dice.values.0 as i8);
state.push(self.dice.values.1 as i8);
// points, trous, bredouille, grande bredouille length=4 x2 joueurs = 8
let white_player: Vec<i8> = self
.get_white_player()
.map(|p| p.to_vec().iter().map(|&x| x as i8).collect())
.unwrap_or(vec![0; 10]);
state.extend(white_player);
let black_player: Vec<i8> = self
.get_black_player()
.map(|p| p.to_vec().iter().map(|&x| x as i8).collect())
.unwrap_or(vec![0; 10]);
state.extend(black_player);
// ensure state has length state_len
state.truncate(state_len);
while state.len() < state_len {
state.push(0);
}
state
}
/// Calculate game state id :
pub fn to_string_id_slow(&self) -> String {
// Pieces placement -> 77 bits (24 + 23 + 30 max)
let mut pos_bits = self.board.to_gnupg_pos_id();
// active player -> 1 bit
// white : 0 (false)
// black : 1 (true)
pos_bits.push(
self.who_plays()
.map(|player| {
if player.color == Color::Black {
'1'
} else {
'0'
}
})
.unwrap_or('0'), // White by default
);
// step -> 3 bits
let step_bits = match self.turn_stage {
TurnStage::RollWaiting => "000",
TurnStage::RollDice => "001",
TurnStage::MarkPoints => "010",
TurnStage::HoldOrGoChoice => "011",
TurnStage::Move => "100",
TurnStage::MarkAdvPoints => "101",
};
pos_bits.push_str(step_bits);
// dice roll -> 6 bits
let dice_bits = self.dice.to_bits_string();
pos_bits.push_str(&dice_bits);
// points 10bits x2 joueurs = 20bits
let white_bits = self
.get_white_player()
.map(|p| p.to_bits_string())
.unwrap_or("0000000000".into());
let black_bits = self
.get_black_player()
.map(|p| p.to_bits_string())
.unwrap_or("0000000000".into());
pos_bits.push_str(&white_bits);
pos_bits.push_str(&black_bits);
pos_bits = format!("{pos_bits:0<108}");
// println!("{}", pos_bits);
// let pos_u8 = pos_bits
// .as_bytes()
// .chunks(6)
// .map(|chunk| str::from_utf8(chunk).unwrap())
// .map(|chunk| u8::from_str_radix(chunk, 2).unwrap())
// .collect::<Vec<u8>>();
let pos_u8 = pos_bits
.as_bytes()
.chunks(6)
.map(|chunk| chunk.iter().fold(0u8, |acc, &b| (acc << 1) | (b - b'0')))
.collect::<Vec<u8>>();
general_purpose::STANDARD.encode(pos_u8)
}
pub fn to_string_id(&self) -> String {
const TOTAL_BITS: usize = 108;
const TOTAL_BYTES: usize = TOTAL_BITS / 6; // 18 bytes
let mut output = Vec::with_capacity(TOTAL_BYTES);
let mut current: u8 = 0;
let mut bit_count: u8 = 0;
// helper to push a single bit
let push_bit = |bit: u8, output: &mut Vec<u8>, current: &mut u8, bit_count: &mut u8| {
*current = (*current << 1) | (bit & 1);
*bit_count += 1;
if *bit_count == 6 {
output.push(*current);
*current = 0;
*bit_count = 0;
}
};
// helper to push a string of '0'/'1'
let push_bits_str =
|bits: &str, output: &mut Vec<u8>, current: &mut u8, bit_count: &mut u8| {
for b in bits.bytes() {
push_bit(b - b'0', output, current, bit_count);
}
};
// --------------------------------------------------
// 1⃣ Board position bits
// --------------------------------------------------
push_bits_str(
&self.board.to_gnupg_pos_id(),
&mut output,
&mut current,
&mut bit_count,
);
// --------------------------------------------------
// 2⃣ Active player (1 bit)
// --------------------------------------------------
let active_bit = self
.who_plays()
.map(|player| (player.color == Color::Black) as u8)
.unwrap_or(0);
push_bit(active_bit, &mut output, &mut current, &mut bit_count);
// --------------------------------------------------
// 3⃣ Turn stage (3 bits)
// --------------------------------------------------
let stage_bits: u8 = match self.turn_stage {
TurnStage::RollWaiting => 0b000,
TurnStage::RollDice => 0b001,
TurnStage::MarkPoints => 0b010,
TurnStage::HoldOrGoChoice => 0b011,
TurnStage::Move => 0b100,
TurnStage::MarkAdvPoints => 0b101,
};
for i in (0..3).rev() {
push_bit(
(stage_bits >> i) & 1,
&mut output,
&mut current,
&mut bit_count,
);
}
// --------------------------------------------------
// 4⃣ Dice (6 bits)
// --------------------------------------------------
push_bits_str(
&self.dice.to_bits_string(),
&mut output,
&mut current,
&mut bit_count,
);
// --------------------------------------------------
// 5⃣ Players points (10 bits each)
// --------------------------------------------------
let white_bits = self
.get_white_player()
.map(|p| p.to_bits_string())
.unwrap_or_else(|| "0000000000".to_string());
let black_bits = self
.get_black_player()
.map(|p| p.to_bits_string())
.unwrap_or_else(|| "0000000000".to_string());
push_bits_str(&white_bits, &mut output, &mut current, &mut bit_count);
push_bits_str(&black_bits, &mut output, &mut current, &mut bit_count);
// --------------------------------------------------
// 6⃣ Pad remaining bits (if needed)
// --------------------------------------------------
while output.len() < TOTAL_BYTES {
push_bit(0, &mut output, &mut current, &mut bit_count);
}
base64::engine::general_purpose::STANDARD.encode(output)
}
pub fn from_string_id(id: &str) -> Result<Self, String> {
let bytes = general_purpose::STANDARD
.decode(id)
.map_err(|e| e.to_string())?;
let bits_str: String = bytes.iter().map(|byte| format!("{:06b}", byte)).collect();
// The original string was padded to 108 bits.
let bits = if bits_str.len() >= 108 {
&bits_str[..108]
} else {
return Err("Invalid decoded string length".to_string());
};
let board_bits = &bits[0..77];
let board = Board::from_gnupg_pos_id(board_bits)?;
let Some(active_player_bit) = bits.chars().nth(77) else {
return Err("No bit at 77th position".to_string());
};
let active_player_color = if active_player_bit == '1' {
Color::Black
} else {
Color::White
};
let turn_stage_bits = &bits[78..81];
let turn_stage = match turn_stage_bits {
"000" => TurnStage::RollWaiting,
"001" => TurnStage::RollDice,
"010" => TurnStage::MarkPoints,
"011" => TurnStage::HoldOrGoChoice,
"100" => TurnStage::Move,
"101" => TurnStage::MarkAdvPoints,
_ => return Err(format!("Invalid bits for turn stage : {turn_stage_bits}")),
};
let dice_bits = &bits[81..87];
let dice = Dice::from_bits_string(dice_bits).map_err(|e| e.to_string())?;
let white_player_bits = &bits[87..97];
let black_player_bits = &bits[97..107];
let white_player =
Player::from_bits_string(white_player_bits, "Player 1".to_string(), Color::White)
.map_err(|e| e.to_string())?;
let black_player =
Player::from_bits_string(black_player_bits, "Player 2".to_string(), Color::Black)
.map_err(|e| e.to_string())?;
let mut players = HashMap::new();
players.insert(1, white_player);
players.insert(2, black_player);
let active_player_id = if active_player_color == Color::White {
1
} else {
2
};
// Some fields are not in the ID, so we use defaults.
Ok(GameState {
stage: Stage::InGame, // Assume InGame from ID
turn_stage,
board,
active_player_id,
players,
history: Vec::new(),
dice,
dice_points: (0, 0),
dice_moves: (CheckerMove::default(), CheckerMove::default()),
dice_jans: PossibleJans::default(),
roll_first: false, // Assume not first roll
schools_enabled: false, // Assume disabled
})
}
pub fn who_plays(&self) -> Option<&Player> {
self.get_active_player()
}
pub fn get_white_player(&self) -> Option<&Player> {
self.players
.iter()
.filter(|(_id, player)| player.color == Color::White)
.map(|(_id, player)| player)
.next()
}
pub fn get_black_player(&self) -> Option<&Player> {
self.players
.iter()
.filter(|(_id, player)| player.color == Color::Black)
.map(|(_id, player)| player)
.next()
}
pub fn player_id_by_color(&self, color: Color) -> Option<&PlayerId> {
self.players
.iter()
.filter(|(_id, player)| player.color == color)
.map(|(id, _player)| id)
.next()
}
pub fn player_id(&self, player: &Player) -> Option<&PlayerId> {
self.players
.iter()
.filter(|(_id, candidate)| player.color == candidate.color)
.map(|(id, _candidate)| id)
.next()
}
pub fn player_color_by_id(&self, player_id: &PlayerId) -> Option<Color> {
self.players
.iter()
.filter(|(id, _)| *id == player_id)
.map(|(_, player)| player.color)
.next()
}
// ----------------------------------------------------------------------------------
// Rules checks
// ----------------------------------------------------------------------------------
/// Determines whether an event is valid considering the current GameState
pub fn validate(&self, event: &GameEvent) -> bool {
use GameEvent::*;
match event {
BeginGame { goes_first } => {
// Check that the player supposed to go first exists
if !self.players.contains_key(goes_first) {
return false;
}
// Check that the game hasn't started yet. (we don't want to double start a game)
if self.stage != Stage::PreGame {
return false;
}
}
EndGame { reason } => {
if let EndGameReason::PlayerWon { winner: _ } = reason {
// Check that the game has started before someone wins it
if self.stage != Stage::InGame {
return false;
}
}
}
PlayerJoined { player_id, name: _ } => {
// Check that there isn't another player with the same id
if self.players.contains_key(player_id) {
return false;
}
}
PlayerDisconnected { player_id } => {
// Check player exists
if !self.players.contains_key(player_id) {
return false;
}
}
Roll { player_id } => {
// Check player exists
if !self.players.contains_key(player_id) {
error!("unknown player_id");
return false;
}
// Check player is currently the one making their move
if self.active_player_id != *player_id {
error!("not active player_id");
return false;
}
// Check the turn stage
if self.turn_stage != TurnStage::RollDice {
error!("bad stage {:?}", self.turn_stage);
return false;
}
}
RollResult { player_id, dice: _ } => {
// Check player exists
if !self.players.contains_key(player_id) {
return false;
}
// Check player is currently the one making their move
if self.active_player_id != *player_id {
return false;
}
// Check the turn stage
if self.turn_stage != TurnStage::RollWaiting {
error!("bad stage {:?}", self.turn_stage);
return false;
}
}
Mark {
player_id,
points: _,
} => {
// Check player exists
if !self.players.contains_key(player_id) {
return false;
}
// Check player is currently the one making their move
if self.active_player_id != *player_id {
return false;
}
// Check points are correct
// let (board, moves) = if *color == Color::Black {
// (board.mirror(), (moves.0.mirror(), moves.1.mirror()))
// } else {
// (board.clone(), *moves)
// };
// let rules_points: u8 = self.get_points().iter().map(|r| r.0).sum();
// if rules_points != *points {
// return false;
// }
}
Go { player_id } => {
if !self.players.contains_key(player_id) {
error!("Player {player_id} unknown");
return false;
}
// Check player is currently the one making their move
if self.active_player_id != *player_id {
error!("Player not active : {}", self.active_player_id);
return false;
}
// Check the player can leave (ie the game is in the KeepOrLeaveChoice stage)
if self.turn_stage != TurnStage::HoldOrGoChoice {
error!("bad stage {:?}", self.turn_stage);
error!(
"black player points : {:?}",
self.get_black_player()
.map(|player| (player.points, player.holes))
);
// error!("history {:?}", self.history);
return false;
}
}
Move { player_id, moves } => {
// Check player exists
if !self.players.contains_key(player_id) {
error!("Player {player_id} unknown");
return false;
}
// Check player is currently the one making their move
if self.active_player_id != *player_id {
error!("Player not active : {}", self.active_player_id);
return false;
}
// Check the turn stage
if self.turn_stage != TurnStage::Move
&& self.turn_stage != TurnStage::HoldOrGoChoice
{
error!("bad stage {:?}", self.turn_stage);
return false;
}
let color = &self.players[player_id].color;
let rules = MoveRules::new(color, &self.board, self.dice);
let moves = if *color == Color::Black {
(moves.0.mirror(), moves.1.mirror())
} else {
*moves
};
if !rules.moves_follow_rules(&moves) {
// println!(">>> rules not followed ");
error!("rules not followed ");
return false;
}
}
PlayError => {
return true;
}
}
// We couldn't find anything wrong with the event so it must be good
true
}
// ----------------------------------------------------------------------------------
// State updates
// ----------------------------------------------------------------------------------
pub fn init_player(&mut self, player_name: &str) -> Option<PlayerId> {
if self.players.len() > 2 {
// println!("more than two players");
return None;
}
let player_id = self.players.len() + 1;
let color = if player_id == 1 {
Color::White
} else {
Color::Black
};
let player = Player::new(player_name.into(), color);
self.players.insert(player_id as PlayerId, player);
Some(player_id as PlayerId)
}
#[cfg(test)]
fn add_player(&mut self, player_id: PlayerId, player: Player) {
self.players.insert(player_id, player);
}
pub fn switch_active_player(&mut self) {
let other_player_id = self
.players
.iter()
.filter(|(id, _player)| **id != self.active_player_id)
.map(|(id, _player)| *id)
.next();
self.active_player_id = other_player_id.unwrap_or(0);
}
/// Consumes an event, modifying the GameState and adding the event to its history
/// NOTE: consume assumes the event to have already been validated and will accept *any* event passed to it
pub fn consume(&mut self, valid_event: &GameEvent) -> Result<(), String> {
use GameEvent::*;
match valid_event {
BeginGame { goes_first } => {
self.active_player_id = *goes_first;
self.stage = Stage::InGame;
self.turn_stage = TurnStage::RollDice;
}
EndGame { reason: _ } => {
self.stage = Stage::Ended;
}
PlayerJoined { player_id, name } => {
let color = if !self.players.is_empty() {
Color::White
} else {
Color::Black
};
self.players.insert(
*player_id,
Player {
name: name.to_string(),
color,
holes: 0,
points: 0,
can_bredouille: true,
can_big_bredouille: true,
dice_roll_count: 0,
},
);
}
PlayerDisconnected { player_id } => {
self.players.remove(player_id);
}
Roll { player_id: _ } => {
self.turn_stage = TurnStage::RollWaiting;
}
RollResult { player_id: _, dice } => {
self.dice = *dice;
self.inc_roll_count(self.active_player_id);
self.turn_stage = TurnStage::MarkPoints;
(self.dice_jans, self.dice_points) = self.get_rollresult_jans(dice)?;
debug!("points from result : {:?}", self.dice_points);
if !self.schools_enabled {
// Schools are not enabled. We mark points automatically
// the points earned by the opponent will be marked on its turn
let new_hole = self.mark_points(self.active_player_id, self.dice_points.0);
if new_hole {
let Some(holes_count) = self.get_active_player().map(|p| p.holes) else {
return Err("No active player".into());
};
debug!("new hole -> {holes_count:?}");
if holes_count > 12 {
self.stage = Stage::Ended;
} else {
self.turn_stage = TurnStage::HoldOrGoChoice;
}
} else {
self.turn_stage = TurnStage::Move;
}
}
}
Mark { player_id, points } => {
if self.schools_enabled {
let new_hole = self.mark_points(*player_id, *points);
if new_hole {
let Some(holes) = self.get_active_player().map(|p| p.holes) else {
return Err("No active player".into());
};
if holes > 12 {
self.stage = Stage::Ended;
} else {
self.turn_stage = if self.turn_stage == TurnStage::MarkAdvPoints {
TurnStage::RollDice
} else {
TurnStage::HoldOrGoChoice
};
}
} else {
self.turn_stage = if self.turn_stage == TurnStage::MarkAdvPoints {
TurnStage::RollDice
} else {
TurnStage::Move
};
}
}
}
Go { player_id: _ } => self.new_pick_up(),
Move { player_id, moves } => {
let Some(player) = self.players.get(player_id) else {
return Err("unknown player {player_id}".into());
};
self.board
.move_checker(&player.color, moves.0)
.map_err(|e| e.to_string())?;
self.board
.move_checker(&player.color, moves.1)
.map_err(|e| e.to_string())?;
self.dice_moves = *moves;
let Some(active_player_id) = self.players.keys().find(|id| *id != player_id) else {
return Err("Can't find player id {id}".into());
};
self.active_player_id = *active_player_id;
self.turn_stage = if self.schools_enabled {
TurnStage::MarkAdvPoints
} else {
// The player has moved, we can mark its opponent's points (which is now the current player)
let new_hole = self.mark_points(self.active_player_id, self.dice_points.1);
if new_hole && self.get_active_player().map(|p| p.holes).unwrap_or(0) > 12 {
self.stage = Stage::Ended;
}
TurnStage::RollDice
};
}
PlayError => {}
}
self.history.push(valid_event.clone());
Ok(())
}
/// Set a new pick up ('relevé') after a player won a hole and choose to 'go',
/// or after a player has bore off (took of his men off the board)
fn new_pick_up(&mut self) {
self.players.iter_mut().for_each(|(_id, p)| {
// reset points
p.points = 0;
// reset dice_roll_count
p.dice_roll_count = 0;
// reset bredouille
p.can_bredouille = true;
// XXX : switch colors
// désactivé pour le moment car la vérification des mouvements échoue, cf. https://code.rhumbs.fr/henri/trictrac/issues/31
// p.color = p.color.opponent_color();
});
// joueur actif = joueur ayant sorti ses dames ou est parti (donc deux jeux successifs)
self.turn_stage = TurnStage::RollDice;
// reset board
self.board = Board::new();
}
fn get_rollresult_jans(&self, dice: &Dice) -> Result<(PossibleJans, (u8, u8)), String> {
let Some(player) = &self.players.get(&self.active_player_id) else {
return Err("No active player".into());
};
debug!(
"get rollresult for {:?} {:?} {:?} (roll count {:?})",
player.color, self.board, dice, player.dice_roll_count
);
let points_rules = PointsRules::new(&player.color, &self.board, *dice);
Ok(points_rules.get_result_jans(player.dice_roll_count))
}
/// Determines if someone has won the game
pub fn determine_winner(&self) -> Option<PlayerId> {
// A player has won if he has got 12 holes
self.players
.iter()
.filter(|(_, p)| p.holes > 11)
.map(|(id, _)| *id)
.next()
}
fn inc_roll_count(&mut self, player_id: PlayerId) {
self.players.get_mut(&player_id).map(|p| {
p.dice_roll_count = p.dice_roll_count.saturating_add(1);
p
});
}
pub fn mark_points_for_bot_training(&mut self, player_id: PlayerId, points: u8) -> bool {
self.mark_points(player_id, points)
}
/// Total accumulated score for a player: `holes × 12 + points`.
///
/// Returns `0` if `player_id` is not found (e.g. before `init_player`).
pub fn total_score(&self, player_id: PlayerId) -> i32 {
self.players
.get(&player_id)
.map(|p| p.holes as i32 * 12 + p.points as i32)
.unwrap_or(0)
}
fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool {
// Update player points and holes
let mut new_hole = false;
self.players.get_mut(&player_id).map(|p| {
let sum_points = p.points + points;
let jeux = sum_points / 12;
let holes = match (jeux, p.can_bredouille) {
(0, _) => 0,
(_, false) => 2 * jeux - 1,
(_, true) => 2 * jeux,
};
new_hole = holes > 0;
if new_hole {
p.can_bredouille = true;
}
p.points = sum_points % 12;
p.holes += holes;
// if points > 0 && p.holes > 15 {
if points > 0 {
debug!(
"player {player_id:?} holes : {:?} (+{holes:?}) points : {:?} (+{points:?} - {jeux:?})",
p.holes, p.points
)
}
p
});
// Opponent updates
let maybe_op = if player_id == self.active_player_id {
self.get_opponent_id()
} else {
Some(player_id)
};
if let Some(opp_id) = maybe_op {
if points > 0 {
self.players.get_mut(&opp_id).map(|opponent| {
// Cancel opponent bredouille
opponent.can_bredouille = false;
// Reset opponent points if the player finished a hole
if new_hole {
opponent.points = 0;
opponent.can_bredouille = true;
}
opponent
});
}
}
new_hole
}
}
/// The reasons why a game could end
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Deserialize)]
pub enum EndGameReason {
PlayerLeft { player_id: PlayerId },
PlayerWon { winner: PlayerId },
}
/// An event that progresses the GameState forward
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Deserialize)]
pub enum GameEvent {
BeginGame {
goes_first: PlayerId,
},
EndGame {
reason: EndGameReason,
},
PlayerJoined {
player_id: PlayerId,
name: String,
},
PlayerDisconnected {
player_id: PlayerId,
},
Roll {
player_id: PlayerId,
},
RollResult {
player_id: PlayerId,
dice: Dice,
},
Mark {
player_id: PlayerId,
points: u8,
},
Go {
player_id: PlayerId,
},
Move {
player_id: PlayerId,
moves: (CheckerMove, CheckerMove),
},
PlayError,
}
impl GameEvent {
pub fn player_id(&self) -> Option<PlayerId> {
match self {
Self::PlayerJoined { player_id, name: _ } => Some(*player_id),
Self::PlayerDisconnected { player_id } => Some(*player_id),
Self::Roll { player_id } => Some(*player_id),
Self::RollResult { player_id, dice: _ } => Some(*player_id),
Self::Mark {
player_id,
points: _,
} => Some(*player_id),
Self::Go { player_id } => Some(*player_id),
Self::Move {
player_id,
moves: _,
} => Some(*player_id),
_ => None,
}
}
pub fn get_mirror(&self, preserve_player: bool) -> Self {
// let mut mirror = self.clone();
let mirror_player_id = if let Some(player_id) = self.player_id() {
if preserve_player {
player_id
} else if player_id == 1 {
2
} else {
1
}
} else {
0
};
match self {
Self::PlayerJoined { player_id: _, name } => Self::PlayerJoined {
player_id: mirror_player_id,
name: name.clone(),
},
Self::PlayerDisconnected { player_id: _ } => GameEvent::PlayerDisconnected {
player_id: mirror_player_id,
},
Self::Roll { player_id: _ } => GameEvent::Roll {
player_id: mirror_player_id,
},
Self::RollResult { player_id: _, dice } => GameEvent::RollResult {
player_id: mirror_player_id,
dice: *dice,
},
Self::Mark {
player_id: _,
points,
} => GameEvent::Mark {
player_id: mirror_player_id,
points: *points,
},
Self::Go { player_id: _ } => GameEvent::Go {
player_id: mirror_player_id,
},
Self::Move {
player_id: _,
moves: (move1, move2),
} => Self::Move {
player_id: mirror_player_id,
moves: (move1.mirror(), move2.mirror()),
},
Self::BeginGame { goes_first } => GameEvent::BeginGame {
goes_first: (if *goes_first == 1 { 2 } else { 1 }),
},
Self::EndGame { reason } => GameEvent::EndGame { reason: *reason },
Self::PlayError => GameEvent::PlayError,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn init_test_gamestate(turn: TurnStage) -> GameState {
let mut state = GameState::default();
state.add_player(1, Player::new("player1".into(), Color::White));
state.add_player(2, Player::new("player2".into(), Color::Black));
state.active_player_id = 1;
state.turn_stage = turn;
state
}
#[test]
fn to_string_id() {
let state = init_test_gamestate(TurnStage::RollDice);
let string_id = state.to_string_id();
// println!("string_id : {}", string_id);
assert_eq!(string_id, "Pz84AAAABz8/AAAAAAgAASAG");
let new_state = GameState::from_string_id(&string_id).unwrap();
assert_eq!(state.board, new_state.board);
assert_eq!(state.active_player_id, new_state.active_player_id);
assert_eq!(state.turn_stage, new_state.turn_stage);
assert_eq!(state.dice, new_state.dice);
assert_eq!(
state.get_white_player().unwrap().points,
new_state.get_white_player().unwrap().points
);
}
#[test]
fn hold_or_go() {
let mut game_state = init_test_gamestate(TurnStage::MarkPoints);
game_state.schools_enabled = true;
let pid = game_state.active_player_id;
let _ = game_state.consume(
&(GameEvent::Mark {
player_id: pid,
points: 13,
}),
);
let player = game_state.get_active_player().unwrap();
assert_eq!(player.points, 1);
assert_eq!(player.holes, 2); // because can bredouille
assert_eq!(game_state.turn_stage, TurnStage::HoldOrGoChoice);
// Go
let _ = game_state.consume(
&(GameEvent::Go {
player_id: game_state.active_player_id,
}),
);
assert_eq!(game_state.active_player_id, pid);
let player = game_state.get_active_player().unwrap();
assert_eq!(player.points, 0);
assert_eq!(game_state.turn_stage, TurnStage::RollDice);
// Hold
let mut game_state = init_test_gamestate(TurnStage::MarkPoints);
game_state.schools_enabled = true;
let pid = game_state.active_player_id;
let _ = game_state.consume(
&(GameEvent::Mark {
player_id: pid,
points: 13,
}),
);
let moves = (
CheckerMove::new(1, 3).unwrap(),
CheckerMove::new(1, 3).unwrap(),
);
let _ = game_state.consume(
&(GameEvent::Move {
player_id: game_state.active_player_id,
moves,
}),
);
assert_ne!(game_state.active_player_id, pid);
assert_eq!(game_state.players.get(&pid).unwrap().points, 1);
assert_eq!(game_state.get_active_player().unwrap().points, 0);
assert_eq!(game_state.turn_stage, TurnStage::MarkAdvPoints);
}
}