815 lines
34 KiB
C#
815 lines
34 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using NeonSprawl.Server.Game.Encounters;
|
|
using NeonSprawl.Server.Game.Factions;
|
|
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.Rewards;
|
|
|
|
public sealed class RewardRouterOperationsTests
|
|
{
|
|
private const string PlayerId = "dev-local-1";
|
|
private const string GatherQuestId = "prototype_quest_gather_intro";
|
|
private const string OperatorChainQuestId = "prototype_quest_operator_chain";
|
|
private const string GridOperatorsFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
|
private const string RustCollectiveFactionId = "prototype_faction_rust_collective";
|
|
private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
|
|
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
|
var timeProvider = TimeProvider.System;
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
|
|
// Act
|
|
var first = Deliver(deps, GatherQuestId, bundle, timeProvider);
|
|
var second = Deliver(deps, GatherQuestId, bundle, timeProvider);
|
|
|
|
// Assert
|
|
Assert.True(first.Success);
|
|
Assert.Null(first.ReasonCode);
|
|
Assert.Empty(first.GrantedItems);
|
|
Assert.Single(first.GrantedSkillXp);
|
|
Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId);
|
|
Assert.Equal(25, first.GrantedSkillXp[0].Amount);
|
|
Assert.Empty(first.GrantedReputation);
|
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
|
|
Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey);
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
|
|
Assert.Equal(salvageBefore + 25, salvageXp);
|
|
Assert.True(second.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
|
|
var xpAfterReplay = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
Assert.True(xpAfterReplay.TryGetValue("salvage", out var salvageAfterReplay));
|
|
Assert.Equal(salvageBefore + 25, salvageAfterReplay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
|
|
// Act
|
|
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Null(result.ReasonCode);
|
|
Assert.Single(result.GrantedItems);
|
|
Assert.Equal("survey_drone_kit", result.GrantedItems[0].ItemId);
|
|
Assert.Equal(1, result.GrantedItems[0].Quantity);
|
|
Assert.Single(result.GrantedSkillXp);
|
|
Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
|
|
Assert.Equal(50, result.GrantedSkillXp[0].Amount);
|
|
Assert.Single(result.GrantedReputation);
|
|
Assert.Equal(GridOperatorsFactionId, result.GrantedReputation[0].FactionId);
|
|
Assert.Equal(15, result.GrantedReputation[0].Amount);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
|
Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit"));
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
|
|
Assert.Equal(salvageBefore + 50, salvageXp);
|
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
|
Assert.Equal(
|
|
PrototypeE7M3QuestFactionRules.GridContractMinStanding,
|
|
deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
|
|
var idempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId);
|
|
var expectedDeltaId = RewardDeliveryIds.MakeReputationDeltaId(idempotencyKey, GridOperatorsFactionId);
|
|
var auditRow = Assert.Single(
|
|
deps.AuditStore.GetDeltasForPlayer(PlayerId),
|
|
row => row.Id == expectedDeltaId);
|
|
Assert.Equal(GridOperatorsFactionId, auditRow.FactionId);
|
|
Assert.Equal(15, auditRow.DeltaAmount);
|
|
Assert.Equal(ReputationDeltaSourceKinds.QuestCompletion, auditRow.SourceKind);
|
|
Assert.Equal(OperatorChainQuestId, auditRow.SourceId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldNotDoubleApplyReputation_WhenAlreadyDelivered()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
|
|
var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
|
|
|
|
// Act
|
|
var first = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
var second = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(first.Success);
|
|
Assert.Single(first.GrantedReputation);
|
|
Assert.Equal(15, first.GrantedReputation[0].Amount);
|
|
Assert.True(second.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
|
|
Assert.Equal(
|
|
standingBefore + 15,
|
|
deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldDenyUnknownFaction_WithoutStoreRecord()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow(
|
|
[],
|
|
[],
|
|
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
|
|
Assert.Equal(0, deps.StandingStore.TryGetStanding(PlayerId, "prototype_faction_unknown").Standing);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRollbackItemsAndSkillXp_WhenReputationApplyDenied()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
|
var bundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
|
[new QuestSkillXpGrantRow("salvage", 50)],
|
|
[new ReputationGrantRow("prototype_faction_unknown", 10)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.UnknownFaction, result.ReasonCode);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
Assert.Equal(
|
|
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
|
CountBagItem(afterInventory!, "survey_drone_kit"));
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
|
|
Assert.Equal(salvageBefore, salvageAfter);
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRollbackAllGrants_WhenSecondReputationAuditAppendFails()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var auditStore = new AuditStoreFailingOnSecondForwardRepAppend();
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
|
var gridStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
|
|
var rustStandingBefore = deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing;
|
|
var bundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
|
[new QuestSkillXpGrantRow("salvage", 50)],
|
|
[
|
|
new ReputationGrantRow(GridOperatorsFactionId, 15),
|
|
new ReputationGrantRow(RustCollectiveFactionId, 10),
|
|
]);
|
|
|
|
// Act
|
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
PlayerId,
|
|
OperatorChainQuestId,
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
auditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AuditAppendFailed, result.ReasonCode);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
Assert.Equal(
|
|
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
|
CountBagItem(afterInventory!, "survey_drone_kit"));
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
|
|
Assert.Equal(salvageBefore, salvageAfter);
|
|
Assert.Equal(gridStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
|
|
Assert.Equal(rustStandingBefore, deps.StandingStore.TryGetStanding(PlayerId, RustCollectiveFactionId).Standing);
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
|
Assert.Equal(2, auditStore.GetDeltasForPlayer(PlayerId).Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
FillBag(deps);
|
|
var bundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
|
[new QuestSkillXpGrantRow("salvage", 50)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
|
|
Assert.Empty(result.GrantedItems);
|
|
Assert.Empty(result.GrantedSkillXp);
|
|
Assert.Empty(result.GrantedReputation);
|
|
Assert.Null(result.DeliveryEvent);
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
var bundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
|
[new QuestSkillXpGrantRow("salvage", 0)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.InvalidAmount, result.ReasonCode);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
Assert.Equal(
|
|
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
|
CountBagItem(afterInventory!, "survey_drone_kit"));
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var existingEvent = new RewardDeliveryEvent(
|
|
PlayerId,
|
|
OperatorChainQuestId,
|
|
DeliveredAt,
|
|
[new RewardItemGrantApplied("survey_drone_kit", 1)],
|
|
[new RewardSkillXpGrantApplied("salvage", 50)],
|
|
[],
|
|
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
|
|
Assert.True(deps.DeliveryStore.TryRecord(existingEvent));
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var bundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
|
[new QuestSkillXpGrantRow("salvage", 50)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
|
Assert.Equal(existingEvent.DeliveredAt, result.DeliveredAt);
|
|
Assert.Equal(existingEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
Assert.Equal(
|
|
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
|
CountBagItem(afterInventory!, "survey_drone_kit"));
|
|
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind_WhenCatalogAllowsIt()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("refine", 25)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, "prototype_quest_refine_intro", bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Null(result.ReasonCode);
|
|
var totals = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
Assert.True(totals.TryGetValue("refine", out var refineXp));
|
|
Assert.Equal(25, refineXp);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldDenyUnknownSkill_WhenSkillIdIsInvalid()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)]);
|
|
|
|
// Act
|
|
var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.UnknownSkill, result.ReasonCode);
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
|
|
|
|
// Act
|
|
var result = Deliver(deps, GatherQuestId, bundle: null, TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Null(result.ReasonCode);
|
|
Assert.Empty(result.GrantedItems);
|
|
Assert.Empty(result.GrantedSkillXp);
|
|
Assert.Empty(result.GrantedReputation);
|
|
Assert.NotNull(result.DeliveryEvent);
|
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
|
|
Assert.Empty(stored.GrantedItems);
|
|
Assert.Empty(stored.GrantedSkillXp);
|
|
Assert.Empty(stored.GrantedReputation);
|
|
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
|
|
Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
|
|
|
// Act
|
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
" ",
|
|
GatherQuestId,
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.InvalidPlayerId, result.ReasonCode);
|
|
Assert.False(deps.DeliveryStore.TryGet(" ", GatherQuestId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldDenyInvalidQuestId_WhenQuestIdIsEmpty()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
|
|
|
// Act
|
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
PlayerId,
|
|
" ",
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.InvalidQuestId, result.ReasonCode);
|
|
Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var winnerEvent = new RewardDeliveryEvent(
|
|
PlayerId,
|
|
GatherQuestId,
|
|
DeliveredAt,
|
|
[],
|
|
[new RewardSkillXpGrantApplied("salvage", 25)],
|
|
[],
|
|
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
|
|
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
|
|
|
// Act
|
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
PlayerId,
|
|
GatherQuestId,
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
store,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
|
Assert.Equal(winnerEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
|
|
Assert.Equal(salvageBefore, salvageAfter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRollbackReputation_WhenTryRecordLosesRace()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = PrototypeE7M3QuestFactionRules.ExpectedCompletionBundles[OperatorChainQuestId];
|
|
var standingBefore = deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing;
|
|
var winnerEvent = new RewardDeliveryEvent(
|
|
PlayerId,
|
|
OperatorChainQuestId,
|
|
DeliveredAt,
|
|
[new RewardItemGrantApplied("survey_drone_kit", 1)],
|
|
[new RewardSkillXpGrantApplied("salvage", 50)],
|
|
[new RewardReputationGrantApplied(GridOperatorsFactionId, 15)],
|
|
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
|
|
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
|
|
|
|
// Act
|
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
PlayerId,
|
|
OperatorChainQuestId,
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
store,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
|
Assert.Equal(standingBefore, deps.StandingStore.TryGetStanding(PlayerId, GridOperatorsFactionId).Standing);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425));
|
|
var pick = deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency");
|
|
Assert.True(pick.Success);
|
|
var perkBefore = deps.PerkStore.GetSnapshot(PlayerId);
|
|
Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkBefore.UnlockedPerkIds);
|
|
var winnerEvent = new RewardDeliveryEvent(
|
|
PlayerId,
|
|
GatherQuestId,
|
|
DeliveredAt,
|
|
[],
|
|
[new RewardSkillXpGrantApplied("salvage", 25)],
|
|
[],
|
|
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
|
|
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
|
|
|
// Act
|
|
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
PlayerId,
|
|
GatherQuestId,
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
store,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
|
Assert.Equal(425, salvageAfter);
|
|
var perkAfter = deps.PerkStore.GetSnapshot(PlayerId);
|
|
Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds);
|
|
Assert.Equal(perkBefore.UnlockedPerkIds, perkAfter.UnlockedPerkIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldNotDoubleGrant_WhenConcurrentRecordRaces()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
var bundle = new QuestRewardBundleRow(
|
|
[new RewardGrantRow("survey_drone_kit", 1)],
|
|
[new QuestSkillXpGrantRow("salvage", 25)]);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
|
|
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
|
var kitBefore = CountBagItem(inventoryBefore!, "survey_drone_kit");
|
|
var barrier = new Barrier(2);
|
|
RewardDeliveryResult result1 = default;
|
|
RewardDeliveryResult result2 = default;
|
|
|
|
// Act
|
|
await Task.WhenAll(
|
|
Task.Run(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
result1 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
}),
|
|
Task.Run(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
result2 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
|
}));
|
|
|
|
// Assert
|
|
Assert.True(result1.Success);
|
|
Assert.True(result2.Success);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
|
Assert.Equal(salvageBefore + 25, salvageAfter);
|
|
Assert.Equal(kitBefore + 1, CountBagItem(inventoryAfter!, "survey_drone_kit"));
|
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryDeliverQuestCompletion_ShouldPreserveWinnerPerks_WhenConcurrentRecordRacesAfterLevelUp()
|
|
{
|
|
// Arrange
|
|
await using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveDependencies(factory);
|
|
Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425));
|
|
Assert.True(deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency").Success);
|
|
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
|
var barrier = new Barrier(2);
|
|
RewardDeliveryResult result1 = default;
|
|
RewardDeliveryResult result2 = default;
|
|
|
|
// Act
|
|
await Task.WhenAll(
|
|
Task.Run(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
result1 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
|
}),
|
|
Task.Run(() =>
|
|
{
|
|
barrier.SignalAndWait();
|
|
result2 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
|
}));
|
|
|
|
// Assert
|
|
Assert.True(result1.Success);
|
|
Assert.True(result2.Success);
|
|
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
|
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
|
Assert.Equal(450, salvageAfter);
|
|
var perkAfter = deps.PerkStore.GetSnapshot(PlayerId);
|
|
Assert.Contains(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds);
|
|
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
|
|
}
|
|
|
|
private static RewardDeliveryResult Deliver(
|
|
RewardRouterTestDependencies deps,
|
|
string questId,
|
|
QuestRewardBundleRow? bundle,
|
|
TimeProvider timeProvider) =>
|
|
RewardRouterOperations.TryDeliverQuestCompletion(
|
|
PlayerId,
|
|
questId,
|
|
bundle,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.SkillProgressionStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
timeProvider);
|
|
|
|
private static void FillBag(RewardRouterTestDependencies 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 RewardRouterTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
|
{
|
|
var services = factory.Services;
|
|
return new RewardRouterTestDependencies(
|
|
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>());
|
|
}
|
|
|
|
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 RewardRouterTestDependencies(
|
|
IItemDefinitionRegistry ItemRegistry,
|
|
IPlayerInventoryStore InventoryStore,
|
|
ISkillDefinitionRegistry SkillRegistry,
|
|
IPlayerSkillProgressionStore SkillProgressionStore,
|
|
ISkillLevelCurve LevelCurve,
|
|
PerkUnlockEngine PerkUnlockEngine,
|
|
IPlayerPerkStateStore PerkStore,
|
|
IRewardDeliveryStore DeliveryStore,
|
|
IFactionStandingStore StandingStore,
|
|
IReputationDeltaStore AuditStore);
|
|
|
|
/// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary>
|
|
private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore
|
|
{
|
|
private bool recordAttempted;
|
|
|
|
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
|
|
{
|
|
recordAttempted = true;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
|
|
{
|
|
if (!recordAttempted)
|
|
{
|
|
deliveryEvent = default;
|
|
return false;
|
|
}
|
|
|
|
deliveryEvent = winnerEvent;
|
|
return true;
|
|
}
|
|
|
|
public bool TryClear(string playerId, string questId) => true;
|
|
}
|
|
|
|
/// <summary>Second forward rep audit append fails; rollback appends still succeed.</summary>
|
|
private sealed class AuditStoreFailingOnSecondForwardRepAppend : IReputationDeltaStore
|
|
{
|
|
private int forwardRepAppendCount;
|
|
private readonly List<ReputationDeltaRow> rows = [];
|
|
|
|
public bool TryAppend(ReputationDeltaRow row)
|
|
{
|
|
if (row.Id.EndsWith(":rollback", StringComparison.Ordinal))
|
|
{
|
|
rows.Add(row);
|
|
return true;
|
|
}
|
|
|
|
if (row.Id.Contains(":rep:", StringComparison.Ordinal))
|
|
{
|
|
forwardRepAppendCount++;
|
|
if (forwardRepAppendCount >= 2)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
rows.Add(row);
|
|
return true;
|
|
}
|
|
|
|
public IReadOnlyList<ReputationDeltaRow> GetDeltasForPlayer(string playerId, int? limit = null)
|
|
{
|
|
var normalized = FactionStandingIds.NormalizePlayerId(playerId);
|
|
var filtered = rows.Where(r => r.PlayerId == normalized).ToList();
|
|
if (limit is > 0)
|
|
{
|
|
return [.. filtered.Take(limit.Value)];
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
public bool TryClearQuestCompletionAudit(string playerId, string questId) => true;
|
|
}
|
|
}
|