NEO-69: Add CraftOperations engine with pre-flight and refine XP.

Introduces CraftResult orchestration over recipe registry and inventory
operations, including output capacity simulation and XP rollback on failure.
pull/103/head
VinPropane 2026-05-24 17:16:59 -04:00
parent bba1415d54
commit e914d5fb96
7 changed files with 643 additions and 0 deletions

View File

@ -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<IRecipeDefinitionRegistry>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>());
}
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<string, int> GetXpTotals(string playerId) =>
new Dictionary<string, int>(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<string, int> skillXpTotals) => false;
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>One input consumed or output granted on a successful craft (NEO-69).</summary>
public readonly record struct CraftIoApplied(string ItemId, int Quantity);

View File

@ -0,0 +1,207 @@
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 wiring is NEO-70.
/// </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 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<CraftIoApplied>(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<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) =>
new(
Success: false,
ReasonCode: reasonCode,
InputsConsumed: EmptyIo,
OutputsGranted: EmptyIo,
XpGrantSummary: null);
}

View File

@ -0,0 +1,22 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>Stable deny reason codes for craft operations (NEO-69).</summary>
public static class CraftReasonCodes
{
public const string UnknownRecipe = "unknown_recipe";
public const string InsufficientMaterials = "insufficient_materials";
public const string InvalidQuantity = "invalid_quantity";
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
/// <summary><see cref="Skills.IPlayerSkillProgressionStore.TryApplyXpDelta"/> returned false.</summary>
public const string ProgressionStoreMissing = "progression_store_missing";
/// <summary>Player inventory store could not read or write.</summary>
public const string InventoryStoreMissing = "inventory_store_missing";
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>
/// 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 <see cref="CraftOperations.TryCraft"/>.
/// </summary>
/// <param name="InputsConsumed">Scaled inputs removed on success; empty when denied.</param>
/// <param name="OutputsGranted">Scaled outputs added on success; empty when denied.</param>
/// <param name="XpGrantSummary">Refine skill XP applied on success; null when denied.</param>
public readonly record struct CraftResult(
bool Success,
string? ReasonCode,
IReadOnlyList<CraftIoApplied> InputsConsumed,
IReadOnlyList<CraftIoApplied> OutputsGranted,
CraftXpGrantSummary? XpGrantSummary);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>Compact refine XP summary on successful craft (NEO-69).</summary>
public readonly record struct CraftXpGrantSummary(string SkillId, int Amount, string SourceKind);

View File

@ -134,6 +134,59 @@ public static class PlayerInventoryOperations
return new PlayerInventoryMutationOutcome(PlayerInventoryMutationKind.Applied, null, result);
}
/// <summary>
/// Returns held quantity for <paramref name="itemId"/> in the catalog-routed container (read-only, NEO-69 craft pre-flight).
/// </summary>
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);
}
/// <summary>
/// Simulates an all-or-nothing add on a snapshot clone without persisting (NEO-69 craft output pre-flight).
/// </summary>
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` ---