Skip to content

Each bench fills its own chest

The base has a forge, an alchemy table and a cooking fire. Everything any of them makes lands in the same backpack, so the player spends the minute after every crafting session moving ingots to one chest and potions to another. The bench already knows where its output belongs. Nothing asks it.

Recipe

Systems required: Crafting. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: your benches queue crafts with a station tag, and the crafter owns a container with each name you route to. It installs itself into the service on enable and puts back what it found on disable. 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

ICraftingOutputRouter looks like it decides where an output goes. What it actually does is name a container, and the service decides whether to use that name:

if (OutputRouter.TryResolveContainer(ref ctx, in item, qty, out var routed) &&
    !string.IsNullOrWhiteSpace(routed))
{
    targetContainer = routed.Trim();
}

Two consequences fall straight out of those two lines.

A refusal and a blank answer are the same answer. Returning false, and returning true with null, both leave targetContainer as the service default. The interface describes them as different — true means "this router made a routing decision" — but no shipped caller can tell them apart. This recipe returns false for "not my bench", because that is the one a reader understands without going to look.

The name is trusted, not validated. It goes straight to the inventory adapter, and that is where the sharp edge is.

A route to a container the crafter does not own fails the craft as though the bag were full

The resolved name is handed to InventoryAdapter.TryGetContainer. A miss returns false out of output planning, and that surfaces as a no-space failure — NoSpacePreflight before the craft or NoSpaceAtDelivery after it.

So a typo in a container name does not report a routing problem. It reports a full bag, at that bench, every time, for as long as the typo exists — and emptying the backpack does not fix it, because the backpack was never the container being asked.

Route names must match containers the crafter actually has. This is the one thing to check first when a bench "stops working".

Never route on the quantity

The seam is handed quantity, and using it is the single most expensive mistake available here.

Batch crafting binary-searches for the largest affordable count

The search assumes routing is monotonic: if n outputs fit, then n-1 fit. A router that changes destination with size — "over fifty goes to the overflow chest" — breaks that assumption, and the batch size offered to the player is then wrong in either direction.

It cannot corrupt an inventory. Output delivery plans every add and commits atomically, so an over-estimate fails the craft cleanly rather than half-delivering it. What it does is lie about how much the player can make, which is worse to diagnose than a crash.

Route on the station, the recipe, or the item. Use quantity for logging, or for a decision that returns the same destination either way.

This class takes quantity as a parameter and never reads it. That is deliberate, and the probe suite asserts it: the answer is identical for 0, 1, 10_000 and int.MaxValue.

Installing is type-gated, and the refusal is a return value

There are two public doors into this seam and they do not accept the same things:

crafting.SetOutputRouter(this);              // MonoBehaviour only. Returns false otherwise.
crafting.Configure(inventory, router: any);  // Takes any implementation. No check.

SetOutputRouter installs nothing and returns false when the router is not a MonoBehaviour — and that bool is the only sign you get. A plain C# router assigned this way simply never routes, with nothing in the log.

That is why this recipe is a component. It is not scene furniture for its own sake; it is the shape the ordinary door accepts.

Being disabled does not stop a router routing

The service holds the router by reference and calls it through the interface. A disabled component is still a live object, so its TryResolveContainer keeps being called every craft.

OnDisable therefore uninstalls — and restores what it found rather than clearing, because SetOutputRouter(null) discards a router the project had configured. The service's readable OutputRouter property exists precisely so a component that swaps the router temporarily can put the old one back.

There is one case where it cannot: if the previous router came in through Configure and is not a component, the ordinary door refuses to take it back. The recipe clears and warns, on the grounds that losing a router loudly beats leaving the service pointing at a disabled object.

Station tags are matched case-insensitively

Everything else in Crafting that reads a station tag compares it OrdinalIgnoreCase — the scheduler's per-station caps, the station-filtered modifiers, the cooldown and level-gate validators. A router that matched exactly would be the only component in the system where Forge and forge are two different benches, so this one uses StringComparer.OrdinalIgnoreCase too, and trims before comparing.

What it does not do

  • A craft with no station tag never routes. There is nothing to key on, so it refuses and the service default applies. Tag the bench, or route on the recipe instead.
  • Blank rows are dropped at build time, not answered with a blank name. RouteCount shows how many survived, which is how an authoring mistake becomes visible.
  • The first row for a station wins. A second row for the same bench is a mistake, and quietly preferring the later one would hide it.
  • Runtime SetRoute edits do not survive the next OnEnable, which rebuilds from the serialized list. Author durable routes in the inspector; use SetRoute for the ones a run creates.

Drop it in

StationStash.cs
using System.Collections.Generic;

