Compare commits

...

3 Commits

Author SHA1 Message Date
VinPropane 126d979939 NEO-54: dedupe slot cloning on PlayerInventorySnapshot 2026-05-23 19:54:27 -04:00
VinPropane 3f664261b7 NEO-54: preserve validation reason codes when player store missing
Deny helper returns Denied + reasonCode even without a snapshot bucket,
so invalid_quantity/invalid_item are stable before NEO-55 HTTP mapping.
2026-05-23 19:44:07 -04:00
VinPropane dafe3cb92f NEO-54: fix in-memory inventory races with atomic TryMutateSnapshot
Re-read snapshot under per-player lock; route add/remove through
TryMutateSnapshot so read-modify-write is serialized for in-memory
and wrapped in one transaction for Postgres.
2026-05-23 19:42:50 -04:00
7 changed files with 214 additions and 91 deletions

View File

@ -271,6 +271,28 @@ public sealed class PlayerInventoryOperationsTests
Assert.Null(outcome.Snapshot);
}
[Fact]
public void TryAddStack_ForUnknownPlayer_WithInvalidQuantity_ShouldDenyWithReasonCode()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var store = factory.Services.GetRequiredService<IPlayerInventoryStore>();
// Act
var outcome = PlayerInventoryOperations.TryAddStack(
"unknown-player-xyz",
"scrap_metal_bulk",
quantity: 0,
registry,
store);
// Assert
Assert.Equal(PlayerInventoryMutationKind.Denied, outcome.Kind);
Assert.Equal(PlayerInventoryReasonCodes.InvalidQuantity, outcome.ReasonCode);
Assert.Null(outcome.Snapshot);
}
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
{
var total = 0;

View File

@ -8,4 +8,13 @@ public interface IPlayerInventoryStore
/// <summary>Atomically replaces the full snapshot for a known player; false when the player is not in the store.</summary>
bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot);
/// <summary>
/// Atomically reads the current snapshot and optionally persists a replacement under per-player lock (in-memory)
/// or within one database transaction (Postgres).
/// </summary>
bool TryMutateSnapshot(
string playerId,
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
out PlayerInventorySnapshot result);
}

View File

@ -23,7 +23,7 @@ public sealed class InMemoryPlayerInventoryStore(IOptions<GamePositionOptions> o
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var existing))
if (key.Length == 0)
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
@ -31,7 +31,13 @@ public sealed class InMemoryPlayerInventoryStore(IOptions<GamePositionOptions> o
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
snapshot = Clone(existing);
if (!byPlayer.TryGetValue(key, out var current))
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
snapshot = Clone(current);
return true;
}
}
@ -40,37 +46,62 @@ public sealed class InMemoryPlayerInventoryStore(IOptions<GamePositionOptions> o
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.ContainsKey(key))
if (key.Length == 0)
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
if (!byPlayer.ContainsKey(key))
{
return false;
}
byPlayer[key] = Clone(snapshot);
return true;
}
}
/// <inheritdoc />
public bool TryMutateSnapshot(
string playerId,
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
out PlayerInventorySnapshot result)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0)
{
result = PlayerInventorySnapshot.Empty();
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
if (!byPlayer.TryGetValue(key, out var current))
{
result = PlayerInventorySnapshot.Empty();
return false;
}
var write = mutator(Clone(current));
result = Clone(write.Value);
if (write.Write)
{
byPlayer[key] = Clone(write.Value);
}
return true;
}
}
private static PlayerInventorySnapshot Clone(PlayerInventorySnapshot source) =>
new()
{
BagSlots = CloneSlots(source.BagSlots),
EquipmentSlots = CloneSlots(source.EquipmentSlots),
BagSlots = PlayerInventorySnapshot.CloneSlots(source.BagSlots),
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(source.EquipmentSlots),
};
private static InventorySlotState[] CloneSlots(InventorySlotState[] slots)
{
var copy = new InventorySlotState[slots.Length];
for (var i = 0; i < slots.Length; i++)
{
var s = slots[i];
copy[i] = new InventorySlotState(s.SlotIndex, s.ItemId, s.Quantity);
}
return copy;
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();

View File

@ -0,0 +1,5 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Result of an atomic inventory transform (NEO-54).</summary>
/// <param name="Write"><c>true</c> to persist <see cref="Value"/>; <c>false</c> to leave storage unchanged.</param>
public readonly record struct PlayerInventoryMutationWrite(bool Write, PlayerInventorySnapshot Value);

View File

@ -25,32 +25,36 @@ public static class PlayerInventoryOperations
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem);
}
if (!store.TryGetSnapshot(playerId, out var before))
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);
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = 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)
if (denyReason is not null)
{
return new PlayerInventoryMutationOutcome(
PlayerInventoryMutationKind.Denied,
PlayerInventoryReasonCodes.InventoryFull,
before);
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result);
}
var after = before.WithSlots(container, slots);
if (!store.TryReplaceSnapshot(playerId, after))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, after);
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
}
/// <summary>Removes <paramref name="quantity"/> from stacks of <paramref name="itemId"/> (lowest slot index first).</summary>
@ -72,56 +76,60 @@ public static class PlayerInventoryOperations
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem);
}
if (!store.TryGetSnapshot(playerId, out var before))
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);
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = CloneSlots(before.GetSlots(container));
var available = CountQuantity(slots, def.Id);
if (available < quantity)
if (denyReason is not null)
{
return new PlayerInventoryMutationOutcome(
PlayerInventoryMutationKind.Denied,
PlayerInventoryReasonCodes.InsufficientQuantity,
before);
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result);
}
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;
}
var after = before.WithSlots(container, slots);
if (!store.TryReplaceSnapshot(playerId, after))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, after);
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
}
private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode)
{
if (!store.TryGetSnapshot(playerId, out var snapshot))
if (store.TryGetSnapshot(playerId, out var snapshot))
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, 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)
@ -178,16 +186,4 @@ public static class PlayerInventoryOperations
return total;
}
private static InventorySlotState[] CloneSlots(InventorySlotState[] slots)
{
var copy = new InventorySlotState[slots.Length];
for (var i = 0; i < slots.Length; i++)
{
var s = slots[i];
copy[i] = new InventorySlotState(s.SlotIndex, s.ItemId, s.Quantity);
}
return copy;
}
}

