Skip to content

A healer who charges by the point

Pay for the healing you actually received — not the healing you asked for.

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 patient has a health component, the patient's scene has a currency service to resolve, and the patient's wallet holds the currency you charge in. 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

IHealthWriter.TryHeal returns a bool, documented as "true when any healing was applied".

That is a smaller promise than it looks. A true means at least one point landed — nothing more. A healing rule may have scaled the amount down before it arrived, or up, and the clamp at maximum absorbs whatever was left over — or, when the patient carries an OverhealShield and the default overheal spill is on, turns it into temporary shield HP the health delta will never see. Ask to heal 50 on a patient who is 3 points down and you get true, three points, and no way to tell.

So the amount you asked for is the one number you must not bill.

Reach for IHealthWriter, not IHealable

IHealable is public and declares exactly the method this recipe wants. Nothing in the framework implements itHealthSystem exposes IHealthWriter, which declares TryHeal with the same signature and word-for-word the same documented return.

Resolving IHealable compiles, reads correctly, and never finds a component, so every heal silently does nothing. This recipe was written that way and had to be corrected.

Check what implements an interface before reaching for it, not just what it is called.

The delta is the only truth — on both seams

Read Current either side of the heal and charge for the difference. Two property reads, correct by construction, and it stays correct whatever rules the project later hangs off the pipeline.

Then do the same to the wallet. ICurrencyService.Debit reports an outcome, not an amount, and a Clamp-mode floor can take less than the bill and still report success — the health system's problem exactly, one system over. The framework's own transaction helpers measure the same way, and it is the only preflight a recipe can reach: the policy-aware one needs the CurrencyPolicy asset, and nothing on ICurrencyService will hand it over. So the receipt carries measured money, not requested money.

Cap the request before you make it — by both limits

The patient can only receive Max - Current. They can only pay for balance / pricePerPoint. Heal the smaller of the two.

Either cap alone is a bug

Without the deficit cap, a nearly-full patient is charged for points the clamp throws away — the overcharge players notice first and tests catch last.

Without the affordability cap, a short purse is refused outright instead of buying the partial heal it could afford. A healer who says "no" to someone with money in their hand is a worse healer than one who says "this much".

And capping the request is not enough on its own. LowHPHealBoostRule ships with a x2 multiplier that fires below 25% health — exactly the patient a paid healer exists for — so more can land than the purse covers. Cap the bill at the quoted price as well: the extra points a heal buff delivers are the buff doing its job, not a billing failure.

Charge last — and not because the heal could be taken back. The priced pickup arrives at the same ordering from the other direction, but the tempting justification — everything above the debit is reversible by doing nothing — is not true here, and it is worth being blunt about that: the thing above the debit is the delivery. The honest reason is arithmetic. The bill cannot be computed until the mutation has happened, because the delta is the bill. The cost of that ordering is that a refused debit cannot be walked back — reversing a heal is damage, from a healer, to fix a billing error — so that case warns loudly and says so on the receipt.

A corpse and a full-health patient are refused before anything is charged

The heal processor already refuses a dead actor, so healing-then-charging would have been safe anyway. It is checked at the top regardless, because that safety depends entirely on the debit staying last — and a recipe that reads "refuse a corpse" up front cannot be broken by someone later moving one line.

The window this leaves, stated rather than hidden

Anything else that heals the patient between the two reads is attributed to this healer and billed. It is a synchronous gap with no yield in it, so in practice only a heal raised from inside the pipeline itself can land there.

Closing it properly needs a heal that reports its own applied amount — which is the seam this recipe is quietly asking for, and the third time the recipes have run into a mutation that will not say what it did.

All arithmetic in long, no floats anywhere. Money is long and a price per point is a whole number of coins, so there is nothing a float would improve here and several things it would corrupt.

Drop it in

