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
@@ -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.