Skip to content

A craft that cleans up after itself

The same authored recipe, charged through Economy's transaction instead of the workbench — so a leg that fails after another succeeded is compensated, a retry carrying the same request id is replayed rather than charged again, and the price can be in two currencies.

Recipe

Systems required: Crafting, Economy, Currency. The wiring below also uses Inventory, because that is the bootstrap that hands back a real IItemStore. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. No scene and no bench — though the services it binds to want a live currency service, a live inventory service, a container and an item resolver. Public API only. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

The part that is not obvious

There are two crafting implementations in this framework, and nothing connects them.

The Crafting system is the one you know: benches, timers, validators, modifiers, output routing, progression. Economy has its own ICraftingService.Craft — a single call that takes money, removes ingredients, delivers a result, compensates the legs that had already succeeded when a later one fails, and replays a repeated request id as a no-op.

It knows nothing about RecipeCore, and RecipeCore knows nothing about it. This recipe is the twenty lines in the middle.

What that buys you is compensation, and it is not a small thing

Instant crafting written by hand has one classic bug: the ingredients come out, the currency goes, and then delivery fails on a full bag — so the player has paid and received nothing.

Economy sets about undoing each completed leg when a later one fails. That is precisely the case a bespoke implementation gets wrong on its first pass, and it is free here — within the limits of the next box, which are worth reading before you lean on it.

Compensation, not a database transaction

ICraftingService promises that it may attempt compensation, and the utility behind it describes itself as best-effort by design. A compensating leg can be refused in its own right: a currency cap or authority can reject the refund, and putting an ingredient back goes through the item resolver you handed the bootstrap, so an ingredient guid that resolver does not know — or knows in a different casing — cannot be returned to the bag even though removing it succeeded.

When that happens Economy reports on CompensationFailureReport.Failed — public, in Core, present in every package — and the failure TryCraft returns says nothing about it. If atomicity is the reason you are here, subscribe to that event, and make sure the resolver knows your ingredient ids as well as your output ids, in the casing the recipe uses.

It answers a limitation of the recipe format from outside it

RecipeCore.Currency is a single CurrencyCost — one id, one amount — so "50 gold and 3 shards" cannot be authored on a recipe at all. PriceBundle carries as many money lines as you like.

The extra costs on this component are added to the recipe's own, which makes a multi-currency craft expressible today without changing what the recipe format means.

With one honest cost

A second price living on a component means one craft's authoring is split across two places, and nothing keeps them in step. That is a real drawback and worth weighing against building the whole thing on the bench instead.

Two recipe fields are refused rather than ignored

More than one output cannot go through this call

ICraftingService.Craft takes a single ItemLine result. Crafting the first output and dropping the rest would silently destroy authored content, so a multi-output recipe is refused outright.

Note the asymmetry, because it is the reason. The cost side of that call is a bundle — many money lines, many item lines. The result side is one line. Nothing about the shape of the call says the two sides should differ, and a recipe format that has always allowed several outputs meets an economy call that allows one.

The limit is the call, not the framework

IShopService.Buy comes out of the same bootstrap, runs against the same ledger and store, and takes an IReadOnlyList<ItemLine> to deliver — with the same preflight, the same compensation and the same whole-transaction idempotency, plus requestId as a first-class parameter rather than something you encode into a source string.

So a two-output atomic craft is expressible today; you just write it as a purchase. The cost is telemetry: Buy hardcodes its own reason codes, so the operation reads as a shop transaction rather than a craft. This recipe chose Craft for the reason codes and refuses the case it cannot serve, rather than quietly dropping an output.

A craft time cannot be honoured either

This path is instantaneous and has nowhere to put a delay. Honouring the recipe's timer is impossible; swallowing it changes what the author wrote. So it is refused.

IsUsable reports both up front. The static overload takes a recipe, so a validation pass can sweep a recipe library and tell you which recipes this path would have to turn away — rather than each one surfacing as a failed craft in front of a player.

