namespace NeonSprawl.Server.Game.Items; /// Server-authoritative inventory add/remove rules (NEO-54). NEO-56 telemetry hook sites: , , and . public static class PlayerInventoryOperations { /// /// Adds of into the catalog-routed container. /// All-or-nothing: denies with when the full amount cannot be placed. /// public static PlayerInventoryMutationOutcome TryAddStack( string playerId, string itemId, int quantity, IItemDefinitionRegistry registry, IPlayerInventoryStore store) { if (quantity <= 0) { return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidQuantity); } var lookup = itemId.Trim(); if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) { return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem); } string? denyReason = null; if (!store.TryMutateSnapshot( playerId, before => { var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); var slots = PlayerInventorySnapshot.CloneSlots(before.GetSlots(container)); var remaining = quantity; remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax); remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax); if (remaining > 0) { denyReason = PlayerInventoryReasonCodes.InventoryFull; return new PlayerInventoryMutationWrite(Write: false, before); } return new PlayerInventoryMutationWrite(Write: true, before.WithSlots(container, slots)); }, out var result)) { return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null); } if (denyReason is not null) { // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` --- // TODO(E9.M1): catalog emit — reasonCode (e.g. inventory_full). Planned payload: playerId, itemId, quantity, // reasonCode, mutationKind=add; optional source when Slice 2+ callers exist. No ingest or ILogger here (comments-only). return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result); } // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `item_created` --- // TODO(E9.M1): catalog emit — successful add only (not remove). Planned payload: playerId, itemId, quantity; // optional mutationKind/source when gather/craft callers exist. No ingest or ILogger here (comments-only). return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result); } /// Removes from stacks of (lowest slot index first). public static PlayerInventoryMutationOutcome TryRemoveStack( string playerId, string itemId, int quantity, IItemDefinitionRegistry registry, IPlayerInventoryStore store) { if (quantity <= 0) { return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidQuantity); } var lookup = itemId.Trim(); if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) { return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem); } string? denyReason = null; if (!store.TryMutateSnapshot( playerId, before => { var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); var slots = PlayerInventorySnapshot.CloneSlots(before.GetSlots(container)); var available = CountQuantity(slots, def.Id); if (available < quantity) { denyReason = PlayerInventoryReasonCodes.InsufficientQuantity; return new PlayerInventoryMutationWrite(Write: false, before); } var remaining = quantity; for (var i = 0; i < slots.Length && remaining > 0; i++) { var slot = slots[i]; if (slot.IsEmpty || !string.Equals(slot.ItemId, def.Id, StringComparison.Ordinal)) { continue; } var take = Math.Min(remaining, slot.Quantity); var left = slot.Quantity - take; slots[i] = left <= 0 ? new InventorySlotState(slot.SlotIndex, null, 0) : new InventorySlotState(slot.SlotIndex, def.Id, left); remaining -= take; } return new PlayerInventoryMutationWrite(Write: true, before.WithSlots(container, slots)); }, out var result)) { return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null); } if (denyReason is not null) { // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` --- // TODO(E9.M1): catalog emit — reasonCode (e.g. insufficient_quantity). Planned payload: playerId, itemId, quantity, // reasonCode, mutationKind=remove; optional source when Slice 2+ callers exist. No ingest or ILogger here (comments-only). return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result); } return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result); } private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode) { // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` --- // TODO(E9.M1): catalog emit — reasonCode from PlayerInventoryReasonCodes (invalid_quantity, invalid_item, etc.). // Planned payload: playerId, reasonCode; itemId, quantity, mutationKind (add|remove) at emit call sites (not on Deny signature today). // Optional source when Slice 2+ callers exist. No ingest or ILogger here (comments-only). if (store.TryGetSnapshot(playerId, out var snapshot)) { return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, snapshot); } return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, null); } private static int MergeIntoExistingStacks(InventorySlotState[] slots, string itemId, int remaining, int stackMax) { for (var i = 0; i < slots.Length && remaining > 0; i++) { var slot = slots[i]; if (slot.IsEmpty || !string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) { continue; } var space = stackMax - slot.Quantity; if (space <= 0) { continue; } var add = Math.Min(space, remaining); slots[i] = new InventorySlotState(slot.SlotIndex, itemId, slot.Quantity + add); remaining -= add; } return remaining; } private static int FillEmptySlots(InventorySlotState[] slots, string itemId, int remaining, int stackMax) { for (var i = 0; i < slots.Length && remaining > 0; i++) { if (!slots[i].IsEmpty) { continue; } var add = Math.Min(stackMax, remaining); slots[i] = new InventorySlotState(i, itemId, add); remaining -= add; } return remaining; } private static int CountQuantity(InventorySlotState[] slots, string itemId) { var total = 0; foreach (var slot in slots) { if (!slot.IsEmpty && string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) { total += slot.Quantity; } } return total; } }