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,8 @@
fileFormatVersion: 2
guid: 2251d22676b666d48ac5e9e32b70391c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Bakes the designer-authored item definitions into a single ItemDatabase blob singleton (immutable,
/// shared, Burst-fast), mirroring AbilityDatabaseAuthoring. Place ONE in the gameplay subscene; it streams
/// identically into the client and server worlds (config, not replicated). DependsOn each definition so a
/// value change re-bakes the blob. Runtime lookup is ID-keyed (ItemDatabaseBlob.TryGetItem), so the list
/// order here does not matter and inserting an item never renumbers existing ids.
/// </summary>
public class ItemDatabaseAuthoring : MonoBehaviour
{
[Tooltip("All item definitions in the game (resources + tools/gear). Looked up at runtime by ItemId.")]
public List<ItemDefinition> Items = new List<ItemDefinition>();
private class DatabaseBaker : Baker<ItemDatabaseAuthoring>
{
public override void Bake(ItemDatabaseAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.None);
int count = authoring.Items != null ? authoring.Items.Count : 0;
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>();
var arr = builder.Allocate(ref root.Items, count);
for (int i = 0; i < count; i++)
{
var def = authoring.Items[i];
if (def == null) { arr[i] = default; continue; }
DependsOn(def);
arr[i] = new ItemDefBlob
{
ItemId = (ushort)def.ItemId,
Category = def.Category,
Tier = def.Tier,
StackMax = def.StackMax,
Name = def.DisplayName,
};
}
var blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent);
builder.Dispose();
AddBlobAsset(ref blob, out _);
AddComponent(entity, new ItemDatabase { Value = blob });
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ee44dc3bc9f3164592195d4068be8d1
@@ -0,0 +1,31 @@
using ProjectM.Simulation;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Designer-facing definition of one item (resource, tool, weapon, gear, consumable). Numeric/byte fields
/// are baked into the ItemDatabase blob (immutable, Burst-fast runtime config). Category and Tier are
/// BYTES, not enums, to dodge BOTH the MCP enum-drop hazard (manage_* silently drop enum fields when set
/// via tooling) AND the cross-assembly enum-in-Burst hazard. UI icon/description are deferred to a later
/// managed lookup keyed by id, exactly like AbilityDefinition.
/// </summary>
[CreateAssetMenu(menuName = "Project M/Item Definition", fileName = "Item_")]
public class ItemDefinition : ScriptableObject
{
[Tooltip("Stable item id (ushort range). Keep 1=Aether, 2=Ore, 3=Biomass; reserve >3 for new items; 0 = none.")]
public int ItemId = 4;
public string DisplayName = "Item";
[Tooltip("ItemCategory byte: 0=Resource, 1=Tool, 2=Weapon, 3=Gear, 4=Consumable.")]
public byte Category = ItemCategory.Resource;
[Tooltip("Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder.")]
public byte Tier = 0;
[Min(1)]
[Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")]
public int StackMax = 999;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84295e2f852afac4fa4b7384857281d9
@@ -67,6 +67,8 @@ namespace ProjectM.Authoring
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
AddBuffer<StatModifier>(entity);
// Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here).
AddBuffer<InventorySlot>(entity);
// Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated).
AddBuffer<TimedModifier>(entity);
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9a4badbd1733bfa4daeb671385fad693
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only sender for <see cref="InventoryDepositRequest"/> RPCs (move the local player's PERSONAL
/// inventory into the shared base stockpile / global ledger). A one-off action (not per-tick predicted
/// input), so it is an RPC: on the deposit key edge (G = deposit ALL) it creates the request entity
/// targeted at the server connection, and the server applies it authoritatively in
/// <see cref="ProjectM.Server.InventoryDepositSystem"/>. Managed SystemBase because it reads the managed
/// Input System; Input System types are fully qualified and <c>using UnityEngine.InputSystem;</c> is
/// intentionally omitted (that namespace defines a PlayerInput type that collides with
/// <see cref="ProjectM.Simulation.PlayerInput"/>). An editor-only static hook (<see cref="Deposit"/>)
/// drives the same path from execute_code for headless validation without a focused Game view. The wire
/// type is unconditional; only this send SYSTEM is build-time managed.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class InventoryDepositSendSystem : SystemBase
{
#if UNITY_EDITOR
struct PendingDeposit { public ushort ItemId; public int Count; }
static readonly System.Collections.Generic.Queue<PendingDeposit> s_Pending =
new System.Collections.Generic.Queue<PendingDeposit>();
/// <summary>EDITOR / execute_code hook: queue a deposit (ItemId 0 = deposit all; Count &lt;= 0 = all of that item).</summary>
public static void Deposit(ushort itemId = 0, int count = 0) =>
s_Pending.Enqueue(new PendingDeposit { ItemId = itemId, Count = count });
#endif
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
// Need the server connection to target the RPC; bail (keeping any queued ops) until connected.
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null && keyboard.gKey.wasPressedThisFrame)
Send(connection, 0, 0); // G -> deposit everything to the base stockpile
#if UNITY_EDITOR
while (s_Pending.Count > 0)
{
var d = s_Pending.Dequeue();
Send(connection, d.ItemId, d.Count);
}
#endif
}
void Send(Entity connection, ushort itemId, int count)
{
var request = EntityManager.CreateEntity();
EntityManager.AddComponentData(request, new InventoryDepositRequest { ItemId = itemId, Count = count });
EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9298a5924b4920b4db8ff2f48732a662
@@ -70,6 +70,9 @@ namespace ProjectM.Client
VisualElement _vignette, _downed;
float _prevHp, _flash;
bool _haveHp;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList;
bool _invOpen;
EntityQuery _huskQuery;
@@ -290,6 +293,35 @@ namespace ProjectM.Client
_vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None;
}
// ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ----
var invKb = UnityEngine.InputSystem.Keyboard.current;
if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen;
if (_invOpen && found)
{
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
_invPanel.style.display = DisplayStyle.Flex;
_invList.Clear();
int shown = 0;
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (int i = 0; i < bag.Length; i++)
{
var slot = bag[i];
if (slot.ItemId == 0 || slot.Count <= 0) continue;
AddInvRow(ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count);
shown++;
}
break;
}
if (shown == 0)
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
}
else
{
_invPanel.style.display = DisplayStyle.None;
}
}
// ---- per-frame helpers ----
@@ -467,6 +499,7 @@ namespace ProjectM.Client
BuildPaletteRow(root);
BuildHintBar(root);
BuildDowned(root);
BuildInventory(root);
}
void BuildVignette(VisualElement root)
@@ -724,6 +757,67 @@ namespace ProjectM.Client
root.Add(_downed);
}
void BuildInventory(VisualElement root)
{
_invPanel = HudUi.Panel(PanelDark);
_invPanel.style.position = Position.Absolute;
_invPanel.style.right = 40; _invPanel.style.bottom = 40;
_invPanel.style.minWidth = 224;
_invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14;
_invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10;
_invPanel.style.alignItems = Align.FlexStart;
_invPanel.pickingMode = PickingMode.Ignore;
var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft);
header.style.marginBottom = 6;
_invPanel.Add(header);
_invList = new VisualElement();
_invList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_invList);
var hint = HudUi.Text("I close - G deposit all at base", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
hint.style.marginTop = 8;
_invPanel.Add(hint);
_invPanel.style.display = DisplayStyle.None;
root.Add(_invPanel);
}
void AddInvRow(string name, Color tint, int count)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.pickingMode = PickingMode.Ignore;
row.Add(HudUi.Text(name, 13, tint, TextAnchor.MiddleLeft));
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
_invList.Add(row);
}
static string ItemName(bool haveDb, ItemDatabase db, ushort id)
{
if (haveDb && db.Value.IsCreated)
{
ref var blob = ref db.Value.Value;
if (blob.TryGetItem(id, out var def)) return def.Name.ToString();
}
if (id == ResourceId.Aether) return "Aether";
if (id == ResourceId.Ore) return "Ore";
if (id == ResourceId.Biomass) return "Biomass";
return "Item " + id;
}
static Color ItemTint(ushort id)
{
if (id == ResourceId.Aether) return AetherCyan;
if (id == ResourceId.Ore) return OreAmber;
if (id == ResourceId.Biomass) return BioGreen;
return new Color(0.85f, 0.85f, 0.9f);
}
static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
@@ -0,0 +1,84 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative handler for <see cref="InventoryDepositRequest"/> RPCs: moves items from the
/// sender's PERSONAL <see cref="InventorySlot"/> inventory into the shared base stockpile (the global
/// <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from). Resolves the sender's
/// player (SourceConnection -&gt; NetworkId -&gt; GhostOwner) via the AbilityUpgradeSystem owner-map idiom,
/// then withdraws from the player's inventory and deposits into the ledger IN-PLACE (buffer mutation is not
/// a structural change). <c>ItemId == 0</c> ("deposit all") is handled BEFORE any per-item withdraw and
/// never writes a 0-id row. Resolves the ledger via <c>GetSingletonEntity&lt;ResourceLedger&gt;()</c> then
/// <c>GetBuffer&lt;StorageEntry&gt;()</c> — NEVER <c>GetSingleton&lt;StorageEntry&gt;</c> (the base
/// container owns a second StorageEntry buffer). Plain server SimulationSystemGroup (not predicted, so the
/// effect applies exactly once — no rollback double-apply).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct InventoryDepositSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ResourceLedger>();
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<InventoryDepositRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (request, receive, requestEntity) in
SystemAPI.Query<RefRO<InventoryDepositRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
var inv = SystemAPI.GetBuffer<InventorySlot>(player);
var req = request.ValueRO;
if (req.ItemId == 0)
{
// Deposit EVERYTHING: drain each non-empty stack into the ledger, then clear the bag.
for (int i = 0; i < inv.Length; i++)
{
var slot = inv[i];
if (slot.ItemId != 0 && slot.Count > 0)
StorageMath.Deposit(ledger, slot.ItemId, slot.Count);
}
inv.Clear();
}
else
{
// Count <= 0 means "all of that item"; Withdraw clamps to what is available.
int want = req.Count <= 0 ? int.MaxValue : req.Count;
int moved = InventoryMath.Withdraw(inv, req.ItemId, want);
if (moved > 0)
StorageMath.Deposit(ledger, req.ItemId, moved);
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 25b1bdf13ad8a6d4ca48bef112b98d28
@@ -33,11 +33,16 @@ namespace ProjectM.Server
{
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
BufferLookup<InventorySlot> m_InvLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Projectile>();
state.RequireForUpdate<ResourceLedger>();
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
}
[BurstCompile]
@@ -46,6 +51,18 @@ namespace ProjectM.Server
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
// Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL
// inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are
// hoisted out of the per-hit sweep (invariant for the tick).
m_GhostOwnerLookup.Update(ref state);
m_InvLookup.Update(ref state);
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, playerEntity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = playerEntity;
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
var tgtPos = new NativeList<float2>(Allocator.Temp);
@@ -121,7 +138,30 @@ namespace ProjectM.Server
// A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit
// value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots.
int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]);
StorageMath.Deposit(ledger, tgtYieldId[bestIdx], amount);
byte yieldId = tgtYieldId[bestIdx];
// Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
int remainder = amount;
if (m_GhostOwnerLookup.HasComponent(projEntity)
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var player)
&& m_InvLookup.HasBuffer(player))
{
int stackMax = Tuning.DefaultStackMax;
if (haveDb && itemDb.Value.IsCreated)
{
ref var itemBlob = ref itemDb.Value.Value;
if (itemBlob.TryGetItem(yieldId, out var def) && def.StackMax > 0)
stackMax = def.StackMax;
}
var inv = m_InvLookup[player];
remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots);
}
// Unresolvable owner or a full bag: the remainder credits the shared ledger (no-loss valve).
if (remainder > 0)
StorageMath.Deposit(ledger, yieldId, remainder);
int rem = tgtRemaining[bestIdx] - amount;
tgtRemaining[bestIdx] = rem;
ecb.DestroyEntity(projEntity);
@@ -158,6 +198,7 @@ namespace ProjectM.Server
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
destroyed.Dispose();
tgtEntity.Dispose();
tgtPos.Dispose();
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0d59e1972aec03a4399355f38b87d5be
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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
@@ -56,5 +56,13 @@ namespace ProjectM.Simulation
/// <summary>Max production cycles a single machine awards in one process (bounds within-session
/// catch-up after any skipped ticks; restore re-seats the baseline so this never reflects wall-clock).</summary>
public const int MaxProductionCatchup = 600;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
public const int InventoryMaxSlots = 24;
/// <summary>Default per-slot stack cap when an item has no ItemDatabase entry (the catalog is optional at runtime).</summary>
public const int DefaultStackMax = 999;
}
}