Gameplaycom.zoa.economy · v0.1.0

ZOA Economy System

Currencies, vendor stock, pricing, and barter: the commerce half of the ledger story.

Economy is about money changing hands. Three services divide the job. `ICurrencyService` holds per-entity balances and moves value between entities. `IVendorService` owns vendor registration, stock, buy and sell prices, and restocking. `IBarterService` tracks item-for-item proposals through a pending, accepted, or rejected lifecycle. All three are plain C# and are registered against the service registry by one scene installer.

Balances are `long`, not `int` or `float`, and they are keyed by a plain string entity id. That means a wallet is not an object you have to own and pass around: any system that knows an entity id can read or credit its balance, and the player, a vendor's till, and a faction treasury are all just ids in the same store.

Since the F107 consolidation, `CurrencyService` no longer keeps its own dictionary. It stores balances in an `IQuantityLedger` from `com.zoa.quantities`, mapping each `CurrencyId` into the ledger's namespace as `currency.<id>`. The `ICurrencyService` surface is unchanged, but the storage is now shared: install a ledger in the scene and currency, narrative quantities, and anything else on that ledger get one storage model and one save section.

Depended on by (1)

How it works

Concepts

Currency is a ledger slot with a clamp

`ICurrencyService` is four operations. `GetBalance` returns zero for an entity that has never held the currency. `AddCurrency` credits and throws on a negative amount. `RemoveCurrency` debits and returns false rather than throwing when the entity cannot afford it. `Transfer` is the two composed: it debits the sender first and only credits the receiver if that succeeded, so a failed transfer leaves both sides untouched.

Underneath, each currency becomes a quantity slot registered lazily the first time it is touched, clamped to the range zero to `long.MaxValue`. Negative balances are therefore structurally impossible through this API. Model debt as a separate quantity when you need it.

`BalanceChanged` fires after every mutation carrying the entity, the currency, the previous and new balances, and the signed change. It is an `EventHandler<BalanceChangedEventArgs>`, so a HUD element binds once and stops polling.

`CurrencyId` normalises to lower case and rejects null or whitespace at construction, so ids cannot drift by casing. `DefaultCurrencies` names six ready-made ones: Gold, Silver, Copper, Scraps, Credits, and Reputation.

Vendors move money and stock, not items

A `VendorDefinition` owns a list of `TradeOffer` rows, and each offer carries an item definition id, a `BuyPrice`, a `SellPrice`, a stock count, and an availability flag. Buy and sell are separate `PriceTag` values, so a vendor that pays a fraction of what it charges needs no markup logic: the two numbers are simply authored differently, and they may even be in different currencies.

`TryBuy` gates on the offer existing, being available, and having stock, debits the buyer for the discounted price, credits a synthetic vendor wallet keyed as `vendor_<id>`, decrements the stock, and raises `ItemPurchased`. `TrySell` is the mirror: it finds the vendor's offer for that item, credits the seller, debits the vendor wallet, increments the stock, and raises `ItemSold`.

What neither call does is touch an inventory. The service returns a `TransactionResult` describing what was agreed and at what price, and moving the actual item into or out of the player's bag is the caller's job. That is the same boundary crafting draws with its adapters: economy owns value, not storage.

Stock of -1 means unlimited and is never decremented. `GetAvailableOffers` filters to offers that are flagged available and either unlimited or still in stock, which is the list a shop UI should render.

Pricing is an amount plus a discount, rounded carefully

`PriceTag` is a struct of a `CurrencyId`, a `long` amount, and a discount factor between zero and one, validated at construction. `GetDiscountedAmount` computes the payable figure; that is the number the vendor service charges.

The rounding is worth knowing about. The calculation runs in double precision and rounds to nearest away from zero, because a float path truncated: under extended-precision evaluation a 20 percent discount on 100 evaluated to 79.9999997 and floored to 79. Prices now land where a designer expects.

Discount lives on the price rather than on the vendor so a single offer can be marked down without touching the rest of the stock, and `ToString` renders both figures when a discount is present, ready for a shop row.

Barter is a negotiation record, not a transfer

