using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.PositionState;
/// PostgreSQL-backed (NS-17). Player ids are stored normalized: invariant lower + trim for case-insensitive parity with .
public sealed class PostgresPositionStateStore : IPositionStateStore
{
private readonly Npgsql.NpgsqlDataSource _dataSource;
private readonly GamePositionOptions _options;
public PostgresPositionStateStore(Npgsql.NpgsqlDataSource dataSource, IOptions options)
{
_dataSource = dataSource;
_options = options.Value;
var id = _options.DevPlayerId.Trim();
if (id.Length == 0)
{
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
}
}
public bool TryGetPosition(string playerId, out PositionSnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
snapshot = default;
return false;
}
PostgresPositionBootstrap.EnsureSchema(_dataSource);
using var conn = _dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"SELECT pos_x, pos_y, pos_z, sequence FROM player_position WHERE player_id = @pid;",
conn);
cmd.Parameters.AddWithValue("pid", norm);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
snapshot = default;
return false;
}
snapshot = ReadSnapshot(reader);
return true;
}
public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
snapshot = default;
return false;
}
PostgresPositionBootstrap.EnsureSchema(_dataSource);
using var conn = _dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
UPDATE player_position
SET pos_x = @x, pos_y = @y, pos_z = @z,
sequence = sequence + 1,
updated_at = now()
WHERE player_id = @pid
RETURNING pos_x, pos_y, pos_z, sequence;
""",
conn);
cmd.Parameters.AddWithValue("pid", norm);
cmd.Parameters.AddWithValue("x", x);
cmd.Parameters.AddWithValue("y", y);
cmd.Parameters.AddWithValue("z", z);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
snapshot = default;
return false;
}
snapshot = ReadSnapshot(reader);
return true;
}
private static PositionSnapshot ReadSnapshot(Npgsql.NpgsqlDataReader reader)
{
var seq = reader.GetInt64(3);
return new PositionSnapshot(
reader.GetDouble(0),
reader.GetDouble(1),
reader.GetDouble(2),
unchecked((ulong)seq));
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}