PaidHealer.cs
using RevGaming.RevFramework.Currency;
using RevGaming.RevFramework.Currency.Abstractions;
using RevGaming.RevFramework.Health.Abstractions;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.PaidHealing
{
    /// <summary>
    /// A healer who charges by the point — and charges for the healing that actually landed, not the
    /// healing that was asked for.
    /// </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.
    /// The patient needs a health component and a wallet, so the scene needs a currency service to
    /// resolve.</para>
    ///
    /// <para><b>Use <see cref="IHealthWriter"/>, not <c>IHealable</c>.</b> <c>IHealable</c> is public,
    /// declares exactly the method this wants, and <b>nothing in the framework implements it</b> —
    /// resolving it compiles, reads correctly, and never finds a component. <c>HealthSystem</c> exposes
    /// <see cref="IHealthWriter"/>, with the same signature and the same documented return. Check what
    /// implements an interface before reaching for it, not just what it is called.</para>
    ///
    /// <para><b>The delta is the only truth, on both seams — and that is the whole recipe.</b>
    /// <see cref="IHealthWriter.TryHeal"/> returns <c>bool</c>, documented as "true when <i>any</i>
    /// healing was applied", so it says one point landed and nothing more: a rule may scale the amount
    /// either way, and the clamp at maximum absorbs the rest or hands it to an <c>OverhealShield</c> as
    /// temporary HP the delta never sees. Charge what you asked for and you overcharge every patient
    /// who was nearly full. So read <see cref="IHealthReadonly.Current"/> either side and charge the
    /// difference — then do the same to the wallet, because <see cref="ICurrencyService.Debit"/>
    /// reports an outcome rather than an amount, and a capped wallet can take less than it was asked
    /// for and still report success.</para>
    ///
    /// <para><b>Cap the request by both limits — then cap the bill too.</b> The patient can only
    /// receive <c>Max - Current</c> and only pay for <c>balance / pricePerPoint</c>; healing the
    /// smaller means a short purse buys a partial heal instead of a refusal. Capping the <i>request</i>
    /// is not enough on its own: a shipped rule such as <c>LowHPHealBoostRule</c> scales the amount up
    /// inside the pipeline, so more can land than the purse covers. The bill is capped at the quoted
    /// price — the extra points are the buff doing its job, not a billing failure.</para>
    ///
    /// <para><b>Charge last, and not because the heal could be taken back.</b> The bill cannot be
    /// computed until the mutation has happened — the delta <i>is</i> the bill. That is the honest
    /// reason, and worth stating in place of the tidier one: the step above the debit is the delivery,
    /// and it is the one here with no inverse. A refused debit therefore warns loudly and is reported
    /// on the receipt rather than swallowed.</para>
    ///
    /// <para><b>A corpse and a full-health patient are refused before anything is charged</b>, since
    /// the heal processor already refuses a dead actor and charging first would take money for a
    /// guaranteed no-op.</para>
    ///
    /// <para><b>The window this leaves, stated rather than hidden.</b> Anything else that heals the
    /// patient between the two reads — a regeneration tick, another handler — is attributed here and
    /// billed. It is a synchronous gap with no yield in it, so in practice only a heal raised from
    /// inside the pipeline can land there; closing it needs a heal that reports its own applied amount,
    /// which is the seam this recipe is quietly asking for. All arithmetic is in <see cref="long"/>:
    /// money is <c>long</c> and a price per point is whole coins, so a float would improve nothing and
    /// corrupt several things.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class PaidHealer : MonoBehaviour
    {
        /// <summary>What one visit cost and what it bought.</summary>
        public readonly struct Receipt
        {
            /// <summary>Health points that actually landed. Zero when nothing was healed.</summary>
            public readonly int pointsHealed;

            /// <summary>What the wallet actually gave up for them, measured either side of the debit.</summary>
            public readonly long charged;

            /// <summary>
            /// True when the healing landed and the patient did not pay for all of it — the debit was
            /// refused outright, or a wallet policy let it take less than the bill.
            /// </summary>
            public readonly bool unpaid;

            public Receipt(int pointsHealed, long charged, bool unpaid)
            {
                this.pointsHealed = pointsHealed;
                this.charged = charged;
                this.unpaid = unpaid;
            }

            /// <summary>True when any healing was delivered.</summary>
            public bool Healed => pointsHealed > 0;

            /// <summary>Nothing was healed and nothing was charged.</summary>
            public static Receipt None => new(0, 0, false);
        }

        [Tooltip("Currency the patient pays in.")]
        [SerializeField] private string currencyId = "gold";

        [Tooltip("Cost of one health point. Whole coins -- money is long and this is not a place for floats.")]
        [SerializeField, Min(1)] private int pricePerPoint = 2;

        /// <summary>
        /// What a full heal would cost this patient right now, and how many points it would buy.
        /// </summary>
        /// <remarks>
        /// <para>Public and mutation-free so a shopfront can show the price before anyone commits to it.
        /// A price you cannot inspect without paying it is a price you cannot put on a button.</para>
        /// <para>The price is a ceiling; the points are an estimate. This is pre-rule arithmetic, and a
        /// heal rule can move the delivered amount in either direction — <see cref="Heal"/> re-runs this
        /// same arithmetic and caps the bill by it, so the extra points a heal rule delivers are never
        /// charged for, though the patient may receive more or fewer points than this promises. The two
        /// agree unless the patient's health or purse moved between the quote and the visit.
        /// <c>HealthSystem.PreviewHeal</c>
        /// answers the rules-aware question, at the cost of running the live rule components: it is not
        /// mutation-free the way this is, and it is only on the concrete component.</para>
        /// </remarks>
        /// <param name="patient">Who is being quoted.</param>
        /// <param name="points">Points they would receive, capped by their deficit and their purse.</param>
        /// <returns>The cost of those points.</returns>
        public long Quote(GameObject patient, out int points)
        {
            points = 0;

            if (!TryReadPatient(patient, out var health))
                return 0;

            var wallet = CurrencyResolve.ServiceFrom(patient);
            if (wallet == null)
                return 0;

            points = AffordablePoints(health, wallet, patient);
            return (long)points * pricePerPoint;
        }

        /// <summary>
        /// Heals the patient as far as their purse and their injuries allow, and charges for what landed.
        /// </summary>
        /// <remarks>
        /// The charge is a <see cref="ICurrencyService.Debit"/> against the patient, so the money leaves
        /// the economy rather than arriving in this healer's wallet. Swap it for
        /// <c>Transfer(patient, gameObject, ...)</c> if your healer should actually be paid — it is on
        /// the same interface, is atomic, and raises a wallet event on both sides.
        /// </remarks>
        /// <param name="patient">Who is being healed and billed.</param>
        /// <returns>
        /// A <see cref="Receipt"/>. <see cref="Receipt.None"/> when there was nothing to sell — no
        /// patient, no wallet, already full, dead, too poor for a single point, or a heal the pipeline
        /// refused outright.
        /// </returns>
        public Receipt Heal(GameObject patient)
        {
            if (!TryReadPatient(patient, out var health))
                return Receipt.None;

            var wallet = CurrencyResolve.ServiceFrom(patient);
            if (wallet == null)
            {
                Debug.LogWarning($"[{nameof(PaidHealer)}] No currency service resolved for " +
                                 $"'{patient.name}', so there is no way to charge them.", this);
                return Receipt.None;
            }

            int requested = AffordablePoints(health, wallet, patient);
            if (requested <= 0)
                return Receipt.None;

            // The measurement, either side of the only call that does anything. TryHeal's bool says
            // "at least one point landed" and nothing about how many, so asking the health system what
            // changed is the only way to bill honestly.
            int before = health.Current;
            health.TryHeal(requested);
            int landed = health.Current - before;

            // Negative is not impossible: something else could damage the patient from inside the heal
            // pipeline. Billing a negative would credit them, so the floor is not defensiveness.
            if (landed <= 0)
                return Receipt.None;

            // Landed can EXCEED the request -- LowHPHealBoostRule ships with a x2 multiplier and fires
            // at exactly the patient this component exists for. Billing the raw delta would then hand a
            // short purse a bill it cannot meet, which the wallet refuses, which gives the heal away.
            // The purse cap has to reach the money, not just the request: the bill stops at the quote.
            long earned = (long)landed * pricePerPoint;
            long budget = (long)requested * pricePerPoint;
            long charge = earned < budget ? earned : budget;

            // The same two reads again, on the other seam. Debit answers with an outcome, not an amount,
            // and a Clamp-mode floor can take less than the bill and still report success -- the health
            // system's problem exactly, one system over. The reason argument reaches the audit trail
            // when the stack carries one and is dropped when it does not.
            var id = new CurrencyId(currencyId);
            long walletBefore = wallet.GetBalance(patient, id).amount;
            var paid = wallet.Debit(patient, id, new Money(charge), nameof(PaidHealer));
            long taken = walletBefore - wallet.GetBalance(patient, id).amount;

            if (paid.Success && taken >= charge)
                return new Receipt(landed, taken, unpaid: false);

            // Healing has no inverse -- taking the points back would be damage, from a healer, for a
            // billing error. Reporting it is the honest end of the trade.
            Debug.LogWarning($"[{nameof(PaidHealer)}] Healed '{patient.name}' for {landed} but took " +
                             $"only {taken} of the {charge} '{currencyId}' it cost ({paid.Code}). " +
                             "They have the rest for free.", this);

            return new Receipt(landed, taken, unpaid: true);
        }

        /// <summary>
        /// How many points this patient can both receive and pay for.
        /// </summary>
        /// <remarks>
        /// Both caps, because either alone is a bug. Without the deficit cap a nearly-full patient is
        /// charged for points that the clamp at maximum throws away; without the affordability cap a
        /// short purse is refused a heal it could have part-paid for. Neither cap binds what the heal
        /// pipeline does with the number afterwards, which is why the bill is capped separately.
        /// </remarks>
        private int AffordablePoints(IHealthReadonly health, ICurrencyService wallet, GameObject patient)
        {
            int deficit = health.Max - health.Current;
            if (deficit <= 0)
                return 0;

            long balance = wallet.GetBalance(patient, new CurrencyId(currencyId)).amount;
            long affordable = balance / pricePerPoint;

            return affordable >= deficit ? deficit : (int)affordable;
        }

        /// <summary>
        /// Reads the health seam, refusing a patient there is nothing to sell to.
        /// </summary>
        /// <remarks>
        /// <para>One lookup, not two. <see cref="IHealthWriter"/> derives from
        /// <see cref="IHealthReadonly"/>, so resolving the writer gives the reads as well — and
        /// resolving both separately would let a project that owns an <see cref="IHealthReadonly"/>-only
        /// component measure one object while healing another.</para>
        /// <para>The dead check is here rather than left to the pipeline. <c>TryHeal</c> refuses a dead
        /// actor anyway, so healing first would be safe — but only because the charge happens
        /// afterwards. A recipe that reads "refuse a corpse" at the top cannot be broken by someone
        /// later moving the debit.</para>
        /// </remarks>
        private static bool TryReadPatient(GameObject patient, out IHealthWriter health)
        {
            health = null;

            if (!patient)
                return false;

            if (!patient.TryGetComponent(out health))
                return false;

            return health.IsAlive && !health.IsFull;
        }
    }
}

