mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[econ] implement catalog
This commit is contained in:
@@ -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]))
|
||||
}
|
||||
@@ -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
@@ -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 storefront’s 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 caller’s 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)
|
||||
|
||||
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
@@ -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 storefront’s 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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user