Brewing straight into a buff¶
The craft finishes, the phial it made is drunk on the spot, and the crafter walks away hasted. No item to click, no consumable to manage — brewing whose product is an effect.
Recipe
Systems required: Crafting, Status Effects. 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, 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.
Inventory is used but never referenced: the phial is consumed through the ICraftingInventoryAdapter the crafting service already hands out.
Read this first: the craft still makes an item¶
A recipe that outputs nothing is what this wants to be, and the framework will not accept one. RecipeCore.IsValid requires at least one output, and an invalid recipe is refused at enqueue with CraftFailReason.InvalidInput.
So the recipe outputs a token — the phial — and this component consumes it the instant the job completes, then applies the effect. That is the honest way round, and it is worth understanding the costs before you build a game on it:
What the workaround costs
- A full bag fails the craft. The token needs somewhere to go, so a brew that produces nothing occupying space can still fail for lack of space.
- A save between delivery and consumption contains the token. Load that save and the player has a phial instead of a buff.
- The token is a real item for the moment it exists, so anything watching the container sees it arrive and leave.
None of that is fatal. All of it is invisible until it happens to a player.
The seam to want is an effect-only craft — a recipe permitted to produce no item, or an output kind that is not an item at all. Until that exists, this is the shape that works.
The part that is not obvious¶
Validate before you consume. The status is built before the phial is destroyed, so a mistyped id costs the player nothing. Consume first and the phial is gone whether or not an effect ever arrives.
Check the consume result. A refused consume means the token is not where the delivery put it — moved, sold, or taken by another handler in the same completion. Refusing to buff is the only safe answer; the alternative hands out an effect nobody paid for.
Batches are one job. Crafting five phials completes a single job with batchCount of five, so five effects are applied and the effect's own stacking rule decides whether that is one long buff, five stacks, or a refresh. Applying once for a batch of five is the bug this exists to point out.
A save and load CAN re-buff, and nothing here prevents it
Within one session you get dedup for free: the service marks a completion applied before it delivers and raises OnJobCompleted only for completions it actually ran, so a restored job whose completion already happened neither re-delivers nor re-raises.
A full-world load is the case that breaks it. CraftingSaveParticipant calls ClearAppliedCompletions immediately before restoring, and does so deliberately — a load is a rewind, and the completion record does not rewind with it. So a job that finished live before the save completes again in the restored timeline: it delivers its token again, raises OnJobCompleted again, and this recipe consumes and buffs again with it.
If that matters in your game, the dedup has to be yours and it has to be keyed on something that survives the load — the job id alone does not.
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.StatusEffects.Abstractions;
using RevGaming.RevFramework.StatusEffects.Core;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.CraftedStatusEffects
{
/// <summary>
/// Brewing that ends in an effect rather than an object: the craft completes, the phial it
/// produced is consumed on the spot, and the crafter walks away buffed.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Crafting</b>, <b>StatusEffects</b>. Inventory is used but not referenced — the phial is
/// consumed through <see cref="ICraftingInventoryAdapter"/>, which the crafting service hands
/// out.</para>
///
/// <para><b>Read this before copying: the craft still produces an item.</b> A recipe that outputs
/// nothing at all is what this wants to be, and the framework will not accept one —
/// <c>RecipeCore.IsValid</c> requires at least one output, and an invalid recipe is refused at
/// enqueue with <c>CraftFailReason.InvalidInput</c>. So the recipe outputs a token item, this
/// consumes it the moment the job completes, and the effect goes on the crafter. The seam to want
/// is an effect-only craft; until it exists, this is the honest way round.</para>
///
/// <para><b>What the workaround costs, stated plainly:</b> the token needs somewhere to go, so a
/// crafter with a full bag fails a craft that produces nothing occupying space. If a save happens
/// between delivery and consumption the token exists in it. The token is a real item, so it can be
/// dropped, sold or stolen in the frame it exists. And a modifier that multiplies outputs or adds
/// them — the shipped Simple Station Bonus modifier does exactly that — delivers more tokens than
/// the recipe authored, while this consumes the authored quantity, so the surplus phials stay in
/// the bag. None of that is fatal; all of it is invisible until it happens to a player.</para>
///
/// <para><b>The consumption result is checked, and the effect is not applied unless it
/// succeeded.</b> Applying first and consuming afterwards — or consuming and not looking — turns a
/// missing phial into a free permanent buff, and a token that another system moved between
/// delivery and this handler is exactly the case that finds it. The apply is checked in the other
/// direction too: <c>ApplyStatus</c> returns <c>void</c> and does nothing at all when the target is
/// immune or authority is denied, so this looks for the id afterwards and warns once when a token
/// was spent on an effect the controller refused. Presence is the only evidence available, so a
/// crafter who already held that id absorbs the refusal unnoticed.</para>
///
/// <para><b>Batches are honoured, and the stacking rule decides what that means.</b> Crafting five
/// phials completes one job with <c>batchCount</c> of five, so five effects are applied and the
/// effect's stacking rule — together with any per-id stack policy on the controller — settles what
/// the player is left holding. Only <c>StatusStackingRule.Stack</c> makes a batch worth more than a
/// single craft, and <c>haste</c> and <c>slow</c> are the only shipped effects that use it; every
/// other built-in replaces, so five phials leave exactly one buff of the authored duration — no
/// shipped effect adds durations together, because <c>Refresh</c> assigns the remaining time rather
/// than extending it. Applying once for a batch of five is still the bug this exists to point out —
/// but pick a stacking effect if the batch is meant to buy the player anything.</para>
///
/// <para><b>A save and load can re-buff, and nothing here prevents it.</b> Within one session the
/// service skips a restored completion it has already applied. A full-world load does not:
/// <c>CraftingSaveParticipant</c> calls <c>ClearAppliedCompletions</c> immediately before restoring,
/// deliberately — a load is a rewind and the completion record does not rewind with it — so a job
/// that finished live before the save completes again in the restored timeline, delivers its token
/// again and raises <c>OnJobCompleted</c> again. This recipe consumes and buffs again with it.
/// Statuses restore through a separate opt-in participant that does not clear live effects first,
/// so a quickload taken mid-brew can leave the crafter holding the pre-load buff as well as the
/// restored one. Clear statuses before restoring if that matters.</para>
///
/// <para><b>What this does not cover.</b> It hangs off job completion, so a craft made through
/// <c>CraftingService.TryCraftImmediateEscrow</c> — which creates no <see cref="CraftJob"/> and
/// raises no job lifecycle events — delivers the token and never buffs. And a brew must produce
/// exactly one output, the token: a recipe that also produces something real is refused with a
/// warning rather than having that output eaten, because nothing here nominates which output is
/// the phial. One row per recipe, too — the first row that names a recipe wins and any later row
/// for the same one is ignored, so two buffs from one brew is not something this expresses.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class CraftedStatusEffects : MonoBehaviour
{
/// <summary>One recipe, and the effect its completion should apply instead of a phial.</summary>
[Serializable]
public struct Brew
{
[Tooltip("The recipe whose completion becomes an effect. It must have exactly one output: the token.")]
public RecipeCore recipe;
[Tooltip("Status id to apply to the crafter, e.g. \"haste\". Ids are compared ordinally " +
"and every id RevFramework ships is lowercase, so a capitalised one builds nothing.")]
public string statusId;
[Tooltip("Duration in seconds, passed to the status builder. Must be above zero: an effect " +
"built with a duration of zero is already expired when it is applied.")]
[Min(0.01f)] public float duration;
[Tooltip("Magnitude or multiplier, passed to the status builder. What it means is the " +
"effect's business - and zero is not neutral: the shipped multiplier effects read " +
"it as the strongest value they allow.")]
public float magnitude;
}
[Tooltip("Crafting service to listen to. Leave empty to find one in the scene on enable.")]
[SerializeField] private CraftingService crafting;
[Tooltip("Recipes that end in an effect rather than an item.")]
[SerializeField] private List<Brew> brews = new();
private readonly List<string> _warned = new();
private void OnEnable()
{
if (!crafting) crafting = FindAnyObjectByType<CraftingService>();
if (!crafting)
{
// OnEnable does not run again for a component that is already enabled, so a service
// spawned later -- or one sitting on an inactive object, which FindAnyObjectByType
// skips -- leaves this inert for the session. Silence looks exactly like a correct
// setup: every brew delivers a phial and the inspector shows nothing wrong.
WarnOnce("service", "No CraftingService found on enable, so nothing is listening. " +
"Assign one in the inspector if the service is spawned later or " +
"lives on an inactive object.");
return;
}
crafting.OnJobCompleted += OnJobCompleted;
}
private void OnDisable()
{
if (crafting) crafting.OnJobCompleted -= OnJobCompleted;
}
private void OnJobCompleted(CraftJob job)
{
if (job == null || !job.owner || !job.recipe)
return;
if (!TryGetBrew(job.recipe, out Brew brew))
return;
if (!job.owner.TryGetComponent<IStatusEffectController>(out var status))
{
WarnOnce("controller:" + job.recipe.name,
$"'{job.owner.name}' completed '{job.recipe.name}' but carries no " +
"IStatusEffectController, so there is nothing to buff. The token was left alone.");
return;
}
int crafted = Mathf.Max(1, job.batchCount);
var id = new StatusId(brew.statusId);
// Everything checkable is checked before anything is consumed, so a misconfigured brew
// costs the player nothing. Duration first, then magnitude -- both are floats, so a swap
// compiles and quietly produces a buff of the wrong length.
if (brew.duration <= 0f)
{
WarnOnce("duration:" + brew.statusId,
$"Brew for '{brew.statusId}' has a duration of {brew.duration}. A timed effect built " +
"with no duration is expired before its first tick, so the craft was left alone.");
return;
}
if (!StatusRegistry.TryBuild(id, brew.duration, brew.magnitude, out IStatusEffect first))
{
WarnOnce("id:" + brew.statusId,
$"No status registered as '{brew.statusId}'. The craft was left alone rather than " +
"consumed - check spelling and case; every id RevFramework ships is lowercase.");
return;
}
// Read before the consume, because presence afterwards is the only evidence available and
// it cannot tell a refused apply from an effect that was already running.
bool had = status.HasStatus(id);
if (!ConsumeTokens(job, crafted))
return;
for (int i = 0; i < crafted; i++)
{
// One instance per application: an effect is a live object with its own timer, so
// handing the same one over twice would be one buff, not two.
IStatusEffect effect = first;
if (i > 0 && !StatusRegistry.TryBuild(id, brew.duration, brew.magnitude, out effect))
break;
status.ApplyStatus(effect, new StatusContext(
instigator: job.owner,
sourceDef: job.recipe,
sourceId: "recipe.craftedStatusEffect",
sourceSlot: 0,
note: "crafted"));
}
// ApplyStatus is void and refuses in silence when the target is immune or authority is
// denied -- and the token is already gone by then. Saying so is the least this can do.
if (!had && !status.HasStatus(id))
{
WarnOnce("refused:" + brew.statusId,
$"'{job.owner.name}' did not take '{brew.statusId}' (immunity, or authority denied) " +
"after the token was consumed. The player paid for an effect that never landed.");
}
}
/// <summary>
/// Removes the token items the completed craft just delivered.
/// </summary>
/// <remarks>
/// Everything here is reached through the crafting service's own adapter, so this recipe never
/// references Inventory. The container is resolved the way delivery resolves it — the job's,
/// falling back to the service default, then whatever the output router names if one is
/// configured — which is what makes the token findable. Only the single token output is
/// consumed, and a recipe with any other number of outputs is refused rather than guessed at.
/// </remarks>
private bool ConsumeTokens(CraftJob job, int crafted)
{
if (!crafting.TryGetInventoryAdapter(out ICraftingInventoryAdapter inventory) || inventory == null)
return false;
IReadOnlyList<ItemRef> outputs = job.recipe.Outputs;
// Consuming every output would destroy the real item in a recipe that produces one, which
// is the obvious adaptation of this class -- "crafting a potion should also haste me" --
// and the one it cannot do safely, because nothing here says which output is the phial.
if (outputs == null || outputs.Count != 1)
{
WarnOnce("outputs:" + job.recipe.name,
$"'{job.recipe.name}' has {outputs?.Count ?? 0} outputs. A brew must have exactly " +
"one, the token, so nothing was consumed and no effect was applied.");
return false;
}
ItemRef token = outputs[0];
int quantity = token.quantity * crafted;
string containerName = ResolveContainer(job, in token, quantity);
if (!inventory.TryGetContainer(job.owner, containerName, out object container) || container == null)
{
WarnOnce("container:" + containerName,
$"No container '{containerName}' on '{job.owner.name}', so the token from " +
$"'{job.recipe.name}' could not be consumed and no effect was applied.");
return false;
}
// A refused consume means the phial is not where the delivery put it -- moved, sold, or
// taken by another handler in this same completion. Refusing to buff is the only safe
// answer: the alternative hands out an effect nobody paid for.
if (!inventory.TryConsumeExact(container, token.guid, quantity))
{
WarnOnce("consume:" + job.recipe.name,
$"The token from '{job.recipe.name}' was not in '{containerName}' at completion, so " +
"no effect was applied.");
return false;
}
return true;
}
/// <summary>
/// Names the container delivery put the token in, router included.
/// </summary>
/// <remarks>
/// The job's container falling back to the service default is only half of it: with an
/// <see cref="ICraftingOutputRouter"/> configured — the shipped Output To Container router is
/// enough — delivery puts the token wherever the router says, and a recipe that consumed from
/// the job's container would find nothing and never buff, silently. A throwing router falls
/// back to the unrouted name here for the same reason delivery does: consuming from a container
/// delivery did not use is worse than not consuming at all.
/// </remarks>
private string ResolveContainer(CraftJob job, in ItemRef token, int quantity)
{
string containerName = job.container ?? crafting.DefaultContainer;
ICraftingOutputRouter router = crafting.OutputRouter;
if (router == null)
return containerName;
var ctx = new CraftContext(job.owner, job.recipe, containerName, job.stationTag);
try
{
if (router.TryResolveContainer(ref ctx, in token, quantity, out string routed) &&
!string.IsNullOrWhiteSpace(routed))
{
return routed.Trim();
}
}
catch (Exception ex)
{
Debug.LogException(ex, this);
}
return containerName;
}
private bool TryGetBrew(RecipeCore recipe, out Brew brew)
{
for (int i = 0; i < brews.Count; i++)
{
if (brews[i].recipe == recipe && !string.IsNullOrWhiteSpace(brews[i].statusId))
{
brew = brews[i];
return true;
}
}
brew = default;
return false;
}
/// <summary>Warns once per distinct key, so a misconfiguration does not warn on every craft.</summary>
private void WarnOnce(string key, string message)
{
if (_warned.Contains(key))
return;
_warned.Add(key);
Debug.LogWarning($"[{nameof(CraftedStatusEffects)}] {message}", this);
}
}
}
Wiring it up¶
- Author the recipe as normal, with a token item as its single output. A phial, a residue, whatever fits your fiction — the player will never see it.
- Put this component anywhere in the scene and add a Brew row: the recipe, the status id, the duration and the magnitude.
- Make sure the crafter has a
StatusEffectController. Nothing else.
What it deliberately does not do¶
It does not clean up after a failed apply. If the status registry has no builder for your id, the craft is left alone entirely — no consume, no effect, one warning. That is the safe direction, but it does mean a mistyped id leaves phials in the bag rather than silently eating them.
It does not decide what stacking means. Five brews apply five effects and the effect's own rule settles the rest. That is the framework's answer, not this recipe's, and overriding it here would hide a decision that belongs to your effect.
It does not survive the crafter dying mid-brew. The effect lands on job.owner; if that object is gone by completion, nothing happens, which is almost certainly what you want.
Related¶
- Crafting — recipes, jobs, batches and the completion lifecycle this listens to.
- Status Effects — the ids you can name in a brew, and the stacking rules that decide what several of the same effect mean.
- A cursed item — the other recipe that applies an effect from somewhere unexpected, and where precise removal is explained.