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
parent
bba1415d54
commit
e914d5fb96
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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` ---
|
||||
|
|
|
|||
Loading…
Reference in New Issue