From 505243844cb6ba41ef180f70100cb754c3eec4ea Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 14 Jul 2026 12:55:03 -0400 Subject: [PATCH] add currency, outfits --- apps/econ/migrations/0001_balance.sql | 15 ++ apps/econ/migrations/0002_outfit.sql | 11 ++ apps/econ/package.json | 1 + apps/econ/src/balance-db.ts | 199 +++++++++++++++++++++ apps/econ/src/econ.app.ts | 63 ++++--- apps/econ/src/outfit-db.ts | 59 ++++++ apps/econ/src/test/integration/api.test.ts | 124 ++++++++++++- apps/econ/wrangler.jsonc | 13 +- 8 files changed, 455 insertions(+), 30 deletions(-) create mode 100644 apps/econ/migrations/0001_balance.sql create mode 100644 apps/econ/migrations/0002_outfit.sql create mode 100644 apps/econ/src/balance-db.ts create mode 100644 apps/econ/src/outfit-db.ts diff --git a/apps/econ/migrations/0001_balance.sql b/apps/econ/migrations/0001_balance.sql new file mode 100644 index 0000000..69331f8 --- /dev/null +++ b/apps/econ/migrations/0001_balance.sql @@ -0,0 +1,15 @@ +-- Currency balances, owned by the `econ` worker. One row per (account, currency): +-- the amount is a real INTEGER column, not a JSON field, so the spend path can be a +-- single atomic `UPDATE ... WHERE amount >= ?` instead of a racy read-modify-write. +-- +-- Only account-scoped currencies live here. RoomCurrency (300) / RoomInventoryItem +-- (301) are scoped to a room and belong to the room-currency endpoints — a row here +-- couldn't say which room it was for. Kept in sync with BALANCE_SCHEMA_DDL in +-- src/balance-db.ts. + +CREATE TABLE IF NOT EXISTS balance ( + account_id INTEGER NOT NULL, + currency_type INTEGER NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, currency_type) + ); diff --git a/apps/econ/migrations/0002_outfit.sql b/apps/econ/migrations/0002_outfit.sql new file mode 100644 index 0000000..8643bc9 --- /dev/null +++ b/apps/econ/migrations/0002_outfit.sql @@ -0,0 +1,11 @@ +-- Saved outfits, owned by the `econ` worker. One row per (account, slot): the client +-- posts an outfit with a `Slot` to /api/avatar/v3/saved/set, and re-saving that slot +-- overwrites it. The outfit is the client's own JSON payload, stored opaquely — we +-- never query inside it. Kept in sync with OUTFIT_SCHEMA_DDL in src/outfit-db.ts. + +CREATE TABLE IF NOT EXISTS outfit ( + account_id INTEGER NOT NULL, + set_id INTEGER NOT NULL, + avatar TEXT NOT NULL, + PRIMARY KEY (account_id, set_id) + ); diff --git a/apps/econ/package.json b/apps/econ/package.json index e6f3526..d9b4ba0 100644 --- a/apps/econ/package.json +++ b/apps/econ/package.json @@ -12,6 +12,7 @@ "deploy": "run-wrangler-deploy", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", + "migrate": "run-wrangler-migrate", "test": "run-vitest" }, "dependencies": { diff --git a/apps/econ/src/balance-db.ts b/apps/econ/src/balance-db.ts new file mode 100644 index 0000000..f8d8bfb --- /dev/null +++ b/apps/econ/src/balance-db.ts @@ -0,0 +1,199 @@ +/** + * Currency balances on the shared `recflare` D1 database. + * + * One row per (account, currency) pair rather than a JSON blob on the account: a + * balance is a number we increment, decrement and compare, and the spend path has to + * be atomic. `UPDATE ... WHERE amount >= ?` on a real column gives us that in one + * statement; a read-modify-write of a JSON blob would race and let a player spend the + * same tokens twice from two concurrent requests. + * + * This worker (`econ`) owns the table and its migration — see apps/econ/migrations/ + * 0001_balance.sql, applied with its own `migrations_table` (d1_migrations_econ) so + * it doesn't clash with the auth/rooms migration histories on the same database. + */ + +/** + * The currencies the client knows about (its `CurrencyType` enum). The client sends + * these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is + * RecCenterTokens — so the values are fixed by the client, not by us. + * + * What each one is: + * - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is + * a client bug or a probe, and `isSpendable` rejects it. + * - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store. + * - `RecCenterTokens` (2): THE general-purpose currency — what players mean by + * "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This + * is the only one the client fetches on load, and the only one we grant at signup. + * - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the + * Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those + * activities only. + * - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet. + * - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are + * scoped to a specific room and served by the `/api/roomcurrencies/*` and + * `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by + * account. They must never be stored in this (account, currency) table — a single + * row here couldn't say WHICH room's currency it is, so a player's coins in one + * room would spend in every other. `isSpendable` rejects them for that reason. + * - `ProgressionEvent` (400): an XP/progression counter the client models as a + * currency. Not spendable. + */ +export const CurrencyType = { + Invalid: 0, + LaserTagTickets: 1, + RecCenterTokens: 2, + LostSkullsGold: 100, + DraculaSilver: 101, + RecRoyaleSeason1: 200, + RoomCurrency: 300, + RoomInventoryItem: 301, + ProgressionEvent: 400, +} as const + +export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType] + +/** + * The account-scoped currencies this table stores. Everything else in `CurrencyType` + * is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to + * the room-currency endpoints (RoomCurrency, RoomInventoryItem) — see the enum doc. + */ +const SPENDABLE: readonly number[] = [ + CurrencyType.LaserTagTickets, + CurrencyType.RecCenterTokens, + CurrencyType.LostSkullsGold, + CurrencyType.DraculaSilver, + CurrencyType.RecRoyaleSeason1, +] + +/** Whether a currency is an account-scoped balance this table may hold. */ +export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType) + +/** + * What a player starts with, granted lazily the first time their balances are read + * (see `ensureStartingBalances`). Currencies absent here start at 0. + * + * This is the whole signup grant — change the number here and it applies to every + * player who hasn't been granted yet. It is NOT re-granted: a player who spends down + * to 0 keeps a 0 row, and the grant is skipped because the row exists. + */ +export const STARTING_BALANCES: ReadonlyArray<{ currencyType: number; amount: number }> = [ + { currencyType: CurrencyType.RecCenterTokens, amount: 10_000 }, +] + +/** + * `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track + * per-platform wallets (real RecNet did, for platform-purchased tokens). + */ +export const ALL_PLATFORMS = -2 + +/** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */ +export const BALANCE_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS balance ( + account_id INTEGER NOT NULL, + currency_type INTEGER NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (account_id, currency_type) + )`, +] + +export interface Balance { + currencyType: number + amount: number +} + +/** + * Grant the signup balances to an account that hasn't been granted yet. INSERT OR + * IGNORE against the (account_id, currency_type) primary key, so an account that + * already has a row for a currency keeps its amount — including a 0 it spent down to. + * That's what stops this from re-granting tokens on every read. + * + * Called on read rather than at account creation so accounts that predate this table + * (every existing player) get their grant too. + */ +export async function ensureStartingBalances(db: D1Database, accountId: number): Promise { + const stmt = db.prepare( + 'INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3)' + ) + await db.batch( + STARTING_BALANCES.map((b) => stmt.bind(accountId, b.currencyType, b.amount)) + ) +} + +/** Every balance an account holds (after its starting grant is applied). */ +export async function getBalances(db: D1Database, accountId: number): Promise { + await ensureStartingBalances(db, accountId) + const { results } = await db + .prepare( + 'SELECT currency_type, amount FROM balance WHERE account_id = ?1 ORDER BY currency_type' + ) + .bind(accountId) + .all<{ currency_type: number; amount: number }>() + return results.map((r) => ({ currencyType: r.currency_type, amount: r.amount })) +} + +/** An account's balance in one currency; 0 when they hold none. */ +export async function getBalance( + db: D1Database, + accountId: number, + currencyType: number +): Promise { + await ensureStartingBalances(db, accountId) + const row = await db + .prepare('SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = ?2') + .bind(accountId, currencyType) + .first<{ amount: number }>() + return row?.amount ?? 0 +} + +/** + * Add `amount` to a balance (a faucet: rewards, gifts, refunds), creating the row when + * the account has none. Returns the new balance. + * + * `amount` must be positive — spending goes through `spendCurrency`, which is the only + * path that checks funds. A negative amount here would silently overdraw. + */ +export async function creditCurrency( + db: D1Database, + accountId: number, + currencyType: number, + amount: number +): Promise { + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error(`creditCurrency: amount must be a positive integer, got ${amount}`) + } + await db + .prepare( + `INSERT INTO balance (account_id, currency_type, amount) VALUES (?1, ?2, ?3) + ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + ?3` + ) + .bind(accountId, currencyType, amount) + .run() + return getBalance(db, accountId, currencyType) +} + +/** + * Spend `amount` of a currency. Returns false — changing nothing — when the account + * can't afford it. + * + * The `amount >= ?3` guard lives in the UPDATE itself, so the check and the debit are + * one atomic statement: two concurrent spends of the same tokens can't both see a + * sufficient balance and both succeed. Never split this into a read-then-write. + */ +export async function spendCurrency( + db: D1Database, + accountId: number, + currencyType: number, + amount: number +): Promise { + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error(`spendCurrency: amount must be a positive integer, got ${amount}`) + } + await ensureStartingBalances(db, accountId) + const { meta } = await db + .prepare( + `UPDATE balance SET amount = amount - ?3 + WHERE account_id = ?1 AND currency_type = ?2 AND amount >= ?3` + ) + .bind(accountId, currencyType, amount) + .run() + return meta.changes > 0 +} diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 1427f2a..59dd92a 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -9,10 +9,13 @@ import defaultAvatar from '../static/default-avatar.json' import myProgress from '../static/my-progress.json' import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' +import { ALL_PLATFORMS, getBalance, isSpendable } from './balance-db' +import { getOutfits, setOutfit } from './outfit-db' import type { Context } from 'hono' import type { Avatar } from './avatar-db' import type { App } from './context' +import type { Outfit } from './outfit-db' /** * Economy Worker. Hosts the avatar/economy endpoints the game client calls on @@ -50,19 +53,6 @@ function toAvatarV2Dto(avatar: Avatar) { } } -/** RecNet currency types (the `CurrencyType` enum the client uses). */ -const CurrencyType = { - Invalid: 0, - LaserTagTickets: 1, - RecCenterTokens: 2, - LostSkullsGold: 100, - DraculaSilver: 101, - RecRoyaleSeason1: 200, - RoomCurrency: 300, - RoomInventoryItem: 301, - ProgressionEvent: 400, -} as const - const app = new Hono() .use( '*', @@ -144,12 +134,33 @@ const app = new Hono() return c.json([]) }) - // The player's saved outfits. [Authorize]; empty without a DB binding. + // The player's saved outfits. [Authorize]. Served back as the client posted them + // (see /saved/set); a player who has saved none gets []. .get('/api/avatar/v3/saved', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: query SavedOutfits once a DB binding exists. - return c.json([]) + return c.json(await getOutfits(c.env.DB, id)) + }) + + // Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is + // the slot to write, and re-saving a slot overwrites it — that's the avatar screen's + // "save over this outfit". The payload is stored verbatim and echoed back: its inner + // fields (OutfitSelectionsV2, FaceFeatures, …) are JSON-in-a-string from the client's + // own serializer, so re-encoding them risks handing back something it can't parse. + // + // A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would + // silently overwrite an outfit the player didn't mean to touch. + .post('/api/avatar/v3/saved/set', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return c.body(null, 400) + } + if (!Number.isInteger(body.Slot)) return c.body(null, 400) + const outfit = body as Outfit + await setOutfit(c.env.DB, id, outfit) + return c.json(outfit) }) // Pending avatar gifts for the player. [Authorize]; empty without a DB binding. @@ -197,16 +208,20 @@ const app = new Hono() return c.json([]) }) - // Token balance. [Authorize]. The `2` in the path is the RecCenterTokens - // CurrencyType. The balance is a fake test value (a large amount) until a DB - // binding tracks real balances; Platform -2 means "all platforms". - .get('/api/storefronts/v4/balance/2', async (c) => { + // Currency balance. [Authorize]. The trailing int is a CurrencyType — the client + // fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a + // player who has never been granted gets their starting balance on this first read. + // + // An unknown or non-account-scoped currency (a room currency, ProgressionEvent, + // Invalid) returns a 0 balance rather than 404: the client treats a failed balance + // fetch as a load error, and "you have none of that" is the honest answer anyway. + .get('/api/storefronts/v4/balance/:currencyType', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: query TokenBalances once a DB binding exists. - return c.json([ - { CurrencyType: CurrencyType.RecCenterTokens, Platform: -2, Balance: 2147483648 }, - ]) + const currencyType = Number.parseInt(c.req.param('currencyType'), 10) + if (Number.isNaN(currencyType)) return c.body(null, 400) + const amount = isSpendable(currencyType) ? await getBalance(c.env.DB, id, currencyType) : 0 + return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }]) }) // Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested diff --git a/apps/econ/src/outfit-db.ts b/apps/econ/src/outfit-db.ts new file mode 100644 index 0000000..66f95c9 --- /dev/null +++ b/apps/econ/src/outfit-db.ts @@ -0,0 +1,59 @@ +/** + * Saved outfits on the shared `recflare` D1 database — the outfit slots a player + * saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from + * `GET /api/avatar/v3/saved`. + * + * One row per (account, slot). The outfit itself is stored as the opaque JSON payload + * the client posted: we never query inside it, and its fields (OutfitSelectionsV2, + * FaceFeatures, …) are themselves JSON-in-a-string produced by the client's own + * serializer. Round-tripping it verbatim is both the simplest and the safest thing — + * re-encoding risks changing a payload the client has to parse back. + * + * The `econ` worker owns this table and its migration (apps/econ/migrations/ + * 0002_outfit.sql). + */ + +/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */ +export const OUTFIT_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS outfit ( + account_id INTEGER NOT NULL, + set_id INTEGER NOT NULL, + avatar TEXT NOT NULL, + PRIMARY KEY (account_id, set_id) + )`, +] + +/** + * A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the + * `set_id` column) — saving to a slot the player already used overwrites it, which is + * exactly what the avatar screen's "save over this outfit" does. The rest of the + * payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor, + * CustomAvatarItems, …) is stored and served back untouched. + */ +export interface Outfit extends Record { + Slot: number +} + +/** Every outfit a player has saved, ordered by slot. */ +export async function getOutfits(db: D1Database, accountId: number): Promise { + const { results } = await db + .prepare('SELECT avatar FROM outfit WHERE account_id = ?1 ORDER BY set_id') + .bind(accountId) + .all<{ avatar: string }>() + return results.map((r) => JSON.parse(r.avatar) as Outfit) +} + +/** + * Save an outfit into one of the player's slots, replacing whatever was there. The + * upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than + * accumulating duplicate rows for it. + */ +export async function setOutfit(db: D1Database, accountId: number, outfit: Outfit): Promise { + await db + .prepare( + `INSERT INTO outfit (account_id, set_id, avatar) VALUES (?1, ?2, ?3) + ON CONFLICT (account_id, set_id) DO UPDATE SET avatar = ?3` + ) + .bind(accountId, outfit.Slot, JSON.stringify(outfit)) + .run() +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a8e24cf..cdf22bc 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -5,6 +5,13 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../econ.app' import { SCHEMA_DDL } from '../../avatar-db' +import { + BALANCE_SCHEMA_DDL, + CurrencyType, + getBalance, + spendCurrency, +} from '../../balance-db' +import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import type { Env } from '../../context' @@ -20,11 +27,32 @@ beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .run() }) +/** + * A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim + * (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the + * round-trip is tested against the actual payload shape, not a tidied-up version. + */ +const SAVED_OUTFIT = { + Slot: 4, + PreviewImageName: 'outfit/2026-07-14/38e84678-1ccf-4cfd-bf3f-5b21eec88b0f.jpg', + OutfitSelections: + '5cd08cfb-c729-4c30-96d9-6a99bb934d91,,1;77d3c585-4928-4471-a425-89036efe7299,,0;40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,,1', + OutfitSelectionsV2: + '{"selections":[{"PrefabGuid":"5cd08cfb-c729-4c30-96d9-6a99bb934d91","CombinationGuid":"","BodyPart":1,"UgcOutfitData":{"BaseAvatarItemColor":{"r":0.0,"g":0.0,"b":0.0,"a":0.0},"CustomAvatarItemId":""}}]}', + FaceFeatures: + '{"ver":6,"eyeId":"pY0dY6IxOEaNv8uNL8qUgQ","eyeScl":-0.007145103067159653,"useHelmetHair":1,"hideEars":false}', + SkinColor: 'Xac-W_R330KfOz-pQla9qg', + HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg', + CustomAvatarItems: [], +} + // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store. const TEST_SECRET = 'test-signing-key' @@ -247,13 +275,69 @@ describe('econ endpoints', () => { test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`) expect(anon.status).toBe(401) + // Account 21 has saved nothing. const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, { - headers: await bearer(), + headers: await bearer('21'), }) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) + test('POST /api/avatar/v3/saved/set saves an outfit, read back by /saved', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, { + method: 'POST', + body: JSON.stringify(SAVED_OUTFIT), + }) + expect(anon.status).toBe(401) + + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, { + method: 'POST', + headers: { ...(await bearer('22')), 'Content-Type': 'application/json' }, + body: JSON.stringify(SAVED_OUTFIT), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(SAVED_OUTFIT) + + // Round-trips verbatim — including the JSON-in-a-string fields the client parses + // back itself (OutfitSelectionsV2, FaceFeatures). + const saved = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, { + headers: await bearer('22'), + }) + expect(await saved.json()).toEqual([SAVED_OUTFIT]) + }) + + test('POST /api/avatar/v3/saved/set overwrites the same slot, and keeps others', async () => { + const headers = await bearer('23') + const post = (outfit: unknown) => + exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(outfit), + }) + + await post({ ...SAVED_OUTFIT, Slot: 4, SkinColor: 'first' }) + await post({ ...SAVED_OUTFIT, Slot: 7, SkinColor: 'other-slot' }) + // Re-saving slot 4 replaces it rather than adding a second row for it. + await post({ ...SAVED_OUTFIT, Slot: 4, SkinColor: 'second' }) + + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`, { headers }) + const outfits = (await res.json()) as Array<{ Slot: number; SkinColor: string }> + expect(outfits.map((o) => [o.Slot, o.SkinColor])).toEqual([ + [4, 'second'], + [7, 'other-slot'], + ]) + }) + + test('POST /api/avatar/v3/saved/set 400s without an integer Slot', async () => { + const { Slot: _Slot, ...noSlot } = SAVED_OUTFIT + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved/set`, { + method: 'POST', + headers: { ...(await bearer('24')), 'Content-Type': 'application/json' }, + body: JSON.stringify(noSlot), + }) + expect(res.status).toBe(400) + }) + test('GET /api/avatar/v2/gifts 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`) expect(anon.status).toBe(401) @@ -333,7 +417,43 @@ describe('econ endpoints', () => { headers: await bearer(), }) expect(res.status).toBe(200) - expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 2147483648 }]) + // The starting grant, applied on this first read. + expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }]) + }) + + test('GET /api/storefronts/v4/balance/2 reflects what the player has spent', async () => { + // Spend from account 7 (a fresh account: the read below grants it first). + expect(await spendCurrency(env.DB, 7, CurrencyType.RecCenterTokens, 2500)).toBe(true) + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('7'), + }) + expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 7500 }]) + }) + + test('a spend the player cannot afford changes nothing', async () => { + const before = await getBalance(env.DB, 8, CurrencyType.RecCenterTokens) + expect(await spendCurrency(env.DB, 8, CurrencyType.RecCenterTokens, before + 1)).toBe(false) + expect(await getBalance(env.DB, 8, CurrencyType.RecCenterTokens)).toBe(before) + }) + + test('the starting grant is not re-granted after spending down to zero', async () => { + // The grant is INSERT OR IGNORE against the row, not a top-up: a player who spends + // everything stays at 0 rather than being refilled by their next balance read. + expect(await spendCurrency(env.DB, 9, CurrencyType.RecCenterTokens, 10_000)).toBe(true) + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('9'), + }) + expect(await res.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 0 }]) + }) + + test('GET /api/storefronts/v4/balance for a room-scoped currency returns 0, not a balance', async () => { + // RoomCurrency (300) is scoped to a room and served elsewhere; this table must not + // hand out an account-wide balance for it. + const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/300`, { + headers: await bearer(), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([{ CurrencyType: 300, Platform: -2, Balance: 0 }]) }) test('GET /api/storefronts/v3/giftdropstore/3 returns the storefront catalog', async () => { diff --git a/apps/econ/wrangler.jsonc b/apps/econ/wrangler.jsonc index cfa2f77..c4da283 100644 --- a/apps/econ/wrangler.jsonc +++ b/apps/econ/wrangler.jsonc @@ -11,14 +11,19 @@ "binding": "ASSETS", "directory": "./static/storefronts" }, - // Shared `recflare` D1 (accounts table) — read/write the player's avatar column. - // The accounts schema/migrations are owned by the `auth` worker. The "local" - // placeholder is replaced with the real id from RECFLARE_D1 at deploy time. + // Shared `recflare` D1. Econ reads/writes the player's avatar column on the accounts + // table (whose schema/migrations the `auth` worker owns) and owns the `balance` table + // itself — hence its own migrations_dir, with a dedicated migrations_table so its + // history doesn't clash with the auth/rooms migrations on the same database. Apply + // with `wrangler d1 migrations apply recflare --remote`. The "local" placeholder is + // replaced with the real id from RECFLARE_D1 at deploy time. "d1_databases": [ { "binding": "DB", "database_name": "recflare", - "database_id": "local" + "database_id": "local", + "migrations_dir": "migrations", + "migrations_table": "d1_migrations_econ" } ], "logpush": false,