[econ] implement catalog

This commit is contained in:
Devin Zuczek
2026-08-27 22:43:22 -04:00
parent 5e138a0646
commit a169cf3e6e
17 changed files with 214338 additions and 18 deletions
+354
View File
@@ -0,0 +1,354 @@
import { CatalogKind } from './catalog-load'
import type { CatalogKindValue } from './catalog-load'
/**
* The item CATALOG on the shared `recflare` D1 database — every avatar item and every
* equipment skin the game knows about, one row each, loaded once by migration rather than
* written at runtime. Nothing here is per-player: ownership lives in `inventory` and
* `equipment`, and this table only says what a thing IS.
*
* It exists to be QUERIED. The catalogs were previously reachable only by parsing a whole
* storefront JSON per request (`sf{N}.json`, 1161 items in sf3 alone) and by scanning the
* bundled `default-avatar-items.json`, which means every lookup pays for the whole file and
* nothing can be searched by name or filtered by tag at all. A row with indexes answers those
* in one statement.
*
* BOTH kinds share one table because they share most of a record — a display name, a tooltip, a
* rarity, a platform mask, a thumbnail — and because the interesting queries ("what is called
* X", "what is this thing the player owns") run across both. `kind` discriminates, and the
* columns each kind alone carries are nullable and empty on the other. Read a row through
* {@link toCatalogAvatarItem}/{@link toCatalogSkin} rather than serving it raw: the client's
* two DTOs share no key order and differ in what they omit.
*
* Where the rows come from: `static/db/avatar-items.json` and `static/db/skins.json`, both
* captured from the reference and loaded by `runx catalog load`.
*
* The migration builds the TABLE ONLY — it holds no rows. The catalog changes as the game's
* item list changes, and that is not a schema change: a migration per refresh would mean a
* deploy per refresh, an ever-growing pile of near-identical data migrations, and no way to
* reload without inventing a new one. So the structure is versioned and the contents are
* (re)loaded on demand, which also makes a refresh a `git diff` of the JSON rather than of
* 700KB of generated SQL.
*
* This worker (`econ`) owns the table and its migration.
*/
// The discriminator and the JSON→row mapping live in `catalog-load.ts`, which deliberately
// touches no Workers types: `runx catalog load` (a plain Node CLI in @repo/tools) imports it,
// and importing this module instead would drag `D1Database` into a package that has no such
// types. Re-exported here so callers still get the whole table from one import.
export {
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CatalogKind,
type AvatarItemCapture,
type CatalogCollision,
type CatalogKindValue,
type CatalogLoadRow,
type CatalogValue,
type SkinCapture,
} from './catalog-load'
/**
* Schema DDL (mirror of migrations/0015_catalog.sql) — also builds the table in tests.
*
* ONE KEY spans both kinds. `item_key` is an avatar item's `AvatarItemDesc` and a skin's
* `ModificationGuid`: neither repeats, the two never collide, and no item has both. That is
* also how the INVENTORY identifies what a player owns, so resolving an owned thing is a lookup
* on this column — not a join that first has to establish which kind of thing it is.
*
* Not every key is a GUID. 191 skins and 109 avatar items carry the short alpha-string ids the
* game used before it moved to GUIDs (`_OWVy3z6iU-M3-zbQgSLig`), so the column is TEXT and
* compared as text. Do not add a uuid-shaped constraint and do not try to parse one.
*
* `avatar_item_id` is carried as DATA ONLY and deliberately not indexed: it is missing from 22
* avatar items and repeated on 9 more (id 9503 alone covers five unrelated developer items), so
* it can neither key nor reliably find anything.
*
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy, and is
* what a generated storefront lists a row under as its `PurchasableItemId`. It is assigned by
* the loader from `CATALOG_ID_BASE` (10000, clear of every captured storefront's own ids) and
* renumbered by every load, so it identifies a row only within one load — see the field's own
* note. It is unique where set, and the migration that adds it is 0016, since 0015 was already
* applied.
*
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
* column may not be defaulted to the empty string.
*/
export const CATALOG_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS catalog (
item_key TEXT PRIMARY KEY,
catalog_id INTEGER,
kind TEXT NOT NULL,
friendly_name TEXT NOT NULL,
tooltip TEXT,
rarity INTEGER NOT NULL DEFAULT 0,
platform_mask INTEGER NOT NULL DEFAULT -1,
thumbnail_image TEXT,
avatar_item_type INTEGER,
avatar_item_id INTEGER,
is_base_avatar_item INTEGER,
tag_list TEXT,
created_at TEXT,
prefab_name TEXT,
unlocked_level INTEGER
)`,
// The search index. Folded, because a name search is case-insensitive and SQLite's LIKE is
// only case-insensitive for ASCII — which these names are not all of.
`CREATE INDEX IF NOT EXISTS idx_catalog_name ON catalog (kind, lower(friendly_name))`,
// Every skin of one prefab, which is how a skin picker is filled.
`CREATE INDEX IF NOT EXISTS idx_catalog_prefab ON catalog (prefab_name) WHERE prefab_name IS NOT NULL`,
// Seasonal rows ('halloween', 'music', …) — a handful of tags over 3000-odd rows, so the
// index is worth far more than its size.
`CREATE INDEX IF NOT EXISTS idx_catalog_tag ON catalog (tag_list) WHERE tag_list IS NOT NULL`,
// The numeric handle. Unique where set — a number that names two rows is useless as a handle
// — and partial, because a row is un-numbered between existing and being numbered by a load.
`CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_id ON catalog (catalog_id) WHERE catalog_id IS NOT NULL`,
]
/** A catalog row exactly as stored — snake_case, both kinds' columns, most of them null. */
export interface CatalogRow {
item_key: string
/**
* A small numeric handle for the row — for the surfaces that need to name an item as a
* number rather than as a comma-laden `AvatarItemDesc` or a guid, and the
* `PurchasableItemId` a generated storefront lists it under.
*
* A LOAD-ORDER SURROGATE, not an identity: `runx catalog load` assigns it, so it is stable
* only until the next load. Never store it, never reference it across a load, never treat it
* as what an item IS — `item_key` is that, and it is what the inventory holds. NULL only
* between a row existing and a load numbering it.
*/
catalog_id: number | null
kind: string
friendly_name: string
tooltip: string | null
rarity: number
platform_mask: number
thumbnail_image: string | null
avatar_item_type: number | null
avatar_item_id: number | null
is_base_avatar_item: number | null
tag_list: string | null
created_at: string | null
prefab_name: string | null
unlocked_level: number | null
}
/**
* The catalog's view of an avatar item — the bundled `default-avatar-items.json` record plus
* the four fields that file leaves out (`AvatarItemId`, `IsBaseAvatarItem`, `CreatedAt`,
* `ThumbnailImage`), which is what `GET /api/avatar/v1/defaultunlocked` serves and what the
* store rows resolve against.
*
* `AvatarItemId` is nullable and `Tooltip` may be null; both are true of the captured data and
* the client reads them that way. Do not tighten either to keep a projection simple.
*/
export interface CatalogAvatarItem {
AvatarItemDesc: string
AvatarItemType: number
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
TagList: string | null
AvatarItemId: number | null
IsBaseAvatarItem: boolean
CreatedAt: string | null
ThumbnailImage: string | null
}
/**
* The catalog's view of a skin, in the client's `Equipment` shape plus the two fields the
* capture carries that an owned-equipment row does not (`UnlockedLevel`, `ThumbnailImage`).
*
* `Favorited` is a PLAYER's flag, not a property of the skin, so the catalog does not store it
* — every captured row said `false` because the capture belonged to one account. It is
* projected as `false` here and overwritten from the owning player's `equipment` row; storing
* it would make one player's favourites everyone's.
*/
export interface CatalogSkin {
PrefabName: string
ModificationGuid: string
UnlockedLevel: number
Favorited: boolean
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
ThumbnailImage: string | null
}
/** Projects a row to the avatar-item record. Throws on a row of the wrong kind. */
export function toCatalogAvatarItem(row: CatalogRow): CatalogAvatarItem {
if (row.kind !== CatalogKind.AvatarItem) {
throw new Error(`catalog row ${row.item_key} is a ${row.kind}, not an avatar item`)
}
return {
// The key IS the desc — that is what makes it the key.
AvatarItemDesc: row.item_key,
AvatarItemType: row.avatar_item_type ?? 0,
PlatformMask: row.platform_mask,
FriendlyName: row.friendly_name,
Tooltip: row.tooltip,
Rarity: row.rarity,
TagList: row.tag_list,
AvatarItemId: row.avatar_item_id,
IsBaseAvatarItem: row.is_base_avatar_item === 1,
CreatedAt: row.created_at,
ThumbnailImage: row.thumbnail_image,
}
}
/** Projects a row to the skin record. Throws on a row of the wrong kind. */
export function toCatalogSkin(row: CatalogRow): CatalogSkin {
if (row.kind !== CatalogKind.Skin) {
throw new Error(`catalog row ${row.item_key} is a ${row.kind}, not a skin`)
}
return {
PrefabName: row.prefab_name ?? '',
// The key IS the guid.
ModificationGuid: row.item_key,
UnlockedLevel: row.unlocked_level ?? 0,
Favorited: false,
PlatformMask: row.platform_mask,
FriendlyName: row.friendly_name,
Tooltip: row.tooltip,
Rarity: row.rarity,
ThumbnailImage: row.thumbnail_image,
}
}
/**
* The base asset an avatar item is built on — the FIRST field of its `AvatarItemDesc`, which is
* `<baseAsset>,<color>,<texture>,`. The client can only draw an item whose base asset it
* already ships with, so this is the field that decides whether a store row renders anything
* (see the Generic-row notes in the `lists` worker).
*/
export function baseAsset(desc: string): string {
return desc.split(',')[0] ?? ''
}
/**
* One catalog row by its key, WHICHEVER kind it is. This is the lookup the inventory wants: a
* player's owned things are ids of exactly this shape, and the row that comes back says which
* kind it turned out to be.
*/
export async function getCatalogItem(db: D1Database, itemKey: string): Promise<CatalogRow | null> {
return await db
.prepare('SELECT * FROM catalog WHERE item_key = ?1')
.bind(itemKey)
.first<CatalogRow>()
}
/**
* One catalog row by its numeric handle.
*
* Only meaningful WITHIN a load: the number is reassigned every time the catalog is loaded, so
* a caller holding one from before a reload will get a different item or nothing at all. Fine
* for a request that looked the number up moments ago; never for anything stored.
*/
export async function getCatalogItemById(
db: D1Database,
catalogId: number
): Promise<CatalogRow | null> {
return await db
.prepare('SELECT * FROM catalog WHERE catalog_id = ?1')
.bind(catalogId)
.first<CatalogRow>()
}
/**
* Resolve many keys at once, in the order asked; unknown keys are skipped rather than left as
* holes, so the result may be SHORTER than the input and must not be read positionally. One
* statement for a whole inventory, which is the point of the table.
*/
export async function getCatalogItems(db: D1Database, itemKeys: string[]): Promise<CatalogRow[]> {
if (itemKeys.length === 0) return []
const placeholders = itemKeys.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM catalog WHERE item_key IN (${placeholders})`)
.bind(...itemKeys)
.all<CatalogRow>()
const byKey = new Map(results.map((r) => [r.item_key, r]))
return itemKeys.flatMap((key) => byKey.get(key) ?? [])
}
/** One avatar item by its `AvatarItemDesc`. Null for an unknown key OR for a skin's key. */
export async function getAvatarItem(
db: D1Database,
avatarItemDesc: string
): Promise<CatalogAvatarItem | null> {
const row = await getCatalogItem(db, avatarItemDesc)
return row?.kind === CatalogKind.AvatarItem ? toCatalogAvatarItem(row) : null
}
/** One skin by its `ModificationGuid`. Null for an unknown key OR for an avatar item's key. */
export async function getSkin(
db: D1Database,
modificationGuid: string
): Promise<CatalogSkin | null> {
const row = await getCatalogItem(db, modificationGuid)
return row?.kind === CatalogKind.Skin ? toCatalogSkin(row) : null
}
/** Every skin of one prefab (`[MakerPen]`, `[QuestSword]`, …), by name. */
export async function getSkinsForPrefab(
db: D1Database,
prefabName: string
): Promise<CatalogSkin[]> {
const { results } = await db
.prepare('SELECT * FROM catalog WHERE prefab_name = ?1 ORDER BY friendly_name')
.bind(prefabName)
.all<CatalogRow>()
return results.map(toCatalogSkin)
}
/**
* Name search within one kind — a case-insensitive substring match, ordered by name so paging
* is stable.
*
* Both sides are lowered so the comparison hits `idx_catalog_name`, whose second column is
* `lower(friendly_name)`: SQLite's `LIKE` folds case for ASCII only, and these names are not
* all ASCII. `%` and `_` in the needle are escaped, so a player searching for a literal
* underscore gets that rather than a wildcard.
*/
export async function searchCatalog(
db: D1Database,
kind: CatalogKindValue,
needle: string,
limit = 50
): Promise<CatalogRow[]> {
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
const { results } = await db
.prepare(
`SELECT * FROM catalog
WHERE kind = ?1 AND lower(friendly_name) LIKE ?2 ESCAPE '\\'
ORDER BY friendly_name, item_key LIMIT ?3`
)
.bind(kind, `%${escaped}%`, limit)
.all<CatalogRow>()
return results
}
/** Every avatar item carrying a seasonal tag (`halloween`, `music`, …), by name. */
export async function getAvatarItemsByTag(
db: D1Database,
tag: string
): Promise<CatalogAvatarItem[]> {
const { results } = await db
.prepare('SELECT * FROM catalog WHERE tag_list = ?1 ORDER BY friendly_name')
.bind(tag)
.all<CatalogRow>()
return results.map(toCatalogAvatarItem)
}
/** How many rows of each kind the catalog holds — the cheap check that a load actually landed. */
export async function countCatalog(db: D1Database): Promise<Record<string, number>> {
const { results } = await db
.prepare('SELECT kind, COUNT(*) AS n FROM catalog GROUP BY kind')
.all<{ kind: string; n: number }>()
return Object.fromEntries(results.map((r) => [r.kind, r.n]))
}
+261
View File
@@ -0,0 +1,261 @@
/**
* Turning the captured JSON into `catalog` rows — the loader half of the item catalog, used by
* `runx catalog load` (in @repo/tools) rather than by the worker.
*
* Separate from `catalog-db.ts` for one reason: that module types its queries with
* `D1Database`, a Workers type, and the loader runs in a plain Node CLI that has no such
* types. Everything here is pure data mapping with no imports, so both sides can use it.
* `catalog-db.ts` re-exports all of it, so nothing outside these two files needs to know.
*
* The mapping lives beside the schema it fills (rather than in the CLI) so that a column added
* to the table and a column added to the loader cannot drift apart — a test pins that they
* agree, and the loader renders values POSITIONALLY, so a mismatch is a silent mis-load.
*/
/** What a catalog row IS — the discriminator, and what says which id `item_key` holds. */
export const CatalogKind = {
/** An avatar item: something worn. Its `item_key` is the `AvatarItemDesc`. */
AvatarItem: 'avatar_item',
/** An equipment skin: a re-skin of a held prefab. Its `item_key` is the `ModificationGuid`. */
Skin: 'skin',
} as const
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
/**
* The capture's avatar-item record (`static/db/avatar-items.json`).
*
* Everything from `TagList` down is absent on some rows — the 22 permanent hair dyes carry
* only the first six fields — so those are optional rather than nullable. The distinction
* matters: a missing key and a null value both land as NULL, but only one of them is a field
* the capture actually recorded.
*/
export interface AvatarItemCapture {
AvatarItemDesc: string
AvatarItemType: number
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
TagList?: string | null
AvatarItemId?: number
IsBaseAvatarItem?: boolean
CreatedAt?: string
ThumbnailImage?: string | null
}
/** The capture's skin record (`static/db/skins.json`). Every field is present on every row. */
export interface SkinCapture {
PrefabName: string
ModificationGuid: string
UnlockedLevel: number
Favorited: boolean
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
ThumbnailImage: string | null
}
/**
* The first `catalog_id` a load hands out.
*
* The catalog needs ids that cannot be confused with any captured storefront's, because a
* generated storefront lists a row under its `catalog_id` DIRECTLY — one number, no second
* numbering and no arithmetic between them. Every real captured `PurchasableItemId` is 2764 or
* below (one sf3 outlier at 20756767 aside), so numbering from 1 would have collided with sf3's
* own head-on and the same id would mean two different items depending on which storefront the
* client read it from. Starting at 10000 puts the whole catalog somewhere nothing else uses.
*/
export const CATALOG_ID_BASE = 10_000
/** The columns a load writes, in the order {@link toCatalogInsertRow} returns values. */
export const CATALOG_INSERT_COLUMNS = [
'item_key',
'catalog_id',
'kind',
'friendly_name',
'tooltip',
'rarity',
'platform_mask',
'thumbnail_image',
'avatar_item_type',
'avatar_item_id',
'is_base_avatar_item',
'tag_list',
'created_at',
'prefab_name',
'unlocked_level',
] as const
/** A value bound into a load's INSERT. `undefined` is a key the capture omitted. */
export type CatalogValue = string | number | boolean | null | undefined
/** One row of a load, plus enough to name it in a collision report. */
export interface CatalogLoadRow {
key: string
/** Its `catalog_id`: 1-based position in this load, also present inside `values`. */
id: number
label: string
values: CatalogValue[]
}
/** A duplicate `item_key` in the captures: which key, which row won, which was dropped. */
export interface CatalogCollision {
key: string
kept: string
dropped: string
}
function avatarItemRow(i: AvatarItemCapture): Omit<CatalogLoadRow, 'id'> {
return {
key: i.AvatarItemDesc,
label: `${i.FriendlyName} (avatar item)`,
values: [
i.AvatarItemDesc,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
CatalogKind.AvatarItem,
i.FriendlyName,
i.Tooltip,
i.Rarity,
i.PlatformMask,
i.ThumbnailImage,
i.AvatarItemType,
i.AvatarItemId,
i.IsBaseAvatarItem ?? false,
i.TagList,
i.CreatedAt,
null,
null,
],
}
}
function skinRow(s: SkinCapture): Omit<CatalogLoadRow, 'id'> {
return {
key: s.ModificationGuid,
label: `${s.FriendlyName} (skin, ${s.PrefabName})`,
values: [
s.ModificationGuid,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
CatalogKind.Skin,
s.FriendlyName,
s.Tooltip,
s.Rarity,
s.PlatformMask,
s.ThumbnailImage,
null,
null,
null,
null,
null,
s.PrefabName,
s.UnlockedLevel,
],
}
}
/**
* Turn both captures into the rows a load writes, de-duplicated on `item_key`.
*
* `item_key` is unique across BOTH kinds, so a repeat is a defect in the capture rather than
* something the table should model. First occurrence wins and the rest are RETURNED rather
* than dropped on the floor: the caller has to report them, because a collision that vanishes
* quietly is the exact failure the single key exists to prevent.
*
* Each surviving row is also numbered — `catalog_id`, from {@link CATALOG_ID_BASE} upward in
* capture order, the small numeric handle the site uses in place of a comma-laden desc or a
* guid, and the `PurchasableItemId` a generated storefront lists it under. It is assigned here
* rather than by the database so the caller can render it into the same INSERT, and it is a
* LOAD-ORDER surrogate: the next load renumbers, and nothing may store it.
*/
export function buildCatalogLoad(
avatarItems: AvatarItemCapture[],
skins: SkinCapture[]
): { rows: CatalogLoadRow[]; collisions: CatalogCollision[] } {
const seen = new Map<string, string>()
const rows: CatalogLoadRow[] = []
const collisions: CatalogCollision[] = []
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
for (const row of [...avatarItems.map(avatarItemRow), ...skins.map(skinRow)]) {
const kept = seen.get(row.key)
if (kept !== undefined) {
collisions.push({ key: row.key, kept, dropped: row.label })
continue
}
seen.set(row.key, row.label)
// Numbered AFTER de-duplication and from {@link CATALOG_ID_BASE}, so a load's ids are
// exactly BASE..BASE+rows.length-1 with no gaps — a dropped duplicate must not burn a
// number.
const id = CATALOG_ID_BASE + rows.length
row.values[idAt] = id
rows.push({ ...row, id })
}
return { rows, collisions }
}
/**
* Rarities that are NOT sold, and so never appear in a generated storefront.
*
* `-1` is the developer/unreleased tier. The items carrying it stay in the `catalog` table —
* that is a record of what EXISTS — but a storefront is a record of what is for SALE, and an
* item listed in one can be bought: `findStoreItem` resolves a purchase against the catalog
* file itself, so listing them at any price would put them on sale.
*
* Lives here rather than in the storefront generator because two things need the same answer:
* the generator, which omits them, and anything that hands the client a PurchasableItem id,
* which must not name one the storefront never listed. A client asked to resolve an id no
* storefront sells renders nothing, indistinguishably from an id it failed to parse.
*/
export const UNSELLABLE_RARITIES: readonly number[] = [-1]
/** Whether an item of this rarity may appear in a storefront. */
export const isSellableRarity = (rarity: number): boolean => !UNSELLABLE_RARITIES.includes(rarity)
/**
* What a catalog item costs, by rarity — the pricing every surface that sells a catalog row
* must agree on.
*
* Shared rather than living in the storefront generator alone because a purchase is CHECKED
* against the price the client was shown: `priceCheck` compares the posted `RequestedPrice`
* with the catalog's, and a server that priced a buy differently from the file it listed would
* refuse every purchase as "Price has changed". One table, both sides.
*
* The tiers 0/10/30/50 were specified; 20 sits between its neighbours at 700 (sf3's own
* rarity-20 items cluster at 400-600, but 600 is taken by rarity 10 here, and two tiers sharing
* a price makes the rarity invisible). {@link UNSELLABLE_RARITIES} is priced by nothing — those
* items are not sold at all.
*/
export const PRICE_BY_RARITY: Record<number, number> = {
0: 150,
10: 600,
20: 700,
30: 800,
50: 3000,
}
/**
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free.
*
* Only reachable if a future capture introduces a rarity nobody has priced. A floor rather than
* a skip because an unpriced item silently vanishing from the store is harder to notice than
* one that turns up cheap; a rarity meant to be unsellable belongs in
* {@link UNSELLABLE_RARITIES}, where a load reports it.
*/
export const DEFAULT_PRICE = 150
/** What one catalog row costs, in RecCenterTokens. */
export const priceForRarity = (rarity: number): number => PRICE_BY_RARITY[rarity] ?? DEFAULT_PRICE
/**
* What Rec Room Plus takes off, in percent. The same number the server's own `subscriberFloor`
* allows, which is what makes a subscriber's discounted `RequestedPrice` land inside the band
* rather than through the floor.
*/
export const SUBSCRIBER_DISCOUNT_PERCENT = 10
/** The subscriber price for a regular one. Floored, matching the server's `subscriberFloor`. */
export const subscriberPriceFor = (regular: number): number =>
Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
+379 -15
View File
@@ -17,13 +17,8 @@ import {
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
// lookup is the store's view of those rows.
import {
getCustomAvatarItems,
toUgcPurchasable,
@@ -40,6 +35,7 @@ import { censorSwears } from '../../api/src/sanitize'
import { BalanceAddType } from '../../notify/src/notification-payloads'
import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import avatarItemCatalog from '../static/db/avatar-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
@@ -55,6 +51,13 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import {
CATALOG_ID_BASE,
CatalogKind,
isSellableRarity,
priceForRarity,
subscriberPriceFor,
} from './catalog-load'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import {
@@ -90,10 +93,13 @@ import {
GameRewardRequest,
InfluencerIdsResponse,
InfluencerTierResponse,
ItemPurchaseInfoList,
ItemPurchaseInfosRequest,
json,
JsonArray,
jsonBody,
JsonObject,
LockedItemsBulkRequest,
MakerAiFreeTrialEligibilityResponse,
OpaqueJsonBody,
OPTIONAL_AUTHED,
@@ -113,11 +119,13 @@ import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
} from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db'
import type { CatalogRow } from './catalog-db'
import type {
ChallengeGiftBlock,
EquipmentGift,
@@ -128,6 +136,12 @@ import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
// lookup is the store's view of those rows.
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
* the `econ` service (these are separate from the main `api` worker). Balances,
@@ -157,6 +171,31 @@ async function authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
* version to order by. `null` when there is no valid token, when it carries no `rn.ver` (an
* older token, issued before the claim did), or when the claim isn't a build at all.
*
* Unverified — a client can claim any build — which is fine for what it gates here: a build
* lying about itself only changes which storefront its own player is shown.
*/
async function authedBuild(c: Context<App>): Promise<number | null> {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
if (version === null) return null
const build = Number.parseInt(version.split('.')[0] ?? '', 10)
return Number.isInteger(build) ? build : null
}
/**
* The last client build treated as the 2023-era one. `GAME_VERSION` — what the rest of the
* stack targets and reports for itself — so it and anything older keep exactly what they had,
* and only a LATER build gets anything served differently.
*
* Compared with {@link authedBuild}, which reads the build off the token's `rn.ver`.
*/
const LEGACY_CLIENT_BUILD = 20230414
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
@@ -593,15 +632,73 @@ interface GiftRequest {
}
/**
* Read a storefront catalog (`sf{type}.json`) from the ASSETS binding. Null when there is
* no such storefront.
* Storefront ids that are served ANOTHER storefront's catalog, because no capture of their
* own exists yet. Placeholder: an alias here is a storefront this server hasn't got, not one
* it has decided is a duplicate, so a line should come OUT again the moment `static/storefronts`
* grows the real `sf{id}.json` — the alias silently wins over a file of that name.
*
* Resolved in {@link storefrontAssetPath} rather than at the route, so an aliased storefront
* is aliased for BUYING too. Browsing and purchasing read the same catalog by id, and an
* alias applied to only the browse side would show a page of items whose every purchase
* 404s as "no such storefront".
*/
const STOREFRONT_ALIASES: Record<string, string> = {
// Empty. 1704 was here, served sf3's catalog, until `static/storefronts/sf1704.json` was
// generated — see `runx storefront build`. Its line came out the moment the file appeared,
// exactly as the note above says it must: an alias silently beats a real file of that name,
// so leaving it would have kept serving sf3 from a storefront that now has its own catalog.
}
/**
* Storefronts that have a SECOND file for newer clients, keyed by the id the client asks for
* and naming the file a build past {@link LEGACY_CLIENT_BUILD} is served instead.
*
* `3` is the general store. The 2023 client keeps `sf3.json` exactly as captured; a later one
* gets `sf3-2025.json`, which is that same file plus every sellable row of the item catalog
* (see `runx storefront build`). One storefront id either way — the client asks for 3 in both
* cases and neither knows there are two files — so nothing about the request changes and no
* item is renumbered. The two id spaces do not collide, which is what makes the merge safe.
*
* Resolved in {@link storefrontAssetPath}, which BOTH the listing route and
* {@link loadStorefront} go through, so browsing and buying always read the same file. That is
* the whole reason it is not done at the route: a newer client shown the merged store and then
* charged against the captured one would have every catalog item 404 as "no such storefront".
*/
const STOREFRONT_BY_BUILD: Record<string, string> = {
'3': 'sf3-2025',
}
/**
* The ASSETS path a storefront id reads from, following any {@link STOREFRONT_ALIASES} entry
* and any {@link STOREFRONT_BY_BUILD} variant. The id arrives as a path param, so it is a
* string here rather than a number: both tables are matched on what the client asked for.
*
* `build` is the caller's `rn.ver` (see {@link authedBuild}), or null when there is no readable
* one. Null gets the captured file: an unversioned token is the OLD client, so treating "can't
* prove its version" as "newer" would swap the store out from under the build that needs it.
*/
function storefrontAssetPath(id: string, build: number | null): string {
const aliased = STOREFRONT_ALIASES[id] ?? id
const variant = STOREFRONT_BY_BUILD[aliased]
if (variant !== undefined && build !== null && build > LEGACY_CLIENT_BUILD) {
return `/${variant}.json`
}
return `/sf${aliased}.json`
}
/**
* Read a storefront catalog from the ASSETS binding — WHICH file depending on the caller's
* build, see {@link storefrontAssetPath}. Null when there is no such storefront.
*
* Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
* storefront reads (and parses) it once: sf3 alone is over a thousand items, and a bulk
* purchase carries up to `BULK_PURCHASE_CAP` lines.
* storefront reads (and parses) it once: sf3 alone is over a thousand items and the merged
* sf3-2025 is four, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
*/
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
const build = await authedBuild(c)
const res = await c.env.ASSETS.fetch(
new URL(storefrontAssetPath(String(storefrontType), build), c.req.url)
)
if (!res.ok) return null
return (await res.json()) as Storefront
}
@@ -621,6 +718,92 @@ async function findStoreItem(
return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null
}
/**
* How one item may be bought, as `POST /api/items/purchaseInfos` answers it. The store row
* holds ids only, so everything a price tag needs comes from here.
*
* `ItemId` re-uses the request's reference verbatim — camelCase members under a PascalCase
* key. It reads like a mistake and is not one: the client's decoder names the members that
* way on both legs, and PascalCasing them here loses the id.
*
* `PurchaseMethodId` names WHICH listing sells the item, and is a tagged union of the two
* kinds of id a listing can have: `Type` 1 carries a `Guid` (a UGC item, keyed by its own
* guid) and leaves `NumberId` null; a storefront's numbered `PurchasableItemId` would be the
* other side. Nothing here sells anything under a second listing, so the guid is the item's own.
*/
interface ItemPurchaseInfo {
ItemId: { itemType: number; itemId: string }
PurchaseMethodId: { Type: number; NumberId: number | null; Guid: string | null }
Prices: Array<{
CurrencyType: number
Price: number
StorefrontSaleData: {
SalePercent: number
SaleStartDate: string | null
SaleEndDate: string | null
} | null
}>
NewUntil: string | null
AvailableAt: string | null
AvailableUntil: string | null
CanBeGifted: boolean
CanApplySubscriberDiscount: boolean
SubscribersOnly: boolean
IsFeatured: boolean
}
/** The `PurchaseMethodId.Type` that carries a `Guid` rather than a `NumberId`. */
const PURCHASE_METHOD_TYPE_GUID = 1
/**
* The purchase-info projection of a custom avatar item.
*
* The price is in `RecCenterTokens` because that is what a UGC item costs: the creation UI's
* floor (`api`'s `/api/customAvatarItems/v1/minPriceForPublicItem`) is a token price, and the
* `price` column it writes is the same number. It must NOT be a room currency — those are
* scoped to a room this endpoint knows nothing about, and the client holds no balance to pay
* one with, so the item would draw a price it can never meet.
*
* The rest is what the row can honestly say:
* - `AvailableAt` is the item's creation — the moment it began being sellable. There is no
* scheduled listing here, so `AvailableUntil` is null: on sale until the creator pulls it.
* - `NewUntil` is null rather than derived from `CreatedAt`: nothing has ever defined how long
* “new” lasts here, and guessing draws the pip on items that are not.
* - `StorefrontSaleData` is a zero-percent sale rather than null, since nothing discounts UGC
* items yet and a present-but-empty sale is the shape the client always gets to read.
* - `SubscribersOnly`/`CanApplySubscriberDiscount` are false: subscriber pricing is a
* storefront-catalog feature (`sf{N}.json`'s `SubscriberPrices`) and no UGC item has one.
* - `IsFeatured` is the row's own flag, the same one the featured feed reads.
*
* `CanBeGifted` is true because the reference let players gift UGC items — but nothing here
* buys a custom avatar item yet, gift or otherwise, so the button it draws leads nowhere until
* that exists. It is the flag to flip if a dead gift button is worse than a missing one.
*/
function toItemPurchaseInfo(item: CustomAvatarItem): ItemPurchaseInfo {
return {
ItemId: { itemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, itemId: item.CustomAvatarItemId },
PurchaseMethodId: {
Type: PURCHASE_METHOD_TYPE_GUID,
NumberId: null,
Guid: item.CustomAvatarItemId,
},
Prices: [
{
CurrencyType: CurrencyType.RecCenterTokens,
Price: item.Price,
StorefrontSaleData: { SalePercent: 0, SaleStartDate: null, SaleEndDate: null },
},
],
NewUntil: null,
AvailableAt: item.CreatedAt,
AvailableUntil: null,
CanBeGifted: true,
CanApplySubscriberDiscount: false,
SubscribersOnly: false,
IsFeatured: item.IsFeatured,
}
}
/** Build the owned avatar-item DTO granted into the buyer's inventory from a gift-drop. */
function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
return {
@@ -1283,6 +1466,62 @@ function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined):
}
}
/**
* Catalog rows as STORE ITEMS, so a bag can be resolved against the `catalog` table the same
* way it is resolved against an `sf{N}.json` file.
*
* The generated storefront (sf1704) is built from these very rows with this very pricing, so an
* item bought here costs exactly what that file lists it at. That is not a nicety: `priceCheck`
* refuses a line whose posted `RequestedPrice` doesn't match, so two pricings would 409 every
* purchase the client made from the page it was shown.
*
* SKINS come through too, keyed the way a gift-drop keys equipment (`EquipmentPrefabName` +
* `EquipmentModificationGuid`) rather than as an avatar item — which is what lets a skin be
* bought at all, since no generated storefront file lists one.
*
* {@link isSellableRarity} is applied here as well as in the generator: the developer tier is
* absent from the file, and resolving a bag straight off the table would otherwise sell items
* the store never offered.
*/
async function catalogStoreItems(db: D1Database, catalogIds: number[]): Promise<StoreItem[]> {
if (catalogIds.length === 0) return []
const placeholders = catalogIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM catalog WHERE catalog_id IN (${placeholders})`)
.bind(...catalogIds)
.all<CatalogRow>()
return results
.filter((row) => row.catalog_id !== null && isSellableRarity(row.rarity))
.map((row) => {
const skin = row.kind === CatalogKind.Skin
const price = priceForRarity(row.rarity)
return {
GiftDrop: {
FriendlyName: row.friendly_name,
// The client's field is a string; the catalog keeps NULL and "" apart.
Tooltip: row.tooltip ?? '',
ConsumableItemDesc: '',
// `item_key` IS the desc for an avatar item and the modification guid for a skin —
// one key column, read into whichever field its kind belongs in.
AvatarItemDesc: skin ? '' : row.item_key,
AvatarItemType: skin ? 0 : (row.avatar_item_type ?? 0),
EquipmentPrefabName: skin ? (row.prefab_name ?? '') : '',
EquipmentModificationGuid: skin ? row.item_key : '',
Rarity: row.rarity,
Context: 0,
Currency: 0,
CurrencyType: 0,
},
Prices: [{ CurrencyType: CurrencyType.RecCenterTokens, Price: price }],
SubscriberPrices: [
{ CurrencyType: CurrencyType.RecCenterTokens, Price: subscriberPriceFor(price) },
],
PurchasableItemId: row.catalog_id as number,
}
})
}
/**
* Resolve one line against the bag's catalog: what it wants, how many, and at what price.
* Returns the failure — with the `UpdateResponse` its entry will carry — instead when the
@@ -1721,6 +1960,45 @@ const app = new Hono<App>({ strict: false })
.onError(withOnError())
.notFound(withNotFound())
// A batch lookup of LOCKED avatar items — the client posts the descs it is about to draw
// and expects back the ones it must grey out, as a BARE ARRAY.
//
// A TEST STUB: it answers the whole bundled catalogue regardless of what was posted, so
// every item the client can see comes back locked. Two consequences to know before reading
// anything into what the client does with it:
//
// - The posted `AvatarItemDescriptions` are NOT read, so nothing is echoed. The reference
// answers per requested desc; a client that matches responses to its request will find
// entries it never asked for and none of the ones it did.
// - It is the whole catalogue every call — 3098 items, about a megabyte — which is fine for
// a stub and is not what a real implementation should send.
//
// The real thing resolves each posted desc against what the player has NOT unlocked. The
// `catalog` table is keyed by `AvatarItemDesc` (`item_key`), so the lookup is already there
// to build on; what is missing is anything recording a lock.
//
// NOTE: the `api` worker has a route of this same path that answers `[]` — it predates this
// one and is left alone deliberately. The client asks THIS host, so that one is unreached;
// they must be reconciled before either is taken for real behaviour.
.post(
'/api/avatar/v1/lockeditems/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'Locked avatar items in bulk (test stub)',
description: [
'Resolves a batch of `AvatarItemDescriptions` to the items that are LOCKED for the',
'caller, as a bare array.',
'TEST STUB: the posted descs are ignored and the whole bundled catalogue comes back,',
'so the client renders everything as locked. Nothing records a lock yet, and nothing',
'is echoed — a real implementation answers one entry per posted desc, carrying that',
'desc verbatim.',
].join(' '),
requestBody: jsonBody(LockedItemsBulkRequest, 'The descs to resolve (currently ignored)'),
responses: { 200: json(JsonArray, 'The locked items — currently the whole catalogue') },
}),
(c) => c.json(avatarItemCatalog)
)
// Default-unlocked avatar items, served from the bundled static JSON.
.get(
'/api/avatar/v1/defaultunlocked',
@@ -2423,6 +2701,53 @@ const app = new Hono<App>({ strict: false })
}
)
// How the items in a store row may be BOUGHT — the counterpart of the bulk lookup above.
// The row itself carries only ids; the client asks this for the price tag, the sale
// banner, the “new” pip and whether the gift button is drawn. It answers one entry per
// RESOLVED id, in request order, dropping ids it doesn't know exactly as the bulk lookup
// does — an item with no purchase info renders as not-for-sale rather than at price zero.
//
// Two shapes meet in one object here and neither may be tidied into the other: the
// request's `{ itemType, itemId }` reference is camelCase, and the response nests THAT
// object, members unchanged, under a PascalCase `ItemId` beside PascalCase siblings.
.post(
'/api/items/purchaseInfos',
describeRoute({
tags: ['Storefront'],
summary: 'Purchase info for a bag of items',
description: [
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and',
'answers how each may be bought: its price in RecCenterTokens, its availability window',
'and the flags the store row draws. Only `itemType` 3 (custom avatar item) is served;',
'other types and unknown ids are dropped, so the response is one entry per RESOLVED',
'id in request order — never a positional match for `Ids[]`.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(ItemPurchaseInfosRequest, 'The ids to price'),
responses: {
200: json(ItemPurchaseInfoList, 'The resolved items purchase info (unknown ids omitted)'),
400: json(ErrorResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
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 || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
const ids = (body.Ids as unknown[]).flatMap((ref) => {
if (!ref || typeof ref !== 'object') return []
const { itemType, itemId } = ref as Record<string, unknown>
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
? [itemId]
: []
})
const items = await getCustomAvatarItems(c.env.DB, ids)
return c.json(items.map(toItemPurchaseInfo))
}
)
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
// unlocked-consumable DTO. A player who has bought none gets an empty list.
@@ -2538,13 +2863,25 @@ const app = new Hono<App>({ strict: false })
)
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
// storefront id via the ASSETS binding; 404s when no such catalog exists.
// storefront id via the ASSETS binding; 404s when no such catalog exists. A few ids are
// stand-ins for another storefront's catalog (see `STOREFRONT_ALIASES`) — resolved through
// the same helper `buyItem` uses, so an aliased storefront can be bought from as well as
// browsed.
.get(
'/api/storefronts/v3/giftdropstore/:id',
describeRoute({
tags: ['Storefront'],
summary: 'Gift-drop storefront catalog',
description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.',
description: [
'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists. An id',
'with no capture of its own may stand in for another storefronts catalog (see',
'`STOREFRONT_ALIASES`, currently empty), and such an alias applies to purchases from',
'that storefront too, not just to this listing. Which FILE a storefront reads from can',
'also depend on the callers build (`rn.ver`): storefront `3` serves the captured',
'`sf3.json` to builds up to 20230414 and the merged `sf3-2025.json` — that same store',
'plus every sellable row of the item catalog — to later ones. The id does not change,',
'and the same resolution applies to purchases, so what is browsed is what is charged.',
].join(' '),
parameters: [
{
name: 'id',
@@ -2561,7 +2898,10 @@ const app = new Hono<App>({ strict: false })
}),
async (c) => {
const id = c.req.param('id')
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
// The same resolution `loadStorefront` uses, so what is browsed is what a purchase is
// checked against — see `storefrontAssetPath`.
const path = storefrontAssetPath(id, await authedBuild(c))
const res = await c.env.ASSETS.fetch(new URL(path, c.req.url))
if (!res.ok) return c.notFound()
return c.json(await res.json())
}
@@ -2806,9 +3146,33 @@ const app = new Hono<App>({ strict: false })
// One catalog read for the bag; every line resolves against it in memory.
const storefront = await loadStorefront(c, storefrontType as number)
// Past LEGACY_CLIENT_BUILD the bag may also name CATALOG rows — the ids the generated
// storefront and the discovery rows hand out (10000 and up) — so those are looked up in
// the `catalog` table and appended. One extra query for the whole bag.
//
// Appended rather than replacing the file: the two id spaces do not overlap
// (`CATALOG_ID_BASE` is above every captured id), so a bag may mix them and a newer
// client buying from a captured storefront still works. An older build is not offered
// catalog ids anywhere, so it is left resolving exactly what it always did.
const build = await authedBuild(c)
const catalogItems =
build !== null && build > LEGACY_CLIENT_BUILD
? await catalogStoreItems(
c.env.DB,
lines.flatMap((line) => {
const numberId = toPurchaseMethodId(line.ItemPurchaseMethodId).NumberId
return numberId !== null && numberId >= CATALOG_ID_BASE ? [numberId] : []
})
)
: []
const bagCatalog: Storefront | null =
catalogItems.length === 0
? storefront
: { StoreItems: [...(storefront?.StoreItems ?? []), ...catalogItems] }
const subscriber = await isSubscriber(c)
const resolved = lines.map((line) =>
resolveBulkLine(line, storefront, currencyType as number, subscriber)
resolveBulkLine(line, bagCatalog, currencyType as number, subscriber)
)
const buyable = resolved.filter(isBulkLine)
+60
View File
@@ -429,6 +429,66 @@ export const UgcPurchasableItemDto = z.object({
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
/**
* `POST /api/items/purchaseInfos` JSON body — the same `{ itemType, itemId }` reference
* shape the UGC bulk lookup takes, minus the room. camelCase INSIDE the reference, which is
* the client's own inconsistency: the response wraps this very object under a PascalCase
* `ItemId` key without renaming its members.
*/
export const ItemPurchaseInfosRequest = z.object({
Ids: z.array(
z.object({
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
itemId: z.string().describe('The `CustomAvatarItemId`'),
})
),
})
/** One `Prices[]` entry: what the item costs in one currency, and any sale on top. */
export const ItemPriceDto = z.object({
CurrencyType: z.number().int().describe('2 = RecCenterTokens — what UGC items are priced in'),
Price: z.number().int(),
StorefrontSaleData: z
.object({
SalePercent: z.number().int(),
SaleStartDate: z.string().nullable(),
SaleEndDate: z.string().nullable(),
})
.nullable()
.describe('Always a zero-percent sale here; nothing discounts UGC items yet'),
})
/** The client's `ItemPurchaseInfo` — how one item may be bought. */
export const ItemPurchaseInfoDto = z.object({
ItemId: z.object({ itemType: z.number().int(), itemId: z.string() }),
PurchaseMethodId: z.object({
Type: z.number().int(),
NumberId: z.number().int().nullable(),
Guid: z.string().nullable(),
}),
Prices: z.array(ItemPriceDto),
NewUntil: z.string().nullable(),
AvailableAt: z.string().nullable(),
AvailableUntil: z.string().nullable(),
CanBeGifted: z.boolean(),
CanApplySubscriberDiscount: z.boolean(),
SubscribersOnly: z.boolean(),
IsFeatured: z.boolean(),
})
/** What the purchase-info lookup answers: one entry per RESOLVED id, unknown ids omitted. */
export const ItemPurchaseInfoList = z.array(ItemPurchaseInfoDto)
/**
* `POST /api/avatar/v1/lockeditems/bulk` JSON body — the descs the client wants the locked
* state for. Currently accepted and not read; see the route.
*/
export const LockedItemsBulkRequest = z.object({
AvatarItemDescriptions: z
.array(z.string())
.describe('The `AvatarItemDesc` of each item the client is about to draw'),
})
export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
+954 -3
View File
@@ -23,6 +23,17 @@ import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventio
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types'
// The catalog's two migrations and the captures the loader reads, imported so the tests at the
// bottom can check the schema they build against `CATALOG_SCHEMA_DDL`. `?raw` because they are
// SQL, not modules: they are never executed here, only read.
import catalogStructureSql from '../../../migrations/0015_catalog.sql?raw'
import catalogIdSql from '../../../migrations/0016_catalog_id.sql?raw'
import avatarItemsJson from '../../../static/db/avatar-items.json'
import skinsJson from '../../../static/db/skins.json'
import sf32025 from '../../../static/storefronts/sf3-2025.json'
// The merged 2025 general store, read as a FILE: which file the route serves depends on the
// caller's build, and these assertions are about the file's CONTENTS.
import sf3 from '../../../static/storefronts/sf3.json'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -31,6 +42,24 @@ import {
getBalance,
spendCurrency,
} from '../../balance-db'
import {
baseAsset,
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CATALOG_SCHEMA_DDL,
CatalogKind,
countCatalog,
getAvatarItem,
getAvatarItemsByTag,
getCatalogItem,
getCatalogItemById,
getCatalogItems,
getSkin,
getSkinsForPrefab,
searchCatalog,
toCatalogSkin,
} from '../../catalog-db'
import { CATALOG_ID_BASE } from '../../catalog-load'
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
// The live weekly rotation, generated the same way the worker generates it, so the challenge
// tests exercise whatever this week actually holds instead of ids from a rotation that has
@@ -41,8 +70,20 @@ import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
import type { Env } from '../../context'
/**
* The half of the merged store that came from the item CATALOG — nothing in the file marks
* which half a row is from, so it is whatever sf3 does not already contain.
*
* NOT `id >= CATALOG_ID_BASE`, which looks equivalent and is not: sf3 carries one id far above
* that range (20756767), so an id test counts it as a catalog row and every count comes out one
* too high. Membership in sf3's own ids is the question actually being asked.
*/
const sf3Ids = new Set(sf3.StoreItems.map((i) => i.PurchasableItemId))
const catalogItems = () => sf32025.StoreItems.filter((i) => !sf3Ids.has(i.PurchasableItemId))
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
@@ -74,6 +115,7 @@ beforeAll(async () => {
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CATALOG_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()
@@ -176,10 +218,16 @@ function b64url(input: ArrayBuffer | string): string {
* account's flags — pass `['gameClient', 'developer']` for an elevated account; the default
* is no claim at all, which reads as no roles.
*/
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
async function bearer(
sub = '42',
roles?: string[],
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
version?: string
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const claims =
roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles }
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
if (roles !== undefined) claims.role = roles
if (version !== undefined) claims['rn.ver'] = version
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(claims)
)}`
@@ -737,6 +785,234 @@ describe('econ endpoints', () => {
expect(anon.status).toBe(401)
})
test('bulkpurchase resolves catalog ids for newer builds, at the storefronts price', async () => {
// A catalog row the generated storefront would list at 600 (rarity 10) and a skin the
// storefront lists nowhere at all — both bought straight off the `catalog` table.
const AVATAR_ID = 20_001
const SKIN_ID = 20_002
const DEV_ID = 20_003
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
VALUES ('bulk-buy-desc,,,', ?1, 'avatar_item', 'Bulk Buy Hat', '', 10, -1, 0)`
)
.bind(AVATAR_ID)
.run()
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name)
VALUES ('bulk-buy-guid', ?1, 'skin', 'Bulk Buy Skin', '', 0, -1, '[MakerPen]')`
)
.bind(SKIN_ID)
.run()
// Rarity -1 is the developer tier: in the catalog, absent from the storefront, and so not
// for sale here either — resolving straight off the table must not sell what the store
// never offered.
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
VALUES ('bulk-buy-dev,,,', ?1, 'avatar_item', 'Bulk Buy Dev Item', '', -1, -1, 0)`
)
.bind(DEV_ID)
.run()
const buy = async (
version: string | undefined,
lines: Array<{ id: number; price: number }>,
sub = '46'
) =>
exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer(sub, undefined, version)) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
StorefrontType: 3,
CurrencyType: 2,
AllowPartialSuccess: false,
PurchaseItemRequests: lines.map((l) => ({
ItemPurchaseMethodId: { Type: 0, NumberId: l.id, Guid: null },
RequestedPrice: l.price,
})),
}),
})
// 150 (rarity 0) and 600 (rarity 10) are the generated storefront's own prices. They MUST
// match: `priceCheck` refuses a line whose posted price differs, so a server pricing a buy
// differently from the file it listed would 409 every purchase.
const res = await buy('20250718.01', [
{ id: AVATAR_ID, price: 600 },
{ id: SKIN_ID, price: 150 },
])
expect(res.status).toBe(200)
const body = (await res.json()) as { Success: boolean; Value: { Balance: number } | null }
expect(body.Success).toBe(true)
// A price the storefront does not list is refused, not quietly charged.
const wrongPrice = await buy('20250718.01', [{ id: AVATAR_ID, price: 1 }])
expect(((await wrongPrice.json()) as { Success: boolean }).Success).toBe(false)
// The developer-tier row is not for sale.
const dev = await buy('20250718.01', [{ id: DEV_ID, price: 150 }])
expect(((await dev.json()) as { Success: boolean }).Success).toBe(false)
// An OLD build is left resolving exactly what it always did — the storefront file — so a
// catalog id means nothing to it. Nothing offers those ids to that build anyway.
const legacy = await buy('20230414', [{ id: AVATAR_ID, price: 600 }])
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
const unversioned = await buy(undefined, [{ id: AVATAR_ID, price: 600 }])
expect(((await unversioned.json()) as { Success: boolean }).Success).toBe(false)
// The two id spaces do not overlap, so a bag may MIX a captured storefront's item with a
// catalog row. Item 73 is sf3's "Bowtie (White)" at 450.
const mixed = await buy('20250718.01', [
{ id: 73, price: 450 },
{ id: SKIN_ID, price: 150 },
])
expect(((await mixed.json()) as { Success: boolean }).Success).toBe(true)
// Cleaned up: the `catalog` block below counts every row in the table, so rows left behind
// here would change what it sees.
await env.DB.prepare('DELETE FROM catalog WHERE catalog_id BETWEEN ?1 AND ?2')
.bind(AVATAR_ID, DEV_ID)
.run()
})
test('POST /api/avatar/v1/lockeditems/bulk returns the catalogue as a bare array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
AvatarItemDescriptions: [
'c70005d5-6276-4a98-acb3-6a77bc19379a,OBcu3bf1NEio_7t9smqGag',
'4eaaad39-aa44-4791-8b67-2eafa8305850',
],
}),
})
expect(res.status).toBe(200)
// A BARE ARRAY, no envelope.
const body = (await res.json()) as Array<{
AvatarItemType: number
AvatarItemDesc: string
FriendlyName: string
Rarity: number
}>
expect(Array.isArray(body)).toBe(true)
// TEST STUB: the whole bundled catalogue, whatever was posted. Pinned so the day this
// grows real lock logic, the change is visible here rather than silently altering what a
// client is told is locked.
expect(body).toHaveLength(avatarItemsJson.length)
expect(body[0]).toMatchObject({
AvatarItemDesc: avatarItemsJson[0]!.AvatarItemDesc,
FriendlyName: avatarItemsJson[0]!.FriendlyName,
})
// Every entry carries the shape the client reads, including the four fields the
// `defaultunlocked` catalogue leaves out.
for (const key of [
'AvatarItemType',
'AvatarItemDesc',
'FriendlyName',
'Tooltip',
'Rarity',
'AvatarItemId',
'IsBaseAvatarItem',
'ThumbnailImage',
'CreatedAt',
]) {
expect(Object.keys(body[0] as object), key).toContain(key)
}
// Nothing is echoed: the posted descs are not read, so what comes back is unrelated to
// what was asked for. A real implementation answers one entry per posted desc.
expect(body.map((i) => i.AvatarItemDesc)).not.toEqual([
'c70005d5-6276-4a98-acb3-6a77bc19379a,OBcu3bf1NEio_7t9smqGag',
'4eaaad39-aa44-4791-8b67-2eafa8305850',
])
// No auth needed, and a body it cannot read is not an error — the answer does not depend
// on either.
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
})
expect(anon.status).toBe(200)
expect(((await anon.json()) as unknown[]).length).toBe(avatarItemsJson.length)
})
test('POST /api/items/purchaseInfos prices custom avatar items in tokens', async () => {
const item = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 206,
name: 'Chrome Jacket',
description: '',
price: 425,
baseAvatarItemId: 1,
baseAvatarItemColor: '#000',
designFilename: 'design_pi.bin',
thumbnailImageFilename: 'thumb_pi.png',
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
Ids: [
{ itemType: 3, itemId: item.CustomAvatarItemId },
// Dropped, both of them: an id nothing owns, and a type this doesn't serve. The
// response is one entry per RESOLVED id, so it is SHORTER than `Ids` rather than
// carrying a null in their places — the client must not read it positionally.
{ itemType: 3, itemId: crypto.randomUUID() },
{ itemType: 1, itemId: item.CustomAvatarItemId },
],
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
// The reference echoed back verbatim: camelCase members under a PascalCase key. The
// client names them that way on both legs and PascalCasing them here loses the id.
ItemId: { itemType: 3, itemId: item.CustomAvatarItemId },
// A UGC listing is keyed by guid, so `Type` 1 and `NumberId` null. A storefront's
// numbered `PurchasableItemId` would be the other side of the union.
PurchaseMethodId: { Type: 1, NumberId: null, Guid: item.CustomAvatarItemId },
// RecCenterTokens (2) — the currency the creation UI's price floor is denominated
// in, and the one the client actually holds a balance in. A room currency (300)
// would draw a price nothing can pay.
Prices: [
{
CurrencyType: 2,
Price: 425,
StorefrontSaleData: { SalePercent: 0, SaleStartDate: null, SaleEndDate: null },
},
],
NewUntil: null,
AvailableAt: item.CreatedAt,
AvailableUntil: null,
CanBeGifted: true,
CanApplySubscriberDiscount: false,
SubscribersOnly: false,
IsFeatured: false,
},
])
})
test('POST /api/items/purchaseInfos 400s without Ids and 401s without a token', async () => {
const bad = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({}),
})
expect(bad.status).toBe(400)
const anon = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ Ids: [] }),
})
expect(anon.status).toBe(401)
})
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
expect(anon.status).toBe(401)
@@ -919,6 +1195,162 @@ describe('econ endpoints', () => {
expect(await res.json()).toBeTruthy()
})
// TEMPORARY, alongside the probe in `econ.app.ts`: the storefront ids are swapped so it can
// be seen from the client which one the 2025 store actually reads. Delete this with the
// probe.
test('storefront 3 serves sf3 to old builds and the merged sf3-2025 to newer ones', async () => {
const store = async (headers: Record<string, string>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/3`, {
headers,
})
expect(res.status).toBe(200)
return (await res.json()) as { StorefrontType: number; StoreItems: unknown[] }
}
const at = async (version: string) =>
(await bearer('42', undefined, version)) as Record<string, string>
// 20230414 is GAME_VERSION — what the rest of the stack targets — so the cutoff is
// INCLUSIVE and that build keeps the captured sf3 exactly as it has always had it.
const legacy = await store(await at('20230414'))
expect(legacy.StoreItems).toHaveLength(sf3.StoreItems.length)
// A same-day rebuild sorts by its DATE, not the `.NN` suffix.
expect((await store(await at('20230414.02'))).StoreItems).toHaveLength(sf3.StoreItems.length)
// A caller with no readable build gets the captured file too: an unversioned token is the
// OLD client, so treating "can't prove its version" as "newer" would swap the store out
// from under the build that needs it.
expect((await store({})).StoreItems).toHaveLength(sf3.StoreItems.length)
expect((await store(await bearer())).StoreItems).toHaveLength(sf3.StoreItems.length)
expect((await store(await at('not-a-build'))).StoreItems).toHaveLength(sf3.StoreItems.length)
// Later builds get the merged store — bigger than either half, and still storefront 3.
for (const version of ['20230616', '20250424.01', '20250718.01']) {
const merged = await store(await at(version))
expect(merged.StorefrontType, version).toBe(3)
expect(merged.StoreItems.length, version).toBe(sf32025.StoreItems.length)
expect(merged.StoreItems.length, version).toBeGreaterThan(sf3.StoreItems.length)
}
// The id does not change and nothing is renumbered: sf3's own items are in the merged file
// unchanged, so a newer client buying one is charged the same as an older client would be.
const bowtie = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73, // sf3's "Bowtie (White)", 450 tokens
CurrencyType: 2,
RequestedPrice: 450,
}),
})
expect(bowtie.status).toBe(200)
// And a CATALOG item can be bought from storefront 3 by a newer build — the half a
// listing-only swap breaks. `findStoreItem` resolves the purchase through the same
// build-aware path the listing does, so an item on the page is an item that can be bought.
// From the catalog half — see `catalogItems`, which is why this is not an id comparison.
const catalogItem = catalogItems()[0]
expect(catalogItem).toBeDefined()
const bought = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: catalogItem!.PurchasableItemId,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
}),
})
expect(bought.status).toBe(200)
// The SAME item is not for sale to an old build, because its store does not list it.
const refused = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20230414')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: catalogItem!.PurchasableItemId,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
}),
})
expect(refused.status).toBe(404)
})
test('sf3-2025 merges sf3 with the catalog, priced by rarity', async () => {
// The general store as the 2025 client sees it. Asserted against the FILE rather than the
// endpoint, because which file the route serves depends on the caller's build and this is
// about the file's contents.
//
// It reports storefront 3, not an id of its own: the client asks for 3 either way and
// neither half knows there are two files.
expect(sf32025.StorefrontType).toBe(3)
// sf3's own items are carried through UNCHANGED — the merge adds to the store the older
// client knows rather than restating it.
const base = new Map(sf3.StoreItems.map((i) => [i.PurchasableItemId, i]))
expect(sf32025.StoreItems.length).toBe(sf3.StoreItems.length + catalogItems().length)
for (const [id, item] of base) {
expect(
sf32025.StoreItems.find((i) => i.PurchasableItemId === id),
String(id)
).toEqual(item)
}
// The subscriber discount is expressed ONLY in `SubscriberPrices`. The top-level
// `SubscriberDiscountPercent` stays 0 so a client cannot take the 10% a second time off an
// already-discounted price and post 19% off, which falls through the server's own
// subscriber floor and is refused as "Price has changed".
expect(sf32025.SubscriberDiscountPercent).toBe(0)
const priceByRarity = new Map([
[0, 150],
[10, 600],
[20, 700],
[30, 800],
[50, 3000],
])
// Rarity -1 is the developer/unreleased tier and is EXCLUDED rather than priced. An item
// listed here can be bought — `findStoreItem` resolves a purchase against this very file —
// so leaving them in at any price would put them on sale.
expect(catalogItems().filter((i) => i.GiftDrop.Rarity === -1)).toEqual([])
for (const item of catalogItems()) {
const expected = priceByRarity.get(item.GiftDrop.Rarity)
expect(expected, `rarity ${item.GiftDrop.Rarity}`).toBeDefined()
expect(item.Prices[0]).toMatchObject({ CurrencyType: 2, Price: expected })
// Floored, matching the server's own `subscriberFloor`. Every tier here divides evenly.
expect(item.SubscriberPrices[0]).toMatchObject({
CurrencyType: 2,
Price: Math.floor((expected! * 90) / 100),
})
}
// Every item is an avatar item with a real desc, and its id is in the GENERATED range,
// echoed in `GiftDropId` the way sf3 does on all 1161 of its own items. Ids must be unique
// or a purchase resolves the wrong row.
const ids = catalogItems().map((i) => i.PurchasableItemId)
expect(new Set(ids).size).toBe(ids.length)
// The id IS the `catalog_id` — one number, no second numbering and no arithmetic between
// them. Catalog ids start at 10000 precisely so this is safe: every captured storefront's
// own ids are 2764 or below, and numbering from 1 would have collided with sf3's head-on,
// making one id mean two different items depending on which storefront it came from.
expect(Math.min(...ids)).toBe(CATALOG_ID_BASE)
expect(ids.every((id) => id >= CATALOG_ID_BASE)).toBe(true)
expect(catalogItems().every((i) => i.GiftDrop.GiftDropId === i.PurchasableItemId)).toBe(true)
expect(catalogItems().every((i) => i.GiftDrop.AvatarItemDesc.length > 0)).toBe(true)
// An id with neither a capture nor an alias still 404s.
const missing = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/1705`)
expect(missing.status).toBe(404)
// 1704 is gone: it was a stand-in for a store that turned out to belong inside sf3, so
// nothing answers that id any more.
const gone = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/1704`)
expect(gone.status).toBe(404)
})
// Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2).
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
@@ -3238,6 +3670,7 @@ describe('econ endpoints', () => {
'GET /econ/roomOffer/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
@@ -3249,6 +3682,7 @@ describe('econ endpoints', () => {
'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request',
'POST /api/items/bulkpurchase',
'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
@@ -3263,3 +3697,520 @@ describe('econ endpoints', () => {
}
})
})
// The item catalog. Loaded by migration, not written at runtime, so these exercise the SHAPE of
// the table and its query helpers against a handful of hand-seeded rows; the drift test at the
// end is what pins the thousands of real ones.
describe('catalog', () => {
// One row of each kind, plus the cases that decided the schema: an avatar_item_id shared by
// two rows and absent from a third, and keys that are alpha strings rather than GUIDs.
beforeAll(async () => {
const insert = (row: unknown[]) =>
env.DB.prepare(
`INSERT INTO catalog (
item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask,
thumbnail_image, avatar_item_type, avatar_item_id, is_base_avatar_item, tag_list,
created_at, prefab_name, unlocked_level
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)`
)
.bind(...row)
.run()
// `catalog_id` is handed out by the loader as 1..N over the captures. These seeds number
// themselves the same way but from a base far above N, so a test that inserts a REAL
// capture row (which carries its own low id) cannot collide with a seed — the constraint
// under test should only ever fire on something the test meant to collide.
let nextId = 900_001
/** An avatar item: `item_key` is its `AvatarItemDesc`, the skin columns stay null. */
const avatarItem = (
desc: string,
name: string,
tooltip: string | null,
rarity: number,
type: number,
id: number | null,
tag: string | null,
createdAt: string | null,
thumb: string | null
) =>
insert([
desc,
nextId++,
'avatar_item',
name,
tooltip,
rarity,
-1,
thumb,
type,
id,
0,
tag,
createdAt,
null,
null,
])
/** A skin: `item_key` is its `ModificationGuid`, the avatar columns stay null. */
const skin = (guid: string, name: string, tooltip: string | null, prefab: string) =>
insert([
guid,
nextId++,
'skin',
name,
tooltip,
0,
-1,
'',
null,
null,
null,
null,
null,
prefab,
0,
])
await avatarItem(
'_OWVy3z6iU-M3-zbQgSLig,,,',
'Vampire Hunter Gloves (Blue)',
// NULL, not '' — the two are different values in the capture and the client's DTO serves
// the difference through.
null,
10,
0,
835,
null,
'2018-11-01T17:51:50.733Z',
'cimomkml6k4toyowd1voh7hqm.png'
)
await avatarItem(
'002a0f2f-1a24-4439-b578-470818ef8325,,,',
'Turkey Sweater',
'',
50,
0,
1570,
'thanksgiving',
'2020-10-28T00:37:27.263Z',
'4g2r02n1g5w09re7hl1ba2yyi.png'
)
// These two share avatar_item_id 9503 — the reason that column keys nothing.
await avatarItem(
'c5010738-41fa-4eca-aeac-e24adaa29789,',
'Helmet Hair',
'',
-1,
0,
9503,
null,
null,
null
)
await avatarItem(
'60067e91-18b8-43ab-ae20-a8ea74c757bf,KUAMuM41hk-YLZoqTiKncA',
'Green Cheer Sash',
'',
-1,
0,
9503,
null,
null,
null
)
// A hair dye: AvatarItemType 1, NO avatar_item_id at all, and a desc that is a bare alpha
// string with no commas. Still perfectly keyed.
await avatarItem(
'pQNfh-3DsEGWfiIls6Qf6g',
'Permanent Hair Dye (Pirate Gold)',
'',
0,
1,
null,
null,
null,
null
)
await skin('19ef59c7-f74b-4c63-935a-1d4b1abd8518', 'Disc (Coop)', '', '[DiscGolfDisc]')
// An alpha-string key, from before the game moved to GUIDs.
await skin('bfrFOdnHzEaIwHqem2dXkg', 'Confetti Gun (Gold)', null, '[PaintballGun] Confetti')
})
test('an avatar item reads back by its desc, nullable fields intact', async () => {
const item = await getAvatarItem(env.DB, '_OWVy3z6iU-M3-zbQgSLig,,,')
expect(item).toEqual({
AvatarItemDesc: '_OWVy3z6iU-M3-zbQgSLig,,,',
AvatarItemType: 0,
PlatformMask: -1,
FriendlyName: 'Vampire Hunter Gloves (Blue)',
// The one the column may never be defaulted to '' for.
Tooltip: null,
Rarity: 10,
TagList: null,
AvatarItemId: 835,
IsBaseAvatarItem: false,
CreatedAt: '2018-11-01T17:51:50.733Z',
ThumbnailImage: 'cimomkml6k4toyowd1voh7hqm.png',
})
expect(await getAvatarItem(env.DB, 'nothing-has-this-desc')).toBeNull()
})
test('a skin reads back by its guid, and an alpha-string key is just as good', async () => {
expect(await getSkin(env.DB, '19ef59c7-f74b-4c63-935a-1d4b1abd8518')).toEqual({
PrefabName: '[DiscGolfDisc]',
ModificationGuid: '19ef59c7-f74b-4c63-935a-1d4b1abd8518',
UnlockedLevel: 0,
// The catalog does not store this: it is a PLAYER's flag, and the capture recorded one
// account's. It is overwritten from the player's own `equipment` row.
Favorited: false,
PlatformMask: -1,
FriendlyName: 'Disc (Coop)',
Tooltip: '',
Rarity: 0,
ThumbnailImage: '',
})
// 191 of the skins are keyed by the short alpha-string ids the game used before GUIDs. The
// column is TEXT and compared as text, so these need no special handling — which is exactly
// why nothing here parses or validates a key's shape.
const gold = await getSkin(env.DB, 'bfrFOdnHzEaIwHqem2dXkg')
expect(gold?.FriendlyName).toBe('Confetti Gun (Gold)')
// Skins carry NULL tooltips too, so the projection must not flatten them to ''.
expect(gold?.Tooltip).toBeNull()
expect((await getSkinsForPrefab(env.DB, '[DiscGolfDisc]')).map((s) => s.FriendlyName)).toEqual([
'Disc (Coop)',
])
expect(await getSkin(env.DB, 'no-such-guid')).toBeNull()
})
test('one key spans both kinds, and asking for the wrong kind gets null, not a mangled row', async () => {
// The lookup the inventory wants: a player's owned things are ids of exactly this shape and
// the row says which kind each turned out to be. No join, no guessing.
const owned = await getCatalogItems(env.DB, [
'19ef59c7-f74b-4c63-935a-1d4b1abd8518',
'_OWVy3z6iU-M3-zbQgSLig,,,',
// Unknown keys are SKIPPED rather than left as holes, so the result is shorter than the
// input and must never be read positionally.
'nothing-owns-this',
])
expect(owned.map((r) => [r.kind, r.friendly_name])).toEqual([
['skin', 'Disc (Coop)'],
['avatar_item', 'Vampire Hunter Gloves (Blue)'],
])
expect(await getCatalogItems(env.DB, [])).toEqual([])
// A key is unique across BOTH kinds, so a typed accessor handed the other kind's key answers
// null rather than projecting a skin into an avatar item's shape.
expect(await getAvatarItem(env.DB, '19ef59c7-f74b-4c63-935a-1d4b1abd8518')).toBeNull()
expect(await getSkin(env.DB, '_OWVy3z6iU-M3-zbQgSLig,,,')).toBeNull()
expect(() => toCatalogSkin({ ...(owned[1] as CatalogRow) })).toThrow(/not a skin/)
})
test('avatar_item_id is carried as data and keys nothing', async () => {
// Two seeded rows share 9503 (five real ones do), and the hair dye has no id at all. Keying
// the table on it would have silently dropped four of those five at load time, which is why
// it is stored, not indexed, and never looked up by.
const shared = await searchCatalog(env.DB, CatalogKind.AvatarItem, 'a')
expect(shared.filter((r) => r.avatar_item_id === 9503)).toHaveLength(2)
const dye = await getAvatarItem(env.DB, 'pQNfh-3DsEGWfiIls6Qf6g')
expect(dye?.AvatarItemId).toBeNull()
expect(dye?.AvatarItemType).toBe(1)
})
test('search is case-insensitive, scoped to one kind, and takes wildcards literally', async () => {
const hits = await searchCatalog(env.DB, CatalogKind.AvatarItem, 'HAIR')
expect(hits.map((r) => r.friendly_name)).toEqual([
'Helmet Hair',
'Permanent Hair Dye (Pirate Gold)',
])
// `kind` scopes it: the same needle against skins finds nothing, so a skin search can never
// surface a wearable.
expect(await searchCatalog(env.DB, CatalogKind.Skin, 'hair')).toEqual([])
expect(
(await searchCatalog(env.DB, CatalogKind.Skin, 'disc')).map((r) => r.prefab_name)
).toEqual(['[DiscGolfDisc]'])
// A needle of LIKE metacharacters matches them literally rather than everything — the escape
// is what stops a player typing `%` from pulling the whole catalog back.
expect(await searchCatalog(env.DB, CatalogKind.AvatarItem, '%')).toEqual([])
expect(await searchCatalog(env.DB, CatalogKind.AvatarItem, '_')).toEqual([])
// A hit that really does contain the character still matches, so escaping didn't break it.
expect((await searchCatalog(env.DB, CatalogKind.AvatarItem, 'cheer')).length).toBe(1)
})
test('seasonal rows come back by tag, and counts are per kind', async () => {
expect((await getAvatarItemsByTag(env.DB, 'thanksgiving')).map((i) => i.FriendlyName)).toEqual([
'Turkey Sweater',
])
expect(await getAvatarItemsByTag(env.DB, 'halloween')).toEqual([])
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 5, skin: 2 })
})
test('the key is unique across both kinds, so a collision is refused', async () => {
// A second avatar item with the same desc...
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask)
VALUES ('_OWVy3z6iU-M3-zbQgSLig,,,', 'avatar_item', 'Impostor', 0, -1)`
).run()
).rejects.toThrow()
// ...a second skin with the same guid...
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES ('bfrFOdnHzEaIwHqem2dXkg', 'skin', 'Impostor', 0, -1, '[PaintballGun]')`
).run()
).rejects.toThrow()
// ...and a skin claiming an avatar item's key. The kinds share ONE key space, which is what
// lets an owned id be resolved without first knowing what kind of thing it is.
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES ('_OWVy3z6iU-M3-zbQgSLig,,,', 'skin', 'Impostor', 0, -1, '[MakerPen]')`
).run()
).rejects.toThrow()
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 5, skin: 2 })
})
test('baseAsset takes the first field of an AvatarItemDesc', async () => {
// `<baseAsset>,<color>,<texture>,` — the base asset is what decides whether the client can
// draw an item at all, so it is read off the key rather than stored twice.
expect(baseAsset('_OWVy3z6iU-M3-zbQgSLig,,,')).toBe('_OWVy3z6iU-M3-zbQgSLig')
expect(baseAsset('60067e91-18b8-43ab-ae20-a8ea74c757bf,KUAMuM41hk-YLZoqTiKncA')).toBe(
'60067e91-18b8-43ab-ae20-a8ea74c757bf'
)
// A dye's desc is a bare alpha string with no commas at all; it is still the base asset.
expect(baseAsset('pQNfh-3DsEGWfiIls6Qf6g')).toBe('pQNfh-3DsEGWfiIls6Qf6g')
})
// The migration builds the TABLE; `runx catalog load` fills it. These two are the seam
// between them: the schema the tests build must be the schema the migration builds, and the
// loader must produce rows that schema accepts.
test('the migrations and CATALOG_SCHEMA_DDL build the same table', async () => {
// Both migrations together: 0015 builds the table, 0016 adds `catalog_id`. They are
// separate because 0015 was already applied, and an edit there would never re-run — which
// is exactly the drift this test exists to catch. `CATALOG_SCHEMA_DDL` declares the end
// state in one CREATE, so it is compared against the pair.
const migrations = `${catalogStructureSql}\n${catalogIdSql}`
// Compared on identifiers rather than text, since the two are formatted differently, and
// `catalog_id` arrives via ALTER rather than inside the CREATE.
for (const column of CATALOG_INSERT_COLUMNS) {
expect(migrations, column).toContain(column)
expect(CATALOG_SCHEMA_DDL[0], column).toContain(`\t\t${column} `)
}
expect(catalogStructureSql).toContain('item_key TEXT PRIMARY KEY')
expect(CATALOG_SCHEMA_DDL[0]).toContain('item_key TEXT PRIMARY KEY')
expect(catalogIdSql).toContain('ALTER TABLE catalog ADD COLUMN catalog_id INTEGER')
for (const index of [
'idx_catalog_name',
'idx_catalog_prefab',
'idx_catalog_tag',
'idx_catalog_id',
]) {
expect(migrations, index).toContain(index)
expect(CATALOG_SCHEMA_DDL.join('\n'), index).toContain(index)
}
// STRUCTURE ONLY. The catalog's contents change as the game's item list does, which is not
// a schema change — rows here would mean a migration and a deploy per refresh. If this
// fails, someone put data back into a migration instead of reloading it.
expect(migrations).not.toContain('INSERT INTO catalog')
expect(migrations).not.toContain('DELETE FROM catalog')
})
test('the loader maps both captures onto the columns it declares', async () => {
const { rows, collisions } = buildCatalogLoad(avatarItemsJson, skinsJson)
// Every row carries exactly one value per declared column, in that order — the loader
// renders them positionally, so a column added to one side and not the other is a silent
// mis-load rather than an error.
expect(rows.every((r) => r.values.length === CATALOG_INSERT_COLUMNS.length)).toBe(true)
const keyAt = CATALOG_INSERT_COLUMNS.indexOf('item_key')
const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
expect(rows.every((r) => r.values[keyAt] === r.key)).toBe(true)
// One key space, both kinds, no collisions between them.
expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length)
const kinds = rows.map((r) => r.values[kindAt])
expect(kinds.filter((k) => k === 'avatar_item')).toHaveLength(avatarItemsJson.length)
// Skins are the one place the counts may legitimately differ: the capture holds five guids
// twice. A repeat is a defect rather than something the table models, so the loader keeps
// the first and RETURNS the rest for the caller to report — dropping them silently is the
// exact failure the single key exists to prevent.
const distinctSkinKeys = new Set(skinsJson.map((s) => s.ModificationGuid)).size
expect(kinds.filter((k) => k === 'skin')).toHaveLength(distinctSkinKeys)
expect(collisions).toHaveLength(skinsJson.length - distinctSkinKeys)
expect(collisions.every((c) => c.kept !== c.dropped)).toBe(true)
// And the rows really do go in: the same table these tests built accepts a sample of the
// real load unchanged, so a capture that would be rejected in production fails here. Rows
// this file already seeded are skipped — they are real capture rows too, and re-inserting
// one would trip the key constraint on the seed rather than on anything under test.
const sample: CatalogLoadRow[] = []
for (const row of [rows[0], rows[1], rows[rows.length - 2], rows[rows.length - 1]]) {
if (row && (await getCatalogItem(env.DB, row.key)) === null) sample.push(row)
}
expect(sample.length).toBeGreaterThan(0)
for (const row of sample) {
await env.DB.prepare(
`INSERT INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
)
.bind(...row.values.map((v) => v ?? null))
.run()
expect((await getCatalogItem(env.DB, row.key))?.friendly_name).toBe(
row.values[CATALOG_INSERT_COLUMNS.indexOf('friendly_name')]
)
await env.DB.prepare('DELETE FROM catalog WHERE item_key = ?1').bind(row.key).run()
}
// The row the capture had a skin pasted over. It is an avatar item, and the skin that
// overwrote its name lives in skins.json where it belongs.
expect(avatarItemsJson.filter((i) => i.FriendlyName === 'Disc (Coop)')).toEqual([])
expect(skinsJson.filter((s) => s.FriendlyName === 'Disc (Coop)')).toHaveLength(1)
})
test('catalog_id is a contiguous, unique, load-order handle from 10000', async () => {
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
// BASE..BASE+N-1 with no gaps, in capture order — avatar items first, then skins. Numbered
// AFTER de-duplication, so a dropped duplicate must not burn a number and leave a hole.
//
// From 10000 rather than 1 because a generated storefront lists a row under this very
// number as its `PurchasableItemId`, and every captured storefront's ids are 2764 or below
// — numbering from 1 would have made one id mean two different items.
expect(rows.map((r) => r.id)).toEqual(rows.map((_, i) => CATALOG_ID_BASE + i))
expect(Math.min(...rows.map((r) => r.id))).toBe(CATALOG_ID_BASE)
expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length)
// The id in the row object and the id in the values it renders are the same number — the
// loader binds `values` positionally, so a mismatch would write one and report the other.
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
expect(rows.every((r) => r.values[idAt] === r.id)).toBe(true)
// It reads back by number, and the number is NOT the item's identity: `item_key` is. A
// caller that stored an id across a load would resolve to a different item or to nothing,
// which is why nothing may persist it.
const seeded = await getCatalogItemById(env.DB, 900_001)
expect(seeded?.item_key).toBe('_OWVy3z6iU-M3-zbQgSLig,,,')
expect(await getCatalogItemById(env.DB, 12_345_678)).toBeNull()
// Unique where set. Two rows may not share a handle — a number that names two items is
// useless as a handle.
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES ('id-collision-probe', 900001, 'skin', 'Impostor', 0, -1)`
).run()
).rejects.toThrow()
// But NULL is allowed any number of times: the index is partial, because a row is
// un-numbered in the window between existing and a load numbering it, and the loader
// clears every id before handing out new ones so a merge cannot collide with stale ones.
for (const key of ['unnumbered-a', 'unnumbered-b']) {
await env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask)
VALUES (?1, 'skin', 'Not Yet Numbered', 0, -1)`
)
.bind(key)
.run()
}
expect((await getCatalogItem(env.DB, 'unnumbered-a'))?.catalog_id).toBeNull()
expect((await getCatalogItem(env.DB, 'unnumbered-b'))?.catalog_id).toBeNull()
await env.DB.prepare("DELETE FROM catalog WHERE item_key LIKE 'unnumbered-%'").run()
})
// `runx catalog load` MERGES by default so a partial capture can add a few items without
// wiping the rest, and REPLACES only when told to. Both halves of that live in the CLI's SQL,
// so this exercises the upsert itself — the CLI's own statement, built from the same column
// list, against the same schema.
//
// MUST STAY LAST in this block: the replace half empties the table, including the rows the
// other catalog tests are seeded with.
test('a merge inserts, refreshes and preserves; a replace removes', async () => {
const columns = CATALOG_INSERT_COLUMNS.join(', ')
const binds = CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')
// Every column but the conflict target, derived from the column list exactly as the CLI
// derives it.
const conflictUpdate = CATALOG_INSERT_COLUMNS.filter((c) => c !== 'item_key')
.map((c) => `${c} = excluded.${c}`)
.join(', ')
/** The CLI's statement: a full-width insert that upserts on the key. */
const upsert = (values: CatalogValue[]) =>
env.DB.prepare(
`INSERT INTO catalog (${columns}) VALUES (${binds})
ON CONFLICT(item_key) DO UPDATE SET ${conflictUpdate}`
)
.bind(...values.map((v) => v ?? null))
.run()
/** A skin row in column order, so a column added to the table lands here too. */
const skinValues = (key: string, name: string, rarity: number): CatalogValue[] =>
CATALOG_INSERT_COLUMNS.map((c) =>
c === 'item_key'
? key
: c === 'kind'
? CatalogKind.Skin
: c === 'friendly_name'
? name
: c === 'rarity'
? rarity
: c === 'platform_mask'
? -1
: c === 'prefab_name'
? '[MakerPen]'
: null
)
// A row nothing in a later load will mention — the one that proves a merge is not a wipe.
await upsert(skinValues('untouched-by-any-load', 'Hand-Added Sentinel', 0))
// Insert: a key the table has never seen.
await upsert(skinValues('merge-test-new', 'Freshly Datamined', 7))
expect((await getCatalogItem(env.DB, 'merge-test-new'))?.friendly_name).toBe(
'Freshly Datamined'
)
// Refresh: the SAME key again with different values updates in place rather than either
// erroring on the key or piling up a second row.
const before = await countCatalog(env.DB)
await upsert(skinValues('merge-test-new', 'Renamed By Refresh', 42))
const refreshed = await getCatalogItem(env.DB, 'merge-test-new')
expect(refreshed?.friendly_name).toBe('Renamed By Refresh')
expect(refreshed?.rarity).toBe(42)
expect(await countCatalog(env.DB)).toEqual(before)
// Preserve: neither of those touched the sentinel. This is the whole point of the default
// — a capture holding two items must not delete the other three thousand.
expect((await getCatalogItem(env.DB, 'untouched-by-any-load'))?.friendly_name).toBe(
'Hand-Added Sentinel'
)
// Every column but the key is carried by the refresh. Derived rather than written out, so
// a column added to the table and forgotten would silently stop being merged.
for (const column of CATALOG_INSERT_COLUMNS) {
expect(conflictUpdate.includes(`${column} = excluded.${column}`), column).toBe(
column !== 'item_key'
)
}
// Replace: `DELETE FROM catalog` first, and the sentinel goes with everything else. That is
// why it is opt-in — pointed at a partial capture it removes whatever the file omits.
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
await env.DB.prepare('DELETE FROM catalog').run()
await upsert((rows[0] as CatalogLoadRow).values)
expect(await getCatalogItem(env.DB, 'untouched-by-any-load')).toBeNull()
expect(await getCatalogItem(env.DB, 'merge-test-new')).toBeNull()
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 })
})
})