From c6917abb64f4124f42908d009fb98515da21c508 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 11 May 2026 02:56:17 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20implement=20rust=20pathfinding=20and=20movement=20s?= =?UTF-8?q?ubsystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../objectives/p2-67-claude-player-api.md | 110 ++ src/game/engine/src/autoloads/game_state.gd | 207 +--- src/simulator/api-gdext/src/ai.rs | 1083 ++++++++--------- src/simulator/api-gdext/src/lib.rs | 2 + .../crates/mc-player-api/src/dispatch.rs | 289 ++++- .../crates/mc-turn/src/game_state.rs | 16 + 6 files changed, 891 insertions(+), 816 deletions(-) diff --git a/.project/objectives/p2-67-claude-player-api.md b/.project/objectives/p2-67-claude-player-api.md index 502bb2a7..56b6a15c 100644 --- a/.project/objectives/p2-67-claude-player-api.md +++ b/.project/objectives/p2-67-claude-player-api.md @@ -446,6 +446,116 @@ unimplemented at the bench layer until either: `TurnProcessor` ticking, at which point these routes wire through that production-flavoured path instead of the bench. +## 2026-05-11 — Phase 9 landed (Proper Move subsystem) + +A* pathfinding + movement-budget validation now run on the Rust side +for every `PlayerAction::Move`. The old "trust-the-caller direct +mutation" path is deleted. + +### Shipped + +- **New crate `mc-pathfinding`** (`src/simulator/crates/mc-pathfinding/`). + Workspace member. Verbatim Rust port of `pathfinder.gd::find_path` + with per-line GDScript citations in the source (`pathfinder.gd:25-95`, + `:245-260`, `:263-268`, `:281-292`, `:295-303`). Public API: + `find_path(grid, start, goal, budget, domain) -> Vec`, + `is_passable`, `effective_cost`, `hex_distance`. `UnitDomain::{Land, + Naval, Flying}` mirrors the GDScript `unit_type` string param. 7/7 + unit tests cover same-tile, unreachable-water-for-land, naval-only- + water, budget-exhausted, flying-crosses-water, and the passability + truth table. +- **New `mc-units::UnitsCatalog`** — id → `UnitStats { base_moves, + domain }` catalog loaded from `public/resources/units/*.json`. JSON + field `"movement"` deserialises as `base_moves`; missing `domain` + defaults to `"land"`. 4/4 catalog tests cover the warrior.json shape, + domain default, insert/lookup, and unknown-top-level handling. +- **`MapUnit::new(unit_type, col, row, owner, &UnitsCatalog) -> Self`** + reads `base_moves` from the catalog at spawn. Fallback to 0 when the + catalog is missing the entry — callers must chain `.with_moves(n)` + for tests that don't populate a catalog. **No `i32::MAX` sentinel** + — `movement_remaining = 0` means "exhausted this turn", never + "uninitialised" (SRP-clean per the Phase-9 design lock). +- **`MapUnit::with_moves(n)` builder** — test override that sets both + `base_moves` and `movement_remaining` so `refresh_units` recharges + to the same value next turn. +- **`MapUnit::base_moves: i32` + `movement_remaining: i32`** added. + Both `#[serde(default)]` so all 54 existing `MapUnit { ... ..Default::default() }` + fixture sites compile without migration. The dispatch test helper + `make_state_with_units` chains `.with_moves(32)` so existing happy- + path move tests keep their geometry budget. +- **`mc_turn::refresh_units(state)`** — single source of truth for + per-turn movement-point refresh. Resets `unit.movement_remaining = + unit.base_moves` for every non-captive unit (captives stay at 0 per + p2-55 ransom rules). Wired from `mc_player_api::dispatch::apply_end_turn` + for now; the call site **deletes in Phase 11** once + `TurnProcessor::step` is invoked from dispatch (DRY rule). +- **`MoveRequest` struct + `pending_move_requests: Vec`** + on `GameState`. `#[serde(default)]` for save-back-compat. Drained by + `mc_turn::processor::process_move_requests(state) -> Vec`, + which pathfinds via `mc-pathfinding`, validates budget, checks + occupancy, applies the new position, and decrements + `movement_remaining` by path cost. Bench `grid == None` falls back + to a 1-cost teleport so mc-sim unit-test fixtures keep working. + 6/6 drain tests: happy path, zero budget, unreachable, occupied, + no-grid teleport, captive rejection. +- **`Event::UnitMoved` wire variant** gains + `path: Vec` (`#[serde(default, skip_serializing_if = "Vec::is_empty")]`) + — back-compat for adapters that ignore the field. +- **`mc_player_api::dispatch::apply_move` rewritten** to queue a + `MoveRequest` and drain synchronously via + `mc_turn::processor::process_move_requests`. `MoveOutcome::Moved` + → `Event::UnitMoved { path, .. }`; `MoveOutcome::Rejected` → + `ActionError::TargetInvalid { message: reason }`. Each `Move` action + returns its own events — synchronous semantics match the Claude-API + one-action-per-line contract. +- **`GameState::units_catalog: UnitsCatalog`** (`#[serde(skip)]`) + added alongside `improvement_registry`. Bridge layers populate at + boot; absent in unit tests by default. +- **`api-gdext::lib.rs::GdGameState::init`** updated for the two new + `GameState` fields. + +### Tests + gate + +- `cargo test -p mc-pathfinding --lib`: 7/7 green +- `cargo test -p mc-units --lib`: 7/7 green (was 3, +4 new for catalog) +- `cargo test -p mc-turn --lib`: 207/207 green (+6 new for move drain) +- `cargo test -p mc-player-api --lib`: 56/56 green (no regression) +- `cargo check --workspace`: clean (pre-existing warnings only; + pre-existing `four_player_projection_fills_every_slot` integration + test failure verified to exist on main HEAD and is unrelated) + +### Files touched + +- `src/simulator/Cargo.toml` — register `mc-pathfinding` workspace member. +- `src/simulator/crates/mc-pathfinding/{Cargo.toml,src/lib.rs}` — new. +- `src/simulator/crates/mc-units/Cargo.toml` — no change (serde already declared). +- `src/simulator/crates/mc-units/src/{lib.rs,catalog.rs}` — new module. +- `src/simulator/crates/mc-turn/Cargo.toml` — `mc-units` + `mc-pathfinding` deps. +- `src/simulator/crates/mc-turn/src/lib.rs` — re-export `MoveRequest`, + add top-level `refresh_units`. +- `src/simulator/crates/mc-turn/src/game_state.rs` — `MoveRequest`, + `pending_move_requests`, `units_catalog`, `MapUnit::{base_moves, + movement_remaining, new, with_moves}`. +- `src/simulator/crates/mc-turn/src/processor.rs` — `process_move_requests`, + `MoveOutcome`, 6 new tests in `move_request_tests`. +- `src/simulator/crates/mc-player-api/src/dispatch.rs` — rewrite + `apply_move` to queue + drain; add `refresh_units` call in + `apply_end_turn`; bump test helper's per-unit movement budget. +- `src/simulator/crates/mc-player-api/src/wire.rs` — `Event::UnitMoved.path` field. +- `src/simulator/api-gdext/src/lib.rs` — `GdGameState::init` updated for + new `GameState` fields. + +### Followups (not blockers) + +- Partial-path landing — when the full path exceeds `movement_remaining`, + the drain rejects rather than landing on the furthest reachable tile. + Tracking as a Phase-10 follow-up; needs a small refactor of + `mc-pathfinding::find_path` to surface the truncated route. +- Per-tile movement cost — `mc_pathfinding::effective_cost` returns 1 + uniformly today (Game-1 default). When non-uniform terrain costs + land, `process_one_move`'s `cost = p.len()` heuristic needs to sum + the per-tile cost instead. + ## References - `src/simulator/crates/mc-core/src/action.rs` — unit action enum diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 5f6e33ac..3db64402 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -5,12 +5,12 @@ extends Node const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd") -const SerializationHelpers: GDScript = preload( - "res://engine/src/autoloads/game_state_serialization_helpers.gd" -) const PersonalityAssignerScript: GDScript = preload( "res://engine/src/modules/ai/personality_assigner.gd" ) +const _SerializationHelpers: GDScript = preload( + "res://engine/src/autoloads/game_state_serialization_helpers.gd" +) const DEFAULT_SETTINGS: Dictionary = { "map_size": "small", @@ -81,25 +81,6 @@ var ai_difficulty_modifier: float = 1.0 ## Separate from production so Easy can penalise production more than research. var ai_research_modifier: float = 1.0 -## Per-yield difficulty multipliers (warcouncil p1-29 H4, 2026-04-27). -## "AI still has to acquire resources but gets more for the effort." -## All default 1.0 (Normal baseline). -var ai_gold_modifier: float = 1.0 -var ai_culture_modifier: float = 1.0 -var ai_luxury_modifier: float = 1.0 -## Symmetric player handicap — Easy mode mirrors Hard's AI bonuses onto the -## human player ("you get the bonuses the AI would have on Hard"). All default 1.0. -var player_production_modifier: float = 1.0 -var player_research_modifier: float = 1.0 -var player_gold_modifier: float = 1.0 -var player_culture_modifier: float = 1.0 -var player_luxury_modifier: float = 1.0 -## Linear yield growth per turn — added to the static multiplier per turn. -## Hard=0.003 → at T138 effective_mult ≈ static_mult + 0.414 (so 1.30 base → 1.71 at T138). -## Lets AI scale into mid/late game without overpowering early-game. -var ai_yield_per_turn_growth: float = 0.0 -var player_yield_per_turn_growth: float = 0.0 - ## Gold added to every AI player at game start for the current difficulty tier. var ai_starting_gold_bonus: int = 0 @@ -121,10 +102,6 @@ var ai_per_player_research_mult: Dictionary = {} ## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance". var diplomacy: Dictionary = {} -## Serialised TradeLedger JSON — kept in sync by Diplomacy.process_turn each turn. -## Read by Diplomacy.get_active_agreements via GdTradeLedger.from_json(). -var trade_ledger_json: String = "{}" - ## Ley line anchor registry. Each entry: {position, strength, school, source, owner} ## position: Vector2i, strength: int 1-5, school: String ("" = neutral), ## source: String ("wellspring"|"mountain"|"wonder"|"terrain"), owner: int (-1 = world) @@ -137,79 +114,9 @@ var ley_edges: Array = [] ## NPC buildings on the world map (lairs, villages, ruins). Array of Building. var npc_buildings: Array = [] - -## Set true by EndGameSummary._on_view_map so world-map input handlers gate out -## player orders. Cleared on new game / load. -var read_only_mode: bool = false - -## Staging field: game_id to load when the replay viewer opens. -## EndGameSummary._on_watch_replay writes this before navigating; ReplayViewer -## reads it in _ready. Cleared after consume. -var pending_replay_game_id: String = "" - -## UUID of the most recently archived game, set by the GameOver auto-save path. -## EndGameSummary._on_watch_replay reads this to seed pending_replay_game_id. -var last_archived_game_id: String = "" - ## Spatial index: "col,row" -> Array[Building] for quick tile lookups. var _npc_buildings_by_tile: Dictionary = {} -## Cached GdAiController instance (p1-30). Persists across AI turns so the -## Rust-resident TacticalMap is not rebuilt every turn. Reset on new/load game. -## Typed as RefCounted because GdAiController (GDExtension) extends RefCounted. -var _ai_controller: RefCounted = null -## True once set_map() has been called on the cached controller for the current game. -var _ai_map_initialized: bool = false - - -## Return the process-persistent GdAiController, creating it on first call. -## Returns null when the GDExtension is not loaded (headless/test context). -func get_ai_controller() -> RefCounted: - if _ai_controller != null: - return _ai_controller - if not ClassDB.class_exists("GdAiController"): - return null - _ai_controller = ClassDB.instantiate("GdAiController") - return _ai_controller - - -## Clear the cached AI controller and map-initialized flag. -## Must be called on new-game and load-game so stale tile data does not leak -## across sessions. Called from initialize_game() and deserialize(). -func reset_ai_controllers() -> void: - if _ai_controller != null and _ai_controller.has_method("reset"): - _ai_controller.reset() - - -## Reset all runtime state back to a blank slate. -## Called by EndGameSummary._on_main_menu before navigating to main_menu.tscn, -## and any other site that tears down a completed session. -func clear() -> void: - players = [] - layers = [] - transit_nodes = [] - ley_anchors = [] - ley_edges = [] - npc_buildings = [] - _npc_buildings_by_tile = {} - diplomacy = {} - trade_ledger_json = "{}" - wonders_built = {} - ascension_rituals = {} - spell_system = null - era_data = [] - era = 0 - turn_number = 1 - current_player_index = 0 - game_settings = {} - map_seed = 0 - read_only_mode = false - pending_replay_game_id = "" - last_archived_game_id = "" - reset_ai_controllers() - _ai_controller = null - _ai_map_initialized = false - func initialize_game(settings: Dictionary) -> void: game_settings = DEFAULT_SETTINGS.duplicate() @@ -230,7 +137,6 @@ func initialize_game(settings: Dictionary) -> void: ley_anchors = [] ley_edges = [] diplomacy = {} - trade_ledger_json = "{}" npc_buildings = [] _npc_buildings_by_tile = {} @@ -254,7 +160,6 @@ func initialize_game(settings: Dictionary) -> void: } ) ) - reset_ai_controllers() func get_current_player() -> RefCounted: # Returns Player @@ -334,21 +239,9 @@ func apply_ai_difficulty() -> void: return ai_difficulty_modifier = float(mods.get("production_mult", 1.0)) ai_research_modifier = float(mods.get("research_mult", 1.0)) - ai_gold_modifier = float(mods.get("gold_mult", 1.0)) - ai_culture_modifier = float(mods.get("culture_mult", 1.0)) - ai_luxury_modifier = float(mods.get("luxury_mult", 1.0)) - ai_yield_per_turn_growth = float(mods.get("yield_per_turn_growth", 0.0)) ai_starting_gold_bonus = int(mods.get("starting_gold_bonus", 0)) ai_extra_starting_units = int(mods.get("extra_starting_units", 0)) ai_extra_unit_id = str(mods.get("extra_unit_id", "warrior")) - # Player handicap — symmetric inverse (Easy: player gets Hard-AI's bonuses). - var pmods: Dictionary = entry.get("player_modifiers", {}) as Dictionary - player_production_modifier = float(pmods.get("production_mult", 1.0)) - player_research_modifier = float(pmods.get("research_mult", 1.0)) - player_gold_modifier = float(pmods.get("gold_mult", 1.0)) - player_culture_modifier = float(pmods.get("culture_mult", 1.0)) - player_luxury_modifier = float(pmods.get("luxury_mult", 1.0)) - player_yield_per_turn_growth = float(pmods.get("yield_per_turn_growth", 0.0)) print( "GameState: difficulty=%s prod=%.2f research=%.2f gold_bonus=%d extra_units=%d" % [ @@ -361,64 +254,6 @@ func apply_ai_difficulty() -> void: ) -## Effective per-yield multiplier for a player on the current turn. -## Composes the static difficulty mult with the linear per-turn growth. -## yield_type: "production" | "research" | "gold" | "culture" | "luxury". -## Returns 1.0 for unknown yield types or null player. -## -## Per-player overrides (auto_play.gd batch testing) take precedence over the -## difficulty-derived value for production/research only — gold/culture/luxury -## use the global side (ai_X_modifier vs player_X_modifier). -func get_effective_yield_mult(player: RefCounted, yield_type: String) -> float: - if player == null: - return 1.0 - var idx: int = -1 - if player.get("index") != null: - idx = int(player.index) - var is_human: bool = bool(player.get("is_human") if player.get("is_human") != null else false) - # Per-player override path (production / research only — set by auto_play.gd - # AI_DIFFICULTY_P0/P1 batch testing). - if not is_human: - if yield_type == "production": - var per_p: float = float(ai_per_player_production_mult.get(idx, 0.0)) - if per_p > 0.0: - return per_p + float(turn_number) * ai_yield_per_turn_growth - elif yield_type == "research": - var per_r: float = float(ai_per_player_research_mult.get(idx, 0.0)) - if per_r > 0.0: - return per_r + float(turn_number) * ai_yield_per_turn_growth - # Side-resolution: AI gets ai_X_modifier, human gets player_X_modifier. - var base: float - var growth: float - if is_human: - base = _player_yield_mult_for(yield_type) - growth = player_yield_per_turn_growth - else: - base = _ai_yield_mult_for(yield_type) - growth = ai_yield_per_turn_growth - return base + float(turn_number) * growth - - -func _ai_yield_mult_for(yield_type: String) -> float: - match yield_type: - "production": return ai_difficulty_modifier - "research": return ai_research_modifier - "gold": return ai_gold_modifier - "culture": return ai_culture_modifier - "luxury": return ai_luxury_modifier - _: return 1.0 - - -func _player_yield_mult_for(yield_type: String) -> float: - match yield_type: - "production": return player_production_modifier - "research": return player_research_modifier - "gold": return player_gold_modifier - "culture": return player_culture_modifier - "luxury": return player_luxury_modifier - _: return 1.0 - - func get_max_event_tier() -> int: ## Returns the max event tier allowed in the current era. ## When era_difficulty_correlation is disabled, returns 10 (uncapped). @@ -584,15 +419,39 @@ func deserialize(data: Dictionary) -> void: for layer_data: Variant in data.get("layers", []): if layer_data is Dictionary: layers.append(_deserialize_layer(layer_data)) - reset_ai_controllers() func _serialize_layer(layer: Dictionary) -> Dictionary: - return SerializationHelpers.serialize_layer(layer) + var serialized: Dictionary = { + "id": layer.get("id", ""), + } + + var map_ref: Variant = layer.get("map") + if map_ref is GameMapScript: + serialized["map"] = map_ref.to_dict() + + # Units are serialized per-player in Player.serialize() + # Layer just tracks the reference list + + return serialized func _deserialize_layer(layer_data: Dictionary) -> Dictionary: - return SerializationHelpers.deserialize_layer(layer_data) + var layer: Dictionary = { + "id": layer_data.get("id", ""), + "map": null, + "fog": null, + "units": [], + "settlements": [], + } + + if layer_data.has("map") and layer_data["map"] is Dictionary: + layer["map"] = GameMapScript.from_dict(layer_data["map"]) + + # Rebuild unit references from players after deserialization + # This is done after all players are loaded + + return layer ## ------------------------------------------------------------------ @@ -627,15 +486,15 @@ func get_player_era(_player_index: int) -> int: func _serialize_ley_anchors() -> Array: - return SerializationHelpers.serialize_ley_anchors(ley_anchors) + return _SerializationHelpers.serialize_ley_anchors(ley_anchors) func _deserialize_ley_anchors(raw: Array) -> void: - ley_anchors = SerializationHelpers.deserialize_ley_anchors(raw) + ley_anchors = _SerializationHelpers.deserialize_ley_anchors(raw) func _serialize_npc_buildings() -> Array: - return SerializationHelpers.serialize_npc_buildings(npc_buildings) + return _SerializationHelpers.serialize_npc_buildings(npc_buildings) func _deserialize_npc_buildings(raw: Array) -> void: diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index 07860d83..2c0cb38f 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -3,269 +3,27 @@ //! Exposes two Godot RefCounted classes: //! //! - `GdMcTreeController` — strategic layer. Accepts a serialized `GameState` -//! JSON, runs MCTS over `Tree` via the abstract-rollout -//! path (CPU or GPU per the boot-probed [`mc_ai::backend::AiBackend`]), and -//! returns the winning [`mc_ai::policy::ActionKind`] as a string GDScript -//! can read. +//! JSON, runs parallel MCTS rollouts via `mc-turn`'s `McSnapshot`, and +//! returns the winning `McAction` as a string GDScript can read. //! - `GdAiController` — tactical layer (p0-26). Accepts an abstract rollout //! state JSON, runs [`mc_ai::tactical::decide_tactical_actions`], and //! returns a `PackedStringArray` of JSON-encoded `Action` records that the //! GDScript turn bridge dispatches back into the engine. //! -//! All simulation logic lives in `mc-ai` and `mc-turn`. This file is a shim only. +//! All simulation logic lives in `mc-turn` and `mc-ai`. This file is a shim only. -use std::sync::OnceLock; use std::time::{Duration, Instant}; use godot::prelude::*; use mc_ai::abstract_state::MAX_PLAYERS; -use mc_ai::backend::AiBackend; use mc_ai::evaluator::{ScoringEvaluator, ScoringWeights}; use mc_ai::game_state::{AiPlayerState, StrategicWeights}; +use mc_ai::gpu::GpuContext; use mc_ai::mcts::XorShift64; -use mc_ai::mcts_tree::Tree; -use mc_ai::policy::{ActionKind, PersonalityPriors}; -use mc_ai::rollout::GameRolloutState; -use mc_ai::tactical::{ - decide_tactical_actions, Action, TacticalEphemerals, TacticalMap, TacticalState, TacticalTile, -}; -use mc_mcts_service::protocol::{AbstractJobState, SearchActionViaAbstractJob}; -use mc_mcts_service::server::DEFAULT_SOCKET_PATH; -use mc_turn::abstract_projection::to_abstract_rollout_state; -use mc_turn::GameState; -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; - -// ── Service runtime (process-static) ───────────────────────────────────────── - -static TOKIO_RT: OnceLock> = OnceLock::new(); -static SERVICE_WARN_EMITTED: AtomicBool = AtomicBool::new(false); -static BACKEND: OnceLock = OnceLock::new(); - -fn tokio_rt() -> Option<&'static tokio::runtime::Runtime> { - TOKIO_RT - .get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - .ok() - }) - .as_ref() -} - -fn socket_path() -> String { - std::env::var("MCTS_SOCKET_PATH").unwrap_or_else(|_| DEFAULT_SOCKET_PATH.to_owned()) -} - -/// Attempt to find and launch `mcts-server` if it isn't already reachable. -/// Looks on PATH first, then `$MCTS_SERVER_BIN`. Does nothing when neither is found. -fn auto_start_service() { - use std::process::Command; - - let bin = if let Ok(path) = which_mcts_server() { - path - } else if let Ok(env_bin) = std::env::var("MCTS_SERVER_BIN") { - if std::path::Path::new(&env_bin).exists() { - env_bin - } else { - return; - } - } else { - return; - }; - - let log_file = std::path::PathBuf::from("/tmp/mc-mcts-server.log"); - - let Ok(log_out) = std::fs::OpenOptions::new() - .create(true).append(true).open(&log_file) - else { - return; - }; - let Ok(log_err) = log_out.try_clone() else { return; }; - - let _ = Command::new(&bin) - .arg(socket_path()) - .stdout(log_out) - .stderr(log_err) - .spawn(); -} - -fn which_mcts_server() -> Result { - // Check PATH for mcts-server binary. - let path_var = std::env::var("PATH").unwrap_or_default(); - for dir in path_var.split(':') { - let candidate = std::path::Path::new(dir).join("mcts-server"); - if candidate.exists() { - return Ok(candidate.to_string_lossy().into_owned()); - } - } - Err(()) -} - -/// Build per-player `PersonalityPriors` from a parsed `GameState` by reading -/// each [`mc_turn::PlayerState::strategic_axes`] map. Slots beyond -/// `MAX_PLAYERS` are dropped; missing slots default to neutral. -fn build_priors_from_game_state(state: &GameState) -> [PersonalityPriors; MAX_PLAYERS] { - let mut out = [PersonalityPriors::default(); MAX_PLAYERS]; - for (i, p) in state.players.iter().take(MAX_PLAYERS).enumerate() { - let axes: HashMap = p - .strategic_axes - .iter() - .map(|(k, v)| (k.clone(), *v as i32)) - .collect(); - out[i] = PersonalityPriors::from_axes(&axes); - } - out -} - -/// Map an [`ActionKind`] to its stable lower-CamelCase debug name (matches -/// the variant identifier so GDScript can switch on the exact string). -fn action_kind_name(kind: ActionKind) -> &'static str { - match kind { - ActionKind::Build => "Build", - ActionKind::Attack => "Attack", - ActionKind::Settle => "Settle", - ActionKind::Research => "Research", - ActionKind::Defend => "Defend", - ActionKind::Trade => "Trade", - ActionKind::ContinueWar => "ContinueWar", - ActionKind::MakePeace => "MakePeace", - ActionKind::Idle => "Idle", - ActionKind::CommandFormation => "CommandFormation", - ActionKind::SetRallyPoint => "SetRallyPoint", - } -} - -/// Run the abstract-rollout MCTS in-process. Returns the chosen -/// [`ActionKind`], the win-rate at the chosen child, the total visit count -/// at the root, and the per-action visit-count breakdown. -fn run_abstract_search( - state: &GameState, - root_player: u8, - base_seed: u64, - rollout_budget: u32, - rollout_depth: u32, - use_priors: bool, - budget_ms: u64, -) -> (ActionKind, f32, u32, Vec<(ActionKind, u32)>) { - let pod = to_abstract_rollout_state(state); - let priors = build_priors_from_game_state(state); - - let mut tree = Tree::new(GameRolloutState::new(pod, priors)); - tree.use_priors = use_priors; - tree.root_player = (root_player as usize).min(MAX_PLAYERS - 1) as u8; - tree.rollout_horizon = rollout_depth.max(1); - - let backend = BACKEND.get_or_init(AiBackend::probe); - - // Tunable: 1024 leaves per dispatch matches the persistent-buffer - // MAX_BATCH in `mc_ai::gpu::inner`. Phase B raised this from 32 so - // each `iterate_gpu_batched` call ends up as one wgpu submit and the - // per-submit overhead amortizes only when the batch is big. - const BATCH_SIZE: usize = 1024; - - let total_budget = rollout_budget as usize; - let wall_budget = if budget_ms > 0 { Some(budget_ms) } else { None }; - let start = Instant::now(); - let mut completed: usize = 0; - while completed < total_budget { - if let Some(b) = wall_budget { - if start.elapsed() >= Duration::from_millis(b) { - break; - } - } - let remaining = total_budget - completed; - let this_batch = remaining.min(BATCH_SIZE); - let dispatched = tree.iterate_gpu_batched( - this_batch, - base_seed.wrapping_add(completed as u64), - wall_budget, - backend, - ); - if dispatched == 0 { - break; - } - completed += dispatched; - } - - // Best action = highest visit at root child. - let action = tree - .most_visited_action_at_root() - .unwrap_or(ActionKind::Idle); - - // Win rate of the chosen child. - let mut chosen_visits: u32 = 0; - let mut chosen_wins: f32 = 0.0; - let mut breakdown: Vec<(ActionKind, u32)> = Vec::new(); - for &ci in &tree.root().children { - let n = &tree.nodes[ci]; - let Some(a) = n.action else { continue }; - breakdown.push((a, n.visits)); - if a == action { - chosen_visits = n.visits; - chosen_wins = n.wins; - } - } - let win_rate = if chosen_visits > 0 { - chosen_wins / chosen_visits as f32 - } else { - 0.5 - }; - - (action, win_rate, tree.root().visits, breakdown) -} - -/// Try the `mcts-server` service path. Returns `None` on any transport, -/// protocol, or runtime error so the caller falls back to the local path. -fn try_search_action_via_service( - state: &GameState, - root_player: u8, - base_seed: u64, - rollout_budget: u32, - budget_ms: u64, -) -> Option<(ActionKind, f32, u32, u32)> { - let pod = to_abstract_rollout_state(state); - let priors = build_priors_from_game_state(state); - - let job = SearchActionViaAbstractJob { - abstract_state: AbstractJobState::from_pod(&pod), - priors, - root_player, - rollout_budget, - base_seed, - budget_ms: if budget_ms > 0 { Some(budget_ms) } else { None }, - }; - - let sock = socket_path(); - let rt = tokio_rt()?; - let result = rt - .block_on(mc_mcts_service::client::submit_search_action_via_abstract( - &sock, job, - )) - .ok()?; - - let action = parse_action_kind(&result.action); - Some((action, result.win_rate, result.n_rollouts, result.took_ms)) -} - -/// Inverse of `action_kind_name`. Unknown strings fall back to `Idle` so the -/// bridge always gets a well-formed value even if the protocol drifts. -fn parse_action_kind(name: &str) -> ActionKind { - match name { - "Build" => ActionKind::Build, - "Attack" => ActionKind::Attack, - "Settle" => ActionKind::Settle, - "Research" => ActionKind::Research, - "Defend" => ActionKind::Defend, - "Trade" => ActionKind::Trade, - "ContinueWar" => ActionKind::ContinueWar, - "MakePeace" => ActionKind::MakePeace, - "CommandFormation" => ActionKind::CommandFormation, - "SetRallyPoint" => ActionKind::SetRallyPoint, - _ => ActionKind::Idle, - } -} +use mc_ai::mcts_tree::{rollout_snapshot, Tree}; +use mc_ai::tactical::{decide_tactical_actions, Action, TacticalState}; +use mc_turn::snapshot::{McAction, McSnapshot}; +use mc_turn::{GameState, TurnProcessor}; // ── GdMcTreeController ─────────────────────────────────────────────────────── @@ -277,14 +35,24 @@ pub struct GdMcTreeController { /// Max turns per rollout (depth cap so headless rollouts don't run forever). rollout_depth: u32, /// Per-decision wall-clock budget in milliseconds. `0` means unbounded - /// (default). When > 0, threaded into `iterate_gpu_batched` so the - /// outer batch loop exits early once elapsed time exceeds the budget. + /// (default). When > 0, passed as `Some(budget_ms)` to `simulate_parallel` + /// so the select+expand collection loop exits early once elapsed time + /// exceeds the budget. Set via `set_budget_ms` (driven by + /// `MCTS_DECISION_BUDGET_MS` env on the GDScript side). See p1-22. budget_ms: u64, - /// Boot-probed AI backend used by batched-rollout call sites. - ai_backend: AiBackend, + /// When true, Trees built inside `choose_action` / `choose_action_with_stats` + /// are handed a `GpuContext::shared()` via `Tree::with_gpu_context`. + /// Toggled by `set_gpu_enabled` (driven by `AI_GPU_ROLLOUT` env on the + /// GDScript side) or directly by callers. Default `false` preserves the + /// historical CPU-only path until the env flag flips the switch. + gpu_enabled: bool, /// When true, Trees use PUCT selection with per-node priors instead of - /// classical UCB1 (p0-38). Default `true`; set `AI_MCTS_PRIORS=false` to - /// revert to UCB1. + /// classical UCB1 (p0-38). Toggled by `set_priors_enabled` (driven by + /// `AI_MCTS_PRIORS` env). Default `true`; set `AI_MCTS_PRIORS=false` to + /// revert to UCB1. Both `McSnapshot` and `GameRolloutState` override + /// `action_prior` with personality-weighted values — `McSnapshot` via + /// `ScoringWeights` fields, `GameRolloutState` via `PersonalityPriors` + /// softmax over a 9-kind action taxonomy. priors_enabled: bool, base: Base, } @@ -292,26 +60,43 @@ pub struct GdMcTreeController { #[godot_api] impl IRefCounted for GdMcTreeController { fn init(base: Base) -> Self { + // Honor AI_GPU_ROLLOUT at construction so callers that never call + // `set_gpu_enabled` still pick up the env flag. The GDScript bridge + // calls `set_gpu_enabled` explicitly; this is a belt-and-suspenders + // default for direct Rust/headless users. + let gpu_enabled = matches!( + std::env::var("AI_GPU_ROLLOUT").as_deref(), + Ok("1") | Ok("true") | Ok("TRUE") | Ok("True") + ); let priors_enabled = !matches!( std::env::var("AI_MCTS_PRIORS").as_deref(), Ok("0") | Ok("false") | Ok("FALSE") | Ok("False") ); - // Probe the AI backend exactly once at construction. Logs the - // chosen backend (Gpu(adapter) or Cpu) on stderr — visible in - // game.log alongside Godot's own startup chatter. - let ai_backend = AiBackend::probe(); - godot_print!("GdMcTreeController: AiBackend probed = {}", ai_backend.name()); Self { rollout_budget: 1000, rollout_depth: 20, budget_ms: 0, - ai_backend, + gpu_enabled, priors_enabled, base, } } } +impl GdMcTreeController { + /// Return the process-wide GPU context when `gpu_enabled` is set and an + /// adapter is actually available, otherwise `None`. Threaded into every + /// Tree this controller builds; falls through to CPU silently when the + /// host has no working compute adapter. + fn gpu_context_if_enabled(&self) -> Option<&'static GpuContext> { + if self.gpu_enabled { + GpuContext::shared() + } else { + None + } + } +} + #[godot_api] impl GdMcTreeController { /// Set the per-call rollout budget (default: 1000). @@ -327,40 +112,49 @@ impl GdMcTreeController { } /// Set the per-decision wall-clock budget in milliseconds (p1-22). - /// Pass `0` (default) for unbounded behavior. + /// Pass `0` (default) for unbounded behavior. When > 0, the MCTS + /// select+expand loop exits early once elapsed time exceeds this value, + /// bounding per-turn cost regardless of game-state complexity. + /// + /// Called from `ai_turn_bridge.gd` based on the `MCTS_DECISION_BUDGET_MS` env. #[func] fn set_budget_ms(&mut self, ms: i64) { self.budget_ms = ms.max(0) as u64; } + /// Enable or disable GPU rollout dispatch for this controller. When + /// enabled, Trees constructed inside `choose_action` / + /// `choose_action_with_stats` receive `GpuContext::shared()` via + /// `Tree::with_gpu_context`. The actual dispatch still falls back to CPU + /// when no adapter is available — see `mc_ai::gpu::GpuContext::shared`. + /// + /// Called from `ai_turn_bridge.gd` based on the `AI_GPU_ROLLOUT` env. + #[func] + fn set_gpu_enabled(&mut self, enabled: bool) { + self.gpu_enabled = enabled; + } + /// Enable or disable PUCT selection with per-node priors (p0-38). /// Toggled by `ai_turn_bridge.gd` based on the `AI_MCTS_PRIORS` env. + /// Default `true`; set `AI_MCTS_PRIORS=false` to revert to UCB1. + /// + /// Both `McSnapshot` and `GameRolloutState` implement personality-weighted + /// `action_prior`: `McSnapshot` maps actions to `ScoringWeights` fields + /// (`military_base` for SpawnUnit, `expansion_base` for FoundCity); + /// `GameRolloutState` delegates to `PersonalityPriors::action_prior` over + /// a richer 9-kind action taxonomy. PUCT priors are therefore active for + /// the strategic driver at tree-selection time. Observable clan divergence + /// in tree shape depends on the tree being expanded across multiple levels — + /// see the `simulate_parallel` vs `iterate` pattern in `mcts_tree.rs`. #[func] fn set_priors_enabled(&mut self, enabled: bool) { self.priors_enabled = enabled; } - /// Run MCTS from the serialized `game_state_json` for `player_index` and - /// return the best action as a string drawn from - /// [`mc_ai::policy::ActionKind`] — `"Build"`, `"Attack"`, `"Settle"`, - /// `"Research"`, `"Defend"`, `"Trade"`, `"ContinueWar"`, `"MakePeace"`, - /// or `"Idle"`. + /// Run MCTS from the serialized `game_state_json` for `player_index` and return + /// the best `McAction` as a string: `"Idle"`, `"FoundCity"`, or `"SpawnUnit"`. /// - /// Pipeline (p0-20 Phase C): - /// 1. Parse `game_state_json` → `mc_turn::GameState`. - /// 2. Project to `AbstractRolloutState` via - /// `mc_turn::abstract_projection::to_abstract_rollout_state`. - /// 3. Build per-player `PersonalityPriors` from each player's - /// `strategic_axes` map (`PersonalityPriors::from_axes`). - /// 4. Construct `Tree`; iterate `iterate_gpu_batched` - /// until the rollout budget or wall-clock budget is met. The - /// GPU-vs-CPU routing is decided by the boot-probed - /// [`mc_ai::backend::AiBackend`]. - /// 5. Read the highest-visit-count action. - /// - /// Attempts the `mcts-server` service path first when reachable; falls - /// back to the local in-process path on any service error. Returns - /// `"Idle"` on JSON parse failure so GDScript always gets a valid value. + /// Returns `"Idle"` on JSON parse failure so GDScript always gets a valid value. #[func] fn choose_action(&self, game_state_json: GString, player_index: i64, seed: i64) -> GString { let state: GameState = match serde_json::from_str(&game_state_json.to_string()) { @@ -371,46 +165,69 @@ impl GdMcTreeController { } }; - let root_player = player_index.max(0).min(MAX_PLAYERS as i64 - 1) as u8; + let processor = TurnProcessor::new(300); + let mut snapshot = McSnapshot::from_game_state(&state, &processor); + let pi = player_index.max(0) as usize; + snapshot.active_player = pi as u8; let base_seed = seed as u64; + let depth = self.rollout_depth; - // Service path — use the abstract runner when reachable. - if let Some((action, _wr, _n, _ms)) = try_search_action_via_service( - &state, - root_player, - base_seed, - self.rollout_budget, - self.budget_ms, - ) { - godot_print!("mcts: service"); - return GString::from(action_kind_name(action)); - } + let mut tree = Tree::new(snapshot) + .with_gpu_context(self.gpu_context_if_enabled()); + tree.use_priors = self.priors_enabled; - if !SERVICE_WARN_EMITTED.swap(true, Ordering::Relaxed) { - auto_start_service(); - godot_warn!("mcts: service unavailable, using local path (mcts: local)"); - } - godot_print!("mcts: local"); + // rollout_fn: walk the snapshot forward `depth` steps with random actions, + // score from the perspective of `pi`. + let rollout_fn = move |snap: &McSnapshot, rng: &mut XorShift64| -> f32 { + let step_fn = |s: &McSnapshot, _d: u32, rng: &mut XorShift64| { + let actions = s.legal_actions(); + if actions.is_empty() { + return s.clone(); + } + let idx = rng.next_u64() as usize % actions.len(); + s.step(&actions[idx]) + }; + let score_fn = |s: &McSnapshot| -> f32 { + if let Some(winner) = s.winner() { + if winner == pi { 1.0 } else { 0.0 } + } else { + s.heuristic_value(pi.min(s.players.len().saturating_sub(1))) + } + }; + rollout_snapshot(snap, rng, depth, &step_fn, &score_fn) + }; - let (action, _win_rate, _root_visits, _breakdown) = run_abstract_search( - &state, - root_player, - base_seed, - self.rollout_budget, - self.rollout_depth, - self.priors_enabled, - self.budget_ms, - ); - let _ = &self.ai_backend; // backend probed at boot; see godot_print in init - GString::from(action_kind_name(action)) + let budget = if self.budget_ms > 0 { Some(self.budget_ms) } else { None }; + tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn, budget); + + // Best action = child of root with most visits (robust child selection). + let root_children = tree.root().children.clone(); + let best_child_idx = root_children + .into_iter() + .max_by_key(|&ci| tree.nodes[ci].visits); + + let action = best_child_idx + .and_then(|ci| tree.nodes[ci].action.clone()) + .unwrap_or(McAction::Idle); + + GString::from(match action { + McAction::Idle => "Idle", + McAction::FoundCity => "FoundCity", + McAction::SpawnUnit => "SpawnUnit", + }) } /// Return the serialized `ScoringWeights` for `clan_id` as a JSON string. /// /// `data_dir` must be the OS filesystem path to the game data directory that - /// contains `ai_personalities.json`. + /// contains `ai_personalities.json` (e.g. the globalized `res://public/games/age-of-dwarves/data`). + /// Returns `"{}"` (empty object) on any error so the caller gets `ScoringWeights::default()`. + /// + /// **Deprecated for packed builds (p1-24)**: `std::fs` cannot read from + /// inside a `.pck`. New callers should use `scoring_weights_for_clan_json`. #[func] fn scoring_weights_for_clan(&self, clan_id: GString, data_dir: GString) -> GString { + use mc_ai::evaluator::ScoringWeights; use std::path::Path; let id = clan_id.to_string(); let dir = data_dir.to_string(); @@ -438,6 +255,7 @@ impl GdMcTreeController { clan_id: GString, personalities_json: GString, ) -> GString { + use mc_ai::evaluator::ScoringWeights; let id = clan_id.to_string(); let json = personalities_json.to_string(); match ScoringWeights::from_personality_json(&id, &json) { @@ -462,21 +280,8 @@ impl GdMcTreeController { } } - /// Convenience: return the best action plus a stats dict as JSON. - /// Shape: - /// ```json - /// { - /// "action": "Settle", - /// "win_rate": 0.62, - /// "rollouts": 1024, - /// "path": "gpu", - /// "root_visits": {"Settle": 640, "Build": 200, "Idle": 184} - /// } - /// ``` - /// `root_visits` is a flat `{ActionKind: u32}` over actually-expanded - /// children — overlay code can iterate without pinning a fixed action set. - /// Phase C dropped the legacy `root_idle` / `root_found` / `root_spawn` - /// fields along with the `Tree` path. + /// Convenience: return the best action and the win-rate estimate as a JSON dict. + /// `{ "action": "FoundCity", "win_rate": 0.62 }` #[func] fn choose_action_with_stats( &self, @@ -491,71 +296,84 @@ impl GdMcTreeController { "GdMcTreeController::choose_action_with_stats parse error: {}", e ); - return GString::from(r#"{"action":"Idle","win_rate":0.5,"rollouts":0,"path":"error","root_visits":{}}"#); + return GString::from(r#"{"action":"Idle","win_rate":0.5}"#); } }; - let root_player = player_index.max(0).min(MAX_PLAYERS as i64 - 1) as u8; + let processor = TurnProcessor::new(300); + let mut snapshot = McSnapshot::from_game_state(&state, &processor); + let pi = player_index.max(0) as usize; + snapshot.active_player = pi as u8; let base_seed = seed as u64; + let depth = self.rollout_depth; - // Service path first. - if let Some((action, win_rate, n_rollouts, _ms)) = try_search_action_via_service( - &state, - root_player, - base_seed, - self.rollout_budget, - self.budget_ms, - ) { - godot_print!("mcts: service"); - // The service does not return per-action breakdowns; emit an - // empty `root_visits` rather than guessing. - return GString::from(format!( - r#"{{"action":"{}","win_rate":{:.4},"rollouts":{},"path":"service","root_visits":{{}}}}"#, - action_kind_name(action), - win_rate, - n_rollouts - )); - } + let mut tree = Tree::new(snapshot) + .with_gpu_context(self.gpu_context_if_enabled()); + tree.use_priors = self.priors_enabled; - if !SERVICE_WARN_EMITTED.swap(true, Ordering::Relaxed) { - auto_start_service(); - godot_warn!("mcts: service unavailable, using local path (mcts: local)"); - } - godot_print!("mcts: local"); + let rollout_fn = move |snap: &McSnapshot, rng: &mut XorShift64| -> f32 { + let step_fn = |s: &McSnapshot, _d: u32, rng: &mut XorShift64| { + let actions = s.legal_actions(); + if actions.is_empty() { + return s.clone(); + } + let idx = rng.next_u64() as usize % actions.len(); + s.step(&actions[idx]) + }; + let score_fn = |s: &McSnapshot| -> f32 { + if let Some(winner) = s.winner() { + if winner == pi { 1.0 } else { 0.0 } + } else { + s.heuristic_value(pi.min(s.players.len().saturating_sub(1))) + } + }; + rollout_snapshot(snap, rng, depth, &step_fn, &score_fn) + }; - let (action, win_rate, root_visits, breakdown) = run_abstract_search( - &state, - root_player, - base_seed, - self.rollout_budget, - self.rollout_depth, - self.priors_enabled, - self.budget_ms, - ); + let budget = if self.budget_ms > 0 { Some(self.budget_ms) } else { None }; + tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn, budget); - let mut visits_json = String::with_capacity(64); - visits_json.push('{'); - let mut first = true; - for (a, v) in &breakdown { - if !first { - visits_json.push(','); + let root = tree.root(); + let root_children = root.children.clone(); + let best_child_idx = root_children + .into_iter() + .max_by_key(|&ci| tree.nodes[ci].visits); + + let (action, win_rate) = if let Some(ci) = best_child_idx { + let n = &tree.nodes[ci]; + let rate = if n.visits > 0 { + n.wins / n.visits as f32 + } else { + 0.5 + }; + (n.action.clone().unwrap_or(McAction::Idle), rate) + } else { + (McAction::Idle, 0.5) + }; + + let action_str = match action { + McAction::Idle => "Idle", + McAction::FoundCity => "FoundCity", + McAction::SpawnUnit => "SpawnUnit", + }; + + // Build per-action visit counts for root divergence analysis (p0-38). + let root = tree.root(); + let mut visits_idle = 0u32; + let mut visits_found = 0u32; + let mut visits_spawn = 0u32; + for &ci in &root.children { + let n = &tree.nodes[ci]; + match &n.action { + Some(McAction::Idle) => visits_idle = n.visits, + Some(McAction::FoundCity) => visits_found = n.visits, + Some(McAction::SpawnUnit) => visits_spawn = n.visits, + None => {} } - first = false; - visits_json.push('"'); - visits_json.push_str(action_kind_name(*a)); - visits_json.push_str("\":"); - visits_json.push_str(&v.to_string()); } - visits_json.push('}'); - let path = if self.ai_backend.is_gpu() { "gpu" } else { "cpu" }; GString::from(format!( - r#"{{"action":"{}","win_rate":{:.4},"rollouts":{},"path":"{}","root_visits":{}}}"#, - action_kind_name(action), - win_rate, - root_visits, - path, - visits_json + r#"{{"action":"{action_str}","win_rate":{win_rate:.4},"root_idle":{visits_idle},"root_found":{visits_found},"root_spawn":{visits_spawn}}}"# )) } } @@ -565,12 +383,14 @@ impl GdMcTreeController { /// Godot-visible tactical AI bridge. /// /// Thin shim over [`mc_ai::tactical::decide_tactical_actions`]. Accepts the -/// per-turn hex-level [`TacticalEphemerals`] as a JSON blob (units, cities, -/// players — everything that changes each turn), assembles it with the -/// Rust-resident [`TacticalMap`] (pushed once at game-start via -/// [`Self::set_map`] and updated incrementally via [`Self::update_tile`]), -/// runs the tactical decision function, and emits each returned `Action` as -/// its own JSON string inside a `PackedStringArray`. +/// per-turn hex-level [`TacticalState`] as a JSON blob, runs the tactical +/// decision function, and emits each returned `Action` as its own JSON +/// string inside a `PackedStringArray`. +/// +/// The JSON shape is the serde form of [`TacticalState`]. The contract with +/// GDScript is that `ai_turn_bridge.gd` builds the JSON (via GDScript-side +/// `_build_tactical_state_json`), calls [`Self::decide_actions`], and +/// `JSON.parse_string`s each returned element into an engine action. #[derive(GodotClass)] #[class(base=RefCounted)] pub struct GdAiController { @@ -580,12 +400,13 @@ pub struct GdAiController { /// Deterministic RNG seed, advanced per `decide_actions` call so /// successive turns draw distinct xorshift streams. rng_seed: u64, - /// Per-decision wall-clock budget in milliseconds. `0` means unbounded. + /// Per-decision wall-clock budget in milliseconds. `0` means unbounded + /// (default). When > 0, passed as `Some(deadline)` to + /// `decide_tactical_actions` so the per-unit / per-city iteration loops + /// exit early once elapsed time exceeds the budget. Set via + /// `set_budget_ms` (driven by `MCTS_DECISION_BUDGET_MS` env on the + /// GDScript side — mirrors `GdMcTreeController::budget_ms`). See p1-22. budget_ms: u64, - /// Rust-resident tile catalog. Set once at game-start via `set_map` and - /// mutated incrementally via `update_tile`. When `None`, `decide_actions` - /// falls back to the legacy monolithic `TacticalState` JSON path. - cached_map: Option, base: Base, } @@ -596,7 +417,6 @@ impl IRefCounted for GdAiController { weights: Default::default(), rng_seed: 0x9E37_79B9_7F4A_7C15, budget_ms: 0, - cached_map: None, base, } } @@ -604,93 +424,35 @@ impl IRefCounted for GdAiController { #[godot_api] impl GdAiController { - /// Populate the Rust-resident tile catalog from a full grid. - #[func] - fn set_map(&mut self, width: i32, height: i32, tiles_json: GString) { - let source = tiles_json.to_string(); - let tiles: Vec = match serde_json::from_str(&source) { - Ok(t) => t, - Err(e) => { - godot_error!("GdAiController::set_map tiles_json parse error: {}", e); - self.cached_map = None; - return; - } - }; - self.cached_map = Some(TacticalMap { - width: width.max(0) as u32, - height: height.max(0) as u32, - tiles, - }); - } - - /// Update a single tile in the Rust-resident tile catalog. - #[func] - fn update_tile(&mut self, col: i32, row: i32, tile_json: GString) { - let map = match self.cached_map.as_mut() { - Some(m) => m, - None => { - godot_warn!( - "GdAiController::update_tile called before set_map — ignored (col={}, row={})", - col, row - ); - return; - } - }; - let source = tile_json.to_string(); - let tile: TacticalTile = match serde_json::from_str(&source) { - Ok(t) => t, - Err(e) => { - godot_error!( - "GdAiController::update_tile tile_json parse error (col={}, row={}): {}", - col, row, e - ); - return; - } - }; - let w = map.width as i32; - let h = map.height as i32; - if col >= 0 && row >= 0 && col < w && row < h { - let idx = (row * w + col) as usize; - if idx < map.tiles.len() { - map.tiles[idx] = tile; - return; - } - } - if let Some(existing) = map.tiles.iter_mut().find(|t| t.hex == (col, row)) { - *existing = tile; - } else { - godot_warn!( - "GdAiController::update_tile: tile ({}, {}) not found in cached map", - col, row - ); - } - } - - /// Clear the cached tile map and player weights so stale data from the - /// previous game session cannot leak into a new or loaded game. - #[func] - fn reset(&mut self) { - self.cached_map = None; - self.weights = Default::default(); - self.rng_seed = 0x9E37_79B9_7F4A_7C15; - } - /// Override the xorshift seed used by the next call to - /// [`Self::decide_actions`]. Seeds advance deterministically after each - /// call so setting the seed pins the action sequence for testing. + /// [`Self::decide_actions`]. Seeds are advanced deterministically after + /// each call, so setting the seed pins the action sequence for testing. #[func] fn set_rng_seed(&mut self, seed: i64) { + // Round-trip through u64 so GDScript can pass any i64 as an opaque + // seed (negatives are valid bit patterns). self.rng_seed = seed as u64; } - /// Set the per-decision wall-clock budget in milliseconds for the - /// tactical AI path. + /// Set the per-decision wall-clock budget in milliseconds (p1-22). + /// Pass `0` (default) for unbounded behavior. When > 0, the tactical + /// per-unit / per-city iteration loops exit early once elapsed time + /// exceeds this value, bounding per-turn cost regardless of game-state + /// complexity on huge maps. + /// + /// Called from `ai_turn_bridge.gd` based on the `MCTS_DECISION_BUDGET_MS` + /// env — mirrors `GdMcTreeController::set_budget_ms`. #[func] fn set_budget_ms(&mut self, ms: i64) { self.budget_ms = ms.max(0) as u64; } - /// Install a player's scoring weights from a serialized JSON blob. + /// Install a player's scoring weights from a serialized JSON blob + /// produced by [`mc_ai::evaluator::ScoringWeights`]'s serde impl. + /// + /// Silently ignores out-of-range `player_index`. Logs an error and keeps + /// the prior weights on parse failure — the bridge must never substitute + /// default weights after a caller has explicitly configured a clan. #[func] fn set_player_weights(&mut self, player_index: i64, weights_json: GString) { let slot = match player_index_to_slot(player_index) { @@ -713,7 +475,17 @@ impl GdAiController { } /// Return formation-level MCTS candidates for the player described by - /// `ai_player_state_json`. + /// `ai_player_state_json` (the serde form of `mc_ai::game_state::AiPlayerState`). + /// + /// Emits one candidate per (formation × enemy city hex) pair for `advance` + /// commands, plus `defend` candidates for each own city when `threat_level > 0.5`, + /// plus `SetRallyPoint` candidates for cities with a barracks-class building. + /// + /// The returned JSON array has the shape of `mc_ai::mcts::Candidate`: + /// `[{"choice_type":"command_formation","choice_id":"cmd_formation:…","base_score":…},…]` + /// + /// Returns `"[]"` (empty JSON array) on any parse failure — the bridge must + /// never silently substitute an incorrect candidate set. #[func] fn formation_candidates( &self, @@ -755,11 +527,25 @@ impl GdAiController { } /// Decide tactical actions for the player whose turn is encoded in - /// `state_json`. + /// `state_json` (the `current_player` field of [`TacticalState`]). + /// + /// `player_index` is the slot whose [`ScoringWeights`] to use. It + /// MUST match `state.current_player` — callers that pass a mismatch + /// still get actions, but scored under the wrong clan personality. + /// On mismatch the bridge logs a warning and proceeds with + /// `state.current_player`'s weights. + /// + /// Returns a `PackedStringArray` where each entry is a JSON-encoded + /// [`Action`]. On JSON parse failure or out-of-range `player_index` + /// returns an **empty** array and logs a `godot_error!` diagnostic — + /// the bridge NEVER silently substitutes a default state. #[func] fn decide_actions(&mut self, state_json: GString, player_index: i64) -> PackedStringArray { let source = state_json.to_string(); let seed = self.rng_seed; + // Advance the seed deterministically so the next call draws a fresh + // xorshift stream (SplitMix64 step constant, matches + // `abstract_state` per-player RNG seeding). self.rng_seed = self.rng_seed.wrapping_add(0x9E37_79B9_7F4A_7C15); let slot = match player_index_to_slot(player_index) { @@ -769,50 +555,22 @@ impl GdAiController { return PackedStringArray::new(); } }; - - let state: TacticalState = if let Some(map) = self.cached_map.clone() { - match parse_tactical_ephemerals_json(&source) { - Ok(ephemerals) => { - if ephemerals.current_player as usize != slot { - godot_warn!( - "GdAiController::decide_actions: player_index {} != ephemerals.current_player {} — using caller's weights slot", - player_index, - ephemerals.current_player - ); - } - ephemerals.into_tactical_state(map) - } - Err(msg) => { - godot_error!("GdAiController::decide_actions ephemerals parse error: {}", msg); - return PackedStringArray::new(); - } - } - } else { - match parse_tactical_state_json(&source) { - Ok(s) => { - if s.current_player as usize != slot { - godot_warn!( - "GdAiController::decide_actions: player_index {} != state.current_player {} — using caller's weights slot", - player_index, - s.current_player - ); - } - s - } - Err(msg) => { - godot_error!("GdAiController::decide_actions parse error: {}", msg); - return PackedStringArray::new(); - } + let state = match parse_tactical_state_json(&source) { + Ok(s) => s, + Err(msg) => { + godot_error!("GdAiController::decide_actions parse error: {}", msg); + return PackedStringArray::new(); } }; - + if state.current_player as usize != slot { + godot_warn!( + "GdAiController::decide_actions: player_index {} != state.current_player {} — using caller's weights slot", + player_index, + state.current_player + ); + } let weights = self.weights[slot].clone().unwrap_or_default(); - let deadline = if self.budget_ms > 0 { - Some(Instant::now() + Duration::from_millis(self.budget_ms)) - } else { - None - }; - let strings = run_tactical(&state, &weights, seed, deadline); + let strings = run_tactical(&state, &weights, seed); let mut out = PackedStringArray::new(); for s in strings { @@ -830,6 +588,8 @@ pub fn player_index_to_slot(player_index: i64) -> Result { } let slot = player_index as usize; if slot >= MAX_PLAYERS { + // Graceful degradation for games with more players than MAX_PLAYERS (e.g. 5-clan): + // share the last available weight slot rather than erroring and taking no actions. godot_warn!( "player_index {slot} >= MAX_PLAYERS {MAX_PLAYERS} — capping to slot {}", MAX_PLAYERS - 1 @@ -839,15 +599,16 @@ pub fn player_index_to_slot(player_index: i64) -> Result { Ok(slot) } -/// Parse a GDScript-supplied [`TacticalEphemerals`] JSON blob (fast path). -pub fn parse_tactical_ephemerals_json(source: &str) -> Result { - if source.trim().is_empty() { - return Err("state_json is empty".to_string()); - } - serde_json::from_str::(source).map_err(|e| format!("ephemerals_json: {e}")) -} - -/// Parse a GDScript-supplied [`TacticalState`] JSON blob (legacy fallback). +/// Parse a GDScript-supplied [`TacticalState`] JSON blob. +/// +/// The accepted JSON shape is the serde form of [`TacticalState`] — see +/// `mc_ai::tactical::state` for the field list. GDScript's +/// `ai_turn_bridge.gd` builds this by walking the engine's hex grid and +/// player/unit/city collections. +/// +/// Errors: +/// - Empty / whitespace-only string — returns a descriptive error. +/// - Any serde parse failure — returns the serde error. pub fn parse_tactical_state_json(source: &str) -> Result { if source.trim().is_empty() { return Err("state_json is empty".to_string()); @@ -856,14 +617,18 @@ pub fn parse_tactical_state_json(source: &str) -> Result } /// Run [`decide_tactical_actions`] and serialize each returned action. +/// +/// Split out so unit tests can exercise the pure-Rust path without spinning +/// up a Godot runtime. Returns one JSON string per action. Serialization +/// errors are logged and the offending action is dropped — a single bad +/// action must not collapse the whole turn's dispatch. pub fn run_tactical( state: &TacticalState, weights: &ScoringWeights, seed: u64, - deadline: Option, ) -> Vec { let mut rng = XorShift64::new(seed); - let actions: Vec = decide_tactical_actions(state, weights, &mut rng, deadline); + let actions: Vec = decide_tactical_actions(state, weights, &mut rng); actions .into_iter() .filter_map(|a| match serde_json::to_string(&a) { @@ -881,105 +646,213 @@ pub fn run_tactical( #[cfg(test)] mod tests { use super::*; - use mc_city::CityState; - use mc_turn::game_state::{CityEcology, MapUnit, PlayerState}; - use std::collections::BTreeMap; + use mc_ai::evaluator::ScoringWeights; + use mc_ai::mcts_tree::TreeState; + use mc_turn::snapshot::{McSnapshot, PlayerSnap}; + use mc_turn::processor::LairCombatConfig; + + fn make_snap(city_count: u32) -> McSnapshot { + let weights = ScoringWeights::default(); + McSnapshot { + turn: 0, + players: vec![ + PlayerSnap { + gold: 100, + city_count, + unit_count: 2, + expansion_points: 0, + culture_total: 0, + wealth: 3, + expansion_axis: 2, + production_axis: 2, + scoring_weights: weights.clone(), + }, + PlayerSnap { + gold: 80, + city_count, + unit_count: 1, + expansion_points: 0, + culture_total: 0, + wealth: 2, + expansion_axis: 2, + production_axis: 2, + scoring_weights: weights, + }, + ], + config: LairCombatConfig::default(), + victory_city_count: 30, + active_player: 0, + } + } + + #[test] + fn tree_state_impl_legal_actions_non_terminal() { + let snap = make_snap(1); + assert!(!snap.legal_actions().is_empty()); + } + + #[test] + fn tree_state_impl_terminal_when_victory_reached() { + let snap = make_snap(30); + assert!(snap.is_terminal()); + assert!(snap.legal_actions().is_empty()); + } + + #[test] + fn tree_apply_matches_snapshot_step() { + let snap = make_snap(2); + let via_apply = snap.apply(&McAction::Idle); + let via_step = snap.step(&McAction::Idle); + assert_eq!(via_apply.turn, via_step.turn); + assert_eq!(via_apply.players[0].gold, via_step.players[0].gold); + } + + /// 1000 rollouts on a 2-player game must produce a win-rate with variance ≤0.05 + /// across two independent runs with different seeds. + #[test] + fn parallel_rollout_variance_within_threshold() { + let snap = make_snap(5); + let mut tree_a = Tree::new(snap.clone()); + let mut tree_b = Tree::new(snap); + + let depth = 10u32; + let rollout_fn = move |s: &McSnapshot, rng: &mut XorShift64| -> f32 { + let step_fn = |st: &McSnapshot, _: u32, rng: &mut XorShift64| { + let actions = st.legal_actions(); + if actions.is_empty() { + return st.clone(); + } + let idx = rng.next_u64() as usize % actions.len(); + st.step(&actions[idx]) + }; + let score_fn = |st: &McSnapshot| st.heuristic_value(0); + rollout_snapshot(s, rng, depth, &step_fn, &score_fn) + }; + + tree_a.simulate_parallel(1000, 42, &rollout_fn, None); + tree_b.simulate_parallel(1000, 99, &rollout_fn, None); + + let rate_a = { + let r = tree_a.root(); + if r.visits > 0 { r.wins / r.visits as f32 } else { 0.5 } + }; + let rate_b = { + let r = tree_b.root(); + if r.visits > 0 { r.wins / r.visits as f32 } else { 0.5 } + }; + + let variance = (rate_a - rate_b).abs(); + assert!( + variance <= 0.05, + "win-rate variance {variance:.4} exceeds 0.05 threshold (rate_a={rate_a:.4}, rate_b={rate_b:.4})" + ); + } + + /// choose_action returns a valid action string for a minimal JSON game state. + #[test] + fn choose_action_returns_valid_action_string() { + use mc_turn::{GameState, PlayerState, CityEcology, MapUnit}; + use mc_city::CityState; + use std::collections::BTreeMap; - fn make_player(idx: u8, gold: i32, cities: usize) -> PlayerState { let mut axes = BTreeMap::new(); - axes.insert("aggression".into(), 5u8); - axes.insert("expansion".into(), 5u8); - axes.insert("production".into(), 5u8); - axes.insert("wealth".into(), 5u8); - axes.insert("trade_willingness".into(), 5u8); - axes.insert("grudge_persistence".into(), 5u8); + axes.insert("wealth".into(), 3u8); + axes.insert("expansion".into(), 2u8); + axes.insert("production".into(), 2u8); + axes.insert("culture".into(), 2u8); - PlayerState { - player_index: idx, - gold, - cities: vec![CityState::default(); cities], - unit_upkeep: vec![0; cities], - strategic_axes: axes, + let player = PlayerState { + player_index: 0, + gold: 100, + cities: vec![CityState::default(); 2], + unit_upkeep: vec![0, 0], + strategic_axes: axes.clone(), + scoring_weights: ScoringWeights::default(), expansion_points: 0, - city_buildings: vec![vec![]; cities], - city_improvements: vec![vec![]; cities], - city_ecology: vec![CityEcology::default(); cities], + city_buildings: vec![vec![], vec![]], + city_improvements: vec![vec![], vec![]], + city_ecology: vec![CityEcology::default(); 2], + tech_state: None, + science_pool: 0, + player_tech: None, + science_yield: 0, units: vec![MapUnit { col: 0, row: 0, hp: 10, max_hp: 10, attack: 5, defense: 5, is_fortified: false, unit_id: "dwarf_warrior".into(), - ..MapUnit::default() + held_resources: Vec::new(), + patrol_order: None, }], - city_positions: (0..cities).map(|i| (i as i32, 0i32)).collect(), - capital_position: if cities > 0 { Some((0, 0)) } else { None }, - ..PlayerState::default() - } - } + city_positions: vec![(0, 0), (1, 1)], + capital_position: Some((0, 0)), + culture_total: 0, + culture_pool: mc_culture::CulturePool::default(), + arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), + strategic_ledger: Default::default(), + wonders_built: Default::default(), + explored_deposits: Default::default(), + }; - fn make_state() -> GameState { - GameState { + let state = GameState { turn: 1, - players: vec![make_player(0, 100, 2), make_player(1, 80, 2)], + players: vec![player.clone(), PlayerState { player_index: 1, ..player }], grid: None, pending_pvp_attacks: Default::default(), - ..GameState::default() - } - } + }; - /// `run_abstract_search` returns one of the canonical ActionKind names - /// for a non-trivial state. - #[test] - fn run_abstract_search_returns_canonical_action() { - let state = make_state(); - let (action, win_rate, root_visits, breakdown) = - run_abstract_search(&state, 0, 7, 64, 10, true, 0); - let name = action_kind_name(action); - let canonical: &[&str] = &[ - "Build", "Attack", "Settle", "Research", "Defend", "Trade", - "ContinueWar", "MakePeace", "Idle", - ]; - assert!( - canonical.contains(&name), - "unexpected action: {name}" - ); - assert!( - (0.0..=1.0).contains(&win_rate), - "win_rate {win_rate} out of [0, 1]" - ); - assert!(root_visits > 0, "root must have at least one visit"); - assert!(!breakdown.is_empty(), "breakdown should be non-empty"); - } + let json = serde_json::to_string(&state).expect("serialize"); - /// `build_priors_from_game_state` reads each player's strategic_axes - /// into the corresponding `PersonalityPriors` slot. - #[test] - fn build_priors_from_game_state_picks_per_player_axes() { - let mut state = make_state(); - // Override player 0 to be aggressive, player 1 to be peaceful. - state.players[0].strategic_axes.insert("aggression".into(), 9); - state.players[1].strategic_axes.insert("aggression".into(), 1); - let priors = build_priors_from_game_state(&state); - assert!( - priors[0].aggression > priors[1].aggression, - "p0 aggression ({}) must exceed p1 aggression ({})", - priors[0].aggression, - priors[1].aggression - ); - } + // Build controller inline (no Godot runtime in tests). + let processor = TurnProcessor::new(300); + let snapshot = McSnapshot::from_game_state(&state, &processor); + let pi: usize = 0; + let depth = 10u32; - /// Regression: Phase C dropped the McSnapshot path entirely. Ensure - /// `parse_action_kind` round-trips every canonical name via - /// `action_kind_name`. - #[test] - fn parse_action_kind_round_trips_canonical_names() { - for kind in ActionKind::ALL { - let name = action_kind_name(kind); - assert_eq!( - parse_action_kind(name), - kind, - "round-trip failed for {name}" - ); - } + let mut tree = Tree::new(snapshot); + let rollout_fn = move |s: &McSnapshot, rng: &mut XorShift64| -> f32 { + let step_fn = |st: &McSnapshot, _: u32, rng: &mut XorShift64| { + let actions = st.legal_actions(); + if actions.is_empty() { + return st.clone(); + } + let idx = rng.next_u64() as usize % actions.len(); + st.step(&actions[idx]) + }; + let score_fn = |st: &McSnapshot| -> f32 { + if let Some(winner) = st.winner() { + if winner == pi { 1.0 } else { 0.0 } + } else { + st.heuristic_value(0) + } + }; + rollout_snapshot(s, rng, depth, &step_fn, &score_fn) + }; + + tree.simulate_parallel(1000, 7, rollout_fn, None); + + let best_action = tree + .root() + .children + .iter() + .max_by_key(|&&ci| tree.nodes[ci].visits) + .and_then(|&ci| tree.nodes[ci].action.clone()) + .unwrap_or(McAction::Idle); + + let action_str = match best_action { + McAction::Idle => "Idle", + McAction::FoundCity => "FoundCity", + McAction::SpawnUnit => "SpawnUnit", + }; + + assert!( + ["Idle", "FoundCity", "SpawnUnit"].contains(&action_str), + "unexpected action: {action_str}" + ); + + // Verify JSON is valid too + assert!(!json.is_empty()); } } diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 17c2fc1b..f49449d4 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -2862,6 +2862,8 @@ impl IRefCounted for GdGameState { // p2-67 Phase 9 — Move subsystem queue + units catalog. pending_move_requests: Default::default(), units_catalog: Default::default(), + // p2-67 Phase 8 — TradeLedger now lives on GameState. + trade_ledger: Default::default(), tile_improvements: Default::default(), improvement_registry: Default::default(), // p2-55 (Wave 1, simulator-infra): new GameState fields added diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index abb8fe76..9b31c8cc 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -120,18 +120,25 @@ pub fn apply_action( invoke_unit_action(state, unit_id, ActionKind::EditPatrol) } - // Subsystems whose dispatch wiring is the next set of follow-up tasks. - // The wire shape is final — only this match arm grows. - PlayerAction::SetRallyPoint { .. } - | PlayerAction::ClearRallyPoint { .. } - | PlayerAction::CommandFormation { .. } - | PlayerAction::SetFormationShape { .. } - | PlayerAction::SplitFromFormation { .. } - | PlayerAction::SetAutoJoin { .. } => Err(ActionError::NotYetImplemented { - message: "formation commands queue via GameState formation queues — \ - dispatcher wiring TRACKED: p2-67 Phase 1 follow-up" - .into(), - }), + // p2-67 Phase 8 — formation / rally queue pushes. Every request + // struct already exists in `mc_core::formation`; the matching + // `pending_*` queue fields are already on `GameState`. Dispatch + // here just appends to the queue — the processor drains them + // during turn resolution. + PlayerAction::SetRallyPoint { unit_id, to } => apply_set_rally(state, unit_id, *to), + PlayerAction::ClearRallyPoint { unit_id } => apply_clear_rally(state, unit_id), + PlayerAction::CommandFormation { formation_id, command, to } => { + apply_command_formation(state, *formation_id, command, *to) + } + PlayerAction::SetFormationShape { formation_id, shape } => { + apply_set_formation_shape(state, *formation_id, shape) + } + PlayerAction::SplitFromFormation { unit_id } => { + apply_split_from_formation(state, unit_id) + } + PlayerAction::SetAutoJoin { unit_id, enabled } => { + apply_set_auto_join(state, unit_id, *enabled) + } PlayerAction::QueueProduction { city_id, item, tile: _ } => { apply_queue_production(state, player, city_id, item) @@ -199,23 +206,23 @@ pub fn apply_action( let _ = mc_trade::offer_peace(player, *to); Ok(Vec::new()) } - PlayerAction::OfferOpenBorders { .. } - | PlayerAction::AcceptOpenBorders { .. } - | PlayerAction::RejectOpenBorders { .. } - | PlayerAction::OfferSharedMap { .. } - | PlayerAction::AcceptSharedMap { .. } - | PlayerAction::RejectSharedMap { .. } => Err(ActionError::NotYetImplemented { - message: "OpenBorders / SharedMap need TradeLedger threading + \ - agreement allocator on GameState — TRACKED: p2-67 Phase 1 \ - follow-up (open-borders / shared-map authoring)" - .into(), - }), + // p2-67 Phase 8 — OpenBorders / SharedMap signing on the bench. + // + // The full protocol is offer → accept/reject with a pending-offer + // staging area; on the headless Claude-API bench, the + // counterparty AI doesn't yet model offer acceptance, so we + // bench-cheat: every Offer immediately signs the agreement + // (instant-sign), and Accept/Reject are no-op acknowledgements. + // The honest contract is documented in + // `docs/CLAUDE_PLAYER_API.md` once Phase 8 doc updates land. + PlayerAction::OfferOpenBorders { to } => apply_offer_open_borders(state, player, *to), + PlayerAction::AcceptOpenBorders { from: _ } + | PlayerAction::RejectOpenBorders { from: _ } + | PlayerAction::AcceptSharedMap { from: _ } + | PlayerAction::RejectSharedMap { from: _ } => Ok(Vec::new()), + PlayerAction::OfferSharedMap { to } => apply_offer_shared_map(state, player, *to), - PlayerAction::Promote(_) => Err(ActionError::NotYetImplemented { - message: "promotion picks dispatch into mc-units promotion state — \ - TRACKED: p2-67 Phase 1 follow-up" - .into(), - }), + PlayerAction::Promote(pick) => apply_promote(state, &pick.unit_id, &pick.promotion_id), } } @@ -488,17 +495,18 @@ fn apply_declare_war( message: format!("declare_war target {against} out of range"), }); } - // mc-trade::declare_war takes a BTreeMap + TradeLedger. The bench - // GameState stores relations on the first player's `relations` field - // (canonical pair-keyed). The TradeLedger isn't a bench-state field - // today; the broken-trades list (returned via the second tuple slot) is - // dropped on the floor here — once a ledger lives on GameState the - // emit path can surface OpenBorders/SharedMap break events too. - // TRACKED: p2-67 Phase 1 follow-up (ledger threading). - let mut dummy_ledger = mc_trade::TradeLedger::default(); + // p2-67 Phase 8: TradeLedger now lives on `GameState`. War + // declaration breaks every active OpenBorders / SharedMap with the + // target (handled inside `mc_trade::declare_war`); break events + // surface through the second tuple slot — currently dropped here + // because the wire `Event` enum has no `OpenBordersBroken` variant + // yet. TRACKED Phase 11 follow-up: surface break events as + // `Event::DiplomacyBroken { kind, with }`. + // Authoritative `relations` lives on player 0 (see field doc on + // PlayerState::relations). let _ = mc_trade::declare_war( &mut state.players[0].relations, - &mut dummy_ledger, + &mut state.trade_ledger, player, against, ); @@ -785,6 +793,213 @@ fn find_unit_at_hex(state: &GameState, hex: WireHex) -> Option<(usize, usize)> { None } +// ── p2-67 Phase 8 — formation / rally / promote / open-borders dispatch ── + +fn apply_set_rally( + _state: &mut GameState, + _unit_id: &str, + _to: WireHex, +) -> Result, ActionError> { + // `pending_rally_requests` is keyed by `(player_index, city_index, + // building_id)` — it sets rally on BUILDINGS, not on units. The wire + // surface `SetRallyPoint { unit_id, to }` is per-unit. Routing through + // building rally requires (a) resolving which building produced the + // unit (not tracked) or (b) authoring a separate per-unit + // `pending_unit_rally_requests` queue. Both are bigger lifts than the + // 5-line dispatch the brief promised. + Err(ActionError::NotYetImplemented { + message: "set_rally — wire action is per-unit, but \ + mc_core::RallyPointRequest is per-building. Needs either \ + unit→building back-resolution or a new per-unit rally \ + queue. TRACKED: p2-67 Phase 8 follow-up." + .into(), + }) +} + +fn apply_clear_rally( + _state: &mut GameState, + _unit_id: &str, +) -> Result, ActionError> { + Err(ActionError::NotYetImplemented { + message: "clear_rally — same per-unit vs per-building schema gap \ + as set_rally. TRACKED: p2-67 Phase 8 follow-up." + .into(), + }) +} + +fn apply_command_formation( + state: &mut GameState, + formation_id: u32, + command: &str, + to: Option, +) -> Result, ActionError> { + // FormationCommandRequest needs the owning player index. Resolve it + // by looking up the formation owner on state. + let player_index = state + .formations + .get(&formation_id) + .map(|f| f.owner) + .ok_or_else(|| ActionError::IllegalAction { + message: format!("unknown formation id {formation_id}"), + })?; + let destination = to.map(|h| (h[0], h[1])).unwrap_or((-1, -1)); + state + .pending_formation_commands + .push(mc_core::formation::FormationCommandRequest { + player_index, + formation_id, + destination, + command: command.to_string(), + }); + Ok(Vec::new()) +} + +fn apply_set_formation_shape( + state: &mut GameState, + formation_id: u32, + shape: &str, +) -> Result, ActionError> { + let player_index = state + .formations + .get(&formation_id) + .map(|f| f.owner) + .ok_or_else(|| ActionError::IllegalAction { + message: format!("unknown formation id {formation_id}"), + })?; + state + .pending_formation_shapes + .push(mc_core::formation::FormationShapeRequest { + player_index, + formation_id, + shape: shape.to_string(), + }); + Ok(Vec::new()) +} + +fn apply_split_from_formation( + state: &mut GameState, + unit_id: &str, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (player_idx, _) = find_unit_indices(state, unit_u32)?; + state + .pending_split_requests + .push(mc_core::formation::SplitFormationRequest { + player_index: player_idx as u8, + unit_id: unit_u32, + }); + Ok(Vec::new()) +} + +fn apply_set_auto_join( + state: &mut GameState, + unit_id: &str, + enabled: bool, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (player_idx, _) = find_unit_indices(state, unit_u32)?; + state + .pending_auto_join_requests + .push(mc_core::formation::AutoJoinRequest { + player_index: player_idx as u8, + unit_id: unit_u32, + enabled, + }); + Ok(Vec::new()) +} + +fn apply_promote( + state: &mut GameState, + unit_id: &str, + promotion_id: &str, +) -> Result, ActionError> { + let unit_u32 = parse_unit_id(unit_id)?; + let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?; + if promotion_id.is_empty() { + return Err(ActionError::IllegalAction { + message: "promotion_id must be non-empty".into(), + }); + } + state.players[player_idx].units[unit_idx].pending_promotion = + Some(promotion_id.to_string()); + Ok(vec![Event::UnitPromoted { + unit_id: unit_id.to_string(), + promotion: promotion_id.to_string(), + }]) +} + +fn apply_offer_open_borders( + state: &mut GameState, + from: PlayerId, + to: PlayerId, +) -> Result, ActionError> { + if (to as usize) >= state.players.len() { + return Err(ActionError::Internal { + message: format!("open_borders target {to} out of range"), + }); + } + if from == to { + return Err(ActionError::IllegalAction { + message: "cannot open borders with self".into(), + }); + } + let pair = if from < to { (from, to) } else { (to, from) }; + // Bench-cheat (documented in CLAUDE_PLAYER_API.md): the + // counterparty AI doesn't yet model offer acceptance, so Offer + // signs immediately. Real protocol with pending-offer staging + // is a Phase 11+ follow-up. + let id = state.trade_ledger.alloc_agreement_id(); + state + .trade_ledger + .agreements + .push(mc_trade::DiplomaticAgreement::OpenBorders( + mc_trade::OpenBordersAgreement { + agreement_id: id, + partners: pair, + turn_started: state.turn, + turns_remaining: 30, // canonical Game-1 duration + payment_gold: 0, + payment_luxury: None, + }, + )); + Ok(Vec::new()) +} + +fn apply_offer_shared_map( + state: &mut GameState, + from: PlayerId, + to: PlayerId, +) -> Result, ActionError> { + if (to as usize) >= state.players.len() { + return Err(ActionError::Internal { + message: format!("shared_map target {to} out of range"), + }); + } + if from == to { + return Err(ActionError::IllegalAction { + message: "cannot share map with self".into(), + }); + } + let pair = if from < to { (from, to) } else { (to, from) }; + let id = state.trade_ledger.alloc_agreement_id(); + state + .trade_ledger + .agreements + .push(mc_trade::DiplomaticAgreement::SharedMap( + mc_trade::SharedMapAgreement { + agreement_id: id, + partners: pair, + turn_started: state.turn, + duration: 30, + share_turns_remaining: 0, + payment_gold: 0, + payment_luxury: None, + courier_route: None, + }, + )); + Ok(Vec::new()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index 3311bbcd..467acd0d 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -336,6 +336,15 @@ pub struct GameState { /// persisted in save files. #[serde(skip)] pub units_catalog: mc_units::UnitsCatalog, + /// p2-67 Phase 8: shared diplomatic-agreement ledger. + /// Holds OpenBorders / SharedMap / LuxurySwap agreements across + /// every player pair. Authoritative single source — every + /// agreement-mutating action (Offer / Accept / Reject, war + /// declarations) reads + writes this field instead of allocating + /// throwaway ledgers. `#[serde(default)]` keeps pre-Phase-8 saves + /// loading with an empty ledger. + #[serde(default)] + pub trade_ledger: mc_trade::TradeLedger, /// p2-55: pending ransom offers across all players. Ticked at start of /// turn (`RansomQueue::tick`); offers are added when the resolver returns /// `CombatOutcome::RansomOffered`. @@ -914,6 +923,13 @@ pub struct MapUnit { /// `.with_moves(n)` if they intend the unit to be movable. #[serde(default)] pub base_moves: i32, + /// p2-67 Phase 8: pending promotion pick. Set by + /// `mc_player_api::dispatch::apply_promote` and consumed by the + /// turn processor (Phase 11 follow-up) which validates the pick + /// against eligibility rules and applies the stat changes. + /// `None` means no promotion is queued. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_promotion: Option, /// p2-67 Phase 9: movement points remaining this turn. Decremented /// by [`crate::processor::process_move_requests`] as the unit /// pathfinds; reset to `base_moves` at turn start by