502 lines
18 KiB
C#
502 lines
18 KiB
C#
using NeonSprawl.Server.Game.Factions;
|
|
using NeonSprawl.Server.Game.Crafting;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.Mastery;
|
|
using NeonSprawl.Server.Game.Quests;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
|
|
namespace NeonSprawl.Server.Tests.Game.Crafting;
|
|
|
|
public sealed class CraftOperationsTests
|
|
{
|
|
private const string PlayerId = "dev-local-1";
|
|
|
|
[Fact]
|
|
public void TryCraft_RefineScrapStandard_ShouldConsumeInputsGrantOutputAndRefineXp()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "scrap_metal_bulk", 5);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"refine_scrap_standard",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
|
var refineXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("refine");
|
|
Assert.True(result.Success);
|
|
Assert.Null(result.ReasonCode);
|
|
Assert.Single(result.InputsConsumed);
|
|
Assert.Equal("scrap_metal_bulk", result.InputsConsumed[0].ItemId);
|
|
Assert.Equal(5, result.InputsConsumed[0].Quantity);
|
|
Assert.Single(result.OutputsGranted);
|
|
Assert.Equal("refined_plate_stock", result.OutputsGranted[0].ItemId);
|
|
Assert.Equal(1, result.OutputsGranted[0].Quantity);
|
|
Assert.NotNull(result.XpGrantSummary);
|
|
Assert.Equal("refine", result.XpGrantSummary!.Value.SkillId);
|
|
Assert.Equal(RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, result.XpGrantSummary.Value.Amount);
|
|
Assert.Equal(RefineSkillXpConstants.ActivitySourceKind, result.XpGrantSummary.Value.SourceKind);
|
|
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
|
|
Assert.Equal(1, CountBagItem(inventory!, "refined_plate_stock"));
|
|
Assert.Equal(10, refineXp);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_RefineScrapStandard_AtQuantityTwo_ShouldScaleInputsAndOutputs()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "scrap_metal_bulk", 10);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"refine_scrap_standard",
|
|
quantity: 2,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
|
Assert.True(result.Success);
|
|
Assert.Single(result.InputsConsumed);
|
|
Assert.Equal(10, result.InputsConsumed[0].Quantity);
|
|
Assert.Single(result.OutputsGranted);
|
|
Assert.Equal(2, result.OutputsGranted[0].Quantity);
|
|
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
|
|
Assert.Equal(2, CountBagItem(inventory!, "refined_plate_stock"));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_WhenMaterialsMissing_ShouldDenyWithInsufficientMaterialsWithoutMutatingInventory()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"refine_scrap_standard",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
var refineXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("refine");
|
|
Assert.False(result.Success);
|
|
Assert.Equal(CraftReasonCodes.InsufficientMaterials, result.ReasonCode);
|
|
Assert.Empty(result.InputsConsumed);
|
|
Assert.Empty(result.OutputsGranted);
|
|
Assert.Null(result.XpGrantSummary);
|
|
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
|
Assert.Equal(0, refineXp);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_WhenBagFullWithScrapInputs_ShouldSucceedByFreeingInputSlots()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "scrap_metal_bulk", 5);
|
|
FillBagExcept(deps, occupiedSlots: 1);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"refine_scrap_standard",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
|
Assert.True(result.Success);
|
|
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
|
|
Assert.Equal(1, CountBagItem(inventory!, "refined_plate_stock"));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_MakePrototypeArmor_WhenEquipmentOccupied_ShouldDenyWithInventoryFull()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "refined_plate_stock", 8);
|
|
SeedStack(deps, "scrap_metal_bulk", 5);
|
|
SeedStack(deps, "prototype_armor_shell", 1);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"make_prototype_armor",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
Assert.False(result.Success);
|
|
Assert.Equal(CraftReasonCodes.InventoryFull, result.ReasonCode);
|
|
Assert.Equal(8, CountBagItem(afterInventory!, "refined_plate_stock"));
|
|
Assert.Equal(5, CountBagItem(afterInventory!, "scrap_metal_bulk"));
|
|
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_MakePrototypeArmor_WhenBagFullButEquipmentEmpty_ShouldPlaceArmorInEquipmentSlot()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "refined_plate_stock", 8);
|
|
SeedStack(deps, "scrap_metal_bulk", 5);
|
|
FillBagExcept(deps, occupiedSlots: 2);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"make_prototype_armor",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
|
Assert.True(result.Success);
|
|
Assert.Equal("prototype_armor_shell", inventory!.EquipmentSlots[0].ItemId);
|
|
Assert.Equal(1, inventory.EquipmentSlots[0].Quantity);
|
|
Assert.Equal(0, CountBagItem(inventory, "refined_plate_stock"));
|
|
Assert.Equal(0, CountBagItem(inventory, "scrap_metal_bulk"));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_ForUnknownRecipe_ShouldDenyWithUnknownRecipe()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"not_a_real_recipe",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(CraftReasonCodes.UnknownRecipe, result.ReasonCode);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_ForWhitespaceRecipeId_ShouldDenyWithUnknownRecipe()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
" ",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
Assert.False(result.Success);
|
|
Assert.Equal(CraftReasonCodes.UnknownRecipe, result.ReasonCode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-1)]
|
|
public void TryCraft_WhenQuantityInvalid_ShouldDenyWithInvalidQuantity(int quantity)
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "scrap_metal_bulk", 5);
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"refine_scrap_standard",
|
|
quantity,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
deps.XpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
|
Assert.False(result.Success);
|
|
Assert.Equal(CraftReasonCodes.InvalidQuantity, result.ReasonCode);
|
|
Assert.Equal(5, CountBagItem(inventory!, "scrap_metal_bulk"));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryCraft_WhenProgressionStoreMissing_ShouldRollbackInventoryAndDeny()
|
|
{
|
|
// Arrange
|
|
using var factory = new InMemoryWebApplicationFactory();
|
|
var deps = ResolveCraftDependencies(factory);
|
|
SeedStack(deps, "scrap_metal_bulk", 5);
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
|
var failingXpStore = new ProgressionStoreAlwaysMissing();
|
|
|
|
// Act
|
|
var result = CraftOperations.TryCraft(
|
|
PlayerId,
|
|
"refine_scrap_standard",
|
|
quantity: 1,
|
|
deps.RecipeRegistry,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore,
|
|
deps.SkillRegistry,
|
|
failingXpStore,
|
|
deps.LevelCurve,
|
|
deps.PerkUnlockEngine,
|
|
deps.QuestRegistry,
|
|
deps.ProgressStore,
|
|
deps.PerkStore,
|
|
deps.DeliveryStore,
|
|
deps.StandingStore,
|
|
deps.AuditStore,
|
|
TimeProvider.System);
|
|
|
|
// Assert
|
|
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
|
Assert.False(result.Success);
|
|
Assert.Equal(CraftReasonCodes.ProgressionStoreMissing, result.ReasonCode);
|
|
Assert.Equal(5, CountBagItem(afterInventory!, "scrap_metal_bulk"));
|
|
Assert.Equal(0, CountBagItem(afterInventory!, "refined_plate_stock"));
|
|
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
|
|
}
|
|
|
|
private static void SeedStack(CraftTestDependencies deps, string itemId, int quantity)
|
|
{
|
|
var outcome = PlayerInventoryOperations.TryAddStack(
|
|
PlayerId,
|
|
itemId,
|
|
quantity,
|
|
deps.ItemRegistry,
|
|
deps.InventoryStore);
|
|
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
|
|
}
|
|
|
|
private static void FillBagExcept(CraftTestDependencies deps, int occupiedSlots)
|
|
{
|
|
for (var i = occupiedSlots; 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 CraftTestDependencies ResolveCraftDependencies(InMemoryWebApplicationFactory factory)
|
|
{
|
|
var services = factory.Services;
|
|
return new CraftTestDependencies(
|
|
services.GetRequiredService<IRecipeDefinitionRegistry>(),
|
|
services.GetRequiredService<IItemDefinitionRegistry>(),
|
|
services.GetRequiredService<IPlayerInventoryStore>(),
|
|
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
|
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
|
services.GetRequiredService<ISkillLevelCurve>(),
|
|
services.GetRequiredService<PerkUnlockEngine>(),
|
|
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
|
services.GetRequiredService<IPlayerQuestStateStore>(),
|
|
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)
|
|
{
|
|
total += slot.Quantity;
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
private readonly record struct CraftTestDependencies(
|
|
IRecipeDefinitionRegistry RecipeRegistry,
|
|
IItemDefinitionRegistry ItemRegistry,
|
|
IPlayerInventoryStore InventoryStore,
|
|
ISkillDefinitionRegistry SkillRegistry,
|
|
IPlayerSkillProgressionStore XpStore,
|
|
ISkillLevelCurve LevelCurve,
|
|
PerkUnlockEngine PerkUnlockEngine,
|
|
IQuestDefinitionRegistry QuestRegistry,
|
|
IPlayerQuestStateStore ProgressStore,
|
|
IPlayerPerkStateStore PerkStore,
|
|
IRewardDeliveryStore DeliveryStore,
|
|
IFactionStandingStore StandingStore,
|
|
IReputationDeltaStore AuditStore);
|
|
|
|
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
|
|
{
|
|
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId) =>
|
|
new Dictionary<string, int>(StringComparer.Ordinal);
|
|
|
|
public bool TryApplyXpDelta(
|
|
string playerId,
|
|
string skillId,
|
|
int amount,
|
|
out int previousXp,
|
|
out int newXp)
|
|
{
|
|
previousXp = 0;
|
|
newXp = 0;
|
|
return false;
|
|
}
|
|
|
|
public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) => false;
|
|
|
|
public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals) => false;
|
|
}
|
|
}
|