[econ] currency WIP

This commit is contained in:
Devin Zuczek
2026-09-14 15:45:07 -04:00
parent 295ec7bc16
commit ba8423e438
10 changed files with 2359 additions and 5 deletions
@@ -0,0 +1,51 @@
-- 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. Play money for one room, meaning nothing outside it.
--
-- A row here is the CURRENCY — its name, its coin art, how fast the room may hand it out.
-- What a player has actually earned is `room_balance` (migration 0019). Owned by the `econ`
-- worker; generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL) — keep in sync.
--
-- These are NOT balances and must never reach the `balance` table, which is keyed by
-- (account_id, 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 src/balance-db.ts,
-- where RoomCurrency is left out of the spendable set for exactly this reason.
--
-- Every currency here is CurrencyType 300 (RoomCurrency) — that is what the type MEANS — and
-- is told apart from the others by its own `currency_id`, so the type is not a column.
--
-- `currency_id` is a GUID rather than an autoincrement: it is how the client names this
-- currency everywhere afterwards, and a room's currencies are minted 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 holds the most of this currency the
-- room may award PER DAY: a faucet rate, not a cap on what a player may hold. Nothing enforces
-- it yet — no day's awards are tracked anywhere — so it is stored and served and otherwise
-- inert.
--
-- `shape` and `color` are the client's own indices into its coin art (the create body sends
-- `Shape=0&Color=19`); nothing interprets them, they are stored and served back. `image_name`
-- is separate and stays empty until a room can upload custom coin art — the client's model
-- carries both, so the column is here rather than in a later migration.
--
-- NOTE: `image_name` was NOT NULL DEFAULT '' here, and 0018 makes it nullable — the client
-- serves null, not an empty string, for a currency with no custom coin art. This file is left
-- as it shipped; the current shape of the table is 0017 + 0018.
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 NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL
);
-- 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);
@@ -0,0 +1,45 @@
-- Make `room_currency.image_name` nullable, and turn the empty strings already stored into
-- nulls. Owned by the `econ` worker; 0017 created the table with `image_name TEXT NOT NULL
-- DEFAULT ''`. Generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL, which mirrors
-- the table as it stands AFTER this migration) — keep in sync.
--
-- The client's own model has `ImageName` as a nullable string and serves NULL for a currency
-- with no custom coin art; an empty string is a different value to its decoder, so a currency
-- created before this read back as having art named "". Nothing can upload coin art yet, so
-- every row is affected and every one of them should be null.
--
-- A rebuild rather than an ALTER: SQLite cannot drop a NOT NULL constraint in place. This is
-- the standard four-step swap — build the new shape, copy across, drop the old, rename — and
-- the index goes with the dropped table, so it is recreated at the end rather than left to be
-- noticed later by a slow query. `NULLIF(image_name, '')` is the conversion: it maps '' to
-- NULL and leaves a real name alone, so a row that somehow already carries art keeps it.
--
-- Safe to re-run in spirit but not in fact — it is a migration, applied once, and the rename
-- would fail on a second pass because `room_currency_new` no longer exists.
CREATE TABLE room_currency_new (
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
);
INSERT INTO room_currency_new (
currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at
)
SELECT
currency_id, room_id, name, description, limit_amount, shape, color, NULLIF(image_name, ''), created_at, modified_at
FROM room_currency;
DROP TABLE room_currency;
ALTER TABLE room_currency_new RENAME TO room_currency;
-- Recreated because dropping the old table took its index with it. Same index 0017 defined:
-- every read is "this room's currencies".
CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id);
@@ -0,0 +1,28 @@
-- How much of a room's own money each player has earned — the other half of room currencies
-- (the currencies themselves are `room_currency`, migration 0017). Written by
-- `POST /api/roomcurrencies/v1/awardCurrency/bulk`, which is how a room pays a player for
-- finishing its obstacle course. Owned by the `econ` worker; generated from
-- src/room-currency-db.ts (ROOM_BALANCE_SCHEMA_DDL) — keep in sync.
--
-- Keyed by CURRENCY, not by currency type, and that difference is the whole point: the
-- account-scoped `balance` table's (account_id, 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 src/balance-db.ts)
-- and live here instead, keyed by the `currency_id` that names one room's currency uniquely.
--
-- The room is not a column: `room_currency` already says which room a currency belongs to,
-- and repeating it here would be a second place for that to be wrong.
--
-- A missing row is a balance of zero. Nothing is inserted until a player is first awarded
-- some, so a room's currency costs a row per player who has actually earned it rather than
-- one per player who has walked in.
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 room's 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);
@@ -0,0 +1,19 @@
-- The purchase offers a room sells its own currency through — "5 SuperTokens for 500 Rec
-- Center Tokens" — served by `GET /api/roomcurrencies/v1/getPurchaseOffersBatch?ids=`. Owned
-- by the `econ` worker; 0017 created the table. Generated from src/room-currency-db.ts
-- (ROOM_CURRENCY_SCHEMA_DDL, which mirrors the table as it stands after 0017 + 0018 + this)
-- — keep in sync.
--
-- A JSON column rather than a table of its own. Offers are a small, ordered list that is only
-- ever read, written and replaced AS A WHOLE, for one currency at a time — the client shows
-- them as that currency's shop and reorders them together. A table would buy per-offer
-- queries nothing asks for, and cost a join on the one read that matters.
--
-- Each offer holds `CurrencyPurchaseOfferId`, `Order`, `Name`, `CurrencyAmount`, `Price` and
-- `ModifiedAt`. It does NOT hold `CurrencyId`: the row it lives on already says which currency
-- these are offers for, and the read projects it back in. Storing it would be a second place
-- for that to be wrong.
--
-- NULL, not '[]', for a currency with no offers — a currency nobody has built a shop for has
-- no list, and the read treats null and an unparseable value alike as "no offers".
ALTER TABLE room_currency ADD COLUMN purchase_offers TEXT;