feat(@projects/@magic-civilization): update worldsim integration docs and test path

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-08 05:17:50 -07:00
parent a6ec2abb3a
commit 6c9b9c8b5d
2 changed files with 76 additions and 2 deletions

View file

@ -88,9 +88,9 @@ hydrology re-solve) hook the terraforming cascade into the same step.
- ◻ **api-gdext per-turn call — BLOCKED WITH CAUSE (whole-game Rust port), not a p2-80-sized change.** The bullet's premise (that the playable game already runs `mc_turn::TurnProcessor::step`, so swapping to `WorldSim::step` is a call-site change) is **false** — see the Premise Correction in the Summary. The interactive discrete turn is GDScript; `WorldSim::step` = Rust `processor.step(state)` (the unified discrete turn) **+** climate + ecology + events. Driving the playable game through `WorldSim::step` therefore requires first porting the *entire discrete turn* (economy / production / research / cities / combat) into `mc_turn::TurnProcessor` and making the live `GdGameState` the authoritative state a Rust turn advances — i.e. the whole-game Rail-1 port tracked separately (`p0-26`-class, `p1-29j` autoplay action-application still a *stub*). That is explicitly **out of p2-80's scope** (Non-goals: "this is integration, persistence, determinism, and presentation only — not authoring/porting engines"). **Deliberately not built:** a narrow "unify the three worldsim orchestration calls into one Rust call" increment — it would not satisfy the bullet's literal text (the game still wouldn't advance through `WorldSim::step`), the Rail-1 gain is marginal (the GDScript "orchestration" is three ordered bridge calls + a seed mix, not simulation logic), and it would risk the render+save paths just stabilized. **Functional note:** p2-80's *functional* intent — "drive the existing worldsim engines in the playable turn" — is already MET: climate + ecology + world-events run every playable turn via the bridge (`turn_manager.gd`), proven visible (Render hook ✓) and persistent (Save persistence). This bullet is the residual *architectural-purity* question of **where the orchestration lives** (Rust vs GDScript), unblockable only by the whole-game port. **Full feasibility + sizing + phased bridgehead plan: `.project/designs/p2-80-bullet2-port-sizing.md`** (verified data-ownership inversion; recommended increment 1 = `p1-29j` autoplay bridgehead).
- ✓ **api-wasm parity — documented separate cut, no silent divergence.** `api-wasm` (the guide-web design lab) depends on `mc-climate` + `mc-ecology` + `mc-compute` but **not** `mc-turn` / `mc-worldsim` (`api-wasm/Cargo.toml`). It exposes `WasmGrid` — a worldgen snapshot plus per-tile climate/tectonics stepping for interactive parameter exploration (`generate_for_lab`, `tileClimateJson`, `computeStatsJson`) — **not** a playable game: no `GameState`, no `TurnProcessor`, no cities/units/events. It deliberately does **not** reuse `WorldSim::step`, which requires `mc-turn::TurnProcessor` + a full `GameState` and would pull the entire gameplay stack into the WASM bundle for a tool that only visualizes worldgen + climate. **No physics divergence:** the game and the lab call the *same* `mc-climate` / `mc-ecology` `process_step` fns — only the orchestration differs (lab: worldgen snapshot + climate stepping for viz; game: `WorldSim::step` per turn).
- ✓ **Full continuous-tick set wired** — verified by grep against the step. `EcologyEngine::process_step` (`engine.rs:276`) runs emergence, LV `tick_populations`, dispersal, tier succession, fish stocks, feedback, lair lifecycle. `generation::apply_migrations` runs in that step too (carrying-capacity migration, `engine.rs:343` — the g2-10 path). `biological::advance_bloom_streak` runs **once per turn** inside `dispatch_world_events` (`mc-worldsim/src/event_dispatch.rs:91`, unconditional when a grid is present), which `WorldSim::step` calls (`lib.rs:189`). `evolution::run_evolution` is **deliberately out** of the per-turn set: it is the pre-game from-epoch worldgen evolution (`evolution.rs:116` "Run pre-game evolution on a grid" — runs `world_age.evolution_ticks` of geological→biological deep time; every caller is a worldgen binary/bench, never a per-turn path). Running it per turn would re-evolve millennia each turn. *(Caveat: `advance_bloom_streak` mutates the grid-only `bloom_streak`, in the bullet-1 unpersisted-grid-state residual — it runs each turn but does not yet survive save/load.)*
- **Determinism gate**: same `(seed, save)` → byte-identical multi-turn worldsim trajectory through the api-gdext path (not just the crate test); golden vector pinned (PCG64 + `SeedDomain::WorldsimDynamics`).
- **Determinism gate — DONE (2026-06-08).** Pinned golden vector through the *real* api-gdext bridge path (not the crate test). GUT `test_worldsim_trajectory_golden_vector` (in `test_worldsim_playable_path.gd`) drives the worldsim per-turn pair through the live `#[func]` bridge methods in `turn_manager.gd`'s exact order — `GdFaunaEcology.tick_populations(grid, map_seed+turn)` then `GdWorldSim.dispatch_on_grid(grid, turn, map_seed)` (the `SeedDomain::WorldsimDynamics` / `dispatch_world_events` RNG path) — for 12 turns, hashes `(fauna continuation JSON \| eco_map JSON)` to SHA-256, and asserts BOTH (a) run_a == run_b (nondeterminism guard) AND (b) the hash equals the frozen golden `78d25ae1…d580d` (catches a deterministic regression that shifts both runs equally — which (a) alone cannot). **Verified on apricot:** the hash is byte-stable across two *separate process invocations* (strongest determinism evidence), 7/7 green. Distinct from the crate test (which drives `WorldSim::step` as one composed call); this exercises the three separately-seed-threaded bridge calls the live game actually makes. The `(seed, save)` save→restore→continue half is locked by `test_continued_trajectory_byte_identical_after_save_load` (also through the bridge).
- ✓ **Render hook***the living world is visible, fog-gated, in real play.* Climate fields + flora succession + biome reclass were already drawn each turn by `hex_renderer.gd` (Layer 2 flora cover + Layer 4 biome sprite, refreshed via the per-turn fog/observation `queue_redraw`). **Fauna population** is now surfaced by `fauna_overlay_renderer.gd` (the `lair_overlay_renderer` pattern): a `wildlife_habitat`-lens overlay reading bulk `GdFaunaEcology::populated_tile_densities()` (→ `EcologyState.tile_densities()`), refreshed on `EventBus.worldsim_updated` (emitted by `turn_manager` after the ecology tick), and **fog-gated** so it never leaks fauna on unexplored tiles. **Joined real-game fog-gated proof (captured 2026-06-07 on apricot, `fauna_overlay_proof.gd` rebuilt to drive production systems, not a synthetic grid):** the proof stands up a REAL game — production `MapGenerator` → real `GameMap` registered as `GameState`'s primary layer, real players — then runs the EXACT production turn pair for 25 turns (`Climate.process_turn(game_map, t, seed)` building/syncing the Rust `GdGridState`, then `EcologyState.tick(climate._grid, …)` — the same two calls `turn_manager` makes), with **fog ON** (asserts `FORCE_DISABLE_FOGOFWAR == false` at runtime) and a partial radius reveal around the human founder, then drives the production `FaunaOverlayRenderer` via the real `wildlife_habitat` lens + `worldsim_updated` signals and screenshots. Runtime metrics (seed 5, duel, deterministic): **960 worldgen tiles · fog flag false · `view_player_index` = 0 (human-prefer branch) · 346 populated tiles · 561/960 revealed · `fog_gate_pass` = 144** — i.e. of 346 live-populated tiles, only the 144 inside the explored region are drawn and **202 are correctly hidden by fog**. The reviewed screenshot shows the three-way contrast: black unexplored frontier / lit explored territory / green→yellow fauna heatmap confined to explored tiles (`.local/proof/fauna_overlay_proof_real_fogged_2026-06-07.png`). This exercises the `_draw()` fog branch end-to-end on the real distribution — the gap the earlier separate harnesses left open. **The barren-world bug behind this is fixed:** the live engine never seeded fauna (`EcologyInitializer` was dead code) and emergence is a deliberate slow trickle (`emergence_rate_base` = 0.001) gated on flora-derived `habitat_suitability` (≈0 at genesis) — it cannot cold-start an empty map. Added world-genesis **seeding**: `emergence::seed_base_trophic` (reuses the emergence pickers/generators, bypasses the rarity roll, base trophic only — herbivore+detritivore on land / filter-feeder in water, gated on stable `quality` not flora-derived suitability) → `EcologyEngine::seed_initial``GdFaunaEcology::seed_initial_populations`, wired into `EcologyState.tick` (lazy, first-tick, skips already-populated tiles so loads aren't double-seeded). Also populated the emergence `species_library` (`load_species_library_from_json`) so ongoing emergence works. **Verified end-to-end on a REAL worldgen autoplay (seed 5, 25 turns):** seeding bootstrapped **960 populated tiles at genesis**, which the LV dynamics regulated to a stable **~376 tiles** (`fauna_tiles` in `turn_stats.jsonl`, instrumented in `auto_play.gd`) — a living, self-regulating world, no synthetic crutch. Render path also unit-proven by GUT `test_fauna_overlay.gd` 5/5 + seeding by `test_fauna_emergence_live.gd` (192 tiles, survives 40 turns); the real-game fog-gated screenshot above is the integrated artifact.
- ◻ `cargo test` green (incl. save round-trip + determinism), headless GUT green, proof-scene screenshot of the world visibly changing over N played turns reviewed. *(Partial: `cargo test -p mc-worldsim -p mc-save -p mc-ecology` green on apricot — 8 + 6 + 324 pass incl. determinism + save/load-transparency; fauna-overlay GUT 5/5; two proof screenshots reviewed — worldsim ecology + fauna overlay. Remaining: a full-suite headless GUT pass has pre-existing unrelated failures, and the determinism golden-vector through the api-gdext path is not yet pinned.)*
- ◻ `cargo test` green (incl. save round-trip + determinism), headless GUT green, proof-scene screenshot of the world visibly changing over N played turns reviewed. *(Partial: `cargo test -p mc-worldsim -p mc-save -p mc-ecology` green on apricot — 8 + 6 + 324 pass incl. determinism + save/load-transparency; fauna-overlay GUT 5/5; worldsim playable-path GUT 7/7 (incl. the now-pinned `test_worldsim_trajectory_golden_vector` determinism golden through the api-gdext path); two proof screenshots reviewed — worldsim ecology + fauna overlay. Remaining: a full-suite headless GUT pass has pre-existing unrelated failures — 35 in diplomacy round-trip / `traded_luxuries` Player property, causally independent of worldsim — to be triaged separately.)*
## Non-goals

