308 lines
12 KiB
C#
308 lines
12 KiB
C#
namespace NeonSprawl.Server.Game.Items;
|
|
|
|
/// <summary>Server-authoritative inventory add/remove rules (NEO-54). NEO-56 telemetry hook sites: <see cref="TryAddStack"/>, <see cref="TryRemoveStack"/>, and <see cref="Deny"/>.</summary>
|
|
public static class PlayerInventoryOperations
|
|
{
|
|
/// <summary>
|
|
/// Adds <paramref name="quantity"/> of <paramref name="itemId"/> into the catalog-routed container.
|
|
/// All-or-nothing: denies with <see cref="PlayerInventoryReasonCodes.InventoryFull"/> when the full amount cannot be placed.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Removes <paramref name="quantity"/> from stacks of <paramref name="itemId"/> (lowest slot index first).</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns held quantity for <paramref name="itemId"/> in the catalog-routed container (read-only, NEO-69 craft pre-flight).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulates an all-or-nothing add on a snapshot clone without persisting (NEO-69 craft output pre-flight).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulates removing <paramref name="quantity"/> from a snapshot clone without persisting (NEO-69 craft pre-flight).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|