diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs new file mode 100644 index 0000000..c0ccf5a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/CraftOperationsTests.cs @@ -0,0 +1,338 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +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.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + var refineXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("refine"); + + // Assert + 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_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.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + var refineXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("refine"); + + // Assert + 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_WhenBagFullForOutput_ShouldDenyWithInventoryFullWithoutConsumingInputs() + { + // Arrange + using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveCraftDependencies(factory); + SeedStack(deps, "scrap_metal_bulk", 5); + FillBagExcept(deps, occupiedSlots: 1); + 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.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + + // Assert + Assert.False(result.Success); + Assert.Equal(CraftReasonCodes.InventoryFull, result.ReasonCode); + 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.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + + // Assert + 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); + + // 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.InventoryStore.TryGetSnapshot(PlayerId, out var inventory); + + // Assert + 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.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory); + + // Assert + 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()); + } + + 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); + + 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs b/server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs new file mode 100644 index 0000000..b78d2ee --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftIoApplied.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// One input consumed or output granted on a successful craft (NEO-69). +public readonly record struct CraftIoApplied(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs new file mode 100644 index 0000000..ad2180d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs @@ -0,0 +1,207 @@ +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Crafting; + +/// +/// Orchestrates craft side effects: recipe resolve, inventory pre-flight, input removal, output grant, refine XP (NEO-69). +/// Craft HTTP wiring is NEO-70. +/// +public static class CraftOperations +{ + private static readonly CraftIoApplied[] EmptyIo = []; + + /// + /// Resolves one craft for and at batch . + /// Mutations commit in order: remove inputs → add outputs → refine XP; compensating rollback on output or XP failure. + /// + public static CraftResult TryCraft( + string playerId, + string recipeId, + int quantity, + IRecipeDefinitionRegistry recipeRegistry, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore, + ISkillDefinitionRegistry skillRegistry, + IPlayerSkillProgressionStore xpStore, + ISkillLevelCurve levelCurve, + PerkUnlockEngine perkUnlockEngine) + { + if (quantity <= 0) + { + return Deny(CraftReasonCodes.InvalidQuantity); + } + + var recipeKey = recipeId.Trim(); + if (recipeKey.Length == 0 || !recipeRegistry.TryGetDefinition(recipeKey, out var recipe)) + { + return Deny(CraftReasonCodes.UnknownRecipe); + } + + if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot)) + { + return Deny(CraftReasonCodes.InventoryStoreMissing); + } + + var scaledInputs = ScaleIoRows(recipe.Inputs, quantity); + var scaledOutputs = ScaleIoRows(recipe.Outputs, quantity); + + foreach (var input in scaledInputs) + { + var held = PlayerInventoryOperations.GetHeldQuantity(snapshot, input.ItemId, itemRegistry); + if (held < input.Quantity) + { + return Deny(CraftReasonCodes.InsufficientMaterials); + } + } + + var simulated = CloneSnapshot(snapshot); + foreach (var output in scaledOutputs) + { + if (!PlayerInventoryOperations.TrySimulateAddStack(ref simulated, output.ItemId, output.Quantity, itemRegistry)) + { + return Deny(CraftReasonCodes.InventoryFull); + } + } + + foreach (var input in scaledInputs) + { + var removeOutcome = PlayerInventoryOperations.TryRemoveStack( + playerId, + input.ItemId, + input.Quantity, + itemRegistry, + inventoryStore); + + if (removeOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + return Deny(CraftReasonCodes.InventoryStoreMissing); + } + + if (removeOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + return Deny(CraftReasonCodes.InsufficientMaterials); + } + } + + var outputsApplied = new List(scaledOutputs.Length); + foreach (var output in scaledOutputs) + { + var addOutcome = PlayerInventoryOperations.TryAddStack( + playerId, + output.ItemId, + output.Quantity, + itemRegistry, + inventoryStore); + + if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) + { + CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); + return Deny(CraftReasonCodes.InventoryStoreMissing); + } + + if (addOutcome.Kind == PlayerInventoryMutationKind.Denied) + { + CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); + CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore); + return Deny(CraftReasonCodes.InventoryFull); + } + + outputsApplied.Add(output); + } + + var xpOutcome = SkillProgressionGrantOperations.TryApplyGrant( + playerId, + RefineSkillXpConstants.RefineSkillId, + RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, + RefineSkillXpConstants.ActivitySourceKind, + skillRegistry, + xpStore, + levelCurve, + perkUnlockEngine); + + if (xpOutcome.Kind != SkillProgressionGrantApplyKind.Granted) + { + CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore); + CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore); + + var xpReason = xpOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing + ? CraftReasonCodes.ProgressionStoreMissing + : xpOutcome.Response?.ReasonCode ?? CraftReasonCodes.ProgressionStoreMissing; + + return Deny(xpReason); + } + + return new CraftResult( + Success: true, + ReasonCode: null, + InputsConsumed: scaledInputs, + OutputsGranted: outputsApplied, + XpGrantSummary: new CraftXpGrantSummary( + RefineSkillXpConstants.RefineSkillId, + RefineSkillXpConstants.ActivityXpPerCraftRefineCompletion, + RefineSkillXpConstants.ActivitySourceKind)); + } + + private static CraftIoApplied[] ScaleIoRows(IReadOnlyList rows, int quantity) + { + var scaled = new CraftIoApplied[rows.Count]; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + scaled[i] = new CraftIoApplied(row.ItemId, row.Quantity * quantity); + } + + return scaled; + } + + private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) => + new() + { + BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots), + EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots), + }; + + private static void CompensatingRestoreInputs( + string playerId, + IReadOnlyList inputs, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore) + { + foreach (var input in inputs) + { + _ = PlayerInventoryOperations.TryAddStack( + playerId, + input.ItemId, + input.Quantity, + itemRegistry, + inventoryStore); + } + } + + private static void CompensatingRemoveOutputs( + string playerId, + IReadOnlyList outputs, + IItemDefinitionRegistry itemRegistry, + IPlayerInventoryStore inventoryStore) + { + foreach (var output in outputs) + { + _ = PlayerInventoryOperations.TryRemoveStack( + playerId, + output.ItemId, + output.Quantity, + itemRegistry, + inventoryStore); + } + } + + private static CraftResult Deny(string reasonCode) => + new( + Success: false, + ReasonCode: reasonCode, + InputsConsumed: EmptyIo, + OutputsGranted: EmptyIo, + XpGrantSummary: null); +} diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs b/server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs new file mode 100644 index 0000000..46744f0 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftReasonCodes.cs @@ -0,0 +1,22 @@ +using NeonSprawl.Server.Game.Items; + +namespace NeonSprawl.Server.Game.Crafting; + +/// Stable deny reason codes for craft operations (NEO-69). +public static class CraftReasonCodes +{ + public const string UnknownRecipe = "unknown_recipe"; + + public const string InsufficientMaterials = "insufficient_materials"; + + public const string InvalidQuantity = "invalid_quantity"; + + /// Passthrough from . + public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull; + + /// returned false. + public const string ProgressionStoreMissing = "progression_store_missing"; + + /// Player inventory store could not read or write. + public const string InventoryStoreMissing = "inventory_store_missing"; +} diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs b/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs new file mode 100644 index 0000000..7ba6fa4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftResult.cs @@ -0,0 +1,15 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// +/// Server-internal craft resolution envelope (NEO-69). May promote to wire JSON when craft HTTP lands (NEO-70). +/// NEO-71 telemetry hook sites will live in . +/// +/// Scaled inputs removed on success; empty when denied. +/// Scaled outputs added on success; empty when denied. +/// Refine skill XP applied on success; null when denied. +public readonly record struct CraftResult( + bool Success, + string? ReasonCode, + IReadOnlyList InputsConsumed, + IReadOnlyList OutputsGranted, + CraftXpGrantSummary? XpGrantSummary); diff --git a/server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs b/server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs new file mode 100644 index 0000000..372f740 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/CraftXpGrantSummary.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// Compact refine XP summary on successful craft (NEO-69). +public readonly record struct CraftXpGrantSummary(string SkillId, int Amount, string SourceKind); diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs index d6b4bd4..652a8e8 100644 --- a/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs @@ -134,6 +134,59 @@ public static class PlayerInventoryOperations return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result); } + /// + /// Returns held quantity for in the catalog-routed container (read-only, NEO-69 craft pre-flight). + /// + public static int GetHeldQuantity( + PlayerInventorySnapshot snapshot, + string itemId, + IItemDefinitionRegistry registry) + { + var lookup = itemId.Trim(); + if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) + { + return 0; + } + + var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); + return CountQuantity(snapshot.GetSlots(container), def.Id); + } + + /// + /// Simulates an all-or-nothing add on a snapshot clone without persisting (NEO-69 craft output pre-flight). + /// + public static bool TrySimulateAddStack( + ref PlayerInventorySnapshot snapshot, + string itemId, + int quantity, + IItemDefinitionRegistry registry) + { + if (quantity <= 0) + { + return false; + } + + var lookup = itemId.Trim(); + if (lookup.Length == 0 || !registry.TryGetDefinition(lookup, out var def)) + { + return false; + } + + var container = PlayerInventorySnapshot.ContainerFromSlotKind(def.InventorySlotKind); + var slots = PlayerInventorySnapshot.CloneSlots(snapshot.GetSlots(container)); + var remaining = quantity; + remaining = MergeIntoExistingStacks(slots, def.Id, remaining, def.StackMax); + remaining = FillEmptySlots(slots, def.Id, remaining, def.StackMax); + + if (remaining > 0) + { + return false; + } + + snapshot = snapshot.WithSlots(container, slots); + return true; + } + private static PlayerInventoryMutationOutcome Deny(string playerId, IPlayerInventoryStore store, string reasonCode) { // --- Telemetry hook site (NEO-56): future E9.M1 catalog event `inventory_transfer_denied` ---