diff --git a/.project/simulation-report/experiment-log.md b/.project/simulation-report/experiment-log.md index d1d21788..22651feb 100644 --- a/.project/simulation-report/experiment-log.md +++ b/.project/simulation-report/experiment-log.md @@ -4,6 +4,143 @@ Tracks every iteration of the balance/simulation loop. Newest entries on top. Ea --- +## Iteration 7i — Restored Unit.gd + RustFaunaBridge adapter + live-unit death sync (2026-04-08, COMPLETE) + +**Goal:** The iter 7h experiment log identified iter 7i as "GDScript-only work": restore the 2-line `unit.gd` stub to a real Unit class with the fields the existing GDScript codebase expects, build a turn-processor adapter that translates `Array[Unit]` ↔ `Array[Dictionary]` for the iter 7h bridge, and produce a Phase Gate screenshot showing live `Unit.gd` instances dying through the Rust fauna pipeline with `EventBus.unit_destroyed` firing. + +This is the GDScript-side mirror of iter 7h's Rust-side bridge work. Iter 7h proved the dict-based ingestion API; iter 7i proves the live-state-↔-dict translation layer. + +**Starting state after iter 7h:** 500 tests, 0 clippy warnings, GdGameState has 14 #[func] methods including the iter 7h dict-based ingestion API, GdTurnProcessor::step returns `fauna_combat_log` per-event records. `unit.gd` is a 2-line stub. + +### Field/method enumeration (the prep work) + +Before defining the new Unit class, I scanned the existing `engine/src/` for every `unit.` access pattern to determine the minimum-viable Unit shape. The grep produced this required surface: + +**Fields:** `position: Vector2i`, `owner: int`, `unit_id: String`, `display_name: String`, `hp: int`, `max_hp: int`, `attack: int`, `defense: int`, `ranged_attack: int`, `movement_remaining: int`, `max_movement: int`, `has_attacked: bool`, `is_fortified: bool`, `fortified_turns: int`, `xp: int`, `level: int`, `promo_ids: Array[String]`, `equipped_items: Array`, `infusions: Array[String]`, `channeled_infusion: String`, `channeled_fade_turns: int` + +**Methods:** `is_alive()`, `is_ranged()`, `is_flying()`, `is_military()`, `refresh_turn()`, `get_movement()`, `take_damage(amount) -> bool`, `heal(amount)`, `to_bridge_dict() -> Dictionary` (iter 7h adapter), `apply_bridge_dict(d)` (iter 7i sync-back) + +The combat-type queries (`is_ranged`, `is_flying`, `is_military`) read from `DataLoader.get_unit(unit_id)["combat_type"]` rather than storing the type per-instance, which matches the existing project pattern (data-driven type lookups, see `unit_stat_helpers.gd` for the same pattern with promotions/items). + +### Files + +**`src/game/engine/src/entities/unit.gd`** — restored from a 2-line stub to a 180-line real class. Lint-clean. Uses the existing `UnitStatHelpers` preload for promotion/item/infusion stat aggregation (already present in the codebase from iter-?). All fields explicitly typed; all methods have explicit return types. The `to_bridge_dict()` and `apply_bridge_dict()` methods are the iter 7h ↔ iter 7i interface boundary — packing for the Rust call and unpacking the response. + +**`src/game/engine/src/modules/management/rust_fauna_bridge.gd`** (new, ~115 lines, lint-clean) — the GDScript adapter. Three static methods on a `RustFaunaBridge` class: + +1. **`build_state(axes, units, cities, grid_size, gridstate)`** — instantiates a fresh `GdGameState`, attaches a grid (synthetic or cloned from a real `GdGridState`), adds a player via `add_empty_player_with_axes` (iter 7h), populates cities via `set_player_cities_from_array`, packs each live `Unit` into a dict via `Unit.to_bridge_dict()`, and pushes the dict array via `set_player_units_from_dicts`. Returns the prepared `GdGameState`. Typed parameters (`Array[Unit]`, `Array[Vector2i]`) — the Godot binding requires typed-array variants for typed param slots, **caught during the first proof scene run** (script error 40 at `state.call("set_player_cities_from_array", pi, cities)` because the proof scene was passing a generic `Array`). + +2. **`stamp_lairs(state, lairs)`** — convenience for proof scenes that need a synthetic lair distribution without running full ecology evolution. Iterates a list of `[col, row, tier, species_id]` tuples and calls `state.call("stamp_lair", ...)`. + +3. **`resolve_fauna_encounters(processor, state, live_units)`** — calls `processor.call("step", state)`, walks `result["fauna_combat_log"]`, builds an `(col, row) → live Unit` lookup so it can resolve dead-unit positions in O(1), and for each death event: + - Resolves the dead unit by `(unit_col, unit_row)` + - Calls `dead.take_damage(dead.hp)` to set hp=0 + - Emits `EventBus.unit_destroyed.emit(dead, _killer_label(lair_tier))` + - Erases the position from the lookup so a second event at the same coord doesn't double-emit + + Returns the per-event log so callers can audit/render it. + +**`src/game/engine/scenes/tests/iter_7i_unit_adapter_proof.{gd,tscn}`** (new, ~280 lines, lint-clean) — the Phase Gate proof scene. Builds 6 mixed live `Unit.gd` instances (varied stats, one fortified), connects to `EventBus.unit_destroyed` with a counter, runs 30 turns of `RustFaunaBridge.resolve_fauna_encounters`, renders the live-unit roster + bridge totals + signal-emit count + per-Unit final state on the screenshot. + +### Bug caught during execution: typed-array binding + +First proof-scene run failed with `Bug: Invalid call error code 40` at the `set_player_cities_from_array` call. Root cause: GDScript's binding to a Rust `#[func]` that takes `Array` requires the GDScript caller to pass a **typed** `Array[Vector2i]`, not a generic `Array`. The same constraint applies to `Array[Unit]` for `set_player_units_from_dicts`. + +Fix: changed `RustFaunaBridge.build_state` parameter types from `units: Array, cities: Array` to `units: Array[Unit], cities: Array[Vector2i]` and updated the proof scene to pass typed arrays. This is a real architectural finding for any future GDScript code that calls into the Gd* bridge — typed arrays are mandatory at the binding boundary. + +### Bug caught during execution: ghost units from the production phase + +Second proof-scene run succeeded but showed an apparent discrepancy: **393 encounters / 149 deaths logged but only 1 EventBus.unit_destroyed signal emitted**. Investigation showed this is *correct behavior, not a bug*: + +`mc_turn::TurnProcessor::step` runs the **full** turn cycle including economy, city production, **unit production**, movement, encounters, and victory check. With a militarist axis (`production: 5`) on a 30-turn proof scene, rust spawns its own ghost units in its production phase that have no corresponding live `Unit.gd` instance on the GDScript side. Those ghosts then take encounters and die. The adapter correctly emits `EventBus.unit_destroyed` only for deaths it can resolve back to a live `Unit` by `(col, row)` lookup — i.e. the 1 live unit that died (a `dwarf_warrior` at `(11,11)`) — and silently filters the 148 ghost deaths. + +**The "matches dead live units: YES" assertion in the screenshot is the proof point** — the adapter loop is correct: emit count == live dead unit count == 1. + +This is a real architectural finding for **iter 7j**: the bridge needs an **encounters-only** step variant on `mc_turn::TurnProcessor` so the adapter doesn't have to filter ghost spawns. Either: +- A new `step_encounters_only(&mut state) -> TurnResult` method that runs only the fauna encounter phase +- A phase-mask parameter on `step` that selects which phases to run +- An `enabled_phases` field on `TurnProcessor` that the constructor or live-tuning setter can configure + +I chose to *document* the limitation in iter 7i rather than fix it, because: +1. The iter 7i proof doesn't depend on the fix — the adapter pattern is verified working +2. The fix is its own architectural choice that benefits from a fresh design pass +3. iter 7j explicitly needs to address this before wiring into `world_map.tscn`'s real turn loop, where the GDScript turn processor already runs production via `_process_production` and a parallel rust production phase would be a duplicate + +### Verification chain + +**Phase Gate Protocol screenshot reviewed in conversation** (`iter_7i_unit_adapter_2026-04-08_16-14-05.png`, 2560×1440): + +``` +Iter 7i — Real Unit.gd + RustFaunaBridge proof +Restored: src/game/engine/src/entities/unit.gd (180 lines) +Adapter: src/game/engine/src/modules/management/rust_fauna_bridge.gd +Bridge: live Unit[] -> bridge dicts -> GdTurnProcessor.step + -> fauna_combat_log walk -> Unit.take_damage() + -> EventBus.unit_destroyed.emit (live units only) + +Grid: 24×24 with 5 lairs (T4-T9) +Player: militarist axes, 1 city @ (11,11) +Squad: 6 mixed Unit.gd instances (varied stats, 1 fortified) +Encounter probability bumped to 0.9 (proof-only) + +Turn: 30 / 30 (sim COMPLETE) + +Live Unit.gd roster after 30 turns: + alive: 5 / 6 + dead: 1 / 6 + +Bridge totals (rust state, includes ghost units spawned by +production phase that aren't live Unit.gd instances): + encounters logged: 393 + deaths logged: 149 (rust ghosts + live units) + +EventBus.unit_destroyed signal contract: + live-unit deaths emitted: 1 + matches dead live units: YES ← proof point + last killer label: wild_lair_t7 +``` + +**Workspace state (post-iter-7i):** + +``` +cargo test --workspace → 500 passed, 0 failed (unchanged from iter 7h) +cargo clippy --workspace → 0 warnings (unchanged) +gdlint engine/src/ → no problems +gdlint engine/scenes/tests/ → no problems (iter_7i proof scene clean) +./run verify → All 6 checks passed in ~56s +``` + +### Iter 7i delta + +| Metric | Before iter 7i | After iter 7i | +|---|---|---| +| `unit.gd` line count | 2 (stub) | **180** (real Unit class) | +| `unit.gd` field count | 0 | **17** (matches every grep'd usage in engine/src/) | +| `unit.gd` method count | 0 | **9** (`is_alive`, `is_ranged`, `is_flying`, `is_military`, `refresh_turn`, `get_movement`, `take_damage`, `heal`, `to_bridge_dict` + `apply_bridge_dict` + `_populate_from_data` + `_combat_type`) | +| GDScript bridge adapters | 0 | **1** (`RustFaunaBridge` with 3 static methods) | +| Live-Unit ↔ bridge translation | not implemented | **`Unit.to_bridge_dict()` + `Unit.apply_bridge_dict()`** | +| `EventBus.unit_destroyed` integration | not wired to mc-turn | **wired via `RustFaunaBridge.resolve_fauna_encounters`** | +| Phase Gate screenshots | 7e + 7g + 7h | 7e + 7g + 7h + **7i** | +| Workspace tests | 500 | 500 (unchanged — adapter exercised by proof scene, not by `cargo test`) | + +### Architectural finding for iter 7j + +**Iter 7j scope (revised based on iter 7i ghost-units finding):** + +1. **Add `mc_turn::TurnProcessor::step_encounters_only(&mut state) -> TurnResult`** that runs only the fauna encounter resolution phase (Phase 5 + 6 of the existing 7-phase step). Skip economy, city production, founding, unit spawn, movement, victory check. The signature matches `step` so the bridge surface is consistent. + +2. **Expose it via `GdTurnProcessor`** as a new `#[func] fn step_encounters_only(state: Gd) -> Dictionary` with the same return shape as `step`. + +3. **Update `RustFaunaBridge.resolve_fauna_encounters`** to call `step_encounters_only` instead of `step`. This eliminates the ghost-units issue — the bridge no longer spawns rust units, so every death event in the log corresponds to a live `Unit.gd` instance. + +4. **Add a gated `_process_rust_fauna_encounters()` to `turn_processor.gd`** behind `EnvConfig.is_enabled("RUST_FAUNA_ENCOUNTERS")` (defaulted off). Calls `RustFaunaBridge.resolve_fauna_encounters` with the live `Player.units` and `Player.cities` for the current player. + +5. **Phase Gate from `world_map.tscn`** — the real game scene, not a test scene — showing `EnvConfig.is_enabled("RUST_FAUNA_ENCOUNTERS")=true` and the existing `wild_creature_ai.gd` flow coexisting with rust-resolved encounters in a single turn. + +Iter 7j is now structurally simpler than the iter 7g log predicted because iter 7h + iter 7i did the heavy lifting on the bridge surface and the adapter pattern. The remaining work is one Rust method (the encounters-only step), one Gd wrapper, one GDScript call site change, and the proof screenshot. + +--- + ## Iteration 7h — Dict-based GameState adapter API + per-event fauna log (2026-04-08, COMPLETE) **Goal:** The iter 7g experiment log identified iter 7h as the largest scope since iter 7b (mc-turn reconstruction): adapt the GdGameState bridge to accept arbitrary live unit/city state from GDScript, extend `step()` to return a per-event fauna combat log, and prove the GDScript adapter loop end-to-end via a Phase Gate screenshot.