A shop that remembers¶
Stock that goes down when players buy things, and is still exactly as they left it after a reload — by joining the save file as an ordinary participant, not through anything the framework provides for shops.
Recipe
Systems required: Economy, plus Core for the save side. Package: Currency & Economy, or Complete. 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.
Currency is used but never referenced. The wallet arrives as an IValueLedger, which is an Economy abstraction, so whichever implementation your game binds is the one that pays.
The part that is not obvious¶
IRevSaveParticipant is not framework-only machinery. Its own documentation says so, and this recipe is what taking that at its word looks like: a shop's stock is game state the framework has never heard of, and it lands in the same save file, in the same envelope, with the same failure reporting as Inventory or Currency. Four members and a registration is the whole cost of being a first-class citizen of the save system.
The version guard must run before you touch anything
This is a contract, not tidiness, and the coordinator behaves differently depending on which you do:
- Throw having changed nothing and the section is kept in
RevSaveReport.Unapplied, where the caller can carry it into the next save intact. That is how a save written by a newer build survives being loaded by an older one. - Throw after mutating and you must say so with
RevSavePartialRestoreException, which sends the section toPartiallyAppliedand marks it un-carryable.
Reporting a partial restore as a clean refusal is the worse bug of the two: it invites the caller to write stale data back over live state believing it is preserving it.
Registration is a lifetime, not a setup step
Registering in OnEnable and unregistering in OnDisable means a shop in an unloaded scene is simply absent from the next save — and its saved section is carried over by RevSaveManager rather than dropped. The village shop's shelves are still as you left them when you come back to the village. Worth understanding before you decide to register everything permanently instead.
Your key is forever
A key is how a saved section finds its way back to the thing that can read it, so renaming one orphans every existing save's data for that section. Use your own prefix — revframework.* is the framework's — and treat the string as shipped the moment a player has a save file.
The other half: selling without losing stock¶
The decrement happens after the result is checked, not before. Taking the goods off the shelf and trusting the sale is how a shop quietly bleeds stock to every refused payment — a wallet that could not pay, a bag with no room, a policy that blocked the purchase. EcoOpResult exists so that never has to be a guess, and this returns the service's own result unchanged so your UI can say which of those it was.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Core.Save;
using RevGaming.RevFramework.Economy;
using RevGaming.RevFramework.Economy.Abstractions;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.PersistentShop
{
/// <summary>
/// A shop whose stock is depleted by what players buy and survives a reload — joining the save
/// file as an ordinary participant rather than through anything the framework provides for it.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Economy</b>, plus Core for the save side. Currency is used but not referenced: the wallet
/// arrives as an <see cref="IValueLedger"/>, which is an Economy abstraction, so whichever
/// implementation your game binds is the one that pays. The goods arrive the same way, through
/// <see cref="IItemStore"/> — but mind the asymmetry: an Economy build composed with
/// <c>EconomyBootstrap</c> is handed a ledger and is handed <c>null</c> for the store, by design.
/// A sale that delivers items therefore needs Inventory's store, or one of your own.</para>
///
/// <para><b>The composition.</b> <see cref="IRevSaveParticipant"/> is not framework-only
/// machinery — its own documentation says so — and this is what taking that at its word looks
/// like. A shop's stock is game state the framework has never heard of, and it lands in the same
/// save file, in the same envelope, with the same failure reporting as Inventory or Currency.</para>
///
/// <para><b>The version guard runs before anything is touched, and that is a contract rather than
/// tidiness.</b> A participant that throws having changed nothing keeps its section in
/// <c>RevSaveReport.Unapplied</c>, preserved verbatim, and <see cref="RevSaveManager"/> hands it to
/// the next capture. That keeps a newer build's data alive across the load; it does not, on its
/// own, keep it. This shop is still registered when that capture runs, so it writes its own
/// section under the same key and the carried copy is reported <c>Displaced</c> — one key holds
/// one section, and live state wins it. Surviving a downgrade is therefore a decision the game
/// makes: on <c>RevSaveManager.LoadCompleted</c>, look for your key in <c>report.Unapplied</c> and
/// either write that section somewhere of your own or unregister this shop before the next save,
/// so it carries on as an unrecognised passenger.</para>
///
/// <para>A participant that throws <i>after</i> mutating must say so, because its section can no
/// longer be written back safely: <see cref="RevSavePartialRestoreException"/> when you can name
/// what did and did not land, or <c>RevSaveRestore.MarkMutated()</c> at the first change, after
/// which any exception is classified as a partial restore. Reporting a partial restore as a clean
/// refusal is the worse bug: it invites the caller to write stale data over live state believing
/// it is preserving it.</para>
///
/// <para><b>Registration is a lifetime, and carry-over is narrower than it looks.</b> Registering
/// in <c>OnEnable</c> and unregistering in <c>OnDisable</c> means a shop in an unloaded scene is
/// simply absent from the next save — absent in the literal sense: <see cref="RevSaveManager"/>
/// carries only the sections a load could <i>not</i> place, so a shop that was restored while it
/// was loaded and has since unloaded has nothing carried, and its key is not written at all. Save
/// while the shop is still loaded, or hold the participant on an object that outlives the scene,
/// if its shelves have to survive unloading. The opposite case — a shop whose scene streams in
/// after the load — is what <see cref="RestoreFromCarryOver"/> is for, but it is deliberately not
/// wired to <c>OnEnable</c>: a carried section is not consumed by being read, so restoring on every
/// enable rolls the shelves back to the last load every time the object is re-enabled. Call it once,
/// when the shop first arrives after a load, and read that method's remarks before you do.</para>
///
/// <para><b>A restore is silent.</b> Nothing here raises an event when the shelves change under a
/// load, and <see cref="Stock"/> is a plain pass-through with no change notification, so a shop
/// panel that read it once goes on showing the pre-load shelves. Reconcile from
/// <c>RevSaveManager.LoadCompleted</c>, which is also where a refused or duplicated section
/// becomes visible.</para>
///
/// <para><b>Pick your own key prefix, and one key per shop.</b> <c>revframework.*</c> is the
/// framework's. A key is how a saved section finds its way back to the thing that can read it, so
/// renaming one orphans every existing save's data for that section — and two shops left on the
/// same key share one section: the second is skipped on capture as a <c>DuplicateKey</c> and
/// ignored on restore, so it silently shows authored stock. Loud in the report, invisible in the
/// game.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class PersistentShopStock : MonoBehaviour, IRevSaveParticipant
{
/// <summary>One line on the shelf: an item, how many are left, and what it costs.</summary>
[Serializable]
public struct StockLine
{
[Tooltip("Item this shop sells.")]
public string itemGuid;
[Tooltip("How many are on the shelf right now.")]
public int quantity;
[Tooltip("Currency this line is priced in. Required - a blank id is not a free line, it " +
"is an invalid money line and Economy refuses the sale.")]
public string currencyId;
[Tooltip("Price of one, in that currency. Must be above zero - a price of zero is refused " +
"for the same reason, rather than given away.")]
public long unitPrice;
}
[Tooltip("Save manager to join. Leave empty to find one in the scene on enable.")]
[SerializeField] private RevSaveManager saveManager;
[Tooltip("Section key in the save file. Use your own prefix - revframework.* is taken - give " +
"every shop its own key, and never change one once saves exist.")]
[SerializeField] private string saveKey = "mygame.shop.village";
[Tooltip("What is on the shelves. Authored here, replaced by a restore.")]
[SerializeField] private List<StockLine> stock = new();
/// <inheritdoc />
public string Key => saveKey;
/// <inheritdoc />
public int Version => 1;
/// <summary>What is currently on the shelves.</summary>
public IReadOnlyList<StockLine> Stock => stock;
private void OnEnable()
{
if (!saveManager) saveManager = FindAnyObjectByType<RevSaveManager>();
if (!saveManager)
{
// The one failure that defeats the whole recipe is the one with no other symptom: an
// unregistered participant produces no outcome, so no report ever mentions it. And
// OnEnable will not run again, so a manager that arrives a frame later is not picked up.
Debug.LogWarning($"[{nameof(PersistentShopStock)}] No RevSaveManager found, so '{saveKey}' " +
"will not be saved or restored.", this);
return;
}
saveManager.Register(this);
// Deliberately NOT calling RestoreFromCarryOver() here. A carried section is never consumed
// -- RevSaveManager rewrites _carryOver only on the next Load -- so restoring on every enable
// would re-apply the same stale section every time this object is enabled, silently rolling
// the shelves back to the state at the last load and then writing that over the correct
// stock on the next save. Proximity culling or pooling would do it on its own. See the
// method's own remarks for when calling it is right.
}
private void OnDisable()
{
if (saveManager) saveManager.Unregister(this);
}
/// <summary>
/// Applies this shop's section from <see cref="RevSaveManager.CarriedOver"/>, for a shop that
/// arrived after the load that read it.
/// </summary>
/// <remarks>
/// A load only reaches the participants registered while it runs. A shop whose scene streams in
/// afterwards is not one of them, so its section was reported unrecognised and carried — and
/// without this the shop would show its authored stock and then displace the real saved section
/// on the next capture, because one key holds one section and the live participant wins it.
/// There is nothing framework-side to arrange: the carried sections and their payloads are
/// public, and this is what reading your own out of them costs.
///
/// <para><b>Call it once, when a streamed-in shop first arrives after a load — not from
/// <c>OnEnable</c>.</b> Reading a carried section does not consume it: <c>RevSaveManager</c>
/// rebuilds its carried list only on the next <c>Load</c>, so the same section stays available
/// all session. Calling this every time the object is enabled therefore re-applies a section
/// that may be many purchases out of date, rolls the shelves back to the state at the last
/// load, and writes that over the correct stock on the next save. Proximity culling or object
/// pooling is enough to trigger it. Once this shop has been part of a capture, the file is the
/// truth and the carried copy is stale.</para>
/// </remarks>
/// <returns><c>true</c> when a carried section for this key was found and applied.</returns>
public bool RestoreFromCarryOver()
{
if (!saveManager) return false;
IReadOnlyList<RevSaveSection> carried = saveManager.CarriedOver;
for (int i = 0; i < carried.Count; i++)
{
RevSaveSection section = carried[i];
if (section == null || section.key != saveKey) continue;
try
{
Restore(section.payload, section.version);
}
catch (Exception e)
{
// The same refusal the load would have had, most often a newer build's payload. The
// section stays carried; what changes is that somebody now knows it was not applied.
Debug.LogWarning($"[{nameof(PersistentShopStock)}] Carried section '{saveKey}' was " +
$"not applied: {e.Message}", this);
return false;
}
return true;
}
return false;
}
/// <summary>
/// Sells from one line, and takes the goods off the shelf only if the sale actually happened.
/// </summary>
/// <remarks>
/// <para>The decrement is deliberately after the result check. Decrementing first and trusting
/// the sale is how a shop quietly loses stock to every refused payment, and the result type
/// exists so that does not have to happen.</para>
///
/// <para><b>The result check is only as honest as the request id, which is why one is a
/// parameter and defaults to null.</b> <c>IShopService.Buy</c>'s last argument doubles as an
/// idempotency key: a repeated <c>(buyer, requestId)</c> replays the first successful result
/// without running the sale again. Hand it a constant and the second purchase returns a cached
/// <c>Ok</c> having delivered nothing and charged nothing — and the check above, seeing
/// <c>IsOk</c>, takes the item off the shelf anyway. Worse, any <i>different</i> basket
/// fingerprints differently and is refused with <c>IdempotencyMismatch</c> for the rest of that
/// buyer's life, because the refusal never records a result to displace the stale one.</para>
///
/// <para>Null is therefore the right default for a shop: each press of Buy is a new
/// transaction. Pass a real id only when you are deliberately retrying one purchase — a network
/// reply you did not see, a button you want double-click-safe — and mint a fresh one per
/// purchase, never per shop.</para>
///
/// <para><b>This shop's own refusals do not borrow Economy's codes.</b> <c>NoSpace</c> and
/// <c>NotOwned</c> are both about the buyer — a full bag, an item they are paying with and do
/// not have — so a shelf that is empty or does not stock the item refuses with
/// <c>InvalidArgs</c> instead, and says which in the message. Branch a "sold out" UI on
/// <see cref="Stock"/> before offering the sale rather than on the code afterwards:
/// <c>Message</c> is the only field that separates the two causes, and it is the one field
/// <c>EcoOpResult</c> says not to branch on.</para>
///
/// <para>A line priced at zero, or with a blank <c>currencyId</c>, is not a free sale: Economy
/// validates the money line first and refuses the whole bundle with <c>InvalidArgs</c> before
/// anything is charged. Both are ordinary authoring slips and both surface as a complaint about
/// a money line nobody wrote, which is why the tooltips say so.</para>
/// </remarks>
/// <param name="buyer">Who is buying.</param>
/// <param name="shop">The shop service performing the exchange.</param>
/// <param name="ledger">The buyer's wallet.</param>
/// <param name="store">Where the goods are delivered.</param>
/// <param name="itemGuid">Which line to buy from.</param>
/// <param name="quantity">How many.</param>
/// <param name="requestId">
/// Idempotency key, and a loaded gun. Leave it <see langword="null"/> — the default — and every
/// call is its own transaction, which is what a shop wants. Pass a value only to make a retry
/// of <i>one</i> logical purchase safe, and never pass a constant: see the remarks.
/// </param>
/// <returns>
/// The service's own result, unchanged, or this shop's own <c>InvalidArgs</c> refusal from
/// before the service was reached.
/// </returns>
public EcoOpResult Buy(
GameObject buyer,
IShopService shop,
IValueLedger ledger,
IItemStore store,
string itemGuid,
int quantity = 1,
string requestId = null)
{
if (shop == null || ledger == null || store == null || quantity <= 0)
return EcoOpResult.InvalidArgs("Missing service, ledger or store, or a quantity below one.");
// Not NotOwned and not NoSpace, however well they read here: in Economy both are about the
// buyer -- items they do not own, a bag with no room -- and a UI branching on the code it
// was handed would say "your inventory is full" when the truth is "the shelf is empty".
int index = IndexOf(itemGuid);
if (index < 0)
return EcoOpResult.InvalidArgs("This shop does not stock that item.");
StockLine line = stock[index];
if (line.quantity < quantity)
return EcoOpResult.InvalidArgs("The shelf does not hold that many.");
var price = PriceBundle.MoneyOnly(new[] { new ChargeLine(line.currencyId, line.unitPrice * quantity) });
var goods = new[] { new ItemLine(line.itemGuid, quantity) };
// saveKey is the vendorId -- telemetry only. requestId is the NEXT argument and it is an
// idempotency key: a repeated one replays the first result as a no-op. A constant here
// would make the second sale a cached success that delivers nothing while this method
// still decrements the shelf, and make every non-identical sale a permanent refusal.
EcoOpResult result = shop.Buy(buyer, ledger, store, in price, goods, saveKey, requestId);
if (result.IsOk)
{
line.quantity -= quantity;
stock[index] = line;
}
return result;
}
/// <summary>Puts stock back on the shelf, for a restock timer or a quest reward.</summary>
/// <remarks>
/// Tops up a line that already exists; it cannot introduce one, because a new line would need a
/// price and a currency this method has no way to know. So an unknown guid, and a quantity of
/// zero or less, both do nothing — which is why this reports whether it did anything rather
/// than returning void. A restock timer can ignore that; a quest reward wired to a guid this
/// shop does not stock is a bug, and false is the only place it shows.
/// </remarks>
/// <returns><c>true</c> when a line was topped up.</returns>
public bool Restock(string itemGuid, int quantity)
{
if (quantity <= 0) return false;
int index = IndexOf(itemGuid);
if (index < 0) return false;
StockLine line = stock[index];
line.quantity += quantity;
stock[index] = line;
return true;
}
/// <inheritdoc />
public string Capture()
{
// Null rather than an empty payload: the coordinator records null as a skipped section,
// which is the honest description of a shop with nothing to remember.
if (stock.Count == 0)
return null;
var payload = new Payload { lines = new List<StockLine>(stock) };
return JsonUtility.ToJson(payload);
}
/// <inheritdoc />
public void Restore(string payload, int version)
{
// Everything that can refuse runs first, while this component is still untouched. A refusal
// from here leaves the section carryable rather than shredded -- which is what buys the game
// the chance to preserve a newer build's save, not the preservation itself: this shop is
// still live at the next capture and will displace it. Class remarks for what to do about it.
if (version > Version)
throw new NotSupportedException(
"Shop stock section was written by a newer build than this one can read.");
if (string.IsNullOrWhiteSpace(payload))
throw new InvalidOperationException("Shop stock section is empty.");
Payload parsed;
try
{
parsed = JsonUtility.FromJson<Payload>(payload);
}
catch (Exception e)
{
throw new InvalidOperationException("Shop stock section is not readable.", e);
}
if (parsed?.lines == null)
throw new InvalidOperationException("Shop stock section carried no lines.");
// Past this line the shelves have been touched, so any failure is a partial restore and has
// to be reported as one. See the class remarks for why the distinction is load-bearing.
// These two calls cannot themselves fail -- the shape is what is being shown, and it earns
// its keep the moment a line needs resolving into something. RevSaveRestore.MarkMutated()
// is the one-line form for the failure you did not anticipate and cannot describe.
try
{
stock.Clear();
stock.AddRange(parsed.lines);
}
catch (Exception e)
{
throw new RevSavePartialRestoreException(
"Shop stock was half-applied and the shelves are now inconsistent.", e);
}
}
private int IndexOf(string itemGuid)
{
if (string.IsNullOrEmpty(itemGuid))
return -1;
for (int i = 0; i < stock.Count; i++)
if (stock[i].itemGuid == itemGuid)
return i;
return -1;
}
[Serializable]
private sealed class Payload
{
public List<StockLine> lines;
}
}
}
Wiring it up¶
- Put the component on your shop object and fill in the stock lines.
- Give it a
saveKeywith your own prefix. Once a player has saved, that string is permanent. - Call
Buy(...)from your shop UI, passing the services your project already binds.
The save side needs nothing further — the component finds the RevSaveManager and registers itself.
What it deliberately does not do¶
It does not restock itself. Restock is there and nothing calls it; a timer, a day cycle, or a quest completion are all reasonable triggers and none of them belong in a shop's inventory.
It does not price dynamically. One price per line, in one currency. Supply and demand, haggling and reputation discounts all belong to the price you pass, and the shop service takes a whole PriceBundle, so none of that needs this class to change.
It does not migrate old saves. Version is 1 and a newer payload is refused rather than guessed at. When you change the format, raise the version and decide there and then whether the old shape is readable — which is exactly the moment you have the information to decide.