diff --git a/CLAUDE.md b/CLAUDE.md index aae4b11d8..9657c8b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: 06-04 → 06-17 (M1–END-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 (M1–END-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 @@ -26,7 +26,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server- | `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]]. | -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 @@ -111,22 +111,22 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui - **`VolumeProfile.Add()` 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`. - **`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]]. -- **A GA "projectile" prefab self-propels** (non-kinematic `Rigidbody`+collider+`ProjectileMoveScript`) — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`). Verify *components*, not the name. +- **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** — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`); verify *components*, not the name. ### 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) ★ 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. -- **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`). -- **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. -- **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. +- **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 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`). +- **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, 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.* -- **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). -- **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). -- **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). +- **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**; 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 = 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 ★ - **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]]. - **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) diff --git a/Docs/Vault/07_Sessions/2026/2026-07-04_Base_Expedition_Ties_Portal_Class_Prep.md b/Docs/Vault/07_Sessions/2026/2026-07-04_Base_Expedition_Ties_Portal_Class_Prep.md new file mode 100644 index 000000000..b2ae4d14e --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-07-04_Base_Expedition_Ties_Portal_Class_Prep.md @@ -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). diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-046_Base_Expedition_Ties_Portal_Class_Prep.md b/Docs/Vault/07_Sessions/_Decisions/DR-046_Base_Expedition_Ties_Portal_Class_Prep.md new file mode 100644 index 000000000..783c9f55e --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-046_Base_Expedition_Ties_Portal_Class_Prep.md @@ -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. diff --git a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md index f5c3021fb..600f5f18f 100644 --- a/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md +++ b/Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md @@ -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 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. + +## 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]].