Docs: DR-027 equipment slots (Phase 1) + session log

DR-027 records the architecture (reuse AbilityRef/StatModifier; event-driven server-only equip supersedes DR-026's per-tick sketch), the 7 validated decisions, the inline-mod + reserved-SourceId fixes, and the MaxHealth-gear / session-only notes. Folds the equipment pointer into CLAUDE.md's inventory bullet net-zero (39925 bytes, >1KB headroom) by trimming the persistence/M7/ability-tier bullets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:09:40 -07:00
parent 516aacee18
commit 8ddfdbc6a5
3 changed files with 164 additions and 4 deletions
@@ -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<ItemModSpec>` 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).
@@ -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 (EQ1EQ7, 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<ItemModAuthoring>`.
- **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<AbilityRef>`
(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).