import { CatalogKind } from './catalog-load' import type { CatalogKindValue } from './catalog-load' /** * The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and * equipment skin the game knows about, one row each, loaded by a CLI 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. * * ALL 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: the served 2025 general store (`static/storefronts/sf3-2025.json`) * and `static/db/skins.json`, loaded by `runx catalog load`. A listed row's `catalog_id` is * its store `PurchasableItemId`. * * 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, listingKind, type CatalogCollision, type CatalogKindValue, type CatalogLoad, type CatalogLoadRow, type CatalogValue, type SkinCapture, type StoreListing, type StorefrontDump, } 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. For a * row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json` * sells it under — and for a skin the store never listed it is handed out by the loader from * `CATALOG_ID_BASE`, well above any real one. 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. For a listed item it is * the store's `PurchasableItemId`, so the number resolves a row here and a listing in * `sf3-2025.json` alike. * * Still not an identity: an unlisted skin's number is a load-order surrogate, and a store * could renumber. Never store it, 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. * * `CreatedAt` and `ThumbnailImage` are OPTIONAL, never null: the capture this record mirrors * carried a date on most rows and simply had no key on the rest — the permanent hair dyes * among them — and never `null`. A row loaded from a store listing has no date at all, and * serving it as `"CreatedAt": null` on every record (as `lockeditems/bulk` did once the * loader moved to the store) broke the dorm mirror's hair dyes: the client resolves locked * hair colours through that route, and a null where it expects a date costs it the whole * response, not the field. Leave the key out, as the capture did. */ 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 ThumbnailImage?: string } /** * 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, // Absent rather than null when the row has none — see the record's note. ...(row.created_at !== null ? { CreatedAt: row.created_at } : {}), ...(row.thumbnail_image !== null ? { 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 * `,,,`. 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 { return await db .prepare('SELECT * FROM catalog WHERE item_key = ?1') .bind(itemKey) .first() } /** * One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item. * * Fine for a request that looked the number up moments ago; never for anything stored, since * an unlisted skin's number is reassigned by every load. */ export async function getCatalogItemById( db: D1Database, catalogId: number ): Promise { return await db .prepare('SELECT * FROM catalog WHERE catalog_id = ?1') .bind(catalogId) .first() } /** * 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 { 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() 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 { 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 { 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 { const { results } = await db .prepare('SELECT * FROM catalog WHERE prefab_name = ?1 ORDER BY friendly_name') .bind(prefabName) .all() 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 { 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() return results } /** Every avatar item carrying a seasonal tag (`halloween`, `music`, …), by name. */ export async function getAvatarItemsByTag( db: D1Database, tag: string ): Promise { const { results } = await db .prepare('SELECT * FROM catalog WHERE tag_list = ?1 ORDER BY friendly_name') .bind(tag) .all() 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> { 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])) }