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); } } var inputsRemoved = new List(scaledInputs.Length); foreach (var input in scaledInputs) { var removeOutcome = PlayerInventoryOperations.TryRemoveStack( playerId, input.ItemId, input.Quantity, itemRegistry, inventoryStore); if (removeOutcome.Kind == PlayerInventoryMutationKind.StoreMissing) { CompensatingRestoreInputs(playerId, inputsRemoved, itemRegistry, inventoryStore); return Deny(CraftReasonCodes.InventoryStoreMissing); } if (removeOutcome.Kind == PlayerInventoryMutationKind.Denied) { CompensatingRestoreInputs(playerId, inputsRemoved, itemRegistry, inventoryStore); return Deny(CraftReasonCodes.InsufficientMaterials); } inputsRemoved.Add(input); } 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); }