Pre-alphaFoundry is in development. Tools, APIs, and sample content may change.

Browse documentation
Gameplaycom.zoa.economy · v0.1.0

ZOA Economy System

Author currencies and vendors, then execute purchases and sales.

Economy manages currencies, vendor stock, prices, and transaction rules. CurrencyService stores balances through IQuantityLedger; the economy services add the checks required to buy, sell, or barter.

Create currency and vendor definitions in the editor, then connect the shop interface to the services. A transaction result lets the interface explain why an exchange could not complete.

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.

Referenced by ZOA manifests (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.

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.

Reference

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"

Reference

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.

Integration

Behavior to account for

See also