neon-sprawl/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs

651 lines
24 KiB
C#

using Microsoft.Extensions.DependencyInjection;
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;
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;
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);
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 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.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.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<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
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);
}