Compare commits

..

No commits in common. "126d979939ea13a33af2a4d2046b775182f72eba" and "ac3900c1948144d5052461a9b7f07b80ecb1b79e" have entirely different histories.

7 changed files with 91 additions and 214 deletions

View File

@ -271,28 +271,6 @@ public sealed class PlayerInventoryOperationsTests
Assert.Null(outcome.Snapshot); 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) private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
{ {
var total = 0; var total = 0;

View File

@ -8,13 +8,4 @@ public interface IPlayerInventoryStore
/// <summary>Atomically replaces the full snapshot for a known player; false when the player is not in the store.</summary> /// <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); 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) public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
{ {
var key = NormalizePlayerId(playerId); var key = NormalizePlayerId(playerId);
if (key.Length == 0) if (key.Length == 0 || !byPlayer.TryGetValue(key, out var existing))
{ {
snapshot = PlayerInventorySnapshot.Empty(); snapshot = PlayerInventorySnapshot.Empty();
return false; return false;
@ -31,13 +31,7 @@ public sealed class InMemoryPlayerInventoryStore(IOptions<GamePositionOptions> o
lock (playerLocks.GetOrAdd(key, _ => new object())) lock (playerLocks.GetOrAdd(key, _ => new object()))
{ {
if (!byPlayer.TryGetValue(key, out var current)) snapshot = Clone(existing);
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
snapshot = Clone(current);
return true; return true;
} }
} }
@ -46,62 +40,37 @@ public sealed class InMemoryPlayerInventoryStore(IOptions<GamePositionOptions> o
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)
{ {
var key = NormalizePlayerId(playerId); var key = NormalizePlayerId(playerId);
if (key.Length == 0) if (key.Length == 0 || !byPlayer.ContainsKey(key))
{ {
return false; return false;
} }
lock (playerLocks.GetOrAdd(key, _ => new object())) lock (playerLocks.GetOrAdd(key, _ => new object()))
{ {
if (!byPlayer.ContainsKey(key))
{
return false;
}
byPlayer[key] = Clone(snapshot); byPlayer[key] = Clone(snapshot);
return true; 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) => private static PlayerInventorySnapshot Clone(PlayerInventorySnapshot source) =>
new() new()
{ {
BagSlots = PlayerInventorySnapshot.CloneSlots(source.BagSlots), BagSlots = CloneSlots(source.BagSlots),
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(source.EquipmentSlots), EquipmentSlots = 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) private static string NormalizePlayerId(string? playerId)
{ {
var t = playerId?.Trim(); var t = playerId?.Trim();

View File

@ -1,5 +0,0 @@
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,36 +25,32 @@ public static class PlayerInventoryOperations
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem); return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem);
} }
string? denyReason = null; if (!store.TryGetSnapshot(playerId, out var before))
if (!store.TryMutateSnapshot(
playerId,
before =>
{ {
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = PlayerInventorySnapshot.CloneSlots(before.GetSlots(container)); var slots = CloneSlots(before.GetSlots(container));
var remaining = quantity; var remaining = quantity;
remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax); remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax);
remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax); remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax);
if (remaining > 0) if (remaining > 0)
{ {
denyReason = PlayerInventoryReasonCodes.InventoryFull; return new PlayerInventoryMutationOutcome(
return new PlayerInventoryMutationWrite(Write: false, before); PlayerInventoryMutationKind.Denied,
PlayerInventoryReasonCodes.InventoryFull,
before);
} }
return new PlayerInventoryMutationWrite(Write: true, before.WithSlots(container, slots)); var after = before.WithSlots(container, slots);
}, if (!store.TryReplaceSnapshot(playerId, after))
out var result))
{ {
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null); return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
} }
if (denyReason is not null) return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, after);
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
} }
/// <summary>Removes <paramref name="quantity"/> from stacks of <paramref name="itemId"/> (lowest slot index first).</summary> /// <summary>Removes <paramref name="quantity"/> from stacks of <paramref name="itemId"/> (lowest slot index first).</summary>
@ -76,18 +72,20 @@ public static class PlayerInventoryOperations
return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem); return Deny(playerId, store, PlayerInventoryReasonCodes.InvalidItem);
} }
string? denyReason = null; if (!store.TryGetSnapshot(playerId, out var before))
if (!store.TryMutateSnapshot(
playerId,
before =>
{ {
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
}
var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind);
var slots = PlayerInventorySnapshot.CloneSlots(before.GetSlots(container)); var slots = CloneSlots(before.GetSlots(container));
var available = CountQuantity(slots, def.Id); var available = CountQuantity(slots, def.Id);
if (available < quantity) if (available < quantity)
{ {
denyReason = PlayerInventoryReasonCodes.InsufficientQuantity; return new PlayerInventoryMutationOutcome(
return new PlayerInventoryMutationWrite(Write: false, before); PlayerInventoryMutationKind.Denied,
PlayerInventoryReasonCodes.InsufficientQuantity,
before);
} }
var remaining = quantity; var remaining = quantity;
@ -107,29 +105,23 @@ public static class PlayerInventoryOperations
remaining -= take; remaining -= take;
} }
return new PlayerInventoryMutationWrite(Write: true, before.WithSlots(container, slots)); var after = before.WithSlots(container, slots);
}, if (!store.TryReplaceSnapshot(playerId, after))
out var result))
{ {
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null); return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
} }
if (denyReason is not null) return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, after);
{
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, denyReason, result);
}
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
} }
private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode) 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.Denied, reasonCode, snapshot); return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.StoreMissing, null, null);
} }
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, null); return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Denied, reasonCode, snapshot);
} }
private static int MergeIntoExistingStacks(InventorySlotState[] slots, string itemId, int remaining, int stackMax) private static int MergeIntoExistingStacks(InventorySlotState[] slots, string itemId, int remaining, int stackMax)
@ -186,4 +178,16 @@ public static class PlayerInventoryOperations
return total; 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,18 +29,6 @@ public sealed class PlayerInventorySnapshot
return slots; 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) => public InventorySlotState[] GetSlots(InventoryContainerKind container) =>
container == InventoryContainerKind.Equipment ? EquipmentSlots : BagSlots; container == InventoryContainerKind.Equipment ? EquipmentSlots : BagSlots;

