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

441 lines
17 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
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,
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,
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,
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,
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,
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,
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,
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,
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>());
}
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);
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;
}
}
}
}
}