The request id is the whole of the idempotency

A repeated (crafter, request id) replays the first successful result as a no-op. Economy keys that replay on the exact string you pass, so a different id is a different craft — which means an id minted per call, or left null, buys nothing. That is why TryCraft passes yours through untouched and mints nothing in its place.

Mint the id where the craft begins, not where the button fires

One value per logical craft — created when the confirm dialog opens, reused by every click that follows. That is what makes a double-clicked button safe, and it is work only the caller can do.

The opposite mistake is a constant id: every craft after the first then silently does nothing while reporting success. An empty or whitespace id is treated as no id at all, and ids are capped at 128 characters, so keep them short and unique within that.

The replay window is smaller than it sounds

It lives on the ICraftingService instance, holds the 32 most recent successful crafts per owner, and is not persisted. It does not survive a domain reload, a save/restore, or a second call to BuildForPlayer — build the services once and share them across your vendors, or each one gets its own window. Treat it as protection against a double-click, not against a retry across a save.

Fresh lists per craft, deliberately

PriceBundle documents that it does not copy or freeze the lists it is handed. A pooled list reused on the next craft would be mutating a bundle a consumer may still be holding.

A craft is a button press, not a per-frame cost, so the allocation is the right trade.

Drop it in

AtomicCraft.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Crafting.Core;

using RevGaming.RevFramework.Economy;
using RevGaming.RevFramework.Economy.Abstractions;

using UnityEngine;
using UnityEngine.Serialization;

