using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Quests; using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Tests.Game.Quests; public sealed class QuestStateOperationsTests { private const string PlayerId = "dev-local-1"; private const string UnknownPlayerId = "unknown-player-xyz"; private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId; private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId; private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; private const string GridContractQuestId = PrototypeE7M1QuestCatalogRules.GridContractQuestId; private const string GridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId; [Fact] public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenGridContractBelowThreshold() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); CompleteChainPrerequisite(deps); // Act var result = TryAccept(deps, GridContractQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode); Assert.Null(result.Snapshot); Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, GridContractQuestId, out _)); } [Fact] public async Task TryAccept_ShouldAcceptGridContract_WhenStandingAtThreshold() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); CompleteChainPrerequisite(deps); SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding); // Act var result = TryAccept(deps, GridContractQuestId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.NotNull(result.Snapshot); Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); } [Fact] public async Task TryAccept_ShouldDenyFactionGateBlocked_WhenStandingOneBelowThreshold() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); CompleteChainPrerequisite(deps); SeedGridStanding(deps, PrototypeE7M3QuestFactionRules.GridContractMinStanding - 1); // Act var result = TryAccept(deps, GridContractQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.FactionGateBlocked, result.ReasonCode); } [Fact] public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAccept(deps, GatherQuestId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.NotNull(result.Snapshot); Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); Assert.Equal(0, result.Snapshot.CurrentStepIndex); } [Fact] public async Task TryAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAccept(deps, RefineQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, result.ReasonCode); Assert.Null(result.Snapshot); } [Fact] public async Task TryAccept_ShouldSucceedRefineIntro_WhenGatherIntroCompleted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryMarkComplete(deps, GatherQuestId).Success); // Act var result = TryAccept(deps, RefineQuestId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); } [Fact] public async Task TryAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAccept(deps, "prototype_quest_missing"); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode); } [Fact] public async Task TryAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); Assert.True(TryAccept(deps, GatherQuestId).Success); // Act var result = TryAccept(deps, GatherQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.AlreadyActive, result.ReasonCode); Assert.NotNull(result.Snapshot); Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); } [Fact] public async Task TryAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryMarkComplete(deps, GatherQuestId).Success); // Act var result = TryAccept(deps, GatherQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode); Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); } [Fact] public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPrerequisiteQuestNotWritable() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAccept(deps, UnknownPlayerId, RefineQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode); } [Fact] public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPlayerNotInStore() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAccept(deps, UnknownPlayerId, GatherQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode); } [Fact] public async Task TryAdvanceStep_ShouldAdvanceChainQuestAndClearCounters() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; AcceptAndComplete(deps, GatherQuestId); AcceptAndComplete(deps, RefineQuestId); AcceptAndComplete(deps, CombatQuestId); Assert.True(TryAccept(deps, ChainQuestId).Success); Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainObjectiveId, 5, out _)); // Act var result = TryAdvanceStep(deps, ChainQuestId, 1); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Equal(1, result.Snapshot!.CurrentStepIndex); Assert.Empty(result.Snapshot.ObjectiveCounters); } [Theory] [InlineData(0)] [InlineData(4)] public async Task TryAdvanceStep_ShouldDenyInvalidStepIndex_WhenIndexNotIncreasingOrPastLastStep(int newStepIndex) { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; AcceptAndComplete(deps, GatherQuestId); AcceptAndComplete(deps, RefineQuestId); AcceptAndComplete(deps, CombatQuestId); Assert.True(TryAccept(deps, ChainQuestId).Success); // Act var result = TryAdvanceStep(deps, ChainQuestId, newStepIndex); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.InvalidStepIndex, result.ReasonCode); } [Fact] public async Task TryAdvanceStep_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAdvanceStep(deps, "prototype_quest_missing", 1); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode); } [Fact] public async Task TryMarkComplete_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryMarkComplete(deps, "prototype_quest_missing"); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode); } [Fact] public async Task TryAdvanceStep_ShouldDenyNotActive_WhenQuestNotAccepted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryAdvanceStep(deps, GatherQuestId, 1); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode); } [Fact] public async Task TryAdvanceStep_ShouldDenyAlreadyCompleted_WhenQuestCompleted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); var timeProvider = TimeProvider.System; Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryMarkComplete(deps, GatherQuestId).Success); // Act var result = TryAdvanceStep(deps, GatherQuestId, 1); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode); } [Fact] public async Task TryMarkComplete_ShouldDeliverGatherIntroBundle_WhenFirstCompletion() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); Assert.True(TryAccept(deps, GatherQuestId).Success); // Act var result = TryMarkComplete(deps, GatherQuestId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var deliveryEvent)); Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); Assert.Single(deliveryEvent.GrantedSkillXp); Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId); Assert.Equal(25, deliveryEvent.GrantedSkillXp[0].Amount); } [Fact] public async Task TryMarkComplete_ShouldNotDoubleGrant_WhenIdempotentReplay() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); Assert.True(TryAccept(deps, GatherQuestId).Success); Assert.True(TryMarkComplete(deps, GatherQuestId).Success); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var firstEvent)); // Act var replay = TryMarkComplete(deps, GatherQuestId); // Assert Assert.True(replay.Success); Assert.Equal(25, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var replayEvent)); Assert.Equal(firstEvent.IdempotencyKey, replayEvent.IdempotencyKey); Assert.Equal(firstEvent.DeliveredAt, replayEvent.DeliveredAt); } [Fact] public async Task TryMarkComplete_ShouldDeliverOperatorChainBundle_WhenFirstCompletion() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); AcceptAndComplete(deps, GatherQuestId); AcceptAndComplete(deps, RefineQuestId); AcceptAndComplete(deps, CombatQuestId); Assert.True(TryAccept(deps, ChainQuestId).Success); deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory); // Act var result = TryMarkComplete(deps, ChainQuestId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); Assert.True(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out var deliveryEvent)); Assert.Single(deliveryEvent.GrantedItems); Assert.Equal("survey_drone_kit", deliveryEvent.GrantedItems[0].ItemId); Assert.Equal(1, deliveryEvent.GrantedItems[0].Quantity); Assert.Single(deliveryEvent.GrantedSkillXp); Assert.Equal("salvage", deliveryEvent.GrantedSkillXp[0].SkillId); Assert.Equal(50, deliveryEvent.GrantedSkillXp[0].Amount); Assert.Single(deliveryEvent.GrantedReputation); Assert.Equal(GridFactionId, deliveryEvent.GrantedReputation[0].FactionId); Assert.Equal(15, deliveryEvent.GrantedReputation[0].Amount); deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); Assert.Equal(CountBagItem(beforeInventory!, "survey_drone_kit") + 1, CountBagItem(afterInventory!, "survey_drone_kit")); Assert.Equal(100, deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage")); Assert.Equal( PrototypeE7M3QuestFactionRules.GridContractMinStanding, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing); } [Fact] public async Task TryAccept_ShouldAcceptGridContract_WhenOperatorChainCompletedWithRepGrant() { // Arrange — operator-chain completion grants survey_drone_kit; grid contract inventory_has_item // objective satisfies on accept, so TryAccept returns completed (not active). Gate check is standing >= 15. await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); AcceptAndComplete(deps, GatherQuestId); AcceptAndComplete(deps, RefineQuestId); AcceptAndComplete(deps, CombatQuestId); Assert.True(TryAccept(deps, ChainQuestId).Success); Assert.True(TryMarkComplete(deps, ChainQuestId).Success); // Act var result = TryAccept(deps, GridContractQuestId); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.NotNull(result.Snapshot); Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); Assert.Equal( PrototypeE7M3QuestFactionRules.GridContractMinStanding, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing); } [Fact] public async Task TryMarkComplete_ShouldDenyAndStayActive_WhenInventoryFull() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); AcceptAndComplete(deps, GatherQuestId); AcceptAndComplete(deps, RefineQuestId); AcceptAndComplete(deps, CombatQuestId); Assert.True(TryAccept(deps, ChainQuestId).Success); FillBag(deps); // Act var result = TryMarkComplete(deps, ChainQuestId); // Assert Assert.False(result.Success); Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode); Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress)); Assert.Equal(QuestProgressStatus.Active, progress.Status); Assert.False(deps.DeliveryStore.TryGet(PlayerId, ChainQuestId, out _)); } [Fact] public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); Assert.True(TryAccept(deps, GatherQuestId).Success); // Act var first = TryMarkComplete(deps, GatherQuestId); var second = TryMarkComplete(deps, GatherQuestId); // Assert Assert.True(first.Success); Assert.Null(first.ReasonCode); Assert.NotNull(first.Snapshot!.CompletedAt); Assert.True(second.Success); Assert.Null(second.ReasonCode); Assert.Equal(first.Snapshot.CompletedAt, second.Snapshot!.CompletedAt); Assert.Equal(QuestProgressStatus.Completed, second.Snapshot.Status); } [Fact] public async Task TryMarkComplete_ShouldDenyNotActive_WhenQuestNeverAccepted() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var result = TryMarkComplete(deps, GatherQuestId); // Assert Assert.False(result.Success); Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode); } [Fact] public async Task Host_ShouldResolveRegistryAndStore_ForQuestStateOperations() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var deps = ResolveDependencies(factory); // Act var accept = TryAccept(deps, GatherQuestId); var complete = TryMarkComplete(deps, GatherQuestId); // Assert Assert.True(accept.Success); Assert.True(complete.Success); Assert.Equal(QuestProgressStatus.Completed, complete.Snapshot!.Status); } [Fact] public void CanWritePlayer_ShouldReturnFalse_ForUnknownPlayer() { // Arrange var store = new InMemoryPlayerQuestStateStore( Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId })); // Act var canWrite = store.CanWritePlayer(UnknownPlayerId); // Assert Assert.False(canWrite); } [Fact] public void CanWritePlayer_ShouldReturnTrue_ForDevPlayer() { // Arrange var store = new InMemoryPlayerQuestStateStore( Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId })); // Act var canWrite = store.CanWritePlayer(PlayerId); // Assert Assert.True(canWrite); } private static void CompleteChainPrerequisite(QuestTestDependencies deps) { Assert.True(deps.ProgressStore.TryActivate(PlayerId, ChainQuestId, out _)); Assert.True(deps.ProgressStore.TryMarkComplete( PlayerId, ChainQuestId, DateTimeOffset.UtcNow, out _)); } private static void SeedGridStanding(QuestTestDependencies deps, int standing) { if (standing == 0) { return; } var auditStore = deps.ReputationDeltaStore; var outcome = ReputationOperations.TryApplyDelta( PlayerId, GridFactionId, standing, $"seed-{standing}", ReputationDeltaSourceKinds.QuestCompletion, ChainQuestId, deps.StandingStore, auditStore, deps.TimeProvider); Assert.True(outcome.Success); Assert.Equal(standing, deps.StandingStore.TryGetStanding(PlayerId, GridFactionId).Standing); } 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 void FillBag(QuestTestDependencies 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 void AcceptAndComplete(QuestTestDependencies deps, string questId) { Assert.True(TryAccept(deps, questId).Success); Assert.True(TryMarkComplete(deps, questId).Success); } private static QuestStateOperationResult TryMarkComplete(QuestTestDependencies deps, string questId) => QuestStateOperations.TryMarkComplete( PlayerId, questId, deps.QuestRegistry, deps.ProgressStore, deps.ItemRegistry, deps.InventoryStore, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.ReputationDeltaStore, deps.TimeProvider); private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) => TryAccept(deps, PlayerId, questId); private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string playerId, string questId) => QuestStateOperations.TryAccept( playerId, questId, deps.QuestRegistry, deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.ReputationDeltaStore, deps.TimeProvider); private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) => QuestStateOperations.TryAdvanceStep( PlayerId, questId, newStepIndex, deps.QuestRegistry, deps.ProgressStore, deps.InventoryStore, deps.ItemRegistry, deps.SkillRegistry, deps.SkillProgressionStore, deps.LevelCurve, deps.PerkUnlockEngine, deps.PerkStore, deps.DeliveryStore, deps.StandingStore, deps.ReputationDeltaStore, deps.TimeProvider); private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory) { var services = factory.Services; return new QuestTestDependencies( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), TimeProvider.System); } private readonly record struct QuestTestDependencies( IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore, IPlayerInventoryStore InventoryStore, IItemDefinitionRegistry ItemRegistry, ISkillDefinitionRegistry SkillRegistry, IPlayerSkillProgressionStore SkillProgressionStore, ISkillLevelCurve LevelCurve, PerkUnlockEngine PerkUnlockEngine, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore ReputationDeltaStore, TimeProvider TimeProvider); }