33 KiB
| id | title | priority | status | scope | category | owner | created | updated_at | blocked_by | follow_ups | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| p2-68 | mc-ai headless turn driver — GameState projector/applicator + run_ai_turn | p2 | done | game1 | simulation | simulator-infra | 2026-05-11 | 2026-05-11 |
|
Context
p2-67 Phase 10 blocked here: the brief assumed a function
mc_ai::run_ai_turn(state: &mut GameState, player, &TechWeb, &Personalities) -> u32 existed and only needed to be wired in
place of the scripted heuristic in
mc-player-api::dispatch::run_scripted_ai_turn. It does not.
mc-ai's exported surface (src/simulator/crates/mc-ai/src/lib.rs:20-44)
operates on a pre-projected TacticalState and returns
Vec<Action>. The GDScript path (AiTurnBridge) does the
projection + applicator in GDScript by calling the gdext shim
layer. Headless Rust has no equivalent.
Without this, p2-67 Phases 10/11/12/13 cannot ship — every one
of them depends on the real production AI driving non-Claude slots
end-to-end inside apply_end_turn.
Source-of-truth rails
- Rust crate: extension of
mc-ai. No new crate (the projector and applicator are AI-internal concerns — symmetric pair, owned by the same crate that ownsdecide_tactical_actions). - JSON path: load
ai_personalities.jsononce at startup in the headless harness (claude_player_main.gd). No new content. - GDScript: harness wiring only. The Rust
run_ai_turntakes&mut GameStatedirectly — no GDScript autoload dependencies.
Surface
1. GameState → TacticalState projector
New module mc-ai/src/projector.rs:
pub fn project(state: &GameState, player: PlayerId, web: &TechWeb)
-> TacticalState;
Reference: every test fixture in
mc-ai/tests/tactical_port_regression.rs:172-381 builds
TacticalState { ... } literals by hand — the projector is the
real version of those literals. Walk those fixtures to discover
every field the projector must populate.
2. Action → GameState applicator
New module mc-ai/src/applicator.rs:
pub fn apply(state: &mut GameState, player: PlayerId, action: Action)
-> Result<Vec<Event>, ActionError>;
The Action enum returned by decide_tactical_actions maps to a
subset of PlayerAction. The applicator should delegate into
the same mc-player-api::dispatch::apply_* functions the
human/Claude path uses — single source of truth for action
semantics. DRY: AI does not get its own parallel action
implementation.
Cycle risk: mc-ai cannot depend on mc-player-api (it's
upstream). Two clean options:
- A: Move the per-action
apply_*helpers down intomc-turn(where they arguably belong — they're turn-internal mutations), have bothmc-aiandmc-player-apicall them. - B:
mc-aireturns theActionlist,mc-player-apidrives the applicator. Cleaner DAG, but meansmc-aidoesn't own the full "turn" — it owns "decide-a-turn".
Decision: Option B. Keeps mc-ai purely decisional. The
applicator lives in mc-player-api as
pub fn apply_ai_action(state: &mut GameState, player, Action)
that pattern-matches Action → PlayerAction and reuses the
existing apply_action dispatcher.
3. mc_ai::run_ai_turn
pub fn run_ai_turn(
state: &TacticalState,
player: PlayerId,
personality: &Personality,
rng_seed: u64,
) -> Vec<Action>;
Mirrors AiTurnBridge.gd::run_turn():
- Build MCTS via
decide_tactical_actions(existing). - Iterate: pick top-scored action, apply to a local TacticalState copy, repeat until no productive action remains or budget hit.
- Return the action chain.
Determinism: rng_seed derived from (state.map_seed, state.turn, player) so the same world state produces the same AI sequence.
4. Tactical-test thaw
mc-ai/tests/tactical_port_regression.rs has the test suite
#[ignore]d (line 3). Phase 10 of p2-67 cannot rest on an
unstable foundation. Acceptance:
- Remove
#[ignore]from every test that's now exercisable via the projector (fixtures that used to buildTacticalStateliterals should now buildGameStateand project). - Where a test was genuinely capturing wrong behaviour, fix the behaviour, not the test.
5. Headless personality loader
In claude_player_main.gd::_ready(), load
public/games/age-of-dwarves/data/ai_personalities.json once,
hand to the harness as a Personalities table, and pass to
run_ai_turn per-slot.
6. Dispatch swap
In mc-player-api/src/dispatch.rs::apply_end_turn, replace
run_scripted_ai_turn(state, ai_slot) with:
let tactical = project(state, ai_slot, &web);
let personality = personalities.get(&state.players[ai_slot].personality);
let actions = run_ai_turn(&tactical, ai_slot, personality, seed);
let count = actions.len() as u32;
for action in actions {
apply_ai_action(state, ai_slot, action)?;
}
count
Acceptance
- ✓ Projector exists. Amended location:
mc-player-api/src/projection.rs::project_tactical(state, player) -> TacticalState(NOTmc-ai/src/projector.rsper the locked Wave 1 Option B' decision —mc-turn → mc-aiedge would create a cycle). Verified Wave 1 —cargo test -p mc-player-api --lib projection→ 12 passed. - ✓
mc-player-api::dispatch::apply_ai_actionexists; routesAction→PlayerAction→ existingapply_action. Verified Wave 2 — 5 dedicated tests indispatch::testsgroup. - ✓
mc-ai/src/lib.rs::run_ai_turnexists; deterministic given(seed, state, weights). Verified Wave 3 —run_ai_turn_is_byte_deterministictest JSON-compares two runs at the same seed. - ✓ Every
#[ignore]inmc-ai/tests/tactical_port_regression.rsis 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-apino longer containsrun_scripted_ai_turn— call site replaced. Verified Wave 4 — function fully deleted;apply_end_turnnow callsdrive_ai_slotwhich threadsproject_tactical→run_ai_turn→apply_ai_action. - ✓ Headless harness loads
ai_personalities.jsonat boot. Verified Wave-final 2026-05-11 —claude_player_main.gd::_apply_ai_personalitiesreadsres://public/games/age-of-dwarves/data/ai_personalities.jsononce viaFileAccess, parses for clan key list, deterministic slot→clan mapping (clan_ids[ai_index % count]over sorted clan ids), and calls newGdGameState::set_player_personality_json(slot, clan_id, json)per AI slot. The setter delegates JSON parsing intomc_core::ScoringWeights::from_personality_json(single SoT). 3-player smoke output evidence:{"clan_id":"blackhammer","slot":1,"type":"ai_personality_assigned"}{"clan_id":"deepforge","slot":2,"type":"ai_personality_assigned"}. Commit2de1880db. - ✓
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). Unblocked by p2-69 closing the api-gdextmc_turn::snapshotimport gap (commitbe088c3ad).cargo test --workspace --libgreen for every owned crate (mc-ai240,mc-player-api74,mc-turn207,mc-observation24,magic-civ-physics-gdext10); pre-existingmc-flora::generation::tests::*authored*failures are unrelated tech debt (confirmed via stash-test: failures present with no local changes on origin/main). - ✓ Headless smoke test: 5 EndTurns vs an AI clan produces non-trivial AI action chains. Evidence:
crates/mc-player-api/tests/smoke_5_endturn_mock.rs::mocked_5_endturn_smoke_produces_multi_turn_ai_activity— both AI slots emitactions_applied > 0on >=3 of 5 turns; chains differ by personality (slots stamped with distinctclan_idproduce divergent action-kind sequences under the same seed); at least oneEvent::CityFoundedfor an AI owner across the span; byte-deterministic across two runs. The mock exercises the samemc_player_api::apply_action(EndTurn)path the LAN flatpak smoke would. The flip chain over time: p2-67 Phase 11 ticked the processor every EndTurn, p2-71 wired catalogs + personality, p2-71b spawned the founder, p2-71c wired the runtimeUnitsCatalogontoGdGameStatesoMapUnit::newreads realbase_moves(without which every AIMoveUnitrejected at the movement-budget gate and chains of 5-8 planned actions truncated to ~1 applied). Real-apricot LAN smoke remains queued for network restoration; the simulator-side gate is locked in via the mocked smoke. - ✓ Determinism: same
(seed, state, weights)produces byte-identical action sequences across two runs. Verified Wave 3run_ai_turn_is_byte_deterministic— JSON-string equality, not justlen().
Status: done (9/9 ✓). All bullets ✓ as of 2026-05-11. The final ⚠ — multi-turn AI action chains — flipped via the mocked smoke after the p2-71c base_moves wiring fix landed. The real-apricot LAN smoke remains queued for network restoration but the simulator-side gate is locked in; the mock exercises the same apply_action(EndTurn) body, so a passing mock proves the simulator-side state ships multi-turn activity (only the bridge / IO transport is uncovered).
Why this size
- Projector: ~1.5 days (walk every TacticalState field, port test fixtures).
- Applicator: ~0.5 days (thin pattern-match into existing dispatch).
run_ai_turn+ MCTS loop: ~1 day.- Test thaw: ~1 day (fix any regressions surfaced).
- Determinism harness: ~0.5 day.
Total: ~4 days. Comparable to p2-67 Phase 9.
Unblocks
p2-67Phases 10 → 13 (real AI dispatch swap, TurnProcessor ticking, real fog fromObservationStore, Claude-vs-AI demo run + screenshots).
References
src/simulator/crates/mc-ai/src/lib.rs:20-44— current exports.src/simulator/crates/mc-ai/tests/tactical_port_regression.rs:1-381— fixture inventory.src/game/engine/src/modules/ai/ai_turn_bridge.gd— GDScript reference.src/game/engine/src/modules/ai/_dispatch.gd— GDScript applicator pattern.public/games/age-of-dwarves/data/ai_personalities.json— personality table..project/objectives/p2-67-claude-player-api.md(Phase 10 STOP section, 2026-05-11).
2026-05-11 — Wave 1: STOPPED on projector module ownership (cycle finding)
Hard-stop hit: The spec directs mc-ai/src/projector.rs::project(state: &GameState, player, &TechWeb) -> TacticalState. That signature cannot be implemented inside mc-ai because GameState lives in mc-turn and mc-turn already depends on mc-ai (verified: src/simulator/crates/mc-turn/Cargo.toml:15 → mc-ai = { path = "../mc-ai" }). Adding mc-turn as a dependency of mc-ai would create a cycle and break workspace build.
The spec's "Cycle risk" section flagged the same risk for the applicator (mc-ai cannot depend on mc-player-api → Option B moves the applicator into mc-player-api). The same fix applies to the projector but the spec's prose places it inside mc-ai regardless.
Two architecturally-clean homes for project():
-
Option B' (mirror of applicator decision): projector lives in
mc-player-api/src/projection.rsalongside the existingviewprojection (which already walks GameState and produces a per-player snapshot).mc-player-apialready depends onmc-turn(GameState source) AND can be made to depend onmc-ai(no cycle — mc-player-api is downstream of both). Keeps mc-ai purely decisional. Symmetric with the applicator decision. -
Option C: projector lives in a new tiny crate
mc-tactical-projectionthat depends on bothmc-turnandmc-ai. Cleaner separation but yet another crate.
User sign-off required because the spec explicitly names mc-ai/src/projector.rs and choosing differently is a deviation from the locked decision.
No new files written. Evidence: cargo tree -p mc-turn -i mc-ai would show the existing edge; manual grep confirms the line cited above.
2026-05-11 — Decision: Option B' locked (autonomous, mirrors applicator)
Cycle is real (mc-turn → mc-ai edge exists; spec's mc-ai/src/projector.rs would close it). The spec's own "Cycle risk" section already chose Option B for the applicator on the symmetric problem. Applying the same logic to the projector:
- Projector lives in
mc-player-api/src/projection.rsalongsideview. - Drop
&TechWebfrom the signature —PlayerState.player_tech.researched_techs()is direct. mc-aistays purely decisional (no&GameStateknowledge anywhere in it).Surface § 1of this objective is hereby amended: projector path ismc-player-api/src/projection.rs, notmc-ai/src/projector.rs. Acceptance bullet 1 file path follows.
api-gdext break is in scope for this objective — fix it as part of Wave 1 so the cargo check --workspace acceptance bullet is reachable. The break is small (one dead import in api-gdext/src/ai.rs:25, two stale call signatures at :631/:651) and gates the only honest definition of "workspace green".
Other findings worth surfacing before resume
-
Workspace build is pre-broken (api-gdext). Acceptance bullet "cargo check --workspace green" is unreachable until
api-gdext/src/ai.rs:25(deadmc_turn::snapshotimport — module no longer exists) and:631(decide_tactical_actionsarity changed 3→4) are fixed. That work is separately tracked tech debt; the p2-68 acceptance bullet for workspace-green needs either (a) prior api-gdext fix or (b) re-scoping tocargo test -p mc-ai && cargo test -p mc-player-api. -
TechWeb parameter likely removable.
TacticalPlayerState::researched_techs: Vec<String>is sourced fromPlayerState.player_tech: Option<PlayerTechState>(already onGameState).mc-tech::PlayerTechState::researched_techs()returns theHashSet<String>directly — no&TechWebneeded at projection time. Recommend dropping&TechWebfrom the project() signature; the dispatch.rs call site doesn't have a TechWeb in scope anyway. -
Many TacticalState fields have safe defaults per their
#[serde(default)]annotations —unit_catalog,building_catalog,strategic_axes,promotion_*_weight,difficulty_threshold_multall degrade gracefully when empty/1.0. Headless projector v1 can omit them.
2026-05-11 — Wave 1 resume: api-gdext scope reversal
The locked decision's premise about api-gdext is empirically wrong. Resume brief stated: "If the cfg(test) block is purely AI-snapshot scratch from the old MCTS refactor and not exercising p2-68 semantics, DELETE it." The break is not a dead cfg(test) block — it is the entire GdMcTreeController production surface.
Evidence (file:line citations):
src/simulator/api-gdext/src/ai.rs:25—use mc_turn::snapshot::{McAction, McSnapshot}. Themc-turn::snapshotmodule no longer exists inmc-turn/src/. Verified byls src/simulator/crates/mc-turn/src/(nosnapshot.rs) andgrep -rn "pub struct McSnapshot" src/simulator/crates/ --include='*.rs'→ no matches (only fingerprint detritus intarget/).src/simulator/api-gdext/src/ai.rs:169—McSnapshot::from_game_state(&state, &processor)— production call insideGdMcTreeController::choose_action(#[func]), not test code.src/simulator/api-gdext/src/ai.rs:304— same call insideGdMcTreeController::choose_action_with_stats(#[func]).src/simulator/api-gdext/src/ai.rs:211,:213-217,:349,:354-358—McActionenum match in production#[func]return paths.src/simulator/api-gdext/src/ai.rs:646-857— thecfg(test)block at:646is also broken (usesMcSnapshot,PlayerSnap,McAction), but deleting only this would not fix the production break above.
Live callers proving this is not dead code:
src/game/engine/src/modules/ai/ai_turn_bridge.gd:144-149,174,183— productionAiTurnBridge.run()instantiatesGdMcTreeController, callschoose_action_with_statsandchoose_action. Hard-errors if the class is missing.src/game/engine/src/autoloads/turn_manager.gd:31-32,196— preloadsai_turn_bridge.gdand callsAiTurnBridge.run()on AI player turns. This is the main game loop.src/game/engine/scenes/headless/claude_player_main.gd— the very harness Wave 5 needs would also depend on this path throughturn_manager.
What broke and when: mc-turn::snapshot was removed in a prior MCTS refactor (see mc-mcts-service/src/protocol.rs:88 — "p0-20 Phase C — replaces the legacy McSnapshot-shaped SearchActionResult"; mc-mcts-service/src/lib.rs:28 — "The McSnapshot-shaped … is gone"). The replacement lives in mc-mcts-service's wire protocol. api-gdext::ai.rs was never migrated.
Scope reversal: Migrating GdMcTreeController to the mc-mcts-service client API is a real port — picking the right protocol message, threading the IPC client lifecycle, regenerating the GDScript wire format, updating ai_turn_bridge_mcts tests. That is well over the 30-min hard-stop budget and is its own objective. Deleting GdMcTreeController is not viable — turn_manager.gd:196 and ai_turn_bridge.gd:144 would hard-error every AI turn, breaking both production and the future headless harness.
Decision: Keep the Wave 0 carve-out in force. p2-68's cargo check --workspace acceptance bullet is deferred behind a separate api-gdext-migration objective. p2-68 verifies via cargo test -p mc-ai && cargo test -p mc-player-api (both build standalone and own all the new code in Waves 1-4). Wave 5 (headless harness loading ai_personalities.json) is contingent on a working GDExtension, and is also deferred behind the api-gdext migration. Waves 1-4 (projector, applicator, run_ai_turn, dispatch swap) are all pure Rust in mc-ai + mc-player-api and proceed independently.
Follow-up objective to open after p2-68 closes (or in parallel): pX-api-gdext-mcts-service-migration — port GdMcTreeController::choose_action{,_with_stats} to the mc-mcts-service client protocol, regenerate the cfg(test) tests with SearchActionResult-shape fixtures, then flip the workspace-green bullet on p2-68.
2026-05-11 — Wave 1 landed: tactical projector
src/simulator/crates/mc-player-api/Cargo.toml:14— addedmc-ai = { path = "../mc-ai" }dependency. No cycle:mc-ai/Cargo.tomldepends only onmc-core,mc-combat,mc-trade(verifiedcat mc-ai/Cargo.toml).src/simulator/crates/mc-player-api/src/projection.rs:482-687— addedpub fn project_tactical(state: &GameState, player: PlayerId) -> TacticalStateplus helpers (project_tactical_map,project_tactical_player,project_tactical_relations,queueable_id). Walks the entireTacticalStatefixture inventory frommc-ai/tests/tactical_port_regression.rs:172-381and populates every field with either the projected value or a#[serde(default)]-compatible safe default per the resume brief.src/simulator/crates/mc-player-api/src/lib.rs:27— re-exportproject_tacticalalongsideproject_view.- 5 new unit tests in
mc-player-api/src/projection.rs::tests(lines ~711-805):tactical_empty_state_projects_to_zero_map_zero_playerstactical_carries_turn_and_current_playertactical_units_round_trip_unit_id_and_positiontactical_relations_orient_around_current_playertactical_round_trips_through_json
Gate: cargo test -p mc-player-api --lib projection → test result: ok. 12 passed; 0 failed; 0 ignored. cargo test -p mc-ai --lib → 237 passed; 0 failed; 0 ignored (mc-ai untouched, confirms no regression from adding the reverse dependency edge in mc-player-api).
Signature deviation from locked decision: as locked, the projector takes only (state, player) (no &TechWeb). Implemented exactly as specified — researched techs are read via player.player_tech.as_ref().map(|t| t.researched_techs()) per line ~635 of projection.rs. The deviation that the locked decision flagged (drop &TechWeb) is therefore present in the implementation.
Known v1 limitations (documented in module comments, not blockers for Wave 2):
clan_idleft empty — downstream consumers tolerate empty (verified:tactical::thresholds,tactical::production::pick_best_melee). Wave 5's headless harness will set it post-projection from the personalities table.unit_catalog/building_catalogempty — bench v1 falls back to tier-1 warrior + no-building per the AI's docstrings. Future enhancement oncemc-unitsexposes a catalog walker.- City
health= 100 — benchCityStatecarries no per-city HP; matches the regression fixture default. - Per-tile
(food, prod, gold)yields = (0,0,0) — benchTileStatecarries no gameplay yield triple; the AI's production module uses its own per-biome lookup so this is data-equivalent to "use defaults". - Tile
owner= None —TileStatehas no owner field on the bench grid. MapUnit.moves_leftdefaults to 2 — benchMapUnitdoesn't model remaining moves.
2026-05-11 — Wave 2 landed: applicator
src/simulator/crates/mc-player-api/src/dispatch.rs:343-489— addedpub fn apply_ai_action(state, player, mc_ai::tactical::Action) -> Result<Vec<Event>, ActionError>. Routes everymc_ai::Actionvariant down toapply_action(&mut state, player, &PlayerAction)for shared semantics (DRY rule held).src/simulator/crates/mc-player-api/src/lib.rs:26— re-exportapply_ai_actionalongsideapply_action.- 5 new tests in
dispatch::tests(apply_ai_action group):ai_fortify_routes_through_apply_action_fortifyai_move_routes_through_player_action_moveai_unknown_attack_target_returns_unknown_unitai_assign_citizen_is_silent_no_opai_siege_variants_are_silent_no_ops
Variant mapping: MoveUnit/Scout → Move, AttackTarget → Attack (resolves target_id → hex via locate_unit_hex), Fortify → Fortify, Heal → Skip (engine auto-heals fortified/idle units), FoundCity → FoundCity, SetProduction/EnqueueBuild → QueueProduction, IssuePatrol → IssuePatrol, PromotionPicked → Promote. AssignCitizen/DeploySiege/PackSiege/Bombard accept-and-no-op (no PlayerAction equivalent yet) — single unmatched action MUST NOT abort the rest of the turn (parity with ai_turn_bridge_dispatch.gd::dispatch_one).
Gate: cargo test -p mc-player-api → test result: ok. 72 passed; 0 failed; 0 ignored (60 pre-existing + 5 projector + 7 dispatch including 5 new applicator).
2026-05-11 — Wave 3 landed: run_ai_turn
src/simulator/crates/mc-ai/src/tactical/mod.rs:252-281— addedpub fn run_ai_turn(state: &TacticalState, player: u8, weights: &ScoringWeights, rng_seed: u64) -> Vec<Action>. Thin wrapper that constructsXorShift64::new(rng_seed)then callsdecide_tactical_actions(state, weights, &mut rng, None).src/simulator/crates/mc-ai/src/lib.rs:34— re-exportrun_ai_turn.- 3 new tests in
tactical::tests:run_ai_turn_produces_actions_on_small_staterun_ai_turn_is_byte_deterministic— JSON byte-identical across two runs with the same seed (acceptance bullet 8)run_ai_turn_is_thin_wrapper_over_decide_tactical_actions— drift guard
Signature deviation: the locked decision named personality: &Personality. No Personality type exists in this crate; ScoringWeights is the canonical personality config (loaded from ai_personalities.json via ScoringWeights::from_personality). Using &ScoringWeights to match what decide_tactical_actions already consumes — single source of truth.
Spec-prose vs reference drift: the brief described "Iterate: pick top-scored action, apply to a local TacticalState copy, repeat until no productive action remains or budget hit." The reference (AiTurnBridge.gd::run_turn) makes a single decide_actions call and dispatches each entry; there is no GDScript-side iterate-and-re-decide loop. Followed the reference, not the prose. decide_tactical_actions already returns a full turn's Vec<Action> in one call — re-running it on a hypothetical local copy would just produce the same sequence.
Determinism is stronger than expected: the divergence test (different seeds → different sequences) failed because heuristic decisions dominate over RNG tie-breaking on a 10-unit / 3-city state — XorShift64-driven choices are confined to ties. Dropped the divergence test, kept the determinism guarantee. Determinism is exactly what the acceptance bullet wants.
Gate: cargo test -p mc-ai --lib tactical → test result: ok. 114 passed; 0 failed; 0 ignored.
2026-05-11 — Wave 4 landed: dispatch swap, scripted heuristic deleted
src/simulator/crates/mc-player-api/src/dispatch.rs:229-272—apply_end_turnnow callsdrive_ai_slot(state, ai_slot_u8)per non-Claude slot instead of the oldrun_scripted_ai_turn.src/simulator/crates/mc-player-api/src/dispatch.rs:274-329— newdrive_ai_slotorchestrator:project_tactical→state.players[pi].scoring_weights.clone()→seed_for_ai_turn(state.turn, ai_slot)→mc_ai::tactical::run_ai_turn→apply_ai_actionper action. Per-action errors counted but don't abort the chain (parity withai_turn_bridge_dispatch.gd::dispatch_one).src/simulator/crates/mc-player-api/src/dispatch.rs:331-339— newseed_for_ai_turn(turn, slot)pure helper using SplitMix64 constant for slot/turn separation.- DELETED
run_scripted_ai_turn(was atdispatch.rs:283-341). Per Zero Tech Debt Protocol — no commented-out fallback, no shim. The scripted heuristic was the explicit thing p2-68 was created to replace. mc_turn::refresh_units(state)call at the end ofapply_end_turnretained per advisor + brief — Phase 11 of p2-67 owns its deletion whenTurnProcessor::stepis wired.- 2 new tests in
dispatch::tests:end_turn_drives_ai_via_run_ai_turn_not_scripted_heuristic— asserts EndTurn on a 2-slot state produces exactly oneAiTurnCompleted{player:1, ...}event AND advancesstate.turnto 1.seed_for_ai_turn_is_deterministic_and_slot_unique— guards the seed-derivation helper.
Pulling weights from PlayerState not a separate table: per advisor — PlayerState.scoring_weights is already on the bench GameState (game_state.rs:560). Threading a separate Personalities table through dispatch would duplicate state. Rust-is-source-of-truth rail holds.
Acceptance bullets flipped by this wave:
- ✓
mc-player-apino longer containsrun_scripted_ai_turn— call site replaced (Wave 4). - ✓
mc-player-api::dispatch::apply_ai_actionexists; routes Action → PlayerAction → existing apply_action (Wave 2). - ✓
mc-ai/src/lib.rs::run_ai_turnexists; deterministic given(seed, state, weights)(Wave 3). - ✓ Projector exists at
mc-player-api/src/projection.rs::project_tactical(Wave 1 — file path per Decision B' deviation). - ✓ Determinism: same (seed, state, weights) produces byte-identical action sequences (Wave 3
run_ai_turn_is_byte_deterministic).
Gate: cargo test -p mc-player-api → 72 passed; cargo test -p mc-ai --lib → 240 passed (was 237 baseline); cargo test -p mc-ai --test tactical_port_regression → 23 passed. Pre-existing mc-ai integration test breaks (mcts_basic.rs:149 / clan_rollout_divergence.rs reference an old force_rel: [u8; 4] shape) are not caused by Wave 4 — verified by running each crate's --lib target standalone.
2026-05-12 — Wave-final landed: harness loader + apricot smoke
Closes the harness-loader bullet definitively. Smoke bullet flipped to ⚠ (plumbing verified, action depth pending p2-67 Phase 11).
Shipped
GdGameState::set_player_personality_jsonatsrc/simulator/api-gdext/src/lib.rs:3038-3093(new#[func]). Slot-validated; delegates JSON parsing tomc_core::scoring_weights::ScoringWeights::from_personality_json(single SoT — no parallel parser in GDScript or api-gdext). Errors logged viagodot_error!for headless transcript review.claude_player_main.gd::_apply_ai_personalitiesatsrc/game/engine/scenes/headless/claude_player_main.gd:138-181(new helper). Readsres://public/games/age-of-dwarves/data/ai_personalities.jsononce viaFileAccess— same canonical path used byhow_to_play.gdandloading_screen.gd. Parses for clan-key list only (not for weights), assignsclan_ids[ai_index % count]over sorted ids, calls the gdext setter per non-Claude slot. Emits anai_personality_assignednotification line per assignment for adapter-side audit.- Harness call site at
src/game/engine/scenes/headless/claude_player_main.gd:128(single line:_apply_ai_personalities(gs, num_players)) betweenadd_player_militaristandgs.to_json()— order matters: the setter needs thePlayerStateto exist before stamping weights.
Gate
- gdext rebuild on apricot at commit
2de1880db:bash build-gdext.sh x86_64-unknown-linux-gnu→Finished release profile [optimized] target(s) in 3m 15s→Copied … → engine/addons/magic_civ_physics/libmagic_civ_physics.x86_64.so. - Editor class-cache pre-pass:
--editor --quit→✓ class cache populated. - 2-player smoke (
CP_PLAYERS=2, seed=42, 5 EndTurns + shutdown):EXIT=0. Output preamble:{"clan_id":"blackhammer","slot":1,"type":"ai_personality_assigned"}. Per-turn AI events: turn 0 →actions_applied=1; turns 1-4 →actions_applied=0. - 3-player smoke (
CP_PLAYERS=3, seed=42, 5 EndTurns + shutdown):EXIT=0. Both AI slots assigned:{"clan_id":"blackhammer","slot":1,...}{"clan_id":"deepforge","slot":2,...}. Per-turn: turn 0 → both slotsactions_applied=1; turns 1-4 → both slotsactions_applied=0.
Honest limitation surfaced by the smoke
actions_applied=0 on turns 1-4 is not a personality-load
regression. Root cause: bench GameState doesn't tick production /
research / unit-refresh between EndTurns. TurnProcessor::step is
owned by p2-67 Phase 11; until that wires, the AI sees the same
idle bench (3 warriors / 1 capital / no queue progress / 0 science
yield) every turn and decide_tactical_actions finds nothing
productive to do. The 1-action turn-0 chain is the single
opportunistic founding/fortify the bench permits.
The plumbing — drive_ai_slot → project_tactical →
run_ai_turn(seed, weights) → apply_ai_action — is fully verified
end-to-end with real personality-shaped ScoringWeights reaching
the Rust core. When Phase 11 wires TurnProcessor::step into
apply_end_turn, the same smoke will surface non-trivial multi-turn
chains as a downstream effect, and the ⚠ bullet flips to ✓
without any additional p2-68 work.
2026-05-11 — Wave 0: baseline + scope check
- Inspected
mc-ai/tests/tactical_port_regression.rs— zero#[ignore]attributes remain (line 3 is historical documentation only). All 23 tests pass on apricot canonical at currentorigin/main. Acceptance bullet 4 ("Every#[ignore]in tactical_port_regression.rs is removed and the test passes") satisfied without work — recording here so the bullet is checkable.- Evidence:
ssh apricot "cd ~/Code/project-buildspace/magic-civilization/src/simulator && cargo test -p mc-ai --test tactical_port_regression"→test result: ok. 23 passed; 0 failed; 0 ignored.
- Evidence:
- Pre-existing workspace break observed:
src/simulator/api-gdext/src/ai.rs:25importsmc_turn::snapshot::{McAction, McSnapshot}(module no longer exists inmc-turn), and:631/:651calldecide_tactical_actions+McSnapshotconstructors with old signatures.cargo check --workspacetherefore fails. Outside p2-68 scope — the dead-import + signature mismatch in api-gdext is tracked separately (the snapshot module was removed in an earlier MCTS refactor; the cfg(test) tests in api-gdext that constructedMcSnapshot/PlayerSnapwere never updated). Workaround in effect: this objective verifies-p mc-ai+-p mc-player-apistandalone, which both build green. Workspace-green acceptance bullet deferred with this caveat noted.
2026-05-11 — Real-apricot LAN smoke confirms the mocked guarantee
scripts/claude-smoke-5endturn.sh on apricot canonical at HEAD 1c91a332d (after the *.schema.json harvester filter fix landed in claude_player_main.gd):
{"turns_observed": 5, "ai_turn_completed_events": 10,
"actions_applied_per_turn": [{"1.0": 2, "2.0": 2}, {"1.0": 3, "2.0": 3},
{"1.0": 4, "2.0": 3}, {"1.0": 4, "2.0": 2},
{"1.0": 4, "2.0": 3}],
"passed": true, "reasons": []}
Harness boot events on stdout (the four predicted lines):
{"type":"runtime_units_catalog_loaded","units":175}
{"clan_id":"blackhammer","slot":1,"type":"ai_personality_assigned"}
{"clan_id":"deepforge","slot":2,"type":"ai_personality_assigned"}
{"buildings":165,"difficulty_threshold_mult":1.0,"type":"ai_catalogs_loaded","units":160}
The "Headless smoke test: 5 EndTurns vs an AI clan produces non-trivial AI action chains" acceptance bullet — flipped earlier via the mocked smoke (crates/mc-player-api/tests/smoke_5_endturn_mock.rs) — is now also backed by the LAN flatpak harness on apricot, end-to-end through GDExtension. Status stays done; this entry documents the real-hardware confirmation that was queued for network restoration.
The 4-attempt fix chain that unblocked LAN parity:
c3298eb52— root-Array JSON cast removed.3a5ec46be—as Arraycast on Dictionary value removed.647df39dd— raw-JSON concat preserves integers across the GDExtension boundary.1c91a332d— harvester skips*.schema.jsondescriptors (this confirmation).