A shield that spends money¶
A ward that buys off incoming damage a point at a time, for as long as the wallet holds out.
Recipe
Systems required: Health, Currency. 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 prefab, no bespoke scene. Public API only. It assumes: the actor has a health component, the actor's scene has a currency service to resolve, and the actor's wallet holds the currency the ward spends. 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¶
IShield is not a pool of hit points. It is a callback in the middle of a hit:
bool TryAbsorb(ref int damage);
The health system calls it after the damage rules have run and before health is decremented, and it hands the damage over by reference. Nothing in that signature says a shield has to be made of anything in particular. It is the one place in the framework where a project can run a transaction inside a hit — so anything you can spend can be spent to keep the hit off health. Money here; charges, ammunition, reputation or sanity are the same three lines.
The five shipped shields are all pools — capacity, overheal, recharge, reduction — which makes it easy to read the interface as "a pool" and stop there.
Charge first. That is the opposite of the other two recipes, and it is deliberate¶
Two recipes in this Cookbook charge last, and both are right to. A pickup you have to pay for delivers the goods and then bills, because a payment taken before a refused delivery needs a refund, and a refund can itself be refused. A healer who charges by the point heals and then bills, because the amount healed is the bill and cannot be known before the mutation.
This ward charges first, and the reason is a property neither of those has.
When the delivery can be scaled to the payment, charge first
Absorbing three points instead of ten is a perfectly good outcome. So the money moves first and the mitigation is computed from what actually left the wallet — there is no window in which damage was absorbed and not paid for, and no compensating action to get wrong.
That is the general rule, and it is worth carrying: a delivery that is all-or-nothing must happen before the charge; a delivery that is divisible should happen after it.
The debit's outcome is not the amount
ICurrencyService.Debit answers with a CurOpResult. A wallet carrying a minimum-balance policy can take less than it was asked for and still report success — exactly the trap PaidHealing found against TryHeal, one system over.
So the balance is read either side and the mitigation comes from the difference. Absorbing what was billed rather than what was taken hands out free mitigation at precisely the moment the purse is running out.
A part-paid point is not absorbed, and the coins are counted rather than refunded¶
When a policy clamps the debit, what came out need not divide by the price of a point. The remainder buys no whole point.
Crediting it back is the tempting fix and the wrong one
A credit can itself be refused by a cap, and discovering that halfway through a hit is the worst place to be — the same reason PricedPickup refuses to build its ordering on a refund.
The case is also self-limiting, which is what makes leaving it alone defensible rather than lazy: after a clamped debit the wallet is sitting on its floor, so the next hit is refused outright and strands nothing. A whole encounter loses less than one point's worth, and StrandedTotal reports it rather than hiding it.
If your project has its CurrencyPolicy asset to hand, CurrencyServiceExtensions.TryComputeEffectiveDebit will give you the clamped figure in advance and the case disappears entirely.
Implementing IShieldPreview is not optional in practice¶
HealthSystem.PreviewDamage consults a shield only through IShieldPreview and skips one that does not implement it — with an editor-only warning that a release build never prints. A ward without it previews as though it were not there, so every damage-forecast UI and every AI that asks "can I survive this?" gets the unmitigated number.
A preview is a claim about this instant, not a reservation
PreviewRemainder must touch no state, which for this ward means reading the balance and never spending it. Anything that moves the wallet between the preview and the hit moves the answer with it.
Taking a hold instead would be a real reservation — and one that has to survive a save, which is why crafting's own escrow path is restricted to immediate operations. A damage preview is not worth that.
Only one shield is selected per actor
The health system resolves a single IShield: an explicit provider first, then a chain-like shield, then the first enabled one it finds. Drop this beside a CapacityShield and one of them never runs.
Put a ShieldChain on the actor to run both in an order you control. The framework already warns about the unchained case in the editor, so this recipe does not repeat the check — but that warning is editor-only too.
Added at runtime, it will not be found on its own
The health system resolves its shield in Awake, and after that only on a child-transform change, a revive, or an explicit call. AddComponent<CoinWard>() on a live actor compiles, sits there and never absorbs anything.
Follow it with HealthSystem.RefreshOptionalComponents(), or hand the ward over with HealthSystem.SetShield. Both are public; neither is obvious from the shield side.
Its charge survives a save with no help, and a pool shield does not
HealthSnapshot carries current, max and dead state only, and restoring one deliberately does not restore shields — Status Effects' own save participant documents this, because re-applying a shield status is the only thing that brings a shield back. So a CapacityShield returns from a load at whatever its inspector says rather than where the fight left it.
This ward keeps its charge in the wallet, and the wallet is saved by Currency's own participant. Backing a shield with state another system already persists is worth more than it looks.
Damage tagged to bypass shields costs nothing
The pipeline skips shields entirely for it, so no money moves and the hit lands in full. That is the correct reading of "true damage" — but it means the ward's effective price depends on how much of your damage is tagged that way, which is worth knowing before tuning it.
Re-entrancy here is real, not theoretical
Spending raises ICurrencyService.OnWalletChanged, and a listener is project code that may do anything — including damaging this actor again, from inside this call.
The nested hit finds the ward already in flight and is let through to health rather than paid for. A wallet read mid-debit cannot be trusted, and a ward that pays for damage caused by its own payment is a loop nobody wants to debug. Removing that guard is measurable: the nested hit gets absorbed for free and a second debit fires.
Anything a shield throws escapes the hit
TryAbsorb runs between the damage being computed and health being decremented. An exception out of it does not fail the shield — it fails TakeDamage, from a stack frame that names the shield and not the caller that misconfigured it.
That is why the price is checked against zero even though [Min(1)] is on the field: the attribute clamps the inspector, not a serialized value that arrived some other way, and the blast radius is the whole hit. Worth applying to any seam the pipeline calls into.
Drop it in¶
using RevGaming.RevFramework.Currency;
using RevGaming.RevFramework.Currency.Abstractions;
using RevGaming.RevFramework.Health.Abstractions.Shields;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.CoinWard
{
/// <summary>
/// A shield that spends money instead of hit points — a ward that buys off incoming damage a
/// point at a time, for as long as the wallet holds out.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Health</b>, <b>Currency</b> — different packages, so <b>Complete only</b>. Public API only.
/// Put it on the actor beside its health component and give that actor a wallet; the scene needs a
/// currency service to resolve.</para>
///
/// <para><b>The composition.</b> <see cref="IShield"/> sits inside the damage pipeline, after the
/// rules have run and before health is decremented, and it is handed the damage <i>by
/// reference</i>. Nothing about it says a shield has to be a pool of hit points. It is the one
/// place in the framework where a transaction can be run in the middle of a hit, so anything a
/// project can spend — money here, but equally charges, ammunition or a reputation — can be spent
/// to keep the hit off health.</para>
///
/// <para><b>Charge first, then absorb what the money bought. That is the whole ordering
/// argument.</b> Two other recipes charge <i>last</i> and are right to
/// (<c>PricedPickup</c>, <c>PaidHealing</c>): a delivery that cannot be sized to the payment must
/// happen first, and a debit refused afterwards is an unrecoverable tail. Here the delivery
/// <i>can</i> be sized to the payment — absorbing three points instead of ten is a perfectly good
/// outcome — so the money moves first and the mitigation is computed from what actually left the
/// wallet. There is no window in which damage was absorbed and not paid for, and no refund path
/// to get wrong. <b>When the delivery can be scaled to the payment, charge first; when it cannot,
/// charge last.</b></para>
///
/// <para><b>The debit's <i>outcome</i> is not the amount.</b> <see cref="ICurrencyService.Debit"/>
/// answers with a <see cref="CurOpResult"/>, and a wallet carrying a minimum-balance policy can
/// take less than it was asked for and still report success. So the balance is read either side
/// and the mitigation comes from the difference — the same measurement <c>PaidHealing</c> makes
/// against <c>TryHeal</c>, one system over. Absorbing what was <i>billed</i> rather than what was
/// <i>taken</i> would hand out free mitigation exactly when the purse is running out.</para>
///
/// <para><b>A part-paid point is not absorbed, and the coins are counted rather than refunded.</b>
/// When a policy floor clamps the debit, what came out need not divide by
/// <see cref="PricePerPoint"/>; the remainder buys no whole point and is reported on
/// <see cref="StrandedTotal"/>. Crediting it back is the tempting fix and the wrong one — a credit
/// can itself be refused by a cap, and discovering that halfway through a hit is the worst place
/// to be. It is also self-limiting: after a clamped debit the wallet is sitting <i>on</i> its
/// floor, so the next hit is refused outright and strands nothing. The whole encounter loses less
/// than one point's worth. If your project has its <c>CurrencyPolicy</c> to hand,
/// <c>CurrencyServiceExtensions.TryComputeEffectiveDebit</c> will tell you the clamped figure in
/// advance and the case disappears.</para>
///
/// <para><b>Implementing <see cref="IShieldPreview"/> is not optional in practice.</b>
/// <c>HealthSystem.PreviewDamage</c> consults a shield only through that interface and skips one
/// that does not implement it, so a preview would report the unmitigated figure — the ward would
/// look like it does nothing until the hit landed. The preview must also touch no state, which for
/// this ward means reading the balance and never spending it: it answers <i>what the wallet could
/// buy off right now</i>, and the answer is only as current as the wallet.</para>
///
/// <para><b>Only one shield is selected per actor.</b> The health system resolves a single
/// <see cref="IShield"/> — an explicit provider, then a chain, then the first enabled one it finds
/// — so dropping this beside a <c>CapacityShield</c> means one of them never runs. Put a
/// <c>ShieldChain</c> on the actor to run both, in an order you control. The framework warns about
/// the unchained case in the editor, so this recipe does not repeat the check.</para>
///
/// <para><b>Added at runtime, it will not be found on its own.</b> The health system resolves its
/// shield in <c>Awake</c>, and after that only on a child-transform change, a revive, or an
/// explicit call. <c>AddComponent<CoinWard>()</c> on a live actor therefore compiles, sits
/// there and never absorbs anything — follow it with
/// <c>HealthSystem.RefreshOptionalComponents()</c>, or hand the ward over with
/// <c>HealthSystem.SetShield</c>.</para>
///
/// <para><b>Its charge survives a save with no help, which a pool shield does not.</b>
/// <c>HealthSnapshot</c> carries current, max and dead state only, and restoring one deliberately
/// does not restore shields — so a <c>CapacityShield</c> comes back at whatever its inspector says
/// rather than where the fight left it. This ward keeps its charge in the wallet, and the wallet
/// is saved by Currency's own participant. Backing a shield with state another system already
/// persists is worth more than it looks.</para>
///
/// <para><b>Damage tagged to bypass shields costs nothing</b> — the pipeline skips shields
/// entirely for it, so no money moves and the hit lands in full. That is the correct reading of
/// "true damage" and it is worth knowing before pricing the ward.</para>
///
/// <para><b>Re-entrancy is real here, not theoretical.</b> Spending raises
/// <c>ICurrencyService.OnWalletChanged</c>, and a listener is project code that may do anything —
/// including damaging this actor again, from inside this call. The nested hit finds the ward
/// already in flight and is let through to health rather than paid for, because a wallet read
/// mid-debit cannot be trusted and a ward that pays for damage caused by its own payment is a loop
/// nobody wants to debug.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class CoinWard : MonoBehaviour, IShield, IShieldPreview
{
[Tooltip("Currency this ward spends. Must be a currency the actor's wallet holds.")]
[SerializeField] private string currencyId = "gold";
[Tooltip("Coins burned to buy off one point of damage. Whole coins - money is long and a " +
"fractional price would only introduce rounding this does not need.")]
[SerializeField, Min(1)] private int pricePerPoint = 5;
[Tooltip("Most points this ward will buy off in a single hit. 0 means no cap, so a rich " +
"actor is immune until the money runs out.")]
[SerializeField, Min(0)] private int maxPointsPerHit;
private bool absorbing;
private bool warnedNoService;
private bool warnedSilentDebit;
/// <summary>Coins burned to buy off one point of damage.</summary>
public int PricePerPoint => pricePerPoint;
/// <summary>Points this ward absorbed on the most recent hit it was asked about.</summary>
public int PointsAbsorbedLast { get; private set; }
/// <summary>What the most recent absorption actually cost, measured off the wallet.</summary>
public long PaidLast { get; private set; }
/// <summary>
/// Coins taken over this ward's lifetime that did not add up to a whole point.
/// </summary>
/// <remarks>
/// Non-zero only where a wallet policy clamped a debit. Bounded in practice by
/// <see cref="PricePerPoint"/> minus one for each time the wallet reaches its floor, because a
/// wallet already at its floor refuses the next debit outright. Worth surfacing in a debug
/// readout; not worth building a refund path for.
/// </remarks>
public long StrandedTotal { get; private set; }
/// <summary>
/// Buys off as much of <paramref name="damage"/> as the wallet can pay for, and reduces it to
/// the remainder.
/// </summary>
/// <remarks>
/// <para>Called by the health system after rules and multipliers have been applied, so
/// <paramref name="damage"/> is the figure that would otherwise reach health.</para>
/// <para>Returns <c>true</c> only when nothing is left to apply, which is what the
/// <see cref="IShield"/> contract reads as full absorption — the hit is then rejected as
/// <c>AbsorbedByShield</c> and no damage event is raised.</para>
/// </remarks>
/// <param name="damage">Incoming damage, reduced in place by whatever was paid for.</param>
/// <returns><c>true</c> when the hit was bought off completely.</returns>
public bool TryAbsorb(ref int damage)
{
PointsAbsorbedLast = 0;
PaidLast = 0;
if (damage <= 0)
return false;
// A wallet listener that damages this actor lands here from inside the debit below. The
// balance is mid-write and the money for this hit is already committed, so the honest
// answer to the nested hit is "not paid for" rather than a second charge.
if (absorbing)
return false;
// [Min(1)] only clamps the inspector, and a shield throws into the middle of a hit — between
// the damage being computed and health being decremented — so a divide by zero here escapes
// TakeDamage rather than the call that set the price. Cheap insurance for the blast radius.
if (pricePerPoint <= 0)
return false;
var wallet = ResolveWallet();
if (wallet == null)
return false;
var id = new CurrencyId(currencyId);
int points = AffordablePoints(wallet, id, damage);
if (points <= 0)
return false;
long bill = (long)points * pricePerPoint;
long before = wallet.GetBalance(gameObject, id).amount;
long taken;
absorbing = true;
try
{
// Debit reports an outcome, not an amount, and a clamped wallet can succeed having
// moved less than the bill — so the mitigation comes from the measurement below and
// the result is kept for the one case the measurement cannot explain.
var paid = wallet.Debit(gameObject, id, new Money(bill), nameof(CoinWard));
taken = before - wallet.GetBalance(gameObject, id).amount;
if (taken <= 0)
{
// A refusal is ordinary: the purse is empty, or an authority said no. Neither is
// worth a log line per hit. A debit reporting SUCCESS having moved nothing is not
// ordinary — it is a policy or cap swallowing the whole charge, and it looks from
// the outside exactly like a ward that has stopped working.
if (paid.Success && !warnedSilentDebit)
{
warnedSilentDebit = true;
Debug.LogWarning(
$"[{nameof(CoinWard)}] A debit of {bill} '{currencyId}' on '{name}' " +
$"reported success ({paid.Code}) and moved nothing. Check the wallet's " +
"policy: this ward will absorb nothing while that holds.", this);
}
return false;
}
}
finally
{
absorbing = false;
}
int bought = (int)(taken / pricePerPoint);
long stranded = taken - ((long)bought * pricePerPoint);
if (stranded > 0)
{
StrandedTotal += stranded;
Debug.LogWarning(
$"[{nameof(CoinWard)}] '{name}' paid {taken} '{currencyId}' but only {bought} " +
$"whole point(s) came of it; {stranded} bought nothing. A minimum-balance policy " +
"clamping the debit is the usual cause.", this);
}
if (bought <= 0)
return false;
if (bought > damage)
bought = damage;
damage -= bought;
if (damage < 0)
damage = 0;
PointsAbsorbedLast = bought;
PaidLast = taken;
return damage == 0;
}
/// <summary>
/// What would reach health if this hit landed right now, without spending anything.
/// </summary>
/// <remarks>
/// <para>Mutation-free, which the <see cref="IShieldPreview"/> contract requires and this
/// ward makes easy: the answer is arithmetic over a balance it only reads.</para>
/// <para><b>It is a claim about this instant.</b> Anything that moves the wallet between the
/// preview and the hit moves the answer with it — the preview is not a reservation, and this
/// ward deliberately does not take one. A hold that survived a save would need persisting, and
/// a damage preview is not worth that.</para>
/// </remarks>
/// <param name="incoming">Damage being previewed.</param>
/// <returns>The part of it the wallet could not buy off.</returns>
public int PreviewRemainder(int incoming)
{
if (incoming <= 0)
return incoming;
var wallet = ResolveWallet();
if (wallet == null)
return incoming;
int points = AffordablePoints(wallet, new CurrencyId(currencyId), incoming);
return points >= incoming ? 0 : incoming - points;
}
/// <summary>
/// How many points of <paramref name="incoming"/> the wallet can pay for right now.
/// </summary>
/// <remarks>
/// Both caps, and the per-hit one is not decoration: without it a wealthy actor is immune to
/// everything, which is a difficulty setting rather than a ward. The division floors, so coins
/// that cannot buy a whole point are never asked for — the only way partial money leaves this
/// wallet is a policy clamping the debit after the fact.
/// </remarks>
private int AffordablePoints(ICurrencyService wallet, CurrencyId id, int incoming)
{
if (pricePerPoint <= 0)
return 0;
int want = maxPointsPerHit > 0 && maxPointsPerHit < incoming ? maxPointsPerHit : incoming;
long balance = wallet.GetBalance(gameObject, id).amount;
if (balance < pricePerPoint)
return 0;
long affordable = balance / pricePerPoint;
return affordable >= want ? want : (int)affordable;
}
/// <summary>
/// Resolves the currency service, complaining once if there is not one.
/// </summary>
/// <remarks>
/// Warned lazily rather than on enable, because a ward enabled during scene load can easily
/// run before the service does and a warning then would be wrong. A ward that is never hit
/// never complains, which is the right trade: the failure this catches is a ward that is being
/// asked to work and silently cannot.
/// </remarks>
private ICurrencyService ResolveWallet()
{
var wallet = CurrencyResolve.ServiceFrom(gameObject);
if (wallet == null && !warnedNoService)
{
warnedNoService = true;
Debug.LogWarning(
$"[{nameof(CoinWard)}] No currency service resolved for '{name}', so this ward " +
"absorbs nothing and every hit lands in full.", this);
}
return wallet;
}
}
}
Wiring it up¶
- Make sure the actor's scene has a currency service to resolve — a
SceneCurrencyService, or one published throughCurrencyBootstrap.Publish— and that the actor's wallet holds the currency. With no service the ward warns once and every hit lands in full. - Put the component on the actor, beside its health component, before play starts — a ward added at runtime needs
RefreshOptionalComponents()orSetShieldbefore it is asked anything. If the actor already has a shield, add aShieldChainas well and let it own the order. - Set the currency, the price per point, and — do set this — a per-hit cap. Left at zero a wealthy actor is immune to everything, which is a difficulty setting rather than a ward.
- Read
PointsAbsorbedLastandPaidLastfor a floating-combat-text readout.StrandedTotalis a debug number: non-zero means a wallet policy is clamping debits.
What it deliberately does not do¶
It does not reserve anything. No hold, no escrow. A hold that survived a save would have to be persisted — the reason crafting's own escrow path is immediate-only — and a damage preview is not worth that machinery.
It does not refund a part-paid point. See above: a refused credit inside a hit is worse than the crumb it would recover, and the crumb is bounded and reported.
It does not pay anybody. The charge is a Debit, so the gold burns. Swap it for Transfer(gameObject, someone, ...) if the ward should feed a patron.
It does not price by percentage or scale with the hit. Money is long and a price per point is a whole number of coins; a float multiplier would reintroduce exactly the rounding the money rules exist to keep out. A cheaper ward for big hits is a per-hit cap plus a lower price, expressed in integers.
It does not stop the actor dying. It reduces what reaches health, and if the wallet cannot cover the whole hit the remainder is ordinary damage that can be lethal. Surviving an otherwise-lethal hit is Last stand, and the two compose.
Related¶
- Health — the damage pipeline, and where shields sit in it.
- Currency — balances, debits and why a debit can be refused.
- A healer who charges by the point — the same measure-the-delta discipline, and the charge-last ordering this one deliberately inverts.
- A pickup you have to pay for — where charging last is the right answer and why.
- A shop that takes payment in blood — the mirror image: health as the money, rather than money as the armour.
- Last stand — the other way to survive a hit you should not have.