From 599b9b4255e15e502811e3b27e21e744ae00950a Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Mon, 8 Jun 2026 09:43:31 -0700 Subject: [PATCH] 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 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) --- Assets/_Project/Items.meta | 8 ++ Assets/_Project/Items/Item_Aether.asset | 19 ++++ Assets/_Project/Items/Item_Aether.asset.meta | 8 ++ Assets/_Project/Items/Item_Biomass.asset | 19 ++++ Assets/_Project/Items/Item_Biomass.asset.meta | 8 ++ Assets/_Project/Items/Item_Ore.asset | 19 ++++ Assets/_Project/Items/Item_Ore.asset.meta | 8 ++ Assets/_Project/Items/Item_StonePickaxe.asset | 19 ++++ .../Items/Item_StonePickaxe.asset.meta | 8 ++ Assets/_Project/Scripts/Authoring/Items.meta | 8 ++ .../Authoring/Items/ItemDatabaseAuthoring.cs | 54 +++++++++++ .../Items/ItemDatabaseAuthoring.cs.meta | 2 + .../Scripts/Authoring/Items/ItemDefinition.cs | 31 ++++++ .../Authoring/Items/ItemDefinition.cs.meta | 2 + .../Authoring/Player/PlayerAuthoring.cs | 2 + Assets/_Project/Scripts/Client/Economy.meta | 8 ++ .../Economy/InventoryDepositSendSystem.cs | 64 +++++++++++++ .../InventoryDepositSendSystem.cs.meta | 2 + .../Scripts/Client/Presentation/HudSystem.cs | 94 +++++++++++++++++++ .../Server/Economy/InventoryDepositSystem.cs | 84 +++++++++++++++++ .../Economy/InventoryDepositSystem.cs.meta | 2 + .../Server/Economy/ResourceHarvestSystem.cs | 43 ++++++++- Assets/_Project/Scripts/Simulation/Items.meta | 8 ++ .../Items/InventoryDepositRequest.cs | 23 +++++ .../Items/InventoryDepositRequest.cs.meta | 2 + .../Scripts/Simulation/Items/InventoryMath.cs | 89 ++++++++++++++++++ .../Simulation/Items/InventoryMath.cs.meta | 2 + .../Scripts/Simulation/Items/InventorySlot.cs | 35 +++++++ .../Simulation/Items/InventorySlot.cs.meta | 2 + .../Scripts/Simulation/Items/ItemCategory.cs | 26 +++++ .../Simulation/Items/ItemCategory.cs.meta | 2 + .../Scripts/Simulation/Items/ItemDatabase.cs | 17 ++++ .../Simulation/Items/ItemDatabase.cs.meta | 2 + .../Simulation/Items/ItemDatabaseBlob.cs | 69 ++++++++++++++ .../Simulation/Items/ItemDatabaseBlob.cs.meta | 2 + Assets/_Project/Scripts/Simulation/Tuning.cs | 8 ++ Assets/_Project/Subscenes/Gameplay.unity | 50 ++++++++++ 37 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 Assets/_Project/Items.meta create mode 100644 Assets/_Project/Items/Item_Aether.asset create mode 100644 Assets/_Project/Items/Item_Aether.asset.meta create mode 100644 Assets/_Project/Items/Item_Biomass.asset create mode 100644 Assets/_Project/Items/Item_Biomass.asset.meta create mode 100644 Assets/_Project/Items/Item_Ore.asset create mode 100644 Assets/_Project/Items/Item_Ore.asset.meta create mode 100644 Assets/_Project/Items/Item_StonePickaxe.asset create mode 100644 Assets/_Project/Items/Item_StonePickaxe.asset.meta create mode 100644 Assets/_Project/Scripts/Authoring/Items.meta create mode 100644 Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs create mode 100644 Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta create mode 100644 Assets/_Project/Scripts/Client/Economy.meta create mode 100644 Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs create mode 100644 Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs create mode 100644 Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta diff --git a/Assets/_Project/Items.meta b/Assets/_Project/Items.meta new file mode 100644 index 000000000..f2638c9ca --- /dev/null +++ b/Assets/_Project/Items.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7994449a235d329439443860dfb4ec82 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_Aether.asset b/Assets/_Project/Items/Item_Aether.asset new file mode 100644 index 000000000..24bb2e180 --- /dev/null +++ b/Assets/_Project/Items/Item_Aether.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_Aether + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 1 + DisplayName: Aether + Category: 0 + Tier: 0 + StackMax: 999 diff --git a/Assets/_Project/Items/Item_Aether.asset.meta b/Assets/_Project/Items/Item_Aether.asset.meta new file mode 100644 index 000000000..0a8253f9b --- /dev/null +++ b/Assets/_Project/Items/Item_Aether.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 938a882b6fbd50d4c9a051ddabc5829b +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_Biomass.asset b/Assets/_Project/Items/Item_Biomass.asset new file mode 100644 index 000000000..cd71939c2 --- /dev/null +++ b/Assets/_Project/Items/Item_Biomass.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_Biomass + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 3 + DisplayName: Biomass + Category: 0 + Tier: 0 + StackMax: 999 diff --git a/Assets/_Project/Items/Item_Biomass.asset.meta b/Assets/_Project/Items/Item_Biomass.asset.meta new file mode 100644 index 000000000..033cc9041 --- /dev/null +++ b/Assets/_Project/Items/Item_Biomass.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a2a0e6d3ae0218d458d2b5305891ce89 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_Ore.asset b/Assets/_Project/Items/Item_Ore.asset new file mode 100644 index 000000000..5a9eca760 --- /dev/null +++ b/Assets/_Project/Items/Item_Ore.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_Ore + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 2 + DisplayName: Ore + Category: 0 + Tier: 0 + StackMax: 999 diff --git a/Assets/_Project/Items/Item_Ore.asset.meta b/Assets/_Project/Items/Item_Ore.asset.meta new file mode 100644 index 000000000..7ea0eb459 --- /dev/null +++ b/Assets/_Project/Items/Item_Ore.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4ed399a329eb6d847921aef05bde213d +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Items/Item_StonePickaxe.asset b/Assets/_Project/Items/Item_StonePickaxe.asset new file mode 100644 index 000000000..6deeb7a62 --- /dev/null +++ b/Assets/_Project/Items/Item_StonePickaxe.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 84295e2f852afac4fa4b7384857281d9, type: 3} + m_Name: Item_StonePickaxe + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDefinition + ItemId: 10 + DisplayName: Stone Pickaxe + Category: 1 + Tier: 1 + StackMax: 1 diff --git a/Assets/_Project/Items/Item_StonePickaxe.asset.meta b/Assets/_Project/Items/Item_StonePickaxe.asset.meta new file mode 100644 index 000000000..eac97a324 --- /dev/null +++ b/Assets/_Project/Items/Item_StonePickaxe.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31ec40cfc6e81ef4ab9bbce57a40621e +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Items.meta b/Assets/_Project/Scripts/Authoring/Items.meta new file mode 100644 index 000000000..b681fbeb2 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Items.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2251d22676b666d48ac5e9e32b70391c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs new file mode 100644 index 000000000..75413bad2 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// 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. + /// + public class ItemDatabaseAuthoring : MonoBehaviour + { + [Tooltip("All item definitions in the game (resources + tools/gear). Looked up at runtime by ItemId.")] + public List Items = new List(); + + private class DatabaseBaker : Baker + { + 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(); + 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(Allocator.Persistent); + builder.Dispose(); + AddBlobAsset(ref blob, out _); + AddComponent(entity, new ItemDatabase { Value = blob }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta new file mode 100644 index 000000000..7dcd14c91 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Items/ItemDatabaseAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ee44dc3bc9f3164592195d4068be8d1 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs new file mode 100644 index 000000000..922269ee2 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs @@ -0,0 +1,31 @@ +using ProjectM.Simulation; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// 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. + /// + [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; + } +} diff --git a/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta new file mode 100644 index 000000000..0a7a91fba --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Items/ItemDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 84295e2f852afac4fa4b7384857281d9 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index ed7ecff2d..71faae6bb 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -67,6 +67,8 @@ namespace ProjectM.Authoring // Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative). AddBuffer(entity); + // Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here). + AddBuffer(entity); // Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated). AddBuffer(entity); diff --git a/Assets/_Project/Scripts/Client/Economy.meta b/Assets/_Project/Scripts/Client/Economy.meta new file mode 100644 index 000000000..5ff449904 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Economy.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9a4badbd1733bfa4daeb671385fad693 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs b/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs new file mode 100644 index 000000000..f777e4c16 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs @@ -0,0 +1,64 @@ +using ProjectM.Simulation; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Client +{ + /// + /// Client-only sender for 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 + /// . Managed SystemBase because it reads the managed + /// Input System; Input System types are fully qualified and using UnityEngine.InputSystem; is + /// intentionally omitted (that namespace defines a PlayerInput type that collides with + /// ). An editor-only static hook () + /// 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. + /// + [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 s_Pending = + new System.Collections.Generic.Queue(); + + /// EDITOR / execute_code hook: queue a deposit (ItemId 0 = deposit all; Count <= 0 = all of that item). + 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(); + } + + protected override void OnUpdate() + { + // Need the server connection to target the RPC; bail (keeping any queued ops) until connected. + if (!SystemAPI.TryGetSingletonEntity(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 }); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta b/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta new file mode 100644 index 000000000..d63db2d3b --- /dev/null +++ b/Assets/_Project/Scripts/Client/Economy/InventoryDepositSendSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9298a5924b4920b4db8ff2f48732a662 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index d1c0848f2..f7c2921dd 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -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(); + bool haveItemDb = SystemAPI.TryGetSingleton(out var itemDb); + _invPanel.style.display = DisplayStyle.Flex; + _invList.Clear(); + int shown = 0; + foreach (var bag in SystemAPI.Query>() + .WithAll()) + { + 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; diff --git a/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs b/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs new file mode 100644 index 000000000..ccea5857e --- /dev/null +++ b/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs @@ -0,0 +1,84 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Server +{ + /// + /// Server-authoritative handler for RPCs: moves items from the + /// sender's PERSONAL inventory into the shared base stockpile (the global + /// 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). ItemId == 0 ("deposit all") is handled BEFORE any per-item withdraw and + /// never writes a 0-id row. Resolves the ledger via GetSingletonEntity<ResourceLedger>() then + /// GetBuffer<StorageEntry>() — NEVER GetSingleton<StorageEntry> (the base + /// container owns a second StorageEntry buffer). Plain server SimulationSystemGroup (not predicted, so the + /// effect applies exactly once — no rollback double-apply). + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + public partial struct InventoryDepositSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + var builder = new EntityQueryBuilder(Allocator.Temp) + .WithAll(); + state.RequireForUpdate(state.GetEntityQuery(builder)); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); + + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, entity) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + playerByConn[owner.ValueRO.NetworkId] = entity; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + + foreach (var (request, receive, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) + { + var conn = receive.ValueRO.SourceConnection; + if (SystemAPI.HasComponent(conn) + && playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var player)) + { + var inv = SystemAPI.GetBuffer(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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta new file mode 100644 index 000000000..539d80e3e --- /dev/null +++ b/Assets/_Project/Scripts/Server/Economy/InventoryDepositSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 25b1bdf13ad8a6d4ca48bef112b98d28 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs index f0ffe7683..6769ed557 100644 --- a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs @@ -33,11 +33,16 @@ namespace ProjectM.Server { const float k_ProjectileRadius = Tuning.HarvestProjectileRadius; + ComponentLookup m_GhostOwnerLookup; + BufferLookup m_InvLookup; + [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); + m_GhostOwnerLookup = state.GetComponentLookup(true); + m_InvLookup = state.GetBufferLookup(false); } [BurstCompile] @@ -46,6 +51,18 @@ namespace ProjectM.Server var ledgerEntity = SystemAPI.GetSingletonEntity(); var ledger = SystemAPI.GetBuffer(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(out var itemDb); + + var playerByConn = new NativeHashMap(8, Allocator.Temp); + foreach (var (owner, playerEntity) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + playerByConn[owner.ValueRO.NetworkId] = playerEntity; + // Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set. var tgtEntity = new NativeList(Allocator.Temp); var tgtPos = new NativeList(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(); diff --git a/Assets/_Project/Scripts/Simulation/Items.meta b/Assets/_Project/Scripts/Simulation/Items.meta new file mode 100644 index 000000000..52d31ddd8 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0d59e1972aec03a4399355f38b87d5be +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs b/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs new file mode 100644 index 000000000..26a3ca809 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs @@ -0,0 +1,23 @@ +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// Client -> server request to move items from the sender's PERSONAL inventory into the shared base + /// stockpile (the global 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. + /// + public struct InventoryDepositRequest : IRpcCommand + { + /// 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. + public ushort ItemId; + + /// Quantity to deposit; <= 0 means "all of that item" (ignored when ItemId is 0). + public int Count; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta b/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta new file mode 100644 index 000000000..1bf61bf2b --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/InventoryDepositRequest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a815da9a948230e46bc4f7154887613e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs new file mode 100644 index 000000000..92af415e2 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs @@ -0,0 +1,89 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic stacking logic for a player's buffer (no RNG / + /// wall-clock / singleton access, so server and any future prediction agree). Parallels + /// 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. + /// + public static class InventoryMath + { + /// + /// Add of : first tops up existing non-full stacks + /// of that item, then appends new stacks (each capped at ) while a free slot + /// remains (buffer length < ). 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. + /// + public static int Deposit(DynamicBuffer 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; + } + + /// + /// Remove up to of 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. + /// + public static int Withdraw(DynamicBuffer 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; + } + + /// Total quantity of across all stacks (0 if absent). + public static int CountOf(DynamicBuffer 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; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta new file mode 100644 index 000000000..e46f72a25 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/InventoryMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c8560f6c2e717b943bed78d40ea87404 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs b/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs new file mode 100644 index 000000000..bb9f84dc6 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs @@ -0,0 +1,35 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// 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 + /// : a [GhostField] buffer with 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 field-for-field. + /// + /// REPLICATION DISCIPLINE — the ONLY writers are server-only: + /// (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 + /// and the 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. + /// + [GhostComponent(OwnerSendType = SendToOwnerType.All)] + [InternalBufferCapacity(24)] + public struct InventorySlot : IBufferElementData + { + /// Item carried in this slot (0 = empty/unused; aligns with InventoryMath's 0-id no-op). + [GhostField] public ushort ItemId; + + /// Quantity in this slot (bounded by the item's StackMax when deposited via InventoryMath). + [GhostField] public int Count; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta b/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta new file mode 100644 index 000000000..c572f67be --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/InventorySlot.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f151c780df9917d4089b2944f3ffb12d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs b/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs new file mode 100644 index 000000000..e88978457 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs @@ -0,0 +1,26 @@ +namespace ProjectM.Simulation +{ + /// + /// 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 . + /// + public static class ItemCategory + { + /// Stackable raw material (Aether/Ore/Biomass). Spendable at the base / for crafting. + public const byte Resource = 0; + + /// Gathering tool (axe/pickaxe). Equippable; gates + scales harvesting (Phase 2). + public const byte Tool = 1; + + /// Weapon. Equipping it grants its ability + stat modifiers (Phase 1). + public const byte Weapon = 2; + + /// Wearable gear (armour/trinket). Equipping it grants stat modifiers (Phase 1). + public const byte Gear = 3; + + /// One-shot consumable (potion/charge). Used from the inventory (later phase). + public const byte Consumable = 4; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta b/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta new file mode 100644 index 000000000..3e2f36f17 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/ItemCategory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fa1718754184d2a418b1099ef7e3ae34 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs b/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs new file mode 100644 index 000000000..11e185d41 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs @@ -0,0 +1,17 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Singleton handle to the baked item-definition database (config, not replicated — baked identically + /// into both worlds from the gameplay subscene, exactly like ). A distinct + /// component type, so GetSingleton<ItemDatabase>() resolves independently of the ability + /// database (singleton-ness is per type). Optional at runtime: consumers that read it use + /// TryGetSingleton and fall back to defaults (e.g. Tuning.DefaultStackMax) so the sim still + /// runs before the catalog is authored. + /// + public struct ItemDatabase : IComponentData + { + public BlobAssetReference Value; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta b/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta new file mode 100644 index 000000000..34e81bb20 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/ItemDatabase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6b355888562be7349b8754168375db9b \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs new file mode 100644 index 000000000..eda274d99 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs @@ -0,0 +1,69 @@ +using Unity.Collections; +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// One authored item definition, baked immutable into the 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 + /// ushort space as / , and it + /// SUBSUMES the low byte ids (Aether=1/Ore=2/Biomass=3) — a resource is just a + /// low-id item of . 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. + /// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a + /// content-only edit. + /// + public struct ItemDefBlob + { + /// Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves). + public ushort ItemId; + + /// Broad category (see ), stored as a byte. + public byte Category; + + /// Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder (Phase 2/3). + public byte Tier; + + /// Max units that stack in a single inventory slot (1 for non-stacking equipment). + public int StackMax; + + /// Designer-facing display name (shown in the HUD inventory panel). + public FixedString64Bytes Name; + } + + /// + /// 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 + /// — 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 ). + /// + public struct ItemDatabaseBlob + { + public BlobArray Items; + + /// Linear lookup by item id (the array is tiny). Returns false if not present. + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta new file mode 100644 index 000000000..46dde8b2e --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Items/ItemDatabaseBlob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ae0cc6bfb8578c42bff8e300078cf2d \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index a62be4e5d..ee597812f 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -56,5 +56,13 @@ namespace ProjectM.Simulation /// 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). public const int MaxProductionCatchup = 600; + + // ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ---- + + /// Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger. + public const int InventoryMaxSlots = 24; + + /// Default per-slot stack cap when an item has no ItemDatabase entry (the catalog is optional at runtime). + public const int DefaultStackMax = 999; } } diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity index 3020b02c6..749e47058 100644 --- a/Assets/_Project/Subscenes/Gameplay.unity +++ b/Assets/_Project/Subscenes/Gameplay.unity @@ -1449,6 +1449,55 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 3, y: 2.5, z: 3} m_Center: {x: 0, y: 1.25, z: 0} +--- !u!1 &722706768 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 722706770} + - component: {fileID: 722706769} + m_Layer: 0 + m_Name: ItemDatabase + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &722706769 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 722706768} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5ee44dc3bc9f3164592195d4068be8d1, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ItemDatabaseAuthoring + Items: + - {fileID: 11400000, guid: 938a882b6fbd50d4c9a051ddabc5829b, type: 2} + - {fileID: 11400000, guid: a2a0e6d3ae0218d458d2b5305891ce89, type: 2} + - {fileID: 11400000, guid: 4ed399a329eb6d847921aef05bde213d, type: 2} + - {fileID: 11400000, guid: 31ec40cfc6e81ef4ab9bbce57a40621e, type: 2} +--- !u!4 &722706770 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 722706768} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &842917524 GameObject: m_ObjectHideFlags: 0 @@ -3685,3 +3734,4 @@ SceneRoots: - {fileID: 1698085403} - {fileID: 450973138} - {fileID: 1268920429} + - {fileID: 722706770}