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`.
- **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 (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
@@ -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<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`.
- **`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)