using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Skills; 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, deps.EncounterRegistry, deps.ItemRegistry, deps.SkillRegistry, deps.FactionRegistry, 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); } [Fact] public void TryIssue_ShouldDenyEconomyCapExceeded_WhenItemQuantityOverBandCap() { // Arrange var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId]; var overCapTemplate = prototype with { CompletionRewardBundle = new QuestRewardBundleRow( [new RewardGrantRow("scrap_metal_bulk", 11)], prototype.CompletionRewardBundle.SkillXpGrants, prototype.CompletionRewardBundle.ReputationGrants), }; var deps = CreateDependencies(overCapTemplate); // Act var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); // Assert Assert.False(result.Success); Assert.Equal(ContractGeneratorReasonCodes.EconomyCapExceeded, result.ReasonCode); Assert.Null(result.Snapshot); Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); } [Fact] public void TryIssue_ShouldDenyInvalidRewardBundle_WhenItemIdUnknown() { // Arrange var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze[TemplateId]; var unknownItemTemplate = prototype with { CompletionRewardBundle = new QuestRewardBundleRow( [new RewardGrantRow("unknown_item_xyz", 1)], prototype.CompletionRewardBundle.SkillXpGrants, prototype.CompletionRewardBundle.ReputationGrants), }; var deps = CreateDependencies(unknownItemTemplate); // Act var result = TryIssue(deps, templateId: TemplateId, seedBucket: SeedBucket); // Assert Assert.False(result.Success); Assert.Equal(ContractGeneratorReasonCodes.InvalidRewardBundle, result.ReasonCode); Assert.Null(result.Snapshot); Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); } 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.EncounterRegistry, deps.ItemRegistry, deps.SkillRegistry, deps.FactionRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); private static GeneratorTestDependencies CreateDependencies(params ContractTemplateRow[] templates) { var rows = templates.Length == 0 ? new Dictionary(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(); var catalogRegistries = ContractPrototypeTestFixtures.CreateCatalogRegistries(); return new GeneratorTestDependencies( templateRegistry, catalogRegistries.EncounterRegistry, catalogRegistries.ItemRegistry, catalogRegistries.SkillRegistry, catalogRegistries.FactionRegistry, instanceStore, standingStore, new FakeTimeProvider(IssuedAt)); } private static InMemoryFactionStandingStore CreateStandingStore() { var byId = new Dictionary(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, IEncounterDefinitionRegistry EncounterRegistry, IItemDefinitionRegistry ItemRegistry, ISkillDefinitionRegistry SkillRegistry, IFactionDefinitionRegistry FactionRegistry, IContractInstanceStore InstanceStore, IFactionStandingStore StandingStore, TimeProvider TimeProvider); /// Simulates store create failure without populating the out snapshot (Bugbot regression). 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); } }