docs(simulation-report): 📝 Add detailed RustFaunaBridge integration log for Iteration 7k in experiment-log.md

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 17:39:18 -07:00
parent 87c40eb78c
commit 6f04c25c28

View file

@ -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.<method>()` 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.<field>` 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).