477 lines
15 KiB
C#
477 lines
15 KiB
C#
using NeonSprawl.Server.Game.Contracts;
|
|
using NeonSprawl.Server.Game.PositionState;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Contracts;
|
|
|
|
public sealed class InMemoryContractInstanceStoreTests
|
|
{
|
|
private const string PlayerId = "dev-local-1";
|
|
private const string UnknownPlayerId = "unknown-player-xyz";
|
|
private const string InstanceId = "prototype_contract_instance_001";
|
|
private const string SecondInstanceId = "prototype_contract_instance_002";
|
|
private const string TemplateId = "prototype_contract_clear_combat_pocket";
|
|
private const string SeedBucket = "2026-06-22";
|
|
private static readonly DateTimeOffset IssuedAt = new(2026, 6, 22, 10, 0, 0, TimeSpan.Zero);
|
|
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public void TryGet_ShouldReturnFalse_WhenRowMissing()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var found = store.TryGet(PlayerId, InstanceId, out _);
|
|
// Assert
|
|
Assert.False(found);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCreateActive_ShouldPersistAllFields()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var created = store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out var snapshot);
|
|
// Assert
|
|
Assert.True(created);
|
|
Assert.Equal(InstanceId, snapshot.ContractInstanceId);
|
|
Assert.Equal(TemplateId, snapshot.TemplateId);
|
|
Assert.Equal(PlayerId, snapshot.PlayerId);
|
|
Assert.Equal(ContractInstanceStatus.Active, snapshot.Status);
|
|
Assert.Equal(SeedBucket, snapshot.SeedBucket);
|
|
Assert.Equal(IssuedAt, snapshot.IssuedAt);
|
|
Assert.Null(snapshot.CompletedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCreateActive_ShouldDenyDuplicateInstanceId()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out var firstSnapshot));
|
|
// Act
|
|
var duplicate = store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddHours(1),
|
|
out var secondSnapshot);
|
|
// Assert
|
|
Assert.False(duplicate);
|
|
Assert.Equal(firstSnapshot.ContractInstanceId, secondSnapshot.ContractInstanceId);
|
|
Assert.Equal(firstSnapshot.IssuedAt, secondSnapshot.IssuedAt);
|
|
Assert.Equal(ContractInstanceStatus.Active, secondSnapshot.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCreateActive_ShouldDenySecondActiveForSamePlayer()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
// Act
|
|
var second = store.TryCreateActive(
|
|
PlayerId,
|
|
SecondInstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddHours(1),
|
|
out var snapshot);
|
|
// Assert
|
|
Assert.False(second);
|
|
Assert.Equal(InstanceId, snapshot.ContractInstanceId);
|
|
Assert.Equal(ContractInstanceStatus.Active, snapshot.Status);
|
|
Assert.True(store.TryGetActiveForPlayer(PlayerId, out var active));
|
|
Assert.Equal(InstanceId, active.ContractInstanceId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryMarkComplete_ShouldSetCompletedStatusAndTimestamp()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
// Act
|
|
var completed = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out var snapshot);
|
|
// Assert
|
|
Assert.True(completed);
|
|
Assert.Equal(ContractInstanceStatus.Completed, snapshot.Status);
|
|
Assert.Equal(CompletedAt, snapshot.CompletedAt);
|
|
Assert.False(store.TryGetActiveForPlayer(PlayerId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryMarkComplete_ShouldBeIdempotent()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
// Act
|
|
var first = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out var firstSnapshot);
|
|
var second = store.TryMarkComplete(
|
|
PlayerId,
|
|
InstanceId,
|
|
CompletedAt.AddHours(1),
|
|
out var secondSnapshot);
|
|
// Assert
|
|
Assert.True(first);
|
|
Assert.False(second);
|
|
Assert.Equal(ContractInstanceStatus.Completed, secondSnapshot.Status);
|
|
Assert.Equal(CompletedAt, secondSnapshot.CompletedAt);
|
|
Assert.Equal(firstSnapshot.TemplateId, secondSnapshot.TemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCreateActive_ShouldAllowNewInstanceAfterComplete()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _));
|
|
// Act
|
|
var reissued = store.TryCreateActive(
|
|
PlayerId,
|
|
SecondInstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddDays(1),
|
|
out var snapshot);
|
|
// Assert
|
|
Assert.True(reissued);
|
|
Assert.Equal(SecondInstanceId, snapshot.ContractInstanceId);
|
|
Assert.Equal(ContractInstanceStatus.Active, snapshot.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCreateActive_ShouldReturnFalse_ForUnknownPlayer()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var created = store.TryCreateActive(
|
|
UnknownPlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _);
|
|
// Assert
|
|
Assert.False(created);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("", InstanceId, TemplateId, SeedBucket)]
|
|
[InlineData(" ", InstanceId, TemplateId, SeedBucket)]
|
|
[InlineData(PlayerId, "", TemplateId, SeedBucket)]
|
|
[InlineData(PlayerId, " ", TemplateId, SeedBucket)]
|
|
[InlineData(PlayerId, InstanceId, "", SeedBucket)]
|
|
[InlineData(PlayerId, InstanceId, TemplateId, "")]
|
|
public void TryCreateActive_ShouldReturnFalse_WhenRequiredIdsEmpty(
|
|
string playerId,
|
|
string instanceId,
|
|
string templateId,
|
|
string seedBucket)
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var created = store.TryCreateActive(
|
|
playerId,
|
|
instanceId,
|
|
templateId,
|
|
seedBucket,
|
|
IssuedAt,
|
|
out _);
|
|
// Assert
|
|
Assert.False(created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryCreateActiveAndTryMarkComplete_ShouldKeepActiveIndexConsistent_WhenRacingOnSameInstanceId()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
await Task.WhenAll(
|
|
Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
|
{
|
|
if (i % 2 == 0)
|
|
{
|
|
_ = store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _);
|
|
return;
|
|
}
|
|
|
|
_ = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _);
|
|
})));
|
|
|
|
// Assert
|
|
if (store.TryGetActiveForPlayer(PlayerId, out var active))
|
|
{
|
|
Assert.True(store.TryGet(PlayerId, active.ContractInstanceId, out var row));
|
|
Assert.Equal(ContractInstanceStatus.Active, row.Status);
|
|
}
|
|
|
|
if (store.TryGet(PlayerId, InstanceId, out var final) &&
|
|
final.Status == ContractInstanceStatus.Completed)
|
|
{
|
|
Assert.False(store.TryGetActiveForPlayer(PlayerId, out _));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGetActiveForPlayer_ShouldKeepActiveIndexConsistent_WhenRacingWithCompleteAndReissue()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
// Act
|
|
await Task.WhenAll(
|
|
Enumerable.Range(0, 32).Select(i => Task.Run(() =>
|
|
{
|
|
if (i % 3 == 0)
|
|
{
|
|
_ = store.TryGetActiveForPlayer(PlayerId, out _);
|
|
return;
|
|
}
|
|
|
|
if (i % 3 == 1)
|
|
{
|
|
_ = store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _);
|
|
return;
|
|
}
|
|
|
|
_ = store.TryCreateActive(
|
|
PlayerId,
|
|
SecondInstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddDays(1),
|
|
out _);
|
|
})));
|
|
|
|
// Assert
|
|
if (store.TryGetActiveForPlayer(PlayerId, out var active))
|
|
{
|
|
Assert.True(store.TryGet(PlayerId, active.ContractInstanceId, out var row));
|
|
Assert.Equal(ContractInstanceStatus.Active, row.Status);
|
|
}
|
|
|
|
Assert.False(
|
|
store.TryGet(PlayerId, InstanceId, out var first) &&
|
|
store.TryGet(PlayerId, SecondInstanceId, out var second) &&
|
|
first.Status == ContractInstanceStatus.Active &&
|
|
second.Status == ContractInstanceStatus.Active);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListForPlayer_ShouldReturnEmpty_WhenPlayerHasNoInstances()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10);
|
|
// Assert
|
|
Assert.Empty(rows);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListForPlayer_ShouldReturnEmpty_ForUnknownPlayer()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var rows = store.ListForPlayer(UnknownPlayerId, maxCompletedRows: 10);
|
|
// Assert
|
|
Assert.Empty(rows);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListForPlayer_ShouldReturnActiveOnly_WhenNoCompletedRows()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
// Act
|
|
var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10);
|
|
// Assert
|
|
var row = Assert.Single(rows);
|
|
Assert.Equal(InstanceId, row.ContractInstanceId);
|
|
Assert.Equal(ContractInstanceStatus.Active, row.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListForPlayer_ShouldReturnCompletedOrderedByIssuedAt_WhenNoActive()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out _));
|
|
Assert.True(store.TryMarkComplete(PlayerId, InstanceId, CompletedAt, out _));
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
SecondInstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddDays(1),
|
|
out _));
|
|
Assert.True(store.TryMarkComplete(
|
|
PlayerId,
|
|
SecondInstanceId,
|
|
CompletedAt.AddDays(1),
|
|
out _));
|
|
// Act
|
|
var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10);
|
|
// Assert
|
|
Assert.Equal(2, rows.Count);
|
|
Assert.Equal(SecondInstanceId, rows[0].ContractInstanceId);
|
|
Assert.Equal(InstanceId, rows[1].ContractInstanceId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListForPlayer_ShouldPrependActiveAndCapCompletedRows()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
var instanceId = $"prototype_contract_instance_done_{i:D2}";
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
instanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddHours(i),
|
|
out _));
|
|
Assert.True(store.TryMarkComplete(
|
|
PlayerId,
|
|
instanceId,
|
|
CompletedAt.AddHours(i),
|
|
out _));
|
|
}
|
|
|
|
const string activeId = "prototype_contract_instance_active";
|
|
Assert.True(store.TryCreateActive(
|
|
PlayerId,
|
|
activeId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt.AddDays(3),
|
|
out _));
|
|
|
|
// Act
|
|
var rows = store.ListForPlayer(PlayerId, maxCompletedRows: 10);
|
|
// Assert
|
|
Assert.Equal(11, rows.Count);
|
|
Assert.Equal(activeId, rows[0].ContractInstanceId);
|
|
Assert.Equal(ContractInstanceStatus.Active, rows[0].Status);
|
|
Assert.All(rows.Skip(1), row => Assert.Equal(ContractInstanceStatus.Completed, row.Status));
|
|
Assert.DoesNotContain(
|
|
rows.Skip(1),
|
|
row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_done_00", StringComparison.Ordinal));
|
|
Assert.DoesNotContain(
|
|
rows.Skip(1),
|
|
row => string.Equals(row.ContractInstanceId, "prototype_contract_instance_done_01", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryClearInstance_ShouldReturnTrue_WhenRowMissing()
|
|
{
|
|
// Arrange
|
|
var store = CreateStore();
|
|
// Act
|
|
var cleared = store.TryClearInstance(PlayerId, InstanceId);
|
|
// Assert
|
|
Assert.True(cleared);
|
|
Assert.False(store.TryGet(PlayerId, InstanceId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Host_ShouldResolveContractInstanceStoresFromDi()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
using var scope = factory.Services.CreateScope();
|
|
var instanceStore = scope.ServiceProvider.GetRequiredService<IContractInstanceStore>();
|
|
var outcomeStore = scope.ServiceProvider.GetRequiredService<IContractOutcomeStore>();
|
|
// Act
|
|
var created = instanceStore.TryCreateActive(
|
|
PlayerId,
|
|
InstanceId,
|
|
TemplateId,
|
|
SeedBucket,
|
|
IssuedAt,
|
|
out var snapshot);
|
|
// Assert
|
|
Assert.IsType<InMemoryContractInstanceStore>(instanceStore);
|
|
Assert.IsType<InMemoryContractOutcomeStore>(outcomeStore);
|
|
Assert.True(created);
|
|
Assert.Equal(TemplateId, snapshot.TemplateId);
|
|
}
|
|
|
|
private static InMemoryContractInstanceStore CreateStore()
|
|
{
|
|
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
|
|
return new InMemoryContractInstanceStore(options);
|
|
}
|
|
}
|