feat(mcts-service): Introduce MCTS protocol definitions, player action dispatch, and abstract turn projection logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 18:23:56 -07:00
parent 959d606ba7
commit da4d665720
3 changed files with 48 additions and 15 deletions

View file

@ -18,9 +18,9 @@ pub struct MctsPlayerState {
pub tech_index: u16,
pub happiness_pool: i16,
/// Relative military force vs each opponent slot (self-slot = 0).
pub force_rel: [u16; 5],
pub force_rel: [u16; mc_ai::abstract_state::MAX_PLAYERS],
/// Diplomatic relation per opponent: <0 war, 0 peace, >0 friend.
pub relations: [i8; 5],
pub relations: [i8; mc_ai::abstract_state::MAX_PLAYERS],
pub rng_state: u64,
pub turn: u32,
}
@ -34,8 +34,8 @@ impl Default for MctsPlayerState {
city_count: 0,
tech_index: 0,
happiness_pool: 0,
force_rel: [0; 5],
relations: [0; 5],
force_rel: [0; mc_ai::abstract_state::MAX_PLAYERS],
relations: [0; mc_ai::abstract_state::MAX_PLAYERS],
rng_state: 0,
turn: 0,
}
@ -137,11 +137,11 @@ pub struct AbstractPlayerStateMirror {
/// `happiness_pool` — signed; negative under unrest.
pub happiness_pool: i16,
/// `force_rel[opp]` — relative military force vs each opponent slot.
pub force_rel: [u16; 5],
pub force_rel: [u16; mc_ai::abstract_state::MAX_PLAYERS],
/// `axes` — strategic axes via `mc_ai::game_state::axes_to_flat`.
pub axes: [u8; 8],
/// `relations[opp]` — diplomatic relation per opponent (-1/0/+1).
pub relations: [i8; 5],
pub relations: [i8; mc_ai::abstract_state::MAX_PLAYERS],
/// `formation_strength[T1..T4]` — mean strength per tier bucket, 0255.
pub formation_strength: [u8; 4],
/// `rng_state` — per-player SplitMix64 state.
@ -189,11 +189,11 @@ impl AbstractPlayerStateMirror {
happiness_pool: self.happiness_pool,
_pad0: 0,
force_rel: self.force_rel,
_pad_fr: 0,
axes: self.axes,
relations: self.relations,
_pad_rel: [0; 3],
_pad_rel: [0; 4],
formation_strength: self.formation_strength,
_pad_fs: [0; 4],
rng_state: self.rng_state,
turn: self.turn,
_pad2: [0; 4],

View file

@ -277,7 +277,10 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
// `max_turns` is advisory in `step` — it only gates the turn-limit
// victory fallback. Headless callers don't enforce a turn cap, so
// pass a large sentinel; victory_config (when present) overrides.
let processor = mc_turn::processor::TurnProcessor::new(u32::MAX);
let mut processor = mc_turn::processor::TurnProcessor::new(u32::MAX);
if let Some(vc) = victory_config_from_env() {
processor.victory_config = Some(vc);
}
let result = processor.step(state);
// Translate processor events to wire events. The `clan: ClanId(u32)`
// field in every TurnEvent emit site is the player index (see e.g.
@ -309,6 +312,36 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
/// Every emit site in `mc_turn::processor` uses `ClanId(pi as u32)`
/// where `pi` is the player index — so `id.0 as PlayerId` is the
/// correct clan→player lookup with no separate table needed.
/// Read `CP_VICTORY_MODE` from the environment and return a matching
/// `VictoryConfig`. Returns `None` when the var is unset or empty —
/// preserving the legacy simple-city-count fallback in `TurnProcessor`
/// for existing harness consumers. Recognised values:
///
/// * `"domination"` — capture-all-capitals only. Other victory paths
/// are disabled (unreachable thresholds, empty science chain). The
/// game ends only via domination (capital capture) or LastSurvivor
/// (opponent has no cities AND no capital). No turn cap — callers
/// enforce their own.
///
/// Unknown values are ignored (return `None`) so a typo doesn't silently
/// alter behaviour mid-training.
fn victory_config_from_env() -> Option<mc_turn::VictoryConfig> {
let mode = std::env::var("CP_VICTORY_MODE").ok()?;
match mode.as_str() {
"domination" => Some(mc_turn::VictoryConfig {
city_count_threshold: usize::MAX,
gold_threshold: i64::MAX,
culture_threshold: i64::MAX,
science_techs_required: Vec::new(),
science_cost_base: 0,
domination_requires_all_capitals: true,
min_domination_turn: 0,
turn_limit: None,
}),
_ => None,
}
}
fn translate_processor_events(events: &[mc_replay::TurnEvent]) -> Vec<Event> {
let mut out: Vec<Event> = Vec::new();
for ev in events {

View file

@ -100,11 +100,11 @@ fn project_player(
happiness_pool: 0,
_pad0: 0,
force_rel,
_pad_fr: 0,
axes,
relations,
_pad_rel: [0; 3],
_pad_rel: [0; 4],
formation_strength,
_pad_fs: [0; 4],
rng_state,
turn: state.turn,
_pad2: [0; 4],
@ -236,8 +236,8 @@ fn formation_tier(f: &Formation, unit_lookup: &BTreeMap<u32, &MapUnit>) -> u8 {
/// `force_rel[opp] = (own_units + 1) / (opp_units + 1)`, clamped to `[0, 1]`
/// then scaled to a u16. Self-slot is 0. Slots beyond `state.players.len()`
/// are 0.
fn compute_force_rel(state: &GameState, player_idx: usize) -> [u16; 5] {
let mut out = [0u16; 5];
fn compute_force_rel(state: &GameState, player_idx: usize) -> [u16; MAX_PLAYERS] {
let mut out = [0u16; MAX_PLAYERS];
let own = state
.players
.get(player_idx)
@ -270,8 +270,8 @@ fn compute_force_rel(state: &GameState, player_idx: usize) -> [u16; 5] {
/// `state.players[0].relations` per the GameState contract — only player 0
/// carries the authoritative copy, synced each turn by `process_trade_phase`
/// (see `game_state.rs:456-459`).
fn project_relations(state: &GameState, player_idx: usize) -> [i8; 5] {
let mut out = [0i8; 5];
fn project_relations(state: &GameState, player_idx: usize) -> [i8; MAX_PLAYERS] {
let mut out = [0i8; MAX_PLAYERS];
let Some(p0) = state.players.first() else {
return out;
};