neon-sprawl/server/NeonSprawl.Server.Tests/Game/Contracts/ContractGeneratorOperations...

348 lines
14 KiB
C#

using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractGeneratorOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string UnknownPlayerId = "unknown-player-xyz";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string GridOperatorsFactionId = "prototype_faction_grid_operators";
private const string GatedTemplateId = "test_gated_contract_template";
private const string SeedBucket = "2026-06-22";
private const string AlternateSeedBucket = "2026-06-23";
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 TryIssue_ShouldCreateActiveInstance_WhenTemplateEligible()
{
// Arrange
var deps = CreateDependencies();
var expectedInstanceId = ContractInstanceIds.MakeDeterministicInstanceId(
PlayerId,
TemplateId,
SeedBucket);
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(expectedInstanceId, result.Snapshot!.ContractInstanceId);
Assert.Equal(TemplateId, result.Snapshot.TemplateId);
Assert.Equal(PlayerId, result.Snapshot.PlayerId);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot.Status);
Assert.Equal(SeedBucket, result.Snapshot.SeedBucket);
Assert.Equal(IssuedAt, result.Snapshot.IssuedAt);
Assert.Equal(EncounterId, result.EncounterTemplateId);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(expectedInstanceId, active.ContractInstanceId);
}
[Fact]
public void TryIssue_ShouldAutoSelectTemplate_WhenTemplateIdOmitted()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket);
// Assert
Assert.True(result.Success);
Assert.Equal(TemplateId, result.Snapshot!.TemplateId);
Assert.Equal(EncounterId, result.EncounterTemplateId);
}
[Fact]
public void MakeDeterministicInstanceId_ShouldMatchForSameInputs_AndDifferForDifferentSeedBucket()
{
// Arrange
// Act
var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket);
var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket);
// Assert
Assert.Equal(first, same);
Assert.NotEqual(first, different);
Assert.StartsWith("ci_", first, StringComparison.Ordinal);
}
[Fact]
public void TryIssue_ShouldDenyActiveContractExists_WhenPlayerAlreadyHasActiveInstance()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
// Assert
Assert.False(second.Success);
Assert.Equal(ContractGeneratorReasonCodes.ActiveContractExists, second.ReasonCode);
Assert.NotNull(second.Snapshot);
Assert.Equal(first.Snapshot!.ContractInstanceId, second.Snapshot!.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
Assert.Null(second.EncounterTemplateId);
}
[Fact]
public void TryIssue_ShouldAllowNewIssue_WhenPriorInstanceCompleted()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
Assert.True(deps.InstanceStore.TryMarkComplete(
PlayerId,
first.Snapshot!.ContractInstanceId,
CompletedAt,
out _));
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: AlternateSeedBucket);
// Assert
Assert.True(second.Success);
Assert.NotEqual(first.Snapshot.ContractInstanceId, second.Snapshot!.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, second.Snapshot.Status);
Assert.Equal(EncounterId, second.EncounterTemplateId);
}
[Fact]
public void TryIssue_ShouldDenyUnknownTemplate_WhenExplicitTemplateMissing()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: "not_a_real_template", seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.UnknownTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenBandMismatch()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket, zoneDifficultyBand: 99);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenAutoSelectBandHasNoTemplates()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: null, seedBucket: SeedBucket, zoneDifficultyBand: 99);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
}
[Fact]
public void TryIssue_ShouldDenyNoEligibleTemplate_WhenFactionStandingBelowMin()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId];
var gatedTemplate = prototype with
{
Id = GatedTemplateId,
MinFactionStanding = new FactionGateRuleRow(GridOperatorsFactionId, 1),
};
var deps = CreateDependencies(gatedTemplate);
// Act
var result = TryIssue(deps, templateId: GatedTemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.NoEligibleTemplate, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenReissuingSameSeedAfterComplete()
{
// Arrange
var deps = CreateDependencies();
var first = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
Assert.True(first.Success);
Assert.True(deps.InstanceStore.TryMarkComplete(
PlayerId,
first.Snapshot!.ContractInstanceId,
CompletedAt,
out _));
// Act
var second = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(second.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, second.ReasonCode);
Assert.NotNull(second.Snapshot);
Assert.Equal(ContractInstanceStatus.Completed, second.Snapshot!.Status);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenCreateActiveReturnsFalseWithoutSnapshot()
{
// Arrange
var deps = CreateDependencies();
var store = new CreateActiveNullSnapshotStore(deps.InstanceStore);
var templateRegistry = deps.TemplateRegistry;
var standingStore = deps.StandingStore;
// Act
var result = ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
templateRegistry,
store,
standingStore,
deps.TimeProvider);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyPlayerNotWritable_WhenPlayerUnknown()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, playerId: UnknownPlayerId, templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.PlayerNotWritable, result.ReasonCode);
Assert.Null(result.Snapshot);
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenPlayerIdEmpty()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, playerId: " ", templateId: TemplateId, seedBucket: SeedBucket);
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
}
[Fact]
public void TryIssue_ShouldDenyInvalidIds_WhenSeedBucketEmpty()
{
// Arrange
var deps = CreateDependencies();
// Act
var result = TryIssue(deps, templateId: TemplateId, seedBucket: " ");
// Assert
Assert.False(result.Success);
Assert.Equal(ContractGeneratorReasonCodes.InvalidIds, result.ReasonCode);
}
private static ContractIssueOperationResult TryIssue(
GeneratorTestDependencies deps,
string? playerId = PlayerId,
string? templateId = TemplateId,
string seedBucket = SeedBucket,
int? zoneDifficultyBand = null) =>
ContractGeneratorOperations.TryIssue(
playerId!,
templateId,
seedBucket,
zoneDifficultyBand,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static GeneratorTestDependencies CreateDependencies(params ContractTemplateRow[] templates)
{
var rows = templates.Length == 0
? new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[TemplateId] = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId],
}
: templates.ToDictionary(static t => t.Id, StringComparer.Ordinal);
var catalog = new ContractTemplateCatalog("/tmp/catalog", rows, catalogJsonFileCount: 1);
var templateRegistry = new ContractTemplateRegistry(catalog);
var positionOptions = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
var instanceStore = new InMemoryContractInstanceStore(positionOptions);
var standingStore = CreateStandingStore();
return new GeneratorTestDependencies(
templateRegistry,
instanceStore,
standingStore,
new FakeTimeProvider(IssuedAt));
}
private static InMemoryFactionStandingStore CreateStandingStore()
{
var byId = new Dictionary<string, FactionDefRow>(StringComparer.Ordinal)
{
[GridOperatorsFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[GridOperatorsFactionId],
};
var catalog = new FactionDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
var registry = new FactionDefinitionRegistry(catalog);
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });
return new InMemoryFactionStandingStore(options, registry);
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private readonly record struct GeneratorTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IFactionStandingStore StandingStore,
TimeProvider TimeProvider);
/// <summary>Simulates store create failure without populating the out snapshot (Bugbot regression).</summary>
private sealed class CreateActiveNullSnapshotStore(IContractInstanceStore inner) : IContractInstanceStore
{
public bool CanWritePlayer(string playerId) => inner.CanWritePlayer(playerId);
public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot) =>
inner.TryGet(playerId, contractInstanceId, out snapshot);
public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot) =>
inner.TryGetActiveForPlayer(playerId, out snapshot);
public bool TryCreateActive(
string playerId,
string contractInstanceId,
string templateId,
string seedBucket,
DateTimeOffset issuedAt,
out ContractInstanceState snapshot)
{
snapshot = null!;
return false;
}
public bool TryMarkComplete(
string playerId,
string contractInstanceId,
DateTimeOffset completedAt,
out ContractInstanceState snapshot) =>
inner.TryMarkComplete(playerId, contractInstanceId, completedAt, out snapshot);
public bool TryClearInstance(string playerId, string contractInstanceId) =>
inner.TryClearInstance(playerId, contractInstanceId);
}
}