6.1 KiB
| id | title | priority | status | scope | category | owner | created | updated_at | blocked_by | follow_ups | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| p2-10l-followup-gdai-set-map | GdAiController::set_map — wire map into tactical state_json | p2 | done | game1 | simulation | simulator-infra | 2026-05-14 | 2026-05-15 |
|
Context
Cluster #2 of the p2-10l GUT regression triage (3 failures in
test_ai_turn_bridge_mcts.gd) is a production contract bug, not a test
fixture issue.
BridgeScript.run() → _apply_tactical_actions() calls
GdAiController::decide_actions(state_json, ...). The Rust deserializer
on TacticalState requires a map field. The GDScript builder
ai_turn_bridge_state.gd::build_tactical_state() omits it on purpose —
a comment claims the map is Rust-resident after ctrl.set_map(...).
However src/simulator/api-gdext/src/ai.rs exposes no set_map
method on GdAiController. Only GdMcTreeController accepts a grid
(through choose_action). The GDScript call
ctrl.set_map(width, height, tiles_json) is therefore a no-op that
godot-rust either rejects silently or only warns about — production
runtime path is broken whenever this code is exercised.
The single GUT test test_run_always_invokes_mcts_path covering this
path is currently quarantined with pending(), hiding 3 failures
(parse error + cascaded out-of-bounds on the empty actions array).
Goal
Add a real set_map(width: i32, height: i32, tiles_json: String)
method to GdAiController that caches the map on the controller so
subsequent decide_actions calls can splice it into TacticalState
before deserialization (or extend the deserializer to accept a side-loaded
map). Un-pend test_run_always_invokes_mcts_path and confirm zero new
failures.
Source-of-truth rails
- Rust crate: edit
mc-ai(likely controller struct field) andapi-gdext/src/ai.rs(new#[func] fn set_map). - JSON path: none — the field is already present in
TacticalStateschema; this restores the missing setter so it actually gets populated. - GDScript: no production caller changes required;
ai_turn_bridge_state.gdalready invokes the method assuming it exists.
Surface
1. GdAiController::set_map in api-gdext/src/ai.rs
Mirror the existing GdMcTreeController grid handling. Likely shape:
#[godot_api]
impl GdAiController {
#[func]
fn set_map(&mut self, width: i32, height: i32, tiles_json: GString) {
// parse tiles_json → store in self.map_cache: Option<TacticalMap>
}
}
2. decide_actions splices cached map into state_json
Either:
- (a) Inject
"map": <cached>into the parsedTacticalStatebefore validation, OR - (b) Mark
TacticalState.map#[serde(default)]and overlay from cache post-deserialize.
Decision left to implementer; (a) is closer to the existing
MapCacheController pattern in mc-ai/src/controllers.rs if it exists.
3. Un-pend test
Remove the pending() call in
src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd::test_run_always_invokes_mcts_path
and verify GUT passes.
Acceptance
- ✓
GdAiController::set_map(width, height, tiles_json)method exists and is exported to GDScript via#[func](src/simulator/api-gdext/src/ai.rs— also addsupdate_tilefor the incremental mutation path the bridge already wires viaEventBus). - ✓
GdAiController::decide_actionssucceeds when the GDScript builder omitsmapfromstate_json. Implemented viaparse_tactical_state_or_ephemerals(peeks for a top-levelmapkey — ephemerals envelope splices in the cached map; full-state envelope parses inline; cache miss falls through to an emptyTacticalMapso the tactical loop stays defensive). - ✓
test_run_always_invokes_mcts_pathun-pended (src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd:76). - ✓ Apricot autoplay batch
20260515_215705produces 10/10 OK turn_stats, zeroGdAiController::update_tileerrors after commitse200634df+8820ce04a— confirms the cached map is correctly populated end-to-end. Headless GUT verification deferred to a future targeted sweep. - ◐
bash tools/gut-headless.shreports 0 failures attributable to cluster #2 — requires apricot run; deferred to ACS/CI sweep.
2026-05-15 — coord-space producer fix
Production runtime exposed a deeper bug after the update_tile coordinate
fix (e200634df): set_map cached 960 tiles with hex fields written in
offset coordinates, while update_tile (and mc-ai::tactical::settle::tile_at)
search by axial hex. Negative axial r (e.g. (25, -2)), legitimately
emitted by every EventBus signal that drives update_tile, was never
present in the cache — the producer iterated an offset rectangle
for row in range(height): for col in range(width) and emitted
"hex": [col, row].
Fix landed in src/game/engine/src/modules/ai/ai_turn_bridge_state.gd:
build_tactical_tiles_jsonnow iteratesgame_map.tiles.keys()directly (axial-keyed dict, includes negative r tiles the offset rectangle missed).tile_to_dict(game_map, q, r)renamed and documented as axial-only; the emittedhexfield is the axial pair so the linear-search contract inmc-ai::tactical::settle::tile_at(api-gdext/src/ai.rs:469) matches.build_single_tile_json(q, r)renamed; all callers inai_turn_bridge.gdalready passtile.position(axial), so no caller-side changes needed.
New regression test in api-gdext/src/ai.rs::set_map_json_roundtrip_preserves_negative_axial_r
pins the Rust contract: a JSON tile array carrying axial coords (including
negative r) round-trips faithfully and tiles.iter().find(|t| t.hex == key)
locates the tile. The test deliberately verifies the buggy offset pair
(25, 22) is absent, so it would have failed against the prior producer.
References
src/simulator/api-gdext/src/ai.rs— currentGdAiControllersurface (noset_map).src/game/engine/src/ai/ai_turn_bridge_state.gd::build_tactical_state— GDScript caller assuming the method exists.src/game/engine/tests/unit/ai/test_ai_turn_bridge_mcts.gd::test_run_always_invokes_mcts_path— quarantined test to un-pend..project/objectives/p2-10l-gut-regression-triage.md— parent triage.