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:
@@ -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 <= 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 -> NetworkId -> 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<ResourceLedger>()</c> then
|
||||
/// <c>GetBuffer<StorageEntry>()</c> — NEVER <c>GetSingleton<StorageEntry></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 -> 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; <= 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 < <paramref name="maxSlots"/>). Returns the REMAINDER that did not fit
|
||||
/// (0 if everything was deposited). No-op (returns 0) for count <= 0; a positive count of itemId 0
|
||||
/// returns the full count (nothing deposited — never writes a 0-id row). stackMax < 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 <= 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<ItemDatabase>()</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 > 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user