namespace NeonSprawl.Server.Game.Items;
/// PostgreSQL-backed inventory keyed by normalized player id (NEO-54).
public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerInventoryStore
{
///
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
PostgresPlayerInventoryBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, norm))
{
snapshot = PlayerInventorySnapshot.Empty();
return false;
}
snapshot = ReadSnapshot(conn, norm);
return true;
}
///
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return false;
}
PostgresPlayerInventoryBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
return false;
}
ReplaceSnapshotRows(conn, norm, snapshot, tx);
tx.Commit();
return true;
}
///
public bool TryMutateSnapshot(
string playerId,
Func 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", playerIdNormalized);
del.ExecuteNonQuery();
}
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.BagSlots, InventoryContainerKind.Bag, tx);
WriteOccupiedSlots(conn, playerIdNormalized, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx);
}
private static void WriteOccupiedSlots(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
InventorySlotState[] slots,
InventoryContainerKind container,
Npgsql.NpgsqlTransaction tx)
{
var kind = PlayerInventorySnapshot.ContainerKindToPersistence(container);
foreach (var slot in slots)
{
if (slot.IsEmpty)
{
continue;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_inventory (player_id, container_kind, slot_index, item_id, quantity, updated_at)
VALUES (@pid, @kind, @slot, @item, @qty, now());
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
cmd.Parameters.AddWithValue("kind", kind);
cmd.Parameters.AddWithValue("slot", slot.SlotIndex);
cmd.Parameters.AddWithValue("item", slot.ItemId!);
cmd.Parameters.AddWithValue("qty", slot.Quantity);
cmd.ExecuteNonQuery();
}
}
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);
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT container_kind, slot_index, item_id, quantity
FROM player_inventory
WHERE player_id = @pid
ORDER BY container_kind, slot_index;
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var container = PlayerInventorySnapshot.ContainerKindFromPersistence(reader.GetString(0));
var index = reader.GetInt32(1);
var itemId = reader.GetString(2);
var quantity = reader.GetInt32(3);
var target = container == InventoryContainerKind.Equipment ? equipment : bag;
if (index >= 0 && index < target.Length)
{
target[index] = new InventorySlotState(index, itemId, quantity);
}
}
return new PlayerInventorySnapshot { BagSlots = bag, EquipmentSlots = equipment };
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
return cmd.ExecuteScalar() is not null;
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}