View file

@ -28,6 +28,13 @@ const SPECIES_DIR: String = "res://public/resources/ecology/fauna/species"
## Three sample species — small enough to be fast, real enough to exercise
## emergence + LV dynamics + dispersal + migration.
const SAMPLE_SPECIES: Array[String] = ["grey_wolf", "abalone", "red_deer"]
## p2-80 bullet 5 — pinned determinism golden. SHA-256 of the TURNS-turn worldsim
## trajectory (fauna continuation JSON | eco_map JSON) driven through the REAL
## #[func] bridge in turn_manager's exact per-turn order. Frozen so a DETERMINISTIC
## regression that shifts the trajectory (e.g. an RNG-domain or formula change)
## is caught — the run_a == run_b self-check alone cannot detect that. Re-pin ONLY
## with a deliberate, reviewed simulation change (PCG64 + SeedDomain::WorldsimDynamics).
const GOLDEN_TRAJECTORY_SHA256: String = "78d25ae196f7f00794ff15499e49e0808040a214f8e53fc230c71b05565d580d"
func test_bridge_classes_registered() -> void:
@ -245,11 +252,78 @@ func test_continued_trajectory_byte_identical_after_save_load() -> void:
)
func test_worldsim_trajectory_golden_vector() -> void:
## p2-80 bullet 5 — determinism golden vector THROUGH the api-gdext path.
##
## Drives the worldsim per-turn pair through the REAL #[func] bridge methods
## in `turn_manager.gd`'s exact order (turn_manager.gd:295-302):
## EcologyState.tick → GdFaunaEcology.tick_populations(grid, map_seed+turn)
## WorldsimState.dispatch → GdWorldSim.dispatch_on_grid(grid, turn, map_seed)
## The second call is the `SeedDomain::WorldsimDynamics` RNG path
## (`mc_worldsim::dispatch_world_events`) — so this golden exercises that
## domain, not only the ecology/climate `process_step` domains.
##
## Two halves of the gate:
## (a) NONDETERMINISM guard — same seed twice → run_a == run_b.
## (b) REGRESSION guard — run_a == frozen GOLDEN. A deterministic change
## that shifts BOTH runs equally passes (a) but fails (b); only a pinned
## value catches it. (The save→restore→continue half of "(seed, save)"
## is locked by test_continued_trajectory_byte_identical_after_save_load.)
##
## Unlike the mc-worldsim crate test (which drives `WorldSim::step`, a single
## composed call), this drives the THREE separately-seed-threaded bridge calls
## the live game actually makes — the distinct "api-gdext path".
var hash_a: String = _run_worldsim_trajectory()
var hash_b: String = _run_worldsim_trajectory()
assert_eq(
hash_b,
hash_a,
"same seed must yield a byte-identical worldsim trajectory through the "
+ "bridge (nondeterminism guard) — a=%s b=%s" % [hash_a, hash_b]
)
gut.p("worldsim trajectory sha256 = %s" % hash_a)
assert_eq(
hash_a,
GOLDEN_TRAJECTORY_SHA256,
"worldsim trajectory must match the pinned determinism golden "
+ "(PCG64 + SeedDomain::WorldsimDynamics through the api-gdext path); "
+ "got %s — re-pin only with a deliberate, reviewed sim change" % hash_a
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _run_worldsim_trajectory() -> String:
## Run the full TURNS-turn worldsim per-turn pair through the bridge and
## return a SHA-256 of (fauna continuation JSON | eco_map JSON). Each call
## builds FRESH state so two invocations are independent same-seed runs.
var grid: RefCounted = _make_terrain_grid()
var fauna: RefCounted = _make_fauna_with_species()
_seed_initial_populations(fauna)
var worldsim: RefCounted = ClassDB.instantiate("GdWorldSim") as RefCounted
assert_not_null(worldsim, "GdWorldSim must instantiate")
# Boosted biological thresholds so plague fires deterministically each turn —
# this populates eco_map and forces the WorldsimDynamics RNG path to do real
# work (default rates are deliberately rare and would leave eco_map empty,
# making the golden blind to the dispatch path).
var boosted_bio: String = (
'{"biological":{"plague":{"civ_min":-1.0,"quality_max":10,'
+ '"trigger_chance":1.0,"spread_factor":1.0,"spread_severity_scale":1.0}}}'
)
worldsim.call("load_thresholds_from_json", "", boosted_bio, "")
for t: int in range(TURNS):
# turn_manager order: ecology tick (map_seed + turn), then world-event
# dispatch (turn, map_seed). Same seed-threading as the live loop.
fauna.call("tick_populations", grid, SEED + t)
worldsim.call("dispatch_on_grid", grid, t, SEED)
var fauna_json: String = String(fauna.call("continuation_state_to_json"))
var eco_json: String = String(worldsim.call("eco_map_to_json"))
return (fauna_json + "|" + eco_json).sha256_text()
func _make_terrain_grid() -> RefCounted:
## Build a 16x16 GdGridState with deterministic temperate terrain so
## emergence has habitable tiles. Mirrors the synthetic terrain the