neon-sprawl/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperatio...

525 lines
20 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
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.Encounters;
public sealed class EncounterCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(2, result.GrantsApplied.Count);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "scrap_metal_bulk" && g.Quantity == 10);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "contract_handoff_token" && g.Quantity == 1);
Assert.NotNull(result.CompleteEvent);
Assert.Equal(PlayerId, result.CompleteEvent!.Value.PlayerId);
Assert.Equal(EncounterId, result.CompleteEvent.Value.EncounterId);
Assert.Equal("prototype_combat_pocket_clear", result.CompleteEvent.Value.RewardTableId);
Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey);
Assert.NotNull(result.CompletedAt);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal(result.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey);
Assert.Equal(2, storedEvent.GrantedItems.Count);
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
var first = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
// Act
var second = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond);
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.Equal(2, first.GrantsApplied.Count);
Assert.False(second.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, second.ReasonCode);
Assert.Empty(second.GrantsApplied);
Assert.Null(second.CompleteEvent);
Assert.Equal(10, CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal(first.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey);
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyNotReady_WhenOnlyTwoTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyInventoryFull_WhenBagHasNoFreeSlots()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
FillBag(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackGrants_WhenCompletionMarkFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var failingCompletionStore = new CompletionStoreDeniesMark(deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
failingCompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, result.ReasonCode);
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var inventoryStore = new InventoryStoreFullAfterFirstGrant(deps.InventoryStore, deps.ItemRegistry);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
inventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyUnknownEncounter_WhenIdIsInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
"unknown_encounter",
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.QuestProgressStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode);
Assert.False(deps.EventStore.TryGet(PlayerId, "unknown_encounter", out _));
}
private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps)
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
EliteNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
}
private static void FillBag(EncounterCompletionTestDependencies 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 EncounterCompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCompletionTestDependencies(
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>());
}
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 int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
total += slot.Quantity;
}
}
return total;
}
private sealed record EncounterCompletionTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore EventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore QuestProgressStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{
public bool IsCompleted(string playerId, string encounterId) =>
inner.IsCompleted(playerId, encounterId);
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => false;
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) =>
inner.TryGetCompletedAt(playerId, encounterId, out completedAt);
}
/// <summary>
/// After the first successful inventory mutation, fills remaining bag slots so the next apply fails
/// while pre-flight simulate (on an empty snapshot) still passes.
/// </summary>
private sealed class InventoryStoreFullAfterFirstGrant(
IPlayerInventoryStore inner,
IItemDefinitionRegistry itemRegistry) : IPlayerInventoryStore
{
private bool _firstMutationApplied;
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot) =>
inner.TryGetSnapshot(playerId, out snapshot);
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) =>
inner.TryReplaceSnapshot(playerId, snapshot);
public bool TryMutateSnapshot(
string playerId,
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
out PlayerInventorySnapshot result)
{
if (!inner.TryMutateSnapshot(playerId, mutator, out result))
{
return false;
}
if (!_firstMutationApplied)
{
_firstMutationApplied = true;
FillRemainingBagSlots(playerId);
inner.TryGetSnapshot(playerId, out result);
}
return true;
}
private void FillRemainingBagSlots(string playerId)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
playerId,
"survey_drone_kit",
quantity: 1,
itemRegistry,
inner);
if (add.Kind != PlayerInventoryMutationKind.Applied)
{
break;
}
}
}
}
}