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,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();