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);
}
///
/// Returns held quantity for in the catalog-routed container (read-only, NEO-69 craft pre-flight).
///
public static int GetHeldQuantity(
PlayerInventorySnapshot snapshot,
string itemId,
IItemDefinitionRegistry registry)
{
var lookup = itemId.Trim();
if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def))
{
return 0;
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
return CountQuantity(snapshot.GetSlots(container), def.Id);
}
///
/// Simulates an all-or-nothing add on a snapshot clone without persisting (NEO-69 craft output pre-flight).
///
public static bool TrySimulateAddStack(
ref PlayerInventorySnapshot snapshot,
string itemId,
int quantity,
IItemDefinitionRegistry registry)
{
if (quantity <= 0)
{
return false;
}
var lookup = itemId.Trim();
if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def))
{
return false;
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = PlayerInventorySnapshot.CloneSlots(snapshot.GetSlots(container));
var remaining = quantity;
remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax);
remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax);
if (remaining > 0)
{
return false;
}
snapshot = snapshot.WithSlots(container, slots);
return true;
}
///
/// Simulates removing from a snapshot clone without persisting (NEO-69 craft pre-flight).
///
public static bool TrySimulateRemoveStack(
ref PlayerInventorySnapshot snapshot,
string itemId,
int quantity,
IItemDefinitionRegistry registry)
{
if (quantity <= 0)
{
return false;
}
var lookup = itemId.Trim();
if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def))
{
return false;
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = PlayerInventorySnapshot.CloneSlots(snapshot.GetSlots(container));
if (CountQuantity(slots, def.Id) < quantity)
{
return false;
}
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;
}
snapshot = snapshot.WithSlots(container, slots);
return true;
}
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;
}
}