263 lines
10 KiB
C#
263 lines
10 KiB
C#
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;
|
|
|
|
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 instanceId = IssueAndCompleteOnce(deps);
|
|
|
|
// Act
|
|
var replay = CompleteOnEncounterClear(deps, EncounterId);
|
|
|
|
// Assert
|
|
Assert.True(replay.Success);
|
|
Assert.Equal(ContractCompletionReasonCodes.NoActiveContract, replay.ReasonCode);
|
|
Assert.True(deps.InstanceStore.TryGet(PlayerId, instanceId, out var instance));
|
|
Assert.Equal(ContractInstanceStatus.Completed, instance.Status);
|
|
Assert.False(deps.InstanceStore.TryGetActiveForPlayer(PlayerId, out _));
|
|
Assert.True(
|
|
deps.DeliveryStore.TryGet(
|
|
PlayerId,
|
|
RewardDeliverySourceKinds.ContractCompletion,
|
|
instanceId,
|
|
out _));
|
|
Assert.Single(deps.OutcomeStore.GetOutcomesForInstance(instanceId));
|
|
}
|
|
|
|
private static string IssueAndCompleteOnce(CompletionTestDependencies deps)
|
|
{
|
|
var issued = IssuePrototypeContract(deps);
|
|
var instanceId = issued.Snapshot!.ContractInstanceId;
|
|
var first = CompleteOnEncounterClear(deps, EncounterId);
|
|
Assert.True(first.Success);
|
|
Assert.Null(first.ReasonCode);
|
|
Assert.Equal(ContractInstanceStatus.Completed, first.Snapshot!.Status);
|
|
return instanceId;
|
|
}
|
|
|
|
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);
|
|
}
|