ZOA Crafting
Recipes, stations, and a validate-before-you-consume engine that reaches inventory and skills only through adapters.
Crafting is a recipe engine and nothing more. A recipe declares what it consumes, what it produces, where it must be performed, how long it takes, and what skill rank it demands. The engine registers recipes, answers whether one can be crafted right now, and executes it. Everything about how items are actually stored and how skills are actually tracked sits behind two small adapter interfaces the host project implements.
That adapter boundary is why the package depends on nothing but `com.zoa.foundation`. `ICraftingInventoryAdapter` has four methods and `ICraftingSkillAdapter` has two, and between them they cover every question the engine needs to ask. Wire them to the ZOA inventory and progression packages, to your own systems, or to a fake in a unit test, and the crafting logic is unchanged.
One category enum covers the whole surface most action and survival games need. `RecipeCategory` spans Craft, Repair, Salvage, AmmoCraft, AttachmentCraft, and Upgrade, so breaking a pistol down into scrap and pressing that scrap into rounds are two recipes in the same registry rather than two subsystems. Stations then partition which of those recipes a given bench will offer.
Depends on (1)
Depended on by (1)
How it works
Concepts
Validate first, then consume
`CanCraft` returns a `CraftingValidation` carrying a boolean and a list of human-readable issue strings, and it checks four things: the station matches (or the recipe requires none), every ingredient is present in the required quantity, the skill requirement is met when a skill adapter is installed, and there is inventory room for every guaranteed output.
`Craft` runs that same validation before it touches anything. On failure it consumes nothing, maps the issues to a `CraftingResultStatus`, raises `CraftFailed`, and returns a failed `CraftingResult` whose `FailureReason` is the joined issue list. On success it consumes ingredients, produces outputs, raises `Crafted`, and returns the produced list.
The ordering matters for UI as much as for correctness. A crafting panel calls `CanCraft` to decide whether the button is enabled and what tooltip to show, then calls `Craft` on the click, and the two answers agree because they run the same code.
Note the asymmetry in the space check: only outputs with a probability of 1 are checked for inventory room, because a probabilistic output may not materialise at all and refusing the craft on its behalf would be wrong.
Ingredients can be consumed or merely required
`RecipeIngredient` carries a `Consumed` flag alongside the item id and quantity. When it is true the ingredient is destroyed on a successful craft. When it is false the ingredient is a tool or catalyst: it has to be in the inventory for validation to pass, and it survives the craft untouched.
That single flag covers a surprising amount of design space without a second concept. A blowtorch that gates armour repairs, a schematic that unlocks an attachment recipe, and a reusable mould are all catalysts. The engine's consume loop simply skips them.
Outputs are the mirror image, with a `Probability` in the range zero to one instead of a flag. A probability of 1 is a guaranteed product; anything less is a bonus roll, which is how salvage recipes express "always some scrap, sometimes an intact component".
Stations partition the recipe book
`StationId` is a trimmed, case-insensitive string handle with implicit conversion to and from string, so `"forge"` and `DefaultStations.Forge` are interchangeable at a call site. `DefaultStations` names the eight built-ins: workbench, forge, chem_lab, ammo_press, weapon_bench, salvage_table, armor_station, and field_kit.
A recipe with an empty `RequiredStation` can be crafted anywhere. `GetRecipesForStation` reflects that: it returns recipes bound to the given station plus every station-less recipe, which is exactly the list a bench UI wants to render. Field crafting therefore falls out of authoring rather than needing an engine feature.
Stations are not registered objects and the engine holds no station state. The current station is a parameter the caller passes into `CanCraft` and `Craft`, so whatever in your scene decides the player is standing at a forge is free to decide it however it likes.
Two adapters are the entire integration surface
`ICraftingInventoryAdapter` asks four questions: does this entity have this item in this quantity, consume it, add it, and is there room for it. `ICraftingSkillAdapter` asks two: what is this entity's level in this skill, and does it meet a threshold. Both key on a plain string entity id, so the engine never holds a GameObject, an item asset, or a progression handle.
The skill adapter is optional. `CraftingEngine`'s constructor takes it as a nullable second argument, and when it is null the skill clause in `CanCraft` is skipped entirely, so recipes with a `RequiredSkillId` become craftable by anyone. That is the right default for a project that has not wired progression yet, and nothing warns you when a gated recipe book opens up.
The inventory adapter is not optional. The constructor throws `ArgumentNullException` without one, because a crafting engine that cannot read or write items has nothing to validate against.
In the editor
Screens
Screenshot pending
/screenshots/crafting-workbench-recipes.png
The Workbench with Crafting selected, the capability strip showing Recipes, Stations Browser, and Overview, and the Recipes list open with several assets grouped by category and one recipe selected so its ingredient and output rows are visible.
Screenshot pending
/screenshots/crafting-recipe-wizard-ingredients.png
The wizard shell with its five-step rail (Identity, Ingredients, Outputs, Requirements, Review), the Ingredients step showing two or three rows with item ids and quantities and at least one row with the Consumed toggle switched off to show a catalyst.
Setup
Workflow
- 01
Implement the inventory adapter
Write an ICraftingInventoryAdapter over whatever holds your items. Four methods, all keyed by a string entity id and a string item id. If you are on com.zoa.inventory, this is a thin wrapper over the player's InventoryComponent and its TryAddQuantity path.
- 02
Construct the engine
New up a CraftingEngine with that adapter, and with a skill adapter if progression is wired. Register it wherever your project resolves services. There is no scene installer in this package: the engine is a plain object with no Unity dependency, so the host decides where it lives and how long it lives.
- 03
Author or build recipes
Create RecipeDefinitionAsset assets through the Workbench's Recipes browser or the Recipe Wizard for designer-facing content, or use RecipeDefinitionBuilder for recipes generated in code. Register each one with RegisterRecipe before anything tries to craft it.
- 04
Drive a bench UI
Call GetRecipesForStation with the station the player is standing at to populate the list; the result already includes station-less recipes. Call CanCraft per row to set the enabled state and render the Issues list as the tooltip.
- 05
Execute and react
Call Craft on confirmation. Subscribe to Crafted and CraftFailed for audio, toasts, analytics, and objective progress rather than branching at every call site. If a recipe has a non-zero CraftTime, run your own progress bar around the call: the engine itself is synchronous and does not wait.
Surface
Key types
ICraftingService
interface
The whole public surface: register recipes, query them by station or category, validate a craft, execute it, and observe the outcome.
- void RegisterRecipe(IRecipeDefinition recipe)
- IRecipeDefinition GetRecipe(RecipeId recipeId)
- IReadOnlyList<IRecipeDefinition> GetRecipesForStation(StationId stationId)
- IReadOnlyList<IRecipeDefinition> GetRecipesByCategory(RecipeCategory category)
- CraftingValidation CanCraft(string entityId, RecipeId recipeId, StationId currentStation)
- CraftingResult Craft(string entityId, RecipeId recipeId, StationId currentStation)
- event Action<string, RecipeId, CraftingResult> Crafted
- event Action<string, RecipeId, CraftingResult> CraftFailed
CraftingEngine
class
The default ICraftingService. Constructed with a required inventory adapter and an optional skill adapter; holds only a recipe dictionary and never touches Unity.
- CraftingEngine(ICraftingInventoryAdapter inventory, ICraftingSkillAdapter skills = null)
IRecipeDefinition
interface
A recipe's static shape. Read-only: the engine registers instances and never mutates them.
- RecipeId Id { get; }
- RecipeCategory Category { get; }
- StationId RequiredStation { get; }
- IReadOnlyList<RecipeIngredient> Ingredients { get; }
- IReadOnlyList<RecipeOutput> Outputs { get; }
- float CraftTime { get; }
- string RequiredSkillId { get; }
- int RequiredSkillLevel { get; }
ICraftingInventoryAdapter
interface
The four-question bridge to whatever holds items. Implement it against com.zoa.inventory, against your own bag, or against a dictionary in a test.
- bool HasItem(string entityId, string itemId, int quantity)
- bool ConsumeItem(string entityId, string itemId, int quantity)
- bool AddItem(string entityId, string itemId, int quantity)
- bool HasInventorySpace(string entityId, string itemId, int quantity)
ICraftingSkillAdapter
interface
The optional bridge to progression. Pass null to the engine and every skill requirement is treated as satisfied.
- int GetSkillLevel(string entityId, string skillId)
- bool MeetsSkillRequirement(string entityId, string skillId, int requiredLevel)
RecipeDefinitionBuilder
class
Fluent construction of an IRecipeDefinition in code. Builds an immutable private implementation, so the result is safe to register and share.
- RecipeDefinitionBuilder(string id, string displayName)
- RecipeDefinitionBuilder WithCategory(RecipeCategory category)
- RecipeDefinitionBuilder RequiresStation(StationId stationId)
- RecipeDefinitionBuilder AddIngredient(string itemId, int quantity, bool consumed = true)
- RecipeDefinitionBuilder AddOutput(string itemId, int quantity, float probability = 1f)
- RecipeDefinitionBuilder WithCraftTime(float seconds)
- RecipeDefinitionBuilder RequiresSkill(string skillId, int level)
- IRecipeDefinition Build()
RecipeIngredient
struct
One input row. The Consumed flag separates a material from a tool: both must be present, only one is destroyed.
- string ItemId
- int Quantity
- bool Consumed
RecipeOutput
struct
One product row. A probability of 1 is guaranteed and is the only kind the space check considers; below 1 is a bonus roll.
- string ItemId
- int Quantity
- float Probability
CraftingValidation
class
The answer to CanCraft: a validity flag plus the complete list of reasons it failed. Built through the static Valid and Invalid factories.
- bool IsValid { get; }
- IReadOnlyList<string> Issues { get; }
- static CraftingValidation Valid()
- static CraftingValidation Invalid(params string[] issues)
CraftingResult
class
The outcome of an attempt. On success it carries the outputs that actually materialised; on failure it carries a status and the joined issue text.
- CraftingResultStatus Status { get; }
- RecipeId RecipeId { get; }
- IReadOnlyList<RecipeOutput> ProducedItems { get; }
- string FailureReason { get; }
- bool IsSuccess { get; }
CraftingResultStatus
enum
Success, InsufficientIngredients, WrongStation, InsufficientSkill, InventoryFull, RecipeNotFound, Failed. The status a UI branches on to pick its refusal message.
RecipeCategory
enum
Craft, Repair, Salvage, AmmoCraft, AttachmentCraft, Upgrade. Categorises by what the recipe does to the world, and drives which bench menus surface it.
StationId
struct
Trimmed, case-insensitive string handle for a station type, with implicit string conversion. An empty StationId on a recipe means it can be crafted anywhere.
- string Value
- bool IsEmpty
- static implicit operator StationId(string value)
RecipeId
struct
Trimmed, case-insensitive recipe handle with implicit string conversion, so save files and unlock lists can hold plain slugs.
- string Value
- bool IsEmpty
DefaultStations
class
The eight built-in station ids: Workbench, Forge, ChemLab, AmmoPress, WeaponBench, SalvageTable, ArmorStation, FieldKit. Static readonly StationId values, not a closed set.
Surface
Authoring assets
RecipeDefinitionAsset
asset
The authoring asset for a recipe, created from Assets > Create > Tools > ZOA > Crafting > Recipe Definition. Captures identity, description, category, serialized ingredient and output lists, and the station, timing, and skill requirements. Extends DefinitionBase, so it is GUID-backed and shows up in the Workbench browsers.
- string RecipeId
- RecipeCategory Category
- IReadOnlyList<SerializedIngredient> Ingredients
- IReadOnlyList<SerializedOutput> Outputs
- string RequiredStationId
- float CraftTime
- string RequiredSkillId
- int RequiredSkillLevel
Usage
Examples
using ZOA.Crafting.Core.Contracts;
using ZOA.Crafting.Core.Definitions;
using ZOA.Crafting.Core.Models;
using ZOA.Crafting.Core.Runtime;
// The skill adapter is optional: pass null and every skill
// requirement is treated as satisfied.
ICraftingService crafting = new CraftingEngine(myInventoryAdapter, mySkillAdapter);
var ammo = new RecipeDefinitionBuilder("craft_9mm", "9mm Ammo x30")
.WithCategory(RecipeCategory.AmmoCraft)
.RequiresStation(DefaultStations.AmmoPress)
.AddIngredient("scrap_metal", 2)
.AddIngredient("gunpowder", 1)
.AddIngredient("reloading_die", 1, consumed: false) // catalyst
.AddOutput("ammo_9mm", 30)
.WithCraftTime(3f)
.RequiresSkill("gunsmithing", 2)
.Build();
crafting.RegisterRecipe(ammo);using ZOA.Crafting.Core.Models;
var station = DefaultStations.WeaponBench;
foreach (var recipe in crafting.GetRecipesForStation(station))
{
// Station-less recipes are included in this list by design.
CraftingValidation check = crafting.CanCraft("player.local", recipe.Id, station);
row.SetEnabled(check.IsValid);
row.tooltip = check.IsValid
? recipe.Description
: string.Join("\n", check.Issues);
}using ZOA.Crafting.Core.Models;
crafting.Crafted += (entityId, recipeId, result) =>
{
foreach (var output in result.ProducedItems)
Toast("Crafted " + output.ItemId + " x" + output.Quantity);
};
crafting.CraftFailed += (entityId, recipeId, result) =>
{
// Status is the branchable value; FailureReason is the joined text.
if (result.Status == CraftingResultStatus.WrongStation)
Toast("Find a bench first.");
else
Toast(result.FailureReason);
};
CraftingResult result = crafting.Craft("player.local", new RecipeId("craft_9mm"), DefaultStations.AmmoPress);
if (!result.IsSuccess)
Debug.Log("Craft refused: " + result.Status);using System.Collections.Generic;
using ZOA.Crafting.Core.Contracts;
// The shape a test fake takes, and the shape a real adapter
// keeps: four methods over string ids, nothing else.
public sealed class DictionaryCraftingInventory : ICraftingInventoryAdapter
{
private readonly Dictionary<string, Dictionary<string, int>> _bags = new();
public bool HasItem(string entityId, string itemId, int quantity) =>
_bags.TryGetValue(entityId, out var bag)
&& bag.TryGetValue(itemId, out var have)
&& have >= quantity;
public bool ConsumeItem(string entityId, string itemId, int quantity)
{
if (!HasItem(entityId, itemId, quantity)) return false;
_bags[entityId][itemId] -= quantity;
return true;
}
public bool AddItem(string entityId, string itemId, int quantity)
{
if (!_bags.TryGetValue(entityId, out var bag))
_bags[entityId] = bag = new Dictionary<string, int>();
bag.TryGetValue(itemId, out var have);
bag[itemId] = have + quantity;
return true;
}
// Unbounded storage: always room. A grid inventory would
// answer this against its real capacity rules.
public bool HasInventorySpace(string entityId, string itemId, int quantity) => true;
}Tooling
Editor tools
Crafting Workbench module
Tools > ZOA > Workbench > Open Workbench, then Crafting
Three capabilities under the Items and Economy workflow. Recipes browses, edits, validates, duplicates, and deletes RecipeDefinitionAsset files grouped by category. Stations Browser lists the eight built-in station ids with their intended roles. Overview explains the adapter architecture.
Recipe Wizard
Tools > ZOA > Advanced > Define > Items > Crafting > Recipe Wizard
A five-step guided flow: identity, ingredients, outputs, requirements, review. The menu entry routes into the Workbench's crafting module. Finishing emits a RecipeDefinitionAsset under Assets/ZOA/Generated/Recipes.
Read this
Notes and caveats
See also