neon-sprawl/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCompletionOperation...

264 lines
10 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public sealed class ContractCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string TemplateId = "prototype_contract_clear_combat_pocket";
private const string EncounterId = "prototype_combat_pocket";
private const string MismatchEncounterId = "prototype_encounter_other";
private const string SeedBucket = "2026-06-22";
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldCompleteAndDeliver_WhenActiveInstanceMatchesEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(ContractInstanceStatus.Completed, result.Snapshot!.Status);
Assert.NotNull(result.Outcome);
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out var delivery));
Assert.Equal(RewardDeliverySourceKinds.ContractCompletion, delivery.SourceKind);
Assert.Equal(instanceId, delivery.ContractInstanceId);
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(
CountBagItem(beforeInventory!, "scrap_metal_bulk") + 5,
CountBagItem(afterInventory!, "scrap_metal_bulk"));
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
Assert.Equal(15, salvageXp);
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var stored));
Assert.Equal(ContractInstanceStatus.Completed, stored.Status);
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenNoActiveContract()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(result.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, result.ReasonCode);
Assert.Null(result.Snapshot);
Assert.Null(result.Outcome);
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldNoOp_WhenEncounterMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
// Act
var result = CompleteOnEncounterClear(deps, MismatchEncounterId);
// Assert
Assert.True(result.Success);
Assert.Equal(ContractCompletionReasonCodes.EncounterMismatch, result.ReasonCode);
Assert.NotNull(result.Snapshot);
Assert.Equal(ContractInstanceStatus.Active, result.Snapshot!.Status);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(instanceId, active.ContractInstanceId);
Assert.False(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldLeaveActive_WhenDeliveryDenies()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
FillBag(deps);
// Act
var result = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.False(result.Success);
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
Assert.True(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out var active));
Assert.Equal(instanceId, active.ContractInstanceId);
Assert.Equal(ContractInstanceStatus.Active, active.Status);
Assert.Empty(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
}
[Fact]
public async Task TryCompleteOnEncounterClear_ShouldBeIdempotent_WhenReplayed()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var issued = IssuePrototypeContract(deps);
var instanceId = issued.Snapshot!.ContractInstanceId;
var first = CompleteOnEncounterClear(deps, EncounterId);
Assert.True(first.Success);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
var salvageAfterFirst = deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
// Act
var replay = CompleteOnEncounterClear(deps, EncounterId);
// Assert
Assert.True(replay.Success);
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterReplay);
Assert.Equal(
CountBagItem(afterFirst!, "scrap_metal_bulk"),
CountBagItem(afterReplay!, "scrap_metal_bulk"));
Assert.Equal(
salvageAfterFirst,
deps.SkillProgressionStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage"));
Assert.True(
deps.DeliveryStore.TryGet(
PlayerId,
RewardDeliverySourceKinds.ContractCompletion,
instanceId,
out _));
}
private static ContractIssueOperationResult IssuePrototypeContract(CompletionTestDependencies deps) =>
ContractGeneratorOperations.TryIssue(
PlayerId,
TemplateId,
SeedBucket,
zoneDifficultyBand: null,
deps.TemplateRegistry,
deps.InstanceStore,
deps.StandingStore,
deps.TimeProvider);
private static ContractCompletionOperationResult CompleteOnEncounterClear(
CompletionTestDependencies deps,
string encounterId) =>
ContractCompletionOperations.TryCompleteOnEncounterClear(
PlayerId,
encounterId,
deps.InstanceStore,
deps.OutcomeStore,
deps.TemplateRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.SkillProgressionStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.PerkStore,
deps.DeliveryStore,
deps.StandingStore,
deps.AuditStore,
deps.TimeProvider);
private static void FillBag(CompletionTestDependencies 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 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 CompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
using var scope = factory.Services.CreateScope();
var services = scope.ServiceProvider;
return new CompletionTestDependencies(
services.GetRequiredService<IContractTemplateRegistry>(),
services.GetRequiredService<IContractInstanceStore>(),
services.GetRequiredService<IContractOutcomeStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IPlayerPerkStateStore>(),
services.GetRequiredService<IRewardDeliveryStore>(),
services.GetRequiredService<IFactionStandingStore>(),
services.GetRequiredService<IReputationDeltaStore>(),
new FakeTimeProvider(CompletedAt));
}
private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
private sealed record CompletionTestDependencies(
IContractTemplateRegistry TemplateRegistry,
IContractInstanceStore InstanceStore,
IContractOutcomeStore OutcomeStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore SkillProgressionStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IPlayerPerkStateStore PerkStore,
IRewardDeliveryStore DeliveryStore,
IFactionStandingStore StandingStore,
IReputationDeltaStore AuditStore,
TimeProvider TimeProvider);
}