Files
recflare/apps/econ/src/catalog-db.ts
T
2026-09-24 13:55:47 -04:00

358 lines
14 KiB
TypeScript

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.
*/
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 — 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<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]))
}