From 6840d371fc09fdbad42d86b94b6a6ff6fc21dad2 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Thu, 26 Feb 2026 13:54:47 +0100 Subject: [PATCH] docs: python & c++ bindings --- doc/plan_cxxbindings.md | 992 ++++++++++++++++++++++++++++++++++++++++ doc/python_research.md | 536 ++++++++++++++++++++++ 2 files changed, 1528 insertions(+) create mode 100644 doc/plan_cxxbindings.md create mode 100644 doc/python_research.md diff --git a/doc/plan_cxxbindings.md b/doc/plan_cxxbindings.md new file mode 100644 index 0000000..29bf314 --- /dev/null +++ b/doc/plan_cxxbindings.md @@ -0,0 +1,992 @@ +# Plan: C++ OpenSpiel Game via cxx.rs + +> Implementation plan for a native C++ OpenSpiel game for Trictrac, powered by the existing Rust engine through [cxx.rs](https://cxx.rs/) bindings. +> +> Base on reading: `store/src/pyengine.rs`, `store/src/training_common.rs`, `store/src/game.rs`, `store/src/board.rs`, `store/src/player.rs`, `store/src/game_rules_points.rs`, `forks/open_spiel/open_spiel/games/backgammon/backgammon.h`, `forks/open_spiel/open_spiel/games/backgammon/backgammon.cc`, `forks/open_spiel/open_spiel/spiel.h`, `forks/open_spiel/open_spiel/games/CMakeLists.txt`. + +--- + +## 1. Overview + +The Python binding (`pyengine.rs` + `trictrac.py`) wraps the Rust engine via PyO3. The goal here is an analogous C++ binding: + +- **`store/src/cxxengine.rs`** — defines a `#[cxx::bridge]` exposing an opaque `TricTracEngine` Rust type with the same logical API as `pyengine.rs`. +- **`forks/open_spiel/open_spiel/games/trictrac/trictrac.h`** — C++ header for a `TrictracGame : public Game` and `TrictracState : public State`. +- **`forks/open_spiel/open_spiel/games/trictrac/trictrac.cc`** — C++ implementation that holds a `rust::Box` and delegates all logic to Rust. +- Build wired together via **corrosion** (CMake-native Rust integration) and `cxx-build`. + +The resulting C++ game registers itself as `"trictrac"` via `REGISTER_SPIEL_GAME` and is consumable by any OpenSpiel algorithm (AlphaZero, MCTS, etc.) that works with C++ games. + +--- + +## 2. Files to Create / Modify + +``` +trictrac/ + store/ + Cargo.toml ← MODIFY: add cxx, cxx-build, staticlib crate-type + build.rs ← CREATE: cxx-build bridge registration + src/ + lib.rs ← MODIFY: add cxxengine module + cxxengine.rs ← CREATE: #[cxx::bridge] definition + impl + +forks/open_spiel/ + CMakeLists.txt ← MODIFY: add Corrosion FetchContent + open_spiel/ + games/ + CMakeLists.txt ← MODIFY: add trictrac/ sources + test + trictrac/ ← CREATE directory + trictrac.h ← CREATE + trictrac.cc ← CREATE + trictrac_test.cc ← CREATE + + justfile ← MODIFY: add buildtrictrac target +trictrac/ + justfile ← MODIFY: add cxxlib target +``` + +--- + +## 3. Step 1 — Rust: `store/Cargo.toml` + +Add `cxx` as a runtime dependency and `cxx-build` as a build dependency. Add `staticlib` to `crate-type` so CMake can link against the Rust code as a static library. + +```toml +[package] +name = "trictrac-store" +version = "0.1.0" +edition = "2021" + +[lib] +name = "trictrac_store" +# cdylib → Python .so (used by maturin / pyengine) +# rlib → used by other Rust crates in the workspace +# staticlib → used by C++ consumers (cxxengine) +crate-type = ["cdylib", "rlib", "staticlib"] + +[dependencies] +base64 = "0.21.7" +cxx = "1.0" +log = "0.4.20" +merge = "0.1.0" +pyo3 = { version = "0.23", features = ["extension-module", "abi3-py38"] } +rand = "0.9" +serde = { version = "1.0", features = ["derive"] } +transpose = "0.2.2" + +[build-dependencies] +cxx-build = "1.0" +``` + +> **Note on `staticlib` + `cdylib` coexistence.** Cargo will build all three types when asked. The static library is used by the C++ OpenSpiel build; the cdylib is used by maturin for the Python wheel. They do not interfere. The `rlib` is used internally by other workspace members (`bot`, `client_cli`). + +--- + +## 4. Step 2 — Rust: `store/build.rs` + +The `build.rs` script drives `cxx-build`, which compiles the C++ side of the bridge (the generated shim) and tells Cargo where to find the generated header. + +```rust +fn main() { + cxx_build::bridge("src/cxxengine.rs") + .std("c++17") + .compile("trictrac-cxx"); + + // Re-run if the bridge source changes + println!("cargo:rerun-if-changed=src/cxxengine.rs"); +} +``` + +`cxx-build` will: + +- Parse `src/cxxengine.rs` for the `#[cxx::bridge]` block. +- Generate `$OUT_DIR/cxxbridge/include/trictrac_store/src/cxxengine.rs.h` — the C++ header. +- Generate `$OUT_DIR/cxxbridge/sources/trictrac_store/src/cxxengine.rs.cc` — the C++ shim source. +- Compile the shim into `libtrictrac-cxx.a` (alongside the Rust `libtrictrac_store.a`). + +--- + +## 5. Step 3 — Rust: `store/src/cxxengine.rs` + +This is the heart of the C++ integration. It mirrors `pyengine.rs` in structure but uses `#[cxx::bridge]` instead of PyO3. + +### Design decisions vs. `pyengine.rs` + +| pyengine | cxxengine | Reason | +| ------------------------- | ---------------------------- | -------------------------------------------- | +| `PyResult<()>` for errors | `Result<()>` | cxx.rs translates `Err` to a C++ exception | +| `(u8, u8)` tuple for dice | `DicePair` shared struct | cxx cannot cross tuples | +| `Vec` for actions | `Vec` | cxx does not support `usize` | +| `[i32; 2]` for scores | `PlayerScores` shared struct | cxx cannot cross fixed arrays | +| Clone via PyO3 pickling | `clone_engine()` method | OpenSpiel's `State::Clone()` needs deep copy | + +### File content + +```rust +//! # C++ bindings for the TricTrac game engine via cxx.rs +//! +//! Exposes an opaque `TricTracEngine` type and associated functions +//! to C++. The C++ side (trictrac.cc) uses `rust::Box`. +//! +//! The Rust engine always works from the perspective of White (player 1). +//! For Black (player 2), the board is mirrored before computing actions +//! and events are mirrored back before applying — exactly as in pyengine.rs. + +use crate::dice::Dice; +use crate::game::{GameEvent, GameState, Stage, TurnStage}; +use crate::training_common::{get_valid_action_indices, TrictracAction}; + +// ── cxx bridge declaration ──────────────────────────────────────────────────── + +#[cxx::bridge(namespace = "trictrac_engine")] +pub mod ffi { + // ── Shared types (visible to both Rust and C++) ─────────────────────────── + + /// Two dice values passed from C++ to Rust for a dice-roll event. + struct DicePair { + die1: u8, + die2: u8, + } + + /// Both players' scores: holes * 12 + points. + struct PlayerScores { + score_p1: i32, + score_p2: i32, + } + + // ── Opaque Rust type exposed to C++ ─────────────────────────────────────── + + extern "Rust" { + /// Opaque handle to a TricTrac game state. + /// C++ accesses this only through `rust::Box`. + type TricTracEngine; + + /// Create a new engine, initialise two players, begin with player 1. + fn new_trictrac_engine() -> Box; + + /// Return a deep copy of the engine (needed for State::Clone()). + fn clone_engine(self: &TricTracEngine) -> Box; + + // ── Queries ─────────────────────────────────────────────────────────── + + /// True when the game is in TurnStage::RollWaiting (OpenSpiel chance node). + fn needs_roll(self: &TricTracEngine) -> bool; + + /// True when Stage::Ended. + fn is_game_ended(self: &TricTracEngine) -> bool; + + /// Active player index: 0 (player 1 / White) or 1 (player 2 / Black). + fn current_player_idx(self: &TricTracEngine) -> u64; + + /// Legal action indices for `player_idx`. Returns empty vec if it is + /// not that player's turn. Indices are in [0, 513]. + fn get_legal_actions(self: &TricTracEngine, player_idx: u64) -> Vec; + + /// Human-readable action description, e.g. "0:Move { dice_order: true … }". + fn action_to_string(self: &TricTracEngine, player_idx: u64, action_idx: u64) -> String; + + /// Both players' scores: holes * 12 + points. + fn get_players_scores(self: &TricTracEngine) -> PlayerScores; + + /// 36-element state observation vector (i8). Mirrored for player 1. + fn get_tensor(self: &TricTracEngine, player_idx: u64) -> Vec; + + /// Human-readable state description for `player_idx`. + fn get_observation_string(self: &TricTracEngine, player_idx: u64) -> String; + + /// Full debug representation of the current state. + fn to_debug_string(self: &TricTracEngine) -> String; + + // ── Mutations ───────────────────────────────────────────────────────── + + /// Apply a dice roll result. Returns Err if not in RollWaiting stage. + fn apply_dice_roll(self: &mut TricTracEngine, dice: DicePair) -> Result<()>; + + /// Apply a player action (move, go, roll). Returns Err if invalid. + fn apply_action(self: &mut TricTracEngine, action_idx: u64) -> Result<()>; + } +} + +// ── Opaque type implementation ──────────────────────────────────────────────── + +pub struct TricTracEngine { + game_state: GameState, +} + +pub fn new_trictrac_engine() -> Box { + let mut game_state = GameState::new(false); // schools_enabled = false + game_state.init_player("player1"); + game_state.init_player("player2"); + game_state.consume(&GameEvent::BeginGame { goes_first: 1 }); + Box::new(TricTracEngine { game_state }) +} + +impl TricTracEngine { + fn clone_engine(&self) -> Box { + Box::new(TricTracEngine { + game_state: self.game_state.clone(), + }) + } + + fn needs_roll(&self) -> bool { + self.game_state.turn_stage == TurnStage::RollWaiting + } + + fn is_game_ended(&self) -> bool { + self.game_state.stage == Stage::Ended + } + + /// Returns 0 for player 1 (White) and 1 for player 2 (Black). + fn current_player_idx(&self) -> u64 { + self.game_state.active_player_id - 1 + } + + fn get_legal_actions(&self, player_idx: u64) -> Vec { + if player_idx == self.current_player_idx() { + if player_idx == 0 { + get_valid_action_indices(&self.game_state) + .into_iter() + .map(|i| i as u64) + .collect() + } else { + let mirror = self.game_state.mirror(); + get_valid_action_indices(&mirror) + .into_iter() + .map(|i| i as u64) + .collect() + } + } else { + vec![] + } + } + + fn action_to_string(&self, player_idx: u64, action_idx: u64) -> String { + TrictracAction::from_action_index(action_idx as usize) + .map(|a| format!("{}:{}", player_idx, a)) + .unwrap_or_else(|| "unknown action".into()) + } + + fn get_players_scores(&self) -> ffi::PlayerScores { + ffi::PlayerScores { + score_p1: self.score_for(1), + score_p2: self.score_for(2), + } + } + + fn score_for(&self, player_id: u64) -> i32 { + if let Some(player) = self.game_state.players.get(&player_id) { + player.holes as i32 * 12 + player.points as i32 + } else { + -1 + } + } + + fn get_tensor(&self, player_idx: u64) -> Vec { + if player_idx == 0 { + self.game_state.to_vec() + } else { + self.game_state.mirror().to_vec() + } + } + + fn get_observation_string(&self, player_idx: u64) -> String { + if player_idx == 0 { + format!("{}", self.game_state) + } else { + format!("{}", self.game_state.mirror()) + } + } + + fn to_debug_string(&self) -> String { + format!("{}", self.game_state) + } + + fn apply_dice_roll(&mut self, dice: ffi::DicePair) -> Result<(), String> { + let player_id = self.game_state.active_player_id; + if self.game_state.turn_stage != TurnStage::RollWaiting { + return Err("Not in RollWaiting stage".into()); + } + let dice = Dice { + values: (dice.die1, dice.die2), + }; + self.game_state + .consume(&GameEvent::RollResult { player_id, dice }); + Ok(()) + } + + fn apply_action(&mut self, action_idx: u64) -> Result<(), String> { + let action_idx = action_idx as usize; + let needs_mirror = self.game_state.active_player_id == 2; + + let event = TrictracAction::from_action_index(action_idx) + .and_then(|a| { + let game_state = if needs_mirror { + &self.game_state.mirror() + } else { + &self.game_state + }; + a.to_event(game_state) + .map(|e| if needs_mirror { e.get_mirror(false) } else { e }) + }); + + match event { + Some(evt) if self.game_state.validate(&evt) => { + self.game_state.consume(&evt); + Ok(()) + } + Some(_) => Err("Action is invalid".into()), + None => Err("Could not build event from action index".into()), + } + } +} +``` + +> **Note on `Result<(), String>`**: cxx.rs requires the error type to implement `std::error::Error`. `String` does not implement it directly. Two options: +> +> - Use `anyhow::Error` (add `anyhow` dependency). +> - Define a thin newtype `struct EngineError(String)` that implements `std::error::Error`. +> +> The recommended approach is `anyhow`: +> +> ```toml +> [dependencies] +> anyhow = "1.0" +> ``` +> +> Then `fn apply_action(...) -> Result<(), anyhow::Error>` — cxx.rs will convert this to a C++ exception of type `rust::Error` carrying the message. + +--- + +## 6. Step 4 — Rust: `store/src/lib.rs` + +Add the new module: + +```rust +// existing modules … +mod pyengine; + +// NEW: C++ bindings via cxx.rs +pub mod cxxengine; +``` + +--- + +## 7. Step 5 — C++: `trictrac/trictrac.h` + +Modelled closely after `backgammon/backgammon.h`. The state holds a `rust::Box` and delegates everything to it. + +```cpp +// open_spiel/games/trictrac/trictrac.h +#ifndef OPEN_SPIEL_GAMES_TRICTRAC_H_ +#define OPEN_SPIEL_GAMES_TRICTRAC_H_ + +#include +#include +#include + +#include "open_spiel/spiel.h" +#include "open_spiel/spiel_utils.h" + +// Generated by cxx-build from store/src/cxxengine.rs. +// The include path is set by CMake (see CMakeLists.txt). +#include "trictrac_store/src/cxxengine.rs.h" + +namespace open_spiel { +namespace trictrac { + +inline constexpr int kNumPlayers = 2; +inline constexpr int kNumChanceOutcomes = 36; // 6 × 6 dice outcomes +inline constexpr int kNumDistinctActions = 514; // matches ACTION_SPACE_SIZE in Rust +inline constexpr int kStateEncodingSize = 36; // matches to_vec() length in Rust +inline constexpr int kDefaultMaxTurns = 1000; + +class TrictracGame; + +// --------------------------------------------------------------------------- +// TrictracState +// --------------------------------------------------------------------------- +class TrictracState : public State { + public: + explicit TrictracState(std::shared_ptr game); + TrictracState(const TrictracState& other); + + Player CurrentPlayer() const override; + std::vector LegalActions() const override; + std::string ActionToString(Player player, Action move_id) const override; + std::vector> ChanceOutcomes() const override; + std::string ToString() const override; + bool IsTerminal() const override; + std::vector Returns() const override; + std::string ObservationString(Player player) const override; + void ObservationTensor(Player player, absl::Span values) const override; + std::unique_ptr Clone() const override; + + protected: + void DoApplyAction(Action move_id) override; + + private: + // Decode a chance action index [0,35] to (die1, die2). + // Matches Python: [(i,j) for i in range(1,7) for j in range(1,7)][action] + static trictrac_engine::DicePair DecodeChanceAction(Action action); + + // The Rust engine handle. Deep-copied via clone_engine() when cloning state. + rust::Box engine_; +}; + +// --------------------------------------------------------------------------- +// TrictracGame +// --------------------------------------------------------------------------- +class TrictracGame : public Game { + public: + explicit TrictracGame(const GameParameters& params); + + int NumDistinctActions() const override { return kNumDistinctActions; } + std::unique_ptr NewInitialState() const override; + int MaxChanceOutcomes() const override { return kNumChanceOutcomes; } + int NumPlayers() const override { return kNumPlayers; } + double MinUtility() const override { return 0.0; } + double MaxUtility() const override { return 200.0; } + int MaxGameLength() const override { return 3 * max_turns_; } + int MaxChanceNodesInHistory() const override { return MaxGameLength(); } + std::vector ObservationTensorShape() const override { + return {kStateEncodingSize}; + } + + private: + int max_turns_; +}; + +} // namespace trictrac +} // namespace open_spiel + +#endif // OPEN_SPIEL_GAMES_TRICTRAC_H_ +``` + +--- + +## 8. Step 6 — C++: `trictrac/trictrac.cc` + +```cpp +// open_spiel/games/trictrac/trictrac.cc +#include "open_spiel/games/trictrac/trictrac.h" + +#include +#include +#include + +#include "open_spiel/abseil-cpp/absl/types/span.h" +#include "open_spiel/game_parameters.h" +#include "open_spiel/spiel.h" +#include "open_spiel/spiel_globals.h" +#include "open_spiel/spiel_utils.h" + +namespace open_spiel { +namespace trictrac { +namespace { + +// ── Game registration ──────────────────────────────────────────────────────── + +const GameType kGameType{ + /*short_name=*/"trictrac", + /*long_name=*/"Trictrac", + GameType::Dynamics::kSequential, + GameType::ChanceMode::kExplicitStochastic, + GameType::Information::kPerfectInformation, + GameType::Utility::kGeneralSum, + GameType::RewardModel::kRewards, + /*min_num_players=*/kNumPlayers, + /*max_num_players=*/kNumPlayers, + /*provides_information_state_string=*/false, + /*provides_information_state_tensor=*/false, + /*provides_observation_string=*/true, + /*provides_observation_tensor=*/true, + /*parameter_specification=*/{ + {"max_turns", GameParameter(kDefaultMaxTurns)}, + }}; + +static std::shared_ptr Factory(const GameParameters& params) { + return std::make_shared(params); +} + +REGISTER_SPIEL_GAME(kGameType, Factory); + +} // namespace + +// ── TrictracGame ───────────────────────────────────────────────────────────── + +TrictracGame::TrictracGame(const GameParameters& params) + : Game(kGameType, params), + max_turns_(ParameterValue("max_turns", kDefaultMaxTurns)) {} + +std::unique_ptr TrictracGame::NewInitialState() const { + return std::make_unique(shared_from_this()); +} + +// ── TrictracState ───────────────────────────────────────────────────────────── + +TrictracState::TrictracState(std::shared_ptr game) + : State(game), + engine_(trictrac_engine::new_trictrac_engine()) {} + +// Copy constructor: deep-copy the Rust engine via clone_engine(). +TrictracState::TrictracState(const TrictracState& other) + : State(other), + engine_(other.engine_->clone_engine()) {} + +std::unique_ptr TrictracState::Clone() const { + return std::make_unique(*this); +} + +// ── Current player ──────────────────────────────────────────────────────────── + +Player TrictracState::CurrentPlayer() const { + if (engine_->is_game_ended()) return kTerminalPlayerId; + if (engine_->needs_roll()) return kChancePlayerId; + return static_cast(engine_->current_player_idx()); +} + +// ── Legal actions ───────────────────────────────────────────────────────────── + +std::vector TrictracState::LegalActions() const { + if (IsChanceNode()) { + // All 36 dice outcomes are equally likely; return indices 0–35. + std::vector actions(kNumChanceOutcomes); + for (int i = 0; i < kNumChanceOutcomes; ++i) actions[i] = i; + return actions; + } + Player player = CurrentPlayer(); + rust::Vec rust_actions = + engine_->get_legal_actions(static_cast(player)); + std::vector actions; + actions.reserve(rust_actions.size()); + for (uint64_t a : rust_actions) actions.push_back(static_cast(a)); + return actions; +} + +// ── Chance outcomes ─────────────────────────────────────────────────────────── + +std::vector> TrictracState::ChanceOutcomes() const { + SPIEL_CHECK_TRUE(IsChanceNode()); + const double p = 1.0 / kNumChanceOutcomes; + std::vector> outcomes; + outcomes.reserve(kNumChanceOutcomes); + for (int i = 0; i < kNumChanceOutcomes; ++i) outcomes.emplace_back(i, p); + return outcomes; +} + +// ── Apply action ────────────────────────────────────────────────────────────── + +/*static*/ +trictrac_engine::DicePair TrictracState::DecodeChanceAction(Action action) { + // Matches: [(i,j) for i in range(1,7) for j in range(1,7)][action] + return trictrac_engine::DicePair{ + /*die1=*/static_cast(action / 6 + 1), + /*die2=*/static_cast(action % 6 + 1), + }; +} + +void TrictracState::DoApplyAction(Action action) { + if (IsChanceNode()) { + engine_->apply_dice_roll(DecodeChanceAction(action)); + } else { + engine_->apply_action(static_cast(action)); + } +} + +// ── Terminal & returns ──────────────────────────────────────────────────────── + +bool TrictracState::IsTerminal() const { + return engine_->is_game_ended(); +} + +std::vector TrictracState::Returns() const { + trictrac_engine::PlayerScores scores = engine_->get_players_scores(); + return {static_cast(scores.score_p1), + static_cast(scores.score_p2)}; +} + +// ── Observation ─────────────────────────────────────────────────────────────── + +std::string TrictracState::ObservationString(Player player) const { + return std::string(engine_->get_observation_string( + static_cast(player))); +} + +void TrictracState::ObservationTensor(Player player, + absl::Span values) const { + SPIEL_CHECK_EQ(values.size(), kStateEncodingSize); + rust::Vec tensor = + engine_->get_tensor(static_cast(player)); + SPIEL_CHECK_EQ(tensor.size(), static_cast(kStateEncodingSize)); + for (int i = 0; i < kStateEncodingSize; ++i) { + values[i] = static_cast(tensor[i]); + } +} + +// ── Strings ─────────────────────────────────────────────────────────────────── + +std::string TrictracState::ToString() const { + return std::string(engine_->to_debug_string()); +} + +std::string TrictracState::ActionToString(Player player, Action action) const { + if (IsChanceNode()) { + trictrac_engine::DicePair d = DecodeChanceAction(action); + return "(" + std::to_string(d.die1) + ", " + std::to_string(d.die2) + ")"; + } + return std::string(engine_->action_to_string( + static_cast(player), static_cast(action))); +} + +} // namespace trictrac +} // namespace open_spiel +``` + +--- + +## 9. Step 7 — C++: `trictrac/trictrac_test.cc` + +```cpp +// open_spiel/games/trictrac/trictrac_test.cc +#include "open_spiel/games/trictrac/trictrac.h" + +#include +#include + +#include "open_spiel/spiel.h" +#include "open_spiel/tests/basic_tests.h" +#include "open_spiel/utils/init.h" + +namespace open_spiel { +namespace trictrac { +namespace { + +void BasicTrictracTests() { + testing::LoadGameTest("trictrac"); + testing::RandomSimTest(*LoadGame("trictrac"), /*num_sims=*/5); +} + +} // namespace +} // namespace trictrac +} // namespace open_spiel + +int main(int argc, char** argv) { + open_spiel::Init(&argc, &argv); + open_spiel::trictrac::BasicTrictracTests(); + std::cout << "trictrac tests passed" << std::endl; + return 0; +} +``` + +--- + +## 10. Step 8 — Build System: `forks/open_spiel/CMakeLists.txt` + +The top-level `CMakeLists.txt` must be extended to bring in **Corrosion**, the standard CMake module for Rust. Add this block before the main `open_spiel` target is defined: + +```cmake +# ── Corrosion: CMake integration for Rust ──────────────────────────────────── +include(FetchContent) +FetchContent_Declare( + Corrosion + GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git + GIT_TAG v0.5.1 # pin to a stable release +) +FetchContent_MakeAvailable(Corrosion) + +# Import the trictrac-store Rust crate. +# This creates a CMake target named 'trictrac-store'. +corrosion_import_crate( + MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/../../trictrac/store/Cargo.toml + CRATES trictrac-store +) + +# Generate the cxx bridge from cxxengine.rs. +# corrosion_add_cxxbridge: +# - runs cxx-build as part of the Rust build +# - creates a CMake target 'trictrac_cxx_bridge' that: +# * compiles the generated C++ shim +# * exposes INTERFACE include dirs for the generated .rs.h header +corrosion_add_cxxbridge(trictrac_cxx_bridge + CRATE trictrac-store + FILES src/cxxengine.rs +) +``` + +> **Where to insert**: After the `cmake_minimum_required` / `project()` lines and before `add_subdirectory(open_spiel)` (or wherever games are pulled in). Check the actual file structure before editing. + +--- + +## 11. Step 9 — Build System: `open_spiel/games/CMakeLists.txt` + +Two changes: add the new source files to `GAME_SOURCES`, and add a test target. + +### 11.1 Add to `GAME_SOURCES` + +Find the alphabetically correct position (after `tic_tac_toe`, before `trade_comm`) and add: + +```cmake +set(GAME_SOURCES + # ... existing games ... + trictrac/trictrac.cc + trictrac/trictrac.h + # ... remaining games ... +) +``` + +### 11.2 Link cxx bridge into OpenSpiel objects + +The `trictrac` sources need the Rust library and cxx bridge linked in. Since the existing build compiles all `GAME_SOURCES` into `${OPEN_SPIEL_OBJECTS}` as a single object library, you need to ensure the Rust library and cxx bridge are linked when that object library is consumed. + +The cleanest approach is to add the link dependencies to the main `open_spiel` library target. Find where `open_spiel` is defined (likely in `open_spiel/CMakeLists.txt`) and add: + +```cmake +target_link_libraries(open_spiel + PUBLIC + trictrac_cxx_bridge # C++ shim generated by cxx-build + trictrac-store # Rust static library +) +``` + +If modifying the central `open_spiel` target is too disruptive, create an explicit object library for the trictrac game: + +```cmake +add_library(trictrac_game OBJECT + trictrac/trictrac.cc + trictrac/trictrac.h +) +target_include_directories(trictrac_game + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/.. +) +target_link_libraries(trictrac_game + PUBLIC + trictrac_cxx_bridge + trictrac-store + open_spiel_core # or whatever the core target is called +) +``` + +Then reference `$` in relevant executables. + +### 11.3 Add the test + +```cmake +add_executable(trictrac_test + trictrac/trictrac_test.cc + ${OPEN_SPIEL_OBJECTS} + $ +) +target_link_libraries(trictrac_test + PRIVATE + trictrac_cxx_bridge + trictrac-store +) +add_test(trictrac_test trictrac_test) +``` + +--- + +## 12. Step 10 — Justfile updates + +### `trictrac/justfile` — add `cxxlib` target + +Builds the Rust crate as a static library (for use by the C++ build) and confirms the generated header exists: + +```just +cxxlib: + cargo build --release -p trictrac-store + @echo "Static lib: $(ls target/release/libtrictrac_store.a)" + @echo "CXX header: $(find target -name 'cxxengine.rs.h' | head -1)" +``` + +### `forks/open_spiel/justfile` — add `buildtrictrac` and `testtrictrac` + +```just +buildtrictrac: + # Rebuild the Rust static lib first, then CMake + cd ../../trictrac && cargo build --release -p trictrac-store + mkdir -p build && cd build && \ + CXX=$(which clang++) cmake -DCMAKE_BUILD_TYPE=Release ../open_spiel && \ + make -j$(nproc) trictrac_test + +testtrictrac: buildtrictrac + ./build/trictrac_test + +playtrictrac_cpp: + ./build/examples/example --game=trictrac +``` + +--- + +## 13. Key Design Decisions + +### 13.1 Opaque type with `clone_engine()` + +OpenSpiel's `State::Clone()` must return a fully independent copy of the game state (used extensively by search algorithms). Since `TricTracEngine` is an opaque Rust type, C++ cannot copy it directly. The bridge exposes `clone_engine() -> Box` which calls `.clone()` on the inner `GameState` (which derives `Clone`). + +### 13.2 Action encoding: same 514-element space + +The C++ game uses the same 514-action encoding as the Python version and the Rust training code. This means: + +- The same `TrictracAction::to_action_index` / `from_action_index` mapping applies. +- Action 0 = Roll (used as the bridge between Move and the next chance node). +- Actions 2–513 = Move variants (checker ordinal pair + dice order). +- A trained C++ model and Python model share the same action space. + +### 13.3 Chance outcome ordering + +The dice outcome ordering is identical to the Python version: + +``` +action → (die1, die2) +0 → (1,1) 6 → (2,1) ... 35 → (6,6) +``` + +(`die1 = action/6 + 1`, `die2 = action%6 + 1`) + +This matches `_roll_from_chance_idx` in `trictrac.py` exactly, ensuring the two implementations are interchangeable in training pipelines. + +### 13.4 `GameType::Utility::kGeneralSum` + `kRewards` + +Consistent with the Python version. Trictrac is not zero-sum (both players can score positive holes). Intermediate hole rewards are returned by `Returns()` at every state, not just the terminal. + +### 13.5 Mirror pattern preserved + +`get_legal_actions` and `apply_action` in `TricTracEngine` mirror the board for player 2 exactly as `pyengine.rs` does. C++ never needs to know about the mirroring — it simply passes `player_idx` and the Rust engine handles the rest. + +### 13.6 `rust::Box` vs `rust::UniquePtr` + +`rust::Box` (where `T` is an `extern "Rust"` type) is the correct choice for ownership of a Rust type from C++. It owns the heap allocation and drops it when the C++ destructor runs. `rust::UniquePtr` is for C++ types held in Rust. + +### 13.7 Separate struct from `pyengine.rs` + +`TricTracEngine` in `cxxengine.rs` is a separate struct from `TricTrac` in `pyengine.rs`. They both wrap `GameState` but are independent. This avoids: + +- PyO3 and cxx attributes conflicting on the same type. +- Changes to one binding breaking the other. +- Feature-flag complexity. + +--- + +## 14. Known Challenges + +### 14.1 Corrosion path resolution + +`corrosion_import_crate(MANIFEST_PATH ...)` takes a path relative to the CMake source directory. Since the Rust crate lives outside the `forks/open_spiel/` directory, the path will be something like `${CMAKE_CURRENT_SOURCE_DIR}/../../trictrac/store/Cargo.toml`. Verify this resolves correctly on all developer machines (absolute paths are safer but less portable). + +### 14.2 `staticlib` + `cdylib` in one crate + +Rust allows `["cdylib", "rlib", "staticlib"]` in one crate, but there are subtle interactions: + +- The `cdylib` build (for maturin) does not need `staticlib`, and building both doubles the compile time. +- Consider gating `staticlib` behind a Cargo feature: `crate-type` is not directly feature-gatable, but you can work around this with a separate `Cargo.toml` or a workspace profile. +- Alternatively, accept the extra compile time during development. + +### 14.3 Linker symbols from Rust std + +When linking a Rust `staticlib`, the C++ linker must pull in Rust's runtime and standard library symbols. Corrosion handles this automatically by reading the output of `rustc --print native-static-libs` and adding them to the link command. If not using Corrosion, these must be added manually (typically `-ldl -lm -lpthread -lc`). + +### 14.4 `anyhow` for error types + +cxx.rs requires the `Err` type in `Result` to implement `std::error::Error + Send + Sync`. `String` does not satisfy this. Use `anyhow::Error` or define a thin newtype wrapper: + +```rust +use std::fmt; + +#[derive(Debug)] +struct EngineError(String); +impl fmt::Display for EngineError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } +} +impl std::error::Error for EngineError {} +``` + +On the C++ side, errors become `rust::Error` exceptions. Wrap `DoApplyAction` in a try-catch during development to surface Rust errors as `SpielFatalError`. + +### 14.5 `UndoAction` not implemented + +OpenSpiel algorithms that use tree search (e.g., MCTS) may call `UndoAction`. The Rust engine's `GameState` stores a full `history` vec of `GameEvent`s but does not implement undo — the history is append-only. To support undo, `Clone()` is the only reliable strategy (clone before applying, discard clone if undo needed). OpenSpiel's default `UndoAction` raises `SpielFatalError`, which is acceptable for RL training but blocks game-tree search. If search support is needed, the simplest approach is to store a stack of cloned states inside `TrictracState` and pop on undo. + +### 14.6 Generated header path in `#include` + +The `#include "trictrac_store/src/cxxengine.rs.h"` path used in `trictrac.h` must match the actual path that `cxx-build` (via corrosion) places the generated header. With `corrosion_add_cxxbridge`, this is typically handled by the `trictrac_cxx_bridge` target's `INTERFACE_INCLUDE_DIRECTORIES`, which CMake propagates automatically to any target that links against it. Verify by inspecting the generated build directory. + +### 14.7 `rust::String` to `std::string` conversion + +The bridge methods returning `String` (Rust) appear as `rust::String` in C++. The conversion `std::string(engine_->action_to_string(...))` is valid because `rust::String` is implicitly convertible to `std::string`. Verify this works with your cxx version; if not, use `engine_->action_to_string(...).c_str()` or `static_cast(...)`. + +--- + +## 15. Complete File Checklist + +``` +[ ] trictrac/store/Cargo.toml — add cxx, cxx-build, staticlib +[ ] trictrac/store/build.rs — new file: cxx_build::bridge(...) +[ ] trictrac/store/src/lib.rs — add `pub mod cxxengine;` +[ ] trictrac/store/src/cxxengine.rs — new file: full bridge implementation +[ ] trictrac/justfile — add `cxxlib` target +[ ] forks/open_spiel/CMakeLists.txt — add Corrosion, corrosion_import_crate, corrosion_add_cxxbridge +[ ] forks/open_spiel/open_spiel/games/CMakeLists.txt — add trictrac sources + test +[ ] forks/open_spiel/open_spiel/games/trictrac/trictrac.h — new file +[ ] forks/open_spiel/open_spiel/games/trictrac/trictrac.cc — new file +[ ] forks/open_spiel/open_spiel/games/trictrac/trictrac_test.cc — new file +[ ] forks/open_spiel/justfile — add buildtrictrac / testtrictrac +``` + +--- + +## 16. Implementation Order + +Implement in this sequence to get early feedback at each step: + +1. **Rust bridge first** (`Cargo.toml` → `build.rs` → `cxxengine.rs`). Run `cargo build -p trictrac-store` and confirm the static library and generated header are produced. +2. **Verify generated header** by locating `$OUT_DIR/cxxbridge/include/trictrac_store/src/cxxengine.rs.h` and inspecting it. Confirm C++ signatures match expectations. +3. **CMake Corrosion integration** in `forks/open_spiel/CMakeLists.txt`. Confirm `cmake ..` succeeds and the `trictrac_cxx_bridge` and `trictrac-store` targets exist. +4. **Write `trictrac.h` and `trictrac.cc`**. Compile with `make trictrac_test` (or equivalent). Fix linker errors from Rust std symbols if Corrosion doesn't handle them. +5. **Run `trictrac_test`**. Use `testing::RandomSimTest` to exercise the full game loop, catch panics from `get_valid_actions` (the `TurnStage::RollWaiting` panic path), and verify game termination. +6. **Smoke-test with the example runner**: `./build/examples/example --game=trictrac`. + +--- + +# Implementation complete + +All files are in place and trictrac_test passes (168 84 final scores, all assertions pass). + +What was done + +Rust side (from previous session, already complete): + +- trictrac/store/Cargo.toml — added staticlib, cxx, anyhow, cxx-build +- trictrac/store/build.rs — drives cxx-build +- trictrac/store/src/cxxengine.rs — full cxx bridge + TricTracEngine impl +- trictrac/store/src/lib.rs — added pub mod cxxengine; + +C++ side (this session): + +- forks/open_spiel/open_spiel/games/trictrac/trictrac.h — game header +- forks/open_spiel/open_spiel/games/trictrac/trictrac.cc — game implementation +- forks/open_spiel/open_spiel/games/trictrac/trictrac_test.cc — basic test + +Build system: + +- forks/open_spiel/open_spiel/CMakeLists.txt — Corrosion + corrosion_import_crate + corrosion_add_cxxbridge +- forks/open_spiel/open_spiel/games/CMakeLists.txt — trictrac_game OBJECT target + trictrac_test executable + +Justfiles: + +- trictrac/justfile — added cxxlib target +- forks/open_spiel/justfile — added buildtrictrac and testtrictrac + +Fixes discovered during build + +| Issue | Fix | +| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| Corrosion creates trictrac_store (underscore), not trictrac-store | Used trictrac_store in CRATE arg and target_link_libraries | +| FILES src/cxxengine.rs doubled src/src/ | Changed to FILES cxxengine.rs (relative to crate's src/) | +| Include path changed: not trictrac-store/src/cxxengine.rs.h but trictrac_cxx_bridge/cxxengine.h | Updated #include in trictrac.h | +| rust::Error not in inline cxx types | Added #include "rust/cxx.h" to trictrac.cc | +| Init() signature differs in this fork | Changed to Init(argv[0], &argc, &argv, true) | +| libtrictrac_store.a contains PyO3 code → missing Python symbols | Added Python3::Python to target_link_libraries | +| LegalActions() not sorted (OpenSpiel requires ascending) | Added std::sort | +| Duplicate actions for doubles | Added std::unique after sort | +| Returns() returned non-zero at intermediate states, violating invariant with default Rewards() | Returns() now returns {0, 0} at non-terminal states | diff --git a/doc/python_research.md b/doc/python_research.md new file mode 100644 index 0000000..90c568f --- /dev/null +++ b/doc/python_research.md @@ -0,0 +1,536 @@ +# Trictrac — Research Notes: Engine & OpenSpiel Integration + +> Generated from a deep read of `trictrac/store/src/` and `forks/open_spiel/open_spiel/python/games/trictrac.py`. + +--- + +## 1. Architecture Overview + +The project connects two codebases through a compiled Python extension: + +``` +┌─────────────────────────────────────┐ +│ trictrac/store/ (Rust crate) │ +│ - full game rules engine │ +│ - pyengine.rs → PyO3 bindings │ +│ compiled by maturin → .whl │ +└──────────────┬──────────────────────┘ + │ import trictrac_store +┌──────────────▼──────────────────────┐ +│ forks/open_spiel/.../trictrac.py │ +│ - TrictracGame (pyspiel.Game) │ +│ - TrictracState (pyspiel.State) │ +│ registered as "python_trictrac" │ +└─────────────────────────────────────┘ +``` + +Build pipeline: +- `just pythonlib` (in `trictrac/`) → `maturin build -m store/Cargo.toml --release` → `.whl` into `target/wheels/` +- `just installtrictrac` (in `forks/open_spiel/`) → `pip install --force-reinstall` the wheel into the devenv venv + +The Rust crate is named `trictrac-store` (package) but produces a lib named `trictrac_store` (the Python module name, set in `Cargo.toml` `[lib] name`). + +--- + +## 2. Rust Engine: Module Map + +| Module | Responsibility | +|---|---| +| `board.rs` | Board representation, checker manipulation, quarter analysis | +| `dice.rs` | `Dice` struct, `DiceRoller`, bit encoding | +| `player.rs` | `Player` struct (score, bredouille), `Color`, `PlayerId`, `CurrentPlayer` | +| `game.rs` | `GameState` state machine, `GameEvent` enum, `Stage`/`TurnStage` | +| `game_rules_moves.rs` | `MoveRules`: move validation and generation | +| `game_rules_points.rs` | `PointsRules`: jan detection and scoring | +| `training_common.rs` | `TrictracAction` enum, action-space encoding (size 514) | +| `pyengine.rs` | PyO3 Python module exposing `TricTrac` class | +| `lib.rs` | Crate root, re-exports | + +--- + +## 3. Board Representation + +```rust +pub struct Board { + positions: [i8; 24], +} +``` + +- 24 fields indexed 0–23 internally, 1–24 externally. +- Positive values = White checkers on that field; negative = Black. +- Initial state: `[15, 0, ..., 0, -15]` — all 15 white pieces on field 1, all 15 black pieces on field 24. +- Field 0 is a sentinel for "exited the board" (never stored in the array). + +**Mirroring** is the central symmetry operation used throughout: + +```rust +pub fn mirror(&self) -> Self { + let mut positions = self.positions.map(|c| 0 - c); + positions.reverse(); + Board { positions } +} +``` + +This negates all values (swapping who owns each checker) and reverses the array (swapping directions). The entire engine always reasons from White's perspective; Black's moves are handled by mirroring the board first. + +**Quarter structure**: fields 1–6, 7–12, 13–18, 19–24. This maps to the four tables of Trictrac: +- 1–6: White's "petit jan" (own table) +- 7–12: White's "grand jan" +- 13–18: Black's "grand jan" (= White's opponent territory) +- 19–24: Black's "petit jan" / White's "jan de retour" + +The "coin de repos" (rest corner) is field 12 for White, field 13 for Black. + +--- + +## 4. Dice + +```rust +pub struct Dice { + pub values: (u8, u8), +} +``` + +Dice are always a pair (never quadrupled for doubles, unlike Backgammon). The `DiceRoller` uses `StdRng` seeded from OS entropy (or an optional fixed seed for tests). Bit encoding: `"{d1:0>3b}{d2:0>3b}"` — 3 bits each, 6 bits total. + +--- + +## 5. Player State + +```rust +pub struct Player { + pub name: String, + pub color: Color, // White or Black + pub points: u8, // 0–11 (points within current hole) + pub holes: u8, // holes won (game ends at >12) + pub can_bredouille: bool, + pub can_big_bredouille: bool, + pub dice_roll_count: u8, // rolls since last new_pick_up() +} +``` + +`PlayerId` is a `u64` alias. Player 1 = White, Player 2 = Black (set at init time; this is fixed for the session in pyengine). + +--- + +## 6. Game State Machine + +### Stages + +```rust +pub enum Stage { PreGame, InGame, Ended } + +pub enum TurnStage { + RollDice, // 1 — player must request a roll + RollWaiting, // 0 — waiting for dice result from outside + MarkPoints, // 2 — points are being marked (schools mode only) + HoldOrGoChoice, // 3 — player won a hole; choose to Go or Hold + Move, // 4 — player must move checkers + MarkAdvPoints, // 5 — mark opponent's points after the move (schools mode) +} +``` + +### Turn lifecycle (schools disabled — the default in pyengine) + +``` +RollWaiting + │ RollResult → auto-mark points + ├─[no hole]──→ Move + │ │ Move → mark opponent's points → switch player + │ └───────────────────────────────→ RollDice (next player) + └─[hole won]─→ HoldOrGoChoice + ├─ Go ──→ new_pick_up() → RollDice (same player) + └─ Move ──→ mark opponent's points → switch player → RollDice +``` + +In schools mode (`schools_enabled = true`), the player explicitly marks their own points (`Mark` event) and then the opponent's points after moving (`MarkAdvPoints` stage). + +### Key events + +```rust +pub enum GameEvent { + BeginGame { goes_first: PlayerId }, + EndGame { reason: EndGameReason }, + PlayerJoined { player_id, name }, + PlayerDisconnected { player_id }, + Roll { player_id }, // triggers RollWaiting + RollResult { player_id, dice }, // provides dice values + Mark { player_id, points }, // explicit point marking (schools mode) + Go { player_id }, // choose to restart position after hole + Move { player_id, moves: (CheckerMove, CheckerMove) }, + PlayError, +} +``` + +### Initialization in pyengine + +```rust +fn new() -> Self { + let mut game_state = GameState::new(false); // schools_enabled = false + game_state.init_player("player1"); + game_state.init_player("player2"); + game_state.consume(&GameEvent::BeginGame { goes_first: 1 }); + TricTrac { game_state } +} +``` + +Player 1 (White) always goes first. `active_player_id` uses 1-based indexing; pyengine converts to 0-based for the Python side with `active_player_id - 1`. + +--- + +## 7. Scoring System (Jans) + +Points are awarded after each dice roll based on "jans" (scoring events) detected by `PointsRules`. All computation assumes White's perspective (board is mirrored for Black before calling). + +### Jan types + +| Jan | Points (normal / doublet) | Direction | +|---|---|---| +| `TrueHitSmallJan` | 4 / 6 | → active player | +| `TrueHitBigJan` | 2 / 4 | → active player | +| `TrueHitOpponentCorner` | 4 / 6 | → active player | +| `FilledQuarter` | 4 / 6 | → active player | +| `FirstPlayerToExit` | 4 / 6 | → active player | +| `SixTables` | 4 / 6 | → active player | +| `TwoTables` | 4 / 6 | → active player | +| `Mezeas` | 4 / 6 | → active player | +| `FalseHitSmallJan` | −4 / −6 | → opponent | +| `FalseHitBigJan` | −2 / −4 | → opponent | +| `ContreTwoTables` | −4 / −6 | → opponent | +| `ContreMezeas` | −4 / −6 | → opponent | +| `HelplessMan` | −2 / −4 | → opponent | + +A single roll can trigger multiple jans, each scored independently. The jan detection process: +1. Try both dice orderings +2. Detect "tout d'une" (combined dice move as a virtual single die) +3. Prefer true hits over false hits for the same move +4. Check quarter-filling opportunities +5. Check rare jans (SixTables at roll 3, TwoTables, Mezeas) given specific board positions and talon counts + +### Hole scoring + +```rust +fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool { + let sum_points = p.points + points; + let jeux = sum_points / 12; // number of completed holes + let holes = match (jeux, p.can_bredouille) { + (0, _) => 0, + (_, false) => 2 * jeux - 1, // no bredouille bonus + (_, true) => 2 * jeux, // bredouille doubles the holes + }; + p.points = sum_points % 12; + p.holes += holes; + ... +} +``` + +- 12 points = 1 "jeu", which yields 1 or 2 holes depending on bredouille status. +- Scoring any points clears the opponent's `can_bredouille`. +- Completing a hole resets `can_bredouille` for the scorer. +- Game ends when `holes > 12`. +- Score reported to OpenSpiel: `holes * 12 + points`. + +### Points from both rolls + +After a roll, the active player's points (`dice_points.0`) are auto-marked immediately. After the Move, the opponent's points (`dice_points.1`) are marked (they were computed at roll-time from the pre-move board). + +--- + +## 8. Move Rules + +`MoveRules` always works from White's perspective. Key constraints enforced by `moves_allowed()`: + +1. **Opponent's corner forbidden**: Cannot land on field 13 (opponent's rest corner for White). +2. **Corner needs two checkers**: The rest corner (field 12) must be taken or vacated with exactly 2 checkers simultaneously. +3. **Corner by effect vs. by power**: If the corner can be taken directly ("par effet"), you cannot take it "par puissance" (using combined dice). +4. **Exit preconditions**: All checkers must be in fields 19–24 before any exit is allowed. +5. **Exit by effect priority**: If a normal exit is possible, exceedant moves (using overflow) are forbidden. +6. **Farthest checker first**: When exiting with exceedant, must exit the checker at the highest field. +7. **Must play all dice**: If both dice can be played, playing only one is invalid. +8. **Must play strongest die**: If only one die can be played, it must be the higher value die. +9. **Must fill quarter**: If a quarter can be completed, the move must complete it. +10. **Cannot block opponent's fillable quarter**: Cannot move into a quarter the opponent can still fill. + +The board state after each die application is simulated to check two-step sequences. + +--- + +## 9. Action Space (training_common.rs) + +Total size: **514 actions**. + +| Index | Action | Description | +|---|---|---| +| 0 | `Roll` | Request dice roll (not used in OpenSpiel mode) | +| 1 | `Go` | After winning hole: reset board and continue | +| 2–257 | `Move { dice_order: true, checker1, checker2 }` | Move with die[0] first | +| 258–513 | `Move { dice_order: false, checker1, checker2 }` | Move with die[1] first | + +Move encoding: `index = 2 + (0 if dice_order else 256) + checker1 * 16 + checker2` + +`checker1` and `checker2` are **ordinal positions** (1-based) of specific checkers counted left-to-right across all White-occupied fields, not field indices. Checker 0 = "no move" (empty move). Range: 0–15 (16 values each). + +### Mirror pattern in get_legal_actions / apply_action + +For player 2 (Black): +```rust +// get_legal_actions: mirror game state before computing +let mirror = self.game_state.mirror(); +get_valid_action_indices(&mirror) + +// apply_action: convert action → event on mirrored state, then mirror the event back +a.to_event(&self.game_state.mirror()) + .map(|e| e.get_mirror(false)) +``` + +This ensures Black's actions are computed as if Black were White on a mirrored board, then translated back to real-board coordinates. + +--- + +## 10. Python Bindings (pyengine.rs) + +The `TricTrac` PyO3 class exposes: + +| Method | Signature | Description | +|---|---|---| +| `new()` | `→ TricTrac` | Create game, init 2 players, begin with player 1 | +| `needs_roll()` | `→ bool` | True when in `RollWaiting` stage | +| `is_game_ended()` | `→ bool` | True when `Stage::Ended` | +| `current_player_idx()` | `→ u64` | 0 or 1 (active_player_id − 1) | +| `get_legal_actions(player_idx)` | `→ Vec` | Action indices for player; empty if not their turn | +| `action_to_string(player_idx, action_idx)` | `→ String` | Human-readable action description | +| `apply_dice_roll(dices: (u8, u8))` | `→ PyResult<()>` | Inject dice result; errors if not in RollWaiting | +| `apply_action(action_idx)` | `→ PyResult<()>` | Apply a game action; validates before applying | +| `get_score(player_id)` | `→ i32` | `holes * 12 + points` for player (1-indexed!) | +| `get_players_scores()` | `→ [i32; 2]` | `[score_p1, score_p2]` | +| `get_tensor(player_idx)` | `→ Vec` | 36-element state vector (mirrored for player 1) | +| `get_observation_string(player_idx)` | `→ String` | Human-readable state (mirrored for player 1) | +| `__str__()` | `→ String` | Debug representation of game state | + +Note: `get_score(player_id)` takes a 1-based player ID (1 or 2), unlike `current_player_idx()` which returns 0-based. + +--- + +## 11. State Tensor Encoding (36 bytes) + +``` +[0..23] Board positions (i8): +N white / −N black checkers per field +[24] Active player: 0=White, 1=Black +[25] TurnStage: 0=RollWaiting, 1=RollDice, 2=MarkPoints, 3=HoldOrGoChoice, + 4=Move, 5=MarkAdvPoints +[26] Dice value 1 (i8) +[27] Dice value 2 (i8) +[28] White: points (0–11) +[29] White: holes (0–12) +[30] White: can_bredouille (0 or 1) +[31] White: can_big_bredouille (0 or 1) +[32] Black: points +[33] Black: holes +[34] Black: can_bredouille +[35] Black: can_big_bredouille +``` + +When called for player 1 (Black), the entire state is mirrored first (`game_state.mirror().to_vec()`). + +### State ID (base64 string for hashing) + +108 bits packed as 18 base64 characters: +- 77 bits: GNUbg-inspired board position encoding (run-length with separators) +- 1 bit: active player color +- 3 bits: turn stage +- 6 bits: dice (3 bits per die) +- 10 bits: white player (4 pts + 4 holes + 2 flags) +- 10 bits: black player +- Padded to 108 bits, grouped as 18 × 6-bit base64 chunks + +--- + +## 12. OpenSpiel Integration (trictrac.py) + +### Game registration + +```python +pyspiel.register_game(_GAME_TYPE, TrictracGame) +``` + +Key parameters: +- `short_name = "python_trictrac"` +- `dynamics = SEQUENTIAL` +- `chance_mode = EXPLICIT_STOCHASTIC` +- `information = PERFECT_INFORMATION` +- `utility = GENERAL_SUM` (both players can score positive; no zero-sum constraint) +- `reward_model = REWARDS` (intermediate rewards, not just terminal) +- `num_distinct_actions = 514` +- `max_chance_outcomes = 36` +- `min_utility = 0.0`, `max_utility = 200.0` +- `max_game_length = 3000` (rough estimate) + +### Chance node handling + +When `needs_roll()` is true, the state is a chance node. OpenSpiel samples one of 36 outcomes (uniform): + +```python +def _roll_from_chance_idx(self, action): + return [(i,j) for i in range(1,7) for j in range(1,7)][action] + +def chance_outcomes(self): + p = 1.0 / 36 + return [(i, p) for i in range(0, 36)] +``` + +Action 0 → (1,1), action 1 → (1,2), …, action 35 → (6,6). The chance action is then passed to `apply_dice_roll((d1, d2))` on the Rust side. + +### Player action handling + +When not a chance node: +```python +def _legal_actions(self, player): + return self._store.get_legal_actions(player) + +def _apply_action(self, action): + self._store.apply_action(action) +``` + +The `Roll` action (index 0) is never returned by `get_legal_actions` in this mode because the Rust side only returns Roll actions from `TurnStage::RollDice`, which is bypassed in the pyengine flow (the RollWaiting→chance node path takes over). + +### Returns + +```python +def returns(self): + return self._store.get_players_scores() +# → [holes_p1 * 12 + points_p1, holes_p2 * 12 + points_p2] +``` + +These are cumulative scores available at any point during the game (not just terminal), consistent with `reward_model = REWARDS`. + +--- + +## 13. Known Issues and Inconsistencies + +### 13.1 `observation_string` missing return (trictrac.py:156) + +```python +def observation_string(self, player): + self._store.get_observation_string(player) # result discarded, returns None +``` + +Should be `return self._store.get_observation_string(player)`. + +### 13.2 `observation_tensor` not populating buffer (trictrac.py:159) + +```python +def observation_tensor(self, player, values): + self._store.get_tensor(player) # result discarded, values not filled +``` + +OpenSpiel's API expects `values` (a mutable buffer, typically a flat numpy array) to be filled in-place. The returned `Vec` from `get_tensor()` is discarded. Should copy data into `values`. + +### 13.3 Debug print statement active (trictrac.py:140) + +```python +print("in apply action", self.is_chance_node(), action) +``` + +This fires on every action application. Should be removed or guarded. + +### 13.4 Color swap on new_pick_up disabled + +In `game.rs:new_pick_up()`: + +```rust +// 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(); +``` + +In authentic Trictrac, players swap colors between "relevés" (pick-ups after a hole is won with Go). This is commented out, so the same player always plays White and the same always plays Black throughout the entire game. + +### 13.5 `can_big_bredouille` tracked but not implemented + +The `can_big_bredouille` flag is stored in `Player` and serialized in state encoding, but the scoring logic never reads it. Grande bredouille (a rare extra bonus) is not implemented. + +### 13.6 `Roll` action in action space but unused in OpenSpiel mode + +`TrictracAction::Roll` (index 0) exists in the 514-action space and in `get_valid_actions()` (for `TurnStage::RollDice`). However, in pyengine, the game starts at `RollWaiting` (dice have been requested but not yet rolled), so `TurnStage::RollDice` is never reached from OpenSpiel's perspective. The chance node mechanism replaces the Roll action entirely. The action space slot 0 is permanently wasted from OpenSpiel's point of view. + +### 13.7 `get_valid_actions` panics on `RollWaiting` + +```rust +TurnStage::MarkPoints | TurnStage::MarkAdvPoints | TurnStage::RollWaiting => { + panic!("get_valid_actions not implemented for turn stage {:?}", ...) +} +``` + +If `get_legal_actions` were ever called while `needs_roll()` is true, this would panic. OpenSpiel's turn logic avoids this because chance nodes are handled separately, but it is a latent danger. + +### 13.8 PPO training script uses wrong model name + +`trictrac_ppo.py` saves to `ppo_backgammon_model.ckpt` — clearly copied from a backgammon example without renaming. Also uses `tensorflow.compat.v1` despite the PyTorch PPO import. + +### 13.9 Opponent points marked at pre-move board state + +The opponent's `dice_points.1` is computed at roll time (before the active player moves), but applied to the opponent after the move. This means the opponent's scoring is evaluated on the board position that existed before the active player moved — which is per the rules of Trictrac (points are based on where pieces could be hit at the moment of the roll), but it's worth noting this subtlety. + +--- + +## 14. Data Flow: A Complete Turn + +``` +Python (OpenSpiel) → Rust (trictrac_store) +───────────────────────────────────────────────────── +is_chance_node() ← needs_roll() [TurnStage == RollWaiting] + (true at game start) + +chance_outcomes() → [(0,p)..(35,p)] + +_apply_action(chance_idx) + _roll_from_chance_idx(idx) → (d1, d2) + apply_dice_roll((d1, d2)) → consume(RollResult{dice}) + → auto-mark active player's points + → if hole: TurnStage=HoldOrGoChoice + → else: TurnStage=Move + +current_player() → 0 or 1 + +_legal_actions(player) ← get_legal_actions(player_idx) + → get_valid_actions on (possibly mirrored) state + → Vec of valid action indices + +_apply_action(action_idx) → apply_action(action_idx) + → TrictracAction::from_action_index + → to_event on (mirrored) state + → mirror event back if player==2 + → validate → consume + → mark opponent points + → switch active player + → TurnStage=RollDice (→ pyengine starts next turn) + +Wait — pyengine starts at RollWaiting, not RollDice! +The next is_chance_node() call will be true again. +``` + +Note on turn transition: After a `Move` event in `game.rs`, turn stage becomes `RollDice` (not `RollWaiting`). The pyengine `needs_roll()` checks for `RollWaiting`. So after a move, `is_chance_node()` returns false — OpenSpiel will ask for a regular player action. But `get_valid_actions` at `TurnStage::RollDice` returns only `Roll` (index 0), which is **not** the chance path. + +This reveals a subtlety: after the Move event, the active player has already been switched, so `current_player()` returns the new active player, and `get_legal_actions` returns `[0]` (Roll). OpenSpiel then applies action 0, which calls `apply_action(0)` → `TrictracAction::Roll` → `GameEvent::Roll` → TurnStage becomes `RollWaiting`. Then the next call to `is_chance_node()` returns true, and the chance mechanism kicks in again. + +So the full sequence in OpenSpiel terms is: +``` +[Chance] dice roll → [Player] move → [Player] Roll action → [Chance] dice roll → ... +``` + +The `Roll` action IS used — it is the bridge between Move completion and the next chance node. + +--- + +## 15. Summary of Design Choices + +| Choice | Rationale | +|---|---| +| All rules engine in Rust | Performance, correctness, can be used in other contexts (CLI, native bots) | +| Mirror pattern for Black | Avoids duplicating all rule logic for both colors | +| Schools disabled by default | Simpler turn structure for RL training; full protocol for human play | +| GENERAL_SUM + REWARDS | Trictrac is not strictly zero-sum; intermediate hole rewards are informative for training | +| Action index for checkers (not fields) | Reduces action space; ordinal checker numbering is compact | +| 514 action slots | 1 Roll + 1 Go + 256 × 2 move combinations (ordered by die priority × 16 × 16 checker pairs) | +| Chance node = dice roll | Standard OpenSpiel pattern for stochastic games |