using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Tests; using Xunit; 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 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 var first = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket); var same = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, SeedBucket); var different = ContractInstanceIds.MakeDeterministicInstanceId(PlayerId, TemplateId, AlternateSeedBucket); // Act // 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_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() { var template = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId]; var rows = new Dictionary(StringComparer.Ordinal) { [template.Id] = template, }; 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 gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; var rustFactionId = "prototype_faction_rust_collective"; var byId = new Dictionary(StringComparer.Ordinal) { [gridFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[gridFactionId], [rustFactionId] = PrototypeE7M3FactionCatalogRules.ExpectedFactionFreeze[rustFactionId], }; 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); }