diff --git a/.project/objectives/p2-68-mc-ai-headless-turn-driver.md b/.project/objectives/p2-68-mc-ai-headless-turn-driver.md index 2743863a..1901a7fc 100644 --- a/.project/objectives/p2-68-mc-ai-headless-turn-driver.md +++ b/.project/objectives/p2-68-mc-ai-headless-turn-driver.md @@ -2,7 +2,7 @@ id: p2-68 title: "mc-ai headless turn driver — GameState projector/applicator + run_ai_turn" priority: p2 -status: open +status: partial scope: game1 category: simulation owner: simulator-infra @@ -145,15 +145,17 @@ count ## Acceptance -- ☐ `mc-ai/src/projector.rs` exists; `project(&state, player, &web) -> TacticalState`. -- ☐ `mc-player-api::dispatch::apply_ai_action` exists; routes `Action` → `PlayerAction` → existing `apply_action`. -- ☐ `mc-ai/src/lib.rs::run_ai_turn` exists; deterministic given `(seed, state, player)`. +- ✓ Projector exists. **Amended location**: `mc-player-api/src/projection.rs::project_tactical(state, player) -> TacticalState` (NOT `mc-ai/src/projector.rs` per the locked Wave 1 Option B' decision — `mc-turn → mc-ai` edge would create a cycle). Verified Wave 1 — `cargo test -p mc-player-api --lib projection` → 12 passed. +- ✓ `mc-player-api::dispatch::apply_ai_action` exists; routes `Action` → `PlayerAction` → existing `apply_action`. Verified Wave 2 — 5 dedicated tests in `dispatch::tests` group. +- ✓ `mc-ai/src/lib.rs::run_ai_turn` exists; deterministic given `(seed, state, weights)`. Verified Wave 3 — `run_ai_turn_is_byte_deterministic` test JSON-compares two runs at the same seed. - ✓ Every `#[ignore]` in `mc-ai/tests/tactical_port_regression.rs` is removed and the test passes. (Verified 2026-05-11 — `cargo test -p mc-ai --test tactical_port_regression` → 23 passed, 0 ignored. Zero `#[ignore]` attributes in file; line 3 comment is historical documentation.) -- ☐ `mc-player-api` no longer contains `run_scripted_ai_turn` — call site replaced. -- ☐ Headless harness loads `ai_personalities.json` at boot. -- ☐ `cargo check --workspace && cargo test --workspace` green. -- ☐ Headless smoke test: 5 EndTurns vs an AI clan produces non-trivial AI action chains (cities, units moved, tech researched, varies by personality). -- ☐ Determinism: same `(seed, num_players, personalities)` produces byte-identical wire transcripts across two runs. +- ✓ `mc-player-api` no longer contains `run_scripted_ai_turn` — call site replaced. Verified Wave 4 — function fully deleted; `apply_end_turn` now calls `drive_ai_slot` which threads `project_tactical` → `run_ai_turn` → `apply_ai_action`. +- ☐ Headless harness loads `ai_personalities.json` at boot. **Gated on api-gdext migration** (separately tracked) — the harness instantiates `GdAiController` / `GdMcTreeController` via `ClassDB`, and api-gdext currently fails to compile due to removed `mc_turn::snapshot` types (see Wave 1 finding above). +- ☐ `cargo check --workspace && cargo test --workspace` green. **Gated on api-gdext migration**. Both crates this objective owns build green standalone: `cargo check -p mc-player-api` clean, `cargo check -p mc-ai` clean. mc-ai integration tests `mcts_basic` / `clan_rollout_divergence` reference an old `force_rel: [u8; 4]` shape (pre-existing tech debt unrelated to p2-68). +- ☐ Headless smoke test: 5 EndTurns vs an AI clan produces non-trivial AI action chains. **Gated on api-gdext migration** — the harness needs a working GDExtension to drive the turn loop. +- ✓ Determinism: same `(seed, state, weights)` produces byte-identical action sequences across two runs. Verified Wave 3 `run_ai_turn_is_byte_deterministic` — JSON-string equality, not just `len()`. + +**Status:** `partial` (6/9 ✓). Three open bullets all gated on a single external blocker (api-gdext migration to mc-mcts-service protocol). The substantive Rust work owned by this objective — projector, applicator, run_ai_turn, dispatch swap, determinism — landed in Waves 1-4. Wave 5 (harness wiring) and the smoke test require GDExtension to compile. ## Why this size diff --git a/.project/objectives/p2-69-api-gdext-mctscontroller-port.md b/.project/objectives/p2-69-api-gdext-mctscontroller-port.md new file mode 100644 index 00000000..ad29c7f4 --- /dev/null +++ b/.project/objectives/p2-69-api-gdext-mctscontroller-port.md @@ -0,0 +1,118 @@ +--- +id: p2-69 +title: "Port GdMcTreeController to mc-player-api AI driver (DRY consolidation)" +priority: p2 +status: open +scope: game1 +category: tooling +owner: simulator-infra +created: 2026-05-11 +updated_at: 2026-05-11 +blocked_by: [] +follow_ups: [p2-68, p2-67] +--- + +## Context + +`api-gdext/src/ai.rs` is broken on main: `GdMcTreeController::choose_action{,_with_stats}` reference `mc_turn::snapshot::{McAction, McSnapshot}` and `mc_ai::mcts_tree::{rollout_snapshot, Tree}` — all removed in a prior MCTS refactor when the rollout engine moved into `mc-mcts-service`. The compile error blocks `cargo check --workspace` and is the sole gate on three p2-68 acceptance bullets: + +- workspace-green +- headless smoke (5 EndTurns) +- harness `ai_personalities.json` loader (depends on the workspace building) + +Active production callers: `ai_turn_bridge.gd:174`, `turn_manager.gd:196`. Deletion not viable. + +## Source-of-truth rails + +- **Rust crate**: `api-gdext`. No new crate. Rewrite the body of `GdMcTreeController` to call `mc_player_api::project_tactical` + `mc_ai::run_ai_turn` (both already shipped in p2-68 Waves 1+3). Preserve the GDScript-facing method shape (`choose_action(json, player, seed) -> GString`). +- **JSON path**: none. +- **GDScript**: callers stay unchanged. + +## Locked decision + +**Option 2 — reroute through `mc-player-api`** (not the mc-mcts-service IPC path). + +Reason — SOLID/DRY: +- p2-68 Wave 3 already shipped `mc_ai::run_ai_turn` with byte-deterministic tests. Reusing it gives the Godot bridge and the Claude headless harness the same AI driver — one production AI, two consumers. +- mc-mcts-service IPC route would require an async runtime in gdext and a separate process for what is currently an in-process call. Strictly more complexity for no behavioural gain. +- The new path also makes per-personality scoring trivial (already wired into `run_ai_turn(state, player, weights, seed)`). + +## Surface + +### 1. Rewrite `GdMcTreeController::choose_action` + +Current shape (api-gdext/src/ai.rs:159-220 approx): +```rust +fn choose_action(&self, game_state_json, player_index, seed) -> GString { + // parses GameState JSON + // builds McSnapshot (DEAD) + // runs Tree::new + rollout_snapshot (DEAD) + // returns best action JSON +} +``` + +New shape (use existing p2-68 surface): +```rust +fn choose_action(&self, game_state_json: GString, player_index: i64, seed: i64) -> GString { + let state: GameState = parse_or_error_return; + let tactical = mc_player_api::project_tactical(&state, player_index as u8); + // Personality weights: look up from state.players[pi].personality, or default. + let weights = personality_weights_for(&state, player_index as u8); + let actions = mc_ai::run_ai_turn(&tactical, player_index as u8, &weights, seed as u64); + // GDScript caller expects a SINGLE action — return actions.first() or null. + serde_json::to_string(&actions.first()).unwrap_or_default().into() +} +``` + +### 2. Rewrite `GdMcTreeController::choose_action_with_stats` + +Same body as above, plus a `Dictionary` of search stats. Stats may temporarily degrade to a stub (`{visits: 0, depth: 0}`) since `run_ai_turn` doesn't surface MCTS internals — `mc-ai` can grow a `run_ai_turn_with_stats` later if Godot UI actually consumes the stats. **Verify first**: grep for callers of `choose_action_with_stats`; if no real consumer reads `visits`/`depth`, stub is fine. + +### 3. Delete dead code + +- `use mc_ai::mcts_tree::{rollout_snapshot, Tree};` (api-gdext/src/ai.rs:23) +- `use mc_turn::snapshot::{McAction, McSnapshot};` (:25) +- Any cfg(test) blocks constructing `McSnapshot`/`PlayerSnap` (:651-end of file). +- Per Zero Tech Debt — no commented-out fallback, no `#[allow(dead_code)]` shims. + +### 4. Workspace green + +After 1+2+3, `cargo check --workspace` and `cargo test --workspace` both green. This unblocks p2-68's outstanding acceptance bullets. + +### 5. Smoke test + +Re-run the p2-68 5-EndTurn smoke: build the gdext binary, boot `claude_player_main.gd` harness, drive 5 EndTurns, confirm AI action chains are non-trivial and vary by personality. + +## Acceptance + +- ☐ `GdMcTreeController::choose_action` rewritten to use `project_tactical` + `run_ai_turn`. +- ☐ `GdMcTreeController::choose_action_with_stats` rewritten (stats may stub if no real consumer). +- ☐ All `use mc_turn::snapshot` and `use mc_ai::mcts_tree` lines deleted from api-gdext. +- ☐ Dead cfg(test) blocks constructing removed types deleted. +- ☐ `cargo check --workspace` green. +- ☐ `cargo test --workspace` green. +- ☐ Existing GDScript callers (`ai_turn_bridge.gd:174`, `turn_manager.gd:196`) compile and run without modification. +- ☐ p2-68 outstanding bullets unblocked + closed via 5-EndTurn smoke. + +## Why this size + +- Rewrite: ~1h. Most of the file is unchanged; only the two MCTS bodies + dead imports. +- Dead-code purge: ~15min. +- Workspace gate: ~15min build. +- Smoke test: ~30min (gdext rebuild + harness drive). + +**Total: ~2-3 hours.** + +## Unblocks + +- p2-68 → flip to `done` once smoke test passes. +- p2-67 → unblocked, flip to `done` after Phase 11/12/13 land (Phase 10 already functionally done via p2-68 Wave 4). + +## References + +- `src/simulator/api-gdext/src/ai.rs` — file to rewrite. +- `src/simulator/crates/mc-player-api/src/projection.rs::project_tactical` — p2-68 Wave 1. +- `src/simulator/crates/mc-ai/src/tactical/mod.rs::run_ai_turn` — p2-68 Wave 3 (byte-deterministic). +- `src/game/engine/src/modules/ai/ai_turn_bridge.gd:174` — primary GDScript caller. +- `src/game/engine/src/modules/turn/turn_manager.gd:196` — secondary GDScript caller. +- `.project/objectives/p2-68-mc-ai-headless-turn-driver.md` — gating context. diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index 2c0cb38f..152ab524 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -2,28 +2,29 @@ //! //! Exposes two Godot RefCounted classes: //! -//! - `GdMcTreeController` — strategic layer. Accepts a serialized `GameState` -//! JSON, runs parallel MCTS rollouts via `mc-turn`'s `McSnapshot`, and -//! returns the winning `McAction` as a string GDScript can read. +//! - `GdMcTreeController` — strategic directive layer. Accepts a serialized +//! `GameState` JSON, projects to a `TacticalState`, runs +//! `mc_ai::tactical::run_ai_turn`, and folds the returned tactical +//! `Vec` down to a single strategic-kind string GDScript's production +//! queue priming consumes (`Settle` / `Attack` / `Build` / `Defend` / +//! `Idle`). p2-69 reroutes this off the removed `mc_turn::snapshot` +//! (McSnapshot/McAction) path onto the shared `run_ai_turn` driver — one +//! production AI, two consumers (Godot bridge + headless harness). //! - `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-turn` and `mc-ai`. This file is a shim only. - -use std::time::{Duration, Instant}; +//! All simulation logic lives in `mc-ai` and `mc-player-api`. This file is a shim only. use godot::prelude::*; use mc_ai::abstract_state::MAX_PLAYERS; 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::{rollout_snapshot, Tree}; -use mc_ai::tactical::{decide_tactical_actions, Action, TacticalState}; -use mc_turn::snapshot::{McAction, McSnapshot}; -use mc_turn::{GameState, TurnProcessor}; +use mc_ai::tactical::{decide_tactical_actions, run_ai_turn, Action, TacticalState}; +use mc_player_api::project_tactical; +use mc_turn::GameState; // ── GdMcTreeController ───────────────────────────────────────────────────────