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) <noreply@anthropic.com>
**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).
- **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 |
**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.
-`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.
**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) |
- **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)
| `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. |
| `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 |
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.