using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
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: (NEO-70). NEO-71 telemetry hook sites: success path and .
///
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,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
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 = snapshot.Clone();
foreach (var input in scaledInputs)
{
if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry))
{
return Deny(CraftReasonCodes.InsufficientMaterials);
}
}
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)
{
CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore);
CompensatingRestoreInputs(playerId, scaledInputs, itemRegistry, inventoryStore);
return Deny(CraftReasonCodes.InventoryStoreMissing);
}
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
{
CompensatingRemoveOutputs(playerId, outputsApplied, itemRegistry, inventoryStore);
CompensatingRestoreInputs(playerId, scaledInputs, 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);
}
// --- Telemetry hook site (NEO-71): future E9.M1 catalog event `item_crafted` ---
// TODO(E9.M1): catalog emit — on every successful craft (inputs removed, outputs granted, refine XP applied).
// Planned payload fields: playerId, recipeId, quantity (batch), inputsConsumed, outputsGranted, xpGrantSummary
// (skillId, amount, sourceKind). No ingest or ILogger here (comments-only). Successful output grants also pass
// through PlayerInventoryOperations (`item_created` hook, NEO-56). PlayerCraftApi delegates here (no duplicate hooks).
QuestObjectiveWiring.TryProcessCraftSuccess(
playerId,
recipeKey,
quantity,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
skillRegistry,
xpStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
timeProvider);
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 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)
{
// --- Telemetry hook site (NEO-71): future E9.M1 catalog event `craft_failed` ---
// TODO(E9.M1): catalog emit — on every structured craft deny with stable reasonCode (CraftReasonCodes).
// Planned payload fields: playerId, recipeId, quantity, reasonCode — from enclosing TryCraft scope at call sites.
// No ingest or ILogger here (comments-only). PlayerCraftApi delegates here (no duplicate hooks).
return new CraftResult(
Success: false,
ReasonCode: reasonCode,
InputsConsumed: EmptyIo,
OutputsGranted: EmptyIo,
XpGrantSummary: null);
}
}