add currency, outfits

This commit is contained in:
Devin Zuczek
2026-07-14 12:55:03 -04:00
parent 9d0d00992f
commit 505243844c
8 changed files with 455 additions and 30 deletions
+15
View File
@@ -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)
);
+11
View File
@@ -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)
);
+1
View File
@@ -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": {
+199
View File
@@ -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<void> {
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<Balance[]> {
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<number> {
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<number> {
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<boolean> {
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
}
+39 -24
View File
@@ -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<App>()
.use(
'*',
@@ -144,12 +134,33 @@ const app = new Hono<App>()
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<string, unknown> | 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<App>()
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
+59
View File
@@ -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<string, unknown> {
Slot: number
}
/** Every outfit a player has saved, ordered by slot. */
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
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<void> {
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()
}
+122 -2
View File
@@ -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 () => {
+9 -4
View File
@@ -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,