mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add currency, outfits
This commit is contained in:
@@ -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)
|
||||
);
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user