using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Rewards; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; namespace NeonSprawl.Server.Tests.Game.Contracts; public sealed class ContractCompletionOperationsTests { private const string PlayerId = "dev-local-1"; private const string TemplateId = "prototype_contract_clear_combat_pocket"; private const string EncounterId = "prototype_combat_pocket"; private const string MismatchEncounterId = "prototype_encounter_other"; private const string SeedBucket = "2026-06-22"; private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero); [Fact] public async Task TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var issued = IssuePrototypeContract(deps); var instanceId = issued.Snapshot!.ContractInstanceId; deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); // Act var result = CompleteOnEncounterClear(deps, EncounterId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.NotNull(result.Snapshot); Assert.Equal(ContractInstanceStatus.Completed, result.Snapshot!.Status); Assert.NotNull(result.Outcome); Assert.True( deps.DeliveryStore.TryGet( PlayerId, RewardDeliverySourceKinds.ContractCompletion, instanceId, out var delivery)); Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, delivery.SourceKind); Assert.Equal(instanceId, delivery.ContractInstanceId); Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId)); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal( CountBagItem(beforeInventory!, "scrap_metal_bulk") + 5, CountBagItem(afterInventory!, "scrap_metal_bulk")); var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId); Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp)); Assert.Equal(15, salvageXp); Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var stored)); Assert.Equal(ContractInstanceStatus.Completed, stored.Status); } [Fact] public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = CompleteOnEncounterClear(deps, EncounterId); // Assert Assert.True(result.Success); Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, result.ReasonCode); Assert.Null(result.Snapshot); Assert.Null(result.Outcome); } [Fact] public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var issued = IssuePrototypeContract(deps); var instanceId = issued.Snapshot!.ContractInstanceId; // Act var result = CompleteOnEncounterClear(deps, MismatchEncounterId); // Assert Assert.True(result.Success); Assert.Equal(ContractCompletionReasonCodes.EncounterMismatch, result.ReasonCode); Assert.NotNull(result.Snapshot); Assert.Equal(ContractInstanceStatus.Active, result.Snapshot!.Status); Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active)); Assert.Equal(instanceId, active.ContractInstanceId); Assert.False( deps.DeliveryStore.TryGet( PlayerId, RewardDeliverySourceKinds.ContractCompletion, instanceId, out _)); } [Fact] public async Task TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var issued = IssuePrototypeContract(deps); var instanceId = issued.Snapshot!.ContractInstanceId; FillBag(deps); // Act var result = CompleteOnEncounterClear(deps, EncounterId); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active)); Assert.Equal(instanceId, active.ContractInstanceId); Assert.Equal(ContractInstanceStatus.Active, active.Status); Assert.Empty(deps.OutcomeStore.GetOutcomesForInstance(instanceId)); } [Fact] public async Task TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var instanceId = IssueAndCompleteOnce(deps); // Act var replay = CompleteOnEncounterClear(deps, EncounterId); // Assert Assert.True(replay.Success); Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode); Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance)); Assert.Equal(ContractInstanceStatus.Completed, instance.Status); Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _)); Assert.True( deps.DeliveryStore.TryGet( PlayerId, RewardDeliverySourceKinds.ContractCompletion, instanceId, out _)); Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId)); } private static string IssueAndCompleteOnce(CompletionTestDependencies deps) { var issued = IssuePrototypeContract(deps); var instanceId = issued.Snapshot!.ContractInstanceId; var first = CompleteOnEncounterClear(deps, EncounterId); Assert.True(first.Success); Assert.Null(first.ReasonCode); Assert.Equal(ContractInstanceStatus.Completed, first.Snapshot!.Status); return instanceId; } private static ContractIssueOperationResult IssuePrototypeContract(CompletionTestDependencies deps) => ContractGeneratorOperations.TryIssue( PlayerId, TemplateId, SeedBucket, zoneDifficultyBand: null, deps.TemplateRegistry, deps.InstanceStore, deps.StandingStore, deps.TimeProvider); private static ContractCompletionOperationResult CompleteOnEncounterClear( CompletionTestDependencies deps, string encounterId) => ContractCompletionOperations.TryCompleteOnEncounterClear( PlayerId, encounterId, deps.InstanceStore, deps.OutcomeStore, deps.TemplateRegistry, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.AuditStore, deps.TimeProvider); private static void FillBag(CompletionTestDependencies deps) { for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++) { var add = PlayerInventoryOperations.TryAddStack( PlayerId, "survey_drone_kit", quantity: 1, deps.ItemRegistry, deps.InventoryStore); Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind); } } private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId) { var total = 0; foreach (var slot in snapshot.BagSlots) { if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal)) { total += slot.Quantity; } } return total; } private static CompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) { using var scope = factory.Services.CreateScope(); var services = scope.ServiceProvider; return new CompletionTestDependencies( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), new FakeTimeProvider(CompletedAt)); } private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider { public override DateTimeOffset GetUtcNow() => utcNow; } private sealed record CompletionTestDependencies( IContractTemplateRegistry TemplateRegistry, IContractInstanceStore InstanceStore, IContractOutcomeStore OutcomeStore, IItemDefinitionRegistry ItemRegistry, IPlayerInventoryStore InventoryStore, ISkillDefinitionRegistry SkillRegistry, IPlayerSkillProgressionStore SkillProgressionStore, ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore, TimeProvider TimeProvider); }