Gameplay · guide
Currencies, vendors, trade, mail and the auction
Every transaction is one balanced, idempotent intent against a ledger seam; the trade confirm-lock needs a revision doc 28 does not mention, and everything is escrowed when it is offered rather than when it is taken.
Edit this page on GitHubDocuments
- EconomyAccount
- AssetMove
- EconomyIntent
- EconomyVerdict
- EconomyResult
- IEconomyLedger
- MemoryEconomyLedger
- KeyHorizon
- CurrencyScope
- CurrencyDefinition
- CurrencyConversionDefinition
- CurrencyConversion
- CurrencyExchange
- Currency
- VendorStockDefinition
- VendorDefinition
- VendorStock
- Vendor
- VendorState
- VendorRefusal
- BuybackEntry
- TradeStatus
- TradeRefusal
- TradeOffer
- TradeSession
- EconomyLibrary
- EconomyModule
- MailId
- MailRefusal
- MailAttachment
- MailMessage
- PostOffice
- ListingId
- AuctionRefusal
- ListingStatus
- AuctionListing
- AuctionHouse
- TradeRecord
- IMarketModel
- MovingAverageMarket
What it is#
An EconomyIntent is a set of movements that either all happen or none do, named by a key that
makes replaying it free. A Currency is gold, tokens, marks or karma — one type with a cap, a
decay and conversions. A VendorState is one vendor's stock, restock clock and buyback window. A
TradeSession is two players swapping, with the confirm-lock that makes the last-moment swap
impossible. A PostOffice delivers goods and money to people who are not online, which is what an
AuctionHouse settles into, and an IMarketModel says what one of something goes for.
What it is for#
The part of a game where correctness is not negotiable. Doc 28: *"every one of those is a ledger transaction with an idempotency key"* — a duplicated settlement, a retried claim and a confirmation that arrives twice are all no-ops the second time by construction.
Using it#
Compile an EconomyLibrary, give the realm an IEconomyLedger, and post intents.
⚠ This library never holds or moves anything. It says what must move; the realm applies it. That
is what keeps a trade escrow from being a second container implementation, and it is the same shape
QuestJournal.TurnIn has.
⚠ Balanced per asset, not overall — otherwise gold leaving one account can be paid for by ore arriving in another.
⚠ A player may not go negative; a world account may. That asymmetry is what makes a world account a source or a sink.
⚠ A confirmation quotes the revision it saw. "Any change re-opens both confirmations" loses the race where a change and a confirmation cross in flight; the revision turns that into a refusal.
⚠ Stock is taken only after the ledger says yes, and buyback costs exactly what was paid.
⚠ A cap reports its overflow and a conversion keeps its remainder; decay rounds down so it can reach zero.
⚠ Everything is escrowed when it is offered, not when it is taken. Mail escrows an attachment on posting and an auction escrows the goods on listing; recording a promise and moving it on claim lets a sender attach a sword, post it, sell it, and have the recipient claim a second one.
⚠ Outbidding refunds the previous bidder in the same intent, because two operations is a window in which the refund fails and somebody's gold is gone. ⚠ The deposit comes back on a sale and is destroyed on an expiry, which is what prices listing something nobody wants. ⚠ A listing with a bid may not be withdrawn, or a seller cancels every auction they are about to lose.
The key set is the one thing here that leaks#
A MemoryEconomyLedger remembers every key it has applied, and by default it remembers them for ever
— which over a week of uptime is every key of that week. Give it a KeyHorizon and sweep it off the
frame path:
var ledger = new MemoryEconomyLedger(KeyHorizon.Outliving(TimeSpan.FromMinutes(2)));// Wherever the realm already does housekeeping. Not from a rule, and not from Post.ledger.Forget(DateTimeOffset.UtcNow);⚠ The argument is the retry window, not the horizon, and that is the whole of the type. The two
failure modes are not comparable: too long costs memory, which is visible in a graph and recoverable
by restarting; too short duplicates an item, which is invisible, permanent and indistinguishable from
an exploit when a player reports it. So Outliving is the only bounded constructor, its
Guaranteed retention is always strictly longer than the window it was built from, and a horizon
shorter than the retries it must outlive cannot be written down.
⚠ Which is why the default is the leak. There is no default horizon, because a number nobody chose is not safer than an unbounded set — it is the same risk with the evidence removed.
⚠ And a departing player's rows are the realm's to take away. Release hands everything an account
holds to the world account it was seeded out of and drops the rows. It is deliberately not an intent:
nothing has moved, the player still owns what they left with, and writing a movement would put a
handover in the journal as a transaction.
Examples#
Two currencies and a vendor:
# Assets/Economy/gold.vxdef!CurrencyDefinitiondisplayName: Goldtag: Currency.Goldcap: 1000000scope: Account# Assets/Economy/smith.vxdef!VendorDefinitiondisplayName: SmithbuybackSlots: 12stock: - { item: items/potion, currency: currency/gold, price: 5 } - { item: items/sword, currency: currency/gold, price: 100, quantity: 2, restockSeconds: 600 }Buying something:
using Vixen.Gameplay;using Vixen.Gameplay.Economy;static class Shop { public static VendorRefusal Buy(VendorState smith, PlayerId who, IEconomyLedger ledger, float now) => // The operation string is what makes a retry free: the same purchase twice is one purchase. smith.Buy(who, row: 1, count: 1, ledger, context: null, now, operation: "click-7134");}A trade, from the server's side:
using Vixen.Gameplay;using Vixen.Gameplay.Economy;static class Swap { public static TradeRefusal Accept(TradeSession trade, PlayerId who, int revisionTheClientSaw) => // Stale if anything moved since that revision — which is the whole confirm-lock. trade.Confirm(who, revisionTheClientSaw); public static TradeRefusal Finish(TradeSession trade, IEconomyLedger ledger) { var refusal = trade.Settle(ledger, out var result); // One intent, so a trade that half-applied is not a state the ledger can be left in. return refusal != TradeRefusal.None || !result.Ok ? refusal : TradeRefusal.None; }}Minting and sinking, which is what a world account is for:
using Vixen.Gameplay;using Vixen.Gameplay.Economy;static class Mint { public static EconomyResult Reward(IEconomyLedger ledger, PlayerId who, DefId currency, long amount) => ledger.Post( EconomyIntent.Transfer( $"reward/{who}/{currency}", EconomyAccount.Of(EconomyAccount.Vendor), EconomyAccount.Of(who), currency, amount ) );}Selling something, and what the fee destroys:
using Vixen.Gameplay;using Vixen.Gameplay.Economy;static class Market { public static AuctionRefusal Sell(AuctionHouse house, PlayerId seller, DefId sword, DefId gold) => // The goods leave the seller now, not when it sells. house.List(seller, sword, 1, gold, startingBid: 500, buyout: 2000, deposit: 25, hours: 24f, now: 0f, "list-1", out _); public static int Settle(AuctionHouse house, float now) => // Closes whatever has run out: sold ones pay the seller by mail and destroy the fee, // unsold ones go back and keep the deposit. house.Expire(now);}See also#
- Items — what a vendor's stock names.
- Inventory — what actually moves the goods this library reports.
- Requirements — what gates a stock row.