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; } using (var del = new Npgsql.NpgsqlCommand( "DELETE FROM player_inventory WHERE player_id = @pid;", conn, tx)) { del.Parameters.AddWithValue("pid", norm); del.ExecuteNonQuery(); } WriteOccupiedSlots(conn, norm, snapshot.BagSlots, InventoryContainerKind.Bag, tx); WriteOccupiedSlots(conn, norm, snapshot.EquipmentSlots, InventoryContainerKind.Equipment, tx); tx.Commit(); return true; } 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) { 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); 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(); } }