neon-sprawl/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs

456 lines
16 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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>());
}
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);
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;
}
}