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(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService()); } 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 GetXpTotals(string playerId) => new Dictionary(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 skillXpTotals) => false; } }