Skip to content

A shop that takes payment in blood

The same IShopService, the same PriceBundle — and a wallet that happens to be a body.

Recipe

Systems required: Economy, Health — plus Currency, which the shop itself needs even though this ledger never touches it, and Inventory if the shop hands over items. 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

IShopService.Buy takes the ledger it charges. It does not own one, does not look one up, and never asks what a coin is.

So "currency" is whatever you hand it. A shop that charges blood, reputation, sanity, karma or favours owed is not a new shop — it is the same shop with a different IValueLedger. Three methods, no base class, no registration.

The same lesson the status-priced shop found, one level up

There, the price turned out to be an argument, so dynamic pricing was building a different bundle rather than a missing hook. Here the ledger is an argument.

Before asking for a seam, check whether the thing you want to vary is already being passed in. Twice now that has been the answer.

The hard method is the middle one

CanPay is a question with no side effects. Grant is a refund. Pay is where the ordering lives — and it re-checks affordability itself rather than trusting the preflight, because the shop calls them as separate steps and anything can hurt the buyer in between.

The price floor is not a nicety — and it is not a guarantee

A purchase that kills the buyer is a death with no killer, no combat, and no explanation on screen. So the ledger refuses a price that would take the buyer below minimumHealth, rather than paying as much as it can and dying. Partial payment for a whole item is worse than a refusal, and the shop cannot tell the difference unless the ledger refuses outright.

What the floor cannot promise is the outcome. It is checked against the price; the damage pipeline decides what lands. A victim-side multiplier rule — CritRule, or the damage-taken multiplier the Vulnerability status installs on its own — scales the charge after that check, and RuleBypass has no flag that opts a hit out of them. On a buyer carrying one, a purchase can take more than the price and, at the extreme, kill.

Pay therefore builds the DamageContext itself and reads DamageResult.FinalApplied, so it reports what was actually taken instead of assuming. If the floor has to hold absolutely, keep amplifying rules off the buyer.

Item lines are refused — and the obvious reason for it is wrong

A PriceBundle carries money lines and item lines, and a body has no pockets. But ignoring them would not under-charge anybody: IShopService charges the item half itself through the IItemStore and hands the ledger a money-only bundle, which is why IValueLedger documents item lines as ignored on all three methods.

Refusing is a design choice about what this ledger is willing to price, and it costs something. The shop runs its money preflight with the whole bundle, so a mixed price like "5 blood and 1 ruby" — a purchase the shop supports end to end — is refused before it starts, and reported to the UI as the buyer being broke.

Healing clamps, so a payout is worth less to the healthy. Grant arrives as healing, and a refund to a buyer at full health is worth much less than the same refund to a wounded one. That is a genuine consequence of pricing in health rather than coins, and not a bug to fix inside the ledger.

Part of it is banked, though, and by the framework rather than by the recipe. A HealthSystem that also carries an OverhealShield spills the overflow into temporary hit points — spillOverhealToTempShield is on by default — and reports the heal as applied. Without that component the overflow is simply gone and TryHeal returns false, which Grant reports as a failure: a refund that did not arrive is exactly what the shop's compensation path exists to hear about.

What a third-party ledger structurally cannot do

The framework's own ledger implements two extra capabilities: one that explains why a preflight said no, and one that reports the debit it actually applied.

Both interfaces are internal. A ledger written outside the Economy assembly cannot implement either, however much it would like to. So a custom ledger is a slightly second-class one — it can refuse, but it cannot say why in the way the shop understands. Worth knowing before you build one, and worth weighing if your refusals need to reach the UI.

Drop it in

