Slice 3: Expedition Combat Spine — epoch-seeded zone waves (DR-040)

Reactivate the dormant Expedition region as a procedural combat venue.
v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the
expedition -> clear -> return -> flat Ore reward (once per epoch) ->
escalated retaliation base siege.

- New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab
  buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition
  by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the
  wave at a deterministic ring under a MaxAlive cap while a player is
  out and the base is Calm; marks ClearedThisEpoch on a real clear.
  [UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle).
- BLOCKER 1: EnemyAISystem region-filters target selection (player +
  structure snapshots gain parallel region lists; no base structures /
  no Core fallback for expedition husks).
- BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and
  CyclePhaseSystem DefendCleared + Core-breach cull all count/cull
  RegionTag{Base} husks only (the breach cull was caught region-blind
  by the post-impl review: a base breach wiped the live expedition
  wave and spuriously paid the reward).
- BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch +
  ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch.
- ExpeditionFieldSystem teardown also culls zone enemies + region-
  guards the clutter loop. Subscene wired with the director + roster.

368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 ->
built, session log, CLAUDE.md cross-region tag-reaudit rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 22:58:26 -07:00
parent cf45ec82ae
commit 3109b86d71
33 changed files with 1044 additions and 161 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
- **Size check** — bash: `wc -c CLAUDE.md` · PowerShell: `(Get-Item CLAUDE.md).Length`. Must be `< 40960`.
- **Archive, don't delete.** When trimming, append the verbose / least-hot detail to the obsidian reference note `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md` under a **new dated heading** (never overwrite an older snapshot), and leave a one-line pointer + the relevant `[[DR-###]]` link here. Design rationale already lives in the per-milestone DRs (`Docs/Vault/07_Sessions/_Decisions/DR-###`).
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
- Condensation history: 2026-06-04 · 06-07 · 06-08 · 06-13 (M1END-2 long-form → archive); 06-17 (6.5 stack swap).
- Condensation history: 06-04 06-17 (M1END-2 long-form + 6.5 stack swap → archive).
## Stack — Unity 6.5.0 (`6000.5.0f1`, stable) as of 2026-06-17
@@ -73,8 +73,8 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **A dev/debug `IRpcCommand` wire TYPE must be UNCONDITIONAL (no `#if`)** — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses; `#if UNITY_EDITOR`-gate only the send/receive SYSTEMS, never the request struct. **Re-mean bytes, don't rename**: unchanged byte VALUES keep the `[GhostField]` serializer identical → re-bake-free (only authoring *default-value* edits re-bake the subscene).
- **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent<Dead>()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only.
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction.
- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. `RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}`. See [[DR-013_M6_Aether_Cycle_Region_Split]].
- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve a ledger buffer via a DISTINCT tag (`ResourceLedger`), **never `GetSingleton<StorageEntry>`** when a second `StorageEntry` buffer exists elsewhere → "multiple instances" throw.
- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. **★ A 2nd region sharing an EXISTING tag (`EnemyTag`) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040).** `RelevantGhostForConnection` = `{int Connection=NetworkId.Value; int Ghost=ghostId}`. See [[DR-013_M6_Aether_Cycle_Region_Split]].
- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve the ledger via its DISTINCT `ResourceLedger` tag (the multi-`StorageEntry` "multiple instances" rule — EB-2 line).
- **Frontend world lifecycle (menu → on-demand worlds) ★:** `CreateLocalWorld` is `internal` in 1.13.2 — use public `CreateClientWorld`/`CreateServerWorld` (they register the `ServerWorld`/`ClientWorld` statics the UI reads); menu world via `DefaultWorldInitialization.Initialize(name, false)`. **Never dispose/create worlds inside an ECS system** — do it on a frame-boundary coroutine (`SessionRunner`, `DontDestroyOnLoad`). The gameplay subscene streams in ONLY if a netcode world is the `DefaultGameObjectInjectionWorld` at `LoadScene` time. See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
### Physics & character controller