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