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(), services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), 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, IPlayerPerkStateStore PerkStore, IRewardDeliveryStore DeliveryStore, IFactionStandingStore StandingStore, IReputationDeltaStore AuditStore); 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; } }