`BarterProposal` names the proposer and holds two lists of `BarterItem` values, offered and requested, with a `BarterStatus` of Pending, Accepted, Rejected, or Expired. `ProposeBarterTrade` stores it, stamps a timestamp, returns a GUID proposal id, and raises `BarterProposed`.

`AcceptBarter` and `RejectBarter` transition a pending proposal and raise the corresponding event, refusing anything already processed. Both are bookkeeping: the service records that a trade was agreed and tells subscribers, and the actual item swap is performed by whatever listens to `BarterAccepted`.

That split makes barter usable with any inventory model. The proposal is a serialisable description of an intent; validating that both sides really hold what they offered, and executing the swap, happens in the layer that owns the bags.

On the vendor side, `AcceptsBarter` and `BarterMarkup` are authored per vendor. Markup is a multiplier where 1.0 is a fair exchange and higher values favour the vendor, available to whatever values the trade.

One installer, three services, and a wallet per entity

`ZOAEconomySceneInstaller` sits at a scene root with execution order -9300. On Awake it registers `ICurrencyService`, `IVendorService`, and `IBarterService` against `FoundryServiceRegistry`, honouring anything already registered, and ensures a `CurrencyPickupAdapter` in the scene. On destroy it unregisters exactly the instances it created.

Currency construction is where the ledger sharing happens. The installer try-resolves an `IQuantityLedger` first: when a `QuantityLedgerComponent` has already installed one, at execution order -9500, the currency service is built on top of it and shares storage with every other ledger consumer. With none registered it allocates a private ledger and works exactly the same.

`WalletComponent` is the per-entity scene-side view. It does not store balances; it holds a stable entity id, seeds starter balances once on first Awake, and forwards `GetBalance`, `Credit`, and `TryDebit` to the registered service. That keeps one source of truth for currency while still letting a designer drag a wallet onto a player prefab and type in a starting purse.

In the editor

Screens

Screenshot pending

/screenshots/economy-workbench-vendors.png

The Workbench with Economy selected, the capability strip showing Vendors, Currencies, and Overview, and a vendor asset selected so the stock table is visible with several rows carrying item ids, buy and sell prices, and stock levels including one set to -1.

The Vendors browser in the Economy Workbench module.

Screenshot pending

/screenshots/economy-currency-wizard.png

The wizard shell with its four-step rail (Identity, Presentation, Rules, Review), the Presentation step showing the symbol field filled, an icon assigned, and the accent colour swatch open.

The Currency Wizard on the Presentation step.

Screenshot pending

/screenshots/economy-wallet-hud.png

A close crop of the in-game HUD showing the wallet element with its currency icon, symbol, and amount, ideally captured mid-change so the value differs from a round starting number.

WalletHUDElement rendering a live balance in play mode.

Setup

Workflow

  1. 01

    Author your currencies

    Create CurrencyDefinitionAsset files through the Workbench's Currencies browser or the Currency Wizard. The currency code is what the runtime resolves, so pick a stable lower-case slug; the symbol, icon, and accent are what the HUD renders.

  2. 02

    Install the services

    Add a ZOAEconomySceneInstaller to a scene root. If you also want narrative quantities and currency to share one store and one save section, add a QuantityLedgerComponent as well: it registers at -9500, ahead of the economy installer at -9300, and the currency service will pick it up.

  3. 03

    Give the player a wallet

    Put a WalletComponent on the player rig, set a stable entity id such as player.local, and add starter balances. Seeding happens once on first Awake and is not repeated on an additive scene reload.

  4. 04

    Author and register vendors

    Create VendorDefinitionAsset files and fill in the stock table: item definition id, buy and sell prices, and stock level, where -1 is unlimited. Call ToVendorDefinition and pass the result to RegisterVendor during bootstrap. Registering the same vendor id twice throws, which is the intended way to catch a duplicated asset.

  5. 05

    Wire the transaction to the inventory

    Call TryBuy or TrySell, check the result, and then move the item yourself. The service settles money and stock; it never adds or removes an item. Subscribe to ItemPurchased and ItemSold for the audio, the toast, and the objective progress.

  6. 06

    Bind the HUD

    Add a WalletHUDElement to your UI Toolkit hierarchy and call Bind with the resolved service, the currency asset, and the entity id. It subscribes to BalanceChanged and updates in place, so nothing needs an Update loop.

