@gridlock/economy
v0.2.1
Published
Generic token-economy primitives for gridlock games — append-only ledger, spend/credit errors, numeric validation. Product packages compose on top with their own reason enums + catalogs.
Readme
@gridlock/economy
Generic token-economy primitives for gridlock games — the mechanism half of the engine/product split. Products compose on top with their own reason enums, offer catalogs, and prices.
The engine defines what the system can do; the product defines what it means. Reason enums, offer catalogs, prices, caps, and player-visible prose are all product-side. Ledger semantics, gate ordering, refusal codes, and the compensating-transaction shape are engine-side.
Migrated from TIC's @tic/economy across MS-27 follow-up #1497. See
docs/architecture/economy.md § Architecture in the TIC repo for the
gridlock/TIC split, the composition pattern, and the designer-interface
table.
What you get
| Symbol | Purpose |
|---|---|
| TokenLedger<TReason> | Append-only per-player ledger contract with credit / spend / adjust / getBalance / getHistory / replayBalance. Concurrency contract: two simultaneous spends serialise via SQLite BEGIN IMMEDIATE (in-memory backing inherits JS single-thread atomicity). |
| InMemoryTokenLedger<TReason> | Default backing — single-threaded JS atomicity. |
| SqliteTokenLedger<TReason> | Production backing — BEGIN IMMEDIATE concurrency, schema migrations baked in, persistence across restart. |
| LedgerEntry<TReason> / HistoryQuery<TReason> | Generic value types. |
| InsufficientBalanceError, InvalidLedgerInputError | Ledger errors. |
| assertPositiveIntegerDelta, assertNonZeroIntegerDelta, assertNonEmptyPlayerId | Generic validation helpers products plug into the ledger's assertCredit / assertSpend hooks. |
| TransactionStore (+ InMemoryTransactionStore, SqliteTransactionStore) | Off-ledger purchase metadata: 1:1 with the host's purchase-reason ledger entry. Holds external-provider session id, SKU, currency, status. Supports refund / chargeback transitions. |
| HistoryExporter<TReason> | Player-visible ledger + purchase history exporter (JSON + CSV). Constructor takes transactionReason: TReason naming the single ledger reason whose entries pair with TransactionRecord rows. |
| OfferEngine<TOfferId, TReason> | Spend-gate pipeline: known offer → enabled in case → stage id (per-stage caps) → frequency cap → balance. Ledger is never written on a refusal. Constructor requires resolveOffer(offerId): OfferDefinition \| undefined — no catalog ships in gridlock. |
| CompensatingOfferFlow<TOfferId, TReason> | "Redeem an offer, run a host-supplied restore, refund on failure" pattern. Hosts construct one flow per compensating offer (e.g. TIC's appeal). The frequency-cap slot is not refunded on restore failure — the player still consumed the slot. |
Status
End-state as of 2026-04-28. All five MS-27 #1497 slices have shipped; the
package is feature-complete as a mechanism layer and is consumed by
@tic/economy through workspace-protocol re-exports.
Installation
npm install @gridlock/economy better-sqlite3better-sqlite3 is a peer-style runtime dependency required by the
SQLite backings; the in-memory backings + the contract types work
without it but tree-shaking won't drop the import unless you avoid
SqliteTokenLedger / SqliteTransactionStore.
Wiring a product on top
A new gridlock game defines its own TReason and TOfferId unions and
supplies a catalog resolver. Reason enums, prices, caps, and ack
templates stay in your code — the engine never sees them.
import {
CompensatingOfferFlow,
HistoryExporter,
InMemoryTokenLedger,
OfferEngine,
type OfferDefinition,
} from '@gridlock/economy';
// Your product reason union — the ledger and offer engine both bind to it.
type MyReason =
| 'grant:welcome'
| 'grant:level-up'
| 'spend:hint'
| 'spend:retry'
| 'purchase'
| 'refund'
| 'adjustment:admin';
type MyOfferId = 'hint' | 'retry';
const CATALOG: Readonly<Record<MyOfferId, OfferDefinition<MyOfferId, MyReason>>> = {
hint: {
offerId: 'hint',
costInTokens: 1,
reason: 'spend:hint',
cap: { kind: 'per-stage', max: 1 },
description: 'Reveal one hint for the current puzzle.',
ackTemplate: 'Hint unlocked — {costInTokens} token spent ({balanceRemaining} left).',
},
retry: {
offerId: 'retry',
costInTokens: 5,
reason: 'spend:retry',
cap: { kind: 'per-case', max: 3 },
description: 'Replay the level with progress preserved.',
ackTemplate: 'Retry granted — {costInTokens} tokens spent ({balanceRemaining} left).',
},
};
const ledger = new InMemoryTokenLedger<MyReason>({
defaultAdjustmentReason: 'adjustment:admin',
});
const offerEngine = new OfferEngine<MyOfferId, MyReason>({
ledger,
resolveOffer: (id) => CATALOG[id],
});
const retryFlow = new CompensatingOfferFlow<MyOfferId, MyReason>({
ledger,
offerEngine,
offerId: 'retry',
refundReason: 'refund',
});
const historyExporter = new HistoryExporter<MyReason>({
ledger,
transactionStore, // your TransactionStore instance
transactionReason: 'purchase',
});That's the whole interface. All gating logic, atomicity, error shapes, and CSV/JSON formats are inherited.
What stays in your product package
- The reason union itself + credit/spend kind sets.
- The catalog (offer ids, prices, caps, ack templates).
- Earn schedules tied to your game's progression model.
- Appeal/retry constants if you want them as importable values.
- Player-facing alert systems, balance UIs, leaderboards.
Reference
- TIC's wiring:
packages/economy/src/in the TIC repo —@tic/economyis the canonical example consumer, with all generic shapes pinned to TIC'sTokenReason+OfferIdunions. - Migration history: TIC issue #1497.
- Architecture rationale:
docs/architecture/economy.md § Architecturein the TIC repo.
