256 lines
9.5 KiB
C#
256 lines
9.5 KiB
C#
using NeonSprawl.Server.Game.Factions;
|
|
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;
|
|
|
|
/// <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,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
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<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).
|
|
|
|
QuestObjectiveWiring.TryProcessCraftSuccess(
|
|
playerId,
|
|
recipeKey,
|
|
quantity,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
xpStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
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<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 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);
|
|
}
|
|
}
|