Surface

Key types

ICurrencyService

interface

Per-entity balances keyed by string id. Credit throws on negatives, debit returns false when unaffordable, transfer is atomic across the pair.

  • long GetBalance(string entityId, CurrencyId currencyId)
  • void AddCurrency(string entityId, CurrencyId currencyId, long amount)
  • bool RemoveCurrency(string entityId, CurrencyId currencyId, long amount)
  • bool Transfer(string fromEntityId, string toEntityId, CurrencyId currencyId, long amount)
  • event EventHandler<BalanceChangedEventArgs> BalanceChanged

CurrencyService

service

The default implementation, backed by an IQuantityLedger. The parameterless constructor allocates a private ledger; the other shares one so currency sits in the same store as every other tracked scalar.

  • CurrencyService()
  • CurrencyService(IQuantityLedger ledger)

IVendorService

interface

Vendor registration, offer lookup, buy and sell, and restock. Handles currency and stock; item movement is left to the caller.

  • void RegisterVendor(VendorDefinition vendorDefinition)
  • VendorDefinition GetVendor(VendorId vendorId)
  • TransactionResult TryBuy(VendorId vendorId, string offerId, string buyerEntityId)
  • TransactionResult TrySell(VendorId vendorId, string itemDefinitionId, string sellerEntityId, int quantity = 1)
  • List<TradeOffer> GetAvailableOffers(VendorId vendorId)
  • void RestockVendor(VendorId vendorId)
  • event EventHandler<ItemPurchasedEventArgs> ItemPurchased
  • event EventHandler<ItemSoldEventArgs> ItemSold

IBarterService

interface

Item-for-item proposals with a pending lifecycle. Records agreement and notifies; the item swap belongs to the subscriber.

  • string ProposeBarterTrade(BarterProposal proposal)
  • bool AcceptBarter(string proposalId)
  • bool RejectBarter(string proposalId)
  • List<BarterProposal> GetPendingProposals(string entityId)
  • BarterProposal GetProposal(string proposalId)
  • event EventHandler<BarterAcceptedEventArgs> BarterAccepted

PriceTag

struct

A currency, an amount, and a discount factor in zero to one. GetDiscountedAmount is the payable figure, rounded to nearest away from zero in double precision.

  • CurrencyId Currency { get; set; }
  • long Amount { get; set; }
  • float Discount { get; set; }
  • long GetDiscountedAmount()

TradeOffer

class

One row of a vendor's stock: item id, separate buy and sell prices, a stock count where -1 means unlimited, and an availability flag.

  • string OfferId { get; set; }
  • PriceTag BuyPrice { get; set; }
  • PriceTag SellPrice { get; set; }
  • int Stock { get; set; }
  • bool IsAvailable { get; set; }
  • void DecrementStock(int quantity)
  • void IncrementStock(int quantity)

VendorDefinition

class

The runtime vendor: identity, offers, whether it barters and at what markup, and its restock interval. Offers can be added, removed, and looked up by id or by item.

  • VendorId Id { get; set; }
  • List<TradeOffer> Offers { get; set; }
  • bool AcceptsBarter { get; set; }
  • float BarterMarkup { get; set; }
  • float RestockIntervalSeconds { get; set; }
  • void AddOffer(TradeOffer offer)
  • TradeOffer GetOffer(string offerId)
  • List<TradeOffer> GetOffersForItem(string itemDefinitionId)

TransactionResult

class

What a buy or sell agreed: success flag, transaction type, item, quantity, the price paid or received, and an error message when it failed. Constructed through the Succeeded and Failed factories.

  • bool Success { get; set; }
  • TransactionType TransactionType { get; set; }
  • string ItemDefinitionId { get; set; }
  • int Quantity { get; set; }
  • PriceTag? PricePaid { get; set; }
  • string ErrorMessage { get; set; }

