diff --git a/.project/simulation-report/experiment-log.md b/.project/simulation-report/experiment-log.md index 6d8fa1e3..84909b96 100644 --- a/.project/simulation-report/experiment-log.md +++ b/.project/simulation-report/experiment-log.md @@ -4,6 +4,150 @@ Tracks every iteration of the balance/simulation loop. Newest entries on top. Ea --- +## Iteration 7k — Gated `_process_rust_fauna_encounters` integration in the real turn_processor.gd (2026-04-08, COMPLETE) + +**Goal:** The iter 7j experiment log identified iter 7k as the final bridge piece: wire `RustFaunaBridge` + `step_encounters_only` into the real `turn_processor.gd` → `turn_manager.gd` dispatch path, behind an `EnvConfig` flag defaulted off, and produce a Phase Gate screenshot proving both the gate-closed default and the gate-open bridged path. + +This is the last iteration in the iter 7 bridge sequence. After this, the architectural commitment "Rust is the simulation source of truth" is *actually present in the real game's turn loop* for the fauna encounter phase — not just in bench binaries and isolated proof scenes. + +### Changes + +**`src/simulator/api-gdext/src/lib.rs`**: + +1. New `GdGameState::set_turn(turn: i64)` `#[func]` method. This is the architectural unblocker for the "rebuild-state-per-call" pattern the GDScript adapter uses: without it, every adapter call starts with `state.turn = 0 → 1` and produces identical deterministic RNG draws. With it, the adapter seeds from the live `GameState.turn_number`, producing a fresh encounter roll stream per real game turn. + +**`src/game/engine/src/entities/player.gd`**: + +2. **Minimal Player.gd restoration** (2-line stub → 23-line real class). Only the fields iter 7k's integration actually reads: `index: int`, `units: Array`, `cities: Array`. Documented as a minimal stub with a carry-forward pointer to iter 7l for the full restoration (treasury, tech state, diplomacy, magic, culture, happiness — follow the iter 7i `unit.gd` restoration pattern). + +**`src/game/engine/src/modules/management/rust_fauna_integration.gd`** (new, 125 lines, lint-clean): + +3. `class_name RustFaunaIntegration extends RefCounted` with three static methods: + - `run_all_players()` — env-flag check, game_map lookup, GdTurnProcessor instantiation, lair list build, per-player dispatch. This is the single public entry point. + - `_build_lair_list()` — enumerate `GameState.npc_buildings` filtering out villages and ruins, stamp every remaining building at `DEFAULT_LAIR_TIER = 5` (iter 7l can refine via wilds.json lookup). + - `_run_for_player(processor, player, lairs, grid_size)` — pack the player's live units into `Array[Unit]`, build the GdGameState via `RustFaunaBridge.build_state`, call `state.set_turn(GameState.turn_number - 1)` to seed the RNG from the live game turn, run `resolve_fauna_encounters`. + +**`src/game/engine/src/modules/management/turn_processor.gd`**: + +4. New `_process_rust_fauna_encounters()` method — a one-line delegator to `RustFaunaIntegration.run_all_players()`. The method is a property of `TurnProcessorScript` so `turn_manager.gd` can call it through the same `proc.()` dispatch pattern as the other turn phases. + +5. **File size preservation**: The initial implementation inlined all 95 lines of integration code in `turn_processor.gd`, pushing it to **577 lines** (over the 500-line cap). Refactored by extracting to the new `rust_fauna_integration.gd` sibling file, bringing `turn_processor.gd` back to **494 lines** — inside the cap with breathing room. This follows the iter 7c `city_buildable_helper.gd` extraction pattern and the iter 7g `_stub_turn_manager.gd` extraction pattern. + +**`src/game/engine/src/autoloads/turn_manager.gd`**: + +6. Added `proc._process_rust_fauna_encounters()` call in `next_player()` adjacent to the existing `proc._process_wild_creatures()` call. The two flows coexist: wild_creature_ai keeps moving wild creatures and resolving combat, the rust pass runs only the encounter rolls (via `step_encounters_only`) when the env flag is set. Only a single line added — the hook blocked a larger edit because existing nearby code uses the `:=` inferred-type form which violates the project's strict GDScript quality rule. + +**`src/game/engine/scenes/tests/iter_7k_turn_processor_gated_proof.{gd,tscn}`** (new, ~280 lines, lint-clean): + +7. Phase Gate proof scene that exercises the **full** dispatch path: constructs a minimal-but-real `GameState` (GameMap + Player + 6 mixed Units + 6 beast_den Building entries as npc_buildings), instantiates a real `TurnProcessorScript`, then runs two phases: + - **Phase 1**: `RUST_FAUNA_ENCOUNTERS` env var unset/false. Calls `proc._process_rust_fauna_encounters()` 15 times. Asserts zero units killed and zero signals emitted (**gate closed**). + - **Phase 2**: `RUST_FAUNA_ENCOUNTERS=true`. Calls `proc._process_rust_fauna_encounters()` with `GameState.turn_number` advancing each iteration. Asserts the signal-per-death contract: every dead live `Unit.gd` instance produces exactly one `EventBus.unit_destroyed` signal. + +### Bug caught mid-execution: deterministic RNG freeze on rebuild-state pattern + +First proof run showed Phase 2 also producing zero deaths — the gate was open, the integration ran, but no encounters fired. Investigation: + +`RustFaunaIntegration._run_for_player` builds a fresh `GdGameState` per call. Without `set_turn`, every call started with `state.turn = 0`; `step_encounters_only` incremented to 1; the deterministic SplitMix64 RNG seed `hash_mix(1, 0xA3B5...)` always produces the same first N draws. If none of those draws hit the 4% default encounter gate, **zero encounters fire ever**. + +The fix: seed `state.turn` from `GameState.turn_number`. Each real game turn advances the live counter, so each adapter call sees a different effective seed. Added `GdGameState::set_turn` in the Rust bridge + the one-line `state.call("set_turn", live_turn - 1)` in `_run_for_player`. `step_encounters_only` increments by 1 internally, so pre-setting to `turn_number - 1` gives an effective start of `turn_number`. + +After the fix, Phase 2 correctly produces 5 deaths / 5 signals over the same scenario. + +This is the **architecturally correct** resolution — not a workaround. A real game running with this integration will naturally have `GameState.turn_number` advancing each turn, and the adapter will produce fresh RNG streams without any manual intervention. The proof scene explicitly advances `GameState.turn_number` to simulate this. + +### Verification + +**Phase Gate Protocol screenshot reviewed in conversation** (`iter_7k_turn_processor_gated_2026-04-08_17-31-21.png`, 2560×1440): + +``` +Iter 7k — Gated turn_processor.gd integration proof +Integration path: + turn_manager.next_player() + -> turn_processor._process_rust_fauna_encounters() + -> RustFaunaIntegration.run_all_players() + -> EnvConfig.get_bool('RUST_FAUNA_ENCOUNTERS') + -> build_state / stamp_lairs / resolve_fauna_encounters + -> GdTurnProcessor.step_encounters_only (iter 7j) + +Live GameState (minimal but real): + game_map: 24×24 GameMap + players: 1 (Player instance) + units: 6 mixed Unit.gd instances + npc_buildings: 6 beast_den Building instances + +Current phase: ON +Alive units: 1 / 6 + +Phase 1 — RUST_FAUNA_ENCOUNTERS env flag OFF (default): + units killed: 0 + signals emitted: 0 + gate closed? (0/0): YES ← proof point 1 + +Phase 2 — RUST_FAUNA_ENCOUNTERS env flag ON: + units killed: 5 + signals emitted: 5 + signal-per-death?: YES ← proof point 2 + last killer label: wild_lair_t5 +``` + +**Both proof points hold simultaneously**: +- **Phase 1 gate closed**: the default configuration is side-effect-free. No existing game behavior is affected by the integration landing unless the env flag is explicitly set. +- **Phase 2 signal-per-death**: when the flag is on, the full dispatch chain (`turn_manager → turn_processor → RustFaunaIntegration → RustFaunaBridge → GdTurnProcessor::step_encounters_only`) preserves the iter 7j signal contract: every dead live `Unit.gd` instance produces exactly one `EventBus.unit_destroyed` signal. + +**Workspace state (post-iter-7k):** + +``` +cargo test --workspace → 502 passed, 0 failed (unchanged from iter 7j) +cargo clippy --workspace → 0 warnings (unchanged) +gdlint engine/src/ → no problems +gdlint engine/scenes/tests/ → no problems (iter_7k proof scene lint-clean) +bash build-gdext.sh → clean, 1m 08s, .so deployed +./run verify → All 6 checks passed in ~65s +turn_processor.gd → 494 lines (under 500-line cap) +``` + +### Iter 7k delta + +| Metric | Before iter 7k | After iter 7k | +|---|---|---| +| `player.gd` line count | 2 (stub) | **23** (real minimal Player class) | +| `GdGameState` `#[func]` methods | 15 | **16** (+`set_turn`) | +| GDScript `turn_processor.gd` hooks to Rust bridge | none | **`_process_rust_fauna_encounters()`** (gated) | +| Real turn loop → Rust fauna path | none (bench and proof scenes only) | **end-to-end wired** | +| `EnvConfig` flag for integration | not defined | **`RUST_FAUNA_ENCOUNTERS`** (default off) | +| `RustFaunaIntegration` helper | not extracted | **new sibling file** (125 lines, 3 static methods) | +| `turn_processor.gd` line count | 482 | 494 (iter 7k delta: +12 lines of delegator/doc, rest extracted) | +| Phase Gate screenshots | 5 (7e, 7g, 7h, 7i, 7j) | **6** (+ iter 7k) | +| Workspace tests | 502 | 502 (unchanged — integration exercised by proof scene) | + +### Architectural completeness + +**The iter 7 bridge sequence is complete.** Six iterations, six Phase Gate screenshots, one end-to-end integration: + +| Iter | Deliverable | +|---|---| +| **7e** | Bridge skeleton: `GdTurnProcessor` + `GdGameState` + synthetic-grid proof | +| **7f** | Bridge hardening: spatial index + config roundtrip + GUT regression | +| **7g** | Real-mapgen proof + `./run verify` regression gate + proptest invariants | +| **7h** | Dict-based ingestion API + per-event `fauna_combat_log` | +| **7i** | Restored `Unit.gd` + `RustFaunaBridge` adapter + `EventBus.unit_destroyed` sync | +| **7j** | `step_encounters_only` (no production, no movement) + signal-per-death contract | +| **7k** | Gated `_process_rust_fauna_encounters` in real `turn_processor.gd` | + +**Every layer from the Rust `mc_turn::TurnProcessor` step function up through the GDScript turn dispatch is now connected.** Setting `RUST_FAUNA_ENCOUNTERS=true` in `.env.development` enables the integration live in any scene that uses `turn_manager.next_player()`, including the real `world_map.tscn`. The default remains off so every player shipping the current binary sees identical behavior to pre-iter-7k. + +### Carry-forward + +**Iter 7l candidates** (no longer structurally blocked by bridge work): + +1. **Full Player.gd restoration** — apply the iter 7i unit.gd restoration pattern to player.gd. Grep `engine/src/` for `player.` accesses, enumerate the union, declare the full field set in one pass. Likely ~15-20 additional fields + 5-10 methods. +2. **True per-lair tier mapping** — replace `RustFaunaIntegration.DEFAULT_LAIR_TIER = 5` with a lookup from `wilds.json` via `DataLoader.get_wilds_config()`. Each `beast_den` → T2-T4, each `apex_nest` → T7-T10, etc. +3. **Retire wild_creature_ai's combat layer** — if the iter 7k gated integration proves stable in playtesting, consider deprecating the parallel GDScript combat flow. Needs a design decision first: are wild creatures supposed to MOVE (GDScript AI) or just AMBUSH (Rust encounter rolls)? +4. **Real `world_map.tscn` Phase Gate screenshot** — the iter 7k proof scene is a focused integration test, not an in-game screenshot. A future iteration could set `RUST_FAUNA_ENCOUNTERS=true` and launch the real world_map scene to capture a gameplay screenshot showing a Rust-resolved encounter visible in the unit panel. Requires the broader game stack (not just turn processing) to be stable enough to boot. +5. **`GdGameState::set_turn` saves** — if save/load ever needs to preserve the exact deterministic RNG seed, `set_turn` lets the loader restore it exactly. Currently the bridge state is rebuilt per-call so this isn't an issue, but it becomes one if iter 7m ever switches to a long-lived GdGameState. + +--- + ## Iteration 7j — `step_encounters_only` (no production, no movement) + signal-per-death contract (2026-04-08, COMPLETE) **Goal:** The iter 7i experiment log identified iter 7j as the final piece needed before real-game integration: add a `step_encounters_only` entry point on `mc_turn::TurnProcessor` that runs ONLY the fauna encounter rolls, expose it via `GdTurnProcessor`, update `RustFaunaBridge` to call it, and prove that the `EventBus.unit_destroyed` signal contract holds (one signal per dead live `Unit.gd` instance, no ghost units).