namespace RevGaming.RevFramework.Cookbook.AtomicCraft
{
    /// <summary>
    /// Runs an authored crafting recipe through Economy's transaction instead of the workbench — so a
    /// leg that fails after another has succeeded is compensated, a retry carrying the same request id
    /// is replayed instead of charged again, and the craft can cost more than one currency.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Crafting</b>, <b>Economy</b>, <b>Currency</b> — the only implementation of
    /// <c>ICraftingService</c> is internal and reaches you through a bootstrap gated on Currency. The
    /// wiring also uses <b>Inventory</b>, because that is the bootstrap that hands back a real
    /// <see cref="IItemStore"/>. Complete only. Public API only.</para>
    ///
    /// <para><b>There are two crafting implementations in this framework and nothing connects them.</b>
    /// Crafting has the benches, timers, validators, modifiers and progression. Economy has its own
    /// <c>ICraftingService.Craft</c>: one call that takes money, removes ingredients, delivers a result
    /// and compensates the legs that already succeeded when a later one fails. Neither knows the other
    /// exists. This class is the twenty lines in the middle, and it uses the recipe asset as a
    /// <i>price list</i> — which is the honest description of what it does.</para>
    ///
    /// <para><b>Compensation is an attempt, not a transaction, and this is the trap.</b>
    /// <c>ICraftingService</c> promises only that it <i>may attempt</i> to undo completed legs, and a
    /// compensating leg can itself be refused — an inventory authority, a currency cap, an item guid
    /// the store's resolver does not know. Economy reports that on
    /// <c>CompensationFailureReport.Failed</c> (in Core, so present in every package) and the failure
    /// this call returns says nothing about it. Subscribe to that event if a stuck half-craft matters.</para>
    ///
    /// <para><b>Going around the crafting service means going around all of it:</b>
    /// <c>ICraftingValidator</c>, <c>ICraftingModifier</c>, level gates, station rules, output routing,
    /// queueing, the crafting events and XP. None of them run here. This is for the instant case — a
    /// vendor who crafts for you, an upgrade counter, a shrine that trades three things for one — and
    /// not a replacement for the workbench.</para>
    ///
    /// <para><b>Two recipe fields are refused rather than ignored,</b> and
    /// <see cref="IsUsable(RecipeCore, out string)"/> reports both up front so a tool can sweep a
    /// library rather than finding out at the counter. <c>Craft</c> delivers a single
    /// <see cref="ItemLine"/>, so a multi-output recipe is refused rather than have the extras silently
    /// destroyed — a limit of the call this class chose, not of Economy: <c>IShopService.Buy</c> takes a
    /// list on the same ledger and store, with the same preflight, compensation and idempotency, at the
    /// cost of telemetering as a shop rather than a craft. A craft time is refused for a different
    /// reason: this path is instantaneous, so honouring it is impossible and swallowing it changes what
    /// the author wrote.</para>
    ///
    /// <para><b>Idempotency lives entirely in the request id</b> — see the parameter on
    /// <see cref="TryCraft"/> for how to mint one. The window it is checked against is smaller than it
    /// sounds: it lives on the <c>ICraftingService</c> instance, holds the 32 most recent successful
    /// crafts per owner, and does not survive a reload or a rebuild of the services. Build them once
    /// and share them, and expect it to cover a double-click, not a retry across a save.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class AtomicCraft : MonoBehaviour
    {
        /// <summary>
        /// One extra currency line added to a recipe's own cost.
        /// </summary>
        /// <remarks>
        /// Exists because <c>RecipeCore.Currency</c> holds exactly one cost and a
        /// <see cref="PriceBundle"/> does not. Nothing keeps these in step with the recipe asset — a
        /// second price living on a component is a real cost of this approach, and worth stating rather
        /// than hiding.
        /// </remarks>
        [Serializable]
        private struct ExtraCost
        {
            [Tooltip("Currency id, matching the ids your currency service knows.")]
            public string currencyId;

            [Tooltip("Amount charged per craft, on top of whatever the recipe itself costs.")]
            [Min(0)] public long amount;
        }

        [Header("Recipe")]
        [Tooltip("Recipe to craft: a RecipeCore asset, or an authoring asset that resolves to one " +
                 "(RecipeDefinition, or your own registered wrapper).")]
        // Renamed from `recipe` when the type widened from RecipeCore to ScriptableObject, so that
        // projects authoring with RecipeDefinition can assign one at all. Unity keys serialized data
        // on the field NAME, so without this attribute the rename silently empties the slot in every
        // scene and prefab that already had it filled -- and an empty slot here refuses every craft
        // with "No recipe assigned", which reads like a bug rather than a migration. RecipeCore is a
        // ScriptableObject, so an existing reference deserializes into the wider field unchanged.
        [FormerlySerializedAs("recipe")]
        [SerializeField] private ScriptableObject recipeAsset;

        [Tooltip("Extra currency costs added to the recipe's own. This is how a craft costs gold AND " +
                 "shards, which a recipe asset cannot express by itself.")]
        [SerializeField] private ExtraCost[] extraCosts = Array.Empty<ExtraCost>();

        [Header("Telemetry")]
        [Tooltip("Vendor id recorded against the currency operations. Optional; for logs and audit only.")]
        [SerializeField] private string vendorId;

        private ICraftingService _crafting;
        private IValueLedger _ledger;
        private IItemStore _store;

        /// <summary>
        /// Gives this component the Economy services it charges and delivers through.
        /// </summary>
        /// <remarks>
        /// All three come out of <c>EconomyInventoryBootstrap.BuildForPlayer</c> together, so this is
        /// one line where you already build them. <c>EconomyBootstrap.BuildForPlayer</c> is not an
        /// alternative here: it documents that the item store it returns is always <c>null</c>, so
        /// bring your own <see cref="IItemStore"/> if that is the route you are on. They are interfaces,
        /// so they cannot come from the Inspector. Build them once and share them — the idempotency
        /// window lives on the crafting service instance.
        /// </remarks>
        public void Bind(ICraftingService crafting, IValueLedger ledger, IItemStore store)
        {
            _crafting = crafting;
            _ledger = ledger;
            _store = store;
        }

        /// <summary>
        /// Whether the assigned recipe can be crafted through this path at all.
        /// </summary>
        /// <param name="reason">Why not, when the answer is false.</param>
        /// <remarks>
        /// A data question, not a runtime one — it does not look at the crafter, the wallet or the bag.
        /// </remarks>
        public bool IsUsable(out string reason) => TryResolveRecipe(out _, out reason);

        /// <summary>
        /// Whether any recipe can be crafted through this path, without a component to hold it.
        /// </summary>
        /// <param name="recipe">Recipe to test.</param>
        /// <param name="reason">Why not, when the answer is false.</param>
        /// <remarks>
        /// Static so a validation pass can sweep a recipe library and report the ones this path would
        /// have to refuse, rather than each one surfacing as a failed craft in front of a player. The
        /// instance overload is this method applied to the assigned asset.
        /// </remarks>
        public static bool IsUsable(RecipeCore recipe, out string reason)
        {
            if (!recipe)
            {
                reason = "No recipe.";
                return false;
            }

            if (!recipe.IsValid)
            {
                reason = "Recipe is not valid: it needs at least one input and one output, with ids and positive quantities.";
                return false;
            }

            if (recipe.Outputs.Count != 1)
            {
                reason = $"Recipe has {recipe.Outputs.Count} outputs and ICraftingService.Craft delivers exactly one. " +
                         "Crafting the first and dropping the rest would silently destroy authored content; " +
                         "IShopService.Buy takes a list of deliveries if you need all of them atomically.";
                return false;
            }

            if (recipe.CraftTimeSeconds > 0f)
            {
                reason = $"Recipe takes {recipe.CraftTimeSeconds}s and this path is instantaneous. " +
                         "Use the crafting service for timed recipes; it is the thing that owns the queue.";
                return false;
            }

            reason = null;
            return true;
        }

        /// <summary>
        /// Charges for the recipe and delivers its output.
        /// </summary>
        /// <param name="crafter">The wallet that pays, and the bucket the request id is remembered
        /// against. It is <i>not</i> the bag: ingredients leave, and the result lands in, whichever
        /// owner the <see cref="IItemStore"/> was built for. Pass that same GameObject unless you mean
        /// to move value between two owners.</param>
        /// <param name="requestId">Idempotency key, and the only thing that provides one. A repeat of
        /// the same value replays the first success instead of charging again; null, empty or
        /// whitespace means no protection at all. Mint it once per logical craft, keep it under 128
        /// characters — longer ids are truncated and can collide — and never make it constant, which
        /// turns every craft after the first into a silent no-op.</param>
        /// <returns>
        /// <see cref="EcoOpResult.Ok"/> when the craft completed, or when a repeated request id replayed
        /// an earlier success. A failure otherwise, after Economy has attempted to undo the legs that
        /// had already succeeded — an attempt, not a guarantee; see the class remarks for the case where
        /// it is refused.
        /// </returns>
        public EcoOpResult TryCraft(GameObject crafter, string requestId = null)
        {
            if (_crafting == null || _ledger == null || _store == null)
                return EcoOpResult.ServiceMissing(
                    "AtomicCraft needs a crafting service, ledger and item store. Call Bind with all " +
                    "three; note that EconomyBootstrap always returns a null store.");

            if (!crafter)
                return EcoOpResult.InvalidArgs("No crafter.");

            if (!TryResolveRecipe(out RecipeCore recipe, out string reason))
                return EcoOpResult.InvalidArgs(reason);

            ItemRef output = recipe.Outputs[0];
            var result = new ItemLine(output.guid, output.quantity);

            // Fresh lists per craft, deliberately. PriceBundle documents that it does not copy or freeze
            // what it is handed, so a pooled list reused on the next craft would be mutating a bundle a
            // consumer may still be holding. A craft is a button press, not a per-frame cost.
            var money = new List<ChargeLine>();
            var items = new List<ItemLine>(recipe.Inputs.Count);

            CurrencyCost currency = recipe.Currency;
            if (!string.IsNullOrWhiteSpace(currency.currencyId) && currency.amountPerCraft > 0)
                money.Add(new ChargeLine(currency.currencyId, currency.amountPerCraft));

            for (int i = 0; i < extraCosts.Length; i++)
            {
                ExtraCost extra = extraCosts[i];

                // Skipped rather than refused: a blank row in an Inspector array is someone part-way
                // through authoring, not a broken craft.
                if (string.IsNullOrWhiteSpace(extra.currencyId) || extra.amount <= 0)
                    continue;

                money.Add(new ChargeLine(extra.currencyId, extra.amount));
            }

            for (int i = 0; i < recipe.Inputs.Count; i++)
            {
                ItemRef input = recipe.Inputs[i];
                items.Add(new ItemLine(input.guid, input.quantity));
            }

            var cost = new PriceBundle(money, items);

            // The caller's request id is passed through untouched, and nothing is minted in its place.
            // Economy dedups on the exact string, so an id invented here would differ on every call --
            // no protection, and a wasted slot in a window that only holds 32 per owner. Whitespace is
            // sanitised away upstream and lands as no id at all, which is the same as null.
            string source = EcoSource.Build(vendorId, requestId);

            return _crafting.Craft(crafter, _ledger, _store, cost, result, EcoReasons.Craft, source);
        }

        // RecipeResolve is the framework's supported seam for this: a RecipeCore asset comes back as
        // itself, and an authoring asset comes back converted, so the field does not have to be the
        // format RecipeCore's own remarks tell you not to author by hand.
        private bool TryResolveRecipe(out RecipeCore recipe, out string reason)
        {
            recipe = null;

            if (!recipeAsset)
            {
                reason = "No recipe assigned.";
                return false;
            }

            if (!RecipeResolve.TryResolve(recipeAsset, out recipe))
            {
                reason = $"'{recipeAsset.name}' is not a recipe this build can resolve. Assign a " +
                         "RecipeCore, or an authoring asset whose converter is registered.";
                return false;
            }

            return IsUsable(recipe, out reason);
        }
    }
}