BarterProposal

class

A proposer plus offered and requested BarterItem lists, carrying a mutable BarterStatus. The constructor rejects an empty proposer or an empty list on either side.

  • string ProposerId { get; set; }
  • List<BarterItem> OfferedItems { get; set; }
  • List<BarterItem> RequestedItems { get; set; }
  • BarterStatus Status { get; set; }

CurrencyId

struct

Case-normalised currency handle. Lower-cased at construction and throws on null or whitespace, so a typo fails loudly at the call site rather than silently creating a second currency.

  • string Value { get; }

DefaultCurrencies

class

Six ready-made CurrencyId values: Gold, Silver, Copper, Scraps, Credits, and Reputation. A convenience set, not a closed list.

WalletComponent

component

Per-entity scene-side wallet. Holds the entity id and a starter-balance list, seeds once, and forwards every read and write to the registered ICurrencyService, so the service stays the single source of truth.

  • string EntityId { get; }
  • ICurrencyService Service { get; }
  • void EnsureSeeded()
  • long GetBalance(CurrencyDefinitionAsset currency)
  • void Credit(CurrencyDefinitionAsset currency, long amount)
  • bool TryDebit(CurrencyDefinitionAsset currency, long amount)

ZOAEconomySceneInstaller

component

Scene-root bootstrap at execution order -9300. Registers the currency, vendor, and barter services, shares a QuantityLedger when one is already installed, and ensures the currency pickup adapter.

  • void EnsureRegistered()
  • ICurrencyService Currency { get; }
  • IVendorService Vendor { get; }
  • IBarterService Barter { get; }

CurrencyPickup

component

A world pickup that awards a currency amount, built on the shared pickup base. It raises a static Granted event rather than crediting directly, which keeps the interaction package free of any economy reference.

  • static event Action<PickupPayload, GameObject> Granted
  • void SetPayload(CurrencyDefinitionAsset currency, long amount)

CurrencyPickupAdapter

component

The one subscriber that turns a currency pickup into a credit. Resolves the collector's wallet, falling back to the collector's GameObject name as the entity id. Ensured into the scene by the installer.

  • static CurrencyPickupAdapter EnsureInScene(Transform parent = null)

WalletHUDElement

class

A UI Toolkit element showing one entity's balance in one currency. Bind it to a service, a currency asset, and an entity id, and it redraws itself from BalanceChanged instead of polling.

  • void Bind(ICurrencyService service, CurrencyDefinitionAsset currency, string entityId)
  • const string UssClass = "zoa-hud-wallet"

Surface

Authoring assets

CurrencyDefinitionAsset

asset

The authoring asset for a currency, from Assets > Create > Tools > ZOA > Economy > Currency. Carries the machine-readable code the runtime resolves plus display metadata (symbol, icon, accent, description) and optional rules for a maximum balance and negative balances.

  • string CurrencyCode
  • string Symbol
  • Sprite Icon
  • Color Accent
  • long MaximumBalance
  • bool AllowNegativeBalance
  • CurrencyId ToCurrencyId()

VendorDefinitionAsset

asset

The authoring asset for a vendor, from Assets > Create > Tools > ZOA > Economy > Vendor. A portrait and description plus barter settings, a restock interval, and the stock table of VendorStockEntry rows that is the heart of vendor authoring. ToVendorDefinition materialises the runtime object.

  • string VendorCode
  • bool AcceptsBarter
  • float BarterMarkup
  • float RestockIntervalSeconds
  • IReadOnlyList<VendorStockEntry> Stock
  • VendorDefinition ToVendorDefinition()

Usage

Examples

Resolving the service and moving moneycsharp
using ZOA.Economy.Core;
using ZOA.Messaging;

var currency = FoundryServiceRegistry.Get<ICurrencyService>();
var credits = new CurrencyId("credits");

currency.BalanceChanged += (sender, e) =>
    Debug.Log(e.EntityId + " " + e.Currency + ": " + e.PreviousBalance + " -> " + e.NewBalance);

currency.AddCurrency("player.local", credits, 500);

