192 lines
6.3 KiB
C#
192 lines
6.3 KiB
C#
namespace NeonSprawl.Server.Game.Items;
|
|
|
|
/// <summary>PostgreSQL-backed inventory keyed by normalized player id (NEO-54).</summary>
|
|
public sealed class PostgresPlayerInventoryStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerInventoryStore
|
|
{
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <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", 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();
|
|
}
|
|
}
|