diff --git a/CLAUDE.md b/CLAUDE.md index da46ce1af..21553e762 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,10 +89,10 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui - **`PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}`** on an ownerless interpolated ghost. **Bake the two tick fields** (turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin). Only `Type` replicates (client derives `Cell` via `BaseGridMath.WorldToCell`). Data-driven `StructureCatalog` buffer. **Occupancy is DERIVED** by scanning live structure ghosts into a Temp `NativeHashSet`, never a mutable buffer on the baked `BaseAnchor`. See [[DR-014_M6_Build_Structures_Automation_Foundation]]. - **Co-op placement atomicity:** commit the `StorageMath.Withdraw` + cell-reservation **in-place inside the RPC foreach** (only `Instantiate` goes through the ECB) so two same-tick requests for one cell can't both pass. - **Buildable turret = hitscan = reversed `EnemyAISystem`:** nearest living Husk in-region within Range, on `NextTick` cooldown append a direct `DamageEvent{Damage, SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem`. No projectile → no tunnelling, no team model. -- **Resource-gated ability tiers reuse `StatModifier`** — grow ONE `StatModifier{Target=Damage, Op=PercentAdd, SourceId=}` (replace-by-SourceId → bounded buffer); `StatRecomputeSystem` folds it into `EffectiveAbilityStats` on both worlds. `GoalProgress{[GhostField] int Charge, Target}` rides the global CycleDirector ghost. -- **M7 Automation (server-only, never predicted) ★:** `Harvester`/`Conveyor`/`Fabricator` are buildable machines on the `PlacedStructure` ghost storing `PeriodTicks` + **server-only** `MachineInput`/`MachineOutput` buffers (NOT `[GhostField]`). Production runs in the plain server group `[UpdateAfter(PredictedSimulationSystemGroup)]` (Harvester→Conveyor→Fabricator), replicating only via the global ledger + `PlacedStructure`. Deterministic catch-up via `ProductionMath.CyclesDue` (**lower-bound 0, never 1**; period-0 guarded). Byte-only EditMode-tested math; `ConveyorMath` order-independent (`CellKey` stable-sort → at-most-one claim → losers stall). `RuntimePlacedTag` = player-built (save-scan). See [[DR-020_M7_Automation_Production_Chains]]. -- **Per-player inventory + data-driven items ★:** harvest → the firing player's PERSONAL `InventorySlot` (`[GhostField]` `OwnerSendType.All`, a `StatModifier` twin; owner read via an OPTIONAL `ComponentLookup` in `ResourceHarvestSystem`, remainder/un-owned → the ledger). Items = an `ItemDatabase` blob (`AbilityDatabase` twin; `ushort ItemId` subsumes `ResourceId`; ID-keyed; `byte Category`/`Tier`). Deposit personal→ledger via the `G`-key `InventoryDepositRequest` RPC (the build economy spends the ledger). `InventoryMath`(stack/slot-cap) ≠ `StorageMath`. Session-only (no SaveData bump). See [[DR-026_Inventory_Equipment_Progression_Foundation]]. -- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load** (`CycleDirectorSpawnSystem` applies a staged `PendingSave` AT SPAWN so the director never replicates a default first); host-only autosave on Siege→Calm + quit; `BaseRestoreSystem` replays structures **charge-free** with REMAINING-tick cooldowns (one `SaveStructureScan.Collect` path). See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. +- **Resource-gated ability tiers / buffs reuse `StatModifier`** (replace/clear-by-SourceId → bounded buffer; `StatRecomputeSystem` folds it into `EffectiveAbilityStats` both worlds). `GoalProgress{[GhostField] int Charge, Target}` rides the global CycleDirector ghost. +- **M7 Automation (server-only, never predicted) ★:** `Harvester`/`Conveyor`/`Fabricator` are buildable machines on the `PlacedStructure` ghost storing `PeriodTicks` + **server-only** `MachineInput`/`MachineOutput` buffers (NOT `[GhostField]`). Production runs in the plain server group `[UpdateAfter(PredictedSimulationSystemGroup)]` (Harvester→Conveyor→Fabricator), replicating only via the global ledger + `PlacedStructure`. Deterministic catch-up via `ProductionMath.CyclesDue` (**lower-bound 0, never 1**; period-0 guarded). `ConveyorMath` order-independent (`CellKey` stable-sort); `RuntimePlacedTag` = player-built. See [[DR-020_M7_Automation_Production_Chains]]. +- **Per-player inventory + equipment + items ★:** harvest → the firing player's PERSONAL `InventorySlot` (`[GhostField]` `OwnerSendType.All`; owner via OPTIONAL `ComponentLookup` in `ResourceHarvestSystem`, remainder/un-owned → ledger); deposit personal→ledger via the `G`-key `InventoryDepositRequest` RPC. Items = an `ItemDatabase` blob (`AbilityDatabase` twin; `ushort ItemId` subsumes `ResourceId`; ID-keyed; item mods **INLINE on `ItemDefBlob` (Mod0..3), NOT a nested BlobArray** — by-value `TryGetItem` reads a nested one empty). Equip = `EquipmentSlot{[GhostField] ushort ItemId}` (index=slot) + server-only event-driven `EquipSystem`: weapon→`AbilityRef.Id` (swaps prefab+base stats), gear→`StatModifier`s tagged `Tuning.EquipSourceIdBase+slot`, stripped via target-agnostic `RemoveBySourceId`; atomic swap. Session-only. See [[DR-026_Inventory_Equipment_Progression_Foundation]] · [[DR-027_Equipment_Slots_Phase1]]. +- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load** (`CycleDirectorSpawnSystem` stages `PendingSave` AT SPAWN); host-only autosave; `BaseRestoreSystem` replays structures charge-free with REMAINING-tick cooldowns. See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. ### Presentation / juice / VFX - **All juice/HUD = client-only managed `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire) that OBSERVES replicated state, never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO()` — NOT a MonoBehaviour `LateUpdate` (job-safety throw). `Entity` is a stable client dict key for a ghost's lifetime — **prune the cache each frame** (a pruned enemy = a kill → death VFX); **never `DestroyEntity` a ghost from the client** (`GhostDespawnSystem` owns despawn). Hit-stop = a camera punch, **never `Time.timeScale`** (corrupts the sim). diff --git a/Docs/Vault/07_Sessions/2026/2026-06-08_Equipment_Slots_Phase1.md b/Docs/Vault/07_Sessions/2026/2026-06-08_Equipment_Slots_Phase1.md new file mode 100644 index 000000000..7ef270d79 --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-06-08_Equipment_Slots_Phase1.md @@ -0,0 +1,62 @@ +--- +date: 2026-06-08 +type: session +tags: +- session +- equipment +- items +- abilities +- statmodifier +- netcode +- ultracode +permalink: gamevault/07-sessions/2026/2026-06-08-equipment-slots-phase1 +--- + +# Session 2026-06-08 — Equipment slots (Phase 1) + +> Driven by `/dots-dev` in **ultracode**, continuing from the Phase 0 inventory slice (committed earlier today, +> [[DR-026_Inventory_Equipment_Progression_Foundation]]). Operator: *"Continue with the next phase."* + +## Decision & design + +Architecture + the 7 decisions live in [[DR-027_Equipment_Slots_Phase1]]. Per the recorded +[[validate-netcode-design-before-coding]] preference, a 4-lens adversarial review ran BEFORE any code (netcode/ +prediction · determinism-Burst · architecture-scope · test → synthesis). Verdict **GO_WITH_CHANGES** — all +architectural decisions confirmed, two data-layer blockers caught with clean fixes: + +- **Blocker (EQ2):** a nested `BlobArray` inside `ItemDefBlob` would read EMPTY — `TryGetItem` returns + the def BY VALUE and copying a struct containing a BlobArray breaks its relative-offset pointer. **Fix:** 4 inline + `ItemModSpec` slots (copy-safe, Burst-trivial). The `ItemDatabaseBlobTests` second-item read-back is the guard. +- **Blocker (EQ3):** a vague "per-slot SourceId range" would collide with the flat sentinel namespace and the + Target-filtered strip would orphan a gear item's other-target mods. **Fix:** reserved `Tuning.EquipSourceIdBase` + (one sentinel per slot, disjoint from upgrade/debug/pickup) + target-agnostic `TimedModifierUtil.RemoveBySourceId`. +- Plus: atomic equip-over-occupied (check bag room before withdraw — no item loss); `EquipmentSlot` index-as-slot + (drop the redundant Slot field); event-driven (supersedes DR-026's per-tick "sync" sketch); mandatory player re-bake. + +## What was built (clean compile, 236/236 EditMode, Play-validated) + +- **Simulation/Items:** `EquipSlotId`, `EquipmentSlot` ([GhostField] OwnerSendType.All), `DefaultAbility`, + `EquipRequest`/`UnequipRequest` RPCs; `ItemDefBlob` grown with `GrantedAbilityId`/`EquipSlot`/4 inline `ItemModSpec` + + `GetMod`; `InventoryMath.CanDeposit` (non-mutating fit check); `Tuning.EquipSourceIdBase` + SourceId map. +- **Authoring:** `ItemDefinition` SO equip fields + `ItemModAuthoring`; baker writes the inline slots; `PlayerAuthoring` + bakes the 4-row `EquipmentSlot` + `DefaultAbility`. +- **Server:** `EquipSystem` (event-driven, owner-map, atomic, weapon→`AbilityRef`, gear→`StatModifier` by slot + sentinel, target-agnostic strip, DefaultAbility on weapon-unequip). +- **Client:** `EquipSendSystem` (keys 1-9 / U + build-safe static hooks); `HudSystem` equipment panel + click-to-equip + (bag row) / click-to-unequip (slot), observe-only from replicated `EquipmentSlot`. +- **Tests:** `ItemDatabaseBlobTests`, `EquipSystemTests` (7 cases). +- **Content:** 4 catalog items — Rapid Blaster (→FastLight), Heavy Cannon (→SlowHeavy +10 dmg), Aether Vest (+15% + MoveSpeed), Power Sigil (+20% Damage) — wired into the `ItemDatabase` subscene; re-baked. + +## Play validation (host+client) + +Equip weapon → `AbilityRef.Id` 1→2 on **both** worlds (prefab+base-stat swap, replicated); equip gear → +`EffectiveCharacterStats.MoveSpeed` 6.0→6.90 (folded both worlds), `equipMods=1`, items left the bag. Unequip → +AbilityRef→1 (DefaultAbility), MoveSpeed→6.0, mods stripped, items returned. Re-baked player handshakes cleanly. HUD +equipment panel renders catalog names (`Weapon: Rapid Blaster` + `unequip`); equippable bag rows are click-wired. + +## Next-session intent + +**Phase 2 — tool-gated harvesting:** bake `RequiredToolType`/`RequiredToolTier` on `ResourceNode`/`BlightClutter`; +`ResourceHarvestSystem` gates + scales yield by the owner's equipped Tool slot (the slot is already reserved + baked). +Then **Phase 3 — crafting + per-player persistence** (additive `SaveData` v3 that restores AND replays equip). diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-027_Equipment_Slots_Phase1.md b/Docs/Vault/07_Sessions/_Decisions/DR-027_Equipment_Slots_Phase1.md new file mode 100644 index 000000000..0e6ce7cb1 --- /dev/null +++ b/Docs/Vault/07_Sessions/_Decisions/DR-027_Equipment_Slots_Phase1.md @@ -0,0 +1,98 @@ +--- +id: DR-027 +title: Equipment slots (Phase 1) — weapon-granted swappable abilities + gear stat mods via the existing AbilityRef/StatModifier machinery, event-driven server-only equip +status: accepted +date: 2026-06-08 +tags: +- decision +- equipment +- inventory +- items +- abilities +- statmodifier +- netcode +- ghostfield +- progression +permalink: gamevault/07-sessions/decisions/dr-027-equipment-slots-phase1 +--- + +# DR-027 — Equipment slots (Phase 1) + +## Context + +Phase 0 ([[DR-026_Inventory_Equipment_Progression_Foundation]]) shipped the per-player inventory + data-driven +`ItemDatabase` catalog. Phase 1 adds **equipment slots that grant abilities + effects**: equip a weapon to swap your +attack, equip gear for stat bonuses. The operator's Phase-0 fork already chose **weapon-granted swappable ability + +gear effects**. A 4-lens adversarial review (netcode/prediction · determinism-Burst · architecture-scope · test) ran +before any code — verdict **GO_WITH_CHANGES** (every architectural decision confirmed; two data-layer blockers caught). + +## Decision — reuse the combat spine, don't add a parallel one + +The key realization (verified in code): `AbilityRef.Id` is already a `[GhostField]` that `AbilityFireSystem` reads for +the projectile prefab **and** `StatRecomputeSystem` keys the effective-stat *base* off. So **setting `AbilityRef.Id` +from an equipped weapon swaps the whole attack (prefab + base stats), prediction-correct for free** (the +`DebugModifierInjectionSystem.CycleAbility` precedent). Gear effects fold through the existing `StatModifier` stack. +No new folding code, no per-tick reconcile. + +### The Phase 1 decisions (EQ1–EQ7, as built) + +- **EQ1 — `EquipmentSlot { [GhostField] ushort ItemId }`** `IBufferElementData`, `OwnerSendType.All`, baked with 4 + fixed empty rows (the **buffer index IS the slot** — Weapon=0/Armor=1/Trinket=2/Tool=3; no redundant Slot field to + desync). A `StatModifier`/`InventorySlot` replication twin. `EquipSlotId` byte consts; Tool reserved for Phase 2. +- **EQ2 — grow `ItemDefBlob` with INLINE mod slots, NOT a nested BlobArray.** `byte GrantedAbilityId` (0=none) + + `byte EquipSlot` (255=not equippable; a SEPARATE field because `Category=Gear` can't distinguish Armor vs Trinket) + + 4 inline `ItemModSpec Mod0..Mod3` (Target 255=unused) + a `GetMod(i)` accessor. **A nested BlobArray would read + EMPTY** because `ItemDatabaseBlob.TryGetItem` returns the def BY VALUE and copying a struct that contains a BlobArray + breaks its relative-offset pointer (the exact hazard the blob's own note warns about). Inline is copy-safe, + Burst-trivial, baker-trivial. `ItemDefinition` SO gains matching byte fields + a `List`. +- **EQ3 — server-only EVENT-DRIVEN `EquipSystem`** (plain `SimulationSystemGroup`, the AbilityUpgradeSystem owner-map + idiom, NOT predicted → no rollback double-apply). `EquipRequest{ushort ItemId}` + `UnequipRequest{byte Slot}` + unconditional `IRpcCommand`s. Per request, **in-place + atomic**: catalog-lookup via `ref db.Value.Value` + + TryGetItem; reject if not equippable or `CountOf(bag,ItemId)<1`; **on equip-over-occupied, verify the bag can hold + the swapped-out item BEFORE any withdrawal (reject otherwise — no item loss)**; Withdraw new, Deposit old + strip + its effects, set slot, apply new effects. Weapon slot → `SystemAPI`/EntityManager `SetComponentData` + (immediate, not ECB); mods → `StatModifier`s tagged the slot sentinel. Only `DestroyEntity(requestEntity)` deferred. +- **EQ3 SourceId block — reserved, collision-free, target-agnostic strip.** `Tuning.EquipSourceIdBase = 0x00E91000`; + slot i tags its mods `EquipSourceIdBase + i`. Provably disjoint from the full live map (`0u` pickups+debug-injection, + `0x00A0E711` upgrade, `0x00DEB061` debug — documented in `Tuning`). Unequip/swap strips via the existing + **`TimedModifierUtil.RemoveBySourceId`** (target-agnostic — never the `&& Target==Damage` clause, which would orphan + a gear item's other-target mods). +- **EQ4 — effects are NOT re-derived per tick** (event-driven, once per equip). They **persist across respawn for + free** (verified: `PlayerRespawnSystem`/`PlayerDeathStateSystem` never touch `AbilityRef`/`StatModifier`/inventory/ + equipment; the player entity is never destroyed). **This SUPERSEDES DR-026's line-63 sketch** of an + `EquipmentEffectSystem` that "syncs per tick". No client-side equip prediction (one-off RPC, ~1 RTT visual latency). +- **EQ5 — `DefaultAbility { byte Id }`** plain non-replicated component, baked from `PlayerAuthoring.PrimaryAbility`, + restored into `AbilityRef.Id` on weapon-unequip (AbilityRef can't self-serve — it mutates on equip). +- **EQ6 — client send (`EquipSendSystem`): number keys 1-9 equip the Nth equippable bag item, U unequips weapon, + plus a build-safe static `Equip`/`Unequip` API** (shared by the HUD click + headless `execute_code`). HUD: a + read-only equipment panel (4 slots + catalog names) folded into the inventory panel, **click a bag item to equip / + a slot to unequip** (reuses the build-palette `ClickEvent`+`pickingMode=Position` pattern), observe-only from the + replicated `EquipmentSlot`. +- **EQ7 — tests:** `ItemDatabaseBlobTests` (inline-mod round-trip on the 2nd item — the EQ2 regression guard); + `EquipSystemTests` (equip sets ability+mod+moves item; unequip reverses + restores DefaultAbility; equip-over swaps; + **full-bag swap rejected with no loss**; non-equippable/absent/unresolvable no-op; **strip leaves foreign SourceIds + untouched**). 236/236 EditMode green. + +## Deliberate notes + +- **MaxHealth gear:** a `MaxHealth` mod raises the ceiling without auto-healing (and clamps Current down on unequip — + `HealthApplyDamageSystem`'s no-auto-heal rule). The Phase-1 demo gear avoids `MaxHealth` (uses Damage/MoveSpeed) to + dodge the surprise; document if added later. +- **Session-only** like inventory (no `SaveData` bump). Phase 3's additive `SaveData` v3 must restore equipment+ + inventory atomically AND **replay equip** (a plain buffer restore wouldn't re-add the StatModifiers — effects are + event-driven). +- `StatModifier` `InternalBufferCapacity(8)`: worst case 4 slots × 4 mods = 16 → heap (functionally fine). Demo gear + keeps ≤1 mod each. + +## Validation (2026-06-08, Play, host+client) + +Catalog (8 items, equip fields) baked both worlds; re-baked player carries `EquipmentSlot`(4)+`DefaultAbility`, clean +handshake. Equip weapon → `AbilityRef.Id` 1→2 (FastLight) on **both** worlds; equip Aether Vest → `EffectiveCharacter +Stats.MoveSpeed` 6.0→6.90 (folded both worlds); slots `[20,22,0,0]` replicated; bag emptied. Unequip → AbilityRef→1 +(DefaultAbility), MoveSpeed→6.0, mods stripped, items returned. HUD equipment panel renders the catalog names + +click-wires equippable rows. Shippable. + +## Links + +[[DR-026_Inventory_Equipment_Progression_Foundation]] (Phase 0) · [[DR-004_Data_Driven_Abilities_Modifiers]] +(StatModifier) · [[DR-016_Stage_G_Combat_Gameplay]] (TimedModifier strip helper reused).