Run Re-Do
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
---
|
||||
id: SPEC-Expedition-Redesign
|
||||
title: Expedition Redesign — Build Spec (multi-room co-op runs, ready-check, choice-of-3 boons, expedition-only economy)
|
||||
status: pending-operator-approval
|
||||
date: 2026-06-29
|
||||
tags:
|
||||
- spec
|
||||
- design
|
||||
- expedition
|
||||
- roguelite
|
||||
- netcode
|
||||
- procgen
|
||||
- reward
|
||||
- economy
|
||||
- north-star
|
||||
permalink: gamevault/07-sessions/2026/2026-06-29-expedition-redesign-build-spec
|
||||
---
|
||||
|
||||
# Expedition Redesign — Build Spec
|
||||
|
||||
> **Provenance.** Synthesized + adversarially reviewed by the design workflow `wf_5fc54784-1dc` (7 ground digests → 3 candidate architectures → merge → netcode/determinism/reuse lens review → adversarial critic → this spec). Every CONFIRMED must-fix from the critique (F1/C8 sort-cycle, N2 cross-arena aggro, N1 boon send-type, F2 launch guard, F7 double-credit latch, F6 seed stability, C7 dropped retaliation writer, F4 tick-wrap, C1/C2 teleport-reuse + coordinate authority) is **already folded into the architecture below** — no known defect remains in-spec.
|
||||
>
|
||||
> **Status: PENDING operator approval + the §6 fork answers.** Build one system at a time, each fully validated before the next (operator directive: NO minimal vertical slice — full depth, completeness over speed). Supersedes the DR-040 "defer layout/theme/depth to v2" framing; consistent with [[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]] and [[DR-042_Loop_Reshape_Expedition_Driven]] (the win-spine is reused, only the credit event moves to run completion). A DR locking this will be filed on approval.
|
||||
|
||||
Unity 6.5 DOTS (Entities) + Netcode for Entities · ProjectM. Server-authoritative, input-only clients.
|
||||
|
||||
---
|
||||
|
||||
## 1. NORTH STAR — the target loop
|
||||
|
||||
The base is a pure SPEND hub (build + upgrade); **no resource nodes spawn at base**. Players gather at a gate and each toggle READY; when **all** players in the session are ready the **whole party launches together** into one shared, seed-generated **multi-room run** (combat / elite / reward / boss rooms; depth and difficulty escalate; shape, layout, and biome vary per run). Clear a room to advance; on each clear every player independently picks **1 of 3** random ability/stat upgrades (Hades-boon shaped, per-run). Resources are **scarce and run-capped** — hauled home and spent at base. Clearing the run's **boss** banks +1 toward the win meter (the existing `GoalProgress`/`GoalReached`/`RunOutcome` win-spine is untouched — only the credit event moves to run completion). The run has a **discrete start and end**; the base never resets.
|
||||
|
||||
---
|
||||
|
||||
## 2. ARCHITECTURE
|
||||
|
||||
### 2.1 Region model — ONE `RegionId.Expedition`, sequential sub-arenas, teardown-before-spawn (DECISIVE)
|
||||
|
||||
The run is a sequence of rooms inside the **single existing `RegionId.Expedition` region**, not N region ids. Only the **active** room is materialized; the previous room is torn down before the next is spawned.
|
||||
|
||||
**Why (against the five axes the open question named):**
|
||||
- **Relevancy cost (decisive).** `RegionRelevancySystem` is `O(region-tagged-ghosts × in-game-connections)`, rebuilt fully every tick with no spatial index or dirty-tracking. The cost lever is *how many region-tagged ghosts are alive at once*, not how many region ids exist. Materializing only the active room keeps the live ghost count at one room's `MaxAlive` budget whether the run is 2 rooms or 12. N-region-ids buys nothing: pre-spawning future rooms multiplies cost by depth; lazy-spawning makes the extra ids dead weight while forcing `RegionMath` to grow a per-id origin table that collides past 2.
|
||||
- **Determinism.** Region id never changes mid-run → no per-region epoch-latch fan-out. One scalar `RunEpoch`/`RoomEpoch` latch, equality-compared.
|
||||
- **Co-op.** One region = one relevancy bucket = the whole party always sees the same active room (locked direction #2: one shared party run).
|
||||
- **Save/restore.** Nothing region-shaped is serialized; the run instance is session-scoped and explicitly not persisted.
|
||||
- **Mid-run join/disconnect.** A client is either in the Expedition bucket or not — no "which of N regions" bookkeeping. Late joiners stay at base; falls out of the existing split with zero new machinery.
|
||||
|
||||
**Sub-arena origins (single coordinate authority — fixes C2).** There is exactly ONE expedition-origin function. `RegionMath.ExpeditionOrigin(baseCenter)` (today's `RegionOrigin(Expedition, ...)`) is **redefined to take the active sub-slot from the global `RunRuntime`**, and every existing call site is repointed to it; no parallel `RoomOrigin` signature is introduced (split-authority hazard). Concretely:
|
||||
```
|
||||
RegionMath.ExpeditionRoomOrigin(baseCenter, subSlot) = baseCenter + (ExpeditionOffsetX + subSlot * RoomStrideX, 0, 0)
|
||||
ExpeditionOffsetX = 1000f ; RoomStrideX = 500f // >= any sweep/AI range
|
||||
RegionMath.ExpeditionOrigin(baseCenter) = ExpeditionRoomOrigin(baseCenter, currentActiveSubSlot) // sole call for "where is the expedition now"
|
||||
```
|
||||
Two ping-pong sub-slots (`subSlot = CurrentRoom & 1`) is the minimum that allows a clean handoff. **Ordering is teardown-before-spawn with one empty tick** (fixes N2, dissolves F3, shrinks F11): on a room advance the cleared room is destroyed first; the next room spawns the following tick at the idle slot; the party teleports onto it. Because only one room's enemies ever exist, the cross-arena-aggro window does not occur.
|
||||
|
||||
**Cross-arena AI safety (defense in depth for N2).** Even with teardown-before-spawn, `EnemyAISystem`'s region-aware `PickWeightedNearest` filters only on `RegionTag.Region` and has no distance cap — both sub-arenas are `Expedition`. Because we never let two rooms coexist, the AI cannot see across the gap. The `RoomTag` (server-only) is additionally available to plumb into the AI target snapshot if a future change ever lets rooms overlap; v1 does not rely on it for aggro isolation.
|
||||
|
||||
### 2.2 Run lifecycle + ready-check
|
||||
|
||||
The run lifecycle is a **server-decided, client-observed `byte` FSM** on the existing **global untagged CycleDirector ghost** (the exact precedent of `RunOutcome`/`GoalProgress`/`CoreIntegrity` — untagged ⇒ relevant to every connection cross-region for free). It is **distinct from `CycleState.Phase`** (Calm↔Siege stays the *base* posture machine for retaliation/final sieges). Single writer = new `RunDirectorSystem`, mirroring how `CyclePhaseSystem` is the sole `Phase` writer.
|
||||
|
||||
**State machine** (`RunLifecycle : byte`):
|
||||
```
|
||||
Staging=0 → Launching=1 → InRoom=2 → RoomReward=3 → Returning=4 → Staging
|
||||
```
|
||||
- **Staging** — party in the base hub; ready-check active; no expedition ghosts exist.
|
||||
- **Launching** — one-tick transient on the all-ready rising edge: chooses `RunSeed`, bumps `RunEpoch`, plans `RoomCount`, sets the launch countdown telegraph; on countdown elapse teleports the party to room-0 and flips `RegionTag` to Expedition.
|
||||
- **InRoom** — active room populated; party fighting/looting; `CurrentRoom` is the depth.
|
||||
- **RoomReward** — room cleared; per-player boon offers pushed; room ghosts torn down; waiting on all surviving players to pick (or the grace backstop).
|
||||
- **Returning** — run ended (boss cleared, party wiped, or all expedition players disconnected): teleport home, flip `RegionTag` to Base, bank the win meter + retaliation inputs + meta **once per RunEpoch**, request save, clear all ready flags → Staging.
|
||||
|
||||
**Cross-FSM launch guard (fixes F2).** The `Staging→Launching` edge is refused while `RunPhase != Normal` OR `RunOutcome != InProgress` — a new run cannot launch while a final siege is arming or after the run outcome has latched.
|
||||
|
||||
**Ready-check derivation (no connection-count singleton exists).**
|
||||
```
|
||||
total = CalculateEntityCount(PlayerTag); ready = count(PlayerReady.Value != 0);
|
||||
allReady = total > 0 && ready == total;
|
||||
```
|
||||
Computed each Staging tick by `RunDirectorSystem`. On the `allReady` rising edge (latch `wasAllReady` in `RunRuntime`) → `Staging→Launching`, set `LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks)`. Un-readying during Launching reverts to Staging and clears `LaunchTick`. Single-player (`total==1`) works trivially. The N/M count is load-bearing on the Staging-only co-location invariant — documented in code (fixes N7).
|
||||
|
||||
**Disconnect / join.**
|
||||
- *Disconnect while staging* — player ghost (+`PlayerReady`) despawns via the connection's `LinkedEntityGroup`; `total` drops; can correctly complete the check.
|
||||
- *Disconnect mid-run* — in-room ghost despawns; `RunDirectorSystem` recomputes the Expedition-region player count; if zero → force `Returning` (clean abort, per-room meta already banked). Safety teardown fires whenever `Lifecycle` leaves `InRoom`.
|
||||
- *Join mid-staging* — spawns at base, `Value=0`, bumps `total`, resets `allReady` (a late joiner is not dragged into an in-flight launch).
|
||||
- *Join mid-run* — closed party (locked #2). Joiner spawns at base in `RegionId.Base`; relevancy hides the run's ghosts; the global `RunInfo` shows a run in progress; HUD shows "Run in progress" and suppresses READY until `Lifecycle==Staging`. The **1-of-N-drops co-op-abandonment case is an explicit OPEN FORK** (§6), not silently "handled".
|
||||
|
||||
### 2.3 Multi-room generation + traversal
|
||||
|
||||
**Per-run seed → per-room layout (the procgen discipline reused verbatim).** `RunRuntime.RunSeed` is chosen once at the `Staging→Launching` edge: `RunSeed = math.max(1u, Hash(RunEpoch, HostSalt))` — never the tick, never 0, monotonic-int + equality-compared. `HostSalt` is a server-incrementing seed source seeded at director spawn from a non-tick source and bumped each run (explicit, fixes C5). Per-room RNG derives with distinct offsets (the `epoch*2+1` trick generalized):
|
||||
```
|
||||
roomSeed = Hash(RunSeed, roomIndex) | 1u // shape/biome/archetype
|
||||
nodeSeed = Hash(RunSeed, roomIndex, 0x0DE) | 1u // resource scatter
|
||||
enemySeed = Hash(RunSeed, roomIndex, 0xEEE) | 1u // enemy jitter (composition stays pure-int via ZoneEnemyMath)
|
||||
boonSeed = Hash(RunSeed, roomIndex, playerSlot)| 1u // per-player boon draw (stable PlayerSlot, NOT NetworkId — fixes F6)
|
||||
```
|
||||
|
||||
**`RoomLayoutMath`** (new pure math, the `ZoneEnemyMath` sibling — no RNG state, EditMode-tested):
|
||||
- `RoomPlan Plan(uint runSeed, int roomIndex, int roomCount, BlobAssetReference<RoomArchetypeBlob> table)` → `RoomPlan { byte RoomType; byte Biome; byte ShapeId; float Radius; int NodeCount; int DifficultyEpoch; }`.
|
||||
- `float3 ScatterInShape(byte shapeId, float3 center, int index, int count, ref Random rng)` — adds rect/polygon variants to today's disk/annulus-only helpers; reuses `EnemyAIMath.RingPosition`/`ClusterOffset` for spawn anchors.
|
||||
- `void PickBoons(uint offerSeed, byte classId, in BoonCatalogBlob pool, out byte o0, out byte o1, out byte o2)` — 3 distinct, rarity-weighted, class-filtered, rejection-sampled.
|
||||
- **Room-type arc** is deterministic from `(runSeed, roomIndex, roomCount)`: room 0 = always Combat; last room = always Boss; one Elite at ~⅔ depth; Reward rooms interleaved by hash.
|
||||
- **Shape/cover/biome** from a baked `BlobAssetReference<RoomArchetypeBlob>` (authored `RoomArchetypeDefinition` SOs → config singleton, both worlds, NOT replicated — the `AbilityDatabaseBlob` pattern).
|
||||
|
||||
**Room population (re-point the existing field/wave chassis from `ExpeditionEpoch` to `RoomEpoch`).**
|
||||
- **`RoomFieldSystem`** (refactor of `ExpeditionFieldSystem`): scatter `RoomPlan.NodeCount` scarce nodes (§2.4) on the `RoomEpoch` change edge (equality compare `LastSpawnedRoomEpoch != RoomEpoch`) at `RegionMath.ExpeditionRoomOrigin(base, ActiveSubSlot)` via `ScatterInShape` + `nodeSeed`. Tag `RegionTag{Expedition}` + `RoomTag{Room}`. Preserve baked Scale with `baked.WithPosition` (never `FromPosition`). Per-`RoomTag` teardown.
|
||||
- **`RoomEnemyDirectorSystem`** (refactor of `ZoneEnemyDirectorSystem`): reseed on `SeededRoomEpoch != RoomEpoch`; reuse `ZoneEnemyMath.WaveSlots/KindForSlot` verbatim, indexed by `RoomPlan.DifficultyEpoch` (a function of `CurrentRoom` and `RoomType`; Elite/Boss bump the band). Reuse the drip-spawn cadence + the `MaxAlive` "spawn-pack-only-if-fits-else-wait" guard. A Boss room spawns one high-HP boss. `ExpeditionObjective` (replicated, untagged ghost) written **above the early-return** so the HUD never freezes. Tag spawns `RoomTag{Room}`.
|
||||
|
||||
**Per-room teardown tag (the DR-031/DR-040 shared-tag-wipe hazard).** `RoomTag : IComponentData { byte Room; }` — server-only, NOT a `[GhostField]`. Stamped on every room-scoped ghost at spawn (`Room = CurrentRoom & 0xFF`). Teardown of room *i* filters on `RoomTag.Room == i` so a transiently-coexisting room is never wiped.
|
||||
|
||||
**Clear → advance (the loop).** Clear condition reuses the existing "real clear" predicate, surfaced through the existing `ExpeditionObjective.State == Cleared` (the enemy director already computes this edge — no separate `RoomClearSystem`/`RoomCleared` byte; collapse per C4). `RunDirectorSystem` consumes it as the sole lifecycle writer:
|
||||
1. `InRoom` + objective Cleared → `InRoom→RoomReward`; trigger boon offers; **tear down the cleared room** (`RoomTag`-filtered); set `RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks)`.
|
||||
2. `RoomReward` + (all surviving players `BoonOffer.Pending==0` **OR** `RewardGraceTick` elapsed, tested via `IsNewerThan` — fixes F4) →
|
||||
- if last room (Boss) → `RoomReward→Returning`;
|
||||
- else advance one tick later (the cleared room is already gone): bump `CurrentRoom`, flip `ActiveSubSlot = CurrentRoom & 1`, set `RunInfo.{CurrentRoom,CurrentRoomType,CurrentBiome}` from the next `RoomPlan`, bump `RoomEpoch` so `RoomFieldSystem`/`RoomEnemyDirectorSystem` spawn the next room at the idle slot, then teleport every Expedition player to `ExpeditionRoomOrigin(base, ActiveSubSlot)` by writing **`LocalTransform.Position =`** (the `RegionTransitSystem` idiom — NEVER `FromPosition`; fixes N4/C1), back to `InRoom`. Teardown-previous → empty tick → spawn-next → teleport ordering guarantees exactly one room alive.
|
||||
3. `Returning` → teleport party to base, flip `RegionTag{Base}`, **bank once per RunEpoch** guarded by `RunRuntime.LastBankedRunEpoch != RunEpoch` (fixes F7): `GoalProgress.Charge += 1` (clamped to Target) + carry the retaliation inputs `ThreatState.PendingReturns++`/`ExpeditionsCompleted++` (fixes C7) + meta counters, set `LastBankedRunEpoch = RunEpoch`, `SaveRequest.Pending=1`, clear all `PlayerReady`, `Lifecycle→Staging`.
|
||||
|
||||
**Win meter (spine untouched).** When `Charge >= Target`, the existing `GoalReachedSystem` arms the climactic final base siege exactly as today; `CyclePhaseSystem` latches `RunOutcome` Victory/Loss unchanged. HUD shows "Room i/N"; biome re-themes per room via `WorldAtmosphereSystem` generalized to read `RunInfo.CurrentBiome` rather than the camera-X>500 threshold.
|
||||
|
||||
`RunInfo.Lifecycle`/`CurrentRoom` are always-on HUD readouts → written **above any presence early-return** in `RunDirectorSystem` (fixes F12).
|
||||
|
||||
### 2.4 Choice-of-3 boon reward
|
||||
|
||||
**Pool.** Authored `BoonDefinition` SOs → `BlobAssetReference<BoonCatalogBlob>` (config singleton, both worlds, not replicated). Each entry is a thin wrapper over the existing stat pipeline:
|
||||
```
|
||||
BoonDefBlob { byte Id; byte Target /*StatTarget*/; byte Op /*ModOp*/; float Value; byte Rarity; byte ClassMask; FixedString64Bytes Name; FixedString128Bytes Desc; byte IconId; }
|
||||
```
|
||||
`Target/Op/Value` map directly to a `StatModifier` (e.g. `{Damage, PercentAdd, 0.15}`, `{CooldownTicks, PercentMult, -0.10}`, `{Range, Flat, 2}`, `{MoveSpeed, PercentMult, 0.10}`, `{MaxHealth, Flat, 25}`). `ClassMask` filters Warrior-only / Ranger-only / both.
|
||||
|
||||
**Per-ability targeting — DECISION: target GLOBAL per-player stat axes (v1).** Each player has exactly one `AbilityRef` (the Fire slot); `StatModifier` has no ability-id discriminator, and adding one to the `[GhostField] StatModifier` buffer re-bakes the ghost (the documented reason `TimedModifier` was split out). So v1 boons target the global ability/character axes (Damage / Range / −CooldownTicks / ProjectileSpeed / AutoTarget* / MoveSpeed / MaxHealth / Melee*). With a single active ability, "+Damage" *is* "improve your ability" — matches the `AbilityUpgradeSystem` precedent, zero new application infra. True per-ability targeting is deferred behind a future multi-ability holder-entity model.
|
||||
|
||||
**Deterministic per-player offer.** On clear, `BoonOfferSystem` (server) draws each Expedition player's 3 distinct, rarity-weighted, class-filtered boons via `RoomLayoutMath.PickBoons(boonSeed, classId, pool, ...)` where `boonSeed = Hash(RunSeed, CurrentRoom, PlayerSlot) | 1u` — seeded from the **stable server-assigned `PlayerSlot`** (the spawn-ring slot `GoInGameServerSystem` already assigns), not the transport `NetworkId` (fixes F6: reconnect-stable, replay-reproducible).
|
||||
|
||||
**Replicating the offer — `SendToOwnerType.SendToOwner` (fixes N1).** `BoonOffer` is an observe-only HUD read of the local player only; no prediction, no teammate read. Use the literally-correct, traffic-minimal owner-only send:
|
||||
```csharp
|
||||
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
|
||||
public struct BoonOffer : IComponentData
|
||||
{
|
||||
[GhostField] public byte Pending; // 1 = awaiting this player's pick
|
||||
[GhostField] public byte Option0;
|
||||
[GhostField] public byte Option1;
|
||||
[GhostField] public byte Option2;
|
||||
}
|
||||
```
|
||||
The HUD reads its local player via `GhostOwnerIsLocal` and raises the modal. `BoonOfferSystem` writes all four fields for each alive Expedition player on the `RoomReward` entry edge. (Validate the `SendToOwner` codegen path in Play; if any issue surfaces it falls back to `SendToOwnerType.All`, which still delivers each player's component only to its owner.)
|
||||
|
||||
**Pick RPC.**
|
||||
```csharp
|
||||
public struct BoonPickRequest : IRpcCommand { public byte Index; } // 0/1/2; UNCONDITIONAL wire type
|
||||
```
|
||||
- *Client send* — `BoonSendSystem` (ClientSimulation), static enqueue (`PickBoon(byte idx)`) + drain. HUD card click → `BoonSendSystem.PickBoon(i)` (capture the loop var into a local before the lambda).
|
||||
- *Server receive* — `BoonApplySystem` (ServerSimulation, plain group). Resolve sender → player. Validate server-authoritatively: `BoonOffer.Pending==1 && Index∈{0,1,2}` (ignore stale/invalid). Map `Index → Option{Index} → BoonDefBlob`, **append a `StatModifier`** with a disjoint SourceId (`Boon = 0x00B00000 + pickCounter`, distinct from the documented `Tuning.cs` map). Set `BoonOffer.Pending=0`. `ecb.DestroyEntity(reqEntity)`. The buffer mutation is non-structural (safe while iterating); it folds through the unchanged `StatRecomputeSystem` → `EffectiveAbilityStats`/`EffectiveCharacterStats` on both worlds, rollback-correct.
|
||||
|
||||
**Co-op independence + stall backstop.** Each player has its own `BoonOffer` + pick RPC. `RunDirectorSystem` leaves `RoomReward` only once **all surviving players** (counted off live `PlayerTag` ghosts so a disconnect can't hold the gate) have `Pending==0` **OR** `RewardGraceTick` elapses. On timeout, the un-picked-offer policy is an OPEN FORK (§6). The modal blocks only the local client; "time slows" is a client-only presentation flourish — the sim never pauses (rollback-safe). The FSM gates on the **server-written `Pending` flag**, not RPC presence (eventually-consistent, benign snapshot lag is acceptable since no predicted logic gates on it).
|
||||
|
||||
### 2.5 Economy reshape
|
||||
|
||||
- **Remove base nodes.** Remove the `BaseFieldSpawner` authoring instance from the gameplay subscene; `BaseFieldSpawnSystem` idles via its `RequireForUpdate<BaseFieldSpawner>`, then retire it + `BaseFieldRuntime` once Play-confirmed empty. Keep the one-time `CycleDirectorSpawnSystem` `StartingOre` grubstake so the first run is launchable.
|
||||
- **Scarce + capped expedition nodes.** Per-room count = `RoomPlan.NodeCount` (Combat/Elite lean, Reward dense, Boss minimal), one-shot per room (no respawn — already the expedition behavior). Run-wide `RunRuntime.NodeBudgetRemaining` floors each room's spawn count and decrements per node → finite per-run allotment = true scarcity across the run. Replace the flat `i%3` round-robin with rarity weighting (Aether rare, Ore/Biomass common).
|
||||
- **Resource → use map.** Ore → building (expedition-only now); Biomass → building/fabricator; Charge (ResourceId 4) → turret ammo (unchanged). **Aether freed** — boons replace the 20-Aether `AbilityUpgradeSystem` spend; that purchase path is retired (keep the `StatModifier`-append machinery — boons reuse it). Aether's fate is an OPEN FORK (§6).
|
||||
- **Win meter** — unchanged shape, re-sourced: `Charge += 1` per completed run, `Target = N runs`; surviving a base siege grants nothing (DR-042 preserved).
|
||||
|
||||
---
|
||||
|
||||
## 3. INVENTORY
|
||||
|
||||
### 3.1 Components & `[GhostField]`s
|
||||
|
||||
| Component | Status | Where | Replicated | Churn |
|
||||
|---|---|---|---|---|
|
||||
| `RunInfo` (byte Lifecycle, int CurrentRoom, int RoomCount, byte CurrentRoomType, byte CurrentBiome, uint LaunchTick) | NEW | CycleDirector ghost (untagged/global), baked | yes — 6 `[GhostField]`s | **director ghost re-bake** |
|
||||
| `RunRuntime` (uint RunSeed, int RunEpoch, int RoomEpoch, byte ActiveSubSlot, int NodeBudgetRemaining, uint HostSalt, uint RewardGraceTick, byte WasAllReady, int LastBankedRunEpoch) | NEW | CycleDirector, `AddComponent` at spawn | no | none |
|
||||
| `PlayerReady` (byte Value) | NEW | player ghost, send-to-all `[GhostField]` | yes | **player ghost re-bake** |
|
||||
| `BoonOffer` (byte Pending, Option0/1/2) | NEW | player ghost, `OwnerSendType.SendToOwner` | yes | **player ghost re-bake (batch with PlayerReady)** |
|
||||
| `RoomTag` (byte Room) | NEW | room-scoped ghosts, server-only | no | none |
|
||||
| `RoomArchetypeBlob`, `BoonCatalogBlob` | NEW | config singletons (blob, from SOs) | no | none |
|
||||
| `RegionTag` | unchanged | — | no | none |
|
||||
| `StatModifier`, `EffectiveAbilityStats/CharacterStats` | unchanged (boons append; **no new member**) | — | yes | **none** |
|
||||
| `GoalProgress`, `RunOutcome`, `CoreIntegrity`, `RunPhase`, `ThreatState` | unchanged | — | mixed | none |
|
||||
|
||||
### 3.2 Systems
|
||||
|
||||
| System | Status | Group / ordering | Responsibility |
|
||||
|---|---|---|---|
|
||||
| `RoomLayoutMath` | NEW (pure static, no system) | — | deterministic `RoomPlan` + `ScatterInShape` + `PickBoons` |
|
||||
| `ReadyToggleSystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateBefore(RunDirectorSystem)]` | apply ready RPC → `PlayerReady` (Staging only) |
|
||||
| `RunDirectorSystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(ReadyToggleSystem)]`, `[UpdateBefore(GoalReachedSystem)]`, `[UpdateBefore(CyclePhaseSystem)]` | **sole `RunInfo.Lifecycle` writer**; ready-count, launch edge+guard, room advance, teleport, return, bank Charge+retaliation once-per-RunEpoch |
|
||||
| `RoomFieldSystem` | CHANGED (from `ExpeditionFieldSystem`) | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(RunDirectorSystem)]` — **drop the inherited `[UpdateAfter(CyclePhaseSystem)]`** | scatter scarce nodes on `RoomEpoch` edge; `RoomTag` teardown; node budget |
|
||||
| `RoomEnemyDirectorSystem` | CHANGED (from `ZoneEnemyDirectorSystem`) | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(RunDirectorSystem)]` — **must NOT add `[UpdateBefore(CyclePhaseSystem)]`** | per-room wave/boss on `RoomEpoch` edge; `MaxAlive` guard; surface clear via `ExpeditionObjective.State` |
|
||||
| `BoonOfferSystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(RunDirectorSystem)]` | on RoomReward entry, compute+write per-player `BoonOffer` from `PlayerSlot` seed |
|
||||
| `BoonApplySystem` | NEW | ServerSimulation, `SimulationSystemGroup`, `[UpdateAfter(BoonOfferSystem)]` | apply pick RPC → append `StatModifier`; clear Pending |
|
||||
| `ReadySendSystem`, `BoonSendSystem` | NEW | ClientSimulation, `SystemBase` | static enqueue + drain → RPC entity |
|
||||
| `CycleDirectorSpawnSystem` | CHANGED | unchanged ordering | stage `RunRuntime` (incl. `HostSalt`, `LastBankedRunEpoch=0`); restore v6 meta born-correct |
|
||||
| `ExpeditionGateSystem` | RETIRED | — | walk-in launch + Charge credit replaced; **retaliation increments carried to `RunDirectorSystem`** |
|
||||
| `BaseFieldSpawnSystem` (+`BaseFieldRuntime`) | RETIRED | — | base nodes removed |
|
||||
| `AbilityUpgradeSystem` | RETIRED | — | Aether-spend upgrade replaced by boons (keep `StatModifier` machinery) |
|
||||
| `HudSystem` | CHANGED | PresentationSystemGroup | ready panel ("N/M READY" + launch ring), "Room i/N", 3-card boon modal |
|
||||
| `WorldAtmosphereSystem` | CHANGED | client | biome cross-fade from `RunInfo.CurrentBiome` |
|
||||
| `RegionRelevancySystem`, `StatRecomputeSystem`, `RegionTransitSystem` | REUSED unchanged | — | relevancy / stat fold / teleport primitive |
|
||||
|
||||
**Sort-cycle audit (fixes F1/C8 — the single highest-risk item; invisible to EditMode, Play-only).** The refactored `RoomFieldSystem`/`RoomEnemyDirectorSystem` **must drop the inherited `[UpdateAfter(CyclePhaseSystem)]`** and instead order `[UpdateAfter(RunDirectorSystem)]`. The resulting linear chain is:
|
||||
```
|
||||
ReadyToggleSystem → RunDirectorSystem → RoomFieldSystem → RoomEnemyDirectorSystem → (CyclePhaseSystem reads later)
|
||||
```
|
||||
`RunDirectorSystem` is strictly **before** `CyclePhaseSystem` and `GoalReachedSystem` (its Charge credit lands before `GoalReached` reads it; one-tick-late is fine), and `ThreatDirectorSystem` (which consumes the `ThreatState` that `RunDirector` now writes) keeps its `[UpdateBefore(CyclePhaseSystem)]` slot, so `RunDirector → ThreatDirector → CyclePhase` is acyclic. **Hard rule: no system in the room chain adds any `[UpdateAfter]`/`[UpdateBefore]` edge to `CyclePhaseSystem`.** The clear signal flows via the data flag `ExpeditionObjective.State` consumed one-tick-late by `RunDirectorSystem`, not via a system-ordering edge (so no `RoomClear→RunDirector` back-edge closes a cycle). Play-validate at Step 2 (where the `RunDirector→CyclePhase` edge first exists) and again at Step 7.
|
||||
|
||||
### 3.3 RPCs
|
||||
|
||||
| RPC | Status | Payload | Notes |
|
||||
|---|---|---|---|
|
||||
| `ReadyToggleRequest` | NEW | `byte Ready` | explicit set 0/1, UNCONDITIONAL wire type |
|
||||
| `BoonPickRequest` | NEW | `byte Index` | 0/1/2, UNCONDITIONAL wire type |
|
||||
| `AbilityUpgradeRequest` | RETIRED | — | **keep as a dead-but-present unconditional struct** until the single coordinated hash bump (fixes C11/N6) |
|
||||
|
||||
**RpcCollection hash discipline (fixes C11).** All new wire types unconditional (no `#if`); only send/receive **systems** may be `#if`-gated. The hash changes when the new RPCs are introduced. To avoid two intermediate-incompatible bumps across the build sequence, `AbilityUpgradeRequest` stays present (dead, unused) until the **single coordinated step** where the two new RPCs land and the old one is removed together — all peers must share that build.
|
||||
|
||||
### 3.4 Churn line
|
||||
|
||||
- **Ghost re-bakes (2, coordinated):** director ghost (`+RunInfo`); player ghost (`+PlayerReady` and `+BoonOffer` in one re-bake).
|
||||
- **RpcCollection hash (1 coordinated bump):** +2 RPCs, −1 retired, landed together; `AbilityUpgradeRequest` kept as a dead wire until then.
|
||||
- **SaveData v5 → v6 (additive; `MinLoadableVersion` stays 2):** new flat fields `int RunsCompleted; int MaxDepthReached;` born-correct on the director via `PendingSave` (exactly like `GoalCharge`/`CoreCurrent`). Optional `CharacterBoon[]` (per-class persisted boons) gated on the persistence OPEN FORK §6. **Must NOT persist:** `RunInfo`/`RunRuntime` (live run instance — re-run from base), `PlayerReady`, `BoonOffer.Pending`; boot always in `Staging`. v2–v5 saves still load (JsonUtility 0-defaults; each restore guard maps 0→baked).
|
||||
|
||||
---
|
||||
|
||||
## 4. SYSTEM-BY-SYSTEM BUILD SEQUENCE
|
||||
|
||||
Each rung compiles clean, passes EditMode, and survives a Play-smoke before the next. Run the adversarial multi-agent design-review BEFORE each netcode-heavy rung. Re-run the clean netcode Play boot (connect / ghost-sync / player-spawn) after every ghost-hash-affecting step. Edit Assets `.cs` only via MCP (`apply_text_edits`/`create_script`), never raw `Write`.
|
||||
|
||||
### Phase A — run lifecycle skeleton (no rooms)
|
||||
|
||||
**Step 1 — `RoomLayoutMath` + `RoomArchetypeBlob` (pure math + blob, NO netcode).**
|
||||
- *Create:* `Simulation/World/RoomLayoutMath.cs`, `Simulation/World/RoomPlan.cs`, `Simulation/World/RoomArchetypeBlob.cs`; `Authoring/World/RoomArchetypeDefinition.cs` (SO) + its baker; `Tests/EditMode/RoomLayoutMathTests.cs`.
|
||||
- *Depends on:* nothing (mirror `ZoneEnemyMath`).
|
||||
- *Validate (EditMode, `ZoneEnemyMathTests` style — no editor focus needed):* same `(seed,index)` → identical `RoomPlan`; distinct `roomIndex` differ; `Plan` gives room0=Combat, last=Boss, exactly one Elite at ~⅔, Reward interleaved; `ScatterInShape` results stay within `RoomPlan.Radius` for disk/rect/polygon; `PickBoons` always returns 3 distinct ids, all passing the `classId` `ClassMask`, deterministic per `offerSeed`. Assert 368 prior EditMode tests still green.
|
||||
|
||||
**Step 2 — `RunInfo` + `RunRuntime` + bake onto CycleDirector + `RunDirectorSystem` lifecycle skeleton.**
|
||||
- *Create:* `Simulation/World/RunInfo.cs`, `Simulation/World/RunRuntime.cs`, `Server/World/RunDirectorSystem.cs`; *modify:* `CycleDirector.prefab` (add `RunInfo` authoring → re-bake), `CycleDirectorSpawnSystem.cs` (AddComponent `RunRuntime` with `HostSalt` seeded, `LastBankedRunEpoch=0`, born-correct `RunInfo.Lifecycle=Staging`).
|
||||
- *Depends on:* Step 1.
|
||||
- *Implementation note:* stub the room loop (Launching → immediately Returning after the launch countdown), but build the **real sub-slot teleport** here (party teleport to `ExpeditionRoomOrigin(base, ActiveSubSlot)` on launch and back to base on return, via `LocalTransform.Position =`) so Steps 5–7 are validatable on a ping-ponging loop (fixes C3). Write `RunInfo` above any early-return (F12). Include the `RunPhase!=Normal || RunOutcome!=InProgress` launch guard (F2).
|
||||
- *Validate:* director ghost re-bakes clean (no console errors). **Play-boot:** `execute_code` reads `RunInfo.Lifecycle==Staging` born-correct on BOTH worlds; force `allReady` by writing `PlayerReady` directly (added in Step 3 — for Step 2 use a temporary `RunRuntime.ForceLaunch` debug flag set via `execute_code`), observe Staging→Launching→(stub)→Returning→Staging and the party teleport to the expedition origin and back. **This is the sort-cycle Play-validation point** (the new `[UpdateBefore(CyclePhaseSystem)]`/`[UpdateBefore(GoalReachedSystem)]` edges) — confirm world creation does not throw `ComponentSystemSorter` "circular dependency cycle". 368 EditMode tests green.
|
||||
|
||||
**Step 3 — `PlayerReady` + `ReadyToggleRequest` + `ReadyToggleSystem` + `ReadySendSystem`.**
|
||||
- *Create:* `Simulation/Player/PlayerReady.cs`, `Simulation/World/ReadyToggleRequest.cs`, `Server/World/ReadyToggleSystem.cs`, `Client/World/ReadySendSystem.cs`; *modify:* player prefab (add `PlayerReady` send-to-all → re-bake), `GoInGameServerSystem.cs` (init `PlayerReady.Value=0` at spawn).
|
||||
- *Depends on:* Step 2.
|
||||
- *Validate (EditMode):* tick `ReadyToggleSystem` with a fabricated `ReadyToggleRequest`+`ReceiveRpcCommandRequest`+player; assert `PlayerReady.Value` flips and that a toggle while `Lifecycle!=Staging` is ignored. **Play (MPPM 2 clients):** toggle ready on each; confirm `RunDirectorSystem` launches only when N/M==M; RpcCollection hash matches (no handshake refusal); a staging disconnect recomputes the count and can complete the check. Replace the Step-2 `ForceLaunch` debug flag with the real ready-derivation.
|
||||
|
||||
### Phase B — rooms & traversal
|
||||
|
||||
**Step 4 — `RoomTag` + per-room teardown helper.**
|
||||
- *Create:* `Simulation/World/RoomTag.cs`, a shared `RoomTeardown` helper (static, `RoomTag`-filtered destroy over `ResourceNode`/`BlightClutter`/`ZoneEnemyTag`-equivalent queries); `Tests/EditMode/RoomTeardownTests.cs`.
|
||||
- *Depends on:* Step 1.
|
||||
- *Validate (EditMode):* spawn two rooms' dummy ghosts (`RoomTag.Room` 0 and 1); tear down room 0; assert ONLY room-0 ghosts die and room-1 survive (the cross-room-wipe regression, DR-031/DR-040 hazard).
|
||||
|
||||
**Step 5 — `RoomFieldSystem` (refactor of `ExpeditionFieldSystem`) + `RunRuntime.NodeBudgetRemaining`.**
|
||||
- *Modify:* `ExpeditionFieldSystem.cs` → `RoomFieldSystem.cs` (rename, re-point to `RoomEpoch`, drop inherited `[UpdateAfter(CyclePhaseSystem)]`, add `[UpdateAfter(RunDirectorSystem)]`, scatter via `ScatterInShape`+`nodeSeed` at `ExpeditionRoomOrigin(base, ActiveSubSlot)`, tag `RoomTag`, node budget); `RunRuntime` already has `NodeBudgetRemaining`.
|
||||
- *Depends on:* 1, 4.
|
||||
- *Validate (EditMode):* equality-reseed = exactly one scatter per `RoomEpoch` (a repeat tick with the same epoch scatters nothing); `NodeBudgetRemaining` decrements per node and floors each room's spawn count to the remaining budget; baked Scale preserved (assert spawned `LocalTransform.Scale` equals the prefab's, not 1). **Play-smoke:** room field appears at the active sub-origin, relevancy-scoped to Expedition players; a manual `RoomEpoch` bump reseeds at the idle slot.
|
||||
|
||||
**Step 6 — `RoomEnemyDirectorSystem` (refactor of `ZoneEnemyDirectorSystem`) per-room roster.**
|
||||
- *Modify:* `ZoneEnemyDirectorSystem.cs` → `RoomEnemyDirectorSystem.cs` (re-point to `RoomEpoch`, index `ZoneEnemyMath` by `RoomPlan.DifficultyEpoch`, drop inherited CyclePhase edge, add `[UpdateAfter(RunDirectorSystem)]`, tag `RoomTag`, Boss-room single-boss branch, keep `MaxAlive` guard + `ExpeditionObjective` above the early-return).
|
||||
- *Depends on:* 1, 4.
|
||||
- *Validate (EditMode):* `DifficultyEpoch` → kind counts via `ZoneEnemyMath` (Elite/Boss bands heavier); `MaxAlive` spawn-only-if-fits holds (a pack that wouldn't fit does not consume a slot); `ExpeditionObjective` written even when the early-return path is taken. **Play-smoke:** rooms fight differently by archetype; Boss room spawns the boss.
|
||||
|
||||
**Step 7 — multi-room advance in `RunDirectorSystem` (consume `ExpeditionObjective.State`).**
|
||||
- *Modify:* `RunDirectorSystem.cs` (consume objective-Cleared one-tick-late → RoomReward; teardown-previous → empty tick → bump `RoomEpoch` + flip `ActiveSubSlot` → teleport via `LocalTransform.Position =`; Boss → Returning; bank once-per-`RunEpoch` guarded by `LastBankedRunEpoch`, incl. `ThreatState` retaliation carries).
|
||||
- *Depends on:* 5, 6, 2, 4.
|
||||
- *Validate (EditMode):* fabricated objective-Cleared → RoomReward → (after grace/picks) `CurrentRoom++` + `ActiveSubSlot` flip + `RoomEpoch` bump; bank fires **exactly once** even if `Returning` persists multiple ticks. **Play-validate the full loop:** room0→…→boss→Returning→home → `Charge += 1` once-per-`RunEpoch`; `execute_code` asserts only ONE room's ghosts alive at any tick (teardown-before-spawn invariant) and that no cross-arena enemy targets a player in the other slot; confirm `ThreatState.ExpeditionsCompleted` increments (retaliation input carried); re-audit `[UpdateBefore/After]` — no cycle at world creation.
|
||||
|
||||
### Phase C — the boon reward
|
||||
|
||||
**Step 8 — `BoonCatalogBlob` + `BoonDefinition` SOs + `BoonOffer` (`SendToOwner`) + `BoonOfferSystem`.**
|
||||
- *Create:* `Simulation/Combat/BoonDefBlob.cs`, `Simulation/Combat/BoonCatalogBlob.cs`, `Authoring/Combat/BoonDefinition.cs` (SO) + baker, `Simulation/Player/BoonOffer.cs`, `Server/Combat/BoonOfferSystem.cs`; *modify:* player prefab (add `BoonOffer` `OwnerSendType.SendToOwner` → re-bake, batch with Step 3's `PlayerReady` add).
|
||||
- *Depends on:* 1 (picker), 7.
|
||||
- *Validate:* player ghost re-bakes. **Play 2 clients:** on a clear, `execute_code` reads each client's local `BoonOffer` (via `GhostOwnerIsLocal`) — confirm each owner sees its own three options, options differ per player (seed includes `PlayerSlot`), and a teammate's `BoonOffer` does NOT arrive on the other client (owner-only send). If `SendToOwner` codegen misbehaves, fall back to `SendToOwnerType.All` and re-validate.
|
||||
|
||||
**Step 9 — `BoonPickRequest` + `BoonApplySystem` + `BoonSendSystem`.**
|
||||
- *Create:* `Simulation/Combat/BoonPickRequest.cs`, `Server/Combat/BoonApplySystem.cs`, `Client/Combat/BoonSendSystem.cs`.
|
||||
- *Depends on:* 8.
|
||||
- *Validate (EditMode):* a valid index appends exactly one correct `StatModifier` (distinct `Boon` SourceId) and clears `Pending`; an out-of-range or non-`Pending` pick is rejected (no append). **Play:** pick on each client; `EffectiveAbilityStats.Damage` (or the chosen axis) rises on BOTH worlds for the picker only (co-op independence); `RunDirectorSystem` leaves `RoomReward` only once all surviving `Pending==0` OR `RewardGraceTick` (`IsNewerThan`) elapses.
|
||||
|
||||
### Phase D — economy & presentation (single coordinated churn step + polish)
|
||||
|
||||
**Step 10 — Economy reshape + coordinated RPC retirement.**
|
||||
- *Modify:* remove `BaseFieldSpawner` from the gameplay subscene; idle then retire `BaseFieldSpawnSystem`+`BaseFieldRuntime`; node-budget cap + rarity weighting in `RoomFieldSystem`; retire `AbilityUpgradeSystem` and remove `AbilityUpgradeRequest` **in this single step** (the one coordinated hash bump — confirm the Aether fork §6 with the operator before deleting the wire).
|
||||
- *Depends on:* 5, 8, 9.
|
||||
- *Validate (Play):* zero nodes at base; first run launchable from grubstake; scarce capped nodes that exhaust in later rooms; building still spends Ore/Biomass; no Aether upgrade path remains; RpcCollection hash matches across freshly-built peers.
|
||||
|
||||
**Step 11 — HUD + biome + SaveData v6.**
|
||||
- *Modify:* `HudSystem.cs` (ready panel "N/M READY" + launch ring, "Room i/N" counter, 3-card boon modal wired to `BoonSendSystem.PickBoon`); `WorldAtmosphereSystem.cs` (biome cross-fade from `RunInfo.CurrentBiome`); `SaveData.cs`→v6 + `SaveWriteSystem.cs` + `PendingSave`/`WorldLauncher.StagePendingSave` + `CycleDirectorSpawnSystem` born-correct restore of `RunsCompleted`/`MaxDepthReached`.
|
||||
- *Depends on:* all prior.
|
||||
- *Validate (Play):* screenshot the READY panel ("2/3" + launch ring), the room counter, the 3-card overlay (click commits via Step 9's RPC); biome varies per room deterministically; v6 save→reload restores `RunsCompleted`/`MaxDepthReached` born-correct and boots in `Staging` (no resumed room); old v5 saves still load (0-default). `RoomCoverPropSystem` (deterministic per-room cover props) is **descoped from v1** (fixes C6) — v1 variety ships on biome tint + shape/layout; cover props are a later isolated rung.
|
||||
|
||||
---
|
||||
|
||||
## 5. RESOLVED RISKS
|
||||
|
||||
| ID | Finding | How the spec now handles it |
|
||||
|---|---|---|
|
||||
| **F1/C8** | Sort-cycle from inherited `[UpdateAfter(CyclePhaseSystem)]` on refactored field/zone systems (Play-only, invisible to EditMode) | Refactored `RoomFieldSystem`/`RoomEnemyDirectorSystem` **drop** the inherited CyclePhase edge, order `[UpdateAfter(RunDirectorSystem)]`. Linear chain `ReadyToggle→RunDirector→RoomField→RoomEnemyDirector`; `RunDirector` strictly before `CyclePhase`/`GoalReached`/`ThreatDirector`. Clear flows via the `ExpeditionObjective.State` data flag consumed one-tick-late, not a system edge. Play-validated at Step 2 and Step 7. |
|
||||
| **N2** | Cross-arena AI aggro: both sub-arenas share `RegionTag{Expedition}`, `PickWeightedNearest` has no range cap | Teardown-before-spawn with one empty tick → two rooms never coexist → AI cannot see across the gap. `RoomTag` available for AI plumbing if rooms ever overlap; v1 does not depend on it. |
|
||||
| **N1** | `BoonOffer` `SendToOwnerType.All` broadcasts; misreads the API | `BoonOffer` uses `OwnerSendType.SendToOwner` (owner-only, traffic-minimal). `All` is the validated fallback only if `SendToOwner` codegen misbehaves. |
|
||||
| **F2** | New run can launch while a final siege arms | `Staging→Launching` refused while `RunPhase!=Normal || RunOutcome!=InProgress`. |
|
||||
| **F7** | Unnamed once-per-`RunEpoch` Charge latch → multi-tick `Returning` double-credit | `RunRuntime.LastBankedRunEpoch`, equality-compared; bank fires once then sets the latch. Validated at Step 7. |
|
||||
| **F6** | `NetworkId`-seeded boons not reconnect-stable / replayable | `boonSeed` keyed by the stable server-assigned `PlayerSlot`, not the transport `NetworkId`. |
|
||||
| **C7** | Retiring `ExpeditionGateSystem` drops `ThreatState.PendingReturns`/`ExpeditionsCompleted` (retaliation-siege input) | Both increments carried to `RunDirectorSystem`'s `Returning` edge. |
|
||||
| **F4** | `RewardGraceTick`/`LaunchTick` raw-`uint` compare → soft-lock on wrap/restore | Stored via `TickUtil.NonZero`; elapsed-test mandated via `new NetworkTick(stored).IsNewerThan(now)`, never raw `uint`. |
|
||||
| **C1/N4** | Missed reuse of the teleport primitive; `FromPosition` Scale-reset trap | Reuse the `RegionTransitSystem` flip+teleport idiom; teleport writes `LocalTransform.Position =` (never `FromPosition`). Orphan cleanup of `ExpeditionGate`/`ExpeditionGateAuthoring`/`RegionTransitRequest` audited at retirement. |
|
||||
| **C2** | Parallel `RoomOrigin` splits coordinate authority | Single authority: `RegionMath.ExpeditionRoomOrigin` + `ExpeditionOrigin(base)` reading the active sub-slot; every existing `RegionOrigin(Expedition,…)` call repointed. |
|
||||
| **C3** | Steps 5–7 not independently validatable (teleport doesn't exist until 7) | Real sub-slot teleport built in Step 2's stub loop (ping-pongs), so the field/enemy rungs are validatable on a moving party. |
|
||||
| **C4** | Duplicative `RoomClearSystem`/`RoomCleared` byte | Collapsed — clear surfaced through the existing `ExpeditionObjective.State==Cleared`, consumed one-tick-late by `RunDirectorSystem`. |
|
||||
| **C11/N6** | Two intermediate-incompatible RPC-hash bumps | `AbilityUpgradeRequest` kept as a dead-but-present unconditional struct until Step 10's single coordinated bump (+2 new, −1 old together). |
|
||||
| **F12** | `RunInfo` must be written above any presence early-return | `RunDirectorSystem` writes `RunInfo.Lifecycle`/`CurrentRoom` above all early-returns. |
|
||||
| **F3/F11** | "one room alive" invariant actually ≤2×MaxAlive; `ghostId==0` transient relevancy leak amplified per room | Dissolved by teardown-before-spawn (one empty tick → strictly one room). Step 7 asserts exactly one room's ghosts alive at any tick. |
|
||||
| **N3** | Relevancy "reused unchanged" understates build-count cost scaling | Cost stated honestly: `O(region-tagged-ghosts × connections)`/tick, scales with cumulative base structures; `DefaultRelevancyQuery` flagged as a future optimization to evaluate, not adopted blindly. |
|
||||
| **N7** | N/M count load-bearing on Staging-only co-location | Documented in code; ready toggles honored only in Staging. |
|
||||
| **N5** | 1-of-N mid-run disconnect mislabeled "handled" | Moved to OPEN FORKS §6 as a co-op-abandonment product call. |
|
||||
| **F9/F10** | Snapshot lag / gate-on-`Pending` | Non-defects: FSM gates on the server-written `Pending` flag; the lag is eventually-consistent, no predicted logic depends on it. |
|
||||
|
||||
---
|
||||
|
||||
## 6. OPEN OPERATOR FORKS (genuine gameplay-design calls — NOT decided here)
|
||||
|
||||
1. **Aether's fate.** Boons replace the 20-Aether in-run upgrade purchase, freeing Aether. (a) Retire Aether entirely (simpler haul); (b) keep it as a premium build material for high-tier base structures; (c) make it a base meta-spend — permanent between-run character upgrades distinct from in-run boons (this path pulls in the `CharacterBoon[]` SaveData v6 field + per-class spawn-seeding). Confirm before Step 10 deletes the Aether-upgrade wire.
|
||||
2. **Un-picked-boon policy when the reward-grace timer fires** (AFK/disconnected picker). (a) Auto-clear the offer (player forgoes it); (b) auto-pick Option0 (player always gets something).
|
||||
3. **Do boons persist across runs?** Locked #3 frames boons as per-run (Hades-shaped, reset each run). A meta layer (permanent character growth) is the `CharacterBoon[]` SaveData v6 path + per-class spawn-seeding, overlapping fork #1(c). Per-run-only is the simpler roguelite-pure default.
|
||||
4. **Launch countdown vs instant launch.** `LaunchTick` supports a telegraphed "all ready → 3-2-1 → go" with an un-ready abort window; instant launch on the all-ready edge is simpler.
|
||||
5. **Run length (`RoomCount`) — fixed vs seed-varied.** Fixed (e.g. always 8) is predictable; a narrow seed-varied range (e.g. 6–10) adds run-to-run variety (the headline goal) at some pacing-consistency cost. Tunable, not load-bearing.
|
||||
6. **1-of-N mid-run disconnect / co-op abandonment** (moved from N5). When one of a multi-player party drops mid-run, the remaining players continue (the run is not aborted unless ALL expedition players leave). Should a dropped player be able to rejoin the in-progress run (catch-up/spectate, a v2 deferral today), and should the win-meter credit still bank for the survivors? Currently survivors continue and bank on boss-clear; the dropped player simply misses the run.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 7. ADDENDUM (2026-07-01) — Branching Route-Map + Permanent Meta-Progression (integrated, review-hardened)
|
||||
|
||||
> **Provenance.** Extends §§1–6 with the four operator locks (branching node-map · permanent meta-progression · Aether = meta currency · seed-varied 6–10 rooms) and realizes the [[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]] two-channel vision. Adversarially reviewed (netcode / determinism-save / reuse-scope lenses). **Every CONFIRMED finding is corrected in the design text below** — D-F1 (ThreatDirector edge), R-F3 (client economy co-strip), D-F2 (unconditional `MetaCounters`), N1/R-F5 (in-place route latch), D-F3 (clear-gated banking), D-F4 (boon-strip ordering), R-F1 (meta upsert), R-F4/N5 (bounded buffer), R-F2 (pre-collected class members), plus the N2/N3/N4/D-F5/R-F6/R-F7/R-F11/R-F12 guards. No baseline CONFIRMED fix (F1/C8, N1-boon-sendtype, N2, F2, F7, F6, C7, F4, C1/C2) is re-opened. **This supersedes §2.4's "append a permanent `StatModifier`" (boons are now run-scoped) and resolves §6 forks #1→(c), #3→per-run, #5→[6,10]; §5 gains the rows below.**
|
||||
|
||||
---
|
||||
|
||||
## A. THE TWO-CHANNEL MODEL (crisp separation — the DR-037 correction)
|
||||
|
||||
Three storage/write channels that **never cross** (distinct SourceId bands, distinct storage, distinct writer systems). Nothing reads as a roguelike reset: base/gear/meta never reset; only run boons expire.
|
||||
|
||||
### A.1 In-run power channel — choice-of-3 boons, RUN-SCOPED (corrects §2.4)
|
||||
|
||||
Boons remain a `StatModifier` append (§2.4's pipeline is otherwise reused), but on a **bounded, range-strippable band**, appended during the run and **stripped on the `Returning` edge** — they do **not** persist.
|
||||
|
||||
- **Band + provenance.** `RunRuntime.BoonPickCounter` (server-only `uint`, reset on the `Staging→Launching` edge, `++` per applied pick). Each applied pick draws `sid = Tuning.BoonSourceIdBase + (BoonPickCounter++ % Tuning.BoonSourceIdSpan)` → boons occupy the disjoint band `[0x00B00000, 0x00B10000)`. One row per pick preserves provenance and lets boons stack as distinct rows (fork #5a; §G).
|
||||
- **Strip = `Returning` edge, inside the once-per-`RunEpoch` bank latch.** New `TimedModifierUtil.RemoveBySourceIdRange(DynamicBuffer<StatModifier> mods, uint lo, uint hiExclusive)` (3-line sibling of the verified `RemoveBySourceId`) is called over **every** `PlayerTag`'s `StatModifier` buffer inside the existing `LastBankedRunEpoch != RunEpoch` block (§2.3 step 3). It also **zeroes every `BoonOffer.Pending`** in the same pass. Server-only; idempotent (a second `Returning` tick strips an empty range); rollback-safe (the `[GhostField]` buffer removal replicates and `StatRecomputeSystem` reverts `Effective*Stats` on both worlds). Disjoint from the class band (`0x00C1A550`) and meta band (`0x00E7A000`), so class seeds + meta seeds survive.
|
||||
- **Ordering hardening (D-F4).** `BoonApplySystem` runs `[UpdateAfter(BoonOfferSystem)]` (hence after `RunDirectorSystem`), so a pick RPC drained on the same tick the strip runs would otherwise append *after* the latch consumed it and leak a run. **Fix, folded in: `BoonApplySystem` gates every pick on `RunInfo.Lifecycle == RoomReward`** (rejects any pick once the FSM has left `RoomReward` toward `RouteSelect`/`Returning`), and the strip zeroes all `Pending`. A grace-timeout straggler pick therefore arrives on a `RouteSelect`/`Returning` tick and is rejected — no post-strip append is possible.
|
||||
- **Defense-in-depth against persistence.** The save writes **director-level state only** — never per-player `StatModifier` buffers — so boons cannot reach disk even if a strip were missed.
|
||||
- **DR-037 reconciliation (R-F11).** DR-037 said "run-scoped via `TimedModifier`," but `TimedModifier.UntilTick` needs a fixed end tick and a seed-varied run has **no known end tick at pick time**. The `Returning`-edge range-strip is therefore *strictly more correct* and **supersedes** the fixed-duration timer for boons. Recorded so no future reader reunifies boons onto a `TimedModifier` countdown.
|
||||
|
||||
### A.2 Permanent power channel — Aether-bought meta upgrades, PERSISTENT
|
||||
|
||||
Aether (§2.5, kept as a **rare** expedition resource; fork #1→(c)) is spent **at the base hub** on permanent per-class tiers. They persist (SaveData v6) and are re-applied **born-correct** at player spawn as meta-band (`[0x00E7A000, 0x00E7A100)`) `StatModifier`s — identical mechanism to the class-seed path, so rollback-correct via the unchanged `StatRecomputeSystem`. The in-run Aether spend (`AbilityUpgradeSystem`) is **retired** (fork #3→per-run boons already replaced in-run upgrades). Full model in §C.
|
||||
|
||||
### A.3 Navigation channel — the branching route (touches no stat/ledger/save)
|
||||
|
||||
`RunMap`/`RouteSelect` decide *where the party goes*; session-scoped, regenerated from `RunSeed`; never persisted. Full model in §B.
|
||||
|
||||
### A.4 Bounded `StatModifier` buffer (R-F4 / N5 — folded in)
|
||||
|
||||
`StatModifier` is `[InternalBufferCapacity(8)] OwnerSendType.All`. Steady-state rows now = class seeds (≤4) + one meta row per owned upgrade (≤ catalog per class, ~12 in v1) + equip (≤4) + in-run boons (≤ max picks ≈ `RoomCount` ≤ 10) ≈ **30**, which overflows 8 into a heap-allocated, owner-replicated buffer every run. **Fix: raise `InternalBufferCapacity` to `32`.** This is a chunk-layout hint, **not** part of the ghost serializer metadata → **no ghost-hash change, no re-bake**; overflow past 32 spills to heap (a perf note, not a crash), so the common case stays chunk-internal. (We keep per-pick boon rows + the range-strip rather than folding by `(Target,Op)`: op-aware folding is wrong for `PercentMult` and would break strip provenance; fork #5b alone does **not** reduce row count — see §G.)
|
||||
|
||||
---
|
||||
|
||||
## B. BRANCHING ROUTE-CHOICE MAP (Slay-the-Spire node-map over the §2 FSM)
|
||||
|
||||
**The party is one shared token occupying exactly one node per layer.** Branching = *which* `RoomPlan` comes next, chosen by the party — **never** more materialized rooms. The §2.1 traversal chassis (single sub-arena origin, two ping-pong sub-slots, teardown-before-spawn, `RoomEpoch` reseed, `LocalTransform.Position =` teleport, `RegionRelevancy`) is **reused verbatim**; netcode cost is identical to the linear arc.
|
||||
|
||||
### B.1 Data model (transient — never a ghost buffer)
|
||||
|
||||
New `Simulation/World/RunMap.cs` (pure data + consts, **not** `IComponentData`):
|
||||
|
||||
```
|
||||
byte RoomTypeId : Combat=0, Elite=1, Reward=2, Boss=3 // append-only, save/replay-stable
|
||||
struct RunMapNode { byte RoomType; byte Biome; byte ShapeId; byte NextMask; } // bit j of NextMask ⇒ reaches column j of the next layer
|
||||
struct RunMap { FixedList512Bytes<RunMapNode> Nodes; byte LayerCount; ... } // bounded MaxLayers=10 × MaxWidth=3 = 30 nodes
|
||||
```
|
||||
|
||||
Stable node key `nodeId = layer*MaxWidth + col`. Layer 0 = single guaranteed-Combat landing node; interior layers width 2–3, typed; layer `L-2` = all-Elite gate; layer `L-1` = single Boss terminal (`NextMask == 0`). `LayerCount == RoomCount ∈ [6,10]` (lock #4). **The room-type arc logic moves here** out of `RoomLayoutMath.Plan` (typing is now a graph-arc property, resolution A of the conflict ledger).
|
||||
|
||||
### B.2 Deterministic generation — `RunMapMath.Generate(uint runSeed) → RunMap`
|
||||
|
||||
New pure static (the `ZoneEnemyMath` integer-hash discipline). **Integer-hash only — no `Unity.Mathematics.Random`** for the map structure (multi-pass edge generation is draw-order fragile, and clients regenerate this — §B.3). (`RoomLayoutMath.ScatterInShape`/`PickBoons` keep their `ref Random` seeds — those are server-only and never client-regenerated.) Passes: depth `L = 6 + Hash(runSeed,0x1A)%5`; per-layer widths; weighted per-node typing (Combat 60 / Reward 25 / Elite 15, guard against an all-Reward layer); primary-edge pass (every source ≥1 out-edge, proportional column map ±1 jitter); coverage pass (every target ≥1 in-edge, forces convergence on the single Boss); branch-widen pass.
|
||||
|
||||
**Invariants (EditMode-asserted — includes the D-F6 "refuted-as-defect, keep-as-test-criteria" items):** same seed → field-identical map; integer-only (no `Random` draw); `LayerCount ∈ [6,10]`; full BFS reachability from `(0,0)`; exactly one Boss = the sole `NextMask==0` terminal; **every start→boss path passes ≥1 Elite** (holds by construction — `L-2` is all-Elite).
|
||||
|
||||
### B.3 Replication decision — regenerate-for-display + authoritative option bytes (justified)
|
||||
|
||||
**Decision: option (b), regenerate-for-display, is chosen over serializing a node buffer.** Only `RunSeed` (constant per run → delta-free after first send) plus the party's `CurrentCol` and the authoritative reachable-option set ride the wire on the **untagged CycleDirector ghost** (a party decision ⇒ global, cross-region-relevant for free — the §2.2 `RunOutcome`/`GoalProgress` precedent). Clients regenerate the full graph *for drawing only* via the identical `RunMapMath.Generate` compiled into both worlds from `ProjectM.Simulation`.
|
||||
|
||||
**Justification.** This mirrors DR-037's decisive principle (server-authored procedural content ⇒ the seed never needs a replicated buffer). Wire cost = 1 `uint` + a handful of bytes changing only at route edges, vs a ~120-byte re-serialized `[GhostField]` node buffer every layer. **Authority never rests on the regen:** the *gameplay* choice uses the server-replicated `RouteOpt*` bytes and the pick is a byte `OptionIndex` the server validates against its own `NextMask`. A divergent client (already blocked by the ghost/RPC hash gates) could only *mis-draw the cosmetic map*, never desync traversal or send an illegal pick.
|
||||
|
||||
New `RunInfo` `[GhostField]`s (all defined + baked at the **single director re-bake**, §D): `uint RunSeed`, `byte CurrentCol`, `byte RouteOptionCount` (0 unless `RouteSelect`), `byte RouteOpt0Col/RouteOpt1Col/RouteOpt2Col`, `byte RouteOpt0Type/RouteOpt1Type/RouteOpt2Type`. The big map panel draws from the regenerated graph (needs `RunSeed`); the clickable option buttons use the authoritative `RouteOpt*` bytes (zero regen dependence for gameplay). (`RunSeed` also lives in server-only `RunRuntime` as the working copy; `RunInfo.RunSeed` is the published `[GhostField]` mirror.)
|
||||
|
||||
### B.4 FSM — distinct `RouteSelect=5` state (append; no byte renumber)
|
||||
|
||||
```
|
||||
Staging=0 → Launching=1 → InRoom=2 → RoomReward=3 → RouteSelect=5 → InRoom (loop)
|
||||
└─(current node is Boss, NextMask==0)──→ Returning=4 → Staging
|
||||
```
|
||||
|
||||
`RouteSelect` is **distinct** from `RoomReward` (different actors/predicates): `RoomReward` completes when *all surviving players picked their own boon*; `RouteSelect` completes when *the party committed one shared route*. `RouteSelect` runs with **no room materialized** — it *is* the teardown-before-spawn empty gap; the next room spawns only on the `RouteSelect→InRoom` edge (bump `RoomEpoch` → spawn at the idle slot → teleport), so the one-room-alive invariant holds by construction, strictly cleaner than §2.1's "one empty tick." **`RunDirectorSystem` stays the sole lifecycle + map writer** — `RouteSelect` is just another `Lifecycle` value it writes. **No new `[Update*]` edge to `CyclePhaseSystem`** (the §3.2 hard rule).
|
||||
|
||||
### B.5 Route RPC + systems
|
||||
|
||||
`Simulation/World/RouteSelectRequest.cs`:
|
||||
```csharp
|
||||
public struct RouteSelectRequest : IRpcCommand { public byte OptionIndex; public int ForRunEpoch; public int ForLayer; }
|
||||
// UNCONDITIONAL wire type; blittable scalars; index into replicated RouteOpt*, never a raw col/int2/enum
|
||||
```
|
||||
|
||||
- **Client** — `RouteSendSystem` (`Client/World/`, `ClientSimulation`, `SystemBase`): static `PickRoute(byte optionIndex)` enqueue + drain → `SendRpcCommandRequest`.
|
||||
- **Server** — `RouteSelectSystem` (`Server/World/`, `ServerSimulation`, plain `SimulationSystemGroup`, `[UpdateBefore(RunDirectorSystem)]` — **no CyclePhase edge**). Drains via the `playerByConn` idiom and validates: `RunInfo.Lifecycle==RouteSelect && ForRunEpoch==RunEpoch && ForLayer==CurrentRoom && OptionIndex<RouteOptionCount && RouteCommand.HasPick==0` **and the sender's `RegionTag.Region == RegionId.Expedition`** (N3 hardening — a base-bound late-joiner cannot commit the party's route). It **stages intent only** (`RunRuntime`/`RunInfo` stay `RunDirector`-exclusive — the `ReadyToggleSystem→RunDirectorSystem` precedent).
|
||||
|
||||
**First-commit latch via an IN-PLACE write (N1 / R-F5 — MANDATED, folded in).** The `RouteCommand { byte HasPick; byte OptionIndex; int ForRunEpoch; int ForLayer; }` server-only singleton on the director is written with **immediate `SystemAPI.SetComponent` inside the drain loop** (the DR-014 in-place-atomicity idiom, exactly as `MetaSpendSystem` and `AbilityUpgradeSystem.cs:59` do). **Only the request `DestroyEntity` goes on the ECB.** Two same-tick picks therefore cannot both observe `HasPick==0`; the first-accepted RPC deterministically latches the route (a hoisted-read / ECB-deferred write would make the "first commit" receipt-order-arbitrary — the defect this fixes). Later picks per `(RunEpoch, layer)` are ignored.
|
||||
|
||||
**Grace backstop.** `RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks)` set on the `RoomReward→RouteSelect` entry; if it elapses (tested `new NetworkTick(RouteGraceTick).IsNewerThan(now)` — never raw `uint`) with no pick, `RunDirector` **auto-picks the lowest-index reachable option** (deterministic; fork #2→(a)). A client-only "3-2-1 committing to <RoomType>" telegraph is cosmetic; the commit is instant server-side (sim never pauses, rollback-safe).
|
||||
|
||||
### B.6 `RunDirector` advance integration — single plan authority (risk H)
|
||||
|
||||
`RunDirectorSystem` (sole writer) gains:
|
||||
- **On `RoomReward→RouteSelect` entry:** `Generate(RunSeed)` → enumerate `NextMask` at `(CurrentRoom, CurrentCol)` → write `RouteOptionCount` + `RouteOptK{Col,Type}` + `RouteGraceTick`. If the current node is Boss (`NextMask==0`) → skip straight to `Returning`.
|
||||
- **On `RouteSelect→InRoom` advance:** resolve chosen col (`RouteCommand.HasPick ? RouteOptK[OptionIndex].Col : lowest-reachable`); clear `RouteCommand.HasPick=0`; `CurrentRoom++`; `CurrentCol=chosen`; `nodeId = CurrentRoom*MaxWidth + CurrentCol`; `roomType = map.Node(nodeId).RoomType`; `plan = RoomLayoutMath.Plan(RunSeed, nodeId, CurrentRoom, roomType, RoomCount, blob)`; write `RunInfo.{CurrentRoom,CurrentRoomType,CurrentBiome,CurrentCol}` **above all early-returns** (F12); **publish `RunRuntime.{CurrentNodeId, CurrentRoomType}`** (the single plan authority — `RoomFieldSystem`/`RoomEnemyDirectorSystem` read these, never re-derive the node/type; generalizes §2.1's C2 coordinate authority to the room *plan*, risk H); `ActiveSubSlot = CurrentRoom & 1`; bump `RoomEpoch`; teleport via `LocalTransform.Position =` (never `FromPosition`); `Lifecycle→InRoom`.
|
||||
|
||||
`RoomFieldSystem`/`RoomEnemyDirectorSystem` change **only** to read `RunRuntime.CurrentNodeId`/`CurrentRoomType` and the new `Plan(runSeed, nodeId, layer, byte roomType, roomCount, blob)` signature; everything else (scatter, `RoomTag`, `MaxAlive`, `ExpeditionObjective` above the early-return) is verbatim §2.3.
|
||||
|
||||
---
|
||||
|
||||
## C. PERMANENT META-PROGRESSION
|
||||
|
||||
### C.1 Catalog (distinct blob — do NOT reuse `BoonDefBlob`)
|
||||
|
||||
Boons are run-scoped/single-shot/rarity-drawn; meta upgrades are permanent/tiered/escalating-priced/optionally tree-gated. Overloading `BoonDefBlob` would blur the two DR-037 channels. New `Simulation/Meta/MetaUpgradeDefBlob.cs`:
|
||||
```
|
||||
byte Id; // stable persisted key (append-only)
|
||||
byte ClassMask; // bit0=Warrior, bit1=Ranger, 3=both
|
||||
byte Target; byte Op; // maps to StatModifier (byte, NEVER enum — Burst-ICE-safe)
|
||||
byte MaxTier; float ValuePerTier;
|
||||
int BaseCost; int CostGrowth; // cost(t) = BaseCost + owned*CostGrowth
|
||||
byte PrereqId (0xFF=none); byte PrereqTier;
|
||||
FixedString64Bytes Name; FixedString128Bytes Desc; byte IconId;
|
||||
```
|
||||
→ `MetaUpgradeCatalogBlob` config singleton, **both worlds, NOT replicated** (the `BoonCatalogBlob`/`RoomArchetypeBlob` precedent), authored via `Authoring/Meta/MetaUpgradeDefinition.cs` SO + baker into the gameplay subscene (batched with the RoomArchetype/BoonCatalog authoring — **one** subscene save).
|
||||
|
||||
### C.2 Persisted model — per-class, shared party pool
|
||||
|
||||
The **class (`ClassId`) is the durable identity anchor** (DR-037; lock #3: "a Warrior's permanent upgrades apply to whoever plays Warrior"). The save is single-slot host-local with no stable per-player on-disk identity (`NetworkId` is unstable), and Aether is a **shared party ledger** on the director — the party pools Aether and invests in classes, which is correct for drop-in co-op (R-F13: intended semantics per lock #3, not a defect).
|
||||
|
||||
- **Owned-tier truth** = `MetaTierState { byte ClassId, UpgradeId, Tier }`, a **`[GhostField]` buffer on the untagged director ghost** (co-op party invests together, not secret; rides the ghost already re-baking for `RunInfo`; clients read it for the shop for free). Baked (empty) onto `CycleDirector.prefab` at the single director re-bake.
|
||||
- **Server-only** on the director: `MetaCounters { int RunsCompleted, MaxDepthReached }` (`AddComponent`). **Server-only** on the player: `PlayerClass { byte ClassId }` (`AddComponent` at spawn — class was previously only wire/`AbilityRef`, so this is genuinely new).
|
||||
|
||||
### C.3 Meta-spend RPC + system
|
||||
|
||||
`Simulation/Meta/MetaSpendRequest.cs`:
|
||||
```csharp
|
||||
public struct MetaSpendRequest : IRpcCommand { public byte UpgradeId; }
|
||||
// UNCONDITIONAL wire; tier is SERVER-COMPUTED (you always buy owned+1) — sending a tier would invite desync/cheat
|
||||
```
|
||||
- **Client** — `MetaSpendSendSystem` (`Client/Meta/`, `ClientSimulation`, `SystemBase`): static `RequestPurchase(byte upgradeId)`.
|
||||
- **Server** — `MetaSpendSystem` (`Server/Meta/`, `ServerSimulation`, plain `SimulationSystemGroup`, `[BurstCompile]`, **no room-chain / CyclePhase edge**). Mirrors `AbilityUpgradeSystem` with **DR-014 in-loop atomicity**. Per request, all in-loop (no hoist), re-reading ledger + `MetaTierState` each iteration (so two same-tick purchases on barely-enough Aether cannot both pass):
|
||||
1. **Phase gate (N4 / R-F9):** reject unless `RunInfo.Lifecycle == Staging` (enforces "base-hub only" server-side, not just via the HUD panel).
|
||||
2. resolve sender → `PlayerClass.ClassId`.
|
||||
3. catalog lookup by `UpgradeId` (`FindDef < 0` → reject unknown).
|
||||
4. `ClassMask` bit gate.
|
||||
5. `owned` = scan `MetaTierState` for `(ClassId, UpgradeId)`; **clamp `owned = min(owned, MaxTier)` (D-F5)**; reject if `owned >= MaxTier`.
|
||||
6. `PrereqId`/`PrereqTier` gate (skip if `0xFF`).
|
||||
7. `cost = BaseCost + owned*CostGrowth`; **soft-fail** (no state change) if ledger Aether `< cost`.
|
||||
8. **commit atomically:** `StorageMath.Withdraw(ledger, ResourceId.Aether, cost)` → bump the `MetaTierState` row to `owned+1` (append the row if absent) → **upsert the live meta `StatModifier` on every spawned player of that class (R-F1 + R-F2, below)** → `SaveRequest.Pending=1`.
|
||||
9. `ecb.DestroyEntity(req)`.
|
||||
|
||||
**Live application = UPSERT, absolute value (R-F1 — folded in).** A fresh `0→1` purchase on an already-spawned player has **no** existing meta row (born-correct seeding only seeds `Tier>0` rows). So it is **not** "grow-in-place": find the row where `SourceId == MetaSourceIdBase + Id`; if present **SET** `Value = ValuePerTier * newTier` (and `Target/Op`); else **APPEND** it. Absolute value (not accumulate), keyed on the meta SourceId.
|
||||
|
||||
**Pre-collect class members (R-F2 — folded in).** "Every spawned player of that class" must **not** nest a `SystemAPI.Query` inside the Bursted drain `foreach`. Before the drain loop, collect `Entity`s with `PlayerClass.ClassId == classId` into a `NativeList<Entity>` (via a `BufferLookup<StatModifier>`), then upsert each — the `AbilityUpgradeSystem.cs:39-42` `playerByConn` pre-build precedent.
|
||||
|
||||
### C.4 Born-correct spawn seeding (`GoInGameServerSystem`, same ECB as `Instantiate`)
|
||||
|
||||
After the existing `AbilityRef` + `ClassTraits.AppendSeeds`, add `PlayerClass{ClassId}` and seed permanent meta: for each `MetaTierState` row of this class, **skip tier 0, skip unknown catalog id (`FindDef < 0` — preserve-don't-crash, the `BaseRestoreSystem` precedent), clamp `tier = min(saved, MaxTier)` (D-F5)**, then `AppendToBuffer` a `StatModifier { Target, Op, Value = ValuePerTier*tier, SourceId = MetaSourceIdBase + Id }`. Rides the `OwnerSendType.All` `StatModifier` buffer → rollback-correct via the unchanged `StatRecomputeSystem`, identical to class seeds.
|
||||
|
||||
**Availability guard wraps the WHOLE iteration (N2 — folded in).** `GoInGameServerSystem.cs:53,68` instantiates the player and destroys the request in one iteration; a guard scoped to only the seeding block would spawn a meta-less player *and* eat the request. **Fix: if the meta singletons (`MetaUpgradeCatalogBlob`, director `MetaTierState`) are absent the tick a `GoInGameRequest` is processed, `continue` without instantiating or destroying — retry next tick.** A no-op in practice (director + catalog come up at subscene-stream, before any `GoInGame` round-trip, per commit `86575dd5b`), which makes the born-correct guarantee unconditional.
|
||||
|
||||
### C.5 SaveData v6 — additive; `MinLoadableVersion` stays 2
|
||||
|
||||
- **New fields (array *field*, not a root array):** `MetaUpgradeSave { byte ClassId, UpgradeId, Tier }[] MetaUpgrades` (sparse — absent row = tier 0), `int RunsCompleted`, `int MaxDepthReached`. `SaveData.CurrentVersion → 6`.
|
||||
- **`SaveService.Load` gains `data.MetaUpgrades ??= Array.Empty<MetaUpgradeSave>();`** (the `Ledger ??=` precedent — else a NullRef at staging). Counters 0-default; **unknown `UpgradeId` rows are preserved** (forward-compat) but skipped for seed/shop/spend.
|
||||
- **Baseline's optional `CharacterBoon[]` is DROPPED** (fork #3 → boons don't persist). Two crisply-distinct save shapes, no channel bleed.
|
||||
- **Born-correct restore, `MetaCounters` added UNCONDITIONALLY (D-F2 — folded in, resolves the §1.2↔§2.1 contradiction).** `CycleDirectorSpawnSystem`: `AddComponent(dir, default(MetaCounters))` **unconditionally at spawn** (mirroring the unconditional `CycleRuntime`/`ThreatState`/`RunPhase`/`SaveRequest` adds), so New-Game / no-`PendingSave` boots have the component the bank block reads. Only **inside** the existing `HasData != 0` block: `ecb.SetComponent(dir, MetaCounters{RunsCompleted, MaxDepthReached})` and `ecb.SetBuffer<MetaTierState>(dir)` from a new staged `PendingMetaRow { byte ClassId, UpgradeId, Tier }` buffer (sibling of `PendingSaveLedgerRow`) — **before Playback, same ECB as `Instantiate`** (no default-empty `[GhostField]`-buffer flicker). New game → empty `MetaTierState` + zeroed `MetaCounters`.
|
||||
- **`WorldLauncher.StagePendingSave`** copies `SaveData.MetaUpgrades → PendingMetaRow` + the two counters (null-guarded). **`PendingSave` gains `RunsCompleted, MaxDepthReached`.**
|
||||
- **`RunDirector` credits `MetaCounters` in the once-per-`RunEpoch` bank block, CLEAR-GATED (D-F3 — folded in).** The `Returning` bank block (§2.3 step 3) is reached on boss-clear, party-wipe, or all-disconnect. The credit is split:
|
||||
- **Always (any terminal):** set `LastBankedRunEpoch`, `SaveRequest.Pending=1`, clear `PlayerReady`, strip boons (§A.1), and `MaxDepthReached = max(MaxDepthReached, actualRoomsCleared)` — **actual depth, never the planned `RoomCount`** (a 1-room abort must not record the planned length).
|
||||
- **Boss-clear only (`RunRuntime.LastTerminalCleared == 1`, set on the `RoomReward→Returning` Boss edge, 0 on abort edges):** `GoalProgress.Charge += 1` (win meter), `RunsCompleted++`, and the retaliation carries `ThreatState.PendingReturns++`/`ExpeditionsCompleted++` (C7). An aborted/wiped run banks no win credit and provokes no retaliation — only the depth high-water is recorded.
|
||||
|
||||
- **Must NOT persist:** run boons (per-player `StatModifier` buffers — never touched, stripped on `Returning`); `RunInfo`/`RunRuntime`/`RunMap` (session-scoped; boot always in `Staging`); `PlayerReady`; `BoonOffer`; `RouteCommand`.
|
||||
|
||||
---
|
||||
|
||||
## D. CONSOLIDATED INVENTORY DELTA (plugs into §3)
|
||||
|
||||
### D.1 Components & `[GhostField]`s (extends §3.1)
|
||||
|
||||
| Component | Status | Where / replication | Re-bake |
|
||||
|---|---|---|---|
|
||||
| `RunMapNode`, `RunMap`, `RoomTypeId` | NEW (`RunMap.cs`, pure data/consts) | transient (regenerated); not `IComponentData` | none |
|
||||
| `RunInfo` | CHANGED (§3.1 NEW) | director ghost; §3.1's 6 `[GhostField]`s **+9 branching** (`RunSeed`,`CurrentCol`,`RouteOptionCount`,`RouteOpt0/1/2Col`,`RouteOpt0/1/2Type`) **+2 HUD mirror** (`RunsCompleted`,`MaxDepthReached`) = **17 total** | folds into the **one** director re-bake |
|
||||
| `MetaTierState {byte ClassId,UpgradeId,Tier}` | NEW | director ghost, `[GhostField]` buffer (untagged/global → all clients); baked empty | folds into the **one** director re-bake |
|
||||
| `RunRuntime` | CHANGED (§3.1 NEW) | director, server-only; **+`int CurrentNodeId`,`byte CurrentCol`,`byte CurrentRoomType`,`uint RouteGraceTick`,`byte LastTerminalCleared`** (branching+clear-gate) **+`uint BoonPickCounter`** (boons) | none |
|
||||
| `RouteCommand {byte HasPick,OptionIndex; int ForRunEpoch,ForLayer}` | NEW | director, server-only singleton, `AddComponent` at spawn | none |
|
||||
| `MetaCounters {int RunsCompleted,MaxDepthReached}` | NEW | director, server-only, **`AddComponent` UNCONDITIONALLY** at spawn (D-F2) | none |
|
||||
| `PlayerClass {byte ClassId}` | NEW | player, server-only, `AddComponent` at spawn | none |
|
||||
| `PlayerReady`, `BoonOffer` | NEW (§3.1) | player ghost; both added at the **one** player re-bake | player re-bake |
|
||||
| `RoomTag {byte Room}` | NEW (§3.1) | room-scoped ghosts, server-only | none |
|
||||
| `MetaUpgradeDefBlob`, `MetaUpgradeCatalogBlob` | NEW | config singleton, both worlds, **not replicated** | none |
|
||||
| `RoomArchetypeBlob`, `BoonCatalogBlob` | NEW (§3.1) | config singletons, not replicated | none |
|
||||
| `PendingSave` (+`RunsCompleted`,+`MaxDepthReached`) · `PendingMetaRow` buffer | CHANGED/NEW | staged in ServerWorld pre-stream, server-only | none |
|
||||
| `StatModifier` | CHANGED (attr only) | `[InternalBufferCapacity(8→32)]` (R-F4/N5); **no `[GhostField]` change → no re-bake** | none |
|
||||
| `Effective*Stats`, `GoalProgress`, `RunOutcome`, `CoreIntegrity`, `RunPhase`, `ThreatState`, `RegionTag` | unchanged | boons/meta append rows; no new member | none |
|
||||
|
||||
### D.2 Pure math (extends §3.2)
|
||||
|
||||
| Item | Status | Note |
|
||||
|---|---|---|
|
||||
| `RunMapMath` (`Generate`, `ReachableOptions`, BFS) | NEW | integer-hash only; EditMode-tested (§B.2 invariants) |
|
||||
| `RoomLayoutMath.Plan` | CHANGED signature | `Plan(runSeed, nodeId, layer, byte roomType, roomCount, blob)`; room-type arc **moved to `RunMapMath`**; `PickBoons`/`ScatterInShape` keep their `ref Random` seeds |
|
||||
| `TimedModifierUtil.RemoveBySourceIdRange(mods, lo, hiExclusive)` | NEW | 3-line sibling of `RemoveBySourceId` |
|
||||
|
||||
### D.3 Systems (ordering obeys the §3.2 no-CyclePhase-edge hard rule)
|
||||
|
||||
| System | Status | Group / ordering |
|
||||
|---|---|---|
|
||||
| `ReadyToggleSystem` | NEW (§3.2) | ServerSim, `[UpdateBefore(RunDirectorSystem)]` |
|
||||
| `RouteSelectSystem` | NEW | ServerSim, `[UpdateBefore(RunDirectorSystem)]` — drain, validate (+region gate N3), **in-place `RouteCommand` latch (N1)**; no CyclePhase edge |
|
||||
| `RunDirectorSystem` | CHANGED (§3.2 NEW) | ServerSim; keeps `[UpdateAfter(ReadyToggleSystem)]`,`[UpdateBefore(GoalReachedSystem)]`,`[UpdateBefore(CyclePhaseSystem)]`. **+`RouteSelect` state + branching advance + publish `RunSeed`/`CurrentNodeId`/`CurrentRoomType` + boon-strip + clear-gated `MetaCounters`/`Charge`/retaliation bank** — ordering unchanged |
|
||||
| `RoomFieldSystem` | CHANGED (from `ExpeditionFieldSystem`) | ServerSim, `[UpdateAfter(RunDirectorSystem)]`, **drop inherited `[UpdateAfter(CyclePhaseSystem)]`**; read `RunRuntime.CurrentNodeId` + new `Plan` |
|
||||
| `RoomEnemyDirectorSystem` | CHANGED (from `ZoneEnemyDirectorSystem`) | ServerSim, `[UpdateAfter(RunDirectorSystem)]`, **must NOT add `[UpdateBefore(CyclePhaseSystem)]`**; read `RunRuntime.CurrentNodeId`/`CurrentRoomType` + new `Plan` |
|
||||
| `BoonOfferSystem` | NEW (§3.2) | ServerSim, `[UpdateAfter(RunDirectorSystem)]` |
|
||||
| `BoonApplySystem` | CHANGED (§3.2 NEW) | ServerSim, `[UpdateAfter(BoonOfferSystem)]`; **gate `Lifecycle==RoomReward` (D-F4)** + SourceId band + `BoonPickCounter` |
|
||||
| `MetaSpendSystem` | NEW | ServerSim, plain `SimulationSystemGroup`, `[BurstCompile]`, **no room-chain/CyclePhase edge**; phase gate (N4) + DR-014 in-loop atomicity + pre-collected class members (R-F2) + upsert (R-F1) + clamp (D-F5) |
|
||||
| `ThreatDirectorSystem` | CHANGED (edge only) | **repoint `[UpdateAfter(typeof(ExpeditionGateSystem))]` → `[UpdateAfter(typeof(RunDirectorSystem))]` at Step 11 (D-F1)** — fixes the compile break on `ExpeditionGateSystem` deletion *and* the undefined RunDirector→ThreatDirector consume order |
|
||||
| `ReadySendSystem`, `BoonSendSystem`, `RouteSendSystem`, `MetaSpendSendSystem` | NEW (2 §3.2 + 2 new) | ClientSim, `SystemBase`, static enqueue + drain |
|
||||
| `CycleDirectorSpawnSystem` | CHANGED | born-correct `RunRuntime` + `RouteCommand` + `MetaTierState` + **unconditional `MetaCounters`** (D-F2); ordering unchanged |
|
||||
| `GoInGameServerSystem` | CHANGED | +`PlayerReady=0` +`PlayerClass` + born-correct meta seeding (whole-iteration guard N2, clamp D-F5, skip-unknown) |
|
||||
| `HudSystem` | CHANGED | ready panel · Room i/N · boon modal · **branching map panel** (regen from `RunInfo.RunSeed` + clickable `RouteOpt*`) · **base meta-shop panel** (Staging-only) · **`AbilityUpgrade` button removed (R-F3)** |
|
||||
| `BuildSendSystem` | CHANGED (R-F3 — **added to CHANGED**) | strip `UpgradeAbility`/`SendUpgrade`/`uKey` at Step 11 or `ProjectM.Client` fails to compile |
|
||||
| `WorldAtmosphereSystem` | CHANGED (§3.2) | biome cross-fade from `RunInfo.CurrentBiome` |
|
||||
| `SaveService`/`SaveWriteSystem`/`WorldLauncher` | CHANGED | v6 stage + write + `??= Array.Empty` |
|
||||
| `ExpeditionGateSystem`, `BaseFieldSpawnSystem`(+`BaseFieldRuntime`), `AbilityUpgradeSystem` | RETIRED (§3.2) | retaliation carried to `RunDirector`; base nodes removed; in-run Aether spend removed (machinery kept) |
|
||||
| `RegionRelevancySystem`, `StatRecomputeSystem`, `RegionTransitSystem` | REUSED unchanged | relevancy / stat-fold / teleport |
|
||||
|
||||
**Resulting acyclic server chain (no CyclePhase edge):** `ReadyToggleSystem, RouteSelectSystem → RunDirectorSystem → {RoomFieldSystem, RoomEnemyDirectorSystem, BoonOfferSystem → BoonApplySystem}`; `RunDirector → ThreatDirector → CyclePhase` (after the Step-11 repoint); `MetaSpendSystem` unordered/plain.
|
||||
|
||||
### D.4 RPCs (extends §3.3)
|
||||
|
||||
| RPC | Status | Payload | Notes |
|
||||
|---|---|---|---|
|
||||
| `ReadyToggleRequest` | NEW (§3.3) | `byte Ready` | unconditional wire |
|
||||
| `BoonPickRequest` | NEW (§3.3) | `byte Index` | unconditional wire |
|
||||
| `RouteSelectRequest` | NEW | `byte OptionIndex; int ForRunEpoch; int ForLayer` | unconditional; index into replicated `RouteOpt*` |
|
||||
| `MetaSpendRequest` | NEW | `byte UpgradeId` | unconditional; tier server-computed |
|
||||
| `AbilityUpgradeRequest` | RETIRED | — | dead-but-present unconditional struct until the single coordinated bump (Step 11) |
|
||||
|
||||
### D.5 Combined churn line (unchanged envelope vs §3.4)
|
||||
|
||||
- **Ghost re-bakes — exactly 2.** (1) **Director** — `+RunInfo` (17 `[GhostField]`s) **+ `MetaTierState` `[GhostField]` buffer**, one re-bake at Step 2. (2) **Player** — `+PlayerReady` + `+BoonOffer`, one re-bake at Step 3. (`RunRuntime`, `RouteCommand`, `MetaCounters`, `PlayerClass`, `PendingSave`/`PendingMetaRow` server-only → no re-bake; the `StatModifier` `InternalBufferCapacity` bump is chunk-internal → no re-bake.)
|
||||
- **RpcCollection hash — exactly 1 coordinated bump at the release boundary: net +4 (`ReadyToggle`, `BoonPick`, `RouteSelect`, `MetaSpend`) / −1 (`AbilityUpgradeRequest`).** All four new structs unconditional + declared at Step 3; `AbilityUpgradeRequest` stays a dead wire until Step 11 removes it (dev-time intermediate hashes are harmless — all local/MPPM peers rebuild together).
|
||||
- **Subscene save — 1:** the `MetaUpgradeCatalogBlob` config singleton, batched with the RoomArchetype/BoonCatalog authoring.
|
||||
- **SaveData v5 → v6, additive, `MinLoadableVersion` stays 2:** `+MetaUpgradeSave[] MetaUpgrades` (array *field*), `+int RunsCompleted`, `+int MaxDepthReached`; `CharacterBoon[]` dropped.
|
||||
|
||||
---
|
||||
|
||||
## E. REVISED BUILD SEQUENCE (how §4's 11 steps change; +3 inserted; each dependency-ordered + validatable in isolation)
|
||||
|
||||
**Re-bake discipline (integration invariant, risk G):** finalize both ghost layouts at their single re-bake point — **director @ Step 2** (`RunInfo` full 17-field set + `MetaTierState` buffer), **player @ Step 3** (`PlayerReady` + `BoonOffer`). Declare all four new RPC wire structs at Step 3. Writer systems arrive later; the inert dead state is harmless. **Adversarial design-review BEFORE Step 8 and before the Step 12–13 meta block.** Re-run the clean netcode Play boot after every ghost-hash-affecting step. Edit Assets `.cs` only via MCP.
|
||||
|
||||
### Phase A — lifecycle skeleton (no rooms)
|
||||
- **Step 1** — *(edits §4 Step 1)* **+ `RunMap.cs`, `RunMapMath.cs`, `RunMapMathTests.cs`; `RoomLayoutMath.Plan` gets the new signature (room-type arc moved to `RunMapMath`).** *Validate (EditMode):* baseline `Plan` determinism + all §B.2 `RunMapMath` invariants (integer-only, reachability, single Boss, ≥1 Elite per path, `LayerCount∈[6,10]`); 368 prior tests green.
|
||||
- **Step 2** — *(edits §4 Step 2 — the sort-cycle Play point)* CREATE `RunInfo.cs` (**all 17 `[GhostField]`s**), `RunRuntime.cs` (all fields incl. `CurrentNodeId`/`LastTerminalCleared`/`BoonPickCounter`), `Simulation/Meta/MetaComponents.cs` (define `MetaTierState`, `RouteCommand`, `MetaCounters`, `PlayerClass`), `RunDirectorSystem.cs` (stub loop with **real sub-slot teleport**, land on `(0,0)`, publish `RunSeed` at Launch, F2 launch guard, F12 write-above-early-return). MODIFY `CycleDirector.prefab` (`+RunInfo` + `MetaTierState` buffer → **the one director re-bake**), `CycleDirectorSpawnSystem` (stage `RunRuntime`+`RouteCommand`+empty `MetaTierState`; **`AddComponent` `MetaCounters` UNCONDITIONALLY** (D-F2); born-correct `Lifecycle=Staging`). *Validate:* re-bake clean; Play-boot both worlds `Lifecycle==Staging`; force-launch → Staging→Launching→(stub)→Returning→Staging with teleport; **confirm world creation throws no `ComponentSystemSorter` cycle** (the `[UpdateBefore(CyclePhase/GoalReached)]` edges first exist here).
|
||||
- **Step 3** — *(edits §4 Step 3 — front-load wire + player layout)* CREATE `PlayerReady.cs`, `BoonOffer.cs`, **all four RPC structs** (`ReadyToggleRequest`, `BoonPickRequest`, `RouteSelectRequest`, `MetaSpendRequest`), `ReadyToggleSystem.cs`, `ReadySendSystem.cs`. MODIFY player prefab (**`+PlayerReady` + `+BoonOffer` → the one player re-bake**; `BoonOffer` inert until Step 9), `GoInGameServerSystem` (`PlayerReady=0` **+ `PlayerClass`**). Keep `AbilityUpgradeRequest` (dead). *Validate:* EditMode ready-toggle flip + Staging-gate; Play (MPPM 2c) launches only at N/M==M; RPC hash matches; staging disconnect recomputes count.
|
||||
|
||||
### Phase B — rooms & linear traversal
|
||||
- **Step 4** — *(= §4 Step 4)* `RoomTag` + teardown helper + `RoomTeardownTests` (cross-room-wipe regression).
|
||||
- **Step 5** — *(edits §4 Step 5)* `RoomFieldSystem` reads `RunRuntime.CurrentNodeId` + new `Plan`. *Validate:* equality-reseed once per `RoomEpoch`; budget floor; baked Scale preserved.
|
||||
- **Step 6** — *(edits §4 Step 6)* `RoomEnemyDirectorSystem` reads `RunRuntime.CurrentNodeId`/`CurrentRoomType` + new `Plan`; Boss branch; `MaxAlive`; `ExpeditionObjective` above early-return.
|
||||
- **Step 7** — *(edits §4 Step 7)* multi-room **LINEAR** advance (col fixed at 0 — validate the traversal chassis before branching); consume `ExpeditionObjective.State`; teardown→empty→bump `RoomEpoch`→teleport; Boss→Returning; **clear-gated bank (D-F3):** always-once-per-epoch bookkeeping + `MaxDepthReached`=actual depth; boss-clear-only `Charge`/`RunsCompleted`/`ThreatState` carries; publish `RunRuntime.CurrentNodeId`/`CurrentRoomType`. *Validate (Play):* full linear loop; exactly one room's ghosts alive; no cross-arena aggro; bank fires once; a fabricated 1-room abort records actual depth + no `Charge`/`RunsCompleted` credit.
|
||||
|
||||
### Phase B2 — branching (netcode-heavy — REVIEW FIRST)
|
||||
- **Step 8** — *(NEW)* CREATE `RouteSelectSystem.cs`, `RouteSendSystem.cs`. MODIFY `RunDirector` (add `RouteSelect` state; consume `RouteCommand`; branching advance replaces linear col-0; publish `CurrentNodeId`/`CurrentRoomType`; `RouteGraceTick`). **In-place `RouteCommand` latch (N1); sender region gate (N3).** *Validate:* adversarial review before; Play-boot after — no sort-cycle; exactly one room alive across a branch; **client-regen map at `(CurrentRoom,CurrentCol)` matches replicated `CurrentRoomType`, integer-only, buttons driven by `RouteOpt*` (D-F6 criteria)**; first-commit latch + `ForRunEpoch`/`ForLayer` reject stale picks; grace auto-pick = lowest-index reachable, deterministic; a base-bound joiner's route pick is rejected.
|
||||
|
||||
### Phase C — run-scoped boons
|
||||
- **Step 9** — *(edits §4 Step 8)* `BoonCatalogBlob` + SOs + `BoonOfferSystem` (`BoonOffer` already on the prefab). *Validate (Play 2c):* owner-only offer delivery, distinct per player (seed = `Hash(RunSeed, CurrentRoom, NetworkId.Value)` — see R-F7 in §F).
|
||||
- **Step 10** — *(edits §4 Step 9)* `BoonApplySystem` + `BoonSendSystem` + the two-channel correction. ADD `Tuning.BoonSourceIdBase/Span`, `RunRuntime.BoonPickCounter`, `TimedModifierUtil.RemoveBySourceIdRange`, the `Returning`-edge strip in `RunDirector`; **gate `BoonApplySystem` on `Lifecycle==RoomReward` (D-F4)**; **raise `StatModifier` `InternalBufferCapacity` 8→32 (R-F4/N5).** *Validate (EditMode):* a pick appends a boon-band mod; a fabricated `Returning` bank tick strips **all** boon-band rows, **leaves** class (`0x00C1A550`) + meta (`0x00E7A000`) rows, and zeroes `BoonOffer.Pending`; a pick on a non-`RoomReward` tick is rejected; strip fires **once** across a multi-tick `Returning`. Play: picker's `Effective*Stats` rise, revert on return.
|
||||
|
||||
### Phase D — economy + the single coordinated churn
|
||||
- **Step 11** — *(edits §4 Step 10)* Remove `BaseFieldSpawner` from the subscene; idle→retire `BaseFieldSpawnSystem`/`BaseFieldRuntime`; node-budget cap + rarity; retire `AbilityUpgradeSystem`, **delete `ExpeditionGateSystem` and repoint `ThreatDirectorSystem` `[UpdateAfter]` → `RunDirectorSystem` (D-F1)**, and **remove `AbilityUpgradeRequest`** (the **one** coordinated hash bump, net +4/−1). **Co-strip `BuildSendSystem.UpgradeAbility`/`SendUpgrade`/`uKey` + `HudSystem` Aether-upgrade button + affordability tint + HowToPlay copy (R-F3)** or `ProjectM.Client` won't compile. **Keep Aether** as a rare expedition resource (fork #1c). *Validate (Play):* **no `ComponentSystemSorter` cycle after the ThreatDirector repoint**; zero base nodes; grubstake-launchable; scarce capped nodes; building still spends Ore/Biomass; no Aether *upgrade* path; RPC hash matches across freshly-built peers; client compiles.
|
||||
|
||||
### Phase E — permanent meta (REVIEW the block first; R-F6 split for isolation)
|
||||
- **Step 12a** — *(NEW)* catalog + born-correct seeding + SaveData v6 plumbing (no wire, no disk yet). CREATE `MetaUpgradeDefBlob.cs`, `MetaUpgradeCatalogBlob.cs`, `Authoring/Meta/MetaUpgradeDefinition.cs`+baker (author the catalog singleton into the subscene, batched). MODIFY `GoInGameServerSystem` (`PlayerClass` + meta seeding: whole-iteration guard N2, clamp D-F5, skip-unknown), `RunDirector` (`MetaCounters` bump in the clear-gated bank block). *Validate (EditMode):* fabricate a director with `MetaTierState` rows → a spawned Warrior gains correct meta `StatModifier`s (`Value=ValuePerTier*tier`, `SourceId=base+id`); a Ranger-masked upgrade is skipped for a Warrior; an unknown id is skipped; a saved tier above a lowered `MaxTier` is clamped.
|
||||
- **Step 12b** — *(NEW)* disk round-trip. MODIFY `SaveData`→v6 (`MetaUpgradeSave[]`,`RunsCompleted`,`MaxDepthReached`), `SaveService` (`??= Array.Empty`), `PendingSave`/`PendingMetaRow`, `WorldLauncher.StagePendingSave`, `SaveWriteSystem`, `CycleDirectorSpawnSystem` (born-correct `MetaTierState` + `SetComponent MetaCounters` inside `HasData`; preserve-unknown-id). *Validate (Play):* `MetaTierState` born-correct on both worlds after a Continue load; boot in `Staging`; old v5 saves 0-default-load.
|
||||
- **Step 13** — *(NEW)* meta-spend RPC systems. CREATE `MetaSpendSystem.cs` (phase gate N4, DR-014 in-loop atomicity, pre-collected class members R-F2, upsert R-F1, clamp D-F5), `MetaSpendSendSystem.cs` (`MetaSpendRequest` declared Step 3). *Validate (EditMode):* valid purchase withdraws the exact escalating Aether, bumps `MetaTierState`, upserts the live meta `StatModifier` (append on 0→1, set on n→n+1); past-`MaxTier`/unaffordable/wrong-class/unmet-prereq/non-Staging soft-fail with no withdraw; two same-tick requests on barely-enough Aether → only one succeeds. Play (2c): purchase updates both clients' `MetaTierState`, buyer's `Effective*Stats` rise immediately, save→reload restores tiers + re-seeds a fresh player.
|
||||
|
||||
### Phase F — presentation & save polish
|
||||
- **Step 14** — *(edits §4 Step 11)* MODIFY `HudSystem` (ready panel · Room i/N · 3-card boon modal → `BoonSendSystem.PickBoon` · **branching map panel** regen from `RunInfo.RunSeed` with clickable `RouteOpt*` → `RouteSendSystem.PickRoute` · **base meta-shop panel** Staging-only: catalog for the local class + owned tier from `MetaTierState` + Aether from ledger + next-tier cost → `MetaSpendSendSystem.RequestPurchase`); `WorldAtmosphereSystem` biome cross-fade. *Validate (Play):* screenshot each panel; route click commits; biome varies per room; win-spine still fires. (`RoomCoverPropSystem` descoped from v1 per §5 C6.)
|
||||
|
||||
---
|
||||
|
||||
## F. RESOLVED RISKS (extends §5 — every CONFIRMED finding + how the design handles it)
|
||||
|
||||
| ID | Finding | Resolution in this addendum |
|
||||
|---|---|---|
|
||||
| **D-F1** *(blocker)* | Retiring `ExpeditionGateSystem` dangles `ThreatDirectorSystem`'s `[UpdateAfter(typeof(ExpeditionGateSystem))]` (hard compile error) + leaves RunDirector→ThreatDirector consume order undefined | Repoint to `[UpdateAfter(typeof(RunDirectorSystem))]` **at Step 11** (the deletion step); interim (Steps 7–10) `ExpeditionGateSystem` idles (walk-in trigger never fires under ready-check launch, so no double-write of `PendingReturns`); the one-tick-late consume tolerates the interim. Re-Play-validate for a sort cycle. |
|
||||
| **R-F3** *(blocker)* | Removing `AbilityUpgradeRequest` breaks `ProjectM.Client` — `BuildSendSystem.cs:51,121,129,312` still enqueues it, `HudSystem.cs:851/258-260` wires the button | Step 11 co-strips `BuildSendSystem.UpgradeAbility`/`SendUpgrade`/`uKey`, the `HudSystem` Aether-upgrade button + affordability tint, + HowToPlay copy; `BuildSendSystem.cs` added to CHANGED (§D.3). |
|
||||
| **D-F2** *(blocker)* | `MetaCounters` added only inside `HasData` → New-Game NRE / Continue double-add; §1.2↔§2.1 contradiction | `AddComponent(MetaCounters)` **unconditionally at director spawn** (mirrors `ThreatState`/`RunPhase`); `SetComponent` restored values only inside `HasData` (§C.5, Step 2). |
|
||||
| **N1 / R-F5** *(major)* | Route first-commit latch is receipt-order-arbitrary if `RouteCommand.HasPick` is written via a hoisted read / deferred ECB | **In-place `SystemAPI.SetComponent(RouteCommand)` inside the drain loop** (DR-014); only the request `DestroyEntity` on the ECB (§B.5). |
|
||||
| **D-F3** *(medium)* | Unconditional `Returning` bank credits `Charge`/`RunsCompleted`/`MaxDepthReached` on an abort/wipe; records planned `RoomCount` | Split bank: always-once bookkeeping + `MaxDepthReached`=**actual depth**; boss-clear-only (`RunRuntime.LastTerminalCleared==1`) `Charge`/`RunsCompleted`/retaliation (§C.5, Step 7). |
|
||||
| **D-F4** *(medium)* | A grace-timeout straggler `BoonPickRequest` drained after the `Returning` strip leaks a run | `BoonApplySystem` gates on `Lifecycle==RoomReward`; strip zeroes all `BoonOffer.Pending` (§A.1, Step 10). |
|
||||
| **R-F1** *(medium)* | Live meta apply mis-specified as "grow-in-place"; 0→1 has no existing row | **Upsert** keyed on `MetaSourceIdBase+Id`: append on 0→1, **set** absolute `Value=ValuePerTier*tier` otherwise (§C.3). |
|
||||
| **R-F4 / N5** *(medium)* | Per-owned-upgrade + per-pick rows overflow `[InternalBufferCapacity(8)]` into a heap-allocated owner-replicated buffer | Raise to **32** (chunk-internal, **no re-bake**); overflow spills to heap (perf, not correctness). Keep per-pick boons + range-strip (op-folding is `PercentMult`-wrong); fork #5b alone does not reduce rows (§A.4, §G). |
|
||||
| **R-F2** *(low-med)* | "Grow every player of that class" naively nests a query in a Bursted drain | Pre-collect class members into a `NativeList<Entity>` before the drain (the `playerByConn` precedent) (§C.3). |
|
||||
| **N2** *(minor)* | Born-correct meta guard must precede `Instantiate`+`Destroy`, not just the seeding block | The availability guard wraps the **whole** `GoInGame` iteration — `continue` without instantiating/destroying; retry next tick (§C.4). |
|
||||
| **N3** *(minor)* | Route pick lacks a sender-region gate → a base-bound joiner could commit the party's route | `RouteSelectSystem` requires the sender's `RegionTag.Region == RegionId.Expedition` (§B.5). |
|
||||
| **N4 / R-F9** *(minor)* | `MetaSpendSystem` has no server phase gate ("base-hub only" was HUD-only) | Reject unless `RunInfo.Lifecycle==Staging`, server-side (§C.3). |
|
||||
| **D-F5** *(low-med)* | A rebalance lowering `MaxTier`/`ValuePerTier` over-applies a saved higher tier | `tier = min(saved, MaxTier)` at seed/shop/spend; `UpgradeId`/`ClassId` documented **append-only** (§C.3–C.4). |
|
||||
| **R-F6** *(process)* | Step 12 bundled ~9 co-validated files, breaking one-system-at-a-time | Split into **12a** (seed from fabricated `MetaTierState`) / **12b** (disk round-trip) (§E). |
|
||||
| **R-F7** *(low, accuracy)* | No stable `PlayerSlot` component exists — `GoInGameServerSystem` uses `NetworkId.Value` | Boon-offer seed = `Hash(RunSeed, CurrentRoom, NetworkId.Value)` (corrects §2.4's "PlayerSlot"); reconnect-stable offers need a real slot component (deferred). Meta keys on `ClassId` → unaffected. |
|
||||
| **R-F11** *(low, doc)* | DR-037 said "run-scoped via `TimedModifier`"; a seed-varied run has no fixed end tick | The `Returning`-edge range-strip **supersedes** the fixed-duration timer for boons; recorded so no reader reunifies onto a countdown (§A.1). |
|
||||
| **R-F12** *(low, guardrail)* | New folders must not spawn new asmdefs | `Simulation/Meta`, `Server/Meta`, `Client/Meta`, `Authoring/Meta`, `Client/World` are `.cs`-only inside the four existing asmdefs — never a `.asmdef` (CLAUDE.md). |
|
||||
| **D-F6** | Client map-regen integer-only + clickable-from-`RouteOpt*` | REFUTED as a defect — it is the design's own constraint; **kept as Step-8 validation criteria** (§B.2, §E Step 8). |
|
||||
| **R-F8** | Four near-identical client send systems vs consolidation | REFUTED as a defect — functionally correct; baseline already specifies separate send systems. No change. |
|
||||
| **R-F13** | Two same-class players share one meta pool | REFUTED as a defect — intended drop-in-co-op semantics per lock #3; one-line operator confirm only (§G). |
|
||||
|
||||
---
|
||||
|
||||
## G. OPEN OPERATOR FORKS (genuine gameplay calls — NOT decided here)
|
||||
|
||||
**Resolved by the four locks (recorded, not re-opened):** §6 #1 → Aether = base meta-spend (kept as a rare resource; in-run spend retired); §6 #3 → boons per-run + strip (meta is the permanent channel); §6 #5 → run length seed-varied `[6,10]`. Meta key granularity → per-class (lock #3), not a fork.
|
||||
|
||||
1. **WHO chooses the route in co-op** *(the headline branching call).* (a) **any-player-first-commits** *(recommended — lowest friction, netcode-simplest, degrades to solo; the in-place first-accepted latch is what §B.5 builds by default)*; (b) majority-vote (needs a tally + tie-break + timeout UI); (c) host-decides. The chassis (in-place latch + `ForRunEpoch`/`ForLayer` reject + grace backstop) supports any.
|
||||
2. **Route grace-timeout auto-pick policy** *(minor).* (a) **lowest-index reachable** *(recommended — deterministic, built by default)*; (b) hash-random reachable; (c) type-priority.
|
||||
3. **Un-picked-boon policy on reward-grace** *(§6 #2, still open, minor).* (a) auto-clear (forgo); (b) **auto-pick Option0** *(recommended — player always gets something; symmetric with #2a)*.
|
||||
4. **Launch countdown vs instant launch** *(§6 #4, tunable).* `LaunchTick` supports a telegraphed 3-2-1 with an un-ready abort window; instant is simpler. Recommend the **countdown**.
|
||||
5. **Boon SourceId provenance** *(minor engineering fork; does NOT affect the R-F4/N5 buffer bound — that is fixed independently by `InternalBufferCapacity=32`).* (a) **per-pick distinct ids + range-strip** *(recommended — preserves provenance, boons stack as rows; built by default)*; (b) single `BoonSourceId` + one-call `RemoveBySourceId` (drops the counter/span/`…Range` helper). Fully reversible.
|
||||
6. **Meta tree-gating in v1** *(design fork; no code change either way — purely how the SOs are authored).* Ship a **flat catalog** (all `PrereqId=0xFF`) *(recommended for the first meta pass — validate the economy before gates)* or author real prereq trees now.
|
||||
7. **Shared per-class meta pool confirm** *(R-F13 — a one-line confirm, not a build fork).* Two players on the same class draw from and invest in one shared per-class tier record (lock #3). Confirm this is the intended drop-in-co-op semantic (recommended: yes).
|
||||
8. **1-of-N mid-run disconnect / co-op abandonment** *(§6 #6, product call).* Survivors continue; the dropped player misses the run; only a boss-clear terminal banks (D-F3). Should a dropped player rejoin the in-progress run (catch-up/spectate — a v2 deferral today)?
|
||||
|
||||
---
|
||||
|
||||
*Files.* **NEW:** `Simulation/World/{RunMap,RunMapMath,RouteSelectRequest}.cs`, `Server/World/RouteSelectSystem.cs`, `Client/World/RouteSendSystem.cs`, `Tests/EditMode/RunMapMathTests.cs`; `Simulation/Meta/{MetaUpgradeDefBlob,MetaUpgradeCatalogBlob,MetaComponents,MetaSpendRequest}.cs`, `Authoring/Meta/MetaUpgradeDefinition.cs`, `Server/Meta/MetaSpendSystem.cs`, `Client/Meta/MetaSpendSendSystem.cs`. **CHANGED:** `Simulation/World/{RunInfo,RunRuntime,RoomLayoutMath}.cs`, `Server/World/{RunDirectorSystem,CycleDirectorSpawnSystem}.cs`, `Server/AI/ThreatDirectorSystem.cs` *(edge repoint, D-F1)*, `Server/Combat/BoonApplySystem.cs`, `Simulation/Combat/{TimedModifier,StatModifier}.cs` *(`…Range` helper; capacity 8→32)*, `Simulation/Tuning.cs`, `Simulation/Persistence/{SaveData,SaveComponents,SaveService}.cs`, `Server/Persistence/SaveWriteSystem.cs`, `Client/UI/{WorldLauncher,HudSystem,BuildSendSystem}.cs` *(R-F3)*, `Server/Connection/GoInGameServerSystem.cs`, `Client/World/WorldAtmosphereSystem.cs`.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: 2026-07-01_Asset_Pipeline_Fabricator_Core
|
||||
type: note
|
||||
permalink: gamevault/07-sessions/2026/2026-07-01-asset-pipeline-fabricator-core
|
||||
---
|
||||
|
||||
# 2026-07-01 — Blender↔Unity asset pipeline + Fabricator & Awakening Core hero models
|
||||
|
||||
**Context:** New tooling landed this session: Blender MCP (live Python/bpy control of Blender, addon socket 9876) + MCP-for-Unity v10 (HTTP 127.0.0.1:8080, new `generate_model`/`import_model`/asset-gen tools) + `com.unity.cloud.gltfast` 6.19.0. Proved a full Synty-kitbash round-trip, then shipped two real assets with it.
|
||||
|
||||
## Proven pipeline (Blender ⇄ Unity, Synty round-trip)
|
||||
|
||||
1. `bpy.ops.import_scene.fbx` on pack `Models/*.fbx` — UV layer `map1` + atlas mapping survive.
|
||||
2. Kitbash freely. **New geometry** gets Synty-styled by pinning all UVs to a flat-color texel on the pack atlas (scan `image.pixels` for a uniform region — the Core's plinth did this).
|
||||
3. **Before export:** unparent (keep world matrix) + `transform_apply(rotation=True, scale=True)`. Blender keeps FBX-imported objects at scale 0.01 — exporting without applying writes per-node 0.01 scales, and *nested* ones multiply (a child hit 0.0001 = invisible in Unity).
|
||||
4. Export: `use_selection, object_types={'MESH'}, apply_unit_scale=True, apply_scale_options='FBX_SCALE_NONE', bake_space_transform=True, path_mode='STRIP', add_leaf_bones=False` → arrives exactly like a Synty original (`fileScale=0.01`, nodes at 1, meter-true bounds). `path_mode='COPY'` duplicates the atlas into `.fbm/` — don't.
|
||||
5. Unity import: `materialImportMode=None`, `addCollider=false` (a Default-layer collider would join the baked PhysicsWorld), assign the pack `.mat` (all packs share ONE ShaderGraph: `PolygonGeneric/Shaders/Generic_Basic` — URP-native, EG-verified).
|
||||
6. Blender viewport screenshots are BLACK when the window isn't drawing — render EEVEE to a file instead. Unity-side visual check: `AssetPreview.GetAssetPreview` kicked in one `execute_code` call, polled + Blit + `EncodeToPNG` in the next.
|
||||
|
||||
Pipeline reference demo: `Assets/_Project/Art/Experiments/` (gattling-gun twin kitbash).
|
||||
|
||||
## Shipped assets (both in `Assets/_Project/Art/Structures/`)
|
||||
|
||||
**`SM_Fabricator_01.fbx`** — 1-cell industrial converter, 1426 verts, ONE material slot (PolygonSciFiSpace atlas). Kitbash: Detail_Machine hull + half-sunk Detail_Vent drum + Detail_Tank (ore in) + Battery pack w/ orange cells (Charge out) + Screen_Small + AirVent. ~1.34×1.04×1.10 m.
|
||||
- **Wired into `Fabricator.prefab` in place** (root mesh+material swap, root scale 2.5→**1.0**; GUID preserved, GhostAuthoring/FabricatorAuthoring untouched — recipe 1 Ore→3 Charge/30t intact). Replaces the BefourStudios battery placeholder that overhung ~2.5 cells and sank 0.75 m underground. Placed visual now matches the 1 m preview-cube footprint.
|
||||
|
||||
**`SM_AwakeningCore_01.fbx`** — the base centerpiece, TWO meshes so materials stay independent:
|
||||
- `SM_AwakeningCore_Machine` (2808 v, SciFiSpace atlas — `PolygonScifiSpace_01_A.mat`): octagonal plinth (custom mesh, UV-pinned grey) + 3 upright Veh_Part_Engine exhaust stacks + shrunken Engine_Construction "awakening rig" facing outward.
|
||||
- `SM_AwakeningCore_Crystals` (355 v): DungeonRealms crystal clusters + accents → assigned existing `Mat_EngineCore_Aether` (keeps the established cyan-HDR-glow hero read; independently tintable for future CoreIntegrity feedback).
|
||||
- **Wired into `Game.unity`**: old `EngineCore` PrefabInstance (bare crystal) replaced by an FBX-linked instance `AwakeningEngineCore` under `BaseBiome` at (0,0,0); `CoreBeaconLight` untouched; static flags copied. Footprint r≈1.6 m / height ≈4.3 m — inside the 2.5 m spawn ring and reads at the 3 m `CoreReachRadius`; collider-free on purpose (returning players teleport to exactly PlotCenter).
|
||||
|
||||
## Validation
|
||||
|
||||
Play boot (subscene re-baked after the prefab edit): director/player/catalog up; Fabricator ghost spawned server-side via catalog-entry instantiate (BuildPlaceSystem's position-only override pattern) → **replicated + rendered client-side** at cell (3.5, 2.5) beside Storage; Core renders with crystal bloom + visible machine plinth/stacks. Zero console errors/warnings for the whole session.
|
||||
|
||||
## Round 2 (same day): Turret + Wall models + ACCURATE placement ghost
|
||||
|
||||
**`SM_Turret_01.fbx`** (3914 v, 2 slots): PolygonMech gattling gun (body+barrels+ammo, handheld bits dropped) on a SciFiSpace machine-slab + finned-drum joint (family language with the Fabricator). 0.67×2.13×1.32 m — barrels overhang the cell like the old ballista did. Wired into `Turret.prefab`: old 6-part ballista "Model" subtree replaced by one MeshFilter/MeshRenderer on the `Model` child (scale 0.8→1), mats `[PolygonScifiSpace_01_A, PolygonMech_01_A]`; root BoxCollider 0.8×1.2×0.8 / layer 9 / Health/Destructible untouched.
|
||||
|
||||
**`SM_Wall_01.fbx`** (42 v, 2 slots): two mirrored PolygonGeneric `SM_Bld_Base_Pillar_Half_01` end caps + custom energy panel + base rail (UV-pinned to a Generic-atlas grey texel). Panel slot gets **`Mat_StructureOwned_Cyan`** → the wall reads as a deployable cyan energy barrier, keeping the owned-structure identity. Long axis Z matches the old collider (0.59×0.6×0.98, untouched). Wired into `Wall.prefab` the same way (Model scale 1.2→1).
|
||||
|
||||
**Accurate build ghost** (`BuildSendSystem` + `HudTheme`): the palette ghost now renders the SELECTED structure's real mesh tinted translucent green/red instead of the generic cube.
|
||||
- `HudTheme` gained `TurretGhostMesh/WallGhostMesh/FabricatorGhostMesh` (+ `StructureGhostMesh(byte)`) — serialized mesh refs in `HudTheme.asset` (build-safe, DR-024 pattern).
|
||||
- `ShowGhost(center, cellSize, valid, type)` + `ApplyGhostMesh`: preview meshes are authored real-size/ground-pivot → position = cell center, scale 1; one `_ghostMat` per submesh so multi-slot meshes tint whole; **cube fallback preserved** for types without a mesh (no selection = 0 → cube). Rotation is a non-issue: `Direction` only ever mattered for conveyors (palette-trimmed).
|
||||
- Edits via MCP `apply_text_edits` (sha-chained, one edit per call, bottom-first).
|
||||
|
||||
**★ NEW GOTCHA — scene PrefabInstance of a raw FBX is reimport-fragile:** the AwakeningCore was first placed as an FBX PrefabInstance; a later `refresh_unity force` shifted the FBX's internal hierarchy fileIDs and the instance **silently dropped its Crystals child** (machine survived, crystal invisible in Play — caught only by the second Play screenshot). Scene YAML + FBX asset both looked intact; the binding was the casualty. **Fix + rule: reference mesh SUB-ASSETS directly** (fileID 4300000-series, name-stable across reimports — exactly what the structure prefabs do) from plain GameObjects or project prefabs; don't scene-instance raw multi-object FBX assets. Core rebuilt as plain `AwakeningEngineCore` GO + `CoreMachine`/`CoreCrystals` children with direct mesh refs.
|
||||
|
||||
**Validation round 2:** Play boot → spawned Turret/Wall/Fabricator row via catalog + teleport; all three render client-side; Core crystal back. Ghost preview forced on-screen (system disabled + reflection-invoked `ShowGhost`): green turret mesh at a free cell, red wall mesh on an occupied cell — both captured. **410/410 EditMode tests pass**; console clean (only unfocused-editor tick-batching warnings).
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
- Fabricator bakes **no Health/Destructible** (pre-existing divergence from DR-032 "machines can die") — flagged, not changed.
|
||||
- Core visual is still static — a client observe-only system could dim/flicker the crystal from `CoreIntegrity` (the two-mesh split was designed for this).
|
||||
- ~~Build-preview ghost stays a generic cube~~ **DONE (round 2)** — real meshes for Turret/Wall/Fabricator, cube fallback for the rest.
|
||||
- Old ballista/spike-wall looks live only in these prefabs' git history; `HudTheme` icons (`TurretIcon` etc.) still show the old Synty sprite glyphs — fine, but a re-curation pass could match the new silhouettes.
|
||||
- Blender asset integrations (PolyHaven/Sketchfab/Rodin/Hunyuan) still disabled in the addon N-panel; MCP-for-Unity v10 `generate_model`/`generate_image` (BYOK) untested.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Expedition Redesign — Build session, Steps 1–11 of 14 (interim log)
|
||||
date: 2026-07-02
|
||||
tags:
|
||||
- session
|
||||
- build
|
||||
- expedition
|
||||
- roguelite
|
||||
- netcode
|
||||
permalink: gamevault/07-sessions/2026/2026-07-02-expedition-redesign-build-steps1-11
|
||||
---
|
||||
|
||||
# Expedition Redesign — Build Steps 1–11 (interim session log)
|
||||
|
||||
Building the [[2026-06-29_Expedition_Redesign_Build_Spec]] (§§1–6 core + §7 addendum) one system at a time, each validated (compile → EditMode → Play-smoke) before the next, per the operator's full-depth directive. **Steps 1–11 of 14 are BUILT + GREEN.** Suite: 444/444 EditMode (from 429 pre-session; 10 superseded tests retired with their systems). Zero console errors across every Play session.
|
||||
|
||||
## What shipped (in build order)
|
||||
|
||||
1. **`RunMap`/`RunMapMath`/`RoomPlan`/`RoomLayoutMath`** — deterministic branching DAG generator (integer-hash only, client-regenerable), room plans, shape scatter. 19 tests incl. reachability/single-boss/Elite-gate invariants.
|
||||
2. **`RunInfo` (17 `[GhostField]`s) + `RunRuntime` + `RunDirectorSystem`** — the run FSM on the CycleDirector ghost (the ONE director re-bake, front-loaded with `MetaTierState`). `RegionMath.ExpeditionRoomOrigin(base, subSlot)` = the single coordinate authority (ping-pong slots, stride 500). Sort-cycle Play-validated.
|
||||
3. **Ready-check** — `PlayerReady` (send-to-all) + `BoonOffer` (`SendToOwner`) on the player ghost (the ONE player re-bake); all 4 RPC wire structs declared up front; `ReadyToggleSystem`/`ReadySendSystem`; real all-ready derivation + un-ready countdown abort. Live wire path validated.
|
||||
4. **`RoomTag` + `RoomTeardown`** — the type-agnostic room-scoped destroy (cross-room-wipe regression pinned).
|
||||
5. **`RoomFieldSystem`** (replaced `ExpeditionFieldSystem`) — per-RoomEpoch scarce scatter, `NodeBudgetRemaining` spend-down (`Tuning.ExpeditionNodeBudget`=12/run), clutter cap, Staging sweep. Relevancy-hiding Play-validated.
|
||||
6. **`RoomEnemyDirectorSystem`** (replaced `ZoneEnemyDirectorSystem`) — waves sized by `RoomPlan.DifficultyEpoch`, single scaled boss (HP×8, scale×1.6), objective Cleared latch above early-returns, Calm-gate deliberately dropped.
|
||||
7. **Linear traversal + clear-gated bank** — objective consumed one-tick-late; teardown-at-RoomReward-entry (one-room-alive invariant, Play-asserted); boss-only Charge/RunsCompleted/retaliation credit; honest `MaxDepthReached`. Full loop Play-validated end-to-end (incl. an accidental wipe-abort self-validation: idle player died → clean no-credit abort).
|
||||
8. **Branching route gate** *(review-gated: `wf_22770994-8e7`, GO with 4 must-fixes — all folded)* — `RouteSelect=5` state, `RouteSelectSystem` (in-place first-commit latch, N3 region gate), `RouteSendSystem`. **Key fix: `RouteSelectRequest.ForRunEpoch` RE-MEANED to carry `(int)RunInfo.RunSeed`** (the server-only RunEpoch is not client-knowable; the replicated seed is the run-identity token — zero wire churn). Predicate order abort→pick→grace. Live non-maskable proof: a client pick of option 2 entered col 2 (`NodeId(1,2)`).
|
||||
9. **Boon catalog + offers** — `BoonCatalog` blob (code-default 12-boon table, designer-overridable rows; `BoonCatalogAuthoring` in the Gameplay subscene), `BoonMath.PickBoons` (deterministic weighted distinct class-filtered), `BoonOfferSystem` (per-player seeds fold NetworkId). Live: owner-only `SendToOwner` delivery confirmed working.
|
||||
10. **Boon apply + the two-channel strip** — `BoonApplySystem` **`[UpdateBefore(RunDirectorSystem)]`** (deviation from the spec's after-ordering: all RPC receivers sit before the director; closes the D-F4 straggler race structurally) + grace auto-pick Option0; boon SourceId band `[0x00B00000, +0x10000)`; `TimedModifierUtil.RemoveBySourceIdRange`; the Returning-edge strip; `StatModifier` capacity 8→32 (no re-bake). Live: pick → CD 22→19 on BOTH worlds → return → 0 boon rows + CD 22 on both.
|
||||
11. **Economy reshape + the coordinated churn** — BaseGate kept as a mesh-only staging landmark (authoring stripped); ReturnGate + BaseFieldSpawner GOs deleted; `ExpeditionGateSystem`/`ExpeditionGate`/`ExpeditionGateAuthoring`/`BaseFieldSpawnSystem`/`BaseFieldSpawner`/`BaseFieldSpawnerAuthoring`/`AbilityUpgradeSystem`/`AbilityUpgradeRequest` all deleted; **`ThreatDirectorSystem` repointed `[UpdateAfter(RunDirectorSystem)]`** (D-F1); `BuildSendSystem`/`HudSystem`/`HowToPlayPanel` co-stripped (R-F3); onboarding gate-pointer hidden (re-pointed at READY in Step 14); node rarity Ore45/Biomass40/**Aether15**. Live: 0 base nodes, grubstake `ledger[Ore]=50`, no sort-cycle.
|
||||
|
||||
## Ordering map (server, all plain group — no CyclePhase edge anywhere in the room chain)
|
||||
`ReadyToggle / RouteSelect / BoonApply → RunDirector → RoomField / RoomEnemyDirector / BoonOffer` · `RunDirector → ThreatDirector → CyclePhase → GoalReached`.
|
||||
|
||||
## Churn ledger
|
||||
Director ghost re-bake (Step 2, incl. `MetaTierState`) · player ghost re-bake (Step 3) · RpcCollection +4/−1 (declared Step 3, retired Step 11) · Gameplay subscene edits (BoonCatalog added; gates/spawner removed) · SaveData still v5 (v6 lands Step 12b).
|
||||
|
||||
## Environment fix
|
||||
Unfocused-editor stalls killed: **Interaction Mode → No Throttling** (EditorPref, set programmatically) + `runInBackground` already on. Editor now fully responsive unfocused; only Burst-heavy recompiles still prefer focus.
|
||||
|
||||
## Remaining
|
||||
- **12a/12b/13 (permanent meta)** — review-gated; pre-code review running (`wf_f920d50c-abb`).
|
||||
- **14 (HUD)** — ready panel, Room i/N, boon modal, branching map panel, meta shop, biome cross-fade, onboarding re-point.
|
||||
- Post-impl diff review + DR + final doc bookend + commit offer after 14.
|
||||
|
||||
## Open operator items (standing defaults in play)
|
||||
Route authority = any-player-first-commits · un-picked boon = auto-Option0 · launch = 3-2-1 countdown · run length seed-varied [6,10] · meta = shared per-class pool, flat catalog v1 · dead-respawned members can't route-pick but are re-conscripted on advance (documented; surface after the fun-gate playtest).
|
||||
Reference in New Issue
Block a user