mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 07:28:53 -07:00
358 lines
14 KiB
TypeScript
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]))
|
|
}
|