style: primary constructors for position state types

Use C# primary constructors and camelCase private fields per csharp-style.
Refactor InMemoryPositionStateStore seeding into CreateInitialMap; align
Postgres integration tests and PositionStateApiTests with factory/httpClient
naming.
pull/15/head
VinPropane 2026-03-30 23:01:36 -04:00
parent e2d64a7a57
commit d20b3931af
5 changed files with 42 additions and 50 deletions

View File

@ -9,7 +9,7 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
public class PositionStateApiTests(InMemoryWebApplicationFactory factory)
: IClassFixture<InMemoryWebApplicationFactory>
{
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);

View File

@ -10,21 +10,14 @@ namespace NeonSprawl.Server.Tests.Game.PositionState;
/// <summary>NS-17: real PostgreSQL + HTTP; collection serializes tests that share the database.</summary>
[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<IOptions<GamePositionOptions>>().Value;
_ = factory.Services;
var options = factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var ddlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
if (!File.Exists(ddlPath))

View File

@ -4,21 +4,22 @@ using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Thread-safe in-memory positions; seeds the configured dev player on construction.</summary>
public sealed class InMemoryPositionStateStore : IPositionStateStore
public sealed class InMemoryPositionStateStore(IOptions<GamePositionOptions> options) : IPositionStateStore
{
private readonly ConcurrentDictionary<string, PositionSnapshot> _positions = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, PositionSnapshot> positions = CreateInitialMap(options.Value);
public InMemoryPositionStateStore(IOptions<GamePositionOptions> options)
private static ConcurrentDictionary<string, PositionSnapshot> 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<string, PositionSnapshot>(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;
}

View File

@ -3,29 +3,26 @@ using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Runs DDL and dev-player seed once when the host starts (PostgreSQL path only).</summary>
internal sealed class PostgresDevPlayerSeedHostedService : IHostedService
internal sealed class PostgresDevPlayerSeedHostedService(
Npgsql.NpgsqlDataSource dataSource,
IOptions<GamePositionOptions> positionOptions) : IHostedService
{
private readonly Npgsql.NpgsqlDataSource _dataSource;
private readonly GamePositionOptions _options;
private readonly GamePositionOptions gameOptions = RequireDevPlayer(positionOptions.Value);
public PostgresDevPlayerSeedHostedService(
Npgsql.NpgsqlDataSource dataSource,
IOptions<GamePositionOptions> 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;
}

View File

@ -3,20 +3,20 @@ using Microsoft.Extensions.Options;
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>PostgreSQL-backed <see cref="IPositionStateStore"/> (NS-17). Player ids are stored normalized: invariant lower + trim for case-insensitive parity with <see cref="InMemoryPositionStateStore"/>.</summary>
public sealed class PostgresPositionStateStore : IPositionStateStore
public sealed class PostgresPositionStateStore(
Npgsql.NpgsqlDataSource dataSource,
IOptions<GamePositionOptions> positionOptions) : IPositionStateStore
{
private readonly Npgsql.NpgsqlDataSource _dataSource;
private readonly GamePositionOptions _options;
private readonly GamePositionOptions gameOptions = RequireValidOptions(positionOptions.Value);
public PostgresPositionStateStore(Npgsql.NpgsqlDataSource dataSource, IOptions<GamePositionOptions> 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