[econ] implement catalog

This commit is contained in:
Devin Zuczek
2026-08-27 22:43:22 -04:00
parent 5e138a0646
commit a169cf3e6e
17 changed files with 214338 additions and 18 deletions
+954 -3
View File
@@ -23,6 +23,17 @@ import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventio
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types'
// The catalog's two migrations and the captures the loader reads, imported so the tests at the
// bottom can check the schema they build against `CATALOG_SCHEMA_DDL`. `?raw` because they are
// SQL, not modules: they are never executed here, only read.
import catalogStructureSql from '../../../migrations/0015_catalog.sql?raw'
import catalogIdSql from '../../../migrations/0016_catalog_id.sql?raw'
import avatarItemsJson from '../../../static/db/avatar-items.json'
import skinsJson from '../../../static/db/skins.json'
import sf32025 from '../../../static/storefronts/sf3-2025.json'
// The merged 2025 general store, read as a FILE: which file the route serves depends on the
// caller's build, and these assertions are about the file's CONTENTS.
import sf3 from '../../../static/storefronts/sf3.json'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -31,6 +42,24 @@ import {
getBalance,
spendCurrency,
} from '../../balance-db'
import {
baseAsset,
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CATALOG_SCHEMA_DDL,
CatalogKind,
countCatalog,
getAvatarItem,
getAvatarItemsByTag,
getCatalogItem,
getCatalogItemById,
getCatalogItems,
getSkin,
getSkinsForPrefab,
searchCatalog,
toCatalogSkin,
} from '../../catalog-db'
import { CATALOG_ID_BASE } from '../../catalog-load'
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
// The live weekly rotation, generated the same way the worker generates it, so the challenge
// tests exercise whatever this week actually holds instead of ids from a rotation that has
@@ -41,8 +70,20 @@ import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
import type { Env } from '../../context'
/**
* The half of the merged store that came from the item CATALOG — nothing in the file marks
* which half a row is from, so it is whatever sf3 does not already contain.
*
* NOT `id >= CATALOG_ID_BASE`, which looks equivalent and is not: sf3 carries one id far above
* that range (20756767), so an id test counts it as a catalog row and every count comes out one
* too high. Membership in sf3's own ids is the question actually being asked.
*/
const sf3Ids = new Set(sf3.StoreItems.map((i) => i.PurchasableItemId))
const catalogItems = () => sf32025.StoreItems.filter((i) => !sf3Ids.has(i.PurchasableItemId))
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
@@ -74,6 +115,7 @@ beforeAll(async () => {
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
@@ -176,10 +218,16 @@ function b64url(input: ArrayBuffer | string): string {
* account's flags — pass `['gameClient', 'developer']` for an elevated account; the default
* is no claim at all, which reads as no roles.
*/
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
async function bearer(
sub = '42',
roles?: string[],
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
version?: string
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const claims =
roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles }
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
if (roles !== undefined) claims.role = roles
if (version !== undefined) claims['rn.ver'] = version
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(claims)
)}`
@@ -737,6 +785,234 @@ describe('econ endpoints', () => {
expect(anon.status).toBe(401)
})
test('bulkpurchase resolves catalog ids for newer builds, at the storefronts price', async () => {
// A catalog row the generated storefront would list at 600 (rarity 10) and a skin the
// storefront lists nowhere at all — both bought straight off the `catalog` table.
const AVATAR_ID = 20_001
const SKIN_ID = 20_002
const DEV_ID = 20_003
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
VALUES ('bulk-buy-desc,,,', ?1, 'avatar_item', 'Bulk Buy Hat', '', 10, -1, 0)`
)
.bind(AVATAR_ID)
.run()
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name)
VALUES ('bulk-buy-guid', ?1, 'skin', 'Bulk Buy Skin', '', 0, -1, '[MakerPen]')`
)
.bind(SKIN_ID)
.run()
// Rarity -1 is the developer tier: in the catalog, absent from the storefront, and so not
// for sale here either — resolving straight off the table must not sell what the store
// never offered.
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
VALUES ('bulk-buy-dev,,,', ?1, 'avatar_item', 'Bulk Buy Dev Item', '', -1, -1, 0)`
)
.bind(DEV_ID)
.run()
const buy = async (
version: string | undefined,
lines: Array<{ id: number; price: number }>,
sub = '46'
) =>
exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer(sub, undefined, version)) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
StorefrontType: 3,
CurrencyType: 2,
AllowPartialSuccess: false,
PurchaseItemRequests: lines.map((l) => ({
ItemPurchaseMethodId: { Type: 0, NumberId: l.id, Guid: null },
RequestedPrice: l.price,
})),
}),
})
// 150 (rarity 0) and 600 (rarity 10) are the generated storefront's own prices. They MUST
// match: `priceCheck` refuses a line whose posted price differs, so a server pricing a buy
// differently from the file it listed would 409 every purchase.
const res = await buy('20250718.01', [
{ id: AVATAR_ID, price: 600 },
{ id: SKIN_ID, price: 150 },
])
expect(res.status).toBe(200)
const body = (await res.json()) as { Success: boolean; Value: { Balance: number } | null }
expect(body.Success).toBe(true)
// A price the storefront does not list is refused, not quietly charged.
const wrongPrice = await buy('20250718.01', [{ id: AVATAR_ID, price: 1 }])
expect(((await wrongPrice.json()) as { Success: boolean }).Success).toBe(false)
// The developer-tier row is not for sale.
const dev = await buy('20250718.01', [{ id: DEV_ID, price: 150 }])
expect(((await dev.json()) as { Success: boolean }).Success).toBe(false)
// An OLD build is left resolving exactly what it always did — the storefront file — so a
// catalog id means nothing to it. Nothing offers those ids to that build anyway.
const legacy = await buy('20230414', [{ id: AVATAR_ID, price: 600 }])
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
const unversioned = await buy(undefined, [{ id: AVATAR_ID, price: 600 }])
expect(((await unversioned.json()) as { Success: boolean }).Success).toBe(false)
// The two id spaces do not overlap, so a bag may MIX a captured storefront's item with a
// catalog row. Item 73 is sf3's "Bowtie (White)" at 450.
const mixed = await buy('20250718.01', [
{ id: 73, price: 450 },
{ id: SKIN_ID, price: 150 },
])
expect(((await mixed.json()) as { Success: boolean }).Success).toBe(true)
// Cleaned up: the `catalog` block below counts every row in the table, so rows left behind
// here would change what it sees.
await env.DB.prepare('DELETE FROM catalog WHERE catalog_id BETWEEN ?1 AND ?2')
.bind(AVATAR_ID, DEV_ID)
.run()
})
test('POST /api/avatar/v1/lockeditems/bulk returns the catalogue as a bare array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
AvatarItemDescriptions: [
'c70005d5-6276-4a98-acb3-6a77bc19379a,OBcu3bf1NEio_7t9smqGag',
'4eaaad39-aa44-4791-8b67-2eafa8305850',
],
}),
})
expect(res.status).toBe(200)
// A BARE ARRAY, no envelope.
const body = (await res.json()) as Array<{
AvatarItemType: number
AvatarItemDesc: string
FriendlyName: string
Rarity: number
}>
expect(Array.isArray(body)).toBe(true)
// TEST STUB: the whole bundled catalogue, whatever was posted. Pinned so the day this
// grows real lock logic, the change is visible here rather than silently altering what a
// client is told is locked.
expect(body).toHaveLength(avatarItemsJson.length)
expect(body[0]).toMatchObject({
AvatarItemDesc: avatarItemsJson[0]!.AvatarItemDesc,
FriendlyName: avatarItemsJson[0]!.FriendlyName,
})
// Every entry carries the shape the client reads, including the four fields the
// `defaultunlocked` catalogue leaves out.
for (const key of [
'AvatarItemType',
'AvatarItemDesc',
'FriendlyName',
'Tooltip',
'Rarity',
'AvatarItemId',
'IsBaseAvatarItem',
'ThumbnailImage',
'CreatedAt',
]) {
expect(Object.keys(body[0] as object), key).toContain(key)
}
// Nothing is echoed: the posted descs are not read, so what comes back is unrelated to
// what was asked for. A real implementation answers one entry per posted desc.
expect(body.map((i) => i.AvatarItemDesc)).not.toEqual([
'c70005d5-6276-4a98-acb3-6a77bc19379a,OBcu3bf1NEio_7t9smqGag',
'4eaaad39-aa44-4791-8b67-2eafa8305850',
])
// No auth needed, and a body it cannot read is not an error — the answer does not depend
// on either.
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
})
expect(anon.status).toBe(200)
expect(((await anon.json()) as unknown[]).length).toBe(avatarItemsJson.length)
})
test('POST /api/items/purchaseInfos prices custom avatar items in tokens', async () => {
const item = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 206,
name: 'Chrome Jacket',
description: '',
price: 425,
baseAvatarItemId: 1,
baseAvatarItemColor: '#000',
designFilename: 'design_pi.bin',
thumbnailImageFilename: 'thumb_pi.png',
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
Ids: [
{ itemType: 3, itemId: item.CustomAvatarItemId },
// Dropped, both of them: an id nothing owns, and a type this doesn't serve. The
// response is one entry per RESOLVED id, so it is SHORTER than `Ids` rather than
// carrying a null in their places — the client must not read it positionally.
{ itemType: 3, itemId: crypto.randomUUID() },
{ itemType: 1, itemId: item.CustomAvatarItemId },
],
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
// The reference echoed back verbatim: camelCase members under a PascalCase key. The
// client names them that way on both legs and PascalCasing them here loses the id.
ItemId: { itemType: 3, itemId: item.CustomAvatarItemId },
// A UGC listing is keyed by guid, so `Type` 1 and `NumberId` null. A storefront's
// numbered `PurchasableItemId` would be the other side of the union.
PurchaseMethodId: { Type: 1, NumberId: null, Guid: item.CustomAvatarItemId },
// RecCenterTokens (2) — the currency the creation UI's price floor is denominated
// in, and the one the client actually holds a balance in. A room currency (300)
// would draw a price nothing can pay.
Prices: [
{
CurrencyType: 2,
Price: 425,
StorefrontSaleData: { SalePercent: 0, SaleStartDate: null, SaleEndDate: null },
},
],
NewUntil: null,
AvailableAt: item.CreatedAt,
AvailableUntil: null,
CanBeGifted: true,
CanApplySubscriberDiscount: false,
SubscribersOnly: false,
IsFeatured: false,
},
])
})
test('POST /api/items/purchaseInfos 400s without Ids and 401s without a token', async () => {
const bad = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({}),
})
expect(bad.status).toBe(400)
const anon = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ Ids: [] }),
})
expect(anon.status).toBe(401)
})
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
expect(anon.status).toBe(401)
@@ -919,6 +1195,162 @@ describe('econ endpoints', () => {
expect(await res.json()).toBeTruthy()
})
// TEMPORARY, alongside the probe in `econ.app.ts`: the storefront ids are swapped so it can
// be seen from the client which one the 2025 store actually reads. Delete this with the
// probe.
test('storefront 3 serves sf3 to old builds and the merged sf3-2025 to newer ones', async () => {
const store = async (headers: Record<string, string>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/3`, {
headers,
})
expect(res.status).toBe(200)
return (await res.json()) as { StorefrontType: number; StoreItems: unknown[] }
}
const at = async (version: string) =>
(await bearer('42', undefined, version)) as Record<string, string>
// 20230414 is GAME_VERSION — what the rest of the stack targets — so the cutoff is
// INCLUSIVE and that build keeps the captured sf3 exactly as it has always had it.
const legacy = await store(await at('20230414'))
expect(legacy.StoreItems).toHaveLength(sf3.StoreItems.length)
// A same-day rebuild sorts by its DATE, not the `.NN` suffix.
expect((await store(await at('20230414.02'))).StoreItems).toHaveLength(sf3.StoreItems.length)
// A caller with no readable build gets the captured file too: an unversioned token is the
// OLD client, so treating "can't prove its version" as "newer" would swap the store out
// from under the build that needs it.
expect((await store({})).StoreItems).toHaveLength(sf3.StoreItems.length)
expect((await store(await bearer())).StoreItems).toHaveLength(sf3.StoreItems.length)
expect((await store(await at('not-a-build'))).StoreItems).toHaveLength(sf3.StoreItems.length)
// Later builds get the merged store — bigger than either half, and still storefront 3.
for (const version of ['20230616', '20250424.01', '20250718.01']) {
const merged = await store(await at(version))
expect(merged.StorefrontType, version).toBe(3)
expect(merged.StoreItems.length, version).toBe(sf32025.StoreItems.length)
expect(merged.StoreItems.length, version).toBeGreaterThan(sf3.StoreItems.length)
}
// The id does not change and nothing is renumbered: sf3's own items are in the merged file
// unchanged, so a newer client buying one is charged the same as an older client would be.
const bowtie = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73, // sf3's "Bowtie (White)", 450 tokens
CurrencyType: 2,
RequestedPrice: 450,
}),
})
expect(bowtie.status).toBe(200)
// And a CATALOG item can be bought from storefront 3 by a newer build — the half a
// listing-only swap breaks. `findStoreItem` resolves the purchase through the same
// build-aware path the listing does, so an item on the page is an item that can be bought.
// From the catalog half — see `catalogItems`, which is why this is not an id comparison.
const catalogItem = catalogItems()[0]
expect(catalogItem).toBeDefined()
const bought = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: catalogItem!.PurchasableItemId,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
}),
})
expect(bought.status).toBe(200)
// The SAME item is not for sale to an old build, because its store does not list it.
const refused = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20230414')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: catalogItem!.PurchasableItemId,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
}),
})
expect(refused.status).toBe(404)
})
test('sf3-2025 merges sf3 with the catalog, priced by rarity', async () => {
// The general store as the 2025 client sees it. Asserted against the FILE rather than the
// endpoint, because which file the route serves depends on the caller's build and this is
// about the file's contents.
//
// It reports storefront 3, not an id of its own: the client asks for 3 either way and
// neither half knows there are two files.
expect(sf32025.StorefrontType).toBe(3)
// sf3's own items are carried through UNCHANGED — the merge adds to the store the older
// client knows rather than restating it.
const base = new Map(sf3.StoreItems.map((i) => [i.PurchasableItemId, i]))
expect(sf32025.StoreItems.length).toBe(sf3.StoreItems.length + catalogItems().length)
for (const [id, item] of base) {
expect(
sf32025.StoreItems.find((i) => i.PurchasableItemId === id),
String(id)
).toEqual(item)
}
// The subscriber discount is expressed ONLY in `SubscriberPrices`. The top-level
// `SubscriberDiscountPercent` stays 0 so a client cannot take the 10% a second time off an
// already-discounted price and post 19% off, which falls through the server's own
// subscriber floor and is refused as "Price has changed".
expect(sf32025.SubscriberDiscountPercent).toBe(0)
const priceByRarity = new Map([
[0, 150],
[10, 600],
[20, 700],
[30, 800],
[50, 3000],
])
// Rarity -1 is the developer/unreleased tier and is EXCLUDED rather than priced. An item
// listed here can be bought — `findStoreItem` resolves a purchase against this very file —
// so leaving them in at any price would put them on sale.
expect(catalogItems().filter((i) => i.GiftDrop.Rarity === -1)).toEqual([])
for (const item of catalogItems()) {
const expected = priceByRarity.get(item.GiftDrop.Rarity)
expect(expected, `rarity ${item.GiftDrop.Rarity}`).toBeDefined()
expect(item.Prices[0]).toMatchObject({ CurrencyType: 2, Price: expected })
// Floored, matching the server's own `subscriberFloor`. Every tier here divides evenly.
expect(item.SubscriberPrices[0]).toMatchObject({
CurrencyType: 2,
Price: Math.floor((expected! * 90) / 100),
})
}
// Every item is an avatar item with a real desc, and its id is in the GENERATED range,
// echoed in `GiftDropId` the way sf3 does on all 1161 of its own items. Ids must be unique
// or a purchase resolves the wrong row.
const ids = catalogItems().map((i) => i.PurchasableItemId)
expect(new Set(ids).size).toBe(ids.length)
// The id IS the `catalog_id` — one number, no second numbering and no arithmetic between
// them. Catalog ids start at 10000 precisely so this is safe: every captured storefront's
// own ids are 2764 or below, and numbering from 1 would have collided with sf3's head-on,
// making one id mean two different items depending on which storefront it came from.
expect(Math.min(...ids)).toBe(CATALOG_ID_BASE)
expect(ids.every((id) => id >= CATALOG_ID_BASE)).toBe(true)
expect(catalogItems().every((i) => i.GiftDrop.GiftDropId === i.PurchasableItemId)).toBe(true)
expect(catalogItems().every((i) => i.GiftDrop.AvatarItemDesc.length > 0)).toBe(true)
// An id with neither a capture nor an alias still 404s.
const missing = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/1705`)
expect(missing.status).toBe(404)
// 1704 is gone: it was a stand-in for a store that turned out to belong inside sf3, so
// nothing answers that id any more.
const gone = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/1704`)
expect(gone.status).toBe(404)
})
// Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2).
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
@@ -3238,6 +3670,7 @@ describe('econ endpoints', () => {
'GET /econ/roomOffer/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
@@ -3249,6 +3682,7 @@ describe('econ endpoints', () => {
'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request',
'POST /api/items/bulkpurchase',
'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
@@ -3263,3 +3697,520 @@ describe('econ endpoints', () => {
}
})
})
// The item catalog. Loaded by migration, not written at runtime, so these exercise the SHAPE of
// the table and its query helpers against a handful of hand-seeded rows; the drift test at the
// end is what pins the thousands of real ones.
describe('catalog', () => {
// One row of each kind, plus the cases that decided the schema: an avatar_item_id shared by
// two rows and absent from a third, and keys that are alpha strings rather than GUIDs.
beforeAll(async () => {
const insert = (row: unknown[]) =>
env.DB.prepare(
`INSERT INTO catalog (
item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask,
thumbnail_image, avatar_item_type, avatar_item_id, is_base_avatar_item, tag_list,
created_at, prefab_name, unlocked_level
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)`
)
.bind(...row)
.run()
// `catalog_id` is handed out by the loader as 1..N over the captures. These seeds number
// themselves the same way but from a base far above N, so a test that inserts a REAL
// capture row (which carries its own low id) cannot collide with a seed — the constraint
// under test should only ever fire on something the test meant to collide.
let nextId = 900_001
/** An avatar item: `item_key` is its `AvatarItemDesc`, the skin columns stay null. */
const avatarItem = (
desc: string,
name: string,
tooltip: string | null,
rarity: number,
type: number,
id: number | null,
tag: string | null,
createdAt: string | null,
thumb: string | null
) =>
insert([
desc,
nextId++,
'avatar_item',
name,
tooltip,
rarity,
-1,
thumb,
type,
id,
0,
tag,
createdAt,
null,
null,
])
/** A skin: `item_key` is its `ModificationGuid`, the avatar columns stay null. */
const skin = (guid: string, name: string, tooltip: string | null, prefab: string) =>
insert([
guid,
nextId++,
'skin',
name,
tooltip,
0,
-1,
'',
null,
null,
null,
null,
null,
prefab,
0,
])
await avatarItem(
'_OWVy3z6iU-M3-zbQgSLig,,,',
'Vampire Hunter Gloves (Blue)',
// NULL, not '' — the two are different values in the capture and the client's DTO serves
// the difference through.
null,
10,
0,
835,
null,
'2018-11-01T17:51:50.733Z',
'cimomkml6k4toyowd1voh7hqm.png'
)
await avatarItem(
'002a0f2f-1a24-4439-b578-470818ef8325,,,',
'Turkey Sweater',
'',
50,
0,
1570,
'thanksgiving',
'2020-10-28T00:37:27.263Z',
'4g2r02n1g5w09re7hl1ba2yyi.png'
)
// These two share avatar_item_id 9503 — the reason that column keys nothing.
await avatarItem(
'c5010738-41fa-4eca-aeac-e24adaa29789,',
'Helmet Hair',
'',
-1,
0,
9503,
null,
null,
null
)
await avatarItem(
'60067e91-18b8-43ab-ae20-a8ea74c757bf,KUAMuM41hk-YLZoqTiKncA',
'Green Cheer Sash',
'',
-1,
0,
9503,
null,
null,
null
)
// A hair dye: AvatarItemType 1, NO avatar_item_id at all, and a desc that is a bare alpha
// string with no commas. Still perfectly keyed.
await avatarItem(
'pQNfh-3DsEGWfiIls6Qf6g',
'Permanent Hair Dye (Pirate Gold)',
'',
0,
1,
null,
null,
null,
null
)
await skin('19ef59c7-f74b-4c63-935a-1d4b1abd8518', 'Disc (Coop)', '', '[DiscGolfDisc]')
// An alpha-string key, from before the game moved to GUIDs.
await skin('bfrFOdnHzEaIwHqem2dXkg', 'Confetti Gun (Gold)', null, '[PaintballGun] Confetti')
})
test('an avatar item reads back by its desc, nullable fields intact', async () => {
const item = await getAvatarItem(env.DB, '_OWVy3z6iU-M3-zbQgSLig,,,')
expect(item).toEqual({
AvatarItemDesc: '_OWVy3z6iU-M3-zbQgSLig,,,',
AvatarItemType: 0,
PlatformMask: -1,
FriendlyName: 'Vampire Hunter Gloves (Blue)',
// The one the column may never be defaulted to '' for.
Tooltip: null,
Rarity: 10,
TagList: null,
AvatarItemId: 835,
IsBaseAvatarItem: false,
CreatedAt: '2018-11-01T17:51:50.733Z',
ThumbnailImage: 'cimomkml6k4toyowd1voh7hqm.png',
})
expect(await getAvatarItem(env.DB, 'nothing-has-this-desc')).toBeNull()
})
test('a skin reads back by its guid, and an alpha-string key is just as good', async () => {
expect(await getSkin(env.DB, '19ef59c7-f74b-4c63-935a-1d4b1abd8518')).toEqual({
PrefabName: '[DiscGolfDisc]',
ModificationGuid: '19ef59c7-f74b-4c63-935a-1d4b1abd8518',
UnlockedLevel: 0,
// The catalog does not store this: it is a PLAYER's flag, and the capture recorded one
// account's. It is overwritten from the player's own `equipment` row.
Favorited: false,
PlatformMask: -1,
FriendlyName: 'Disc (Coop)',
Tooltip: '',
Rarity: 0,
ThumbnailImage: '',
})
// 191 of the skins are keyed by the short alpha-string ids the game used before GUIDs. The
// column is TEXT and compared as text, so these need no special handling — which is exactly
// why nothing here parses or validates a key's shape.
const gold = await getSkin(env.DB, 'bfrFOdnHzEaIwHqem2dXkg')
expect(gold?.FriendlyName).toBe('Confetti Gun (Gold)')
// Skins carry NULL tooltips too, so the projection must not flatten them to ''.
expect(gold?.Tooltip).toBeNull()
expect((await getSkinsForPrefab(env.DB, '[DiscGolfDisc]')).map((s) => s.FriendlyName)).toEqual([
'Disc (Coop)',
])
expect(await getSkin(env.DB, 'no-such-guid')).toBeNull()
})
test('one key spans both kinds, and asking for the wrong kind gets null, not a mangled row', async () => {
// The lookup the inventory wants: a player's owned things are ids of exactly this shape and
// the row says which kind each turned out to be. No join, no guessing.
const owned = await getCatalogItems(env.DB, [
'19ef59c7-f74b-4c63-935a-1d4b1abd8518',
'_OWVy3z6iU-M3-zbQgSLig,,,',
// Unknown keys are SKIPPED rather than left as holes, so the result is shorter than the
// input and must never be read positionally.
'nothing-owns-this',
])
expect(owned.map((r) => [r.kind, r.friendly_name])).toEqual([
['skin', 'Disc (Coop)'],
['avatar_item', 'Vampire Hunter Gloves (Blue)'],
])
expect(await getCatalogItems(env.DB, [])).toEqual([])
// A key is unique across BOTH kinds, so a typed accessor handed the other kind's key answers
// null rather than projecting a skin into an avatar item's shape.
expect(await getAvatarItem(env.DB, '19ef59c7-f74b-4c63-935a-1d4b1abd8518')).toBeNull()
expect(await getSkin(env.DB, '_OWVy3z6iU-M3-zbQgSLig,,,')).toBeNull()
expect(() => toCatalogSkin({ ...(owned[1] as CatalogRow) })).toThrow(/not a skin/)
})
test('avatar_item_id is carried as data and keys nothing', async () => {
// Two seeded rows share 9503 (five real ones do), and the hair dye has no id at all. Keying
// the table on it would have silently dropped four of those five at load time, which is why
// it is stored, not indexed, and never looked up by.
const shared = await searchCatalog(env.DB, CatalogKind.AvatarItem, 'a')
expect(shared.filter((r) => r.avatar_item_id === 9503)).toHaveLength(2)
const dye = await getAvatarItem(env.DB, 'pQNfh-3DsEGWfiIls6Qf6g')
expect(dye?.AvatarItemId).toBeNull()
expect(dye?.AvatarItemType).toBe(1)
})
test('search is case-insensitive, scoped to one kind, and takes wildcards literally', async () => {
const hits = await searchCatalog(env.DB, CatalogKind.AvatarItem, 'HAIR')
expect(hits.map((r) => r.friendly_name)).toEqual([
'Helmet Hair',
'Permanent Hair Dye (Pirate Gold)',
])
// `kind` scopes it: the same needle against skins finds nothing, so a skin search can never
// surface a wearable.
expect(await searchCatalog(env.DB, CatalogKind.Skin, 'hair')).toEqual([])
expect(
(await searchCatalog(env.DB, CatalogKind.Skin, 'disc')).map((r) => r.prefab_name)
).toEqual(['[DiscGolfDisc]'])
// A needle of LIKE metacharacters matches them literally rather than everything — the escape
// is what stops a player typing `%` from pulling the whole catalog back.
expect(await searchCatalog(env.DB, CatalogKind.AvatarItem, '%')).toEqual([])
expect(await searchCatalog(env.DB, CatalogKind.AvatarItem, '_')).toEqual([])
// A hit that really does contain the character still matches, so escaping didn't break it.
expect((await searchCatalog(env.DB, CatalogKind.AvatarItem, 'cheer')).length).toBe(1)
})
test('seasonal rows come back by tag, and counts are per kind', async () => {
expect((await getAvatarItemsByTag(env.DB, 'thanksgiving')).map((i) => i.FriendlyName)).toEqual([
'Turkey Sweater',
])
expect(await getAvatarItemsByTag(env.DB, 'halloween')).toEqual([])
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 5, skin: 2 })
})
test('the key is unique across both kinds, so a collision is refused', async () => {
// A second avatar item with the same desc...
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask)
VALUES ('_OWVy3z6iU-M3-zbQgSLig,,,', 'avatar_item', 'Impostor', 0, -1)`
).run()
).rejects.toThrow()
// ...a second skin with the same guid...
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES ('bfrFOdnHzEaIwHqem2dXkg', 'skin', 'Impostor', 0, -1, '[PaintballGun]')`
).run()
).rejects.toThrow()
// ...and a skin claiming an avatar item's key. The kinds share ONE key space, which is what
// lets an owned id be resolved without first knowing what kind of thing it is.
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES ('_OWVy3z6iU-M3-zbQgSLig,,,', 'skin', 'Impostor', 0, -1, '[MakerPen]')`
).run()
).rejects.toThrow()
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 5, skin: 2 })
})
test('baseAsset takes the first field of an AvatarItemDesc', async () => {
// `<baseAsset>,<color>,<texture>,` — the base asset is what decides whether the client can
// draw an item at all, so it is read off the key rather than stored twice.
expect(baseAsset('_OWVy3z6iU-M3-zbQgSLig,,,')).toBe('_OWVy3z6iU-M3-zbQgSLig')
expect(baseAsset('60067e91-18b8-43ab-ae20-a8ea74c757bf,KUAMuM41hk-YLZoqTiKncA')).toBe(
'60067e91-18b8-43ab-ae20-a8ea74c757bf'
)
// A dye's desc is a bare alpha string with no commas at all; it is still the base asset.
expect(baseAsset('pQNfh-3DsEGWfiIls6Qf6g')).toBe('pQNfh-3DsEGWfiIls6Qf6g')
})
// The migration builds the TABLE; `runx catalog load` fills it. These two are the seam
// between them: the schema the tests build must be the schema the migration builds, and the
// loader must produce rows that schema accepts.
test('the migrations and CATALOG_SCHEMA_DDL build the same table', async () => {
// Both migrations together: 0015 builds the table, 0016 adds `catalog_id`. They are
// separate because 0015 was already applied, and an edit there would never re-run — which
// is exactly the drift this test exists to catch. `CATALOG_SCHEMA_DDL` declares the end
// state in one CREATE, so it is compared against the pair.
const migrations = `${catalogStructureSql}\n${catalogIdSql}`
// Compared on identifiers rather than text, since the two are formatted differently, and
// `catalog_id` arrives via ALTER rather than inside the CREATE.
for (const column of CATALOG_INSERT_COLUMNS) {
expect(migrations, column).toContain(column)
expect(CATALOG_SCHEMA_DDL[0], column).toContain(`\t\t${column} `)
}
expect(catalogStructureSql).toContain('item_key TEXT PRIMARY KEY')
expect(CATALOG_SCHEMA_DDL[0]).toContain('item_key TEXT PRIMARY KEY')
expect(catalogIdSql).toContain('ALTER TABLE catalog ADD COLUMN catalog_id INTEGER')
for (const index of [
'idx_catalog_name',
'idx_catalog_prefab',
'idx_catalog_tag',
'idx_catalog_id',
]) {
expect(migrations, index).toContain(index)
expect(CATALOG_SCHEMA_DDL.join('\n'), index).toContain(index)
}
// STRUCTURE ONLY. The catalog's contents change as the game's item list does, which is not
// a schema change — rows here would mean a migration and a deploy per refresh. If this
// fails, someone put data back into a migration instead of reloading it.
expect(migrations).not.toContain('INSERT INTO catalog')
expect(migrations).not.toContain('DELETE FROM catalog')
})
test('the loader maps both captures onto the columns it declares', async () => {
const { rows, collisions } = buildCatalogLoad(avatarItemsJson, skinsJson)
// Every row carries exactly one value per declared column, in that order — the loader
// renders them positionally, so a column added to one side and not the other is a silent
// mis-load rather than an error.
expect(rows.every((r) => r.values.length === CATALOG_INSERT_COLUMNS.length)).toBe(true)
const keyAt = CATALOG_INSERT_COLUMNS.indexOf('item_key')
const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
expect(rows.every((r) => r.values[keyAt] === r.key)).toBe(true)
// One key space, both kinds, no collisions between them.
expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length)
const kinds = rows.map((r) => r.values[kindAt])
expect(kinds.filter((k) => k === 'avatar_item')).toHaveLength(avatarItemsJson.length)
// Skins are the one place the counts may legitimately differ: the capture holds five guids
// twice. A repeat is a defect rather than something the table models, so the loader keeps
// the first and RETURNS the rest for the caller to report — dropping them silently is the
// exact failure the single key exists to prevent.
const distinctSkinKeys = new Set(skinsJson.map((s) => s.ModificationGuid)).size
expect(kinds.filter((k) => k === 'skin')).toHaveLength(distinctSkinKeys)
expect(collisions).toHaveLength(skinsJson.length - distinctSkinKeys)
expect(collisions.every((c) => c.kept !== c.dropped)).toBe(true)
// And the rows really do go in: the same table these tests built accepts a sample of the
// real load unchanged, so a capture that would be rejected in production fails here. Rows
// this file already seeded are skipped — they are real capture rows too, and re-inserting
// one would trip the key constraint on the seed rather than on anything under test.
const sample: CatalogLoadRow[] = []
for (const row of [rows[0], rows[1], rows[rows.length - 2], rows[rows.length - 1]]) {
if (row && (await getCatalogItem(env.DB, row.key)) === null) sample.push(row)
}
expect(sample.length).toBeGreaterThan(0)
for (const row of sample) {
await env.DB.prepare(
`INSERT INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
)
.bind(...row.values.map((v) => v ?? null))
.run()
expect((await getCatalogItem(env.DB, row.key))?.friendly_name).toBe(
row.values[CATALOG_INSERT_COLUMNS.indexOf('friendly_name')]
)
await env.DB.prepare('DELETE FROM catalog WHERE item_key = ?1').bind(row.key).run()
}
// The row the capture had a skin pasted over. It is an avatar item, and the skin that
// overwrote its name lives in skins.json where it belongs.
expect(avatarItemsJson.filter((i) => i.FriendlyName === 'Disc (Coop)')).toEqual([])
expect(skinsJson.filter((s) => s.FriendlyName === 'Disc (Coop)')).toHaveLength(1)
})
test('catalog_id is a contiguous, unique, load-order handle from 10000', async () => {
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
// BASE..BASE+N-1 with no gaps, in capture order — avatar items first, then skins. Numbered
// AFTER de-duplication, so a dropped duplicate must not burn a number and leave a hole.
//
// From 10000 rather than 1 because a generated storefront lists a row under this very
// number as its `PurchasableItemId`, and every captured storefront's ids are 2764 or below
// — numbering from 1 would have made one id mean two different items.
expect(rows.map((r) => r.id)).toEqual(rows.map((_, i) => CATALOG_ID_BASE + i))
expect(Math.min(...rows.map((r) => r.id))).toBe(CATALOG_ID_BASE)
expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length)
// The id in the row object and the id in the values it renders are the same number — the
// loader binds `values` positionally, so a mismatch would write one and report the other.
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
expect(rows.every((r) => r.values[idAt] === r.id)).toBe(true)
// It reads back by number, and the number is NOT the item's identity: `item_key` is. A
// caller that stored an id across a load would resolve to a different item or to nothing,
// which is why nothing may persist it.
const seeded = await getCatalogItemById(env.DB, 900_001)
expect(seeded?.item_key).toBe('_OWVy3z6iU-M3-zbQgSLig,,,')
expect(await getCatalogItemById(env.DB, 12_345_678)).toBeNull()
// Unique where set. Two rows may not share a handle — a number that names two items is
// useless as a handle.
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES ('id-collision-probe', 900001, 'skin', 'Impostor', 0, -1)`
).run()
).rejects.toThrow()
// But NULL is allowed any number of times: the index is partial, because a row is
// un-numbered in the window between existing and a load numbering it, and the loader
// clears every id before handing out new ones so a merge cannot collide with stale ones.
for (const key of ['unnumbered-a', 'unnumbered-b']) {
await env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask)
VALUES (?1, 'skin', 'Not Yet Numbered', 0, -1)`
)
.bind(key)
.run()
}
expect((await getCatalogItem(env.DB, 'unnumbered-a'))?.catalog_id).toBeNull()
expect((await getCatalogItem(env.DB, 'unnumbered-b'))?.catalog_id).toBeNull()
await env.DB.prepare("DELETE FROM catalog WHERE item_key LIKE 'unnumbered-%'").run()
})
// `runx catalog load` MERGES by default so a partial capture can add a few items without
// wiping the rest, and REPLACES only when told to. Both halves of that live in the CLI's SQL,
// so this exercises the upsert itself — the CLI's own statement, built from the same column
// list, against the same schema.
//
// MUST STAY LAST in this block: the replace half empties the table, including the rows the
// other catalog tests are seeded with.
test('a merge inserts, refreshes and preserves; a replace removes', async () => {
const columns = CATALOG_INSERT_COLUMNS.join(', ')
const binds = CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')
// Every column but the conflict target, derived from the column list exactly as the CLI
// derives it.
const conflictUpdate = CATALOG_INSERT_COLUMNS.filter((c) => c !== 'item_key')
.map((c) => `${c} = excluded.${c}`)
.join(', ')
/** The CLI's statement: a full-width insert that upserts on the key. */
const upsert = (values: CatalogValue[]) =>
env.DB.prepare(
`INSERT INTO catalog (${columns}) VALUES (${binds})
ON CONFLICT(item_key) DO UPDATE SET ${conflictUpdate}`
)
.bind(...values.map((v) => v ?? null))
.run()
/** A skin row in column order, so a column added to the table lands here too. */
const skinValues = (key: string, name: string, rarity: number): CatalogValue[] =>
CATALOG_INSERT_COLUMNS.map((c) =>
c === 'item_key'
? key
: c === 'kind'
? CatalogKind.Skin
: c === 'friendly_name'
? name
: c === 'rarity'
? rarity
: c === 'platform_mask'
? -1
: c === 'prefab_name'
? '[MakerPen]'
: null
)
// A row nothing in a later load will mention — the one that proves a merge is not a wipe.
await upsert(skinValues('untouched-by-any-load', 'Hand-Added Sentinel', 0))
// Insert: a key the table has never seen.
await upsert(skinValues('merge-test-new', 'Freshly Datamined', 7))
expect((await getCatalogItem(env.DB, 'merge-test-new'))?.friendly_name).toBe(
'Freshly Datamined'
)
// Refresh: the SAME key again with different values updates in place rather than either
// erroring on the key or piling up a second row.
const before = await countCatalog(env.DB)
await upsert(skinValues('merge-test-new', 'Renamed By Refresh', 42))
const refreshed = await getCatalogItem(env.DB, 'merge-test-new')
expect(refreshed?.friendly_name).toBe('Renamed By Refresh')
expect(refreshed?.rarity).toBe(42)
expect(await countCatalog(env.DB)).toEqual(before)
// Preserve: neither of those touched the sentinel. This is the whole point of the default
// — a capture holding two items must not delete the other three thousand.
expect((await getCatalogItem(env.DB, 'untouched-by-any-load'))?.friendly_name).toBe(
'Hand-Added Sentinel'
)
// Every column but the key is carried by the refresh. Derived rather than written out, so
// a column added to the table and forgotten would silently stop being merged.
for (const column of CATALOG_INSERT_COLUMNS) {
expect(conflictUpdate.includes(`${column} = excluded.${column}`), column).toBe(
column !== 'item_key'
)
}
// Replace: `DELETE FROM catalog` first, and the sentinel goes with everything else. That is
// why it is opt-in — pointed at a partial capture it removes whatever the file omits.
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
await env.DB.prepare('DELETE FROM catalog').run()
await upsert((rows[0] as CatalogLoadRow).values)
expect(await getCatalogItem(env.DB, 'untouched-by-any-load')).toBeNull()
expect(await getCatalogItem(env.DB, 'merge-test-new')).toBeNull()
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 })
})
})