Gameplaycom.zoa.crafting · v0.1.0

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.

The Recipes browser in the Crafting Workbench module.

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.

The Recipe Wizard on the Ingredients step.

Setup

Workflow

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

Bootstrapping the engine and registering a recipecsharp
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);
The reloading die must be in the inventory for validation to pass and is still there afterwards. Only ingredients flagged as consumed are destroyed.
Gating a bench button on CanCraftcsharp
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);
}
Issues is the full list, not the first failure, so a player short on two materials sees both at once.
Crafting and handling the outcomecsharp
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);
A refused craft consumes nothing. Validation runs in full before the first ConsumeItem call.
A minimal inventory adaptercsharp
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