BloodLedger.cs
using RevGaming.RevFramework.Economy;
using RevGaming.RevFramework.Economy.Abstractions;
using RevGaming.RevFramework.Health.Abstractions;
using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.Abstractions.Mutation;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.BloodLedger
{
    /// <summary>
    /// A shop that takes payment in health — the same <see cref="IShopService"/>, the same
    /// <see cref="PriceBundle"/>, and a wallet that happens to be a body.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Economy</b>, <b>Health</b> — and <b>Currency</b> in practice, because <c>ShopService</c> is
    /// <c>internal</c> and the only public route to an <c>IShopService</c> is a bootstrap
    /// (<c>EconomyBootstrap</c>, or <c>EconomyInventoryBootstrap</c> when the shop hands over goods).
    /// Every overload of both takes a currency service and refuses a null one. This ledger never uses
    /// it. Public API only.</para>
    ///
    /// <para><b>The reveal is that money is an argument.</b> <c>IShopService.Buy</c> takes the ledger
    /// it charges — it does not own one, does not look one up, and never asks what a coin is. So
    /// "currency" is whatever you hand it, and a shop that charges blood, reputation, sanity, karma or
    /// favours is not a new shop. It is the same shop with a different ledger.</para>
    ///
    /// <para><b>Which is the same lesson the status-priced shop found one level down.</b> There, the
    /// <i>price</i> turned out to be an argument, so dynamic pricing was building a different bundle
    /// rather than a missing hook. Here the <i>ledger</i> is an argument. Before asking for a seam, it
    /// is worth checking whether the thing you want to vary is already being passed in.</para>
    ///
    /// <para><b>Three methods, and the hard one is the middle.</b> <c>CanPay</c> is a question with no
    /// side effects. <c>Grant</c> is a refund. <c>Pay</c> is where the ordering rules live, and this one
    /// re-checks affordability itself rather than trusting the preflight — the shop calls them as
    /// separate steps, and anything can hurt the buyer in between.</para>
    ///
    /// <para><b>The price floor is not a nicety, and it is not a guarantee either.</b> A purchase that
    /// kills the buyer is a death with no killer, no combat and no explanation, and it is the first
    /// thing a player calls a bug. So the ledger refuses a price that would take the buyer below
    /// <see cref="minimumHealth"/> rather than paying as much as it can and dying. What it cannot do is
    /// promise the outcome: the floor is checked against the <i>price</i>, and the damage pipeline
    /// decides what actually lands. A victim-side multiplier rule — <c>CritRule</c>, or the
    /// damage-taken multiplier the Vulnerability status installs by itself — scales the charge after
    /// that check, and <c>RuleBypass</c> has no flag that opts a hit out of them. So on a buyer
    /// carrying one of those rules a purchase can take more than the price, and at the extreme it can
    /// kill. <c>Pay</c> reports what landed instead of pretending; if the floor has to hold absolutely,
    /// keep amplifying rules off the buyer.</para>
    ///
    /// <para><b>Item lines are refused, and the honest reason is not the obvious one.</b> A
    /// <see cref="PriceBundle"/> can carry money lines and item lines, and a body has no pockets.
    /// Ignoring them would not under-charge anyone — <c>IShopService</c> charges the item half itself
    /// through the <c>IItemStore</c> and hands the ledger a money-only bundle, which is why
    /// <c>IValueLedger</c> documents item lines as ignored. Refusing is a design choice about what this
    /// ledger is willing to price, and it has a cost worth knowing: the shop runs its money preflight
    /// with the <i>whole</i> bundle, so a mixed price like "5 blood and 1 ruby" — a purchase the shop
    /// supports end to end — is refused before it starts, and reported as the buyer being broke.</para>
    ///
    /// <para><b>What a custom ledger structurally cannot do, and it is worth knowing before you
    /// build one.</b> The framework's own ledger implements two extra capabilities — one that explains
    /// <i>why</i> a preflight said no, and one that reports the debit it actually applied. Both
    /// interfaces are <c>internal</c>, so a ledger written outside the Economy assembly cannot
    /// implement either, however much it would like to. A third-party ledger is therefore a slightly
    /// second-class one: it can refuse, but it cannot say why in the way the shop understands, and when
    /// the pipeline takes a different amount than the price the shop still compensates the price.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class BloodLedger : MonoBehaviour, IValueLedger
    {
        [Tooltip("Health points one unit of price costs. 1 means a 10-coin item costs 10 health.")]
        [SerializeField, Min(1)] private int healthPerUnit = 1;

        [Tooltip("Health a purchase must leave behind. A price that would breach it is refused rather " +
                 "than part-paid; see the class remarks for why that is a refusal and not a promise.")]
        [SerializeField, Min(1)] private int minimumHealth = 1;

        [Tooltip("Currency ids this ledger accepts. Empty accepts every id, which is usually what you want " +
                 "when the shop only ever sees this ledger.")]
        [SerializeField] private string[] acceptedIds = new string[0];

        /// <summary>
        /// Whether the buyer could pay this price in health right now.
        /// </summary>
        /// <remarks>
        /// A question, not a reservation: it changes nothing, and it can be wrong by the time
        /// <see cref="Pay"/> runs. That is not a flaw to design around — it is why <see cref="Pay"/>
        /// re-checks.
        /// </remarks>
        public bool CanPay(GameObject owner, in PriceBundle price)
            => owner
               && owner.TryGetComponent<IHealthReadonly>(out var health)
               && TryPriceInHealth(price, out int cost, out _)
               && health.Current - cost >= minimumHealth;

        /// <summary>
        /// Takes the price out of the buyer, or refuses.
        /// </summary>
        /// <returns>
        /// <see cref="EcoOpResult.Ok"/> when the charge landed, carrying a message when the amount the
        /// pipeline took differs from the price. A failure otherwise — a refused charge comes back as
        /// <c>PolicyBlocked</c> carrying the reason the health system gave, rather than one blanket
        /// message for every way a hit can be turned down. A refusal costs the buyer nothing: the
        /// charge bypasses shields, so there is no absorption to leave behind, and health is written
        /// only on the success path.
        /// </returns>
        public EcoOpResult Pay(GameObject owner, in PriceBundle price, string reason = null, string sourceId = null)
        {
            if (!owner || !owner.TryGetComponent<IHealthMutator>(out var health))
                return EcoOpResult.ServiceMissing("No health on the payer.");

            if (!TryPriceInHealth(price, out int cost, out string refusal))
                return EcoOpResult.InvalidArgs(refusal);

            if (cost <= 0)
                return EcoOpResult.Ok();

            // Re-checked here rather than trusting CanPay. The shop calls them as separate steps and the
            // world moves in between -- a trap on the way to the counter is enough.
            if (health.Current - cost < minimumHealth)
                return EcoOpResult.InsufficientFunds($"Needs {cost} health and cannot go below {minimumHealth}.");

            // The context is built by hand rather than through a DealDamage extension, and three fields
            // are the whole reason.
            //
            // Attacker stays null because there is no attacker. Putting the buyer there -- which every
            // extension overload does -- copies the buyer's own team id into the hit, and TeamRule then
            // cancels it as friendly fire, so a blood shop silently never works on any buyer with a
            // team. A null attacker also stops LifestealRule rebating the payment straight back.
            //
            // BypassShields, because temporary hit points are a ward and not blood. A shield left in the
            // path absorbs the payment and the pipeline then reports a refusal -- having already spent
            // what it absorbed.
            //
            // Armor and Affinity are bypassed for the same reason: a breastplate does not make blood
            // cheaper. They are the only two families RuleBypass offers.
            var ctx = DamageContext.CreateBasic(null, owner, cost, DamageTag.None);
            ctx.BypassShields = true;
            ctx.BypassRules = RuleBypass.Armor | RuleBypass.Affinity;
            ctx.SourceId = sourceId;

            // ApplyDamage rather than the bool-returning extensions. The bool cannot tell "took 90"
            // from "took 100 and killed them", and cannot say why a refusal happened -- and this is the
            // one place that needs both.
            DamageResult charge = health.ApplyDamage(in ctx);

            if (!charge.Applied)
                return Refuse(charge.Reason);

            // Reported, not corrected. What landed is what the pipeline decided, and a ledger outside
            // the Economy assembly cannot tell the shop the real debit -- the interface for that is
            // internal -- so a message is the whole of what can be said here.
            return charge.FinalApplied == cost
                ? EcoOpResult.Ok()
                : EcoOpResult.Ok($"Took {charge.FinalApplied} health for a price of {cost}.");
        }

        /// <summary>
        /// Pays the buyer back — a refund, a sale, or a reward, arriving as healing.
        /// </summary>
        /// <remarks>
        /// <para>Healing clamps at maximum, so a payout to a healthy buyer is worth less than the same
        /// payout to a wounded one. That is a real consequence of pricing in health rather than coins,
        /// and not something to fix inside a ledger.</para>
        ///
        /// <para>Part of it is not lost, though, and it is worth knowing which part. A
        /// <c>HealthSystem</c> that also carries an <c>OverhealShield</c> spills the overflow into
        /// temporary hit points — <c>spillOverhealToTempShield</c> is on by default — and reports the
        /// heal as applied. Without that component the overflow is simply gone and <c>TryHeal</c>
        /// returns false, which this method reports as a failure, because a refund that did not arrive
        /// is exactly what the shop's compensation path exists to hear about.</para>
        /// </remarks>
        public EcoOpResult Grant(GameObject owner, in PriceBundle payout, string reason = null, string sourceId = null)
        {
            // IHealthWriter, not IHealable. The name that fits is the one that does not work: IHealable
            // is public and declares exactly this method, but nothing in the framework implements it --
            // HealthSystem exposes IHealthWriter instead, which declares TryHeal with the same signature
            // and the same documented return. Resolving IHealable here compiled, read correctly, and
            // made every payout a silent ServiceMissing. Check what implements an interface before
            // reaching for it, not just what it is called.
            if (!owner || !owner.TryGetComponent<IHealthWriter>(out var healable))
                return EcoOpResult.ServiceMissing("No health writer on the payee.");

            if (!TryPriceInHealth(payout, out int amount, out string refusal))
                return EcoOpResult.InvalidArgs(refusal);

            if (amount <= 0)
                return EcoOpResult.Ok();

            // The bool is not the amount -- but it is exactly "did anything happen", which is the
            // question Grant's return type asks. Discarding it made every payout a success, including
            // the ones that healed nothing, and Grant is the shop's refund path: a compensation the
            // ledger reports as fine is one the framework never warns about and never reports through
            // CompensationFailureReport. It is also the sale payout, where a false reported as Ok means
            // the seller's items are gone and nothing arrived.
            if (!healable.TryHeal(amount))
                return EcoOpResult.PolicyBlocked(
                    "The payout applied no healing: the payee is at full health, is dead, or a rule refused it.");

            return EcoOpResult.Ok();
        }

        /// <summary>
        /// Converts a bundle's money lines into health, refusing anything a body cannot pay.
        /// </summary>
        private bool TryPriceInHealth(in PriceBundle bundle, out int cost, out string refusal)
        {
            cost = 0;
            refusal = null;

            // A body has no pockets, so this ledger prices only the money half of a bundle and refuses
            // rather than half-answering. See the class remarks for what that costs.
            if (bundle.Items != null && bundle.Items.Count > 0)
            {
                refusal = "This ledger deals in health, and a body cannot pay an item line.";
                return false;
            }

            if (bundle.Money == null)
                return true;

            long total = 0;

            for (int i = 0; i < bundle.Money.Count; i++)
            {
                ChargeLine line = bundle.Money[i];

                if (!line.IsValid || !Accepts(line.Id))
                {
                    refusal = $"This ledger does not accept a price line of '{line.Id}'.";
                    return false;
                }

                // Saturating, not clamped at the end. A price is long and health is int, and the earlier
                // version of this clamped only the final cast -- so a bundle big enough to wrap the long
                // arithmetic arrived as a NEGATIVE total, cast to a cost of zero, and Pay handed the
                // goods over for nothing. Anything past int.MaxValue is unpayable by a body anyway, so
                // hold the running total there and let CanPay refuse it.
                long lineCost = System.Math.Min(line.Amount, int.MaxValue) * healthPerUnit;
                total = System.Math.Min(total + System.Math.Min(lineCost, int.MaxValue), int.MaxValue);
            }

            cost = (int)total;
            return true;
        }

        /// <summary>
        /// Turns a damage rejection into an economy refusal that says why.
        /// </summary>
        /// <remarks>
        /// The code is always <c>PolicyBlocked</c> — every one of these is the health system declining
        /// a mutation it was asked for — and the reason is carried in the message, which is the only
        /// place a ledger outside the Economy assembly can put it.
        /// </remarks>
        private static EcoOpResult Refuse(DamageRejectionReason reason)
            => EcoOpResult.PolicyBlocked(reason switch
            {
                DamageRejectionReason.TargetIsDead => "The buyer is dead and cannot pay.",
                DamageRejectionReason.Invincible => "The buyer is invincible, so the payment could not be taken.",
                DamageRejectionReason.DamageLocked => "The buyer's health is locked against damage.",
                DamageRejectionReason.AuthorityBlocked => "A health authority refused the payment.",
                DamageRejectionReason.AbsorbedByShield => "A shield absorbed the payment.",
                DamageRejectionReason.ZeroOrNegativeAmount => "A damage rule reduced the payment to nothing.",
                _ => "A damage rule cancelled the payment."
            });

        private bool Accepts(string id)
        {
            if (acceptedIds == null || acceptedIds.Length == 0)
                return true;

            for (int i = 0; i < acceptedIds.Length; i++)
            {
                // Ordinal, because these are ids rather than words.
                if (string.Equals(acceptedIds[i], id, System.StringComparison.OrdinalIgnoreCase))
                    return true;
            }

            return false;
        }
    }
}

