992 lines
39 KiB
Markdown
992 lines
39 KiB
Markdown
# 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<ffi::TricTracEngine>` 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<usize>` for actions | `Vec<u64>` | 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<ffi::TricTracEngine>`.
|
||
//!
|
||
//! 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<TricTracEngine>`.
|
||
type TricTracEngine;
|
||
|
||
/// Create a new engine, initialise two players, begin with player 1.
|
||
fn new_trictrac_engine() -> Box<TricTracEngine>;
|
||
|
||
/// Return a deep copy of the engine (needed for State::Clone()).
|
||
fn clone_engine(self: &TricTracEngine) -> Box<TricTracEngine>;
|
||
|
||
// ── 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<u64>;
|
||
|
||
/// 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<i8>;
|
||
|
||
/// 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<TricTracEngine> {
|
||
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<TricTracEngine> {
|
||
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<u64> {
|
||
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<i8> {
|
||
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<ffi::TricTracEngine>` 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 <memory>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#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<const Game> game);
|
||
TrictracState(const TrictracState& other);
|
||
|
||
Player CurrentPlayer() const override;
|
||
std::vector<Action> LegalActions() const override;
|
||
std::string ActionToString(Player player, Action move_id) const override;
|
||
std::vector<std::pair<Action, double>> ChanceOutcomes() const override;
|
||
std::string ToString() const override;
|
||
bool IsTerminal() const override;
|
||
std::vector<double> Returns() const override;
|
||
std::string ObservationString(Player player) const override;
|
||
void ObservationTensor(Player player, absl::Span<float> values) const override;
|
||
std::unique_ptr<State> 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<trictrac_engine::TricTracEngine> engine_;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TrictracGame
|
||
// ---------------------------------------------------------------------------
|
||
class TrictracGame : public Game {
|
||
public:
|
||
explicit TrictracGame(const GameParameters& params);
|
||
|
||
int NumDistinctActions() const override { return kNumDistinctActions; }
|
||
std::unique_ptr<State> 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<int> 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 <memory>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#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<const Game> Factory(const GameParameters& params) {
|
||
return std::make_shared<const TrictracGame>(params);
|
||
}
|
||
|
||
REGISTER_SPIEL_GAME(kGameType, Factory);
|
||
|
||
} // namespace
|
||
|
||
// ── TrictracGame ─────────────────────────────────────────────────────────────
|
||
|
||
TrictracGame::TrictracGame(const GameParameters& params)
|
||
: Game(kGameType, params),
|
||
max_turns_(ParameterValue<int>("max_turns", kDefaultMaxTurns)) {}
|
||
|
||
std::unique_ptr<State> TrictracGame::NewInitialState() const {
|
||
return std::make_unique<TrictracState>(shared_from_this());
|
||
}
|
||
|
||
// ── TrictracState ─────────────────────────────────────────────────────────────
|
||
|
||
TrictracState::TrictracState(std::shared_ptr<const Game> 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<State> TrictracState::Clone() const {
|
||
return std::make_unique<TrictracState>(*this);
|
||
}
|
||
|
||
// ── Current player ────────────────────────────────────────────────────────────
|
||
|
||
Player TrictracState::CurrentPlayer() const {
|
||
if (engine_->is_game_ended()) return kTerminalPlayerId;
|
||
if (engine_->needs_roll()) return kChancePlayerId;
|
||
return static_cast<Player>(engine_->current_player_idx());
|
||
}
|
||
|
||
// ── Legal actions ─────────────────────────────────────────────────────────────
|
||
|
||
std::vector<Action> TrictracState::LegalActions() const {
|
||
if (IsChanceNode()) {
|
||
// All 36 dice outcomes are equally likely; return indices 0–35.
|
||
std::vector<Action> actions(kNumChanceOutcomes);
|
||
for (int i = 0; i < kNumChanceOutcomes; ++i) actions[i] = i;
|
||
return actions;
|
||
}
|
||
Player player = CurrentPlayer();
|
||
rust::Vec<uint64_t> rust_actions =
|
||
engine_->get_legal_actions(static_cast<uint64_t>(player));
|
||
std::vector<Action> actions;
|
||
actions.reserve(rust_actions.size());
|
||
for (uint64_t a : rust_actions) actions.push_back(static_cast<Action>(a));
|
||
return actions;
|
||
}
|
||
|
||
// ── Chance outcomes ───────────────────────────────────────────────────────────
|
||
|
||
std::vector<std::pair<Action, double>> TrictracState::ChanceOutcomes() const {
|
||
SPIEL_CHECK_TRUE(IsChanceNode());
|
||
const double p = 1.0 / kNumChanceOutcomes;
|
||
std::vector<std::pair<Action, double>> 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<uint8_t>(action / 6 + 1),
|
||
/*die2=*/static_cast<uint8_t>(action % 6 + 1),
|
||
};
|
||
}
|
||
|
||
void TrictracState::DoApplyAction(Action action) {
|
||
if (IsChanceNode()) {
|
||
engine_->apply_dice_roll(DecodeChanceAction(action));
|
||
} else {
|
||
engine_->apply_action(static_cast<uint64_t>(action));
|
||
}
|
||
}
|
||
|
||
// ── Terminal & returns ────────────────────────────────────────────────────────
|
||
|
||
bool TrictracState::IsTerminal() const {
|
||
return engine_->is_game_ended();
|
||
}
|
||
|
||
std::vector<double> TrictracState::Returns() const {
|
||
trictrac_engine::PlayerScores scores = engine_->get_players_scores();
|
||
return {static_cast<double>(scores.score_p1),
|
||
static_cast<double>(scores.score_p2)};
|
||
}
|
||
|
||
// ── Observation ───────────────────────────────────────────────────────────────
|
||
|
||
std::string TrictracState::ObservationString(Player player) const {
|
||
return std::string(engine_->get_observation_string(
|
||
static_cast<uint64_t>(player)));
|
||
}
|
||
|
||
void TrictracState::ObservationTensor(Player player,
|
||
absl::Span<float> values) const {
|
||
SPIEL_CHECK_EQ(values.size(), kStateEncodingSize);
|
||
rust::Vec<int8_t> tensor =
|
||
engine_->get_tensor(static_cast<uint64_t>(player));
|
||
SPIEL_CHECK_EQ(tensor.size(), static_cast<size_t>(kStateEncodingSize));
|
||
for (int i = 0; i < kStateEncodingSize; ++i) {
|
||
values[i] = static_cast<float>(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<uint64_t>(player), static_cast<uint64_t>(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 <iostream>
|
||
#include <memory>
|
||
|
||
#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 `$<TARGET_OBJECTS:trictrac_game>` in relevant executables.
|
||
|
||
### 11.3 Add the test
|
||
|
||
```cmake
|
||
add_executable(trictrac_test
|
||
trictrac/trictrac_test.cc
|
||
${OPEN_SPIEL_OBJECTS}
|
||
$<TARGET_OBJECTS:tests>
|
||
)
|
||
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<TricTracEngine>` 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<T>` (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<T>` 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<T, E>` 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<std::string>(...)`.
|
||
|
||
---
|
||
|
||
## 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 |
|