View File

@ -29,6 +29,18 @@ public sealed class PlayerInventorySnapshot
return slots;
}
public static InventorySlotState[] CloneSlots(InventorySlotState[] slots)
{
var copy = new InventorySlotState[slots.Length];
for (var i = 0; i < slots.Length; i++)
{
var s = slots[i];
copy[i] = new InventorySlotState(s.SlotIndex, s.ItemId, s.Quantity);
}
return copy;
}
public InventorySlotState[] GetSlots(InventoryContainerKind container) =>
container == InventoryContainerKind.Equipment ? EquipmentSlots : BagSlots;

View File

@ -43,19 +43,63 @@ public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSou
return false;
}
ReplaceSnapshotRows(conn, norm, snapshot, tx);
tx.Commit();
return true;
}
/// <inheritdoc />
public bool TryMutateSnapshot(
string playerId,
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
out PlayerInventorySnapshot result)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
result = PlayerInventorySnapshot.Empty();
return false;
}
PostgresPlayerInventoryBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
result = PlayerInventorySnapshot.Empty();
return false;
}
var current = ReadSnapshot(conn, norm, tx);
var write = mutator(current);
result = write.Value;
if (write.Write)
{
ReplaceSnapshotRows(conn, norm, write.Value, tx);
}
tx.Commit();
return true;
}
private static void ReplaceSnapshotRows(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
PlayerInventorySnapshot snapshot,
Npgsql.NpgsqlTransaction tx)
{
using (var del = new Npgsql.NpgsqlCommand(
"DELETE FROM player_inventory WHERE player_id = @pid;",
conn,
tx))
{
del.Parameters.AddWithValue("pid", norm);
del.Parameters.AddWithValue("pid", playerIdNormalized);
del.ExecuteNonQuery();
}
WriteOccupiedSlots(conn, norm, snapshot.BagSlots, InventoryContainerKind.Bag, tx);
WriteOccupiedSlots(conn, norm, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx);
tx.Commit();
return true;
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.BagSlots, InventoryContainerKind.Bag, tx);
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx);
}
private static void WriteOccupiedSlots(
@ -89,7 +133,10 @@ public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSou
}
}
private static PlayerInventorySnapshot ReadSnapshot(Npgsql.NpgsqlConnection conn, string playerIdNormalized)
private static PlayerInventorySnapshot ReadSnapshot(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
Npgsql.NpgsqlTransaction? tx = null)
{
var bag = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.BagSlotCount);
var equipment = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.EquipmentSlotCount);
@ -101,7 +148,8 @@ public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSou
WHERE player_id = @pid
ORDER BY container_kind, slot_index;
""",
conn);
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
using var reader = cmd.ExecuteReader();
while (reader.Read())