using Microsoft.Extensions.DependencyInjection; 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; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; using Xunit; 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; [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); 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")); } [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 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.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.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.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(), 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, TimeProvider TimeProvider); }