Salvage, without authoring a single salvage table¶
Break an item back down into some of what it was made of — using the recipes you already wrote, read backwards.
Recipe
Systems required: Crafting, Loot and Inventory. Inventory is not referenced at compile time — the item is taken through the crafting service's own adapter and the parts are delivered through the loot service's — but nothing works without it installed, because the shipped implementations of both adapters live behind Inventory. All three ship in the same package. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No scene, no prefab, no setup ritual. 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¶
Salvage normally arrives as a second database: every item, and what it gives back. Which then has to be kept in step with the recipes by hand, forever, and silently drifts the first time somebody rebalances a sword.
It does not need to exist. A recipe whose output is the sword already lists what the sword is made of. Salvage is a lookup and a loot table built at runtime.
What makes it possible at all is a shared id space
Loot references items by GUID string specifically so the Loot assembly needs no dependency on Inventory — and Crafting.Core.ItemRef makes the same choice for exactly the same reason.
Two systems that have never heard of each other therefore agree on what an item is. That is the only reason a recipe's inputs can be poured straight into LootEntry.itemGuid with no lookup in between.
Recovery must be lossy, and this is the part to get right before shipping it
A craft you can perfectly undo is not a cost, it is a delay: every material becomes refundable and the economy flattens. Three separate things pull toward that, and each one needs its own answer.
A craft that makes several units. "1 Log gives 4 Planks" would refund a whole Log per Plank if the refund were computed per craft — a 4x duplicator. The refund is divided by how many units of the salvaged item one craft produces, and a share below a whole unit is carried by the recovery chance rather than floored away. By-products make this the ordinary case: "10 Ore gives 2 Ingots and 5 Slag" has to divide the Ore five ways for Slag.
The division is per output kind, though, so a craft with several distinct outputs still refunds the whole input list once for each kind: salvaging every Ingot and then every Slag from that one craft is expected to return the 10 Ore twice over before the chance is applied. Two output kinds is break-even at a chance of 0.5 and three is a profit, so set the chance against how many kinds a craft produces.
Two recipes for the same item. The loop pays out whichever recipe salvage picked while costing whichever one the player crafted, so salvage resolves to the recipe with the fewest input units per unit produced, and the recovery chance defaults below one.
A recipe that consumes what it produces. Upgrade, repair and recharge recipes list their own output among their inputs and have short input lists, so the ranking would prefer them and salvage would hand the item straight back. They are skipped: salvage resolves through construction recipes only.
Cheapest here means fewest units, not least value
Input units per unit produced needs no economy, no prices and no second authored number, which is exactly why it is used — but it measures count, not worth. A masterwork sword taking 2 Mythril beats a plain one taking 5 Iron on that measure, so salvage would pay out Mythril for an Iron craft and the loop becomes a material-upgrade machine.
Where competing recipes for one item use materials of very different worth, keep the rare variant out of the catalogue, or rank by your own price data instead.
The currency the recipe cost is deliberately not returned. Where recipes carry a currency cost, it is the part of a craft's price that cannot be recovered. It is not a brake on a free recipe, and a recipe's currency cost is empty by default — there the recovery chance and fraction carry the whole loss on their own.
Salvage is a loot roll, so the actor's loot modifiers apply to it. Any ILootModifier on the actor's parent chain rewrites the grants, so a magic-find or quantity bonus raises salvage returns and can undo the loss those settings buy. A game that wants salvage exempt has to gate its modifiers on something this roll does not set.
Take the item first, then roll
The same ordering the death drops recipe was forced into, taken here by choice. Nothing can be recovered from an item that was not first successfully removed, so "salvage succeeded and the item is still in the bag" cannot be expressed.
The cost is the mirror risk: a delivery that fails completely loses the parts — and it does not show up in the returned value, which is what the table rolled, not what the actor received. LootService.Undelivered (an event on the service, not a member of the result) is where that shows up, and a "you recovered..." feed belongs on LootService.Granted.
The same ordering costs one more thing: an empty return no longer proves the item survived. Every refusal returns empty without taking it, but so does a successful take whose recovery rolls all failed — half of all salvages at one input and the default chance.
IndependentChance, not Weighted. Every input is tested against its own recovery chance, which is what "some of it comes back" means. A weighted table picks exactly one entry per roll — so salvaging a five-part item would return one part, which reads as a bug rather than as a setting.
The derived table is destroyed, and the branch matters
LootTable.Create returns a real ScriptableObject marked HideFlags.DontSave, which carries HideFlags.DontUnloadUnusedAsset. An unreferenced table is therefore never reclaimed — not by a scene load, not by UnloadUnusedAssets, not by anything but the explicit destroy. A table built per salvage and not destroyed leaks one object per salvage, permanently.
Destroy refuses to run outside play mode, so branch on Application.isPlaying. In practice a salvage call outside play mode returns empty long before a table exists, because the crafting service binds its inventory adapter in Awake — the branch is there so the cleanup is safe to copy into something that does bind by hand.
Drop it in¶
using System.Collections.Generic;
using RevGaming.RevFramework.Crafting.Abstractions;
using RevGaming.RevFramework.Crafting.Core;
using RevGaming.RevFramework.Crafting.UnityIntegration;
using RevGaming.RevFramework.Loot.Abstractions;
using RevGaming.RevFramework.Loot.Core;
using RevGaming.RevFramework.Loot.UnityIntegration;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.SalvageFromRecipe
{
/// <summary>
/// Breaking an item back down into some of what it was made of — without authoring a single salvage
/// table, because the recipes already say what everything is made of.
/// </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>Inventory</b>. Inventory is not referenced at compile time —
/// the item is taken through <see cref="ICraftingInventoryAdapter"/> and the parts are delivered
/// through the loot service's own adapter — but nothing works without it installed, because the
/// shipped implementations of both adapters live behind Inventory. All three ship in the same
/// package.</para>
///
/// <para><b>The composition is reading the recipe database backwards.</b> A salvage system normally
/// arrives as a second database — every item, and what it gives back — which then has to be kept in
/// step with the recipes by hand forever. It does not need to exist: a recipe whose output is the
/// sword already lists what the sword is made of, so salvage is a lookup and a loot table built at
/// runtime.</para>
///
/// <para><b>What makes it possible at all is a shared id space.</b> Loot references items by GUID
/// string specifically so the Loot assembly needs no dependency on Inventory, and
/// <see cref="ItemRef"/> makes the same choice for exactly the same reason. Two systems that have
/// never heard of each other therefore agree on what an item is, which is the only reason a
/// recipe's inputs can be poured straight into <see cref="LootEntry.itemGuid"/> with no lookup in
/// between.</para>
///
/// <para><b>Recovery must be lossy, and this is the part to get right before shipping it.</b> A
/// craft you can perfectly undo is not a cost, it is a delay — every material in the game becomes
/// refundable and the economy flattens. Three things pull toward that, and each is answered
/// separately.</para>
///
/// <para><b>One: a craft that makes several units.</b> "1 Log gives 4 Planks" would refund a whole
/// Log per Plank salvaged if the refund were computed per craft, which is a 4x duplicator. The
/// refund is therefore divided by how many units of the salvaged item one craft produces, and a
/// share smaller than a whole unit is carried by the recovery chance rather than floored away.
/// By-products make this the common case, not the exotic one: "10 Ore gives 2 Ingots and 5 Slag"
/// has to divide the Ore five ways for Slag. The division is per output <i>kind</i>, though, so a
/// craft with several distinct outputs still refunds the whole input list once for each kind:
/// salvaging every Ingot and then every Slag from that one craft is expected to return the 10 Ore
/// twice over before the chance is applied. Two output kinds is break-even at a chance of 0.5 and
/// three is a profit, so set the chance against how many kinds a craft produces.</para>
///
/// <para><b>Two: two recipes for the same item.</b> The loop pays out whichever one salvage picked
/// while costing whichever one the player crafted, so salvage resolves to the recipe with the
/// fewest input units <i>per unit produced</i>. Be honest about what that measures: unit count, not
/// value. If a masterwork sword takes 2 Mythril where the plain one takes 5 Iron, this rule picks
/// the Mythril recipe and the loop becomes a material-upgrade machine. Where competing recipes use
/// materials of very different worth, keep the rare variant out of <c>catalogue</c> or rank by your
/// own price data instead.</para>
///
/// <para><b>Three: a recipe that consumes what it produces.</b> Upgrade, repair and recharge
/// recipes list their own output among their inputs, and they are short, so ranking by input count
/// would prefer them and salvage would hand the item straight back. They are skipped: salvage
/// resolves through construction recipes only.</para>
///
/// <para><b>The currency the recipe cost is deliberately not returned.</b> Where recipes carry a
/// currency cost it is the part of a craft's price that cannot be recovered. It is not a brake on a
/// free recipe, and a recipe's currency cost is empty by default — there the recovery chance and
/// fraction carry the whole loss on their own.</para>
///
/// <para><b>Salvage is a loot roll, so the actor's loot modifiers apply to it.</b> Any
/// <see cref="ILootModifier"/> on the actor's parent chain rewrites the grants, so a magic-find or
/// quantity bonus increases salvage returns and can undo the loss the settings above buy. A game
/// that wants salvage exempt has to gate its modifiers on something this roll does not set.</para>
///
/// <para><b>Take the item first, then roll.</b> The same ordering the death-drops recipe was forced
/// into, taken here by choice: nothing can be recovered from an item that was not first
/// successfully removed, so the "salvage succeeded and the item is still in the bag" duplication
/// cannot be expressed. The cost is the mirror risk — a delivery that fails completely loses the
/// parts — and it does not show up in the returned value, which is what the table <i>rolled</i>,
/// not what the actor received. Subscribe to <see cref="LootService.Undelivered"/> to catch it, and
/// drive any "you recovered…" feed from <see cref="LootService.Granted"/>.</para>
///
/// <para><b>The derived table is destroyed, and the branch matters.</b>
/// <see cref="LootTable.Create"/> returns a real <see cref="ScriptableObject"/> marked
/// <see cref="HideFlags.DontSave"/>, which carries <see cref="HideFlags.DontUnloadUnusedAsset"/> —
/// so an unreferenced table is never reclaimed, by a scene load or anything else, and only the
/// explicit destroy releases it. <see cref="Object.Destroy(Object)"/> refuses to run outside play
/// mode, hence the branch; in practice a salvage call outside play mode returns empty long before
/// the table exists, because the crafting service binds its inventory adapter in <c>Awake</c>.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class SalvageBench : MonoBehaviour
{
[Tooltip("Crafting service, used only for its inventory adapter. Leave empty to find one in the scene.")]
[SerializeField] private CraftingService crafting;
[Tooltip("Loot service that rolls and delivers the parts. Leave empty to find one in the scene.")]
[SerializeField] private LootService loot;
[Tooltip("The recipes that describe what things are made of. Usually every recipe in the game. "
+ "Accepts RecipeCore or RecipeDefinition; Unity definitions are converted via RecipeResolve.")]
[SerializeField] private List<ScriptableObject> catalogue = new();
[Tooltip("Chance each individual input comes back. Below 1, or crafting costs nothing to undo.")]
[SerializeField, Range(0f, 1f)] private float recoveryChance = 0.5f;
[Tooltip("Fraction of each input's quantity that can come back. Amounts below a whole unit are "
+ "carried by the chance rather than rounded away.")]
[SerializeField, Range(0f, 1f)] private float recoveryFraction = 1f;
/// <summary>
/// Takes one of an item from the actor and rolls back some of what it was made of.
/// </summary>
/// <param name="actor">
/// Who is salvaging. The parts are granted to them — or spawned at their feet as world pickups
/// instead, if the loot service is configured to spawn.
/// </param>
/// <param name="itemGuid">The item to break down.</param>
/// <param name="container">
/// Container to take from and deliver into. Empty uses the crafting service's default, which is
/// passed on explicitly rather than left to the loot service's own default — the two are
/// separate settings and nothing keeps them equal. A blank crafting default would hand the
/// choice back to Loot, so the loot service's default is resolved and used for both sides.
/// </param>
/// <returns>
/// What the table rolled, or <see cref="LootResult.Empty"/> if nothing could be salvaged.
/// An empty result is not proof the item survived: every refusal below returns empty without
/// taking it, but so does a successful take whose recovery rolls all failed — which at one
/// input and the default chance is half of all salvages. If the caller needs to tell those
/// apart, it has to observe the inventory itself.
/// </returns>
public LootResult Salvage(GameObject actor, string itemGuid, string container = null)
{
if (!actor || string.IsNullOrWhiteSpace(itemGuid))
return LootResult.Empty;
if (!crafting) crafting = FindAnyObjectByType<CraftingService>();
if (!loot) loot = FindAnyObjectByType<LootService>();
if (!crafting || !loot)
return LootResult.Empty;
// Borrowing the crafting service's adapter does not borrow its rules. The consume below is
// gated by the *inventory* authority, but a project that gates crafting specifically would
// be surprised to find a bench built on the crafting service sitting outside that gate.
// Free when nothing is bound.
if (!crafting.CanMutate(actor, out _))
return LootResult.Empty;
if (!TryFindCheapestRecipeFor(itemGuid, out RecipeCore source, out int producedPerCraft))
return LootResult.Empty;
string containerName = string.IsNullOrWhiteSpace(container)
? crafting.DefaultContainer
: container;
// A cleared Default Container on the crafting service is a blank string, and blank means
// "your default" to each side separately -- the item would leave one container and the
// parts arrive in another. Loot's accessor normalises blank, so it settles the tie.
if (string.IsNullOrWhiteSpace(containerName))
containerName = loot.DefaultContainer;
if (!crafting.TryGetInventoryContext(actor, containerName, out CraftingInventoryContext bag))
return LootResult.Empty;
var entries = BuildEntries(source, producedPerCraft);
if (entries.Count == 0)
return LootResult.Empty;
// Taken before anything is rolled. A refused consume is the item not being where the caller
// thought it was -- already sold, already salvaged by a second click -- and returning here
// means no parts were created for an item nobody actually gave up.
if (!bag.TryConsumeExact(itemGuid, 1))
return LootResult.Empty;
// IndependentChance rather than Weighted: every input is tested against its own recovery
// chance, which is what "some of it comes back" means. A weighted table would pick exactly
// one input per roll, so salvaging a five-part item would return one part.
var table = LootTable.Create(
LootTableMode.IndependentChance,
rollsMin: 1,
rollsMax: 1,
allowDuplicates: false,
entries.ToArray());
try
{
return loot.RollAndGrant(table, actor, containerName, actor.transform.position);
}
finally
{
if (Application.isPlaying) Destroy(table);
else DestroyImmediate(table);
}
}
/// <summary>
/// One loot entry per recipe input, at the configured recovery odds, scaled to a single unit of
/// the salvaged item.
/// </summary>
/// <remarks>
/// The division by <paramref name="producedPerCraft"/> is what makes a batch recipe safe: one
/// craft's inputs have to be shared between every unit that craft produced. A share below one
/// whole unit cannot be expressed as a quantity, so it is carried by the chance instead.
/// Neither obvious alternative works: dropping the entry switches salvage silently off for the
/// commonest recipe shape of all, a single-unit input, and leaving the row at quantity zero
/// does the opposite — the roller clamps a zero quantity up to one, so every fractional share
/// would round <i>up</i> to a full unit.
/// </remarks>
private List<LootEntry> BuildEntries(RecipeCore source, int producedPerCraft)
{
var entries = new List<LootEntry>(source.Inputs.Count);
int perCraft = Mathf.Max(1, producedPerCraft);
for (int i = 0; i < source.Inputs.Count; i++)
{
ItemRef input = source.Inputs[i];
if (string.IsNullOrWhiteSpace(input.guid) || input.quantity <= 0)
continue;
float share = input.quantity * recoveryFraction / perCraft;
int recovered = Mathf.FloorToInt(share);
float chance = recoveryChance;
if (recovered <= 0)
{
recovered = 1;
chance *= share;
}
if (chance <= 0f)
continue;
entries.Add(new LootEntry
{
kind = LootEntryKind.Item,
itemGuid = input.guid,
chance01 = chance,
quantityMin = recovered,
quantityMax = recovered,
// Weight is not consulted in IndependentChance mode, but it is left non-zero so the
// same entries still behave if the mode above is ever changed to Weighted. A table
// of zero-weight entries in Weighted mode awards nothing, silently.
weight = 1f,
});
}
return entries;
}
/// <summary>
/// The recipe that makes this item for the least per unit produced, among every recipe that
/// makes it.
/// </summary>
/// <remarks>
/// Cheapest rather than first, and it is not a nicety. Two recipes for one sword — a plain one
/// and a masterwork one — means salvage has to choose, and choosing the expensive one lets a
/// player craft the cheap sword and salvage it as the expensive one. Input units per unit
/// produced is a crude measure of cheap and a deliberate one: it needs no economy, no prices
/// and no second authored number. It measures count, not worth, so it does not protect against
/// a variant built from fewer but rarer materials — see the class remarks. Recipes that consume
/// the item they produce are skipped entirely, or an upgrade recipe would win on its short
/// input list and refund the item itself.
/// </remarks>
private bool TryFindCheapestRecipeFor(string itemGuid, out RecipeCore cheapest, out int producedPerCraft)
{
cheapest = null;
producedPerCraft = 0;
float best = float.MaxValue;
for (int i = 0; i < catalogue.Count; i++)
{
// RecipeResolve is the supported seam: a project authors RecipeDefinition assets, not
// RecipeCore, and this is what the framework's own workbenches use to accept both.
RecipeCore recipe = RecipeResolve.ResolveOrNull(catalogue[i]);
if (!recipe)
continue;
int produced = ProducedPerCraft(recipe, itemGuid);
if (produced <= 0 || Consumes(recipe, itemGuid))
continue;
int units = TotalInputUnits(recipe);
if (units <= 0)
continue;
float cost = (float)units / produced;
if (cost >= best)
continue;
best = cost;
cheapest = recipe;
producedPerCraft = produced;
}
return cheapest;
}
private static int ProducedPerCraft(RecipeCore recipe, string itemGuid)
{
int produced = 0;
var outputs = recipe.Outputs;
for (int i = 0; i < outputs.Count; i++)
{
if (SameItem(outputs[i].guid, itemGuid) && outputs[i].quantity > 0)
produced += outputs[i].quantity;
}
return produced;
}
private static bool Consumes(RecipeCore recipe, string itemGuid)
{
var inputs = recipe.Inputs;
for (int i = 0; i < inputs.Count; i++)
{
if (SameItem(inputs[i].guid, itemGuid))
return true;
}
return false;
}
// Ordinal, because these are GUID strings rather than words: a culture-aware comparison on an
// id is a bug waiting for the machine it fails on. Ignoring case, because the inventory side
// canonicalises item ids to lower case while a recipe asset stores whatever was typed -- an
// exact comparison here would fail closed on data every other system in the composition takes.
private static bool SameItem(string a, string b) =>
string.Equals(a, b, System.StringComparison.OrdinalIgnoreCase);
private static int TotalInputUnits(RecipeCore recipe)
{
int total = 0;
var inputs = recipe.Inputs;
for (int i = 0; i < inputs.Count; i++)
{
if (inputs[i].quantity > 0)
total += inputs[i].quantity;
}
return total;
}
}
}
Wiring it up¶
- Put the component wherever your salvage UI lives, and give it the recipe catalogue — usually every recipe in the game. The field takes
ScriptableObject, soRecipeCoreassets and theRecipeDefinitionassets a Crafting + Inventory project actually authors both drop in; they are resolved throughRecipeResolve, the same seam the shipped workbenches use. - Set the recovery chance. That is the lever to reach for first, because it applies to every recipe shape. Leave the fraction at 1 unless inputs come in bulk: it scales each input's quantity, so on a single-unit input it only ever thins the odds, which the chance already does more directly.
- Call
Salvage(player, itemGuid)from the button.
What it deliberately does not do¶
It does not author anything. There is no salvage table, no per-item override list, and no second place to edit when a recipe changes. If that is a limitation for you, the fix is a recipe variant, not a parallel database.
It does not return the currency. See above — that is the lever that keeps the loop unprofitable wherever recipes carry a currency cost, and nothing at all where they do not.
It does not tell you whether the item survived. An item no recipe produces salvages to nothing, and the call reports that the same way it reports a refused consume: an empty result, and the item untouched. It reports a successful take whose every recovery roll failed the same way too — and the item is gone in that case. A caller that needs to distinguish them has to observe the inventory itself.
It does not exempt salvage from loot modifiers, and it does not choose where the parts land. The roll goes through the loot service, so the actor's ILootModifier components apply, and if the service is configured to spawn pickups the parts appear on the floor at the actor's feet rather than in the named container. Roll followed by Grant is the pairing that never spawns, at the cost of losing the pickup fallback when the bag is full.
It does not batch. One item per call, because the interesting failure — a partial delivery — is already the hard part at a quantity of one.
Related¶
- Crafting — recipes, inputs and the inventory adapter this borrows.
- Loot — table modes, rolling and delivery.
- Death drops — the take-then-roll ordering, arrived at from the other direction.
- Loot odds that shift with the player's condition — the other recipe that builds a table at runtime, and the same cleanup trap.