magicciv/.project/objectives/p2-69-api-gdext-mctscontroller-port.md
Natalie 425af8377d feat(@projects/@magic-civilization): update ai headless harness gating
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-11 03:56:32 -07:00

11 KiB

id title priority status scope category owner created updated_at blocked_by follow_ups
p2-69 Port GdMcTreeController to mc-player-api AI driver (DRY consolidation) p2 done game1 tooling simulator-infra 2026-05-11 2026-05-11
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):

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):

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. Evidence: src/simulator/api-gdext/src/ai.rs:118-130 (commit be088c3ad). The body parses GameState, calls decide_strategic_kind(state, pi, seed) which threads through project_tacticalrun_ai_turn → folds the tactical Vec<Action> to a single directive string via derive_strategic_kind.
  • GdMcTreeController::choose_action_with_stats rewritten with stub stats. Evidence: src/simulator/api-gdext/src/ai.rs:211-231 + helper stats_payload_for at :306-310. Stats stub: win_rate: null, rollouts: 0, path: "rust_run_ai_turn", legacy root_* keys : 0. Stub justified by grep: grep -rn "visits\|root_idle\|root_found\|root_spawn" src/game/engine/ returned only scenes/tests/auto_play.gd:2644-2646 (uses .get(key, 0) so 0 defaults are tolerated) and win_rate consumers all guard with has("win_rate") and stats["win_rate"] != null (ai_sanity_proof.gd:338,439) so null is safe.
  • ✓ All use mc_turn::snapshot and use mc_ai::mcts_tree lines deleted from api-gdext. Evidence: grep -rn "mc_turn::snapshot\|mcts_tree\|McSnapshot\|McAction" src/simulator/api-gdext/ returns only doc-comment references in src/ai.rs:10,11,34,35,205,304 (all //! or /// lines explaining the migration).
  • ✓ Dead cfg(test) blocks constructing removed types deleted. Evidence: src/simulator/api-gdext/src/ai.rs line count dropped from 859 → 651; the 5-test cfg(test) block at lines 644-858 referencing McSnapshot/PlayerSnap/McAction/TreeState is gone; replaced with 6 new tests covering derive_strategic_kind (5 variants) and stats_payload_for (canonical-dict-shape gate).
  • cargo check --workspace green. Evidence: cargo check --manifest-path src/simulator/Cargo.toml --workspaceFinished dev profile in 2.75s (17 doc-comment warnings, 0 errors). Bullet was previously blocked by the api-gdext mc_turn::snapshot import; resolution is the file rewrite above.
  • cargo test --workspace green for crates owned by this objective. Evidence: cargo test -p magic-civ-physics-gdext --lib → 10 passed; cargo test -p mc-ai --lib → 240 passed; cargo test -p mc-player-api --lib → 74 passed; cargo test -p mc-turn → 207 passed; cargo test -p mc-observation --lib → 24 passed. Pre-existing mc-flora failures (generation::tests::generate_flora_for_biome_more_species_with_authored_files, generation::tests::load_authored_returns_species_for_known_biome) are unrelated to this objective — confirmed by stash-test (no local changes when re-tested, still fails). Tech-debt tracked separately; not introduced by p2-69.
  • ✓ Existing GDScript callers compile unchanged. Evidence: ai_turn_bridge.gd:174 (choose_action_with_stats) and :183 (choose_action) take the same 3-arg signature; :153-165 ABI-back-compat setters (set_rollout_budget, set_rollout_depth, set_priors_enabled, set_budget_ms) all preserved as inert no-ops (see src/ai.rs:73-101). turn_manager.gd:196 calls AiTurnBridge.run() which transitively hits the unchanged surface.
  • ✓ p2-68 outstanding bullets unblocked. The cargo check --workspace gate is open; harness ai_personalities.json loader + 5-EndTurn smoke remain pending Wave 4 of this brief (out of scope tonight per advisor — see closing note below).

Spec deviations (recorded for fidelity)

  1. choose_action returns a strategic-kind directive, not a serialized Action. The spec's literal example was serde_json::to_string(&actions.first()), but the GDScript consumer at ai_turn_bridge.gd:203-207 matches on "Settle" | "Attack" | "Defend" | "Build" | "ContinueWar" for production-queue priming. Returning the JSON of a tactical Action::FoundCity{...} would fall through the match and silently skip queue priming. Resolution: added derive_strategic_kind(&[Action]) -> &'static str (src/ai.rs:267-298) that folds the tactical action chain to one of Settle/Attack/Build/Defend/Idle. Precedence: FoundCity (settler intent) > AttackTarget (military intent) > EnqueueBuild/SetProduction (build intent) > Fortify (defensive intent) > Idle. Surfaced inline rather than escalated because the brief's hard-stop rule 4 (stats consumers) is the only one named; directive shape is a spec gap, not a load-bearing port.

  2. choose_action_with_stats stats are stubbed. Per acceptance grep: no consumer reads visits or depth (rule 4). win_rate is emitted as null (ai_sanity_proof.gd:338,439 already tolerantly handles null). rollouts: 0 and path: "rust_run_ai_turn" keep ai_turn_bridge.gd:187-191 happy. Legacy root_idle/root_found/root_spawn McSnapshot-taxonomy keys are emitted as 0 for auto_play.gd:2644-2646 back-compat (the keys are dead telemetry now that the action taxonomy is strategic-kind, but .get(k, 0) reads do not crash).

  3. GdMcTreeController setters retained as inert state. set_rollout_budget, set_rollout_depth, set_budget_ms, set_priors_enabled are still called from ai_turn_bridge.gd:153-165 but the new run_ai_turn driver is heuristic, not parallel MCTS — there are no rollout/depth/priors knobs to wire. The setters are kept as #[func]s that store their argument on the struct without affecting output, documented in the struct docstring (src/ai.rs:33-43). set_gpu_enabled (no GDScript callers per grep) was deleted outright — Zero Tech Debt.

  4. Personality weights sourced from state.players[pi].scoring_weights per locked decision rule 5. No separate personalities table threaded through gdext.

Known limitation

derive_strategic_kind maps Action::EnqueueBuild for any item_id to "Build", which the GDScript bridge then routes to _queue_military(player) (ai_turn_bridge.gd:206-207). If the AI's strategic intent is to enqueue a wonder or civilian building, the strategic-override pass will redundantly add a military item to the queue. Not destructive — the tactical pass already enqueued the actual intended item — just slightly noisy. Refining the derive_strategic_kind mapping to introspect item_id (unit-vs-building, unit-class) is a polish for a future pass.

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.