Crafts that fall on the floor instead of vanishing¶
A job that finishes into a full bag drops its output on the ground, rather than evaporating.
Recipe
Systems required: Crafting, Loot and Pickups. Pickups is a hard requirement here, not a flavour note: the only thing in the framework that puts a physical object on the ground is the loot service's pickup spawner. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists, plus the two things that spawner needs — a LootPickupSpawner on the LootService and a pickup prefab. 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¶
The problem is real and it is quiet. A job that completes while the crafter has no room fails with CraftFailReason.NoSpaceAtDelivery. Inputs may be refunded depending on the service's policy, but the output is simply never created — so a player who queued a long craft, went shopping, and came back full loses the result and is told nothing beyond a failure event most projects never subscribe to.
The seam that looks right is the wrong one
ICraftingOutputRouter exists precisely to decide where an output goes. It answers with a container name — and in the shipped inventory integration there is no container called the floor.
So routing cannot express "put it on the ground", however much the name suggests otherwise. (A custom ICraftingInventoryAdapter is free to decide that some name means the world; the routing seam itself still only ever answers with the name.) Same shape as the loot modifier that adjusts what was won rather than what could drop: the seam is real, and it is pointed at a different question.
So the composition is the failure event plus a table built at runtime. Listen for the delivery failure, build a one-entry-per-output table with LootTable.Create, and roll it at the bench with an explicit spawnAt. container is left null, which resolves to the loot service's own DefaultContainer — not to "no container": the spill is offered to that container first, and reaches the ground through the service's pickup spawner only when the container refuses it. spawnAt chooses where that spawn lands, nothing more.
That fallback is the whole mechanism, and it is why the spawner is mandatory. It does not need the service's Spawn Pickups toggle — with the toggle off and a spawner bound, only what will not fit goes on the floor, which is the better behaviour for this recipe.
Nothing on the LootService reports whether a spawner is bound, so this cannot be checked up front. What it can do is check afterwards: the returned LootResult says what the table produced, not what landed, so the recipe subscribes to LootService.Undelivered for the duration of its own roll and warns when the spill reached neither the container nor the ground. A recipe whose entire purpose is not losing the player's goods should not be the one component that assumes delivery worked.
Only the delivery failures — not every failure
A craft refused at enqueue never consumed anything and has nothing to spill. Spilling for those would mint items out of a refusal.
Four reasons mean "it was made and had nowhere to go": NoSpaceAtDelivery, NoInventoryAtDelivery, and their zero-duration twins NoSpaceImmediate and NoInventoryImmediate. The twins are the same post-accept state — inputs consumed, completion marked, output never created — and zero craft time is the authoring default, so leaving them out would drop exactly the failures a default-authored recipe hits. All four are handled; every other reason goes to the rejection events (OnPreflightRejected / OnJobRejected) and never reaches this one.
Batches spill as batches
A job of five that fails delivery lost five outputs, so quantities are multiplied by batchCount. Spilling one is the bug that looks like it works right up until somebody crafts in bulk.
chance01 is 1 — these are not odds. The items were made and had nowhere to go, so anything less than certainty would be deleting part of a craft the player already paid for. The table is a delivery mechanism here, not a random one.
That certainty is certainty at the table. The spill is a real loot roll, so every ILootModifier above the crafter in the hierarchy runs on it, with no opt-out on this path — a luck component scales it, a pity upgrade can replace it outright, and a modifier that zeroes an award has it dropped in silence. If the spill must be exactly what the craft made, keep loot modifiers off the crafter's parent chain.
One component per station tag, or one for the whole scene. The subscription is to the service, not to a bench, and a job carries no bench reference — only a station tag. Leave stationTag empty and this handles every craft in the scene, which means a spill point set on it is where everyone's failed craft lands. Two components matching the same job spill it twice.
The derived table is destroyed, with the play-mode branch. LootTable.Create returns a real ScriptableObject marked HideFlags.DontSave, which Unity will not collect at all — not on a scene load, not by UnloadUnusedAssets. It leaks for the process lifetime, once per failed craft, until you destroy it. And Destroy refuses to run outside play mode, so an edit-mode caller would log an error and leak the very object the cleanup exists to release.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Crafting.Abstractions;
using RevGaming.RevFramework.Crafting.Core;
using RevGaming.RevFramework.Crafting.UnityIntegration;
using RevGaming.RevFramework.Loot.Core;
using RevGaming.RevFramework.Loot.UnityIntegration;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.SpilledCraft
{
/// <summary>
/// A craft that finishes into a full bag drops on the floor instead of evaporating.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Crafting</b>, <b>Loot</b> and <b>Pickups</b>. Pickups is a hard requirement, not a flavour
/// note: the only thing in the framework that puts a physical object on the ground is the loot
/// service's pickup spawner, so a <c>LootPickupSpawner</c> must be assigned to the LootService and
/// its prefab must carry a <c>LootPickupPayload</c>. Without that pair, this class runs, logs, and
/// puts nothing on the floor.</para>
///
/// <para><b>The problem is real and quiet.</b> A job that completes while the crafter has no room
/// fails with <c>CraftFailReason.NoSpaceAtDelivery</c>. The inputs may be refunded depending on the
/// service's policy, but the <i>output</i> is simply not created — so a player who queued a long
/// craft, went shopping, and came back full loses the result and is told nothing beyond a failure
/// event most projects never subscribe to.</para>
///
/// <para><b>The seam that looks right is the wrong one.</b>
/// <see cref="ICraftingOutputRouter"/> exists precisely to decide where an output goes — and it
/// answers with a <i>container name</i>. There is no container called the floor in the shipped
/// inventory integration, so routing cannot express "put it on the ground", however much its name
/// suggests otherwise. (A custom <see cref="ICraftingInventoryAdapter"/> could define what such a
/// name means; the routing seam itself still only ever answers with the name.) Same shape as the
/// loot modifier that adjusts what was won rather than what could drop: the seam is real, and it is
/// pointed at a different question.</para>
///
/// <para><b>So the composition is the failure event plus a table built at runtime.</b> The recipe
/// listens for the delivery failure, builds a one-entry-per-output table with
/// <see cref="LootTable.Create"/>, and rolls it at the bench with an explicit spawn position.
/// <c>container</c> is left null, which resolves to the service's own <c>DefaultContainer</c> — not
/// to "no container": the spill is offered to that container first and reaches the ground through
/// the pickup spawner only when the container refuses it. <c>spawnAt</c> chooses where that spawn
/// lands, nothing more.</para>
///
/// <para><b>Only the delivery failures, not every failure.</b> A craft refused at enqueue never
/// consumed anything and has nothing to spill; spilling for those would mint items out of a refusal.
/// The four reasons that mean "it was made and had nowhere to go" are
/// <see cref="CraftFailReason.NoSpaceAtDelivery"/>,
/// <see cref="CraftFailReason.NoInventoryAtDelivery"/> and their zero-duration twins
/// <see cref="CraftFailReason.NoSpaceImmediate"/> and
/// <see cref="CraftFailReason.NoInventoryImmediate"/> — same consumed inputs, same uncreated output,
/// and instant is the authoring default. All four are handled; every other reason reaches the
/// rejection events (<c>OnPreflightRejected</c> / <c>OnJobRejected</c>) and never arrives here.</para>
///
/// <para><b>Batches spill as batches.</b> A job of five that fails delivery lost five outputs, so the
/// quantities are multiplied by <c>batchCount</c>. Spilling one is the bug that looks like it works
/// right up until somebody crafts in bulk.</para>
///
/// <para><b>The spill is a real loot roll, so it runs the owner's loot modifiers.</b> Every
/// <c>ILootModifier</c> above the crafter in the hierarchy gets to scale, replace or zero the
/// awards, with no opt-out on this path — a luck component or a pity upgrade will change what a
/// failed craft returns. Keep loot modifiers off the crafter's parent chain if the spill must be
/// exactly what the craft made.</para>
///
/// <para><b>One component per station tag, or one for the whole scene.</b> The subscription is to
/// the service, not to a bench, and a job carries no bench reference — only a station tag. Leave
/// <c>stationTag</c> empty and this handles every craft in the scene, which means a spill point set
/// on it is where <i>everyone's</i> failed craft lands. Two components matching the same job spill
/// it twice.</para>
///
/// <para><b>Do not pair this with refunded inputs.</b>
/// <c>CraftingService.RefundInputsOnDeliveryFail</c> hands the inputs back on exactly the failures
/// spilled for here, so with both on the player keeps the inputs and collects the outputs from one
/// craft, repeatably. It defaults off; this warns once when it is not.</para>
///
/// <para><b>The derived table is destroyed, with the play-mode branch.</b>
/// <see cref="LootTable.Create"/> returns a real <see cref="ScriptableObject"/> marked
/// <see cref="HideFlags.DontSave"/>, which Unity will not collect at all — not on a scene load, not
/// by <c>UnloadUnusedAssets</c>. It leaks for the process lifetime, once per failed craft, until it
/// is destroyed. <see cref="Object.Destroy(Object)"/> refuses to run outside play mode, so an
/// edit-mode caller would log an error and leak the very object the cleanup exists to release.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class SpilledCraft : MonoBehaviour
{
[Tooltip("Crafting service to listen to. Leave empty to find one in the scene on enable.")]
[SerializeField] private CraftingService crafting;
[Tooltip("Loot service that spawns the dropped outputs. Leave empty to find one in the scene.")]
[SerializeField] private LootService loot;
[Tooltip("Only spill for jobs carrying this station tag. Leave empty to handle every craft.")]
[SerializeField] private string stationTag;
[Tooltip("Where the outputs land. Leave empty to drop at the crafter's own position.")]
[SerializeField] private Transform spillPoint;
// Setup warnings are worth saying once, not once per enable -- and each condition needs its
// OWN latch. A single shared flag looks tidier and silently loses the second warning: enable
// once with no service (latched), assign one, re-enable with refunds on, and the double-pay
// warning below never fires. The two conditions are independent, so the latches are too.
private bool _warnedNoService;
private bool _warnedRefund;
private void OnEnable()
{
if (!crafting) crafting = FindAnyObjectByType<CraftingService>(FindObjectsInactive.Include);
if (!crafting)
{
// Silently doing nothing is the exact failure this component exists to prevent, so the
// missing service is said out loud rather than discovered by a player losing a craft.
Warn(ref _warnedNoService,
"No CraftingService, so nothing is listening for the delivery failures this " +
"component exists to catch.");
return;
}
crafting.OnJobFailed += OnJobFailed;
if (crafting.RefundInputsOnDeliveryFail)
Warn(ref _warnedRefund,
"CraftingService.RefundInputsOnDeliveryFail is on, so a failed delivery already " +
"returns the inputs. Spilling the outputs as well pays one craft twice — turn off " +
"one of the two.");
}
private void OnDisable()
{
if (crafting) crafting.OnJobFailed -= OnJobFailed;
}
private void OnJobFailed(CraftJob job, CraftFailReason reason)
{
// Delivery failures only, and all four of them. The *Immediate pair is the zero-duration twin
// of the *AtDelivery pair -- inputs consumed, completion marked, output never created -- and
// zero duration is the authoring default. Anything refused earlier never produced an output
// and never reaches this event.
if (reason is not (CraftFailReason.NoSpaceAtDelivery
or CraftFailReason.NoInventoryAtDelivery
or CraftFailReason.NoSpaceImmediate
or CraftFailReason.NoInventoryImmediate))
return;
if (job == null || !job.owner || !job.recipe)
return;
// Placement is per-component but the subscription is scene-wide, so without this filter a
// spill point catches every crafter in the world, not the bench it sits on.
if (!string.IsNullOrWhiteSpace(stationTag)
&& !string.Equals(stationTag.Trim(), job.stationTag, StringComparison.OrdinalIgnoreCase))
return;
if (!loot) loot = FindAnyObjectByType<LootService>(FindObjectsInactive.Include);
if (!loot)
{
Debug.LogWarning($"[{nameof(SpilledCraft)}] No LootService, so '{job.recipe.name}' was " +
"lost to a full bag with nothing to catch it.", this);
return;
}
var entries = BuildEntries(job);
if (entries.Count == 0)
return;
// rollsMin, rollsMax and allowDuplicates are Weighted-mode only. IndependentChance tests every
// entry exactly once; these three are passed to satisfy the signature and do nothing here.
var table = LootTable.Create(
LootTableMode.IndependentChance,
rollsMin: 1,
rollsMax: 1,
allowDuplicates: false,
entries.ToArray());
// Delivery is synchronous inside RollAndGrant, and Undelivered is the only honest report that
// neither the container nor the spawner took the goods -- the returned LootResult says what
// the table produced, not what landed. Subscribed for this call only, so this reports on its
// own spill rather than on every loot roll in the game.
Action<GameObject, LootResult> reportLost = (_, _) =>
Debug.LogWarning($"[{nameof(SpilledCraft)}] '{job.recipe.name}' was spilled and still " +
"landed nowhere. Assign a LootPickupSpawner to the LootService, with a " +
"prefab carrying a LootPickupPayload.", this);
try
{
Vector3 where = spillPoint ? spillPoint.position : job.owner.transform.position;
loot.Undelivered += reportLost;
loot.RollAndGrant(table, job.owner, container: null, spawnAt: where);
}
finally
{
loot.Undelivered -= reportLost;
if (Application.isPlaying) Destroy(table);
else DestroyImmediate(table);
}
}
/// <summary>
/// One certain entry per <i>base</i> recipe output, at the quantity the batch was authored for.
/// </summary>
/// <remarks>
/// <para><c>chance01</c> is 1: these are not odds at the table. The items were made and had
/// nowhere to go, so anything less than certainty would be deleting part of a craft the player
/// already paid for. Certainty at the table is not certainty at delivery — the owner's loot
/// modifiers run afterwards, and a zeroed award is dropped silently.</para>
/// <para>Bonus and chance outputs contributed by an <c>ICraftingModifier</c> are not recovered.
/// The service computes a job's <c>CraftAdjustments</c> privately and exposes no public read of
/// them, so the spill is the recipe as authored.</para>
/// </remarks>
private static List<LootEntry> BuildEntries(CraftJob job)
{
var outputs = job.recipe.Outputs;
var entries = new List<LootEntry>(outputs.Count);
int crafted = Mathf.Max(1, job.batchCount);
for (int i = 0; i < outputs.Count; i++)
{
ItemRef output = outputs[i];
if (string.IsNullOrWhiteSpace(output.guid) || output.quantity <= 0)
continue;
int total = output.quantity * crafted;
entries.Add(new LootEntry
{
kind = LootEntryKind.Item,
itemGuid = output.guid,
chance01 = 1f,
quantityMin = total,
quantityMax = total,
weight = 1f,
});
}
return entries;
}
private void Warn(ref bool latched, string message)
{
if (latched) return;
latched = true;
Debug.LogWarning($"[{nameof(SpilledCraft)}] {message}", this);
}
}
}
Wiring it up¶
- Install Pickups and give the loot service a floor route: add a
LootPickupSpawner(Add Component >RevFramework > Integrations > Loot > Pickups > Loot Pickup Spawner), assign it to the LootService's Pickup Spawner slot, and give it a pickup prefab carrying aLootPickupPayload. The spawner refuses to spawn without both, and without the spawner nothing can land on the ground. The Spawn Pickups toggle can stay off. - Put the component anywhere in the scene — it finds the crafting and loot services itself.
- Set a station tag on it if you want per-bench placement, and a spill point to go with it. Both empty means one component for the whole scene, spilling at each crafter's own feet — which is the safe default, because one component with a spill point and no tag catches every crafter in the world.
- Check that the crafting service's Refund Inputs On Delivery Fail is off. It defaults off; see below for why it matters.
It only acts on failures, so it is inert until one happens.
What it deliberately does not do¶
It does not stop the failure. The craft still fails and still raises OnJobFailed; your UI should still say what happened. This catches the goods, it does not pretend the delivery worked.
It does not check whether the ground is a sensible place. A bench inside a wall drops items inside a wall. Spawn placement is your scene's problem and the loot service's scatter settings are where it belongs.
It does not handle currency outputs. A recipe's currency cost is taken at accept and is a separate question from a delivery that failed.
It does not pair with refunded inputs. CraftingService.RefundInputsOnDeliveryFail hands the inputs back on exactly the failures this spills for, so with both on the player keeps the inputs and collects the outputs from one craft — repeatably, by keeping the bag full on purpose. It defaults off; the component warns once when it is not. Pick one compensation.
It does not recover a crafting modifier's bonus outputs. The spill is the recipe's own outputs times batchCount. Extra and chance outputs an ICraftingModifier would have added are not included, because the service computes a job's CraftAdjustments privately and exposes no public read of them.
It does not verify that the failed delivery left nothing behind. Delivery normally rolls itself back, but the service warns when it could not take an item back — a listener that moves items on an inventory change can hold onto part of a partially-committed craft. In that case the spill adds the outputs on top of what the player kept. Watch the console for the service's own rollback warning.
Related¶
- Crafting — jobs, delivery, and the failure reasons.
- Loot — tables, rolling, and delivery to the world.
- While you were away — the report that tells the player a craft could not be delivered; this is what to do about it.
- Salvage, without authoring a single salvage table — the other recipe that builds a loot table at runtime, with the same cleanup trap.