// Debit refuses rather than throwing when the entity is short.
if (!currency.RemoveCurrency("player.local", credits, 900))
    Debug.Log("Not enough credits.");

// Transfer debits first: a failed transfer credits nobody.
bool paid = currency.Transfer("player.local", "faction.seekers", credits, 250);
Balances clamp at zero. RemoveCurrency returning false is the affordability check, so there is no separate CanAfford call.
A vendor purchase, end to endcsharp
using ZOA.Economy.Core;
using ZOA.Messaging;

var vendors = FoundryServiceRegistry.Get<IVendorService>();
var vendorId = new VendorId("outpost_quartermaster");

foreach (var offer in vendors.GetAvailableOffers(vendorId))
{
    Debug.Log(offer.ItemDefinitionId + " costs " + offer.BuyPrice.GetDiscountedAmount()
              + " " + offer.BuyPrice.Currency);
}

TransactionResult result = vendors.TryBuy(vendorId, "offer.ammo_9mm", "player.local");

if (result.Success)
{
    // The service settled the money and the stock. Handing the
    // item over is ours to do, through whatever owns the bag.
    GrantItem(result.ItemDefinitionId, result.Quantity);
}
else
{
    Debug.Log("Refused: " + result.ErrorMessage);
}
TryBuy always transacts a quantity of one. Selling takes a quantity argument; buying does not.
Authoring a vendor in codecsharp
using ZOA.Economy.Core;

var vendorId = new VendorId("outpost_quartermaster");
var vendor = new VendorDefinition(
    vendorId,
    displayName: "Outpost Quartermaster",
    description: "Sells what the outpost can spare.",
    acceptsBarter: true,
    barterMarkup: 1.2f,
    restockIntervalSeconds: 1800f);

var credits = new CurrencyId("credits");

vendor.AddOffer(new TradeOffer(
    offerId: "offer.ammo_9mm",
    vendorId: vendorId,
    itemDefinitionId: "ammo_9mm",
    buyPrice: new PriceTag(credits, 40, discount: 0.2f),   // pays 32
    sellPrice: new PriceTag(credits, 12),
    stock: 20));

vendors.RegisterVendor(vendor);
Buy and sell prices are independent PriceTags, so the vendor's spread is authored rather than computed.
Sharing one ledger across currency and narrative valuescsharp
using ZOA.Economy.Core;
using ZOA.Quantities.Core.Contracts;
using ZOA.Quantities.Core.Runtime;

// What the scene installer does when a QuantityLedgerComponent
// has already registered a ledger: currency balances and every
// other tracked scalar land in one store and one save section.
IQuantityLedger ledger = new QuantityLedger();
ICurrencyService currency = new CurrencyService(ledger);

currency.AddCurrency("player.local", new CurrencyId("credits"), 100);

// Currency slots are namespaced under "currency.<id>" in the ledger.
long viaLedger = ledger.Get("player.local", new QuantityId("currency.credits"));
The parameterless CurrencyService constructor allocates its own private ledger, the shape tests and headless construction use.

Tooling

Editor tools

Economy Workbench module

Tools > ZOA > Workbench > Open Workbench, then Economy

Three capabilities under the Items and Economy workflow: Vendors and Currencies browsers that create, edit, validate, duplicate, and delete definition assets under Assets/ZOA/Economy, plus an Overview of how the services fit together. Each list row can ping its asset into the Project window.

Currency Wizard

Tools > ZOA > Advanced > Define > Items > Economy > Currency Wizard

A four-step flow: identity, presentation, rules, review. The menu entry routes into the Workbench's economy module and finishing emits a CurrencyDefinitionAsset.

Bundled currencies

Tools > ZOA > Advanced > Generate > Economy > Bundled Currencies > Ensure Assets

Emits baseline currency assets under Assets/ZOA/Generated/Economy/Currencies, including the bullet and gold codes. Idempotent: existing assets are detected and skipped.

Economy scene installer utility

ZOAEconomySceneInstallerUtility installs or updates the economy scene root and its installer in the active scene. Scene scaffolding calls it directly; there is no menu item.

Read this

Notes and caveats

See also