Wiring it up

  1. Make sure the patient's scene has a currency service to resolve — a SceneCurrencyService, or one published through CurrencyBootstrap.Publish — and that the patient's wallet holds the currency. A patient with no wallet is not an error here; it is a Receipt.None with nothing logged.
  2. Put the component on the healer — an NPC, a shrine, a med bay.
  3. Set the currency and the price per point.
  4. Call Quote(patient, out int points) to fill in the button, and Heal(patient) when it is pressed. The price is a ceiling for that visit — Heal re-runs the same arithmetic and caps the bill by it — and the points are a pre-rule estimate, since a heal rule can move the number actually delivered in either direction.
  5. Read the Receipt for what to show: how many points landed, what the wallet actually gave up, and whether the patient covered the whole bill.

What it deliberately does not do

It does not heal on a timer or over time. A regeneration effect is Status Effects' job and already exists; this is a transaction, and transactions are instantaneous on purpose.

It does not refuse a partial heal. Buying what you can afford is the behaviour most games want, and the Receipt carries enough for a UI to say "that is all you can pay for" rather than "no".

It does not reverse a heal it could not bill. There is no such operation that is not just damage. It reports instead, in a build where the report survives.

It does not pay the healer. The charge is a Debit against the patient, so the money leaves the economy rather than arriving in the healer's wallet. Swap the Debit for Transfer(patient, gameObject, ...) if your healer should actually be paid — same interface, atomic, and it raises a wallet event on both sides.

It does not price by percentage. A percentage of max health reintroduces the float arithmetic that the money rules exist to keep out of prices.

  • Health — the pipeline, rules, and what refuses a heal.
  • Currency — balances, debits and why a debit can be refused.
  • A pickup you have to pay for — the same charge-last ordering where the payload cannot report what it delivered either.
  • Recipes that cost blood — health as the currency instead of the thing being bought.