Recipes that cost blood¶
A bench that takes health instead of money: it refuses the craft when the crafter is too weak to pay, and takes the price out of them when it finishes.
Recipe
Systems required: Crafting, Health. 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.
The part that is not obvious¶
Two hooks that know nothing about each other. ICraftingValidator is collected from the crafter and its parents before a craft is accepted and gets to refuse it. The completion event is where the price is actually taken. Crafting never learns that health is involved; Health never learns a craft happened. The recipe is the only thing that knows both — which is what makes this a composition rather than a feature request.
A validator can lower the count instead of refusing. Returning a smaller maxCrafts is how you say "you can afford three of those, not ten". Refusing outright is for when they cannot afford one.
That lowers what a preflight reports. It does not cap a queue.
The count a validator returns bounds what the batch preflight advertises as affordable. It does not bound how many jobs can be queued, and nothing re-checks the total as they run. Ten crafts queued at full health all complete, whether or not the blood was there for all ten.
Preflight is advice; the completion re-checks — which is also a discount
Between accepting a craft and finishing it, the crafter can be hurt by something else entirely. So the refusal at preflight cannot be trusted at delivery, and the price is re-measured against current health before it is taken. Same shape as a Health rule deciding in PRE and committing in POST, for the same reason: the world moves in between.
Nothing is escrowed at accept, so the re-measure cuts both ways: a crafter hurt while the job ran pays less, and one already down at minHealthAfter pays nothing and keeps the outputs. The two ways out both cost something — charge at OnJobAccepted and own the refund problem, or refuse to complete rather than discount. This recipe takes the discount, deliberately.
The cap bounds what this recipe asks for, not what lands
A craft that finishes and kills the crafter is a death with no killer, no combat and no explanation on screen. Players call that a bug, and they are right to — so the price is clamped to leave minHealthAfter.
That clamp is applied to the request, and the request is not what arrives. The charge goes through the crafter's own damage pipeline, where a vulnerability effect, a rule, or anything else that scales incoming damage can take it past the cap. If a blood price must never be lethal in your game, that has to be enforced at the pipeline — a rule of your own — not here.
DealDamageNoCombat is the right call, and not only for tidiness — routing this through ordinary damage makes the crafter treat the workbench as an attacker, with everything that follows from that in your AI and your combat log.
One rough edge, worth knowing before you copy it
CraftFailReason is a closed enum, so a custom refusal has to borrow an existing reason. NoCurrency is the closest honest fit — the price could not be paid — but your UI will need to know that "no currency" sometimes means "not enough blood".
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.Health;
using RevGaming.RevFramework.Health.Abstractions;
using RevGaming.RevFramework.Health.Abstractions.Contracts;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.BloodPriceCrafting
{
/// <summary>
/// Recipes that cost health instead of money: this component refuses the craft when the crafter is
/// too weak to pay, and takes the price out of them when it completes.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Crafting</b>, <b>Health</b>. Public API only.</para>
///
/// <para><b>The composition is two hooks that do not know about each other.</b>
/// <see cref="ICraftingValidator"/> is collected from the crafter and its parents before a craft is
/// accepted, and gets to refuse it; the completion event is where the price is actually taken. The
/// crafting system never learns that health is involved, and the health system never learns a craft
/// happened — the recipe is the only thing that knows both.</para>
///
/// <para><b>The two halves are not scoped alike, so the charging half scopes itself.</b> Validators
/// are collected per owner; <see cref="CraftingService.OnJobCompleted"/> is service-wide, and every
/// component in the scene hears every crafter's jobs. Left unfiltered, two crafters each carrying
/// one of these charge one validated craft twice over, and a crafter carrying none is charged by
/// somebody else's price list. So the charging half ignores any job whose owner is not this
/// transform or under it — the same scope the validator half already has. One per crafter is right,
/// and one on a shared parent covers everything beneath it.</para>
///
/// <para><b>The crafter itself must carry the health.</b> The service finds this component anywhere
/// in the owner's parent chain, but both halves here look for <see cref="IHealthReadonly"/> on the
/// owner <see cref="GameObject"/> exactly — the object passed to <see cref="CraftingService"/> as
/// owner. Put the component on a parent if you like; put the <c>HealthSystem</c> on a parent
/// and every blood-priced craft is silently unrefused and free.</para>
///
/// <para><b>The price table takes either authoring asset.</b> Projects on the Inventory integration
/// author recipes as <c>RecipeDefinition</c> assets, which are converted into runtime
/// <see cref="RecipeCore"/> instances that do not exist at author time and can never be dragged into
/// an Inspector slot. So each row serialises a plain <see cref="ScriptableObject"/> and is matched
/// through <see cref="RecipeResolve"/>, the same seam the shipped workbench components use.</para>
///
/// <para><b>Preflight is advice, and the completion re-checks — which is also a discount.</b>
/// Nothing is escrowed at accept: the price is measured again at delivery against whatever health
/// the crafter has left. A crafter hurt while the job ran therefore pays less, and one down at
/// <c>minHealthAfter</c> pays nothing and keeps the outputs. Queue depth is not bounded by health
/// either, so ten crafts queued at full health complete whether or not the blood was there for all
/// ten. The two ways out both cost something: charge at
/// <see cref="CraftingService.OnJobAccepted"/> and own the refund problem, or refuse to complete
/// rather than discount. This recipe takes the discount, deliberately.</para>
///
/// <para><b>The cap bounds what this recipe asks for, not what lands.</b> The price is clamped to
/// leave <c>minHealthAfter</c>, because a craft that finishes and kills the crafter is a death with
/// no killer and the first thing a player calls a bug. But modelling a price as self-damage means
/// the price inherits the whole damage pipeline: a crit rule doubles it past the floor and can kill
/// after all, armour quietly reduces it, a shield or i-frames or a team rule with friendly fire off
/// can cancel it outright, and lifesteal hands some of it straight back. Preview will not save you —
/// crits deliberately do not roll in a preview. A project that wants the price to be arithmetic
/// rather than a hit should write it with <c>HealthSystem.SetCurrentHealth</c> instead.</para>
///
/// <para><b>Why <see cref="DamageExtensions.DealDamageNoCombat"/>.</b> Not because a workbench is an
/// attacker — there is no workbench in this call, the crafter is its own attacker. The ordinary
/// overloads nudge the <i>attacker</i> into combat state, and paying a blood price is not fighting:
/// flagging it as combat would suppress the crafter's regen and refresh their disengage timers. The
/// gate that does it ships off, so this is a choice about intent rather than an observable
/// difference until your project turns it on. The cost of the NoCombat overload is that it returns
/// only a bool — there is no result-returning variant — so this recipe can tell that the price was
/// refused but not how much of it landed.</para>
///
/// <para><b>It rides the job lifecycle, and only that.</b>
/// <see cref="CraftingService.TryCraftImmediateEscrow"/> runs validators but creates no
/// <see cref="CraftJob"/> and raises no completion, so on that path the refusal fires and the charge
/// never does — a craft gated on a price it does not pay. Blood-priced recipes must go through
/// <see cref="CraftingService.Enqueue"/>. In the other direction, a job that finished while the game
/// was closed raises its completion during the restore, so the blood price is taken at load time —
/// after health has been restored, which is why the debt is not lost.</para>
///
/// <para><b>One rough edge worth knowing before you copy it:</b>
/// <see cref="CraftFailReason"/> is a closed enum, so a custom refusal has to borrow an existing
/// reason. <see cref="CraftFailReason.NoCurrency"/> is the closest honest fit — the price could not
/// be paid — and your UI will need to know that "no currency" sometimes means "not enough blood".
/// </para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class BloodPriceCrafting : MonoBehaviour, ICraftingValidator
{
/// <summary>One recipe and the health it costs to make.</summary>
[System.Serializable]
public struct BloodPrice
{
[Tooltip("The recipe that costs health. Accepts RecipeCore or RecipeDefinition; Unity " +
"definitions are converted at runtime via RecipeResolve.")]
public ScriptableObject recipe;
[Tooltip("Health taken per craft. Batches multiply it.")]
[Min(1)] public int healthPerCraft;
}
[Tooltip("Crafting service to listen to. Leave empty to find one in the scene on enable.")]
[SerializeField] private CraftingService crafting;
[Tooltip("Recipes paid for in health.")]
[SerializeField] private List<BloodPrice> prices = new();
[Tooltip("Health the crafter is always left with. The price this recipe asks for is capped to " +
"leave it; damage rules on the crafter can still take more.")]
[SerializeField, Min(1)] private int minHealthAfter = 1;
// The one state the two halves share. Validators keep being collected and run while a GameObject
// is merely deactivated -- the service drops only components that are destroyed or unticked --
// and OnEnable resolves the service exactly once, so "can refuse" and "can charge" come apart in
// both directions. Refusing a craft this component will not be charging is the worse half to
// leave running, so the refusal follows the charge.
private bool _charging;
private void OnEnable()
{
if (!crafting) crafting = FindAnyObjectByType<CraftingService>();
if (!crafting)
{
Debug.LogWarning($"[{nameof(BloodPriceCrafting)}] No CraftingService found, so no blood " +
"price will be charged and no craft will be refused.", this);
return;
}
crafting.OnJobCompleted += OnJobCompleted;
_charging = true;
}
private void OnDisable()
{
if (crafting) crafting.OnJobCompleted -= OnJobCompleted;
_charging = false;
}
/// <summary>
/// Refuses a craft the crafter cannot survive paying for, and lowers the count a batch preflight
/// reports as affordable.
/// </summary>
/// <remarks>
/// Collected from the owner and its parents by the service before acceptance, when validators
/// are enabled. Returning the proposal untouched is how a validator says "no opinion".
/// </remarks>
public CraftCheck Validate(ref CraftContext ctx, in CraftCheck proposed)
{
// No opinion while the charging half is off: a component that cannot take the price has no
// business refusing the craft either.
if (!_charging)
return proposed;
if (proposed.maxCrafts <= 0 || !TryGetPrice(ctx.recipe, out int perCraft))
return proposed;
if (!ctx.owner || !ctx.owner.TryGetComponent<IHealthReadonly>(out var health))
return proposed;
int spendable = health.Current - minHealthAfter;
if (spendable < perCraft)
return new CraftCheck { maxCrafts = 0, reason = CraftFailReason.NoCurrency };
// A batch costs its batch size, so the affordable count is a division rather than a yes/no.
// Lowering maxCrafts does NOT shrink a batch: EnqueueBatch is all-or-nothing and rejects a
// request it cannot satisfy in full, and the ordinary path preflights one craft at a time, so
// this branch only ever changes what CanCraftCount and Probe report. Ask CanCraftCount first
// and enqueue that number.
int affordable = spendable / perCraft;
return affordable >= proposed.maxCrafts
? proposed
: new CraftCheck { maxCrafts = affordable, reason = proposed.reason };
}
// The owner test the job events do not do. IsChildOf is true for the transform itself, so this
// is exactly the scope the service uses to collect validators, read from the other end.
private bool IsMine(CraftJob job) =>
job != null && job.owner && job.owner.transform.IsChildOf(transform);
private void OnJobCompleted(CraftJob job)
{
if (!IsMine(job) || !job.recipe)
return;
if (!TryGetPrice(job.recipe, out int perCraft))
return;
if (!job.owner.TryGetComponent<IHealthReadonly>(out var health))
return;
int price = perCraft * Mathf.Max(1, job.batchCount);
// Re-checked here rather than trusted from preflight: the crafter may have been hurt while
// the job ran. That makes the cap a discount as well as a floor -- see the remarks.
int payable = Mathf.Max(0, health.Current - minHealthAfter);
int toTake = Mathf.Min(price, payable);
if (toTake <= 0)
return;
// NoCombat because paying a price is not fighting, not because anything here is an attacker.
// The bool is the only answer this overload gives, and it is worth having: a team rule with
// friendly fire off, a shield, i-frames or a damage lock all cancel the hit and would
// otherwise make every blood-priced craft free in silence.
if (!job.owner.DealDamageNoCombat(job.owner, toTake, DamageTag.None))
Debug.LogWarning($"[{nameof(BloodPriceCrafting)}] The blood price of {toTake} for job " +
$"{job.id} was refused by the damage pipeline on '{job.owner.name}', " +
"so the craft was free. Friendly-fire gating, a shield, invincibility " +
"or a damage lock will all do that.", this);
}
private bool TryGetPrice(RecipeCore recipe, out int perCraft)
{
perCraft = 0;
if (!recipe)
return false;
for (int i = 0; i < prices.Count; i++)
{
// Resolved rather than compared directly: an authoring wrapper and the runtime RecipeCore
// it converts into are different objects, and resolution is cached.
if (prices[i].healthPerCraft > 0 && RecipeResolve.ResolveOrNull(prices[i].recipe) == recipe)
{
perCraft = prices[i].healthPerCraft;
return true;
}
}
return false;
}
}
}
Wiring it up¶
- Put the component on the crafter, or any parent of it — the service collects validators from both.
- Make sure validators are enabled on the crafting service.
- Add a row per recipe: the recipe, and the health it costs.
What it deliberately does not do¶
It does not refund. A craft that fails after the price was taken does not give the blood back, because the price is taken at completion — after the point where a craft can still fail. Move the charge earlier and you inherit the refund problem the currency path already solves properly.
It does not scale with difficulty, level or anything else. One number per recipe. Everything else is a multiplier you already know how to write.
Related¶
- Crafting — validators, preflight, batches and the completion event.
- Health — the damage entry points, and why the no-combat one exists.
- Last stand — the same decide-then-commit split, inside one system.