neon-sprawl/server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs

235 lines
9.0 KiB
C#

using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>
/// Orchestrates craft side effects: recipe resolve, inventory pre-flight, input removal, output grant, refine XP (NEO-69).
/// Craft HTTP: <see cref="PlayerCraftApi"/> (NEO-70). NEO-71 telemetry hook sites: <see cref="TryCraft"/> success path and <see cref="Deny"/>.
/// </summary>
public static class CraftOperations
{
private static readonly CraftIoApplied[] EmptyIo = [];
/// <summary>
/// Resolves one craft for <paramref name="playerId"/> and <paramref name="recipeId"/> at batch <paramref name="quantity"/>.
/// Mutations commit in order: remove inputs → add outputs → refine XP; compensating rollback on output or XP failure.
/// </summary>
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 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<CraftIoApplied>(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<CraftIoApplied>(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).
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<RecipeIoRow> 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<CraftIoApplied> 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<CraftIoApplied> 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);
}
}