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( Npgsql.NpgsqlDataSource dataSource, IOptions positionOptions) : IPositionStateStore { private readonly GamePositionOptions gameOptions = RequireValidOptions(positionOptions.Value); private static GamePositionOptions RequireValidOptions(GamePositionOptions options) { if (options.DevPlayerId.Trim().Length == 0) { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); } return options; } 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(); } }