Build Phase 1 gym first; standing ★-review auth; build A0 material foundation now; delete siege branches. Phase 1: manual aim (auto-target cut), 4 discrete ability buttons, Harpooner yanks target-to-you, roadmap 5 Sparks. Fresh save epoch (no migration). Dark-side contact = non-telegraphed chip. Lantern = free-position plant. Locked into: operator-questions (resolved summary), Phase 1 spec (open forks -> resolved), currency spec (fresh saves), world-model Build Spec (operator-resolutions block). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
43 KiB
title, date, tags, status, permalink
| title | date | tags | status | permalink | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| LANTERN — World-Model Build Spec (review-hardened, Phase 0.3 gate) | 2026-07-13 |
|
review-hardened | 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).
Operator resolutions (2026-07-14)
The open forks this spec surfaced are now decided (Lantern_Operator_Questions):
- NR-3 dark-side contact → non-telegraphed low chip/grab. Enemies in the dark may deal minor chip; a full skillshot-grade burst is only owed for a LIT (telegraphed) strike. The wind-up-START gate on the light-union stands; the dark-bite chip is a separate, non-telegraphed damage event.
- RS-6 lantern placement → free-position plant. Only the atomic withdraw+persist idiom transfers from
BuildPlaceSystem(notCanPlace/occupancy); a lantern plants at any valid pocket position. - END-1/EB-1 (RS-2 site-class c) → confirmed mothballed: DELETE the branches. The
RegionTag→PocketTagpass removes the Core-fallback + structure-aggro branches fromEnemyAISystemetc., not generalizes them. - Saves → fresh epoch (no migration; see Lantern_Currency_ReMean). SaveData v7 is additive over a fresh LANTERN save, not a co-op-Hades migration.
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):
- Light-as-information = hard relevancy.
SetIsIrrelevantnever serializes an out-of-set ghost; untagged/global ghosts stay relevant for free (RegionRelevancySystem.cs:9-17). This is the correct and only mechanism. - Global shared state rides the untagged director ghost.
RunInfo/GoalProgress/CycleStateare already spawned MINUSRegionTag(CycleDirectorSpawnSystem.cs:10-13) → relevant cross-pocket for free. The Lamp meter, descent-info scalars, and pinned-set all belong here. - Pinning = freeze the content salt + save deltas. Generation is a pure function; only player mutation is stored (
RoomFieldSystemHash(seed,nodeId,sub)+BaseRestoreSystemborn-correct replay). The tide-line drops out of this cleanly. RunDirectorSystemstays 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.- 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). GhostImportanceis NOT needed for the slice.O(ghosts × partySize)with capped ghost counts (~≤40) is trivial. Held in reserve for bandwidth only.- 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
CombatFeedbackSystemalive-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 —
RunMapMathcannot be salvaged as-is).RegionTag→PocketTagis 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.GeneratetheWorldSeed, never a per-descent salt. Rewrite proposal lines 46 & 61 accordingly ("the graph is generated fromWorldSeedand is stable across descents; only per-pocket content re-knots viaDescentSalt"). - Only per-pocket CONTENT (
RoomFieldSystemscatter, enemy roster, harvest) re-knots viaDescentSalt; a pinned pocket uses its frozenPinnedSaltinstead. Content depends on room type, so this is only sound because the type is nowWorldSeed-stable. - Harden the resolver:
RunMap.NodeAt(pinnedNodeId)(RunMap.cs:100) must bounds/width-check (reject nodeId >= Nodes.Lengthandcol >= Width(layer)) so a futureMaxLayers/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
PocketIdis pinned iff it has a row inPinnedPocketSave[]— state this explicitly;PinnedSalt-value is NEVER the predicate. - At the pocket-content RNG-seed call site apply the existing
| 1uconvention (RoomFieldSystem.cs:93seedsnew Random(Hash(...) | 1u)) — orsalt == 0u ? 1u : saltif full-space collision-freeness matters — so a frozen salt of exactly 0 can't produce a degenerateUnity.Mathematics.Random. Never mutate the stored salt. - Do NOT route
PinnedSaltthroughTickUtil.NonZero— it is a "0 = ready" tick sentinel, not a seed guard;NonZero(0)=1would collide the salt-0 pocket with the salt-1 pocket's content.TickUtil.NonZerostays 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 everyStaging→Launchinglaunch (RunDirectorSystem.cs:106-123), NOT gated on boss-clear likeRunsCompleted. - Persist it from both save paths (the boss-clear autosave in
SaveWriteSystem.cs, AND the quit-to-menu writer via the sharedMetaSaveScancollector). - At restore (
CycleDirectorSpawnSystem), seed the descent lineage fromTotalDescents(e.g.RunRuntime.RunEpoch = TotalDescentsand/orHostSalt = Hash(0x5EED0001u, (uint)TotalDescents)), so the next descent's salt is distinct across sessions. - Keep the re-knot
DescentSaltfold DISJOINT from any livePinnedSalt(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
PocketIdreachable from the station in theWorldSeedgraph (BFS viaRunMapMath.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
PocketIdis 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 explicitPocketIdSET, 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 pinnedPocketIdis representable (col < Width(layer),layer < LayerCount) in theWorldSeedgraph — 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, andNodeId(0,0) = 0collides with today'sRegionId.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. IntroduceIsStation(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
CombatFeedbackSystemalive-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 > 0leaving_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 fromWorldFeedbackSystem.cs:118-130is 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
Healthquery to simulate a relevancy drop, tick the presentation system, assert no kill cue (noTryHold/AddShake(KillShake)/PunchFov(KillFovKick)). Also cover the batch pocket-exit drop of N live enemies asserting zero crunches.
- An alive-vanish (cached
-
NR-3 — gate enemy WIND-UP COMMIT on the light-union, not just movement.
AttackWindup.WindUpUntilTickis 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 — yetEnemyAISystemstill commits the wind-up and appends aDamageEventon server range alone (Grunt strike commitEnemyAISystem.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.
- 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
-
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 rideRunInfo's scalar-only pattern. Model it as a bounded[GhostField] IBufferElementDataon the untagged director ghost, copying theMetaTierStateidiom exactly (CycleDirectorSpawnSystem.cs:123-126):PinnedPocketState { [GhostField] int PocketKey; [GhostField] uint CosmeticKey /*see DP-4*/ },[InternalBufferCapacity(N)]with an enforced hard cap, noOwnerSendType(ownerless interpolated), restored born-correct at spawn viaecb.SetBufferfrom persistedPinnedPocketSave[]. Re-serializes on every pin. Split concerns: the muster point + common HUD only need a single scalar[GhostField](deepest-pinnedPocketKey) which CAN live on theDescentInfoscalar 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.csdocstring). 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
PocketTagflip (Dead-gated movement/aim/fire already handled byPlayerDeathStateSystem). 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 theexpeditionPlayers==0→Returning/Ascendingpath (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 ANDPocketTag = activePocketKeytogether (never the station key). - Re-audit the downstream coupling that DEPENDS on dead players leaving the expedition: the
RoomReward/BoonOfferexit gate filtersregion == RegionId.Expeditionspecifically 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 (
PlayerRespawnSystemsends dead players to the base ring;EnterRoomonly re-teleports on the NEXT advance,RunDirectorSystem.cs:457-466; late joiners are never conscripted mid-run,:144-149).RunParticipantconscription/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
RunParticipantconnection, 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→EnterRoomteleport,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
uinttick viaTickUtil.NonZero) that forces the Bell recall. - EnterPocket = today's
EnterRoom: publish the node as single plan authority, flip the ping-pong sub-slot, bumpRoomEpoch, teleport participants (Positionwrite in place, neverFromPosition). 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:
BuildPlaceSystemresolves cells viaBaseGridMathonBaseAnchorand bakesRegionTag{Base}(:94,:103);BaseRestoreSystemreplays one-shot at load,RequireForUpdate-gated onPendingStructure+StructureCatalog+BaseAnchor(:36-39), hardcodesRegionTag{Base}(:95) andCellToWorldbase 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); thePocketTag{PocketKey}tag, neverRegionTag{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 ALLPlacedStructureghosts (: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 theCanPlace/occupancy machinery transfers too). -
DP-2 — pinned-pocket RESTORE is per-entry LAZY replay, NOT a boot one-shot; do not cite
BaseRestoreSystemas the lifecycle template. Reuse only its born-correct MECHANISM: a staging carrier holdingPinnedPocketSave[]staged at spawn, andHealth.Currentset in the SAMEEntityCommandBufferasInstantiate(BaseRestoreSystem.cs:87-94— a deferred set leaks bakedMaxto 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 onPocketId. At boot the party isAtStation; 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), tagPocketTag{PocketKey}, setHealthborn-correct same-ECB. - The carrier is NOT destroyed after one replay — it survives to re-replay on each subsequent entry. Key delta rows per
PocketIdso entry N replays only that pocket's rows.
- Restore fires inside
-
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 (RoomEnemyDirectorSystemwritesExpeditionObjectiveon 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, mirroringPortalCommand/RouteCommand).RunDirectorSystem— the FSM, which also performs muster — is the SOLE writer of the replicated pinned set, consuming that signal one-tick-late (likeExpeditionObjective). 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 aroundRunDirectorSystemorCyclePhaseSystem— 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, activePocketId,DescentSalt-derived descent seed the client regenerates the graph + dark-pocket dressing from, deepest-pinnedPocketKey(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. CarriesPocketKey+ 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:68keys dressing offri.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 frozenPinnedSalt(or a derived cosmetic hash) as thePinnedPocketState.CosmeticKey[GhostField]; client dressing keys on THAT for pinned pockets, falling back to the descent seed for dark pockets. Reconcile §6's "server-onlyPinnedSalt" 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); theRunDirectorSystem.cs:90/203region == RegionId.Expeditioncounts +EnterRoom :460region = RegionId.Expeditionwrite 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.
- Seed model + SaveData v7 (no ghost churn; save-format additive). Add
WorldSeed,TotalDescents,PinnedPocketSave[]toSaveData(v7,MinLoadableVersionunchanged). SplitRunMapMath.GenerateontoWorldSeed; move content re-key intoRoomFieldSystem; bounds-hardenRunMap.NodeAt. L1: DP-1 test (Generate(WorldSeed)byte-identical across aDescentSaltsweep; pinnedPocketIdsameRoomType/Biomeacross the sweep;BossNodeIdinvariant); DP-3 test (pinned predicate = array membership, salt-0 handled); DP-6 test (two loads of the same on-disk save yield different first-descentDescentSaltonceTotalDescentsadvanced; consecutive descents differ); DP-5 save-time invariant test (pinned set non-empty, contains station, all representable). RegionTag → PocketTagsemantic pass (server-only component — NO ghost re-bake; per-site checklist RS-2/NR-5). IntroducePocketTag{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).- 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/RunInfosemantics; add descent timer. L1:RunDirectorTraversalTests/RouteSelectSystemTestsre-pass. L2: Play. - In-pocket death model (NR-2/RS-3 — behavior change, no wire change). Gate
PlayerRespawnSystemwarp behind party-wipe; guttering-suit in-place respawn; revisit theBoonOfferregion==Expeditiongate. L1: new respawn test (single death keepsPocketTag= 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. - Pinning + persistence: plant RPC + lazy per-entry restore (RPC-COLLECTION churn: new
IRpcCommandwire type must be unconditional; ghost re-bake: newPinnedPocketState[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 fromBuildPlaceSystem), new per-entry restore system (born-correct mechanism fromBaseRestoreSystem, carrier survives), muster-at-deepest-reachable,RunDirectorsole writer of the pinned set consuming the burn signal one-tick-late. L1: DP-2 born-correct test (pocket-entryHealthset 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. - 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.
- [Deferred to Phase-1 relevancy-light ★review] Layer-B light-union + hysteresis +
CombatFeedbackSystemfix +EnemyAISystemwind-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] DynamicBuffercapNmust cover a persistent campaign's lit map. If pocket ids are provably small/stable within theWorldSeedgraph a fixed bitmask is cheaper, but the campaign-growth concern makes the capped buffer the safer default. Watch:PinnedPocketState[InternalBufferCapacity]and the enforced cap; theMetaTierStateprecedent atCycleDirectorSpawnSystem.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
LitThisPocketlatch resets for free ONLY if pocket ghosts are destroyed per pocket. Watch: the sub-slot flip inRunDirectorSystem.EnterRoom(:442,run.ActiveSubSlot = layer & 1) andRoomTeardown.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(matchesRoomFieldSystem.cs:93) collapses even→odd pairs;salt == 0u ? 1u : saltpreserves 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 storedPinnedSalt. - [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 aroundRunDirectorSystem/CyclePhaseSystemis 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
BuildPlaceSystemtransfers. Watch: resolve BEFORE scoping step 5; if free-position, only the atomic withdraw+persist idiom (BuildPlaceSystem.cs:87-90) transfers, notCanPlace/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
TotalDescentsat 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 |