Wiring it up

  1. Put the component on the shopkeeper, or anywhere your shop code can reach. The buyer is the one who pays: the ledger reads health off the GameObject the shop passes it, never off itself.
  2. Set the health-per-unit rate and the floor.
  3. Build the Economy services as usual — EconomyBootstrap.BuildForPlayer(player, currency). ShopService is internal, so a bootstrap is the only public route to an IShopService, and every overload wants a non-null ICurrencyService. The shop needs a currency service to exist even though this ledger never uses one.
  4. Pass BloodLedger at the Buy call site instead of the ledger the bootstrap handed you.
  5. Buy delivers items through an IItemStore, so a blood shop that hands over goods also needs Inventory (EconomyInventoryBootstrap). A blood shop selling services, unlocks or passage can pass store: null.

A ledger is a service, and this one is a component

The shop holds the reference you hand it for the life of the call, and its null guard is typed as the interface — so a destroyed MonoBehaviour ledger is fake-null and slips straight past it. That is harmless here only because this class touches no Unity state of its own: three serialized fields, no transform, no cached components. A ledger that caches a wallet in Awake or logs with this would throw from inside the transaction, after the preflight. Keep a ledger component stateless, or keep it on something that outlives every shop it is handed to.

What it deliberately does not do

It does not mix money and blood. A bundle is paid entirely in health or refused. Splitting a price across two ledgers is a shop-level decision and does not belong inside one of them.

It does not promise the buyer survives. It refuses any price that would breach the floor, and it neutralises what it can — the payment carries no attacker, so friendly fire and lifesteal do not see it, and it bypasses shields, armour and affinity. What it cannot neutralise is a victim-side multiplier rule, because no public flag exists to opt out of one. See the warning above.

It does not silently under- or over-report. Pay returns the reason the health system gave when a charge is refused, and says so in the message when the pipeline took a different amount than the price. What it cannot do is tell the shop the real debit — that interface is internal — so a rollback still compensates the nominal price.