A shopkeeper who charges you more when you are cursed¶
Prices that read the buyer's condition at the moment of the sale — a surcharge while a curse is on them, a discount while something else is.
Recipe
Systems required: Economy, Status Effects. 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¶
There is no pricing seam, and you do not need one. IShopService charges the PriceBundle it is handed and has no opinion about where the numbers came from. So dynamic pricing is not a hook, a subclass or an extension point — it is building a different bundle before you call Buy.
Everything a shop could want is the same three lines with a different multiplier: reputation, haggling, happy hour, a faction that hates you, a curse that makes merchants gouge you. Once you see that the price is an argument rather than a property, the whole category opens up.
Money is long, multipliers are float, and this is where it goes quietly wrong
Casting the product truncates, so a surcharge can round down into a discount. Worse, a float carries about seven significant digits — a price in the millions comes back changed even at a multiplier of exactly 1.
The maths here happens in double and rounds away from zero, so a surcharge is never accidentally a discount and a large price survives being multiplied by 1.
A pile of discounts must not reach free
Stack enough modifiers and the arithmetic gets to zero, which turns a shop into a giveaway and is the kind of bug players share screenshots of. Anything that had a price keeps one — the floor is minimumCharge, not zero.
Item costs are deliberately not scaled. A barter price of three pelts does not become 3.75 pelts, and rounding it either overcharges or hands out a free pelt. If your game wants scaled barter, that is a design decision to make on purpose rather than a rounding rule to inherit.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Economy;
using RevGaming.RevFramework.Economy.Abstractions;
using RevGaming.RevFramework.StatusEffects.Abstractions;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.StatusPricedShop
{
/// <summary>
/// A shopkeeper who charges you more while you are cursed and less while you are charming —
/// prices that read the buyer's condition at the moment of the sale.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Economy</b>, <b>StatusEffects</b>. Public API only.</para>
///
/// <para><b>The composition is smaller than it sounds, and that is the finding.</b>
/// <see cref="IShopService"/> charges the <see cref="PriceBundle"/> it is handed and has no opinion
/// about where the numbers came from. So dynamic pricing needs no pricing seam, no hook and no
/// subclass: build a different bundle. Everything a shop could want — reputation, haggling,
/// happy hour, a curse that makes merchants gouge you — is the same three lines with a different
/// multiplier.</para>
///
/// <para><b>Money is <see cref="long"/> and multipliers are <see cref="float"/>, which is where
/// this goes wrong quietly.</b> Casting the product truncates, so a 10% surcharge on 15 gold
/// charges 16 truncated and 17 rounded, and a large price loses precision entirely once it passes
/// the range a float can represent exactly. Rounding happens here in <see cref="double"/> and
/// lands on a whole unit, because a shop that is a coin out is a bug report you cannot
/// reproduce.</para>
///
/// <para><b>A modifier never makes something free, and never makes a discount dearer.</b> A
/// stacked pile of discounts reaching zero turns a shop into a giveaway, so the floor is one unit
/// rather than zero for anything that had a price to begin with — and the floor is clamped to the
/// list price, so raising it above a cheap item cannot charge more for discounting that item.</para>
///
/// <para><b>Price once per transaction.</b> Both methods here are live reads of the buyer's
/// statuses, so two reads either side of an expiring curse disagree. Hold the bundle
/// <see cref="PriceFor"/> returned for the life of the sale and pass that same bundle to any retry:
/// <see cref="IShopService.Buy"/>'s <c>requestId</c> replay compares the money lines, so a re-priced
/// retry is refused as <see cref="EcoOpCode.IdempotencyMismatch"/> instead of replaying as a
/// no-op.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class StatusPricedShop : MonoBehaviour
{
/// <summary>A status, and what it does to this shop's prices while it is on the buyer.</summary>
[Serializable]
public struct PriceEffect
{
[Tooltip("Status id on the buyer, e.g. \"vulnerability\". Ids are compared ordinally and " +
"every id RevFramework ships is lowercase, so a capitalised one never matches.")]
public string statusId;
[Tooltip("Multiplier applied while it is active. 1.25 is a 25% surcharge; 0.9 is a discount. " +
"A row is ignored until this is above zero.")]
[Min(0f)] public float multiplier;
}
[Tooltip("Statuses this shopkeeper reacts to. All active ones multiply together — one multiplier " +
"per row, so two rows naming the same status multiply twice, and three stacks of a " +
"status price the same as one.")]
[SerializeField] private List<PriceEffect> priceEffects = new();
[Tooltip("Discount floor: a modified line never falls below this, or below its own list price if " +
"that is lower. One value for every currency, and it does nothing while no status is " +
"changing the price.")]
[SerializeField, Min(1)] private long minimumCharge = 1;
private readonly List<ChargeLine> _lines = new();
/// <summary>
/// The multiplier this buyer is currently paying. 1 means the list price.
/// </summary>
/// <param name="buyer">The object the statuses live on.</param>
/// <remarks>
/// <para>Public so a shop UI can show "prices are 25% higher while you are cursed" rather than
/// leaving the player to work out why the numbers moved. It is the exact multiplier and not the
/// effective rate: the charge rounds to whole units per line, so an advertised 25% surcharge
/// takes a third more on a 3 gold potion.</para>
/// <para><paramref name="buyer"/> is the character carrying the status controller, which is not
/// necessarily the wallet owner handed to <see cref="IShopService.Buy"/>. An object without one
/// prices at 1, indistinguishable from a buyer carrying nothing.</para>
/// </remarks>
public float MultiplierFor(GameObject buyer)
{
if (!buyer || !buyer.TryGetComponent<IStatusEffectController>(out var status))
return 1f;
float multiplier = 1f;
for (int i = 0; i < priceEffects.Count; i++)
{
PriceEffect effect = priceEffects[i];
if (string.IsNullOrWhiteSpace(effect.statusId))
continue;
// A row added in the inspector and not filled in carries a multiplier of zero, which
// would price the entire shop at the floor. Half-configured means inert, not free.
// The comparison is written this way so a NaN entry is skipped too.
if (!(effect.multiplier > 0f))
continue;
// Trimmed because ids compare ordinally: a space picked up from a paste never matches,
// and the whitespace guard above does not catch " vulnerability".
if (status.HasStatus(new StatusId(effect.statusId.Trim())))
multiplier *= effect.multiplier;
}
return multiplier;
}
/// <summary>
/// Rebuilds a price for this buyer, leaving the goods side of the bundle untouched.
/// </summary>
/// <param name="buyer">Whose condition sets the price.</param>
/// <param name="listPrice">The shop's list price.</param>
/// <returns>A bundle to hand straight to <see cref="IShopService.Buy"/>.</returns>
public PriceBundle PriceFor(GameObject buyer, in PriceBundle listPrice)
{
float multiplier = MultiplierFor(buyer);
IReadOnlyList<ChargeLine> money = listPrice.Money;
if (money == null || money.Count == 0 || Mathf.Approximately(multiplier, 1f))
return listPrice;
_lines.Clear();
// Same-currency lines are merged before scaling. Economy merges duplicates itself, so
// [gold 5, gold 5] and [gold 10] are one transaction to it — rounding each line separately
// would charge them different totals, and a price that depends on how the caller split the
// basket is another bug report you cannot reproduce.
for (int i = 0; i < money.Count; i++)
{
ChargeLine line = money[i];
int existing = IndexOfCurrency(line.Id);
if (existing >= 0)
_lines[existing] = new ChargeLine(line.Id, _lines[existing].Amount + line.Amount);
else
_lines.Add(line);
}
for (int i = 0; i < _lines.Count; i++)
_lines[i] = new ChargeLine(_lines[i].Id, Scale(_lines[i].Amount, multiplier));
// Item costs are deliberately not scaled: a barter price of "three pelts" does not become
// 3.75 pelts, and rounding it would either overcharge or hand out a free pelt. If your game
// wants scaled barter, that is a design decision to make explicitly, not a rounding rule.
return listPrice.Items != null && listPrice.Items.Count > 0
? new PriceBundle(_lines.ToArray(), listPrice.Items)
: PriceBundle.MoneyOnly(_lines.ToArray());
}
/// <summary>Index of the line already accumulated for a currency, or -1.</summary>
private int IndexOfCurrency(string currencyId)
{
for (int i = 0; i < _lines.Count; i++)
{
if (string.Equals(_lines[i].Id, currencyId, StringComparison.Ordinal))
return i;
}
return -1;
}
/// <summary>
/// Applies the multiplier to one amount, in whole units.
/// </summary>
/// <remarks>
/// <para>In <see cref="double"/> rather than <see cref="float"/>: money is <see cref="long"/>,
/// and a float carries about seven significant digits, so a price past about 16.8 million
/// (2^24) would come back changed even at a multiplier of exactly 1.</para>
/// <para>Rounded away from zero rather than truncated, so truncation cannot quietly eat part of
/// a surcharge, and floored at <c>minimumCharge</c> — but never above the amount that was
/// listed, so a discount cannot end up dearer than the list price.</para>
/// </remarks>
private long Scale(long amount, float multiplier)
{
if (amount <= 0)
return amount;
double scaled = amount * (double)multiplier;
// A product outside long's range is an unchecked conversion: under Unity's Mono it wraps to
// long.MinValue, which the floor below would then serve up as the cheapest line in the shop.
// The comparison is written this way so NaN takes this branch too.
if (!(scaled >= 0d) || scaled >= long.MaxValue)
return amount;
long rounded = (long)Math.Round(scaled, MidpointRounding.AwayFromZero);
return Math.Max(Math.Min(minimumCharge, amount), rounded);
}
}
}
Wiring it up¶
- Put the component on your shop object.
- Add a row per status: the id, and what it does to prices while it is on the buyer. All active ones multiply together.
- Where you currently pass a list price to
Buy, passPriceFor(buyer, listPrice)instead.
MultiplierFor(buyer) is public so the shop UI can say "prices are 25% higher while you are cursed" rather than leaving the player to work out why the numbers moved. A price that changes without explanation reads as a bug.
What it deliberately does not do¶
It does not touch selling. Buy and sell prices moving together is a design decision, not an oversight — a curse that makes merchants gouge you might reasonably pay more for your goods, or less, and only your game knows.
It does not persist anything. The multiplier is derived from live statuses every time it is asked, so there is no stale price to save and none to invalidate.
It does not stack additively. Two 10% surcharges make 21%, not 20%. Multiplicative stacking is the choice that never produces a negative price no matter how many modifiers you add.
Related¶
- Economy — the shop service, bundles, ledgers and stores.
- Status Effects — the ids you can price against.
- A shop that remembers — the other half of a shop: stock that survives a reload.