View File

@ -43,63 +43,19 @@ public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSou
return false; 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( using (var del = new Npgsql.NpgsqlCommand(
"DELETE FROM player_inventory WHERE player_id = @pid;", "DELETE FROM player_inventory WHERE player_id = @pid;",
conn, conn,
tx)) tx))
{ {
del.Parameters.AddWithValue("pid", playerIdNormalized); del.Parameters.AddWithValue("pid", norm);
del.ExecuteNonQuery(); del.ExecuteNonQuery();
} }
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.BagSlots, InventoryContainerKind.Bag, tx); WriteOccupiedSlots(conn, norm, snapshot.BagSlots, InventoryContainerKind.Bag, tx);
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx); WriteOccupiedSlots(conn, norm, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx);
tx.Commit();
return true;
} }
private static void WriteOccupiedSlots( private static void WriteOccupiedSlots(
@ -133,10 +89,7 @@ public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSou
} }
} }
private static PlayerInventorySnapshot ReadSnapshot( private static PlayerInventorySnapshot ReadSnapshot(Npgsql.NpgsqlConnection conn, string playerIdNormalized)
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
Npgsql.NpgsqlTransaction? tx = null)
{ {
var bag = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.BagSlotCount); var bag = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.BagSlotCount);
var equipment = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.EquipmentSlotCount); var equipment = PlayerInventorySnapshot.CreateEmptySlots(PlayerInventorySnapshot.EquipmentSlotCount);
@ -148,8 +101,7 @@ public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSou
WHERE player_id = @pid WHERE player_id = @pid
ORDER BY container_kind, slot_index; ORDER BY container_kind, slot_index;
""", """,
conn, conn);
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized); cmd.Parameters.AddWithValue("pid", playerIdNormalized);
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
while (reader.Read()) while (reader.Read())