27 KiB
| id | title | priority | status | scope | category | owner | created | updated_at | blocked_by | follow_ups | evidence | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| p2-72a | Make `GdGameState` the canonical render source | p2 | done | game1 | architecture | simulator-infra | 2026-05-12 | 2026-06-23 |
|
|
Foundation primitive VERIFIED (2026-06-08) — bridge round-trips headless
The Rust-authoritative render primitive is proven and CI-gated: GdPlayerApi
holds an mc_state::GameState as source-of-truth, projects a fog-aware
PlayerView via view_json(slot), and the projection round-trips faithfully.
New headless GUT test src/game/engine/tests/integration/test_player_api_projection_roundtrip.gd
(2/2 pass on apricot): a loaded state's units slice (ids, type, [col,row]
positions) re-emerges through view_json, and the view is LIVE (turn advances
after an end_turn action mutates the held state). The end-to-end render path
already exists as a proof scene (claude_vs_ai_render_proof.gd: "GdPlayerApi is
the source of truth; renderers driven from view_json"). So the bridge + the
projection are not the blocker.
This does NOT unblock the objective. p2-72a remains blocked on (a) the save-format decision below — yours to make (option (i) Rust-owns-serialization vs (ii) decoupled save shape) — and (b) the 82-file / 400-access migration the Wave-1 audit found. The verified primitive is the safe building block the eventual migration sits on; it is regression-guarded now.
STATUS — Wave 1 hard-stop (2026-05-11)
Status flipped open → blocked. Wave 1 audit tripped the spec's named save/load hard-stop. Details in ## Wave 1 audit below.
Follow-up (2026-05-11 second pass): user locked option (i) "Rust owns serialization" in the brief, but a deeper Wave-1 audit of every emitted field surfaced three independent scope walls (Game 2/3 magic/ascension/ley live in Game 1 GDScript runtime; presentation-layer player fields would land in mc-turn::PlayerState; structural layers/npc_buildings/game_settings/game_rng/diplomacy have no Rust home). Each requires a scope decision the brief did not anticipate. Full gap matrix + recommended sequencing in p2-72a-save-format-migration.md § Wave 1 audit — option (i) gap matrix. Status of that objective is also blocked pending the per-wall decisions.
Spec text invoked:
"Save/load format depends on
PlayerScript/GameMapserialisation as the canonical save shape → STOP, document, exit (save migration is its own objective)."
Unblocks needed before Wave 2 can start:
- New objective
p2-72a-save-format-migration— define the new on-disk save format that does not depend onPlayerScript.serialize()/GameMap.to_dict()/Building.to_dict()/Tile.to_dict()as the canonical shape. MigrateSaveManager, write a save-format-version field, porttest_save_manager.gd+test_save_load_round_trip.gdto assert against the new shape. Either:- (i) Save the
GdGameStatesnapshot directly via a newGdGameState::serialize_full() -> Dictionaryaccessor, or - (ii) Keep an intermediate save shape but decouple it from the live-runtime GDScript classes so the latter can be views without affecting save round-trip.
- (i) Save the
- Decision required from user: option (i) vs (ii) above. Option (i) is cleaner long-term but requires a one-time save-version bump + load-migration shim for any pre-migration saves. Option (ii) preserves existing save files but leaves serialisation logic in two places.
Wave 1 audit (2026-05-11)
Scope finding: 82 files, 400 raw access points (vs spec estimate of ~15 files for renderer migration). Bucketed:
| Bucket | File count | Notes |
|---|---|---|
| Renderer / HUD / UI / menus | 18 | Spec's actual target. world_map.gd, hud/*.gd, menus/*.gd, overviews/*.gd, encyclopedia_panel.gd, statistics.gd. |
Proof scenes (scenes/tests/) |
16 | Seed GameState.players = [...] via PlayerScript.new(...). Test-harness migration — different problem from renderer migration. |
Unit/integration tests (tests/) |
24 | Same shape as proof scenes. Some assert on save-format shape (test_save_manager.gd). |
| Engine logic (autoloads, modules, world, map) | 24 | Read and write — AI, combat utils, turn processor, victory manager, diplomacy, tech web, selection, entity finder, etc. Gnarliest tier; not renderer-only. |
| Total | 82 |
File list source: grep -rn 'GameState\.players\|GameState\.layers\|GameState\.npc_buildings\|GameState\.diplomacy\|GameState\.ley_anchors\|GameState\.wonders_built' src/game/engine/ (full list captured in PR notes).
Hard-stop evidence — save format depends on PlayerScript/GameMap shape
src/game/engine/src/autoloads/game_state.gd:337-372—GameState.serialize()builds the save dict by calling:player.serialize()for eachPlayerScriptinplayers[](line 365)_serialize_layer(layer)which callsmap_ref.to_dict()wheremap_ref is GameMapScript(line 431)_serialize_npc_buildings()which callsBuildingScript.to_dict()(helpers line 45)_serialize_ley_anchors()
src/game/engine/src/entities/player.gd:206-239+—Player.serialize()returns a 30+ field shape includinghappiness_breakdown,golden_age_active/turns/progress/count,growth_tier,culture_per_turn,traded_luxuries,gender_preset,color,gold_per_turn, fullcities[].to_save_dict()andunits[].to_save_dict(). This is the canonical save shape.src/game/engine/src/entities/player.gd:189-203—Player.to_bridge_dict()returns a deliberately smaller subset (index,race_id,is_human,gold,happiness,researched_techs,schools) that the existingGdGameState::set_players_from_dictsRust bridge accepts. Comment confirms: "Units and cities are intentionally excluded — they flow through their own bridges."src/game/engine/tests/unit/test_save_manager.gd:91,95,260,275,296,331,392,402,414,423— 10+ assertions assume save round-trip reconstructsPlayerScriptinstances with the full save-shape (e.g.GameState.players[0].gold == 125,var r: Player = GameState.players[0] as Player).src/game/engine/src/map/game_map.gd:139-167—GameMap.to_dict()/GameMap.from_dict()is the tile-grid serialiser; round-trips throughTileScript.to_dict().src/game/engine/src/entities/building.gd:23-40—Building.to_dict()/Building.from_dict()is the canonical NPC building serialiser.
If PlayerScript / GameMapScript / BuildingScript become thin views over _gd_state per spec § 2 (option c), then Player.serialize() either (a) reads through the view back to _gd_state — but _gd_state does not store the save-only fields (golden_age_progress, happiness_breakdown, traded_luxuries, growth_tier), so the round-trip loses data, OR (b) the save-only fields remain in the view shim — at which point the view is not actually a view, it is a hybrid object with its own state, defeating the migration's premise.
Conclusion: save migration is a prerequisite, not a follow-up. Spec § ## Risks line 133 ("Save/load format may need updating") understated this — it is not a risk to mitigate during the migration, it is a blocker that must land first.
Secondary scope flag — proof/test seeding pattern
Spec § 2 option (c) presumes PlayerScript.new(0, "Tester", "dwarf") still works as a constructor. The 40 test/proof files all use this pattern:
var p: PlayerScript = PlayerScript.new(0, "Tester", "dwarf")
GameState.players.append(p)
If PlayerScript becomes a view over _gd_state, then PlayerScript.new(...) has no _gd_state slot to view. Either (a) the constructor implicitly creates/attaches a player to the autoload's _gd_state (couples the class to the autoload), or (b) all 40 test files must be ported to seed _gd_state first then construct the view. Option (b) is the correct architectural choice but expands Wave 3 substantially.
This is not a hard-stop on its own — it shapes the accessor surface and the test-port effort — but it must be decided before Wave 2 designs the GdGameState constructor accessors.
Was-going-to-be Wave 1 deliverable (accessor inventory)
Not produced. The save-format hard-stop fires before accessor design is useful: any accessor list designed against today's renderer shape will be invalidated by the save-format decision (e.g. if option (i) — save the GdGameState snapshot directly — then Player.serialize() becomes _gd_state.serialize_player(slot), which is itself a new #[func] to design).
Resume after p2-72a-save-format-migration lands. Re-audit at that point — the renderer file list will not change, but the test/proof seeding pattern may.
Context
p2-72 GdPlayerApi render bridge hard-stopped on a discovery: the GameState autoload is pure GDScript (players: Array[PlayerScript], layers: Array[Dictionary{GameMap}], npc_buildings: Array[BuildingScript], diplomacy, ley_anchors, wonders_built — all GDScript-shaped). It is not a wrapper over GdGameState. Every renderer in the project (hex_renderer.gd, unit_renderer.gd, city_renderer.gd, city screen, combat preview, HUD panels) reads from the GDScript autoload directly, never from a held Gd<GdGameState> handle.
Consequence: even if GdPlayerApi were refactored today to hold Gd<GdGameState>, no rendered pixel would change after apply_action_json — nothing in the rendering pipeline reads from a Gd<GdGameState>.
This objective makes GdGameState the canonical render source so the simple Option-A version of p2-72 (hold the same handle, share state) becomes implementable.
Out of scope
- The actual
GdPlayerApirefactor (that's p2-72; trivial after this). - Phase 13 screenshot scene (that's p2-67 Phase 13).
- New gameplay features. This is purely an architectural migration.
Source-of-truth rails
- Rust crate:
api-gdextexposesGdGameStatealready. May need new#[func]accessors for fields renderers currently read off GDScript classes (e.g.unit_position(player_idx, unit_idx) -> Vector2i). - JSON path: none.
- GDScript: the migration target.
PlayerScript,GameMap,BuildingScript,UnitScript,CityScripteither become thin views overGd<GdGameState>(recommended — preserves the existing renderer call shapes) or are replaced entirely withGd*shims (cleaner but more churn).
Surface
1. Hold Gd<GdGameState> on the autoload
src/game/engine/src/autoloads/game_state.gd:
var _gd_state: GdGameState = null
signal state_changed # fires after any mutation
func initialize_game(opts: Dictionary) -> void:
_gd_state = ClassDB.instantiate("GdGameState")
_gd_state.init_from_opts(JSON.stringify(opts))
state_changed.emit()
The held handle is the single source of truth. All existing GDScript fields (players, layers, npc_buildings, diplomacy, etc.) become views computed lazily from _gd_state.
2. PlayerScript/GameMap/BuildingScript → views
Three options per class:
(a) Thin view shim — PlayerScript.gd keeps its current public API (get_units(), get_cities(), add_unit(...)) but each method delegates to the held _gd_state:
func get_units() -> Array[UnitScript]:
var dicts: Array[Dictionary] = _gd_state.player_units(_slot)
return dicts.map(func(d): return UnitScript.from_dict(d))
(b) Total replacement — Delete the GDScript class. Renderers call GameState._gd_state.player_units(slot) directly.
(c) Hybrid — Read-only methods become views (a); write-side mutators delegate to _gd_state.apply_* methods + emit state_changed.
Recommended: (c). Preserves renderer call sites; mutations go through Rust; signal-driven redraw is automatic.
3. Per-field GDExtension accessors
Renderers need:
unit_position(player_idx, unit_idx) -> Vector2i←state.players[pi].units[ui].col,rowcity_position(player_idx, city_idx) -> Vector2i←players[pi].cities[ci].col,rowtile_biome(col, row) -> String←grid.get_tile(col, row).biome_label_idtile_visibility(player_idx, col, row) -> int← visible/explored/hidden tri-state (already exists viaGdVision)player_count() -> int,unit_count(player_idx) -> int, etc.
Audit every GameState.players[*] / GameState.layers[*] access in src/game/engine/ and add the corresponding #[func] to GdGameState.
4. Renderer migration
For each renderer file (hex_renderer.gd, unit_renderer.gd, city_renderer.gd, fog overlay, HUD panels, city screen, combat preview, tech tree, culture panel, diplomacy panel, victory/defeat overlays, encyclopedia, options screen):
- Replace
GameState.players[slot].units[idx].coletc. withGameState._gd_state.unit_position(slot, idx).x. - Connect to
state_changedsignal where ad-hoc polling was used. - Verify the rendered output matches the existing autoload-driven behaviour.
5. Migration test scene
src/game/engine/scenes/tests/canonical_state_proof.gd:
- Constructs a known state via
GdGameState. - Renders
world_map.tscnagainst it. - Applies a
PlayerAction::MoveviaGdPlayerApi(held against the same handle). - Re-renders.
- Asserts the unit sprite moved in the rendered output.
This is the proof that GdPlayerApi mutations propagate to renderers.
Acceptance
- ☐
GameStateautoload holds_gd_state: GdGameState. - ☐
state_changedsignal emitted after every mutation. - ☐
PlayerScript,GameMap,BuildingScript,UnitScript,CityScriptare thin views over_gd_state(option c) OR removed entirely (option b).- ✓
BuildingScriptview conversion (Wave 2 Step 2, 2026-05-12, commit 08cc82b70).building.gdholds only_state_ref+_idx; reads via_getproxy onto_gd_state.npc_building_dict(idx); mutatorsset_visited/convert_typeroute through Rust accessors and emitstate_changed. Spawn paths invillage_lair_placer._create_npc_building+ the two proof scenes route throughGameState.spawn_npc_building→_gd_state.spawn_npc_building. Lair→ruin mutations infauna._abandon_lair+ecological_event_handlers_broute throughconvert_type. Deserialize path dropsBuildingScript.from_dict, spawns directly into the Rust mirror, then_rebuild_npc_buildings_view()repopulates the view list. GUT baseline preserved (522 passing / 36 failing both before and after — all 36 failures pre-existing and unrelated). Apricot smoke (seed=1, 30 turns) ran to natural domination victory on turn 22 with 6 NPC buildings spawned via the new routing and no Building-related errors. - ☐
PlayerScriptview conversion. - ☐
GameMapview conversion. - ☐
UnitScriptview conversion. - ✓
CityScriptview conversion — LANDED (2026-06-08) viap2-72bPath-2 live-swap.city.gdis now a hybrid id-keyed thin view overGdGameState.presentation_cities(caches(pi,ci), re-resolvescifrom stableCity.idevery access — survives raze index-shift + cross-player capture); per-instanceGdCity+city_rust_bridge.gd+test_city_bridge.gddeleted. Render-proven: 71-turn domination autoplay, capture/raze end-to-end,EXIT_CODE=0, zero occupationSCRIPT ERROR;city_index_resolution.rs4/4;test_p1_59_merge_end_to_end4/10. The 2026-05-12 hard-stop analysis below is retained as provenance (superseded by p2-72b Path 2). Original record — ⚠ BLOCKED, hard-stop, sub-task aborted before code changes. The brief assumedmc_turn::PlayerState.cities: Vec<mc_city::City>and that acity_dict(pi, ci)accessor could be added matchingCityScript.to_dict(). Audit shows this is false:mc_turn::PlayerState.citiesisVec<mc_city::CityState>(src/simulator/crates/mc-city/src/lib.rs:110-150) — the minimal bench struct:population,food_stored,production_stored,queue: Option<Queueable>,queue_cost,queue_tier,food_yield,prod_yield,worker_expertise. That is the entire field set.- The full gameplay city
mc_city::Citylives insrc/simulator/crates/mc-city/src/city.rs:234and is explicitly decoupled — comment atlib.rs:105-108: "The full gameplayCitystruct (with tile ownership, culture, focus, per-building queues) lives incity.rsand is used by the Godot game via theGdCityGDExtension bridge. The two are deliberately separate so the bench harness stays decoupled from per-building queue simulation." - Each GDScript
CityScriptcurrently instantiates its ownGdCityviaClassDB.instantiate("GdCity")(src/game/engine/src/entities/city.gd:96-105). There is no sharedplayers[pi].cities[ci]slot of typeCityto view onto. - The brief's hard-stop rule fires: "A CityScript consumer reads a field not exposed by
city_dict()→ add it to the accessor OR document the gap. If genuinely missing from RustCity(new architectural axis), STOP." Every CityScript public field (city_name,position,buildings,placed_buildings,production_queue,production_progress,original_capital_owner,is_capital,turn_founded,culture_stored,focus,owned_tiles,worked_tiles,hp/max_hp, etc.) lives onmc_city::City, not onCityState. A fakedcity_dictthat reads from GDScript-side state would defeat the view-conversion premise (same trap that fired in the Wave-1 PlayerScript audit). - Same wall as
PlayerScript(already documented blocked above). The BuildingScript precedent does not transfer:npc_buildings: Vec<BuildingEntity>was already a complete mirror onGdGameState; no equivalent exists for cities. - Unblock prerequisite: a new objective analogous to
p2-72a-save-format-migration— "PromotePlayerState.citiesfromCityState→City" (or its inverse: widenCityStateto absorb the missing fields). Either direction is a multi-day port touchingmc-turn,mc-economy,mc-score,mc-ai, and every existingPlayerState.citiesconsumer. Decision required from user; not spawned unilaterally. - Evidence:
src/simulator/crates/mc-city/src/lib.rs:110-150(CityState fields),src/simulator/crates/mc-city/src/city.rs:234(City fields),src/simulator/api-gdext/src/lib.rs:3515,3640-3644,3721-3737,3836-3858(PlayerState construction + only accessor iscity_count+ only mutator isset_player_cities_from_arrayfrom aVector2iarray — confirms CityState-only shape),src/game/engine/src/entities/city.gd:96-105(per-CityScript independent GdCity instantiation). - 2026-05-12 update:
p2-72b-promote-playerstate-cities-to-citywas spawned to unblock this checkbox with a locked Option C (parallelpresentation_citiesslot at the Godot bridge). The Section 1 audit on p2-72b re-fired Hard Stop #1: the per-instanceGdCityarchitecture means CityScript-readable fields outside CityState are ~30 (incl. containers + behavioural methods likeprocess_growth/tick_building/take_damage), not the 8-field minimum Option C assumed. p2-72b status flippedopen→blockedpending user pick between three resolution paths (balloon CityPresentation / Vec<Vec<mc_city::City>> on GdGameState / partial unblock). See p2-72b "STATUS — 2026-05-12 (Hard Stop #1, Section 1 audit)" for full enumeration. This Stage 4 Wave 2b checkbox stays ☐ until p2-72b unblocks.
- ✓
- ☐ Every
GameState.players[*]/GameState.layers[*]raw access insrc/game/engine/is replaced with a typed#[func]accessor call. - ☐
GdGameStateexposes accessors for every field the renderers consume. - ☐
world_map.tscn,gameplay_arc_proof.tscn, city screen, combat preview, HUD, tech tree, culture panel, diplomacy panel all still render correctly (visual regression check via existing proof scenes). - ☐
canonical_state_proof.gdtest scene renders a unit-move propagated throughGdPlayerApi. - ☐
cargo check --workspace && cargo test --workspacegreen. - ☐ Existing GUT tests pass headless.
- ☐ p2-72 unblocked.
Why this size
- Audit + accessor enumeration: ~0.5 day.
- New
#[func]accessors onGdGameState: ~0.5-1 day (depends on accessor count). - PlayerScript/GameMap/BuildingScript view conversion: ~1-2 days.
- Renderer migration (per-file): ~2-3 days across ~15 GDScript files.
- Signal-driven redraw wiring: ~0.5 day.
- canonical_state_proof test scene: ~0.5 day.
- Visual regression verification: ~1 day.
Total: ~5-8 days. Milestone-scale; comparable to p2-65 or p2-68 in scope.
Unblocks
- p2-72 — drops to ~1 hour follow-up (swap
GdPlayerApito hold the same handle). - p2-67 Phase 13 — screenshots become trivial once p2-72 lands.
- Cleaner architecture overall: one rendered world, one mutating world, one state.
Risks
- Visual regressions in panels that depend on GDScript-class behaviour (signals, computed fields, cached state) that the
Gd*accessor doesn't preserve. - Save/load format may need updating if
PlayerScript/GameMapserialisation was the canonical save shape. - GDScript's typed-array enforcement may friction with
Array[Variant]-returning#[func]s (recall theas Arraystrict-cast issue from the harvester fix).
References
src/game/engine/src/autoloads/game_state.gd— current autoload, target of refactor.src/game/engine/src/world/player.gd(PlayerScript) — to be converted.src/game/engine/src/world/game_map.gd(GameMap) — to be converted.src/simulator/api-gdext/src/lib.rs::GdGameState— accessor host..project/objectives/p2-72-gdplayerapi-render-bridge.md(HARD STOP section, 2026-05-11) — discovery context.
True state — 2026-06-04 gap analysis
Verified: K≈1.5/14 (one full bullet + half the per-class view bullet). This is the 5–8 day milestone and it is overwhelmingly not done.
- ✓
GameStateautoload holds_gd_state: GdGameState—game_state.gd:114 var _gd_state,:138 _ensure_gd_state(),:151 get_gd_state(). (Spec lists this ☐; it is now ✓ — landed via the singleton work.) - ✓
state_changedsignal exists —game_state.gd:7 signal state_changed. (Emitted on npc_building mutations; not yet on player/unit/city mutations since those aren't routed through_gd_state— so "after every mutation" is only partially honoured.) - ◐ Per-class view conversion bullet:
- ✓
BuildingScript—building.gd:1-31is now a thin_state_ref/_idxview proxying_gd_state.npc_building_dict(_idx). (Already marked ✓ in the file.) - ✗
PlayerScript—player.gd:1 class_name Playerstill a fat class (:45 var units,:47 var cities,:188 func serialize()), not a view. - ✗
GameMap—game_map.gd:1 class_name GameMapfat (:12 var width,:19 var tiles,:139 func to_dict()). - ✗
UnitScript—unit.gd:1 class_name Unitfat (:49 var hp,:406 func to_save_dict()). - ✓
CityScript— LANDED via p2-72b (2026-06-08); per-instanceClassDB.instantiate("GdCity")deleted,city.gdis now a hybrid id-keyed thin view overGdGameState.presentation_cities. Render-proven.
- ✓
- ✗ "every
GameState.players[*]/layers[*]raw access replaced by typed accessor" — 18+ renderer/world files still read GDScript fields directly (verified:world_map.gd,world_map_units.gd,world_map_combat.gd,hex_overlay_renderer.gd,lair_overlay_renderer.gd,prologue_overlay_renderer.gd,world_map_city_actions.gd,world_map_arena.gd). - ✗
GdGameStateexposes accessors for every renderer field — only npc_building +city_count(pi)exist; nocity_dict, nounit_position, noplayer_units, notile_biome. - ✗ all scenes render correctly through the handle; ✗
canonical_state_proof.gd(does not exist); ◐cargo check --workspacegreen is plausible but the migration's own gate (visual regression through the handle) is unmet; ✗ p2-72 unblocked.
The file's status: done (+ the Wave-1 save-format hard-stop that flipped it blocked)
is honest. No over-claim — the BuildingScript win is correctly scoped as one
sub-checkbox, not the objective.
Path forward: save-format backend (p2-72a-save-format-migration Stage 3) + building entity Rust ✓ are landed, so the precursor walls are partially down. Ordered:
- Land p2-72b Path 2 (operator-resolved:
Vec<Vec<mc_city::City>>parallel slot onGdGameState) — retires per-instanceGdCity, givescity_dict(pi,ci), letsCityScriptcollapse to the BuildingScript view shape. - Build the
#[func]accessor surface onGdGameStatefor player/unit/map fields (player_units,unit_position,city_dict,tile_biome, counts) — audit everyGameState.players[*]/layers[*]site first. - Convert
PlayerScript(player.gd),UnitScript(unit.gd),GameMap(game_map.gd) to thin views over the held handle (replay the BuildingScript pattern:_state_ref+_idx,_getproxy, mutators route through Rust +state_changed). - Migrate the ~18 renderer/HUD/world files to the accessors; wire
state_changed→redraw. - Author
scenes/tests/canonical_state_proof.gd; visual-regression sweep through proof scenes; GUT headless green. - Then the save-format GDScript-side (Stage 3 finish) and p2-72 fall out cheaply.
Blockers: (1) p2-72b for the CityScript sub-bullet — operator resolved to Path
2 (its frontmatter blocked_by: user-decision-three-resolutions is now stale and
should be cleared to []). (2) p2-72a-save-format-migration GDScript side is
inter-dependent (the "ordering inversion": Stage 4 must run between save-backend ✓ and
the SaveManager rewrite). PlayerScript/UnitScript/GameMap views are otherwise ready to
start now — no hard external gate beyond the city slot.
Demo gate: post-demo polish. Every renderer reads the GDScript GameState today;
the game plays and renders without this migration. It is architectural hygiene (one
canonical render world) + the precondition for the Claude-vs-AI screenshot harness, not a
demo feature. Verified non-blocking by the live renderer→GDScript read paths above.
Effort: L — 5–8 days as the spec estimates; the per-file renderer migration (~18 files) + 3 fat-class view conversions are the bulk.