Files
recflare/apps/econ/src/room-currency-db.ts
T
2026-10-08 13:03:26 -04:00

670 lines
25 KiB
TypeScript

/**
* Room currencies — a room's own money.
*
* A creator mints one for their room ("MySpecialTokens"), hands it out for doing things in
* there, and sells things for it: finish the obstacle course, earn 50 tokens, spend them on a
* custom shirt at the shop by the door. It is play money for one room, and it means nothing
* outside it.
*
* This module is the CURRENCY ITSELF — its name, its coin art, how fast the room may hand it
* out. What a player has actually earned is a separate thing, in `room_balance` further down.
* The endpoints:
* - `POST /api/roomcurrencies/v1/createCurrency` mints one
* - `POST /api/roomcurrencies/v1/updateCurrency` edits it
* - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's
* - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players
* - the purchase offers below are the shop that sells the currency for other money
* (`POST /api/roomcurrencies/v1/createPurchaseOffer` lists one, `…/deletePurchaseOffer`
* takes it down)
* - `POST /api/roomCurrencies/v2/purchase` buys from that shop, with tokens
* - `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` spends it, on a room key
*
* Two things to know before touching it:
*
* These must never reach the `balance` table, which is keyed by `(account, currency_type)`
* alone. That key cannot say WHICH room's currency a row is, so one room's tokens would spend
* in every other room — see `CurrencyType` in balance-db.ts, where `RoomCurrency` is left out
* of the spendable set for exactly this reason.
*
* And every currency here carries `CurrencyType.RoomCurrency` (300), because that is what the
* type MEANS: "some room's currency". One is told apart from another by its own `CurrencyId`,
* never by its type — so the type is a constant on the DTO rather than a column.
*
* This worker (`econ`) owns the table and its migrations — see apps/econ/migrations/, 0017,
* 0018 and 0020.
*/
import { CurrencyType } from './balance-db'
/**
* Schema DDL — also builds the table in tests. Mirrors the table as it STANDS: 0017 created
* it, 0018 made `image_name` nullable and 0020 added `purchase_offers`, so this is the three
* of them together rather than any one file.
*/
export const ROOM_CURRENCY_SCHEMA_DDL: string[] = [
// `currency_id` is a GUID rather than an autoincrement: it is how the client names this
// currency everywhere afterwards (its balances, its storefront prices), and a room's
// currencies are created independently of every other room's, so an id that says nothing
// about ordering is the honest one.
//
// `limit_amount`, not `limit` — `LIMIT` is a SQL keyword, and a column that has to be
// quoted at every use is a column that will eventually not be. It is the most of this
// currency the room may award PER DAY, a faucet rate rather than a cap on what a player
// may hold, and nothing enforces it yet: no day's awards are tracked anywhere.
//
// `shape` and `color` are the client's own indices into its coin art (the body sends
// `Shape=0&Color=19`); nothing here interprets them, they are stored and served back.
// `color` is signed — the client sends -1 for "no colour chosen".
//
// `image_name` is separate and NULLABLE, not empty-string: the client serves null for a
// currency with no custom coin art, and null is what its model carries. It stays null
// until a room can upload art — the column is here rather than in a later migration
// because the client's model has the member either way.
//
// `purchase_offers` is the currency's shop, as a JSON array (see
// {@link RoomCurrencyPurchaseOffer}). A column rather than a table because offers are
// read, written and replaced as a WHOLE, for one currency at a time.
`CREATE TABLE IF NOT EXISTS room_currency (
currency_id TEXT PRIMARY KEY,
room_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
limit_amount INTEGER NOT NULL,
shape INTEGER NOT NULL,
color INTEGER NOT NULL,
image_name TEXT,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
purchase_offers TEXT
)`,
// Every read is "this room's currencies" — the whole access pattern.
`CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id)`,
]
/**
* A room currency as the client reads it — its own model, member for member and IN ITS
* ORDER, taken from the client's field layout rather than inferred. The same object the
* create endpoint answers with and the `RoomCurrencyCreated`/`Modified` frames carry, which
* is why `econ.app.ts` assigns one of these straight to a `RoomCurrencyPayload`: that
* assignment is what stops the two drifting apart.
*
* `CurrencyType` is always {@link CurrencyType.RoomCurrency} — see the module doc. `RoomId`
* is `long?` in the client's model but is never null here: a room currency belongs to a
* room by definition, and the column is NOT NULL.
*/
export interface RoomCurrency {
CurrencyId: string
RoomId: number
Name: string
Description: string
CurrencyType: number
/**
* The most of this currency the room may award PER DAY — a faucet rate, not a ceiling on
* what a player may hold. Stored and served; nothing enforces it yet, because nothing
* tracks a day's awards.
*/
Limit: number
/** A byte in the client's model — its index into the coin shapes. */
Shape: number
Color: number
/** Null for a currency with no custom coin art, which is every one of them so far. */
ImageName: string | null
/** ISO-8601 UTC. */
CreatedAt: string
/** ISO-8601 UTC. */
ModifiedAt: string
}
/** What the create endpoint supplies; the id and timestamps are minted here. */
export interface NewRoomCurrency {
RoomId: number
Name: string
Description: string
Limit: number
Shape: number
Color: number
}
interface RoomCurrencyRow {
currency_id: string
room_id: number
name: string
description: string
limit_amount: number
shape: number
color: number
image_name: string | null
created_at: string
modified_at: string
}
const SELECT_COLUMNS = `currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at`
const toRoomCurrency = (row: RoomCurrencyRow): RoomCurrency => ({
CurrencyId: row.currency_id,
RoomId: row.room_id,
Name: row.name,
Description: row.description,
CurrencyType: CurrencyType.RoomCurrency,
Limit: row.limit_amount,
Shape: row.shape,
Color: row.color,
ImageName: row.image_name,
CreatedAt: row.created_at,
ModifiedAt: row.modified_at,
})
/**
* Mint a room currency, returning it as the client reads it back.
*
* `CreatedAt` and `ModifiedAt` start equal — the client's model carries both and an edit
* will move only the second, so a never-edited currency reads as created-and-untouched
* rather than as having no modification time at all.
*/
export async function createRoomCurrency(
db: D1Database,
currency: NewRoomCurrency
): Promise<RoomCurrency> {
const now = new Date().toISOString()
const row = await db
.prepare(
`INSERT INTO room_currency
(currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)
RETURNING ${SELECT_COLUMNS}`
)
.bind(
crypto.randomUUID(),
currency.RoomId,
currency.Name,
currency.Description,
currency.Limit,
currency.Shape,
currency.Color,
now
)
.first<RoomCurrencyRow>()
return toRoomCurrency(row!)
}
/** One currency by its id, or null when there is no such row. */
export async function getRoomCurrency(
db: D1Database,
currencyId: string
): Promise<RoomCurrency | null> {
const row = await db
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_currency WHERE currency_id = ?1`)
.bind(currencyId)
.first<RoomCurrencyRow>()
return row ? toRoomCurrency(row) : null
}
/**
* What an update may change. Everything else about a currency is fixed: `CurrencyId` names
* it, `RoomId` is the room that minted it (moving one between rooms would strand whatever
* players hold of it), `CurrencyType` is what it IS, and `CreatedAt` already happened.
*
* Every field is optional and an absent one is LEFT ALONE rather than reset. The client sends
* the whole form, so this only bites a partial request — and there, blanking a description
* because it wasn't mentioned is the worse of the two readings.
*/
export interface RoomCurrencyEdit {
Name?: string
Description?: string
Limit?: number
Shape?: number
Color?: number
}
/**
* Apply an edit to a currency, returning it as the client reads it back.
*
* `ModifiedAt` moves; `CreatedAt` does not. That is the whole point of the client's model
* carrying both, and it is why the two start equal on a currency that has never been edited.
*
* The caller supplies the already-loaded currency (after its room's owner check) both to
* avoid a re-read and because the merge needs the current values to leave absent fields alone.
*/
export async function updateRoomCurrency(
db: D1Database,
currency: RoomCurrency,
edit: RoomCurrencyEdit
): Promise<RoomCurrency> {
const merged: RoomCurrency = {
...currency,
Name: edit.Name ?? currency.Name,
Description: edit.Description ?? currency.Description,
Limit: edit.Limit ?? currency.Limit,
Shape: edit.Shape ?? currency.Shape,
Color: edit.Color ?? currency.Color,
ModifiedAt: new Date().toISOString(),
}
await db
.prepare(
`UPDATE room_currency
SET name = ?2, description = ?3, limit_amount = ?4, shape = ?5, color = ?6, modified_at = ?7
WHERE currency_id = ?1`
)
.bind(
merged.CurrencyId,
merged.Name,
merged.Description,
merged.Limit,
merged.Shape,
merged.Color,
merged.ModifiedAt
)
.run()
return merged
}
/**
* Every currency a room has minted, oldest first — the order they were created in, which is
* the order the room's owner built them up in and so the one they expect to see.
*/
export async function getRoomCurrencies(db: D1Database, roomId: number): Promise<RoomCurrency[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM room_currency WHERE room_id = ?1 ORDER BY created_at, currency_id`
)
.bind(roomId)
.all<RoomCurrencyRow>()
return results.map(toRoomCurrency)
}
/**
* One purchase offer on a room currency — a way to BUY that currency with other money, rather
* than earning it. A room that sells its tokens rather than only handing them out lists them
* here: "5 MySpecialTokens for 500 Rec Center Tokens". The client's own model, member for
* member and in its order.
*
* `CurrencyId` names the currency being SOLD, so an offer knows its own shop. It is not part
* of what gets stored: the row the offers live on already says which currency they belong to,
* and {@link getPurchaseOffers} projects it back in. `Price` is in whatever currency the room
* charges in, which nothing here records yet — the client's model has no field for it either.
*/
export interface RoomCurrencyPurchaseOffer {
CurrencyPurchaseOfferId: string
CurrencyId: string
Order: number
Name: string
CurrencyAmount: number
Price: number
/** ISO-8601 UTC. */
ModifiedAt: string
}
/**
* An offer as it is STORED in `room_currency.purchase_offers` — everything above except
* `CurrencyId`, which the owning row already says.
*/
export type StoredPurchaseOffer = Omit<RoomCurrencyPurchaseOffer, 'CurrencyId'>
/**
* Read a JSON offers column into whole offers, stamping each with the currency it belongs to.
*
* Null, an empty column and anything that doesn't parse as an array are all "no offers" — a
* currency nobody has built a shop for simply has none, and a column that has somehow been
* corrupted should show an empty shop rather than fail the whole batch.
*/
function parsePurchaseOffers(currencyId: string, json: string | null): RoomCurrencyPurchaseOffer[] {
if (!json) return []
let parsed: unknown
try {
parsed = JSON.parse(json)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
return (parsed as StoredPurchaseOffer[]).map((offer) => ({
CurrencyPurchaseOfferId: String(offer.CurrencyPurchaseOfferId ?? ''),
CurrencyId: currencyId,
Order: Number(offer.Order ?? 0),
Name: String(offer.Name ?? ''),
CurrencyAmount: Number(offer.CurrencyAmount ?? 0),
Price: Number(offer.Price ?? 0),
ModifiedAt: String(offer.ModifiedAt ?? ''),
}))
}
/** What the create-offer endpoint supplies; the id and `ModifiedAt` are minted here. */
export interface NewPurchaseOffer {
Name: string
CurrencyAmount: number
Price: number
Order: number
}
/**
* Append a purchase offer to a currency's shop, returning it as the client reads it back.
*
* Appended IN SQL, with `json_insert(…, '$[#]', …)`, rather than read-modify-written here:
* two offers added at once would otherwise race, and the second read would overwrite the
* first's append with a list that never had it. `COALESCE(…, '[]')` covers the null column a
* currency starts life with, so the first offer needs no separate "create the array" step.
*
* The stored object deliberately omits `CurrencyId` — the row it lands on already says which
* currency these are offers for (see {@link StoredPurchaseOffer}) — but the returned offer
* carries it, because the client's model does.
*/
export async function createPurchaseOffer(
db: D1Database,
currencyId: string,
offer: NewPurchaseOffer
): Promise<RoomCurrencyPurchaseOffer> {
const stored: StoredPurchaseOffer = {
CurrencyPurchaseOfferId: crypto.randomUUID(),
Order: offer.Order,
Name: offer.Name,
CurrencyAmount: offer.CurrencyAmount,
Price: offer.Price,
ModifiedAt: new Date().toISOString(),
}
await db
.prepare(
`UPDATE room_currency
SET purchase_offers = json_insert(COALESCE(purchase_offers, '[]'), '$[#]', json(?2))
WHERE currency_id = ?1`
)
.bind(currencyId, JSON.stringify(stored))
.run()
// Built out member by member rather than spread: the client's model puts `CurrencyId`
// SECOND, and a spread would land it at the end.
return {
CurrencyPurchaseOfferId: stored.CurrencyPurchaseOfferId,
CurrencyId: currencyId,
Order: stored.Order,
Name: stored.Name,
CurrencyAmount: stored.CurrencyAmount,
Price: stored.Price,
ModifiedAt: stored.ModifiedAt,
}
}
/** A purchase offer found by its own id, beside the room that sells it. */
export interface FoundPurchaseOffer {
Offer: RoomCurrencyPurchaseOffer
RoomId: number
}
/**
* Find a purchase offer by its `CurrencyPurchaseOfferId` — all a purchase names. The offer
* lives inside its currency's `purchase_offers` JSON, so this walks the shops (`json_each`)
* for the one that lists it; the currency it sells is the row it was found on. Null when no
* shop lists that id.
*/
export async function findPurchaseOffer(
db: D1Database,
purchaseOfferId: string
): Promise<FoundPurchaseOffer | null> {
const row = await db
.prepare(
`SELECT c.currency_id, c.room_id, o.value AS offer
FROM room_currency AS c, json_each(c.purchase_offers) AS o
WHERE json_valid(c.purchase_offers)
AND lower(json_extract(o.value, '$.CurrencyPurchaseOfferId')) = lower(?1)
LIMIT 1`
)
.bind(purchaseOfferId)
.first<{ currency_id: string; room_id: number; offer: string }>()
if (!row) return null
const [offer] = parsePurchaseOffers(row.currency_id, `[${row.offer}]`)
return offer ? { Offer: offer, RoomId: row.room_id } : null
}
/**
* Take a purchase offer down by its `CurrencyPurchaseOfferId`, returning the offer that was
* removed — beside its room, like {@link findPurchaseOffer} — or null when no shop lists it.
*
* The id is all the delete names, and the offer lives inside its currency's `purchase_offers`
* JSON, so this is a find (which says which row, and which room gates the delete) and then a
* rewrite of that ONE row's array without the offer. The rewrite is done in SQL —
* `json_group_array` over `json_each`, keeping every entry whose id is not this one — rather
* than read-modify-written here, so an offer being added to the same shop at the same moment
* is not lost under a stale copy of the list; `createPurchaseOffer` appends in SQL for the same
* reason. Filtered by id, not by position, so a concurrent append cannot shift the wrong entry
* under the delete. The match is case-insensitive like the find: the client has been seen
* sending ids in either case.
*
* A shop emptied by its last delete is left as `[]`, not reset to null: both read as "no
* offers", and nothing distinguishes a shop never built from one cleared out.
*/
export async function deletePurchaseOffer(
db: D1Database,
purchaseOfferId: string
): Promise<FoundPurchaseOffer | null> {
const found = await findPurchaseOffer(db, purchaseOfferId)
if (!found) return null
const { meta } = await db
.prepare(
`UPDATE room_currency
SET purchase_offers = (
SELECT json_group_array(json(o.value))
FROM json_each(room_currency.purchase_offers) AS o
WHERE lower(json_extract(o.value, '$.CurrencyPurchaseOfferId')) <> lower(?2)
)
WHERE currency_id = ?1 AND json_valid(purchase_offers)`
)
.bind(found.Offer.CurrencyId, purchaseOfferId)
.run()
// The find said the row was there; a rewrite that touched nothing means it went away
// between the two (the currency deleted, say), and there is no offer to report removed.
return meta.changes > 0 ? found : null
}
/** One currency's shop — the group the batch read answers in, one per currency asked for. */
export interface RoomCurrencyPurchaseOffers {
CurrencyId: string
PurchaseOffers: RoomCurrencyPurchaseOffer[]
}
/**
* The purchase offers of several currencies at once, GROUPED BY CURRENCY — what
* `getPurchaseOffersBatch` serves.
*
* Grouped rather than flat even though every offer already carries its own `CurrencyId`: the
* group is what says a currency was ASKED ABOUT, which a flat list cannot. A currency with no
* shop yet still gets a group, with an empty `PurchaseOffers` — "this currency sells nothing"
* and "you didn't ask about this currency" are different answers.
*
* Groups come back in the order the ids were given, each shop sorted by its offers' `Order`,
* so a caller gets its own batch back in its own order. An id naming no currency at all is
* omitted, the way the other batch lookups here treat an unknown id — there is no shop to
* report, empty or otherwise.
*/
export async function getPurchaseOffers(
db: D1Database,
currencyIds: string[]
): Promise<RoomCurrencyPurchaseOffers[]> {
if (currencyIds.length === 0) return []
const placeholders = currencyIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(
`SELECT currency_id, purchase_offers FROM room_currency WHERE currency_id IN (${placeholders})`
)
.bind(...currencyIds)
.all<{ currency_id: string; purchase_offers: string | null }>()
const byId = new Map(results.map((r) => [r.currency_id, r.purchase_offers]))
return currencyIds
.filter((currencyId) => byId.has(currencyId))
.map((currencyId) => ({
CurrencyId: currencyId,
PurchaseOffers: parsePurchaseOffers(currencyId, byId.get(currencyId) ?? null).sort(
(a, b) => a.Order - b.Order
),
}))
}
/**
* How much of a room's own money each player has earned — the other half of the feature, the
* currencies themselves being above.
*
* Keyed by CURRENCY, not by currency type, and that difference is the whole point: the
* account-scoped `balance` table's `(account, currency_type)` key cannot say which room's
* currency a row is, so one room's tokens would spend in every other room. Room currencies
* are left out of that table for exactly this reason (see `CurrencyType` in balance-db.ts).
*
* A missing row is a balance of zero. Nothing is inserted until a player is first paid, so a
* room's currency costs a row per player who has actually earned some rather than one per
* player who has walked in.
*/
export const ROOM_BALANCE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_balance (
currency_id TEXT NOT NULL,
player_id INTEGER NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (currency_id, player_id)
)`,
// "What does this player hold across the room?" walks the currencies, so the reverse
// lookup earns its own index rather than a scan of every holder of every currency.
`CREATE INDEX IF NOT EXISTS idx_room_balance_player ON room_balance (player_id)`,
]
/** One player's holding of one room currency. */
export interface RoomBalance {
CurrencyId: string
PlayerId: number
Amount: number
}
/** What an award did: the resulting total, and how much actually landed. */
export interface RoomAwardResult {
/** The RESULTING total, never the change. */
Balance: number
/**
* What was actually added. Equal to the delta except where the zero floor bit — a
* deduction larger than the balance takes it to zero, not below, and this reports the part
* that had somewhere to come from.
*/
AmountAwarded: number
}
/**
* Move a player's holding of a room currency by `delta`, returning what they hold afterwards
* and how much of the delta actually landed.
*
* Floored at zero in the statement itself, so a deduction and a concurrent award can't race
* a balance negative between a read and a write: a negative `delta` is a deduction, and a
* player cannot owe a room currency.
*
* NOT capped at the currency's `Limit`. `Limit` is the most of a currency a room may award
* PER DAY — a faucet rate, not a ceiling on what a player may hold — and nothing tracks a
* day's awards yet, so it is stored and served but enforced nowhere. Clamping a balance to it
* (as this once did) silently refuses awards a player was entitled to: a `Limit` of 3 left
* everyone stuck at 3 coins forever.
*
* The before-reading, the write and the after-reading go in ONE `batch`, which D1 runs as a
* transaction, so nothing can change between the two reads but the write between them.
*/
export async function awardRoomCurrency(
db: D1Database,
currencyId: string,
playerId: number,
delta: number
): Promise<RoomAwardResult> {
const read = db.prepare(
'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2'
)
const [before, , after] = await db.batch<{ amount: number }>([
read.bind(currencyId, playerId),
db
.prepare(
`INSERT INTO room_balance (currency_id, player_id, amount)
VALUES (?1, ?2, MAX(0, ?3))
ON CONFLICT (currency_id, player_id)
DO UPDATE SET amount = MAX(0, room_balance.amount + ?3)`
)
.bind(currencyId, playerId, delta),
read.bind(currencyId, playerId),
])
// Read BACK rather than taken from a `RETURNING` on the upsert. The two differ in how they
// fail: a RETURNING that came back empty is indistinguishable from an award that changed
// nothing, so a write that didn't land would report `AmountAwarded: 0` on a balance that
// had in fact moved — a plausible-looking answer that is simply wrong. A read is either a
// row or no row, and no row is a real, correct zero.
const previous = before.results[0]?.amount ?? 0
const balance = after.results[0]?.amount ?? 0
return { Balance: balance, AmountAwarded: balance - previous }
}
/** What a player holds of each of a room's currencies. A currency they've never earned is absent. */
export async function getRoomBalances(
db: D1Database,
roomId: number,
playerId: number
): Promise<RoomBalance[]> {
const { results } = await db
.prepare(
`SELECT b.currency_id AS currencyId, b.player_id AS playerId, b.amount AS amount
FROM room_balance AS b
JOIN room_currency AS c ON c.currency_id = b.currency_id
WHERE c.room_id = ?1 AND b.player_id = ?2
ORDER BY c.created_at, c.currency_id`
)
.bind(roomId, playerId)
.all<{ currencyId: string; playerId: number; amount: number }>()
return results.map((r) => ({
CurrencyId: r.currencyId,
PlayerId: r.playerId,
Amount: r.amount,
}))
}
/** What a player holds of one room currency; 0 when they have never earned any. */
export async function getRoomBalance(
db: D1Database,
currencyId: string,
playerId: number
): Promise<number> {
const row = await db
.prepare('SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2')
.bind(currencyId, playerId)
.first<{ amount: number }>()
return row?.amount ?? 0
}
/**
* Spend `amount` of a room currency. Returns false — changing nothing — when the player
* does not hold that much.
*
* NOT {@link awardRoomCurrency} with a negative delta: that floors at zero, which is right
* for a room taking coins away and wrong for a purchase — a player 1 coin short would pay
* what they had and still be sold the thing. The `amount >= ?3` guard lives in the UPDATE
* itself, so the check and the debit are one atomic statement, as `spendCurrency` is for
* tokens.
*/
export async function spendRoomCurrency(
db: D1Database,
currencyId: string,
playerId: number,
amount: number
): Promise<boolean> {
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error(`spendRoomCurrency: amount must be a positive integer, got ${amount}`)
}
const { meta } = await db
.prepare(
`UPDATE room_balance SET amount = amount - ?3
WHERE currency_id = ?1 AND player_id = ?2 AND amount >= ?3`
)
.bind(currencyId, playerId, amount)
.run()
return meta.changes > 0
}