From fc31d066d0fdb06fc2e4bfe6d0b3ca89ec6c219c Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 13 Jul 2026 23:53:59 -0700 Subject: [PATCH] =?UTF-8?q?Docs:=20LANTERN=20Phase=200.3=20world-model=20r?= =?UTF-8?q?eview-hardened=20=E2=80=94=20Build=20Spec=20+=20DR-049?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial design review (wf_aa6fb661-e25; 22 agents, 0 failures) over the world-model proposal + ground-truth code raised 18 findings (12 CONFIRMED + 6 PARTIAL, 0 refuted) and caught real design-breakers before any code: - DP-1/RS-1 (HIGH): the seed model was self-contradictory (RunMapMath regenerates the WHOLE graph per seed, incompatible with a persistent pinned prefix) -> split: graph topology is a pure function of WorldSeed ONLY; DescentSalt re-knots content; pinning freezes PinnedSalt. - NR-2/RS-3 (HIGH): salvaged respawn warps a dead Wick to base -> splits the co-located party -> in-pocket death; warp gated behind party-wipe; revive is new code, not salvage. - DP-2 (HIGH): born-correct restore is per-entry lazy replay, not a boot one-shot. - NR-1 phantom-kill CombatFeedback fix; NR-3 enemy wind-up gated on the light-union; RS-2 RegionTag->PocketTag is a ~23-site semantic rewrite, not a rename. World_Model_Build_Spec.md = the review-hardened contract (all 18 folded, findings ledger, build order, open risks). DR-049 records the decision. Spike -> reviewed; proposal -> superseded. New forks parked (dark-contact policy, lantern placement, END-1/EB-1 mothball timing). Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/Vault/00_Home/Home.md | 2 +- .../06_Roadmap/Lantern_Operator_Questions.md | 15 ++ .../Lantern_World_Model_Design_Proposal.md | 8 +- .../06_Roadmap/Lantern_World_Model_Spike.md | 8 +- .../06_Roadmap/World_Model_Build_Spec.md | 231 ++++++++++++++++++ .../DR-049_Lantern_World_Model_Design.md | 83 +++++++ 6 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 Docs/Vault/06_Roadmap/World_Model_Build_Spec.md create mode 100644 Docs/Vault/07_Sessions/_Decisions/DR-049_Lantern_World_Model_Design.md diff --git a/Docs/Vault/00_Home/Home.md b/Docs/Vault/00_Home/Home.md index a3405494f..9852df1cf 100644 --- a/Docs/Vault/00_Home/Home.md +++ b/Docs/Vault/00_Home/Home.md @@ -21,7 +21,7 @@ Multiplayer game on **Unity DOTS** (Entities) + **Netcode for Entities** (server - **Game Design** → [[Systems_Index]] — per-system design docs - **Roadmap** → **[[Roadmap_Lantern_Slice]]** (operative) · [[Lantern_Strip_Mothball_Inventory]] (salvage map) · [[Lantern_World_Model_Spike]] (★review-first) · [[Backlog]] / [[Path_to_Fun]] / [[Milestones]] / [[Iteration_2026-07_CoopHades]] (history). - **Sessions** → `07_Sessions/2026/` — dated work logs (latest: [[2026-07-13_Lantern_Phase0_Adoption_Pivot]] · [[2026-07-12_Phase17_Boon_Overhaul]]) -- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-048 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-048_Lantern_Adoption_Full_Pivot]] (the LANTERN pivot) +- **Decisions** → `07_Sessions/_Decisions/` — decision records DR-001 … DR-049 · [[DR-001_Netcode_Test_Harness]] · latest [[DR-049_Lantern_World_Model_Design]] (world-model, review-hardened) · [[DR-048_Lantern_Adoption_Full_Pivot]] (the pivot) - **Meta** → [[Documentation_Protocol]] · [[Tags]] - **Templates** → [[Session_Log_Template]] · [[Decision_Record_Template]] diff --git a/Docs/Vault/06_Roadmap/Lantern_Operator_Questions.md b/Docs/Vault/06_Roadmap/Lantern_Operator_Questions.md index 03260a73b..1c8472bff 100644 --- a/Docs/Vault/06_Roadmap/Lantern_Operator_Questions.md +++ b/Docs/Vault/06_Roadmap/Lantern_Operator_Questions.md @@ -43,6 +43,21 @@ permalink: gamevault/06-roadmap/lantern-operator-questions 5. **✅ Manual-aim vs the current soft auto-target.** Design cuts soft auto-target for SoD-grade manual aim (per Identity). Confirm you want the auto-target fully gone in the gym (I kept the reticle plumbing). +## World model (forks surfaced by the design review — [[World_Model_Build_Spec]]) + +8. **✅ Dark-side contact policy (NR-3).** Enemies act in the dark (server-side), but their telegraph is culled + with them — so a full strike from the dark would be undodgeable. The spec gates enemy **wind-up start** on + the light-union. Open feel-fork: should an enemy still in the dark deal a **non-telegraphed low chip / grab** + ("the dark bites"), or **nothing at all** until lit? **Recommendation:** non-telegraphed chip (makes + "dimming = quiet-for-blind" a real cost without undodgeable burst). Confirm. +9. **✅ Lantern placement granularity (RS-6).** Is a waypoint-lantern a **free-position plant** or a **build-grid + cell placement**? Decides how much of `BuildPlaceSystem` transfers. **Recommendation:** free-position plant + for the slice (only the atomic withdraw+persist idiom transfers; less machinery). Confirm before coding pinning. +10. **⛔ END-1 / EB-1 mothball timing (RS-2 site-class c).** The `RegionTag→PocketTag` semantic pass must delete + the Core-fallback (END-1) + structure-aggro (EB-1) branches rather than generalize them across N pockets. + Confirm these siege-era systems are mothballed (they are, per [[Lantern_Strip_Mothball_Inventory]]) so the + pass deletes those branches. **Recommendation:** confirmed-mothball → delete the branches. + ## Economy / naming 6. **✅ Currency re-mean mapping.** Ore/Aether/Biomass/Charge → Flame/Lumen/Gloam/Loudness/Salvage/Chum/Tallow. diff --git a/Docs/Vault/06_Roadmap/Lantern_World_Model_Design_Proposal.md b/Docs/Vault/06_Roadmap/Lantern_World_Model_Design_Proposal.md index a6d407912..a10215008 100644 --- a/Docs/Vault/06_Roadmap/Lantern_World_Model_Design_Proposal.md +++ b/Docs/Vault/06_Roadmap/Lantern_World_Model_Design_Proposal.md @@ -2,12 +2,18 @@ title: LANTERN — World-Model Design Proposal (pre-review, Phase 0.3) date: 2026-07-13 tags: [roadmap, lantern, netcode, world-model, design-proposal, phase-0] -status: proposal-pre-review +status: superseded-by-build-spec permalink: gamevault/06-roadmap/lantern-world-model-design-proposal --- # LANTERN World-Model — Design Proposal (pre-review) +> [!warning] SUPERSEDED by [[World_Model_Build_Spec]] (2026-07-13, [[DR-049_Lantern_World_Model_Design]]). +> The adversarial review (18 findings) reworked this proposal — notably the seed model was internally +> contradictory (graph-from-descent-salt vs stable PocketId, DP-1/RS-1), the persistence lifecycle was wrong +> (DP-2), and respawn-to-base broke the co-location invariant (NR-2/RS-3). **Read the Build Spec, not this**, +> for the authoritative design. Kept as the record of the pre-review thinking + the review's input. + > Phase 0.3 of [[Roadmap_Lantern_Slice]]. This is the **plan** produced from the [[Lantern_World_Model_Spike]] > brief, grounded in the actual code (three read-only ground-truth passes + ctx7 on the Netcode relevancy/ > importance APIs). **It is the INPUT to the ★ adversarial design review, not a build contract** — the review diff --git a/Docs/Vault/06_Roadmap/Lantern_World_Model_Spike.md b/Docs/Vault/06_Roadmap/Lantern_World_Model_Spike.md index c63da3386..75b0856aa 100644 --- a/Docs/Vault/06_Roadmap/Lantern_World_Model_Spike.md +++ b/Docs/Vault/06_Roadmap/Lantern_World_Model_Spike.md @@ -2,12 +2,18 @@ title: LANTERN — World-Model Design Spike (Phase 0.3, ★review-first) date: 2026-07-13 tags: [roadmap, lantern, netcode, world-model, design-spike, phase-0, review-first] -status: brief-review-pending +status: reviewed permalink: gamevault/06-roadmap/lantern-world-model-spike --- # LANTERN — World-Model Design Spike (pocket-graph + seed-pinning) +> [!success] REVIEW COMPLETE (2026-07-13, [[DR-049_Lantern_World_Model_Design]]). +> The design review ran (`wf_aa6fb661-e25`, 18 findings) → the review-hardened design is [[World_Model_Build_Spec]] +> and the proposal it hardened is [[Lantern_World_Model_Design_Proposal]]. This brief is kept as the record of +> the problem framing + the hazards the review was told to attack. **The design half of Phase 0.3's gate is +> passed** (the in-engine A0 style-proof scene remains the other open Phase-0 gate). + > **★ REVIEW-FIRST — this is a BRIEF, not a design.** Phase 0.3 of [[Roadmap_Lantern_Slice]], adopted under > [[DR-048_Lantern_Adoption_Full_Pivot]]. This frames the slice's **hardest netcode problem** and its open > questions so the adversarial design review has a target. **No world code is written until that review diff --git a/Docs/Vault/06_Roadmap/World_Model_Build_Spec.md b/Docs/Vault/06_Roadmap/World_Model_Build_Spec.md new file mode 100644 index 000000000..e7ecb7248 --- /dev/null +++ b/Docs/Vault/06_Roadmap/World_Model_Build_Spec.md @@ -0,0 +1,231 @@ +--- +title: LANTERN — World-Model Build Spec (review-hardened, Phase 0.3 gate) +date: 2026-07-13 +tags: [roadmap, lantern, netcode, world-model, build-spec, phase-0] +status: review-hardened +permalink: gamevault/06-roadmap/lantern-world-model-build-spec +--- + +> Phase 0.3 gate output of [[Roadmap_Lantern_Slice]], adopted under [[DR-048_Lantern_Adoption_Full_Pivot]] +> and recorded in [[DR-049_Lantern_World_Model_Design]]. Synthesized by the adversarial design review +> (`wf_aa6fb661-e25`, 22 agents, 0 failures — 18 findings, 12 CONFIRMED + 6 PARTIAL, 0 refuted) over +> [[Lantern_World_Model_Design_Proposal]] + the ground-truth code. It **supersedes that proposal** as the +> authority for Phase 2 world code. + +# LANTERN World-Model — Build Spec (review-hardened) + +**Status — Phase 0.3 gate output.** This is the build contract that the ★ adversarial design review (netcode / determinism / reuse lenses, refute-first verified) hardened out of `Lantern_World_Model_Design_Proposal.md`. It supersedes that proposal as the authority for Phase 2 world code. Eighteen findings surfaced (12 CONFIRMED, 6 PARTIAL, 0 refuted); every one is folded below as either a locked decision or a tracked open risk. **No world code is written outside this spec.** The falsifiable slice test it serves: *does pinning a pocket feel like winning something* ([[Roadmap_Lantern_Slice]] Phase 3 gate). + +## Locked decisions + +**Operator forks (locked pre-review, non-negotiable):** +- **F-A — Co-located party, exactly one active pocket at a time.** The party never splits across pockets. This is architecturally *today's* single-active-room + ping-pong two-slot arena (`RegionMath`, +1000u offset, 500u stride — `RegionComponents.cs:39-57`), re-keyed from a 2-value region to a per-pocket key. +- **F-B — Party light-union relevancy.** Wicks (players) are always relevant to each other; world/enemy/hazard ghosts outside the party's *combined* lamp radius are never serialized to any client. Hard relevancy (`GhostRelevancyMode.SetIsIrrelevant`), never importance — "gamma-cranking cannot reveal what was never sent." + +**Design points the review CONFIRMED as sound (build on these as-is):** +1. **Light-as-information = hard relevancy.** `SetIsIrrelevant` never serializes an out-of-set ghost; untagged/global ghosts stay relevant for free (`RegionRelevancySystem.cs:9-17`). This is the correct and only mechanism. +2. **Global shared state rides the untagged director ghost.** `RunInfo`/`GoalProgress`/`CycleState` are already spawned MINUS `RegionTag` (`CycleDirectorSpawnSystem.cs:10-13`) → relevant cross-pocket for free. The Lamp meter, descent-info scalars, and pinned-set all belong here. +3. **Pinning = freeze the content salt + save deltas.** Generation is a pure function; only player mutation is stored (`RoomFieldSystem` `Hash(seed,nodeId,sub)` + `BaseRestoreSystem` born-correct replay). The tide-line drops out of this cleanly. +4. **`RunDirectorSystem` stays the sole FSM writer** and keeps its single-writer + receiver-sets-command discipline (`RunDirectorSystem.cs:11-35`). The descent lifecycle is a rename+extend of the existing state machine, not a new one. +5. **Lamp radius as `[GhostField]` is already server-authoritative** — `[GhostField]`s replicate server→client only; no client can author one. The DP-4/NR-4 "client-authored radius exploit" premise is rejected: keep the radius derived from server-only fuel timers and sent to all (teammates must see each other's cones). +6. **`GhostImportance` is NOT needed for the slice.** `O(ghosts × partySize)` with capped ghost counts (~≤40) is trivial. Held in reserve for bandwidth only. +7. **Layer-B light-union is connection-INDEPENDENT** (relevant-to-any-member ⇒ relevant-to-all-party-connections) — compute once per pocket per tick, apply to all party connections. This holds *only because* of F-A + in-pocket respawn (see NR-2/RS-3 below); if that guarantee breaks, fall back to per-connection keying. + +## Review verdict + +- **Netcode — PASS WITH CHANGES.** 6 findings (4 CONFIRMED, 2 PARTIAL). No blocker; two changes are hard requirements before coding: the `CombatFeedbackSystem` alive-prune fix (NR-1) and the in-pocket death model (NR-2). Enemy engagement must gate on the light-union (NR-3). +- **Determinism — NEEDS REWORK.** 6 findings (5 CONFIRMED, 1 PARTIAL); three HIGH. The proposal's seed model is internally contradictory (graph-from-descent-salt vs stable PocketId, DP-1/RS-1) and its persistence lifecycle citation is wrong (DP-2). These are re-specified below and MUST land before any world code — they are save-corruption / soft-lock class. +- **Reuse — NEEDS REWORK (on graph generation) / PASS WITH CHANGES (elsewhere).** 6 findings (3 CONFIRMED, 3 PARTIAL); one HIGH (RS-1 — `RunMapMath` cannot be salvaged as-is). `RegionTag→PocketTag` is a semantic rewrite over ~23 sites in 15 files, not a rename (RS-2). Respawn/participant path is NOT free salvage (RS-3). + +## The contract + +### C1 — The seed model (resolves DP-1, DP-3, DP-6, RS-1) + +Three seeds, one stable graph. **INVARIANT: the pocket graph topology is a pure function of `WorldSeed` ONLY, byte-identical across every descent of a save. `DescentSalt` is NEVER an input to graph generation.** + +| Seed | Scope | Persistence | Feeds | +|---|---|---|---| +| **`WorldSeed`** | per save (the shelf's identity) | **NEW SaveData v7 field**, distinct from `RunsCompleted`/`RunEpoch` lineage | `RunMapMath.Generate(WorldSeed)` → topology: `LayerCount`, per-layer widths, every node's `RoomType`/`Biome`/`ShapeId`, edges, `PocketId` space, `BossNodeId` | +| **`DescentSalt`** | per descent (monotonic) | derived from **NEW SaveData v7 `TotalDescents`** (see DP-6 below) | per-*dark*-pocket CONTENT only: `Hash(WorldSeed, PocketId, DescentSalt, subStream)` — the `RoomFieldSystem.cs:93/117/141/175` fold, re-meaned from `RunSeed` | +| **`PinnedSalt`** | per pinned pocket | **server-only**, frozen in `PinnedPocketSave[]` (v7) at plant time | replaces `DescentSalt` for that pocket's content ⇒ regenerates identically every descent | +| **`PocketId`** | stable graph-node id | transient (regenerated) | `= RunMap.NodeId(layer,col) = layer*MaxWidth+col` (`RunMap.cs:94`) | + +**DP-1 / RS-1 resolution — split the two concerns `RunMapMath` fuses.** `RunMapMath.Generate` today derives the ENTIRE graph (including `L = 6 + Hash(s,0x1A)%5`, `RunMapMath.cs:52`) from one seed, and `RunDirectorSystem` reseeds it every run (`RunSeed = max(1u, Hash(RunEpoch, HostSalt))`, `RunDirectorSystem.cs:112`). A pinned `PocketId` would therefore point at a structurally-different (or out-of-range) node next descent. Fix: +- Feed `RunMapMath.Generate` the **`WorldSeed`**, never a per-descent salt. Rewrite proposal lines 46 & 61 accordingly ("the graph is generated from `WorldSeed` and is stable across descents; only per-pocket content re-knots via `DescentSalt`"). +- Only per-pocket CONTENT (`RoomFieldSystem` scatter, enemy roster, harvest) re-knots via `DescentSalt`; a pinned pocket uses its frozen `PinnedSalt` instead. Content depends on room type, so this is only sound *because* the type is now `WorldSeed`-stable. +- **Harden the resolver:** `RunMap.NodeAt(pinnedNodeId)` (`RunMap.cs:100`) must bounds/width-check (`reject nodeId >= Nodes.Length` and `col >= Width(layer)`) so a future `MaxLayers`/topology change degrades gracefully instead of throwing or silently returning a default (all-zero ⇒ `RoomTypeId.Combat`) node. + +**DP-3 resolution — pinned predicate is ARRAY MEMBERSHIP, never `salt != 0`.** `PinnedSalt` is a raw `Hash` output that CAN be 0, and JsonUtility 0-defaults missing v7 fields (`SaveData.MinLoadableVersion = 2`, additive floor). So: +- A `PocketId` is pinned **iff it has a row in `PinnedPocketSave[]`** — state this explicitly; `PinnedSalt`-value is NEVER the predicate. +- At the pocket-content RNG-seed call site apply the existing `| 1u` convention (`RoomFieldSystem.cs:93` seeds `new Random(Hash(...) | 1u)`) — or `salt == 0u ? 1u : salt` if full-space collision-freeness matters — so a frozen salt of exactly 0 can't produce a degenerate `Unity.Mathematics.Random`. **Never mutate the stored salt.** +- Do **NOT** route `PinnedSalt` through `TickUtil.NonZero` — it is a "0 = ready" tick sentinel, not a seed guard; `NonZero(0)=1` would collide the salt-0 pocket with the salt-1 pocket's content. `TickUtil.NonZero` stays scoped to the actual ticks (lamp-fuel timers, descent timer). + +**DP-6 resolution — `DescentSalt` lineage must be PERSISTED or the dark repeats across quit+continue.** `RunEpoch` is server-only working state, reset to 0 at restore, and `HostSalt` refolds from `RunsCompleted` (`CycleDirectorSpawnSystem.cs:136`) — so two sessions with the same `RunsCompleted` regenerate the identical salt sequence, and already-explored dark pockets reappear unchanged (the anti-persistence promise fails across the most common boundary). Fix: +- Add SaveData v7 **`TotalDescents`** — a monotonic descent counter incremented on **every** `Staging→Launching` launch (`RunDirectorSystem.cs:106-123`), NOT gated on boss-clear like `RunsCompleted`. +- Persist it from **both** save paths (the boss-clear autosave in `SaveWriteSystem.cs`, AND the quit-to-menu writer via the shared `MetaSaveScan` collector). +- At restore (`CycleDirectorSpawnSystem`), seed the descent lineage from `TotalDescents` (e.g. `RunRuntime.RunEpoch = TotalDescents` and/or `HostSalt = Hash(0x5EED0001u, (uint)TotalDescents)`), so the next descent's salt is distinct across sessions. +- Keep the re-knot `DescentSalt` fold DISJOINT from any live `PinnedSalt` (a reroll must never collide with a frozen pocket). +- **Residual gap (accept + note):** a hard app-kill with no save write won't advance the persisted counter. If the fiction must survive crash/kill, save-on-launch (persist the incremented counter at the launch edge). Otherwise the dark repeats only after a crash — tolerable for the slice. + +### C2 — The pocket graph & muster (resolves DP-5, RS-1) + +- `RunMapMath.Generate(WorldSeed)` produces the stable spine. `PocketId` = `NodeId(layer,col)`; the station is a **permanent home pocket** outside the node space (see C3 StationPocketKey). +- **Muster** = the deepest pinned `PocketId` **reachable from the station** in the `WorldSeed` graph (BFS via `RunMapMath.ReachableOptions`/`AllNodesReachable`, `RunMapMath.cs:178-223`), not depth-alone. The dark frontier past the deepest pin is fresh each descent (slice target 3–5 dark pockets past the frontier). +- **DP-5 loss-floor INVARIANT:** the station `PocketId` is a permanent, un-gutterable member of the pinned set and the guaranteed muster fallback. When the set is only the station (fresh save / full gutter), muster at the station. Store the pinned set as an explicit `PocketId` SET, never a "prefix" (per-lantern independent burn makes a contiguous-prefix assumption unsound). If the "continuous lit road" fiction matters, allow only the *current deepest* pinned pocket to gutter (frontier-only loss); otherwise accept holes but keep muster keyed on graph-reachability-from-station. +- **Save-time invariant + test:** the persisted pinned set is never empty, always contains the station `PocketId`, and every persisted pinned `PocketId` is representable (`col < Width(layer)`, `layer < LayerCount`) in the `WorldSeed` graph — so an un-pin cascade can never persist a stranded muster. + +### C3 — Relevancy: two hard-cull layers (resolves NR-1, NR-2, NR-3, NR-4, NR-6, RS-2, RS-3) + +**Layer A — pocket-occupancy scope (coarse).** `PocketTag { int PocketKey }` replaces `RegionTag` (server-only, NOT a `[GhostField]`). Because the party is co-located (F-A), key the coarse cull off **the run's single active `PocketKey`** (read from the director ghost), not off each connection's own tag — so any temporarily-divergent participant (a downed/respawning Wick, C4) still sees the active fight. This is the connection-independent form of today's `RegionRelevancySystem.cs:31-67`. + +- **StationPocketKey MUST be a disjoint sentinel.** `PocketKey = NodeId`, and `NodeId(0,0) = 0` collides with today's `RegionId.Base = 0`. Reserve a key outside the node-id space (e.g. `-1` / `int.MinValue`) for the station so the home pocket never aliases the first dark pocket. Introduce `IsStation(key)` / `IsDark(key)` helpers. + +**Layer B — light-union relevancy (fine, party-union).** Within the active pocket, a non-player ghost is relevant iff it lies within ANY party member's lamp radius (union). Connection-independent (locked decision 7). Wicks always relevant (added to no irrelevant set). + +- **NR-1 / NR-6 — LOCK "once-lit-stays-relevant until pocket exit" (hysteresis), it is a REQUIREMENT, not an open question.** A per-ghost server-only enableable latch (`LitThisPocket`), born unset at spawn, set the first tick the ghost enters any lamp, never re-cleared mid-pocket. This kills per-tick lamp-sweep thrash and sidesteps the relevancy-re-entry false-edge hazard. Because pocket ghosts are **destroyed per pocket** (not reused), the latch resets for free at teardown — **confirm in code review that no ghost is ever carried across a pocket/sub-slot flip** (the exact reuse point the codebase has been bitten on). EditMode test: enter pocket A on sub-slot 0, light ghosts, tear down, enter pocket B on the reused sub-slot, assert B's ghosts start unlit/irrelevant. + +- **NR-1 — FIX `CombatFeedbackSystem` alive-prune directly (hysteresis alone only DEFERS the storm to pocket-exit).** Today the prune-of-a-cached-alive-enemy path fires the full KILL package — `Burst(_deathFx…)`, `KillShake`, `KillFovKick`, `TryHold()` hit-stop, kill flash, rumble (`CombatFeedbackSystem.cs:295-306`) — on the assumption "vanished while alive == I killed it." Genuine kills already fire the crunch at the Health 0-crossing (`CombatFeedbackSystem.cs:237-247`) while the ghost is still relevant, so: + - An alive-vanish (cached `Hp > 0` leaving `_seen`) must NOT fire the full package. The correct discriminator is **"did this enemy's last replicated Health actually reach ~0?"** (it won't for a relevancy drop) — a bare proximity gate copied from `WorldFeedbackSystem.cs:118-130` is insufficient here because a light-boundary enemy can be inside the local player's own range and still pass it. Gate the crunch on the last-Health-≈0 condition (optionally AND proximity). On an alive-vanish, at most emit a subtle silent despawn puff. + - At pocket teardown (`Ascending`/`ChooseNext`/pocket→next), explicitly suppress the alive-prune crunch entirely — the pocket is being torn down, not cleared. + - **Regression test (tunnelling-style, plain-Entities EditMode):** cache an alive enemy, remove it from the `Health` query to simulate a relevancy drop, tick the presentation system, assert no kill cue (no `TryHold`/`AddShake(KillShake)`/`PunchFov(KillFovKick)`). Also cover the batch pocket-exit drop of N live enemies asserting zero crunches. + +- **NR-3 — gate enemy WIND-UP COMMIT on the light-union, not just movement.** `AttackWindup.WindUpUntilTick` is a `[GhostField]` specifically so the client renders the ~0.3s dodge tell (`AttackWindup.cs:6-18`); an enemy outside the union is never serialized, so its telegraph is never received — yet `EnemyAISystem` still commits the wind-up and appends a `DamageEvent` on server range alone (Grunt strike commit `EnemyAISystem.cs:189-206`, wind-up starts at `:221`/`:362`/`:478`). That is undodgeable damage from an actor whose fairness contract was culled — it breaks the skillshot pillar. Fix: + - Reuse the SAME per-pocket-per-tick light-union set computed for Layer-B relevancy (connection-independent — the server AI is world-authoritative, no per-connection branching, no extra cost) and expose it to `EnemyAISystem`. + - Require light-membership TRUE before a wind-up may **START** (`:221`/`:362`/`:478`); the commit-once-started rules (`:189-206`) then stay fair because the strike began lit. + - **Light-lead margin:** an interpolated enemy that becomes relevant exactly at wind-up start renders ~interpolation-buffer late, compressing the tell. Require the enemy to have been lit for ≥ (interpolation delay) before it may commit, or budget the wind-up lead to cover it. + - Movement stays server-only and unrestricted (enemies seek/loom/approach in the dark — that IS the fiction). + - **Design fork (recommended, satisfies "dimming is quiet-for-blind"):** permit dark-side CONTACT only as a NON-telegraphed low chip / grab ("the dark bites"), never a full skillshot-grade burst — so a received telegraph is only ever owed for lit strikes, and blindness costs chip, not undodgeable damage. Hazard NR-1's once-lit hysteresis does NOT address this — it is a separate gate. + +- **NR-4 — wire the pinned set as a `[GhostField] DynamicBuffer`, split from the cheap scalars.** The pinned set is variable-length and grows across a campaign; it cannot ride `RunInfo`'s scalar-only pattern. Model it as a bounded `[GhostField] IBufferElementData` on the untagged director ghost, copying the `MetaTierState` idiom exactly (`CycleDirectorSpawnSystem.cs:123-126`): `PinnedPocketState { [GhostField] int PocketKey; [GhostField] uint CosmeticKey /*see DP-4*/ }`, `[InternalBufferCapacity(N)]` with an enforced hard cap, no `OwnerSendType` (ownerless interpolated), restored born-correct at spawn via `ecb.SetBuffer` from persisted `PinnedPocketSave[]`. Re-serializes on every pin. **Split concerns:** the muster point + common HUD only need a single scalar `[GhostField]` (deepest-pinned `PocketKey`) which CAN live on the `DescentInfo` scalar component; only the full lit-MAP needs the buffer. + +- **Server interaction guard preserved.** Relevancy is client-visibility only; server AI/damage still act on ghosts in the dark (`RegionRelevancySystem.cs` docstring). Keep an equivalent server-side pocket guard (today's cross-region damage re-check) so cross-pocket interaction can't leak in the shared PhysicsWorld. + +### C4 — Death & respawn (resolves NR-2, RS-3) + +**Do NOT port `PlayerRespawnSystem`'s warp-to-base + `RegionTag=Base` flip unchanged.** Today it hard-warps a dead player to the base spawn ring and flips its server-only `RegionTag` to `Base` (`PlayerRespawnSystem.cs:72-78`, itself a soft-brick bugfix). Re-keyed to pockets, a single mid-fight death relocates that Wick into the STATION pocket while the party fights on — a transient two-occupied-pocket state that violates F-A, makes the vision's in-place flame-share revive physically impossible (the suit is 1000u away), and — because Layer-A would cull the base-warped player from the active pocket — hands them a blank fight. `RunDirectorSystem` does not abort on a single death (`expeditionPlayers==0` only, `:163`), so the split persists for minutes. + +**Adopt option (a) — in-pocket death:** +- A downed Wick stays in the ACTIVE pocket as a guttering-suit entity with **NO `PocketTag` flip** (Dead-gated movement/aim/fire already handled by `PlayerDeathStateSystem`). It stays relevant to the party; spectating works; the Phase-3 in-place flame-share revive is reachable. +- **Gate `PlayerRespawnSystem`'s Position write + pocket flip behind a party-wipe check** (all-participants-down). A full wipe is already the `expeditionPlayers==0` → `Returning`/`Ascending` path (`RunDirectorSystem.cs:163,190,230,293`), which cleanly warps everyone home together in one tick without splitting the party. Give the guttering suit its own respawn/revive timer that resolves **in place** at the active pocket origin (`RegionMath.ExpeditionRoomOrigin(baseCenter, activeSubSlot)`), writing position AND `PocketTag = activePocketKey` together (never the station key). +- **Re-audit the downstream coupling that DEPENDS on dead players leaving the expedition:** the `RoomReward`/`BoonOffer` exit gate filters `region == RegionId.Expedition` specifically so a dead-respawned base player can't stall the party (`RunDirectorSystem.cs:196-215`). In-pocket respawn changes that assumption — the gate must be revisited so a revived in-pocket player is (or isn't) counted deliberately. +- **The co-op death/revive loop is NEW code, not salvage.** Delete the proposal §4 "RunParticipant reused verbatim / rejoin via existing conscription path" claim — it is factually wrong (`PlayerRespawnSystem` sends dead players to the base ring; `EnterRoom` only re-teleports on the NEXT advance, `RunDirectorSystem.cs:457-466`; late joiners are never conscripted mid-run, `:144-149`). `RunParticipant` conscription/teleport/release is reused only for the launch/return roster. +- If option (b) were ever preferred instead, Layer-A must be re-specified as connection-independent over the launch roster (the active pocket's ghosts relevant to every `RunParticipant` connection, including one tagged station). Option (a) is the locked choice. + +### C5 — The descent FSM (rename + extend `RunDirectorSystem`) + +`RunDirectorSystem` stays the sole writer of the replicated lifecycle FSM. Append-only byte state remap (`RunLifecycle.cs` values are append-only — do NOT re-mean): + +| Today (value) | LANTERN | Note | +|---|---|---| +| `Staging` (0) | **AtStation** | muster/prep/ready at the hub (the permanent home pocket / StationPocketKey) | +| `Launching` (1) | **Descending** | Bell-down telegraph; pick descent identity (bump `TotalDescents`, C1/DP-6) | +| `InRoom` (2) | **InPocket** | active pocket populated | +| `RoomReward` (3) | **PocketClear** | mutation thresholds fire here (pocket-clear beats, never mid-combat) | +| `Returning` (4) | **Ascending** | Bell recall (teleport) or lit-road walk home; bank; → AtStation | +| `RouteSelect` (5) | **ChooseNext** | pick the next pocket edge | +| `RoomExplore` (6) | **PocketExplore** | loot + plant-a-lantern window | + +- **Bell transit = teleport** (the proven `PortalCommand`→`EnterRoom` teleport, `RunDirectorSystem.cs:236-283,438-469`). Lit-road walk-home is a stretch goal. +- **~30–40 min hard session cap** = a NEW server descent timer (plain `uint` tick via `TickUtil.NonZero`) that forces the Bell recall. +- **EnterPocket** = today's `EnterRoom`: publish the node as single plan authority, flip the ping-pong sub-slot, bump `RoomEpoch`, teleport participants (`Position` write in place, never `FromPosition`). Now also fires the lazy pinned-pocket restore (C6). + +### C6 — Pinning, session-burn & the Lamp meter (resolves DP-2, RS-5, RS-6) + +**Plant a lantern (PocketExplore)** = a placement RPC reusing only the co-op-atomic PATTERN from `BuildPlaceSystem.cs:87-90` (in-place `StorageMath.Withdraw` + reservation in the RPC foreach; only `Instantiate` via ECB). It freezes `PinnedSalt = Hash(WorldSeed, PocketId, DescentSalt)`, adds the `PocketId` row to `PinnedPocketSave[]` (v7), and persists. + +- **RS-6 — BuildPlace/BaseRestore reuse is PATTERN-ONLY; build two NEW server systems.** Both existing systems are hardwired to the permanent base: `BuildPlaceSystem` resolves cells via `BaseGridMath` on `BaseAnchor` and bakes `RegionTag{Base}` (`:94,:103`); `BaseRestoreSystem` replays one-shot at load, `RequireForUpdate`-gated on `PendingStructure`+`StructureCatalog`+`BaseAnchor` (`:36-39`), hardcodes `RegionTag{Base}` (`:95`) and `CellToWorld` base coords (`:77`), destroys the carrier (`:128`). A dark pocket lives at a transient sub-slot origin and doesn't exist at load. The new pieces require: coordinate authority = `RegionMath.ExpeditionRoomOrigin(baseCenter, activeSubSlot)`; the `PocketTag{PocketKey}` tag, never `RegionTag{Base}`; replay gated on pocket-ENTRY (re-runnable per entry, NOT a self-destructing boot carrier); and a **per-ACTIVE-pocket occupancy scan** — `BuildPlaceSystem`'s scan over ALL `PlacedStructure` ghosts (`:54-60`) would false-collide cells across the two transiently-coexisting sub-slot arenas in the shared PhysicsWorld. **Resolve before scoping:** whether a waypoint-lantern is a free-position plant (then only the atomic withdraw+persist idiom transfers) or a build-grid cell placement (then the `CanPlace`/occupancy machinery transfers too). + +- **DP-2 — pinned-pocket RESTORE is per-entry LAZY replay, NOT a boot one-shot; do not cite `BaseRestoreSystem` as the lifecycle template.** Reuse only its born-correct MECHANISM: a staging carrier holding `PinnedPocketSave[]` staged at spawn, and `Health.Current` set in the SAME `EntityCommandBuffer` as `Instantiate` (`BaseRestoreSystem.cs:87-94` — a deferred set leaks baked `Max` to clients for one snapshot; this hazard is STRONGER at runtime, because a pocket's ghosts become relevant to the party over Layer-B the same tick they spawn). Concretely: + - Restore fires inside `EnterPocket` (C5), keyed on `PocketId`. At boot the party is `AtStation`; pinned pockets are needed only when the descent reaches them via muster. + - On entry: regenerate the pocket geometry deterministically from the frozen `PinnedSalt`, THEN instantiate that pocket's structure/harvest deltas into the active sub-slot (`ExpeditionRoomOrigin`), tag `PocketTag{PocketKey}`, set `Health` born-correct same-ECB. + - The carrier is **NOT destroyed after one replay** — it survives to re-replay on each subsequent entry. Key delta rows per `PocketId` so entry N replays only that pocket's rows. + +- **Session-only burn:** fuel is a runtime server countdown per lit lantern (never persisted — loads full, drains only while the session runs, honoring "no offline punishment"). Guttering telegraphs long/loud; full gutter un-pins per the loss-floor invariant (C2 — station un-gutterable; frontier-only or reachability-preserving). + +- **RS-5 — burn adds NO second writer to the replicated FSM.** The replicated pinned set (`[GhostField]` buffer, C3/NR-4) and Lamp meter (`GoalProgress`-equivalent, its own component) are SEPARATE replicated components on the director ghost — multiple writers to *different* components on one ghost is already proven safe (`RoomEnemyDirectorSystem` writes `ExpeditionObjective` on the same director ghost). Burn maintains ONLY the server-only per-lantern fuel timers and, on full gutter, sets a server-only signal/command (receiver-sets-command idiom, mirroring `PortalCommand`/`RouteCommand`). **`RunDirectorSystem` — the FSM, which also performs muster — is the SOLE writer of the replicated pinned set**, consuming that signal one-tick-late (like `ExpeditionObjective`). This puts muster's READ and burn's un-pin WRITE in one system (no cross-system read/write ordering) and needs **NO `[UpdateBefore/After]` edge** around `RunDirectorSystem` or `CyclePhaseSystem` — dodging the sort-cycle the codebase warns is invisible to EditMode and throws only at Play world creation (`RunDirectorSystem.cs:32-35`). **Play-validate world creation regardless.** + +- **Lamp meter** = banked light deposited at a lit lantern, on the `GoalProgress`-style replicated component on the untagged director ghost. Proven reuse, low risk. + +### C7 — The wire contract (replicated vs server-only) + +**Global untagged director ghost:** +- `DescentInfo` (≈ `RunInfo`, all `[GhostField]` SCALARS): lifecycle byte, active `PocketId`, `DescentSalt`-derived **descent seed** the client regenerates the graph + dark-pocket dressing from, **deepest-pinned `PocketKey`** (scalar — muster point + common HUD), Lamp meter charge/target, party Loudness sum. +- **`PinnedPocketState` `[GhostField] DynamicBuffer`** (NR-4): the full lit-map set. `[InternalBufferCapacity(N)]`, hard-capped, ownerless. Carries `PocketKey` + a **cosmetic key** (DP-4 below). + +**Server-only (never `[GhostField]` — adding these never re-bakes the ghost):** `DescentSalt`/`TotalDescents` lineage, per-pocket `PinnedSalt`, `PocketTag`, `RunParticipant`, `LitThisPocket` hysteresis latch, lamp fuel timers, pocket teardown tags, the light-union set. + +**Player ghost:** ready flag + **lamp radius** as `[GhostField]` sent to all (server-authoritative, decision 5 — clients render own + teammates' cones; server computes relevancy) + Loudness pip. + +- **DP-4 — replicate a cosmetic key for pinned pockets or the client draws different dressing each descent.** `RoomDressingSystem.cs:68` keys dressing off `ri.RunSeed` (the per-descent seed) hashed with room index; for a PINNED pocket that must "regenerate identically," the client would key on the changing descent seed and mismatch the server's frozen-salt gameplay ghosts (cosmetic, not a gameplay desync — ghosts stay authoritative — but it undercuts the pinned fantasy). Fix: carry the frozen `PinnedSalt` (or a derived cosmetic hash) as the `PinnedPocketState.CosmeticKey` `[GhostField]`; client dressing keys on THAT for pinned pockets, falling back to the descent seed for dark pockets. Reconcile §6's "server-only `PinnedSalt`" with the client's regen role via this replicated derived key (the raw salt stays server-only). + +## Salvage & rename map (corrected against the reuse lens) + +| Existing | Verdict | What actually happens | +|---|---|---| +| `RunMapMath.Generate` | **REWRITE the seed contract (RS-1, HIGH)** | Feed `WorldSeed` (not per-descent `RunSeed`); topology becomes descent-stable. Content re-key moves to `RoomFieldSystem`. Bounds-harden `NodeAt`. | +| `RunMap`/`RunMapNode`/`RoomTypeId` | **Reuse** | `PocketId = NodeId`, transient regen, byte type ids append-only. | +| `RunDirectorSystem` | **Reuse + extend** | Rename states (append-only bytes); add descent timer; add lazy pinned-restore call in `EnterPocket`; sole writer of the replicated pinned set (RS-5). | +| `RegionTag{byte}` → `PocketTag{int PocketKey}` | **SEMANTIC REWRITE, not a rename (RS-2/NR-5, ~23 sites / 15 files)** | Every `== RegionId.Base/Expedition` binary must be reclassified — see the per-site table below. | +| `RegionRelevancySystem` | **Rewrite to two layers** | Layer-A keyed off the run's single active `PocketKey` (connection-independent); Layer-B light-union + hysteresis latch. | +| `RoomTag`/`RoomTeardown` | **Rename → `PocketTag`/teardown** | Mechanism intact; per-pocket occupancy scope. | +| `RoomFieldSystem` `Hash(seed,id,sub)` | **Re-key** | `Hash(WorldSeed, PocketId, DescentSalt-or-PinnedSalt, sub)`; seed `\| 1u` guard stays. | +| ping-pong two-slot arena (`RegionMath`) | **Reuse verbatim** | `ExpeditionRoomOrigin`, +1000/500u stride — the pocket transit coordinate authority. | +| `SaveData` (+born-correct staging) | **Extend → v7** | New: `WorldSeed`, `TotalDescents`, `PinnedPocketSave[]`. Additive floor keeps v2–v6 loadable. | +| `BuildPlaceSystem` | **PATTERN-ONLY (RS-6)** | New plant system reuses the atomic withdraw+reserve idiom; NOT the base-grid/`RegionTag{Base}` machinery. | +| `BaseRestoreSystem` | **PATTERN-ONLY (DP-2/RS-6)** | New per-entry lazy replay reuses the born-correct same-ECB `Health` mechanism; NOT the boot-once/carrier-destroy/`RegionTag{Base}` lifecycle. | +| `PlayerRespawnSystem` | **REWRITE the death model (NR-2/RS-3, HIGH)** | In-pocket respawn; warp-to-station gated behind party-wipe. New guttering-suit + flame-share revive is NEW code. | +| `CombatFeedbackSystem` | **FIX the alive-prune path (NR-1)** | Gate the kill crunch on last-replicated-Health-≈0, not on the vanish edge. | +| `EnemyAISystem` | **GATE wind-up start on the light-union (NR-3)** | Movement unrestricted; commit requires lit + light-lead margin. | +| `GoalProgress` | **Reuse** | Lamp-meter readout on the untagged ghost. | +| `RoomDressingSystem` | **Re-key for pinned pockets (DP-4)** | Key on replicated `CosmeticKey` for pinned, descent seed for dark. | + +**`RegionTag → PocketTag` per-site classification (RS-2/NR-5 — audit each, this is a discrete task with a per-site checklist):** +- **(a) station-vs-not-station membership → rewrite as `IsStation`/`IsDark`:** `PlayerRespawnSystem.cs:78` (now the party-wipe branch, C4); `BuildPlaceSystem.cs:103`, `BaseRestoreSystem.cs:95` (place → station key); the `RunDirectorSystem.cs:90/203` `region == RegionId.Expedition` counts + `EnterRoom :460` `region = RegionId.Expedition` write become "in any active pocket" (`!= StationPocketKey`) / "write the specific entered pocket's key." +- **(b) same-pocket equality → keep as pure key equality, widen the type only:** `EnemyProjectileDamageSystem.cs:96`, `EnemyAIMath.PickWeightedNearest` (`EnemyAISystem.cs:149`), harvest→ledger routing (`MeleeComboSystem`, `ResourceHarvestSystem`), boss/geyser/hazard "in expedition" guards. +- **(c) siege-era branch to DELETE, not port:** the Core-fallback (`huskCoreAlive`, `EnemyAISystem.cs:126/242/385/596`, END-1) and structure-aggro (EB-1) branches inside otherwise-live systems. **Decide the END-1/EB-1 mothball cut BEFORE porting** so these branches are deleted, not generalized across N pockets. + +## Build order + +Each step ends with **`refresh_unity scope=scripts` → `read_console`** (no Burst/source-gen errors) before proceeding. Wire/bake-churn classified per step; L1 = EditMode determinism/invariant tests, L2 = Play world-creation (sort-cycle is Play-only), L3 = focused two-client Play smoke (relevancy/gamma test). **Scope note (RS-4):** this first spec is the "pinning feels like winning" half — Layer-A + seed-pin persistence + muster + FSM rename. Layer-B light-union is deferred to the roadmap's separate Phase-1 relevancy-light ★review (`Roadmap_Lantern_Slice.md:40,45`); the steps below carry it as a distinct phase so it isn't confounded with pinning. The Lamp meter (proven `GoalProgress` reuse) and session-cap timer (plain server timer) ride along at low risk — do NOT split them into their own gates. + +1. **Seed model + SaveData v7 (no ghost churn; save-format additive).** Add `WorldSeed`, `TotalDescents`, `PinnedPocketSave[]` to `SaveData` (v7, `MinLoadableVersion` unchanged). Split `RunMapMath.Generate` onto `WorldSeed`; move content re-key into `RoomFieldSystem`; bounds-harden `RunMap.NodeAt`. **L1:** DP-1 test (`Generate(WorldSeed)` byte-identical across a `DescentSalt` sweep; pinned `PocketId` same `RoomType`/`Biome` across the sweep; `BossNodeId` invariant); DP-3 test (pinned predicate = array membership, salt-0 handled); DP-6 test (two loads of the same on-disk save yield different first-descent `DescentSalt` once `TotalDescents` advanced; consecutive descents differ); DP-5 save-time invariant test (pinned set non-empty, contains station, all representable). +2. **`RegionTag → PocketTag` semantic pass (server-only component — NO ghost re-bake; per-site checklist RS-2/NR-5).** Introduce `PocketTag{int PocketKey}` + `IsStation`/`IsDark` + StationPocketKey sentinel. Reclassify all ~23 sites (a/b/c). Delete END-1/EB-1 (c) branches. **L1:** existing region tests (`RunRosterRegressionTests`, `PlayerRespawnSystemTests`, damage/aggro) re-pass under the widened type. **L2:** Play world-creation clean (no sort-cycle). +3. **Descent FSM rename + session-cap timer (ghost-field VALUE churn only — byte state values are append-only, no re-mean → no re-bake).** Rename `RunLifecycle`/`RunInfo` semantics; add descent timer. **L1:** `RunDirectorTraversalTests`/`RouteSelectSystemTests` re-pass. **L2:** Play. +4. **In-pocket death model (NR-2/RS-3 — behavior change, no wire change).** Gate `PlayerRespawnSystem` warp behind party-wipe; guttering-suit in-place respawn; revisit the `BoonOffer` `region==Expedition` gate. **L1:** new respawn test (single death keeps `PocketTag` = active key, position at pocket origin; full wipe warps all together). **L3:** two-client — one dies mid-fight, confirm they stay in the fight and the party never splits. +5. **Pinning + persistence: plant RPC + lazy per-entry restore (RPC-COLLECTION churn: new `IRpcCommand` wire type must be unconditional; ghost re-bake: new `PinnedPocketState` `[GhostField]` buffer + Lamp-meter component + scalar `[GhostField]`s on the director ghost — server+client bake the same prefab so hash matches).** New plant system (pattern from `BuildPlaceSystem`), new per-entry restore system (born-correct mechanism from `BaseRestoreSystem`, carrier survives), muster-at-deepest-reachable, `RunDirector` sole writer of the pinned set consuming the burn signal one-tick-late. **L1:** DP-2 born-correct test (pocket-entry `Health` set same-ECB); RS-5 (pinned set has exactly one writer). **L2:** Play world-creation clean (RS-5 sort-cycle guard — mandatory). **L3:** plant → quit → continue → re-descend → pinned pocket regenerates identically with deltas; unpinned dark pocket re-knots. +6. **Session-only burn + Lamp meter (server-only fuel timers + one replicated component).** Burn drains fuel, signals un-pin on full gutter honoring the loss-floor invariant. **L1:** un-pin never persists a stranded muster (DP-5); burn never fires offline. **L3:** gutter a frontier lantern, confirm station stays lit and muster resolves. +7. **[Deferred to Phase-1 relevancy-light ★review] Layer-B light-union + hysteresis + `CombatFeedbackSystem` fix + `EnemyAISystem` wind-up gate (NR-1/NR-3/NR-6).** Ghost re-bake only if the lamp-radius `[GhostField]` is added here. **L1:** NR-1 alive-prune regression (no crunch on relevancy drop; batch pocket-exit zero crunches); NR-6 latch reset across sub-slot reuse. **L3:** gamma test (nothing outside the union is on the wire); no phantom kill-crunch during a lamp sweep; no undodgeable dark strike. + +## Open risks (§implementer-watch) + +- **[NR-4 · PARTIAL] Pinned-set buffer capacity vs campaign growth.** The `[GhostField] DynamicBuffer` cap `N` must cover a persistent campaign's lit map. If pocket ids are provably small/stable within the `WorldSeed` graph a fixed bitmask is cheaper, but the campaign-growth concern makes the capped buffer the safer default. *Watch:* `PinnedPocketState` `[InternalBufferCapacity]` and the enforced cap; the `MetaTierState` precedent at `CycleDirectorSpawnSystem.cs:123-126`. Re-serializes on every pin — profile if pin frequency is high. +- **[NR-6 · PARTIAL] Hysteresis latch reset depends on "no ghost reused across pockets."** The `LitThisPocket` latch resets for free ONLY if pocket ghosts are destroyed per pocket. *Watch:* the sub-slot flip in `RunDirectorSystem.EnterRoom` (`:442`, `run.ActiveSubSlot = layer & 1`) and `RoomTeardown.DestroyRoom` — confirm no ghost survives a teardown onto the reused slot. EditMode test in step 7. +- **[DP-3 · PARTIAL] Salt-0 seed coercion choice.** `| 1u` (matches `RoomFieldSystem.cs:93`) collapses even→odd pairs; `salt == 0u ? 1u : salt` preserves the full 32-bit space. *Watch:* the pocket-content RNG-seed call site in the new restore/field path — pick one and document; do not mutate the stored `PinnedSalt`. +- **[RS-4 · PARTIAL] Scope discipline.** The falsifiable test ("pinning feels like winning") needs only steps 1–6; Layer-B (step 7) is the roadmap's separate ★review (`Roadmap_Lantern_Slice.md:40`). *Watch:* keep session-burn behind a toggle during the pinning playtest so un-pin churn doesn't confound it; do NOT fold Layer-B into the pinning spec. +- **[RS-5 · PARTIAL] Sort-cycle is Play-only.** The one-tick-late burn signal avoids an `[UpdateBefore/After]` edge, but ANY new cross-system ordering around `RunDirectorSystem`/`CyclePhaseSystem` is invisible to EditMode. *Watch:* `RunDirectorSystem.cs:32-35` — Play-validate world creation after steps 3, 5, 6 regardless of green tests. +- **[RS-6 · PARTIAL] Lantern placement granularity is unresolved.** Free-position plant vs build-grid cell decides how much of `BuildPlaceSystem` transfers. *Watch:* resolve BEFORE scoping step 5; if free-position, only the atomic withdraw+persist idiom (`BuildPlaceSystem.cs:87-90`) transfers, not `CanPlace`/occupancy. +- **[NR-3 open fork] Dark-side contact policy.** Whether the dark deals non-telegraphed chip or nothing at all is a feel call. *Watch:* `EnemyAISystem.cs:189-206` (commit) and `:221/:362/:478` (wind-up starts) — the gate goes on wind-up START; the chip path (if adopted) is a separate non-telegraphed damage event. +- **[C1/DP-6 residual] Crash before save loses descent-counter advance.** Accepted for the slice (dark repeats only after a hard kill). *Watch:* if the fiction must survive crash, add save-on-launch persisting the incremented `TotalDescents` at the launch edge (`RunDirectorSystem.cs:106-123`). + +## Findings ledger + +| ID | Lens | Sev | Verdict | Resolution (spec section) | +|---|---|---|---|---| +| NR-1 | netcode | MED | CONFIRMED | LOCK once-lit hysteresis + FIX `CombatFeedbackSystem` alive-prune on last-Health-≈0 + regression test — C3 | +| NR-2 | netcode | HIGH | CONFIRMED | In-pocket death; warp-to-station gated behind party-wipe; flame-share revive is new code — C4 | +| NR-3 | netcode | MED | CONFIRMED | Gate enemy wind-up START on the light-union + light-lead margin; movement free; dark = chip fork — C3 | +| NR-4 | netcode | MED | PARTIAL | Pinned set = `[GhostField]` buffer (`MetaTierState` idiom) + scalar deepest-pin; lamp radius already server-authoritative — C3/C7 | +| NR-5 | netcode | MED | CONFIRMED | `RegionTag→PocketTag` = audited semantic pass, disjoint StationPocketKey, per-site a/b/c — Salvage map | +| NR-6 | netcode | LOW | PARTIAL | Server-only `LitThisPocket` latch, resets on per-pocket ghost destroy; confirm no reuse + test — C3/Open risks | +| DP-1 | determinism | HIGH | CONFIRMED | `Generate` seeded by `WorldSeed` ONLY; `DescentSalt` never a graph input; bounds-harden `NodeAt` — C1 | +| DP-2 | determinism | HIGH | CONFIRMED | Per-entry lazy restore (not boot-once); reuse born-correct same-ECB `Health` mechanism, carrier survives — C6 | +| DP-3 | determinism | HIGH | PARTIAL | Pinned predicate = array membership; `\|1u`/`==0?1u` at seed site; never `TickUtil.NonZero` the salt — C1 | +| DP-4 | determinism | MED | CONFIRMED | Replicate a `CosmeticKey` per pinned pocket; client dressing keys on it (fallback descent seed) — C7 | +| DP-5 | determinism | MED | CONFIRMED | Station un-gutterable + muster = deepest reachable-from-station; save-time invariant + test — C2 | +| DP-6 | determinism | MED | CONFIRMED | Persist `TotalDescents` (per-launch, both save paths); seed lineage from it at restore — C1 | +| RS-1 | reuse | HIGH | CONFIRMED | `RunMapMath` NOT salvageable as-is; split spine (`WorldSeed`) from content (`DescentSalt`/`PinnedSalt`) — C1/Salvage map | +| RS-2 | reuse | MED | CONFIRMED | ~23 binary `RegionId` sites in 15 files = semantic rewrite w/ per-site checklist — Salvage map | +| RS-3 | reuse | MED | CONFIRMED | Respawn/participant path NOT free salvage; in-pocket respawn + revisit `BoonOffer` gate — C4 | +| RS-4 | reuse | MED | PARTIAL | Scope first spec to pinning (A + persistence + muster + FSM); defer Layer-B to Phase-1 review — Build order | +| RS-5 | reuse | MED | PARTIAL | Pinned set/Lamp = separate components; burn signals server-only; `RunDirector` sole writer, one-tick-late, no ordering edge — C6 | +| RS-6 | reuse | LOW | PARTIAL | BuildPlace/BaseRestore = pattern-only; new systems, pocket coords, per-pocket occupancy; resolve plant granularity — C6/Open risks | diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-049_Lantern_World_Model_Design.md b/Docs/Vault/07_Sessions/_Decisions/DR-049_Lantern_World_Model_Design.md new file mode 100644 index 000000000..8c0db7b37 --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-049_Lantern_World_Model_Design.md @@ -0,0 +1,83 @@ +--- +id: DR-049 +title: LANTERN world-model design — pocket-graph + seed-pinning + light-relevancy (review-hardened) +status: accepted +date: 2026-07-13 +tags: [decision, lantern, netcode, world-model, phase-0, review] +permalink: gamevault/07-sessions/decisions/dr-049-lantern-world-model-design +--- + +# DR-049 — LANTERN world-model design (review-hardened) + +## Context + +Phase 0.3 of [[Roadmap_Lantern_Slice]] (the ★review-first world-model spike, framed in +[[Lantern_World_Model_Spike]]) is the slice's hardest netcode problem: a seed-pinned **pocket-graph** where +**light is territory** (what you light persists; the dark re-knots), enforced across 2–4 co-op clients under +server authority + client prediction + a per-player light-based information radius. Two operator forks were +locked first: **co-located party (one active pocket)** and **party light-union relevancy (Wicks always +visible)**. A design proposal ([[Lantern_World_Model_Design_Proposal]]) was written from three read-only +ground-truth passes (relevancy/lifecycle/save) + ctx7 on `GhostRelevancy`/`GhostImportance`, then put through +the standing adversarial design-review ritual **before any world code**. + +## Decision + +**Adopt the review-hardened world model in [[World_Model_Build_Spec]] as the Phase 0.3 gate output and the +authority for Phase 2 world code.** No world code is written outside that spec. + +The design review (`wf_aa6fb661-e25`; 22 agents, **0 failures/quota-deaths** — findings verified real, not the +masquerading-empty trap) ran three lenses (netcode/relevancy · determinism/prediction · reuse/scope) with +refute-first critics and raised **18 findings — 12 CONFIRMED, 6 PARTIAL, 0 refuted.** It did its job: it +caught design-breakers a green-tests-and-clean-Play pass would have missed. Verdict: + +- **Netcode — pass with changes.** Hard requirements folded in: in-pocket death (NR-2), the `CombatFeedbackSystem` + alive-prune phantom-kill fix (NR-1), enemy wind-up gated on the light-union (NR-3). +- **Determinism — needed rework (3 HIGH), now re-specified.** The proposal's seed model was internally + contradictory (DP-1/RS-1) and its persistence-lifecycle citation was wrong (DP-2). Re-specified in the Build + Spec's C1/C6 — these were save-corruption / soft-lock class. +- **Reuse — needed rework on graph generation.** `RunMapMath` is **not** salvageable as-is (RS-1); the + `RegionTag→PocketTag` change is a **semantic rewrite over ~23 sites in 15 files**, not a rename (RS-2). + +**The load-bearing reworks (now locked in the Build Spec):** +1. **Seed model split (DP-1/RS-1):** the pocket **graph topology is a pure function of `WorldSeed` ONLY** and + is byte-identical across every descent; `DescentSalt` re-knots only per-pocket **content**; **pinning = + freeze a per-pocket `PinnedSalt`**. A pocket is pinned **iff it has a `PinnedPocketSave[]` row** (never a + `salt != 0` test — salt can be 0). `DescentSalt` lineage must be **persisted** (`TotalDescents`, v7) or the + dark repeats across quit+continue (DP-6). +2. **In-pocket death (NR-2/RS-3):** `PlayerRespawnSystem`'s warp-to-base + `RegionTag=Base` flip is **not** + ported unchanged — it would split the co-located party. A downed Wick stays in the active pocket (flame- + share revive reachable); warp-to-station is gated behind a **party-wipe** check. The co-op death/revive + loop is **new code, not salvage.** +3. **Two hard-cull relevancy layers:** Layer-A pocket-occupancy keyed off the run's **single active + `PocketKey`** (connection-independent), with a **disjoint StationPocketKey sentinel** (station must not + alias `NodeId(0,0)=0`); Layer-B party light-union with a **locked once-lit hysteresis latch** + the NR-1 + fix + the NR-3 wind-up gate. +4. **Persistence = per-entry lazy replay (DP-2), not a boot one-shot** — reuse only `BaseRestoreSystem`'s + born-correct same-ECB `Health` mechanism, not its lifecycle. `BuildPlaceSystem`/`BaseRestoreSystem` are + **pattern-only** salvage (RS-6). +5. **Scope discipline (RS-4):** this spec is the *"pinning feels like winning"* half (Layer-A + persistence + + muster + FSM rename). Layer-B light-union is deferred to the roadmap's separate **Phase-1 relevancy-light + ★review** so it isn't confounded with pinning. + +## Consequences + +- **Phase 0.3 gate:** the world-model design review has **passed with a re-specified design** (the Build + Spec). Combined with DR-048 (adoption) + the A0 style-proof scene (still open), this closes 0.3's design + half; the in-engine A0 gate remains open. +- **No re-bake / no code yet.** This is design only. Phase 2 world code follows the Build Spec's build order + (each step ends compile+`read_console`; wire/bake churn classified per step; Play-validate world creation + for sort-cycles regardless of green tests — RS-5). +- **New operator forks surfaced** (parked in [[Lantern_Operator_Questions]]): the **dark-side contact policy** + (non-telegraphed chip vs nothing — NR-3), **lantern placement granularity** (free-position vs build-grid — + RS-6), and the **END-1/EB-1 mothball timing** (decide before the `RegionTag` semantic pass so the Core- + fallback / structure-aggro branches are deleted, not generalized — RS-2 site class (c)). +- **Revisit when:** implementation surfaces a contradiction with the spec (loop back to a mini-review), or the + Phase-3 pinning playtest fails its falsifiable test. + +## Related + +- [[World_Model_Build_Spec]] — the durable contract this DR accepts (all 18 findings folded) +- [[Lantern_World_Model_Design_Proposal]] — the pre-review proposal (superseded) +- [[Lantern_World_Model_Spike]] — the ★review-first brief · [[Roadmap_Lantern_Slice]] Phase 0.3 / Phase 2 +- [[DR-048_Lantern_Adoption_Full_Pivot]] — the adoption · [[Lantern_Strip_Mothball_Inventory]] — salvage +- [[validate-netcode-design-before-coding]] — the ritual that caught these before code