Docs: DR-046 base<->expedition ties + session log; CLAUDE.md net-zero

DR-046 (portal-gated rooms, class-at-base via shared ClassSwapUtil, per-run
prep spend, Health.Max GhostField, C3/C4/Spitter) + the session log. CLAUDE.md
folds the batch into the core-loop bullet and corrects the now-wrong
'Health.Max unreplicated' line; paid net-zero (40921 B) by archiving the
one-time Rukhanka-samples bullet + trims into the gotchas archive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 10:37:50 -07:00
parent 304ee8c2a7
commit e6cbba5e25
4 changed files with 227 additions and 13 deletions
+13 -13
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`. - **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-###`). - **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. - **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: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive) · 07-04 (base-mining core-loop bullet + stack-history sentence → archive; DR-044 supersedes) · 07-04b (EB-1/2 · END-1/2 bullets condensed → DR pointers, paying for the DR-045 combat-demo additions). - Condensation history: 06-04 → 06-17 (M1END-2 long-form + 6.5 stack swap → archive) · 07-04 (base-mining core-loop bullet + stack-history sentence → archive; DR-044 supersedes) · 07-04b (EB-1/2 · END-1/2 bullets → DR pointers, pays DR-045) · 07-04c (Rukhanka-samples/bake/history/enemy-anim bullets trimmed → archive, pays the DR-046 portal/class/prep additions + Health.Max fix).
## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27 ## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27
@@ -26,7 +26,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
| `com.unity.mathematics` | 1.4.0 | (transitive) | | `com.unity.mathematics` | 1.4.0 | (transitive) |
| `com.rukhanka.animation` | **2.9.0** | Local pkg (`Packages/com.rukhanka.animation`). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication **OFF** → client-derived. See [[DR-022_Animation_Pipeline_Rukhanka_Synty]]. | | `com.rukhanka.animation` | **2.9.0** | Local pkg (`Packages/com.rukhanka.animation`). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication **OFF** → client-derived. See [[DR-022_Animation_Pipeline_Rukhanka_Synty]]. |
Values match `packages-lock.json` (reconciled 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1; `com.unity.ai.assistant` REMOVED 07-04 — console noise). **History:** the 6.4.7→6.5.0 unified-versioning swap (2026-06-17) validated green; the 6.6.0a6 transport bug does NOT hit 6.5-stable — detail in [[DR-002_Unity66_Alpha_Netcode_Transport]] + the gotchas archive. Values match `packages-lock.json` (reconciled 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1; `com.unity.ai.assistant` REMOVED 07-04 — console noise). **History:** 6.4.7→6.5.0 swap validated green; the 6.6.0a6 transport bug misses 6.5-stable — [[DR-002_Unity66_Alpha_Netcode_Transport]] + archive.
## Namespaces & assembly split ## Namespaces & assembly split
@@ -111,22 +111,22 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **`VolumeProfile.Add<T>()` does NOT persist** (serializes `{fileID:0}`) — use `AssetDatabase.AddObjectToAsset(comp, profile)` + `SaveAssets`, verify on disk. - **`VolumeProfile.Add<T>()` does NOT persist** (serializes `{fileID:0}`) — use `AssetDatabase.AddObjectToAsset(comp, profile)` + `SaveAssets`, verify on disk.
- **A reverted engine/URP upgrade can stamp `URPGlobalSettings.asset` `m_AssetVersion` AHEAD of the package's `k_LastVersion`** (11>10, from the reverted 6.6 alpha); URP migrates forward-only so `URPPreprocessBuild` rejects it (*"not at last version"*) — **blocks player builds, not editor Play**. Fix: reflection-set `m_AssetVersion` back to `k_LastVersion` + `SaveAssets`. - **A reverted engine/URP upgrade can stamp `URPGlobalSettings.asset` `m_AssetVersion` AHEAD of the package's `k_LastVersion`** (11>10, from the reverted 6.6 alpha); URP migrates forward-only so `URPPreprocessBuild` rejects it (*"not at last version"*) — **blocks player builds, not editor Play**. Fix: reflection-set `m_AssetVersion` back to `k_LastVersion` + `SaveAssets`.
- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong). - **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong).
- **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP cosmetic colliders are **inert to the DOTS PhysicsWorld**). **World collision = subscene-only ★:** `Environment`-layer boundary ring + landmark box colliders (player blocked via the default layer matrix); enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem` (filter=`WorldCollisionConfig.EnvironmentMask`). Boundary = a height-gated `SM_Env_Rock_Cliff` bowl rim, flat walkable interior. See [[2026-06-08_World_Collision_HUD_Scaling]]. - **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP cosmetic colliders are **inert to the DOTS PhysicsWorld**). **World collision = subscene-only ★:** `Environment`-layer boundary ring + landmark box colliders (player blocked via the default layer matrix); enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem` (filter=`WorldCollisionConfig.EnvironmentMask`). Boundary = a height-gated `SM_Env_Rock_Cliff` bowl rim. See [[2026-06-08_World_Collision_HUD_Scaling]].
- **A GA "projectile" prefab self-propels** (non-kinematic `Rigidbody`+collider+`ProjectileMoveScript`) — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`). Verify *components*, not the name. - **A GA "projectile" prefab self-propels** — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`); verify *components*, not the name.
### Aim controls ### Aim controls
- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** (`PlayerInputGatherSystem`, managed, `GhostInputSystemGroup`: cursor ray → `AimMath.PlanarAimFromRay`); only the direction crosses the wire. Scheme = last-meaningful-actuation-wins **`byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — compared in Bursted `AbilityFireSystem`); server gates `AutoTarget` to gamepad only. `AimReticleSystem` (client presentation, observe-only) RE-raycasts the KBM ground point INSIDE itself (it runs after the follow-cam LateUpdate; latching from the gather drifts a frame); hardware cursor hidden while aiming+focused, restored on focus-loss. - **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** (`PlayerInputGatherSystem`, managed, `GhostInputSystemGroup`: cursor ray → `AimMath.PlanarAimFromRay`); only the direction crosses the wire. Scheme = last-meaningful-actuation-wins **`byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — compared in Bursted `AbilityFireSystem`); server gates `AutoTarget` to gamepad only. `AimReticleSystem` (client, observe-only) RE-raycasts the KBM ground point INSIDE itself (runs after the follow-cam LateUpdate latching from the gather drifts a frame); hardware cursor hidden while aiming+focused.
### Animation (Rukhanka) ★ ### Animation (Rukhanka) ★
Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_Animation_MonsterMash]] · [[Synty_Asset_Inventory]]. Skeletal animation = **Rukhanka 2.9** (the only maintained Entities-native option on 6.4). **Netcode replication OFF** (`RUKHANKA_WITH_NETCODE` undefined) → **client-derived**: `PlayerAnimationDriveSystem` (client-only `SystemBase`, `[WorldSystemFilter(LocalSimulation|ClientSimulation)]` + `[UpdateBefore(RukhankaAnimationSystemGroup)]`) reads replicated state and writes params via `AnimatorParametersAspect`/`FastAnimatorParameter`. No new `[GhostField]`s; no `DefaultVariant` strip (define off → ghost hash unchanged). Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_Animation_MonsterMash]] · [[Synty_Asset_Inventory]]. Skeletal animation = **Rukhanka 2.9** (the only maintained Entities-native option on 6.4). **Netcode replication OFF** (`RUKHANKA_WITH_NETCODE` undefined) → **client-derived**: `PlayerAnimationDriveSystem` (client-only `SystemBase`, `[WorldSystemFilter(LocalSimulation|ClientSimulation)]` + `[UpdateBefore(RukhankaAnimationSystemGroup)]`) reads replicated state and writes params via `AnimatorParametersAspect`/`FastAnimatorParameter`. No new `[GhostField]`s; no `DefaultVariant` strip (define off → ghost hash unchanged).
- **The rig must bake on the SAME entity that holds the gameplay components the drive job reads** — put `Animator` + `RigDefinitionAuthoring` on the **player root** (not a child) and flatten the skeleton + SMRs under it, else the single-entity drive query matches nothing. - **The rig must bake on the SAME entity that holds the gameplay components the drive job reads** — `Animator` + `RigDefinitionAuthoring` on the **player root** (not a child), flatten skeleton + SMRs under it, else the drive query matches nothing.
- **CPU engine still skins via Entities-Graphics GPU deformation → needs a deformation-aware material** (`AnimatedLitShader`, a multi-target ShaderGraph with a `UniversalTarget`; Synty atlas → its `_BaseColorMap`). Stock URP/Lit renders **unskinned static** + a `"does not support skinning"` warning (NOT magenta — that's a reused HDRP sample `.mat`). - **CPU engine skins via Entities-Graphics GPU deformation → needs a deformation-aware material** (`AnimatedLitShader`, multi-target ShaderGraph + `UniversalTarget`; Synty atlas → `_BaseColorMap`). Stock URP/Lit renders **unskinned static** + a `"does not support skinning"` warning (NOT magenta — that's a reused HDRP sample `.mat`).
- **Importing the Rukhanka "Animation Samples"** (the only source of `AnimatedLitShader`) drags in 26 sample subscenes (one NRE's the unguarded clip baker), sample systems that run in your worlds, and a conflicting TextMesh Pro folder. Fix: `MoveAsset` the 3 deformation ShaderGraphs to `_Project/Shaders/` (GUID-preserving), then delete the samples tree. - **The 3 deformation ShaderGraphs (incl. `AnimatedLitShader`) live in `_Project/Shaders/`** — GUID-preserved `MoveAsset` out of the Rukhanka "Animation Samples" tree (then deleted; importing those samples drags in 26 subscenes + world-running sample systems + a TMP conflict). Detail → gotchas archive 07-04b.
- **First Rukhanka bake is ~60 s, synchronous on the main thread** (editor freezes → looks like a hang, isn't); the animation blob is cached after → fast re-plays. - **First Rukhanka bake is ~60 s, main-thread-synchronous** (editor freezes — not a hang; blob cached after → fast re-plays).
- **The server runs Rukhanka unless you strip it** — its **deformation** systems are `[WorldSystemFilter(Default)]` (⊇ ServerSimulation). **`ServerStripAnimationSystem`** (server-only one-shot) disables every `Rukhanka.Runtime` system on the server (group-disable cascades; matched by assembly name → no type ref). *Only Play-validation caught this.* - **The server runs Rukhanka unless you strip it** — its **deformation** systems are `[WorldSystemFilter(Default)]` (⊇ ServerSimulation). **`ServerStripAnimationSystem`** (server-only one-shot) disables every `Rukhanka.Runtime` system on the server (group-disable cascades; matched by assembly name → no type ref). *Only Play-validation caught this.*
- **Build the controller via the `AnimatorController` API** (`manage_animation` drops enum/Vector blend-tree fields). **Skeleton-root = walk up from a bone to the soldier's direct child**, NOT `SkinnedMeshRenderer.rootBone` (the *bounds* root — the head SMR's is `Spine_03`; using it destroys the lower skeleton). - **Build the controller via the `AnimatorController` API** (`manage_animation` drops enum/Vector blend-tree fields). **Skeleton-root = walk up from a bone to the soldier's direct child**, NOT `SkinnedMeshRenderer.rootBone` (the *bounds* root — head SMR's is `Spine_03` destroys the lower skeleton).
- **Synty Polygon characters share one Generic skeleton**; the FBX needs **Optimize Game Objects OFF** (Rukhanka requirement). Entity origin = capsule **center** (~1 m up) → offset the **un-keyed `Root` bone** local Y (Rukhanka bakes it as a constant → it persists through clips). Root motion **OFF** (the CC owns the transform; blend tree is velocity-driven). - **Synty Polygon characters share one Generic skeleton**; FBX needs **Optimize Game Objects OFF** (Rukhanka req). Entity origin = capsule center (~1 m up) → offset the un-keyed `Root` bone local Y (baked constant → persists through clips). Root motion **OFF** (CC owns the transform; blend tree velocity-driven).
- **ENEMIES reuse the player pipeline** — a Husk is an ownerless interpolated ghost = a remote player, so `EnemyAnimationDriveSystem` mirrors the REMOTE path (`LocalTransform` delta velocity + prevPos cache; facing via `AnimParamMath.PlanarForward`; maxSpeed from `EnemyStats`; `IsAttacking = AttackWindup != 0`). **Drop `[RequireMatchingQueriesForUpdate]`** so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via the **`EnemyRigTools`** editor tool, **GUID-preserving** (`DeleteAsset+CopyAsset` orphans subscene refs); `WaveSystem` uses `baked.WithPosition` (not `FromPosition`resets Scale). - **ENEMIES reuse the player pipeline** — a Husk = ownerless interpolated ghost = a remote player, so `EnemyAnimationDriveSystem` mirrors the REMOTE path (`LocalTransform` delta velocity + prevPos cache; `IsAttacking = AttackWindup != 0`). **Drop `[RequireMatchingQueriesForUpdate]`** so the prune runs every frame (else a cache entry leaks per kill). Build enemy prefabs via **`EnemyRigTools`**, GUID-preserving (`DeleteAsset+CopyAsset` orphans subscene refs); `WaveSystem` uses `baked.WithPosition` (not `FromPosition`Scale reset). See [[DR-023_Enemy_Animation_MonsterMash]].
### MCP / editor workflow ★ ### MCP / editor workflow ★
- **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**; a raw-`Write`-created NEW `.cs` gets **no `.meta` / no test-discovery** until `refresh_unity scope=all mode=force`. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) `script_apply_edits` **`anchor_replace`** (regex) + **`delete_method`** work even on a `struct : ISystem`. - **Edit Assets `.cs` ONLY via MCP `apply_text_edits` / `create_script`** (Unity's scripting pipeline) — the raw `Write` tool does NOT reliably trigger a recompile on an unfocused editor → tests/`execute_code` run a **stale assembly**; a raw-`Write`-created NEW `.cs` gets **no `.meta` / no test-discovery** until `refresh_unity scope=all mode=force`. (`Write`/`Edit` are fine for non-asset files: this vault, asmdef JSON, etc.) `script_apply_edits` **`anchor_replace`** (regex) + **`delete_method`** work even on a `struct : ISystem`.
@@ -141,7 +141,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. - `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above). - **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
- **Core loop = ready-check multi-room RUNS ★ (DR-044; DR-031/042 base-mining text archived 07-04):** base = pure SPEND hub (no base nodes; `Tuning.StartingOre` grubstake). `RunDirectorSystem` = sole `RunInfo` writer (`[UpdateBefore(CyclePhaseSystem)]`; NOTHING else in the room chain touches CyclePhase); banks Charge+retaliation once-per-`RunEpoch` on boss-clear ONLY; strips the boon band + every `BoonOffer` on exit; teleports ONLY launch-stamped **`RunParticipant`s** (released on Returning — dead-respawned re-conscript, late joiners stay home). Rooms spawn per `RoomEpoch` at ping-pong sub-slots, `RoomTag`-torn-down; first slot waits `Tuning.RoomEntryGraceTicks`. ★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`. **Demo (DR-045): `GoalProgress.Target`=2; Boss = scaled Charger + server-only `BossState` kit (`BossAISystem` sole mover; radial SLAM via `AttackWindup`; phase-2 summon; knockback-immune; excluded from the Charger pass; client bar = `Max×BossHealthMultiplier`, `Health.Max` unreplicated). Grunt windup commits ~last 30%; Charger stagger roots.** See [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]] · [[DR-045_Combat_Demo_Feel_Boss_Fight]]. - **Core loop = ready-check multi-room RUNS ★ (DR-044; DR-031/042 base-mining text archived 07-04):** base = pure SPEND hub (no base nodes; `Tuning.StartingOre` grubstake). `RunDirectorSystem` = sole `RunInfo` writer (`[UpdateBefore(CyclePhaseSystem)]`; NOTHING else in the room chain touches CyclePhase); banks Charge+retaliation once-per-`RunEpoch` on boss-clear ONLY; strips the boon band + every `BoonOffer` on exit; teleports ONLY launch-stamped **`RunParticipant`s** (released on Returning — dead-respawned re-conscript, late joiners stay home). Rooms spawn per `RoomEpoch` at ping-pong sub-slots, `RoomTag`-torn-down; first slot waits `Tuning.RoomEntryGraceTicks`. ★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`. **Demo (DR-045/046): `GoalProgress.Target`=2; `Health.Max` now `[GhostField]` (boss + enemy HP bars). Boss = scaled Charger + server-only `BossState` kit (sole `BossAISystem` mover: SLAM/phase-2 summon/knockback-immune). Grunt windup commits ~last 30%; Charger stagger + Spitter cornered-hold. Rooms gate on a PORTAL (`RunLifecycle.RoomExplore=6` loot window; `PortalInteractRequest`→server `PortalCommand`; empty expedition advances now); class picked + per-run PREP buffs spent at base each run (Staging-gated RPCs via the shared `ClassSwapUtil`; disjoint prep `StatModifier` band stripped on Returning).** See [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]] · [[DR-045_Combat_Demo_Feel_Boss_Fight]] · [[DR-046_Base_Expedition_Ties_Portal_Class_Prep]].
## DOTS / ECS conventions (authoritative summary) ## DOTS / ECS conventions (authoritative summary)
@@ -0,0 +1,73 @@
---
title: Base↔Expedition Ties — Portal Rooms, Class-at-Base, Per-Run Prep Spend (+ C3/C4/Spitter/Health.Max)
date: 2026-07-04
tags: [session, combat, demo, netcode, rpc, class, prep, portal, dots-dev, ultracode]
permalink: gamevault/07-sessions/2026/2026-07-04-base-expedition-ties-portal-class-prep
---
# Base↔Expedition Ties (batch 2 of the demo pass, /dots-dev, ultracode)
Operator brief (follow-up to the combat-feel pass): *"do C3 & C4 + the Health.Max GhostField; no time for resource
collection in an expedition — after enemies clear, spawn a portal to interact with for the next room / choice UI; the
ranged enemies are too hard (fast, instant-flee on approach) — are projectiles i-frame dodgeable?; let the player pick
their class as part of the expedition / loadout from base resources & crafting; tie the base and expeditions together."*
Decisions + full change list: [[DR-046_Base_Expedition_Ties_Portal_Class_Prep]]. (Batch 1 = [[2026-07-04_Combat_Demo_Feel_Pass]].)
## How it went
1. **Fork → operator** (AskUserQuestion): class/loadout = **per-run prep spend** (free class pick at base + a per-run buff
panel spending base resources, wiped on return).
2. **Pre-code adversarial review** (`wf_e0c9f916-ca1`, 3 lenses + critics, 13 confirmed). Drove the three key design
moves: (a) the class swap needed a full **`ClassSwapUtil`** extraction — otherwise base-selection would drain Aether
into the wrong class's meta + leave stale HP; (b) the portal needed the room **teardown moved** off the InRoom edge +
the boss-branch/route-gate **relocated** into the new RoomExplore exit; (c) prep needed reset-per-run, solved elegantly
as "purchased == the prep StatModifier band is present," lifetime == the band stripped on Returning (no extra buffer).
3. **Serial MCP implementation** (~15 edits + 11 new files) in compile-checked batches: sim types (ClassSwapUtil,
ClassSelectRequest, PrepCatalog, PrepPurchaseRequest, PortalInteractRequest, PortalCommand, RunLifecycle.RoomExplore,
RunRuntime.ExploreGraceTick, Tuning, Health.Max) → server receivers (ClassSelectReceive, PrepPurchase, PortalInteract;
RunDirectorSystem RoomExplore rework; CycleDirectorSpawn PortalCommand; DebugCommand dedup) → client senders
(ClassSelectSend, PrepPurchaseSend, PortalInteractSend) → HudSystem (class row, prep panel, "E to LEAVE" portal
prompt) → feel (CombatFeedback C3/C4, EnemyAISystem Spitter cornered-hold, EnemySpitter.prefab).
4. **Verify**: clean compile; 456/456 EditMode (5 RunDirectorTraversalTests updated to the RoomExplore/portal contract via
a `PortalAdvance` helper); live Play smoke — both worlds in-game with the 3 new RPCs (RpcCollection hash matched), all
3 receivers created, PortalCommand on the director, GoalProgress.Target=2 server==client, zero Play exceptions.
5. **Post-impl diff review** (`wf_f0e6cecc-8b0`, clean run, 0 agent failures): 9 candidates, **2 confirmed (the same
issue from two lenses)** — the RoomExplore empty-abort guard excluded a boss clear, so a post-boss total wipe/leave
idled the run ~30 s (the full ExploreGrace) before the win banked. Fixed (drop the boss condition → an empty expedition
advances now). Folded 3 hygiene items (Temp-ECB `Dispose` in the 3 receivers; the orphaned `_lastConeFireTick`
comment). Re-ran tests → still 456/456.
## Gotchas worth remembering
- **Extract the shared mutation before you add a second caller.** The class swap looked like "set PlayerClass + AbilityRef
+ heal," but the load-bearing part is the **meta-band strip + per-class MetaTierState replay**; a second caller that
skips it silently corrupts the Aether economy. `ClassSwapUtil` makes the dev tool and the base-selection RPC share one
code path — the pre-code review flagged this as the #1 risk.
- **"Purchased once per run" needs no bookkeeping if the effect IS the record.** Prep buffs live as StatModifiers in a
disjoint SourceId band; the once-per-run gate is just "is a modifier with this row's SourceId already present?", and the
lifetime is the band stripped on Returning. No per-player PrepPurchased buffer, no reset bug surface.
- **When you insert a new FSM beat, re-audit which edge owns teardown.** Moving the loot window in meant room teardown had
to move OFF the InRoom→RoomReward edge and the boss-branch/route-gate had to move INTO the new RoomExplore exit — else
the room would be gone before you could loot it, or advance would double-fire. RunDirectorSystem must stay the *sole*
RunInfo writer + teardown owner through the change.
- **An empty-party abort guard must not exclude the terminal (boss) clear.** Gating the RoomExplore fast-exit on
`LastTerminalCleared==0` meant a post-boss wipe waited out the full grace timer before banking the win — self-healing
but a ~30 s dead-time on the single most important moment of the demo. The abort should fire for *any* empty expedition.
- **Spitter projectiles were always dodgeable** — the fix for "too hard" was the *approach* behavior (a cornered-hold so
you can close distance), not the projectile (i-frames already negate a DamageEvent by SourceTick). Diagnose the felt
problem, not the assumed one.
- **Health.Max as a `[GhostField]` is worth the one re-bake** when two display surfaces (boss bar + floating enemy bars)
both need a server-mutated max; the client-side reconstruction from DR-045 only covered the boss and was fragile.
## Validation
Clean compile · 456/456 EditMode · live Play netcode smoke (3 new RPCs hash-matched, receivers up, PortalCommand present,
Target=2 server==client, console clean) · two adversarial reviews (13-confirmed pre-code folded into the plan; post-impl
clean bar the one RoomExplore dead-time + 3 hygiene items, all fixed). L3 fun-gate (portal loot-window pacing, prep buff
feel, Spitter cornered-hold, class-at-base flow) is the operator's playtest.
## Next-session intent
Operator fun-gate: the portal loot window (does `ExploreGraceTicks` feel right vs. an instant advance?), the prep buff
values, the Spitter cornered-hold, and the base→class→prep→launch flow. Live-tune the prep row values + PortalInteractRange.
Then the standalone 2-instance LAN smoke (DR-044 §5, still open).
@@ -0,0 +1,128 @@
---
id: DR-046
title: Base↔Expedition Ties — Portal-Gated Rooms, Class-at-Base, Per-Run Prep Spend
date: 2026-07-04
status: locked
tags:
- decision
- combat
- demo
- netcode
- rpc
- class
- prep
- portal
supersedes: extends DR-044 (room traversal) + DR-045 (combat demo pass); adds RunLifecycle.RoomExplore
permalink: gamevault/07-sessions/decisions/dr-046-base-expedition-ties-portal-class-prep
---
# DR-046 — Base↔Expedition Ties (Portal Rooms + Class-at-Base + Prep Spend)
Operator brief (ultracode, batch 2 of the demo pass): *"do C3 & C4 + the Health.Max GhostField; there's no time for
resource collection during an expedition — after enemies are cleared, spawn a portal/door that must be interacted with to
go to the next room / bring up the choice UI; the ranged enemies are too hard (too fast, instant-flee on approach) — are
projectiles i-frame dodgeable?; let the player choose their class as part of the expedition / get the loadout from base
resources & crafting; it needs more to tie the base and expeditions together."* Full session log:
[[2026-07-04_Base_Expedition_Ties_Portal_Class_Prep]]. Builds on [[DR-045_Combat_Demo_Feel_Boss_Fight]] +
[[DR-044_Expedition_Redesign_Shipped_Demo_Polish]].
## 1. Operator-locked fork (AskUserQuestion)
**Class / loadout = "Per-run prep spend"** — at base the player freely picks a class (Staging UI, re-pickable every run)
AND spends base Ore/Biomass/Aether on a per-run **prep panel** for temporary run buffs that are wiped on return. This
ties the base economy to the expedition without a persistent-gear grind, and keeps a run self-contained (roguelite).
## 2. Portal-gated rooms + the loot window (`RunLifecycle.RoomExplore = 6`)
Rooms no longer auto-advance the instant the last enemy dies. New FSM beat between the reward and the next route:
- **`RunLifecycle.RoomExplore = 6`** appended to `RunInfo.cs` (byte value → no ghost re-bake). New server-only working
fields: `RunRuntime.ExploreGraceTick` (soft-timeout so a stuck party can't softlock) — the party "explores"/loots the
cleared room, then **interacts with a portal to advance**.
- **`PortalCommand{byte HasInteract}`** (`Simulation/Meta/MetaComponents.cs`) — server-only working flag on the
CycleDirector, added at spawn beside `RouteCommand` (`CycleDirectorSpawnSystem`). NOT a `[GhostField]` (a runtime-added
replicated component wouldn't replicate anyway; the client derives the portal from replicated `RunInfo`).
- **`PortalInteractRequest`** (empty `IRpcCommand`) → **`PortalInteractReceiveSystem`** (server,
`[UpdateBefore(RunDirectorSystem)]`, RoomExplore-gated, **sender must be an expedition-region player**) sets
`PortalCommand.HasInteract=1`, drain-destroys every request. Client **`PortalInteractSendSystem`** (static `Interact()`).
- **`RunDirectorSystem` (still the SOLE `RunInfo` writer + teardown owner)**:
- Room **teardown MOVED off** the `InRoom→RoomReward` edge — the room + nodes now persist through the loot window.
- `RoomReward` exit → strip the boon band → arm `ExploreGraceTick`**`RoomExplore`** (the boss-branch + route-gate
logic RELOCATED out of here).
- New `RoomExplore` case: **empty expedition → `Returning` immediately** (banks the win if boss-cleared, else aborts
no-credit — the leftover room is swept by `RoomFieldSystem` at Staging); else on `PortalCommand.HasInteract` OR the
ExploreGrace timeout → `DestroyRoom` + the relocated boss-branch (`LastTerminalCleared→Returning`) / route-gate
(ReachableOptions → RouteSelect, or `optionCount==0 → Returning`); clears `PortalCommand`.
- **Client portal cue (`HudSystem`)** — when `Lifecycle==RoomExplore` and the local player is within
`Tuning.PortalInteractRange` of the client-derived portal position
`RegionMath.ExpeditionRoomOrigin(PlotCenter(BaseAnchor), (byte)(CurrentRoom & 1)) + PortalOffsetZ`, show an **"E to
LEAVE"** prompt; E near the portal → `PortalInteractSendSystem.Interact()`. Never reads `RunParticipant`/`RunRuntime`/
`RegionTag` client-side. Tuning: `PortalOffsetZ=-5`, `PortalInteractRange=3.5`, `ExploreGraceTicks=1800`.
**Post-impl review fix (confirmed, medium):** the RoomExplore empty-abort guard first read `expeditionPlayers==0 &&
LastTerminalCleared==0`, which SKIPPED a boss clear — so a post-boss total wipe/disconnect idled the run in RoomExplore
for the full ~30 s ExploreGrace before the win banked (a promptness regression vs the old flow, self-healing but a bad
dead-time on the game's win moment). **Fix: drop the boss condition**`if (expeditionPlayers == 0)` advances an empty
expedition NOW (boss→Returning banks the win; non-boss→abort no-credit). No explicit teardown needed (Staging sweep).
## 3. Class-at-base (shared swap helper — the bug-prone part extracted)
- **`ClassSwapUtil`** (`Simulation/Combat/`) — extracts the FULL class swap from the (dev-only) `SetClass` path so the
base-selection and the debug tool can't drift: `Apply(rawClass, mods, haveMeta, metaCat, metaRecord, out newClass, out
newAbilityId)` does `ClassTraits.Reapply` + the `AbilityRef` id + **the meta-band strip + per-class `MetaTierState`
replay** (the part that, done wrong, drains Aether into the wrong class's meta or leaves stale HP). `HealClamp(ref
Health, baseMaxHealth, mods)` refills to the recomputed effective max.
- **`ClassSelectRequest{byte ClassId}`** (unconditional `IRpcCommand`) → **`ClassSelectReceiveSystem`** (server,
`[UpdateBefore(RunDirectorSystem)]`, **Staging-gated**, sender-resolved) → `ClassSwapUtil.Apply` + set
`PlayerClass`/`AbilityRef`/`AbilityCooldown=0` + `HealClamp`, drain-destroy. Client **`ClassSelectSendSystem`** (static
`RequestClass(byte)`).
- **`DebugCommandReceiveSystem` SetClass refactored to call `ClassSwapUtil`** (dedup, no `#if` drift).
- **`HudSystem`** Staging: a Warrior/Ranger class row (re-pickable each run at base).
## 4. Per-run prep spend
- **`PrepCatalog`** (`Simulation/Combat/`) — static `PrepRow{byte Id, CostResId; int Cost; byte Target, Op; float Value}`
+ `TryGet`. Rows: Id0 +30 MaxHealth (Ore 30) · Id1 +12% MoveSpeed (Biomass 40) · Id2 +20% MeleeDamage (Aether 25) ·
Id3 +20% Damage (Aether 25).
- **`PrepPurchaseRequest{byte OptionId}`** (`IRpcCommand`) → **`PrepPurchaseSystem`** (server,
`[UpdateBefore(RunDirectorSystem)]`, **Staging-gated**): `TotalOf` pre-check **BEFORE** `Withdraw` (in-loop atomic),
**once-per-run gated by the presence of the row's prep `StatModifier`** (elegant: "purchased" == the band is present;
lifetime == the band, stripped on Returning — no extra per-player buffer needed), then append a run-scoped
`StatModifier` in the prep SourceId band. Client **`PrepPurchaseSendSystem`** (static `RequestPrep(byte)`).
- **Prep band** — `Tuning.PrepSourceIdBase=0x00D00000`, `PrepSourceIdSpan=0x10000` (disjoint from boon/class/meta/equip).
- **`RunDirectorSystem` Returning** strips the prep band (`TimedModifierUtil.RemoveBySourceIdRange`) beside the boon band
→ buffs cleared on return, matching the roguelite "self-contained run" rule.
- **`HudSystem`** Staging: a prep panel (reuses the meta-shop row-builder pattern).
## 5. Health.Max GhostField + combat feel (C3/C4/Spitter)
- **`Health.Max` promoted to `[GhostField]`** — the ONLY ghost-hash change this batch (deliberate, re-baked). Replaces
DR-045's client-side `bakedMax × multiplier` reconstruction so the boss bar AND the floating enemy HP bars read a
correct fraction regardless of server-side Max mods (boss ×8, class/boon HP). Display-only; clamp/refill still use
`EffectiveCharacterStats.MaxHealth`. The boss bar reverted to reading the replicated `Max` directly.
- **C3 — melee cone connect-thunk** (`CombatFeedbackSystem`): the Warrior cone now lands a hit-thunk cue on connect.
- **C4 — hit-stop throttle** (`CombatFeedbackSystem` + `PrototypeCameraRig.Hold`, `FeelConfig.HitStopFreezeEnabled=true`):
a `_lastHoldTime` throttle so a kill / finisher / heavy-hit fires the freeze but a horde wipe can't stutter-storm it.
- **Spitter readable-but-fair** (`EnemyAISystem` + `EnemySpitter.prefab`): MoveSpeed 2.4, AttackCooldownTicks 90,
`CorneredRange` 6, ProjectileSpeed 8; a **cornered-hold** — within `CorneredRange` the Spitter stops fleeing
(`bandVel=0`) so it can be closed on and killed, instead of instantly kiting on approach.
- **Confirmed: Spitter projectiles ARE dash-i-frame dodgeable** — `HealthApplyDamageSystem` negates any `DamageEvent`
whose `SourceTick` falls in the dash i-frame window. No change needed.
## 6. Validation
Clean compile (0 errors). 456/456 EditMode (5 `RunDirectorTraversalTests` updated to drive the new
RoomReward→RoomExplore→portal→RouteSelect/Returning flow via a `PortalAdvance` helper + `typeof(PortalCommand)` fixture).
Live Play netcode smoke: both worlds go in-game with the 3 new RPCs (RpcCollection hash matched), all 3 receiver systems
created, `PortalCommand` on the director, `GoalProgress.Target=2` server==client, zero Play exceptions. Pre-code
adversarial review (`wf_e0c9f916-ca1`, 13 confirmed folded into the plan — the class-swap-helper extraction, the portal
teardown/branch relocation, and the reset-per-run prep model all came from it). Post-impl diff review
(`wf_f0e6cecc-8b0`, clean run, 0 agent failures): 9 candidates, 2 confirmed (the same RoomExplore boss-clear dead-time,
from two lenses — fixed §2); 3 hygiene items folded (Temp-ECB `Dispose` in the 3 receivers to match convention; the
orphaned `_lastConeFireTick` comment reattached); the rest refuted.
## 7. Open / operator-side
Boss + onboarding fun-gate playtests, live-tuning, and the standalone 2-instance LAN smoke (DR-044 §5) remain the
operator's. New feel knobs to check live: the Spitter cornered-hold range/speed, the prep buff values, and whether the
portal loot window (`ExploreGraceTicks`) feels right vs. an instant advance.
@@ -409,3 +409,16 @@ exist; in-run nodes → personal `InventorySlot`, spill→ledger, `G`=deposit is
note: `Assets/_Project/Scripts/Simulation/Meta.meta` was committed whitespace-only in `Run Re-Do` and stayed note: `Assets/_Project/Scripts/Simulation/Meta.meta` was committed whitespace-only in `Run Re-Do` and stayed
latent until a package re-resolve forced a full reimport ("The GUID … cannot be extracted") — deleted so Unity latent until a package re-resolve forced a full reimport ("The GUID … cannot be extracted") — deleted so Unity
regenerated it; folder-meta GUIDs are unreferenced, so regeneration is safe. regenerated it; folder-meta GUIDs are unreferenced, so regeneration is safe.
## 2026-07-04b — Base↔Expedition ties (DR-046): Rukhanka-samples-import bullet archived to pay for the portal/class/prep additions
To pay (net-zero) for the DR-046 core-loop additions (portal loot window, class-at-base, per-run prep spend) +
the corrected `Health.Max`-is-now-`[GhostField]` fact, the one-time Rukhanka "Animation Samples" import bullet
was moved out of CLAUDE.md (the import is done — low recurrence — so by the "hottest rules only" rule it no
longer earns inline space). Verbatim:
> - **Importing the Rukhanka "Animation Samples"** (the only source of `AnimatedLitShader`) drags in 26 sample subscenes (one NRE's the unguarded clip baker), sample systems that run in your worlds, and a conflicting TextMesh Pro folder. Fix: `MoveAsset` the 3 deformation ShaderGraphs to `_Project/Shaders/` (GUID-preserving), then delete the samples tree.
The inline Rukhanka section keeps the pointer (`AnimatedLitShader` lives in `_Project/Shaders/`, GUID-preserved
from the deleted samples tree) so the deformation-material rule is still self-contained. Full rationale:
[[DR-022_Animation_Pipeline_Rukhanka_Synty]].