Short on gold, but you have silver¶
The player has 10 gold and a thousand silver. The sword costs 40 gold. They can afford it — it is simply in the wrong pocket, and the shop says no.
Currency ships an exchange. Economy ships a ledger. Nothing joins them, so the fix is a decorator about twenty lines long — and the interesting part is that the exchange only quotes in the direction you do not need.
Recipe
Systems required: Currency, Economy. Package: Currency & Economy, or Complete. Shape: one class you drop into a project that already exists. Not a component — a plain C# object you hand to IShopService.Buy in place of the ledger. No scene, no prefab. Public API only. It assumes: you already build your economy services, and you have an exchange table with a rule for each pair you want converted. 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¶
TableCurrencyExchange implements ICurrencyExchange, and a Teaching panel demonstrates it. The shipped ledger contains zero references to an exchange of any kind — a price in gold is paid in gold or refused.
So the two halves exist and nothing connects them. This decorates the ledger the bootstrap already gave you, which is what keeps its policy, holds and authority doing their jobs:
var (shop, rewards, crafting, ledger, store) = EconomyBootstrap.BuildForPlayer(player, wallet, policy);
var paying = new ExchangingLedger(ledger, wallet, exchange, new[] { new CurrencyId("silver") });
shop.Buy(player, paying, store, in price, goods);
The quote runs the wrong way¶
ICurrencyExchange gives you one question, and it is the reverse of the one you have:
bool TryQuote(CurrencyId src, CurrencyId dst, long srcAmount, out long dstAmount);
That answers "how much gold for 100 silver". You need "how much silver for the 30 gold I am short", and there is no call for it.
Do not divide by a unit quote
The obvious inverse — quote one silver, divide the shortfall by the rate — is wrong three times over, and the shipped table shows all three:
var raw = (double)srcAmount * r.rate * (1.0 - fee);
long q = r.roundDown ? (long)Math.Floor(raw) : (long)Math.Round(raw, MidpointRounding.AwayFromZero);
if (q <= 0) return false;
- the fee is a percentage, so the rate you infer from one unit is not the rate for a hundred;
- the rounding is applied to the total, not per unit, so the error does not scale linearly;
minSrcandmaxSrcare hard bounds — outside them there is no quote at all, not a clamped one — so a one-unit probe can legitimately returnfalsewhile the amount you actually need quotes perfectly well.
A quote of 1 returning false does not mean the currencies are unconvertible. It usually means you asked below the minimum.
So the inverse is a search. This one binary-searches the smallest source amount whose quote covers the shortfall, after quoting the ceiling once to establish that the buyer's whole balance can cover it at all. Sixty-odd quotes at worst, every one side-effect free.
It assumes the quote is monotonic, and that is an assumption about the implementation
More source can never yield less destination — true of the shipped table, and promised nowhere in ICurrencyExchange. A custom exchange with a stepped or tiered rate can defeat the search.
It cannot mint anything if it does: the worst case is a conversion that turns out not to cover the shortfall, and the payment then fails on its own terms.
Converting is not reversible, so the ordering is the recipe¶
Every other charge in this Cookbook can be undone by doing nothing, or sized to what was paid. A pickup you have to pay for charges last so a refused delivery needs no refund. A shield that spends money charges first because the mitigation can be sized to the payment.
An exchange is neither.
There is nothing to roll back with
Rates are not symmetric — a fee is charged in each direction — so converting back loses more than it recovers. And ICurrencyExchange says so itself:
Exact mutation and rollback behaviour is implementation-defined. Full transactional atomicity is not guaranteed.
The answer is not a compensating action. It is a narrower window: convert the exact minimum, convert it last, and convert only after everything else that could refuse has already been asked. So the inner ledger is tried unconverted first, and a buyer who could always afford it never has a coin exchanged.
The window that is left, and it cannot be closed from out here¶
IValueLedger.CanPay returns a bool. The shipped ledger does know why it refused — it says so through ILedgerPreflightReason — and that interface is internal.
So a decorator can see that the inner ledger refused, and never why
A refusal caused by a spending authority is indistinguishable from an empty pocket. This class converts only when the inner ledger reported InsufficientFunds, which is the coarsest test available and the best one there is: any other code is left alone rather than answered with somebody's silver.
When the retry is refused anyway, the buyer has paid an exchange fee for nothing. It is reported loudly, LastConversions says exactly what moved, and it is not reversed.
This recipe is the concrete customer case for making that interface public. It is the framework's parked question 9, and until it is answered the window stays open.
What it deliberately does not do¶
It does not convert item lines. A PriceBundle can charge goods as well as money, and no rate turns silver into three pelts. Those are forwarded and the inner ledger owns the answer.
It does not convert on Grant. A payout arrives in whatever the payer chose, and converting it would be this class deciding what the player would rather hold.
It does not top up beyond the shortfall. It raises exactly what is missing, not a round number, because every converted coin is a fee the player did not have to pay.
It does not decide the order. The fallback list is yours and it matters: this empties the first currency before touching the second, so put the one the player values least first.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Currency;
using RevGaming.RevFramework.Currency.Abstractions;
using RevGaming.RevFramework.Economy;
using RevGaming.RevFramework.Economy.Abstractions;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.ExchangingLedger
{
/// <summary>
/// A wallet that pays in gold and, when the gold runs short, converts silver at the till — so a
/// purchase the buyer can afford in total is not refused because it is in the wrong pocket.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Currency</b>, <b>Economy</b> — the same package, so this runs on Currency & Economy as
/// well as Complete. Public API only. It is a plain C# class you hand to
/// <c>IShopService.Buy</c> in place of the ledger the bootstrap gave you.</para>
///
/// <para><b>Exchange ships, and nothing connects it to a payment.</b> <c>TableCurrencyExchange</c>
/// implements <see cref="ICurrencyExchange"/> and a Teaching panel demonstrates it, but the shipped
/// ledger contains no reference to an exchange of any kind: a price in gold is paid in gold or
/// refused. This is the twenty lines in between, and it is a decorator rather than a replacement —
/// <c>EconomyBootstrap.BuildForPlayer</c> hands back a real <see cref="IValueLedger"/> and this
/// wraps it, so every rule, policy and hold the shipped one honours is still doing its job.</para>
///
/// <para><b>The quote runs the wrong way, and that decides the whole implementation.</b>
/// <see cref="ICurrencyExchange.TryQuote"/> answers <i>"how much gold for 100 silver"</i>. The
/// question here is the inverse — <i>"how much silver for the 40 gold I am short"</i> — and there
/// is no call for it. <b>Do not divide by a unit quote.</b> The shipped table applies a percentage
/// fee and then floors or rounds the <i>total</i>, and it enforces hard <c>minSrc</c>/<c>maxSrc</c>
/// bounds outside which there is <b>no quote at all</b> rather than a clamped one — so the rate is
/// not linear near the ends and a one-unit quote can legitimately return <c>false</c>. The only
/// sound inversion is a search, and this one binary-searches the smallest source amount whose quote
/// covers the shortfall.</para>
///
/// <para><b>The search assumes the quote is monotonic, which is an assumption about the
/// implementation rather than the interface.</b> The shipped table satisfies it — more source can
/// never yield less destination — but <see cref="ICurrencyExchange"/> promises nothing of the kind,
/// so a custom exchange with a tiered or stepped rate can defeat the search. It cannot mint money
/// if it does: the worst outcome is a conversion that turns out not to cover the shortfall, and the
/// payment below then fails on its own terms.</para>
///
/// <para><b>Converting is not reversible, so the ordering is the recipe.</b> Every other charge in
/// this Cookbook is undone by doing nothing (<c>PricedPickup</c>) or is sized to what was paid
/// (<c>CoinWard</c>). <b>An exchange is neither.</b> Rates are not symmetric — a fee is charged in
/// each direction — so converting back loses more than it recovers, and
/// <see cref="ICurrencyExchange.TryExchange"/>'s own remarks say <i>"Full transactional atomicity
/// is not guaranteed."</i> There is nothing to roll back with, and the answer is not a compensating
/// action but a narrower window: convert the exact minimum, convert it last, and convert it only
/// once everything else that could refuse has been asked.</para>
///
/// <para><b>The window that is left, and it cannot be closed from out here.</b>
/// <c>IValueLedger.CanPay</c> returns a <c>bool</c>. The shipped ledger <i>does</i> know why it
/// refused and says so through <c>ILedgerPreflightReason</c> — which is <c>internal</c>, so a
/// third-party ledger can see <b>that</b> the inner one refused and never <b>why</b>. So a refusal
/// caused by a spending authority or a policy is indistinguishable from a refusal caused by an
/// empty pocket, and this class can only find out by converting and trying. When that happens the
/// buyer has been converted for nothing; it is reported loudly and not reversed. <b>This recipe is
/// the concrete case for making that interface public</b> — see the framework findings note.</para>
///
/// <para>Two limits, stated rather than hidden. <b>Item lines are forwarded untouched:</b> a
/// <see cref="PriceBundle"/> can charge goods as well as money, and no exchange rate turns silver
/// into three pelts. And <b><see cref="Grant"/> is a straight forward</b> — a payout arrives in the
/// currency the payer chose, and converting it would be this class deciding what the player wants
/// to hold.</para>
/// </remarks>
public sealed class ExchangingLedger : IValueLedger
{
private readonly IValueLedger _inner;
private readonly ICurrencyService _wallet;
private readonly ICurrencyExchange _exchange;
private readonly IReadOnlyList<CurrencyId> _fallbacks;
/// <summary>What the last <see cref="Pay"/> converted, for a receipt or a confirm prompt.</summary>
/// <remarks>
/// Reset by every <see cref="Pay"/>, so read it immediately after the call it belongs to.
/// Empty when nothing was converted, which is the ordinary case.
/// </remarks>
public IReadOnlyList<Conversion> LastConversions => _lastConversions;
private readonly List<Conversion> _lastConversions = new();
/// <summary>One leg of a top-up: <see cref="SourceAmount"/> of <see cref="From"/> became <see cref="To"/>.</summary>
public readonly struct Conversion
{
public readonly CurrencyId From;
public readonly CurrencyId To;
public readonly long SourceAmount;
public Conversion(CurrencyId from, CurrencyId to, long sourceAmount)
{
From = from;
To = to;
SourceAmount = sourceAmount;
}
}
/// <summary>
/// Wraps a ledger so it can top up from other currencies.
/// </summary>
/// <param name="inner">
/// The ledger to decorate — the one <c>EconomyBootstrap.BuildForPlayer</c> returned. Every
/// payment still goes through it, so its policy, holds and authority are untouched.
/// </param>
/// <param name="wallet">Currency service the balances are read from.</param>
/// <param name="exchange">Exchange used for quoting and converting.</param>
/// <param name="fallbacks">
/// Currencies to convert <i>from</i>, in the order they should be spent. Order is a design
/// decision and not a detail: put the currency the player values least first, because this
/// will empty it before touching the next one.
/// </param>
public ExchangingLedger(
IValueLedger inner,
ICurrencyService wallet,
ICurrencyExchange exchange,
IReadOnlyList<CurrencyId> fallbacks)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_wallet = wallet ?? throw new ArgumentNullException(nameof(wallet));
_exchange = exchange ?? throw new ArgumentNullException(nameof(exchange));
_fallbacks = fallbacks ?? Array.Empty<CurrencyId>();
}
/// <summary>
/// Whether the buyer could pay, counting what a conversion could raise.
/// </summary>
/// <remarks>
/// <para><b>Not a forward.</b> Forwarding this is the instinct and it is the bug: the inner
/// ledger answers about the pocket the price names, so a buyer with the money in silver is told
/// no and the shop greys out a purchase <see cref="Pay"/> would complete. A decorator has to
/// re-answer every method in its own terms — the rule <c>FloorStore</c> found in this same
/// system, and <c>RollingBackupStore</c> found in Core.</para>
/// <para>Mutation-free: it quotes and never exchanges. That makes it a claim about this
/// instant, not a reservation — anything that moves the wallet between here and
/// <see cref="Pay"/> moves the answer with it.</para>
/// </remarks>
public bool CanPay(GameObject owner, in PriceBundle price)
{
if (_inner.CanPay(owner, in price))
return true;
if (!owner)
return false;
// Quoted against a copy of the balances, because two lines can want the same fallback and
// the first one's spending has to be visible to the second. Without this a buyer with 100
// silver is reported able to cover two 100-silver shortfalls.
var reserved = new Dictionary<string, long>(StringComparer.Ordinal);
return TryPlan(owner, in price, reserved, plan: null);
}
/// <summary>
/// Pays, converting from the fallback currencies only as far as the shortfall requires.
/// </summary>
/// <remarks>
/// <para>The inner ledger is tried <b>first and unconverted</b>, so a buyer who could always
/// afford it never has a coin exchanged. Only its refusal starts any of this.</para>
/// <para>Every conversion happens before the retry and none of them is undone if the retry
/// fails — see the class remarks for why reversing is worse than reporting.</para>
/// </remarks>
/// <param name="owner">Who is paying.</param>
/// <param name="price">What it costs.</param>
/// <param name="reason">Audit reason, forwarded to the inner ledger and to the exchange.</param>
/// <param name="sourceId">Audit source id, forwarded unchanged.</param>
/// <returns>
/// The inner ledger's own result when it succeeds, before or after topping up. A refusal
/// otherwise — including the case where the conversion succeeded and the payment then did not,
/// which is reported rather than reversed.
/// </returns>
public EcoOpResult Pay(GameObject owner, in PriceBundle price, string reason = null, string sourceId = null)
{
_lastConversions.Clear();
EcoOpResult direct = _inner.Pay(owner, in price, reason, sourceId);
if (direct.IsOk)
return direct;
if (!owner)
return direct;
// Only a funds problem is worth converting for. This is the coarsest possible test and the
// class remarks say why it has to be: ILedgerPreflightReason is internal, so "the inner
// ledger refused" is the whole of what can be known. A code that is clearly not about money
// is left alone rather than answered with somebody's silver.
if (direct.Code != EcoOpCode.InsufficientFunds)
return direct;
var reserved = new Dictionary<string, long>(StringComparer.Ordinal);
var plan = new List<Conversion>();
if (!TryPlan(owner, in price, reserved, plan))
return direct;
for (int i = 0; i < plan.Count; i++)
{
Conversion leg = plan[i];
CurOpResult swapped = _exchange.TryExchange(
_wallet, owner, leg.From, leg.To, leg.SourceAmount,
reason ?? "ExchangingLedger", sourceId);
if (!swapped.Success)
{
// Stop at the first failure rather than pressing on. A later leg cannot help if an
// earlier one did not land, and every additional conversion is another irreversible
// loss charged to a purchase that is already going to be refused.
Debug.LogWarning(
$"[Cookbook] {nameof(ExchangingLedger)}: converting {leg.SourceAmount} " +
$"'{leg.From}' to '{leg.To}' failed ({swapped.Code}), so the purchase was " +
"refused. Anything converted before this leg has NOT been converted back.");
return direct;
}
_lastConversions.Add(leg);
}
EcoOpResult retry = _inner.Pay(owner, in price, reason, sourceId);
if (retry.IsOk)
return retry;
// The money was raised and the payment still refused -- an authority, a policy, a cap. The
// buyer is out the exchange fee for nothing. Converting back would charge a second fee on
// top, so this reports and stops.
Debug.LogWarning(
$"[Cookbook] {nameof(ExchangingLedger)}: converted {_lastConversions.Count} time(s) " +
$"for '{owner.name}' and the payment was still refused ({retry.Code}). The conversion " +
"is NOT reversed -- rates are not symmetric, so undoing it would cost a second fee. " +
"Read LastConversions to tell the player what happened.");
return retry;
}
/// <inheritdoc />
/// <remarks>
/// Forwarded untouched. A payout arrives in whatever the payer chose to pay in, and converting
/// it would be this class deciding what the player would rather hold.
/// </remarks>
public EcoOpResult Grant(GameObject owner, in PriceBundle payout, string reason = null, string sourceId = null)
=> _inner.Grant(owner, in payout, reason, sourceId);
/// <summary>
/// Works out whether every money line can be covered, and optionally how.
/// </summary>
/// <remarks>
/// One routine behind both <see cref="CanPay"/> and <see cref="Pay"/>, deliberately: two copies
/// of this arithmetic would be two answers to the same question, and the preflight disagreeing
/// with the payment is the whole failure this recipe exists to avoid.
/// </remarks>
/// <param name="owner">Who is paying.</param>
/// <param name="price">What it costs.</param>
/// <param name="reserved">Fallback currency already spoken for by an earlier line.</param>
/// <param name="plan">Receives the conversions needed, or null to only test feasibility.</param>
private bool TryPlan(
GameObject owner,
in PriceBundle price,
Dictionary<string, long> reserved,
List<Conversion> plan)
{
IReadOnlyList<ChargeLine> money = price.Money;
if (money == null || money.Count == 0)
return false;
for (int i = 0; i < money.Count; i++)
{
ChargeLine line = money[i];
if (!line.IsValid)
return false;
var want = new CurrencyId(line.Id);
if (!want.IsValid)
return false;
long held = _wallet.GetBalance(owner, want).amount;
long shortfall = line.Amount - held;
if (shortfall <= 0)
continue;
if (!TryCoverShortfall(owner, want, shortfall, reserved, plan))
return false;
}
return true;
}
/// <summary>Raises <paramref name="shortfall"/> of <paramref name="want"/> from the fallbacks, in order.</summary>
private bool TryCoverShortfall(
GameObject owner,
CurrencyId want,
long shortfall,
Dictionary<string, long> reserved,
List<Conversion> plan)
{
for (int f = 0; f < _fallbacks.Count && shortfall > 0; f++)
{
CurrencyId from = _fallbacks[f];
// Converting a currency into itself is a fee charged for nothing.
if (!from.IsValid || string.Equals(from.value, want.value, StringComparison.Ordinal))
continue;
reserved.TryGetValue(from.value, out long already);
long spendable = _wallet.GetBalance(owner, from).amount - already;
if (spendable <= 0)
continue;
if (!TrySmallestSourceFor(from, want, shortfall, spendable, out long spend, out long gained))
continue;
reserved[from.value] = already + spend;
plan?.Add(new Conversion(from, want, spend));
shortfall -= gained;
}
return shortfall <= 0;
}
/// <summary>
/// The smallest amount of <paramref name="from"/> whose quote yields at least
/// <paramref name="needed"/> of <paramref name="to"/>.
/// </summary>
/// <remarks>
/// <para><b>A search rather than a division, and the class remarks say why.</b> The quote runs
/// source-to-destination only, applies a percentage fee, rounds the total, and refuses outright
/// outside its configured bounds — so there is no rate to divide by that is correct at both
/// ends of the range.</para>
/// <para>The ceiling is quoted first. If the buyer's entire balance in this currency cannot
/// raise the shortfall there is nothing to search for, and quoting it also settles the common
/// case where an amount is above the rule's <c>maxSrc</c> and has no quote at all.</para>
/// <para>Bounded by construction: the balance is a <see cref="long"/> and each step halves the
/// interval, so this is at most about sixty quotes and every one of them is side-effect free.</para>
/// </remarks>
private bool TrySmallestSourceFor(
CurrencyId from, CurrencyId to, long needed, long available,
out long spend, out long gained)
{
spend = 0;
gained = 0;
if (needed <= 0 || available <= 0)
return false;
if (!_exchange.TryQuote(from, to, available, out long best) || best < needed)
return false;
long lo = 1, hi = available;
spend = available;
gained = best;
while (lo <= hi)
{
long mid = lo + ((hi - lo) / 2);
if (_exchange.TryQuote(from, to, mid, out long got) && got >= needed)
{
spend = mid;
gained = got;
hi = mid - 1;
}
else
{
// Covers both "not enough yet" and "no quote at this amount". A bound the rule
// refuses is treated as too small, which walks the search up into the range the
// ceiling quote already proved is answerable.
lo = mid + 1;
}
}
return true;
}
}
}
Wiring it up¶
- Build your economy services as you already do.
EconomyBootstrap.BuildForPlayerreturns theIValueLedgerthis wraps, along with the shop and the store. - Build an exchange.
CurrencyFactories.BuildExchange(table)is public and takes aCurrencyExchangeTable— theRevFramework ▸ Currency ▸ Exchange Tableasset — so you never need the internal implementation type. - Author a rule for each direction you want converted. Silver-to-gold is not gold-to-silver, and this recipe only ever converts into the priced currency.
- Construct the ledger with the fallbacks in spending order, and pass it to
IShopService.Buyrather than the one the bootstrap returned. Everything else about the call is unchanged. - Read
LastConversionsafter a purchase if you want to tell the player what happened. "Converted 334 silver" is the difference between a shop that feels helpful and one that feels like it robbed them. - Consider showing the conversion before committing it. This class has no confirm step, and
CanPayis side-effect free — so a UI can ask, quote it itself, and only then callPay.
Related¶
- Currency — wallets, policies, the audit trail and the exchange table.
- Economy — bundles, ledgers, stores and results.
- A shop that takes payment in blood — the other
IValueLedger, and the recipe that first found that the money is an argument rather than a property. - A shop that hands over the goods when the bag is full — the same decorator lesson one interface over: a preflight is a claim about the implementation answering it, never a pass-through.
- A pickup you have to pay for — the ordering rule this one inverts, and why.