Inventory: per-player items backbone (DR-026 Phase 0)

Data-driven ItemDatabase catalog + per-player replicated InventorySlot ([GhostField] OwnerSendType.All, a StatModifier twin). Harvest reroutes to the firing player's personal inventory (optional ComponentLookup<GhostOwner> in ResourceHarvestSystem; remainder/un-owned -> ledger); the G-key InventoryDepositRequest RPC moves the bag into the shared ledger the build economy spends. Catalog asset (Aether/Ore/Biomass + Stone Pickaxe) wired into the Gameplay subscene; read-only HUD inventory panel. ushort ItemId subsumes ResourceId; byte Category/Tier baked for gear-tier progression. Session-only (no SaveData bump).

Play-validated host+client: catalog baked into both worlds, the re-baked player ghost carries InventorySlot with a clean handshake, a server write replicates to the client owner, the deposit RPC round-trips, and the HUD renders catalog names. See DR-026.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:43:31 -07:00
parent 1ed2aa46c5
commit 599b9b4255
37 changed files with 848 additions and 1 deletions
@@ -0,0 +1,23 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server request to move items from the sender's PERSONAL inventory into the shared base
/// stockpile (the global <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from).
/// A one-off action, so it is an RPC (not a per-tick predicted input), and the server applies it exactly
/// once in the plain SimulationSystemGroup (no rollback double-apply). Payload is plain blittable scalars
/// (no entity refs, no enum): the server resolves the sender's player from the RPC's SourceConnection. The
/// wire type is UNCONDITIONAL (never #if-gated) so the RpcCollection hash matches across release/dev peers;
/// only the send/receive SYSTEMS may be #if-gated.
/// </summary>
public struct InventoryDepositRequest : IRpcCommand
{
/// <summary>Item to deposit, or 0 to deposit EVERYTHING the player is carrying. The server branches on
/// 0 BEFORE any per-item withdraw and never writes a 0-id row.</summary>
public ushort ItemId;
/// <summary>Quantity to deposit; &lt;= 0 means "all of that item" (ignored when ItemId is 0).</summary>
public int Count;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a815da9a948230e46bc4f7154887613e
@@ -0,0 +1,89 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic stacking logic for a player's <see cref="InventorySlot"/> buffer (no RNG /
/// wall-clock / singleton access, so server and any future prediction agree). Parallels
/// <see cref="StorageMath"/> in spirit but does NOT collapse into it: StorageMath is an unbounded
/// single-row merge, whereas this enforces a per-item stack cap and a max slot count and supports multiple
/// stacks of the same item once a stack fills. DynamicBuffer is a handle, so mutations apply to the
/// underlying entity buffer; growing it (buffer.Add) is a resize, NOT a structural change, so it is safe to
/// call while iterating a different query. Unit-tested in EditMode via a plain Entities world.
/// </summary>
public static class InventoryMath
{
/// <summary>
/// Add <paramref name="count"/> of <paramref name="itemId"/>: first tops up existing non-full stacks
/// of that item, then appends new stacks (each capped at <paramref name="stackMax"/>) while a free slot
/// remains (buffer length &lt; <paramref name="maxSlots"/>). Returns the REMAINDER that did not fit
/// (0 if everything was deposited). No-op (returns 0) for count &lt;= 0; a positive count of itemId 0
/// returns the full count (nothing deposited — never writes a 0-id row). stackMax &lt; 1 = unbounded.
/// </summary>
public static int Deposit(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count, int stackMax, int maxSlots)
{
if (count <= 0) return 0;
if (itemId == 0) return count;
if (stackMax < 1) stackMax = int.MaxValue;
// Top up existing stacks of this item.
for (int i = 0; i < buffer.Length && count > 0; i++)
{
if (buffer[i].ItemId != itemId) continue;
var e = buffer[i];
int space = stackMax - e.Count;
if (space <= 0) continue;
int add = space < count ? space : count;
e.Count += add;
buffer[i] = e;
count -= add;
}
// Append new stacks while a slot is free.
while (count > 0 && buffer.Length < maxSlots)
{
int add = stackMax < count ? stackMax : count;
buffer.Add(new InventorySlot { ItemId = itemId, Count = add });
count -= add;
}
return count;
}
/// <summary>
/// Remove up to <paramref name="count"/> of <paramref name="itemId"/> across all its stacks, clamped to
/// what is available; drops a stack that reaches zero. Returns the amount actually withdrawn (0 if none).
/// Iterates back-to-front so RemoveAt does not skip a stack. No-op for count &lt;= 0 or itemId 0.
/// </summary>
public static int Withdraw(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count)
{
if (count <= 0 || itemId == 0) return 0;
int taken = 0;
for (int i = buffer.Length - 1; i >= 0 && count > 0; i--)
{
if (buffer[i].ItemId != itemId) continue;
var e = buffer[i];
int t = e.Count < count ? e.Count : count;
e.Count -= t;
taken += t;
count -= t;
if (e.Count <= 0)
buffer.RemoveAt(i);
else
buffer[i] = e;
}
return taken;
}
/// <summary>Total quantity of <paramref name="itemId"/> across all stacks (0 if absent).</summary>
public static int CountOf(DynamicBuffer<InventorySlot> buffer, ushort itemId)
{
int total = 0;
for (int i = 0; i < buffer.Length; i++)
if (buffer[i].ItemId == itemId)
total += buffer[i].Count;
return total;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c8560f6c2e717b943bed78d40ea87404
@@ -0,0 +1,35 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// One (item, count) row in a player's PERSONAL inventory. The per-player DynamicBuffer of these is the
/// server-authoritative source of what that player is carrying. A structural twin of
/// <see cref="StatModifier"/>: a [GhostField] buffer with <see cref="SendToOwnerType.All"/> so the owning
/// (predicting) client receives its own inventory — without it the owner, being the owner, would not get
/// the owner-typed buffer at all and the HUD would read empty. BOTH fields carry [GhostField]; the
/// [GhostComponent] attribute alone does NOT auto-replicate fields (an un-annotated field ships as a
/// silent zero), so the annotations mirror <see cref="StorageEntry"/> field-for-field.
///
/// REPLICATION DISCIPLINE — the ONLY writers are server-only: <see cref="ProjectM.Server.ResourceHarvestSystem"/>
/// (harvest yield) and the deposit-to-base RPC handler, both in the plain server SimulationSystemGroup. So
/// there is no predicted-loop double-apply and the owner never mispredicts its inventory — it is a pure
/// server-authored snapshot. NEVER mutate this from a client predicted system (that would reintroduce a
/// double-apply / mispredict path). ItemId is the same opaque ushort id space as <see cref="StorageEntry"/>
/// and the <see cref="ItemDatabase"/> catalog.
///
/// NOTE: adding this [GhostField] buffer CHANGES the player ghost serialization hash — the player prefab /
/// subscene MUST be re-baked (consistently in both worlds) or the connect handshake desyncs.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
[InternalBufferCapacity(24)]
public struct InventorySlot : IBufferElementData
{
/// <summary>Item carried in this slot (0 = empty/unused; aligns with InventoryMath's 0-id no-op).</summary>
[GhostField] public ushort ItemId;
/// <summary>Quantity in this slot (bounded by the item's StackMax when deposited via InventoryMath).</summary>
[GhostField] public int Count;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f151c780df9917d4089b2944f3ffb12d
@@ -0,0 +1,26 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Broad item-category ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard that
/// already de-Bursted ProjectileClassificationSystem). The category lets systems and UI treat an item
/// generically (a resource stacks and is spendable at the base; a tool/weapon is equippable; a
/// consumable is used) without a per-id switch. Stored in the ItemDatabase blob's <see cref="ItemDefBlob"/>.
/// </summary>
public static class ItemCategory
{
/// <summary>Stackable raw material (Aether/Ore/Biomass). Spendable at the base / for crafting.</summary>
public const byte Resource = 0;
/// <summary>Gathering tool (axe/pickaxe). Equippable; gates + scales harvesting (Phase 2).</summary>
public const byte Tool = 1;
/// <summary>Weapon. Equipping it grants its ability + stat modifiers (Phase 1).</summary>
public const byte Weapon = 2;
/// <summary>Wearable gear (armour/trinket). Equipping it grants stat modifiers (Phase 1).</summary>
public const byte Gear = 3;
/// <summary>One-shot consumable (potion/charge). Used from the inventory (later phase).</summary>
public const byte Consumable = 4;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fa1718754184d2a418b1099ef7e3ae34
@@ -0,0 +1,17 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Singleton handle to the baked item-definition database (config, not replicated — baked identically
/// into both worlds from the gameplay subscene, exactly like <see cref="AbilityDatabase"/>). A distinct
/// component type, so <c>GetSingleton&lt;ItemDatabase&gt;()</c> resolves independently of the ability
/// database (singleton-ness is per type). Optional at runtime: consumers that read it use
/// <c>TryGetSingleton</c> and fall back to defaults (e.g. <c>Tuning.DefaultStackMax</c>) so the sim still
/// runs before the catalog is authored.
/// </summary>
public struct ItemDatabase : IComponentData
{
public BlobAssetReference<ItemDatabaseBlob> Value;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6b355888562be7349b8754168375db9b
@@ -0,0 +1,69 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored item definition, baked immutable into the <see cref="ItemDatabase"/> blob. This is the
/// single source of truth for everything an item IS — resources, tools, weapons, gear, consumables — so
/// adding game content is an authoring row + re-bake, with no code change. Id space is the SAME
/// <c>ushort</c> space as <see cref="StorageEntry.ItemId"/> / <see cref="InventorySlot.ItemId"/>, and it
/// SUBSUMES the low <see cref="ResourceId"/> byte ids (Aether=1/Ore=2/Biomass=3) — a resource is just a
/// low-id item of <see cref="ItemCategory.Resource"/>. KEEP ids 1-3 stable for the existing resources and
/// reserve new item ids &gt; 3; 0 = none. Entity/prefab refs do NOT live here (blobs don't remap entity
/// refs) — a future companion buffer carries those, exactly like AbilityPrefabElement.
///
/// The blob is config (baked identically into both worlds, NOT replicated, NOT in SaveData), so growing
/// this struct later (a granted-ability id, a StatModifier-spec array, a slot id for Phase 1/2/3) is a
/// pure re-bake with zero migration: no SaveData version bump, no ghost-hash change, no desync. <see cref="Tier"/>
/// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a
/// content-only edit.
/// </summary>
public struct ItemDefBlob
{
/// <summary>Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves).</summary>
public ushort ItemId;
/// <summary>Broad category (see <see cref="ItemCategory"/>), stored as a byte.</summary>
public byte Category;
/// <summary>Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder (Phase 2/3).</summary>
public byte Tier;
/// <summary>Max units that stack in a single inventory slot (1 for non-stacking equipment).</summary>
public int StackMax;
/// <summary>Designer-facing display name (shown in the HUD inventory panel).</summary>
public FixedString64Bytes Name;
}
/// <summary>
/// Immutable designer-authored item database, baked from ScriptableObjects to a blob asset and shared by
/// every entity (Burst-fast, zero per-instance cost). Looked up by stable <see cref="ItemDefBlob.ItemId"/>
/// — ID-KEYED, never by array index, so inserting a new item never renumbers existing ids.
///
/// NOTE: the lookup is intentionally NOT a 'readonly' method. A readonly struct method forces a defensive
/// copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its relative-offset
/// pointer, so the array would read as empty. A plain (non-readonly) method accesses the BlobArray in place.
/// Always reach this through 'ref blob.Value' (mirrors <see cref="AbilityDatabaseBlob"/>).
/// </summary>
public struct ItemDatabaseBlob
{
public BlobArray<ItemDefBlob> Items;
/// <summary>Linear lookup by item id (the array is tiny). Returns false if not present.</summary>
public bool TryGetItem(ushort id, out ItemDefBlob def)
{
for (int i = 0; i < Items.Length; i++)
{
if (Items[i].ItemId == id)
{
def = Items[i];
return true;
}
}
def = default;
return false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0ae0cc6bfb8578c42bff8e300078cf2d