using RevGaming.RevFramework.Crafting.Abstractions;
using RevGaming.RevFramework.Crafting.Core;
using RevGaming.RevFramework.Crafting.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.StationStash
{
    /// <summary>
    /// Sends each bench's output to the container that bench feeds — the forge fills the weapon rack,
    /// the alchemy table fills the reagent chest — and leaves anything crafted away from a listed
    /// station on the service default.
    /// </summary>
    /// <remarks>
    /// <para><b>Route on the station, the recipe or the item. Never on the quantity.</b> Batch
    /// crafting finds the largest affordable count by binary search, and that search assumes routing
    /// is monotonic: if <i>n</i> outputs fit then <i>n-1</i> do. A router that diverts above a
    /// threshold — "over fifty goes to the overflow chest" — breaks the assumption, and the batch
    /// size offered to the player is then wrong in either direction. It cannot corrupt an inventory,
    /// because delivery plans every add and commits atomically, so an over-estimate fails the craft
    /// cleanly. It just quietly lies about how much you can make. <c>quantity</c> is a parameter here
    /// and is deliberately never read.</para>
    ///
    /// <para><b>A refusal and a blank answer are the same answer.</b> The service takes the routed
    /// name only when the call returns <c>true</c> <i>and</i> the name is not blank, so returning
    /// <c>false</c> and returning <c>true</c> with <c>null</c> both mean "use the default" — they are
    /// indistinguishable to the shipped caller, whatever the interface says about having made a
    /// decision. This class returns <c>false</c> for "not mine", because that is the one a reader
    /// understands without checking.</para>
    ///
    /// <para><b>The ordinary install door is type-gated, and it reports the refusal in a return value
    /// nothing is obliged to read.</b> <c>SetOutputRouter</c> installs nothing and returns
    /// <c>false</c> unless the router is a <see cref="MonoBehaviour"/>;
    /// <c>Configure(router:)</c> takes any implementation and does not check. So a plain C# router
    /// works through one public door and vanishes through the other. This class is a component, so
    /// the ordinary door works — and that is the reason it is one.</para>
    ///
    /// <para><b>A route to a container the owner does not have fails the craft as though the bag were
    /// full.</b> The resolved name goes to the inventory adapter, and a miss returns false out of
    /// output planning — which surfaces as a no-space failure, not a routing failure. A typo in a
    /// container name therefore reads to a player as "this bench is full", permanently, and nothing
    /// in the log says otherwise. Route names must match the containers the crafter actually owns.
    /// </para>
    ///
    /// <para><b>Installing is not the same as being enabled.</b> The service holds the router by
    /// reference and calls it through the interface, so a disabled component keeps routing. That is
    /// why <c>OnDisable</c> below uninstalls, and why it restores what it found rather than clearing:
    /// <c>SetOutputRouter(null)</c> discards a router the project configured, which is what the
    /// service's readable <c>OutputRouter</c> property exists to make avoidable.</para>
    ///
    /// <para>Station tags compare case-insensitively everywhere else in Crafting — the scheduler's
    /// per-station caps, the station-filtered modifiers, the cooldown and level-gate validators all
    /// use <c>OrdinalIgnoreCase</c> — so this map does too. A bench tagged <c>Forge</c> matches a
    /// route written <c>forge</c>.</para>
    ///
    /// <para>Two limits, stated rather than hidden. A craft with no station tag never routes here,
    /// because there is nothing to key on; give the bench a tag or use a different seam. And a route
    /// whose container is blank is dropped at build time rather than answered with a blank name,
    /// since the caller would ignore it anyway and a dropped route is visible in the count.</para>
    /// </remarks>
    [AddComponentMenu("RevFramework/Cookbook/Station Stash")]
    public sealed class StationStash : MonoBehaviour, ICraftingOutputRouter
    {
        /// <summary>One bench's destination: outputs crafted at <see cref="station"/> go to <see cref="container"/>.</summary>
        [System.Serializable]
        public struct Route
        {
            [Tooltip("Station tag, matched case-insensitively against the tag the craft was queued with.")]
            public string station;

            [Tooltip("Container name on the crafter. Must be one they actually own, or the craft fails as 'no space'.")]
            public string container;
        }

        [Tooltip("The crafting service to install into. Left empty, this component routes nothing and " +
                 "says so once on enable.")]
        [SerializeField] private CraftingService crafting;

        [Tooltip("Station-to-container routes. The first entry for a station wins; blanks are dropped.")]
        [SerializeField] private Route[] routes;

        private readonly Dictionary<string, string> _byStation =
            new(System.StringComparer.OrdinalIgnoreCase);

        private ICraftingOutputRouter _previous;
        private bool _installed;
        private bool _warnedNoService;

        private void OnEnable()
        {
            Rebuild();

            if (!crafting)
            {
                // Said out loud, because this failure has no other symptom. Without a service there is
                // nothing to install into, every craft goes to the service default, and the benches
                // look like they are working -- they are just all filling the same bag. Latched, so a
                // half-wired scene does not warn on every enable.
                if (!_warnedNoService)
                {
                    _warnedNoService = true;
                    Debug.LogWarning(
                        $"[Cookbook] '{name}' has no {nameof(CraftingService)} assigned, so it never " +
                        "installs itself and every craft goes to the service default. Assign one.", this);
                }

                return;
            }

            // Captured so OnDisable can put back what was here. Null is a legitimate value to
            // restore -- it means "no router, use the default" -- and is not the same as "unknown".
            _previous = crafting.OutputRouter;

            // Always true for a component; the return value is only false for a plain C# router,
            // which this door silently refuses. Kept as an assignment rather than a discarded call
            // so the uninstall below can tell "I installed" from "I never did".
            _installed = crafting.SetOutputRouter(this);
        }

        private void OnDisable()
        {
            if (!_installed) return;
            _installed = false;

            // Fake-null: on teardown the service may already be destroyed, and touching it then is
            // the error this check exists for.
            if (!crafting) return;

            // Something else took the slot while this was enabled. Putting _previous back now would
            // undo their install, so leave it alone -- last writer wins, and it was not us.
            if (!ReferenceEquals(crafting.OutputRouter, this)) return;

            if (crafting.SetOutputRouter(_previous)) return;

            // The router that was here came in through Configure and is not a component, so the
            // ordinary door will not take it back. Clearing loses it; leaving the service pointing
            // at a disabled component that still routes every craft is worse.
            crafting.SetOutputRouter(null);
            Debug.LogWarning(
                "[StationStash] The previous output router was not a MonoBehaviour and could not be " +
                "restored through SetOutputRouter, so routing has been cleared to the service default. " +
                "Re-install it with CraftingService.Configure(router: ...).", this);
        }

        /// <summary>
        /// Adds or replaces one route at runtime. A blank container removes the station's route.
        /// </summary>
        /// <remarks>
        /// Runtime edits do not survive the next <c>OnEnable</c>, which rebuilds from the serialized
        /// list. Author durable routes in the inspector and use this for the ones a run creates.
        /// </remarks>
        public void SetRoute(string station, string container)
        {
            if (string.IsNullOrWhiteSpace(station)) return;

            string key = station.Trim();

            if (string.IsNullOrWhiteSpace(container)) _byStation.Remove(key);
            else _byStation[key] = container.Trim();
        }

        /// <summary>Drops every route. The service default then applies to every craft.</summary>
        public void ClearRoutes() => _byStation.Clear();

        /// <summary>How many routes are live, after blanks and duplicates were dropped.</summary>
        public int RouteCount => _byStation.Count;

        /// <summary>
        /// Answers with the container this craft's station feeds, or refuses so the service default
        /// applies.
        /// </summary>
        /// <param name="ctx">Craft context. Only <c>stationTag</c> is read.</param>
        /// <param name="item">The output being added. Not read — routing is per bench, not per item.</param>
        /// <param name="quantity">Deliberately unused. See the batch-preview note in the remarks.</param>
        /// <param name="containerName">The station's container, or <c>null</c> when this refuses.</param>
        /// <returns><c>true</c> only when a route matched.</returns>
        public bool TryResolveContainer(ref CraftContext ctx, in ItemRef item, int quantity, out string containerName)
        {
            string station = ctx.stationTag;

            // TryGetValue throws on a null key, and the service catches everything this throws and
            // falls back to the default -- so an unguarded null here would be a silent no-op that
            // looks exactly like a working router with no matching route.
            if (string.IsNullOrWhiteSpace(station))
            {
                containerName = null;
                return false;
            }

            if (_byStation.TryGetValue(station.Trim(), out string target))
            {
                containerName = target;
                return true;
            }

            containerName = null;
            return false;
        }

        /// <summary>Rebuilds the lookup from the serialized routes, dropping blanks and duplicates.</summary>
        private void Rebuild()
        {
            _byStation.Clear();

            if (routes == null) return;

            for (int i = 0; i < routes.Length; i++)
            {
                string station = routes[i].station;
                string container = routes[i].container;

                if (string.IsNullOrWhiteSpace(station) || string.IsNullOrWhiteSpace(container)) continue;

                string key = station.Trim();

                // First entry wins. A second row for the same bench is an authoring mistake, and
                // silently preferring the later one hides it; RouteCount shows the shortfall.
                if (!_byStation.ContainsKey(key)) _byStation.Add(key, container.Trim());
            }
        }
    }
}

Wiring it up

  1. Put the component anywhere in the scene and assign the CraftingService in crafting. Left empty it installs nothing, routes nothing, and warns once on enable rather than letting every craft go to the service default in silence.
  2. Add one route per bench. station is the tag the craft is queued with, matched case-insensitively and trimmed; container is a container name the crafter owns, not the bench.
  3. Make sure the crafts actually carry a station tag. The tag reaches the router through CraftRequest.stationTag, which is what the shipped benches pass — CraftingWorkbench2D and CraftingWorkbench3D both forward their stationTagFilter field into the request. EnqueueOne and EnqueueBatch take no station tag at all, so a craft queued through either of those routes arrives here with nothing to key on and falls through to the service default.
  4. Check RouteCount after enable if a route seems not to fire. It counts what survived the blank and duplicate filtering, so a shortfall against the inspector list is an authoring mistake rather than a routing one.
  5. Use SetRoute and ClearRoutes for routes a run creates — a chest the player builds, a bench they unlock. They do not survive the next OnEnable, which rebuilds from the serialized list.