diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs index 2adc1ae..31e2d13 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PositionStateApiTests.cs @@ -9,7 +9,7 @@ namespace NeonSprawl.Server.Tests.Game.PositionState; public class PositionStateApiTests(InMemoryWebApplicationFactory factory) : IClassFixture { - private readonly HttpClient _client = factory.CreateClient(); + private readonly HttpClient httpClient = factory.CreateClient(); [Fact] public async Task GetPosition_ShouldReturnVersionedPayloadAtOrigin_WhenDevPlayerRequested() @@ -18,7 +18,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory) const string url = "/game/players/dev-local-1/position"; // Act - var response = await _client.GetAsync(url); + var response = await httpClient.GetAsync(url); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -39,7 +39,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory) const string url = "/game/players/DEV-LOCAL-1/position"; // Act - var response = await _client.GetAsync(url); + var response = await httpClient.GetAsync(url); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -57,7 +57,7 @@ public class PositionStateApiTests(InMemoryWebApplicationFactory factory) const string url = "/game/players/unknown-player/position"; // Act - var response = await _client.GetAsync(url); + var response = await httpClient.GetAsync(url); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs index e7bf450..2302247 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresPositionStateIntegrationTests.cs @@ -10,21 +10,14 @@ namespace NeonSprawl.Server.Tests.Game.PositionState; /// NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database. [Collection("Postgres integration")] -public sealed class PostgresPositionStateIntegrationTests +public sealed class PostgresPositionStateIntegrationTests(PostgresWebApplicationFactory factory) { - private readonly PostgresWebApplicationFactory _factory; - - public PostgresPositionStateIntegrationTests(PostgresWebApplicationFactory factory) - { - _factory = factory; - } - [RequirePostgresFact] public async Task PostMove_ThenGet_ShouldReflectPersistedPositionAndSequence() { // Arrange await ResetPlayerPositionTableAsync(); - using var client = _factory.CreateClient(); + using var client = factory.CreateClient(); var cmd = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, @@ -68,7 +61,7 @@ public sealed class PostgresPositionStateIntegrationTests PositionStateResponse? persisted = null; // Act - using (var first = _factory.CreateClient()) + using (var first = factory.CreateClient()) { var post = await first.PostAsJsonAsync("/game/players/dev-local-1/move", cmd); postStatus = post.StatusCode; @@ -94,7 +87,7 @@ public sealed class PostgresPositionStateIntegrationTests { // Arrange await ResetPlayerPositionTableAsync(); - using var client = _factory.CreateClient(); + using var client = factory.CreateClient(); var cmd = new MoveCommandRequest { SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, @@ -117,8 +110,8 @@ public sealed class PostgresPositionStateIntegrationTests throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set."); } - _ = _factory.Services; - var options = _factory.Services.GetRequiredService>().Value; + _ = factory.Services; + var options = factory.Services.GetRequiredService>().Value; var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql"); if (!File.Exists(ddlPath)) diff --git a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs index ed436e6..62edcbf 100644 --- a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs +++ b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs @@ -4,21 +4,22 @@ using Microsoft.Extensions.Options; namespace NeonSprawl.Server.Game.PositionState; /// Thread-safe in-memory positions; seeds the configured dev player on construction. -public sealed class InMemoryPositionStateStore : IPositionStateStore +public sealed class InMemoryPositionStateStore(IOptions options) : IPositionStateStore { - private readonly ConcurrentDictionary _positions = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary positions = CreateInitialMap(options.Value); - public InMemoryPositionStateStore(IOptions options) + private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) { - var o = options.Value; var id = o.DevPlayerId.Trim(); if (id.Length == 0) { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); } + var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var p = o.DefaultPosition ?? new DefaultPositionOptions(); - _positions[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); + map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); + return map; } public bool TryGetPosition(string playerId, out PositionSnapshot snapshot) @@ -26,11 +27,11 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore var key = playerId?.Trim(); if (!string.IsNullOrEmpty(key)) { - return _positions.TryGetValue(key, out snapshot); + return positions.TryGetValue(key, out snapshot); } + snapshot = default; return false; - } public bool TryApplyMoveTarget(string playerId, double x, double y, double z, out PositionSnapshot snapshot) @@ -44,17 +45,18 @@ public sealed class InMemoryPositionStateStore : IPositionStateStore while (true) { - if (!_positions.TryGetValue(key, out var previous)) + if (!positions.TryGetValue(key, out var previous)) { snapshot = default; return false; } var next = new PositionSnapshot(x, y, z, previous.Sequence + 1); - if (!_positions.TryUpdate(key, next, previous)) + if (!positions.TryUpdate(key, next, previous)) { continue; } + snapshot = next; return true; } diff --git a/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs b/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs index 55217b2..e3fcc61 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresDevPlayerSeedHostedService.cs @@ -3,29 +3,26 @@ using Microsoft.Extensions.Options; namespace NeonSprawl.Server.Game.PositionState; /// Runs DDL and dev-player seed once when the host starts (PostgreSQL path only). -internal sealed class PostgresDevPlayerSeedHostedService : IHostedService +internal sealed class PostgresDevPlayerSeedHostedService( + Npgsql.NpgsqlDataSource dataSource, + IOptions positionOptions) : IHostedService { - private readonly Npgsql.NpgsqlDataSource _dataSource; - private readonly GamePositionOptions _options; + private readonly GamePositionOptions gameOptions = RequireDevPlayer(positionOptions.Value); - public PostgresDevPlayerSeedHostedService( - Npgsql.NpgsqlDataSource dataSource, - IOptions options) + private static GamePositionOptions RequireDevPlayer(GamePositionOptions options) { - _dataSource = dataSource; - var o = options.Value; - if (string.IsNullOrWhiteSpace(o.DevPlayerId)) + if (string.IsNullOrWhiteSpace(options.DevPlayerId)) { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); } - _options = o; + return options; } public Task StartAsync(CancellationToken cancellationToken) { - PostgresPositionBootstrap.EnsureSchema(_dataSource); - PostgresPositionBootstrap.SeedDevPlayer(_dataSource, _options); + PostgresPositionBootstrap.EnsureSchema(dataSource); + PostgresPositionBootstrap.SeedDevPlayer(dataSource, gameOptions); return Task.CompletedTask; } diff --git a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs index f48df4e..132fec5 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionStateStore.cs @@ -3,20 +3,20 @@ 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 +public sealed class PostgresPositionStateStore( + Npgsql.NpgsqlDataSource dataSource, + IOptions positionOptions) : IPositionStateStore { - private readonly Npgsql.NpgsqlDataSource _dataSource; - private readonly GamePositionOptions _options; + private readonly GamePositionOptions gameOptions = RequireValidOptions(positionOptions.Value); - public PostgresPositionStateStore(Npgsql.NpgsqlDataSource dataSource, IOptions options) + private static GamePositionOptions RequireValidOptions(GamePositionOptions options) { - _dataSource = dataSource; - _options = options.Value; - var id = _options.DevPlayerId.Trim(); - if (id.Length == 0) + 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) @@ -28,9 +28,9 @@ public sealed class PostgresPositionStateStore : IPositionStateStore return false; } - PostgresPositionBootstrap.EnsureSchema(_dataSource); + PostgresPositionBootstrap.EnsureSchema(dataSource); - using var conn = _dataSource.OpenConnection(); + 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); @@ -55,9 +55,9 @@ public sealed class PostgresPositionStateStore : IPositionStateStore return false; } - PostgresPositionBootstrap.EnsureSchema(_dataSource); + PostgresPositionBootstrap.EnsureSchema(dataSource); - using var conn = _dataSource.OpenConnection(); + using var conn = dataSource.OpenConnection(); using var cmd = new Npgsql.NpgsqlCommand( """ UPDATE player_position