Wiring it up

  1. Put the component wherever the instant craft happens — a vendor, an upgrade counter, a shrine.
  2. Assign the recipe, and any extra currency lines the recipe format cannot hold. The field takes a RecipeCore asset or an authoring asset that resolves to one, so a RecipeDefinition drops straight in — the component resolves it through RecipeResolve.
  3. Call Bind with the crafting service, ledger and store from EconomyInventoryBootstrap.BuildForPlayer. All three come out of it together — and build it once, sharing the result with every component that binds, because the replay window belongs to that crafting service instance. EconomyBootstrap.BuildForPlayer will not do: its item store is documented as always null, so take that route only if you are supplying your own IItemStore.
  4. Call TryCraft(crafter, requestId) and read the result. Pass the same requestId for a retry of the same logical craft; without one there is no replay protection.
  5. Keep in mind that crafter picks the wallet and the replay bucket only. The bag is whichever owner the IItemStore was built for, so pass that same GameObject unless you deliberately mean one character to pay for another's ingredients.

What it deliberately does not do

It is not a replacement for the workbench. Going around the crafting service means going around everything the crafting service does: ICraftingValidator, ICraftingModifier, level gates, station rules, output routing, queueing, the crafting events, and XP. None of them run here.

So the recipe asset is being used as a price list. That is the honest description of what this does, and it is the right shape for the instant, transactional case — a vendor who crafts for you, an upgrade counter, a shrine that trades three things for one. It is the wrong shape for anything with a bench in front of it.

It does not enforce a station tag. A recipe restricted to a forge will craft here anyway, because there is no bench in this path to check it against. If that matters, check StationTag yourself before calling.

It does not award XP. XpPerCraft is reported by the crafting service as an OnCraftXp event when the service has XP enabled — nothing in the framework stores or applies it, that is your game's job — and the crafting service is not involved in this path at all, so no such event is raised here.

It does not tell you when compensation failed. TryCraft returns the code for the leg that failed, not for the undo. Subscribe to CompensationFailureReport.Failed if you want to hear about the case where the refund or the put-back was itself refused.