[econ] skins and consumables

This commit is contained in:
Devin Zuczek
2026-09-24 13:55:47 -04:00
parent 5013c5d4e6
commit 13b80f9748
12 changed files with 202173 additions and 159452 deletions
+26 -23
View File
@@ -3,9 +3,9 @@ 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
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
* equipment skin the game knows about, one row each, loaded by a CLI rather than written at
* runtime. Nothing here is per-player: ownership lives in `inventory` and
* `equipment`, and this table only says what a thing IS.
*
* It exists to be QUERIED. The catalogs were previously reachable only by parsing a whole
@@ -14,15 +14,16 @@ import type { CatalogKindValue } from './catalog-load'
* 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
* ALL kinds share one table because they share most of a record — a display name, a tooltip, a
* rarity, a platform mask, a thumbnail — and because the interesting queries ("what is called
* X", "what is this thing the player owns") run across both. `kind` discriminates, and the
* columns each kind alone carries are nullable and empty on the other. Read a row through
* {@link toCatalogAvatarItem}/{@link toCatalogSkin} rather than serving it raw: the client's
* two DTOs share no key order and differ in what they omit.
*
* Where the rows come from: `static/db/avatar-items.json` and `static/db/skins.json`, both
* captured from the reference and loaded by `runx catalog load`.
* Where the rows come from: the served 2025 general store (`static/storefronts/sf3-2025.json`)
* and `static/db/skins.json`, loaded by `runx catalog load`. A listed row's `catalog_id` is
* its store `PurchasableItemId`.
*
* The migration builds the TABLE ONLY — it holds no rows. The catalog changes as the game's
* item list changes, and that is not a schema change: a migration per refresh would mean a
@@ -42,12 +43,15 @@ export {
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CatalogKind,
type AvatarItemCapture,
listingKind,
type CatalogCollision,
type CatalogKindValue,
type CatalogLoad,
type CatalogLoadRow,
type CatalogValue,
type SkinCapture,
type StoreListing,
type StorefrontDump,
} from './catalog-load'
/**
@@ -66,12 +70,11 @@ export {
* 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.
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
* sells it under — and for a skin the store never listed it is handed out by the loader from
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
* adds it is 0016, since 0015 was already applied.
*
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
@@ -113,13 +116,14 @@ 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.
* number rather than as a comma-laden `AvatarItemDesc` or a guid. For a listed item it is
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
* `sf3-2025.json` alike.
*
* 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.
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
* and it is what the inventory holds. NULL only between a row existing and a load numbering
* it.
*/
catalog_id: number | null
kind: string
@@ -244,11 +248,10 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
}
/**
* One catalog row by its numeric handle.
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
*
* 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.
* Fine for a request that looked the number up moments ago; never for anything stored, since
* an unlisted skin's number is reassigned by every load.
*/
export async function getCatalogItemById(
db: D1Database,
+358 -137
View File
@@ -10,6 +10,15 @@
* 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.
*
* WHERE THE ROWS COME FROM. The 2025 general store as this worker serves it, and nothing else:
* `static/storefronts/sf3-2025.json`, the live game's storefront 3 dumped
* (`static/db/Watch_EnumValue_3.json`) and written out by `runx storefront build` with its
* captured sale stripped and the skins the store never listed (`static/db/skins.json`)
* appended as listings of their own. Loading the served file is what makes a row here and a
* listing there one record: same asset id, same number, always. An earlier `avatar-items.json`
* capture used to be the source and a storefront was generated FROM the table with invented
* ids; the dump made both unnecessary.
*/
/** What a catalog row IS — the discriminator, and what says which id `item_key` holds. */
@@ -18,34 +27,78 @@ export const CatalogKind = {
AvatarItem: 'avatar_item',
/** An equipment skin: a re-skin of a held prefab. Its `item_key` is the `ModificationGuid`. */
Skin: 'skin',
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
Consumable: 'consumable',
} 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, copied
* in from the 2025 storefront dump (`static/db/Watch_EnumValue_3.json`), carry no `CreatedAt`
* because a store listing records none — 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
/** One price on a store listing. `StorefrontSaleData` is a discount the dump captured live. */
export interface StoreListingPrice {
CurrencyType: number
Price: number
StorefrontSaleData: {
SalePercent: number
SaleStartDate: string | null
SaleEndDate: string | null
} | null
}
/** The capture's skin record (`static/db/skins.json`). Every field is present on every row. */
/**
* A store listing's `GiftDrop` as the 2025 dump records it — the client's own `GiftDrop` class.
* Only the members the loader reads are typed; the rest ride along untouched.
*
* Exactly ONE of the four item ids is set on a listing that carries an item: `AvatarItemDesc`,
* `ConsumableItemDesc`, `EquipmentModificationGuid` or `CustomAvatarItemId`. A query box
* (`IsQuery`) and a token bundle (`Currency` > 0) carry none.
*/
export interface StoreListingGiftDrop {
GiftDropId: number
FriendlyName: string
Tooltip: string | null
TagList: string | null
ConsumableItemDesc: string
AvatarItemDesc: string
CustomAvatarItemId: string | null
AvatarItemType: number | null
EquipmentPrefabName: string
EquipmentModificationGuid: string
IsQuery: boolean
Rarity: number
Currency: number
CurrencyType: number
AvatarItemId: number
ThumbnailImageName: string | null
[extra: string]: unknown
}
/** One listing of the store dump — a `StoreItems[]` entry, as the client reads it. */
export interface StoreListing {
PurchasableItemId: number
GiftDrop: StoreListingGiftDrop
Prices: StoreListingPrice[]
SubscriberPrices: StoreListingPrice[] | null
[extra: string]: unknown
}
/** One storefront page — the dump (`static/db/Watch_EnumValue_3.json`) or the served file. */
export interface StorefrontDump {
StoreItems: StoreListing[]
NextUpdate: string
StorefrontType: number
SubscriberDiscountPercent: number
[extra: string]: unknown
}
/**
* The capture's skin record (`static/db/skins.json`). Every field is present on every row.
*
* A thin capture: every `Rarity` is 0 or 1, every `UnlockedLevel` 0, every `ThumbnailImage`
* empty, and where a guid is also in the store the store's name and rarity are the real ones
* and disagree with these on every row. So it is read by `runx storefront build` ALONE, for
* the skins the store does not list — see {@link unlistedSkinListing} — and the loader never
* sees it.
*/
export interface SkinCapture {
PrefabName: string
ModificationGuid: string
@@ -59,16 +112,146 @@ export interface SkinCapture {
}
/**
* The first `catalog_id` a load hands out.
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
*
* 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.
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
* consumable listed twice, and the catalog is of things, not of listings.
*/
export const CATALOG_ID_BASE = 10_000
export type ListingKind =
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
/** Classify one listing by which of its item ids is set. */
export function listingKind(listing: StoreListing): ListingKind {
const drop = listing.GiftDrop
if (drop.AvatarItemDesc !== '') return CatalogKind.AvatarItem
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
return 'custom_avatar_item'
}
if (drop.IsQuery) return 'query'
if (drop.Currency > 0) return 'currency'
return 'unknown'
}
/**
* The dump's listings with its verbatim repeats collapsed — twenty listings appear twice, byte
* for byte. One copy is kept, in first position; a repeated id whose copies DIFFER is refused,
* since order would then decide what the id means. The storefront file is written from this,
* and the catalog load reads that file, so neither sees a repeat as a duplicate item.
*/
export function collapseRepeatedListings(listings: StoreListing[]): {
listings: StoreListing[]
repeats: number
} {
const byId = new Map<number, string>()
const kept: StoreListing[] = []
let repeats = 0
for (const listing of listings) {
const json = JSON.stringify(listing)
const first = byId.get(listing.PurchasableItemId)
if (first === undefined) {
byId.set(listing.PurchasableItemId, json)
kept.push(listing)
} else if (first === json) {
repeats++
} else {
throw new Error(
`PurchasableItemId ${listing.PurchasableItemId} is listed twice with different contents`
)
}
}
return { listings: kept, repeats }
}
/**
* The first `PurchasableItemId` given to a skin the store does NOT list.
*
* Every other number in `sf3-2025.json` is the game's own. The skins the 2025 store never sold
* still have to be listed — the weekly gift pool and the discovery rows draw skins from the
* store, and a `catalog` row IS a listing — so they are appended with numbers of their own,
* from here, in `skins.json` order. The store's ids top out below 40 000; a million is clear of
* anything a fresher dump could bring.
*/
export const UNLISTED_SKIN_ID_BASE = 1_000_000
/** The rarity an unlisted skin is sold at: the top tier, where the store puts most of its own. */
export const UNLISTED_SKIN_RARITY = 50
/**
* What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the
* capture. A prestige price, and the same for a subscriber: no discount, no sale.
*/
export const GOLD_SKIN_PRICE = 100_000
/** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */
export const isGoldSkin = (listing: StoreListing): boolean =>
listing.GiftDrop.EquipmentModificationGuid !== '' &&
listing.GiftDrop.FriendlyName.includes('(Gold)')
/** RecCenterTokens — the currency the store sells in. */
const CURRENCY_TYPE_TOKENS = 2
/**
* A skin the store does not list, AS a listing — the dump's own skin shape, so the client reads
* it like the rest. Rarity {@link UNLISTED_SKIN_RARITY} and priced from it: the store's own
* rarity-50 skins run 1000-6000 tokens, and {@link PRICE_BY_RARITY} puts this in the middle.
* The capture's name and tooltip. `ThumbnailImageName` is `""` — as it is on EVERY skin the
* served store lists, the game's own included (see `runx storefront build`).
*/
export function unlistedSkinListing(skin: SkinCapture, id: number): StoreListing {
const price = priceForRarity(UNLISTED_SKIN_RARITY)
const priced = (p: number): StoreListingPrice => ({
CurrencyType: CURRENCY_TYPE_TOKENS,
Price: p,
StorefrontSaleData: null,
})
return {
GiftDrop: {
GiftDropId: id,
FriendlyName: skin.FriendlyName,
Tooltip: skin.Tooltip ?? '',
TagList: '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
CustomAvatarItemId: null,
AvatarItemType: null,
EquipmentPrefabName: skin.PrefabName,
EquipmentModificationGuid: skin.ModificationGuid,
IsQuery: false,
QueryRedirectContext: null,
QueryRedirectTag: null,
QueryRedirectRarity: null,
Unique: false,
SubscribersOnly: false,
Rarity: UNLISTED_SKIN_RARITY,
Context: 1003,
Currency: 0,
CurrencyType: 0,
ItemCount: 1,
ItemSetId: null,
ItemSetFriendlyName: '',
AvatarItemId: 0,
EquipmentItemId: 0,
ThumbnailImageName: '',
AvatarItemInfo: null,
},
PurchasableItemId: id,
Type: 0,
Prices: [priced(price)],
SubscriberPrices: [priced(subscriberPriceFor(price))],
IsFeatured: false,
NewUntil: null,
AvailableAt: null,
AvailableUntil: null,
CanBeGifted: true,
OnlyAvailableThroughCv2: false,
}
}
/** The columns a load writes, in the order {@link toCatalogInsertRow} returns values. */
export const CATALOG_INSERT_COLUMNS = [
@@ -95,7 +278,7 @@ 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`. */
/** Its `catalog_id`: the listing's `PurchasableItemId`. */
id: number
label: string
values: CatalogValue[]
@@ -108,126 +291,185 @@ export interface CatalogCollision {
dropped: string
}
function avatarItemRow(i: AvatarItemCapture): Omit<CatalogLoadRow, 'id'> {
/** What {@link buildCatalogLoad} produced, and what it left out. */
export interface CatalogLoad {
rows: CatalogLoadRow[]
collisions: CatalogCollision[]
/** Listings that load as nothing, counted by {@link ListingKind}. */
skipped: Partial<Record<ListingKind, number>>
}
/**
* The store records no platform mask — every listing is for every platform, which is what the
* `platform_mask` column's `-1` default means.
*/
const ALL_PLATFORMS = -1
/**
* A listed avatar item. `is_base_avatar_item` and `created_at` are NULL because a store listing
* records neither; a NULL there is "not recorded", which is true.
*/
function avatarItemRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
return {
key: i.AvatarItemDesc,
label: `${i.FriendlyName} (avatar item)`,
key: drop.AvatarItemDesc,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (avatar item)`,
values: [
i.AvatarItemDesc,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
drop.AvatarItemDesc,
listing.PurchasableItemId,
CatalogKind.AvatarItem,
i.FriendlyName,
i.Tooltip,
i.Rarity,
i.PlatformMask,
i.ThumbnailImage,
i.AvatarItemType,
i.AvatarItemId,
i.IsBaseAvatarItem ?? false,
i.TagList,
i.CreatedAt,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
drop.AvatarItemType ?? 0,
// 0 is the dump's "none"; the column's is NULL.
drop.AvatarItemId || null,
null,
drop.TagList,
null,
null,
null,
],
}
}
function skinRow(s: SkinCapture): Omit<CatalogLoadRow, 'id'> {
/** A listed consumable: the shared columns only, keyed by its `ConsumableItemDesc`. */
function consumableRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
return {
key: s.ModificationGuid,
label: `${s.FriendlyName} (skin, ${s.PrefabName})`,
key: drop.ConsumableItemDesc,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (consumable)`,
values: [
s.ModificationGuid,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
drop.ConsumableItemDesc,
listing.PurchasableItemId,
CatalogKind.Consumable,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
null,
null,
null,
drop.TagList,
null,
null,
null,
],
}
}
/** A listed skin: what the listing carries. */
function listedSkinRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
return {
key: drop.EquipmentModificationGuid,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (skin, ${drop.EquipmentPrefabName})`,
values: [
drop.EquipmentModificationGuid,
listing.PurchasableItemId,
CatalogKind.Skin,
s.FriendlyName,
s.Tooltip,
s.Rarity,
s.PlatformMask,
s.ThumbnailImage,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
null,
null,
null,
null,
null,
s.PrefabName,
s.UnlockedLevel,
drop.EquipmentPrefabName,
null,
],
}
}
/**
* Turn both captures into the rows a load writes, de-duplicated on `item_key`.
* Turn the served store 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
* `item_key` is unique across ALL kinds, so a repeat is a defect in the source 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.
* quietly is the exact failure the single key exists to prevent. (The dump's verbatim repeats
* are collapsed by {@link collapseRepeatedListings} before the served file is written, so they
* never get here; a consumable's PACK is counted, not reported — see {@link ListingKind}.)
*
* 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.
* Rows come out in STORE ORDER, one per listing that sells a catalog item, and `catalog_id` is
* the listing's `PurchasableItemId` — a real, stable number, the one the client buys by. A
* store id naming two different keys is refused outright: it cannot be resolved by order, and
* nothing in the dump does it.
*/
export function buildCatalogLoad(
avatarItems: AvatarItemCapture[],
skins: SkinCapture[]
): { rows: CatalogLoadRow[]; collisions: CatalogCollision[] } {
export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
const seen = new Map<string, string>()
const idOwner = new Map<number, 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 skipped: Partial<Record<ListingKind, number>> = {}
/** Keep the row, or report it as a repeat of one already kept. */
const push = (row: CatalogLoadRow): void => {
const kept = seen.get(row.key)
if (kept !== undefined) {
collisions.push({ key: row.key, kept, dropped: row.label })
continue
return
}
const owner = idOwner.get(row.id)
if (owner !== undefined) {
throw new Error(`catalog_id ${row.id} names two items: ${owner} and ${row.label}`)
}
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 })
idOwner.set(row.id, row.label)
rows.push(row)
}
return { rows, collisions }
for (const listing of listings) {
const kind = listingKind(listing)
switch (kind) {
case CatalogKind.AvatarItem:
push(avatarItemRow(listing))
break
case CatalogKind.Consumable:
// The pack of a consumable already listed singly: one thing, one row.
if (seen.has(listing.GiftDrop.ConsumableItemDesc)) {
skipped.consumable_pack = (skipped.consumable_pack ?? 0) + 1
break
}
push(consumableRow(listing))
break
case CatalogKind.Skin:
push(listedSkinRow(listing))
break
default:
skipped[kind] = (skipped[kind] ?? 0) + 1
}
}
return { rows, collisions, skipped }
}
/**
* Rarities that are NOT sold, and so never appear in a generated storefront.
* Rarities that mark the developer/unreleased tier.
*
* `-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.
* `-1`. The 2025 store lists a handful of such items and this server serves that store
* verbatim, so they CAN be bought; the tier is kept out of the discovery rows the `lists`
* worker draws from the catalog, which is the one place this is still read.
*/
export const UNSELLABLE_RARITIES: readonly number[] = [-1]
/** Whether an item of this rarity may appear in a storefront. */
/** Whether an item of this rarity is outside the developer tier. */
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.
* What an item costs by rarity, in RecCenterTokens — for the things the store dump does not
* price: the skins it never listed ({@link unlistedSkinListing}) and a first-party custom
* avatar item it does not list (`apps/api/scripts/price-custom-avatar-items.ts`). Everything
* the dump lists carries its real price, verbatim.
*/
export const PRICE_BY_RARITY: Record<number, number> = {
0: 150,
@@ -238,52 +480,31 @@ export const PRICE_BY_RARITY: Record<number, number> = {
}
/**
* 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.
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free. A
* floor rather than a skip because an unpriced item silently vanishing is harder to notice
* than one that turns up cheap.
*/
export const DEFAULT_PRICE = 150
/** What one catalog row costs, in RecCenterTokens. */
/** What one item of this rarity 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.
* What Rec Room Plus takes off, in percent — the same number the econ worker'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`. */
/** The subscriber price for a regular one. Floored, matching the worker's `subscriberFloor`. */
export const subscriberPriceFor = (regular: number): number =>
Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
/**
* The last client build treated as the 2023-era one, as a build number and as the ISO date the
* item catalogue records `CreatedAt` in.
* The last client build served the 2023 general store.
*
* One constant in two forms because two things key off the same moment: the econ worker decides
* which storefront FILE a caller is served by comparing their token's `rn.ver` to the number,
* and the storefront generator decides which ITEMS go in the 2023 file by comparing each item's
* `CreatedAt` to the date. They must not drift — a build served the old store but a store built
* to a different date would sell items that build has never heard of.
* The econ worker compares a caller's token `rn.ver` to this to pick which storefront FILE
* they get for storefront 3: `sf3.json` (the 2023 store, frozen) up to and including this
* build, `sf3-2025.json` (the 2025 store dump) after it.
*/
export const LEGACY_CLIENT_BUILD = 20_230_414
/** {@link LEGACY_CLIENT_BUILD} as `YYYY-MM-DD`, for comparing against a `CreatedAt`. */
export const LEGACY_CLIENT_BUILD_DATE = '2023-04-14'
/**
* Whether an item existed by the time of {@link LEGACY_CLIENT_BUILD} — i.e. whether the 2023
* store should sell it.
*
* An item with NO `CreatedAt` is treated as too new and left out: three catalogue rows carry
* none, and there is no way to show they predate the cutoff. Excluding is the conservative
* direction — the 2023 store missing three items nobody noticed is better than it offering
* something that build cannot render.
*/
export const existedByLegacyBuild = (createdAt: string | null | undefined): boolean =>
typeof createdAt === 'string' && createdAt.slice(0, 10) < LEGACY_CLIENT_BUILD_DATE
+58 -130
View File
@@ -39,7 +39,6 @@ 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'
@@ -56,18 +55,10 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import { getCatalogItem } from './catalog-db'
import { getCatalogItem, toCatalogAvatarItem } from './catalog-db'
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
// which store FILE a caller is served here, and which ITEMS go in that file there. The two must
// name the same moment or a build gets a store built to a different cutoff.
import {
CATALOG_ID_BASE,
CatalogKind,
isSellableRarity,
LEGACY_CLIENT_BUILD,
priceForRarity,
subscriberPriceFor,
} from './catalog-load'
// which store FILE a caller is served here, and is what the generator's help names.
import { CatalogKind, LEGACY_CLIENT_BUILD } from './catalog-load'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import {
@@ -861,8 +852,8 @@ interface GiftRequest {
*/
const STOREFRONT_ALIASES: Record<string, string> = {
// Empty. 1704 was here for a while, standing in for a 2025 gift-drop storefront nobody had
// captured; the items it was meant to sell turned out to belong in the general store, so
// they are in `sf3-2025.json` and served as storefront 3 — see {@link STOREFRONT_BY_BUILD}.
// captured; the 2025 general store has since been dumped and is served as storefront 3 —
// see {@link STOREFRONT_BY_BUILD}.
// That is a per-BUILD variant of one storefront rather than an alias between two ids, which
// is why nothing is listed here.
}
@@ -871,16 +862,17 @@ const STOREFRONT_ALIASES: Record<string, string> = {
* 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, and BOTH files are generated from the item catalog by
* `runx storefront build` — the same store at two points in time. `sf3.json` holds what existed
* by {@link LEGACY_CLIENT_BUILD}; `sf3-2025.json` holds everything. 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 between them.
* `3` is the general store at two points in time. `sf3.json` is the 2023 store, frozen;
* `sf3-2025.json` is the real 2025 store — a dump of the live game's storefront 3, written by
* `runx storefront build` with its captured sale stripped (see that command). 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. The two files number their items differently — each is
* the store its build actually had — and nothing crosses between them.
*
* 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".
* the whole reason it is not done at the route: a newer client shown the 2025 store and then
* charged against the 2023 one would have every purchase 404 as "no such storefront".
*/
const STOREFRONT_BY_BUILD: Record<string, string> = {
'3': 'sf3-2025',
@@ -909,8 +901,8 @@ function storefrontAssetPath(id: string, build: number | null): string {
* 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 the merged
* sf3-2025 is four, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
* storefront reads (and parses) it once: sf3 alone is over two thousand items and sf3-2025
* over three, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
*/
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
const build = await authedBuild(c)
@@ -1317,10 +1309,12 @@ const ROLL_STOREFRONT_TYPE = 3
* Every item a roll or a weekly gift may draw, or `[]` if it can't be read (a roll then yields
* nothing).
*
* The storefront PLUS every equipment skin, which no storefront sells: skins are awarded from
* weekly challenges rather than bought, so they were taken out of sf3 — and the weekly gift pool
* is exactly the equipment in this list, which would otherwise be empty. They come from the
* `catalog` table, whose skins are the same rows `static/db/skins.json` holds.
* The storefront PLUS every equipment skin the storefront does not sell. The 2025 store lists
* every skin (`runx storefront build` appends the ones the game's own store never sold), the
* 2023 store none — and the weekly gift pool is exactly the equipment in this list, which for
* an older build would otherwise be empty. The rest come from the `catalog` table, which is
* loaded from the 2025 store; one already listed is not added again, or a roll would weight it
* double.
*
* Being in this list does NOT make an item purchasable. `findStoreItem` and the bulk bag resolve
* a purchase against the storefront file, never against this.
@@ -1332,7 +1326,9 @@ async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
)
.bind(CatalogKind.Skin)
.all<CatalogRow>()
return [...(storefront?.StoreItems ?? []), ...results.map(toSkinStoreItem)]
const listed = storefront?.StoreItems ?? []
const sold = new Set(listed.map((item) => item.GiftDrop.EquipmentModificationGuid))
return [...listed, ...results.filter((row) => !sold.has(row.item_key)).map(toSkinStoreItem)]
}
/**
@@ -1380,10 +1376,11 @@ const WEEKLY_GIFT_EXCLUDED_PREFABS = ['[Sandbox_']
* equipment — the captured rotation's is a camera skin — and that guid is exactly what marks an
* entry as equipment.
*
* The pool comes from the catalog's SKINS now rather than from sf3, which no longer sells
* equipment at all: skins are awarded here, not bought. See {@link loadRollCatalog}.
* The pool is the roll catalog's equipment — the skins the caller's store sells plus the
* catalog's, see {@link loadRollCatalog} — so a week can be themed on a skin the 2023 store
* never sold.
*
* `GiftDropId` comes off `PurchasableItemId`, which for a skin is its `catalog_id`.
* `GiftDropId` comes off `PurchasableItemId`, which is a skin's `catalog_id` as well.
*/
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
return catalog
@@ -1610,6 +1607,12 @@ async function grantGiftDrop(
) {
await grantEquipment(db, accountId, toEquipment(giftDrop))
}
// A first-party custom avatar item: the 2025 store lists five hundred of them, each a plain
// numbered listing whose drop names the item by guid. Ownership is the same row a bag's
// guid-keyed line writes; without this the line charges and the box opens on nothing.
if (typeof giftDrop.CustomAvatarItemId === 'string' && giftDrop.CustomAvatarItemId !== '') {
await grantCustomAvatarItem(db, accountId, giftDrop.CustomAvatarItemId)
}
const isConsumable =
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT * (options.copies ?? 1) : 0
@@ -1880,69 +1883,6 @@ 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 (`sf3-2025.json`) 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.
*
* Mostly redundant now that the merged store carries every sellable AVATAR ITEM — a newer
* build's bag resolves those straight out of the file. What it still reaches that the file does
* not is SKINS, which the generator leaves out, 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 &&
row.kind === CatalogKind.AvatarItem &&
isSellableRarity(row.rarity)
)
.map((row) => {
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 `AvatarItemDesc` for an avatar item — that is what makes it the
// key. The equipment fields stay empty: only avatar items reach here.
AvatarItemDesc: row.item_key,
AvatarItemType: row.avatar_item_type ?? 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
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
@@ -2733,8 +2673,8 @@ const app = new Hono<App>({ strict: false })
tags: ['Avatar'],
summary: 'Locked avatar items in bulk',
description: [
'Resolves `AvatarItemDescriptions` against the bundled item catalogue and answers the',
'matching records as a bare array. The match is on the WHOLE `AvatarItemDesc`, so a',
'Resolves `AvatarItemDescriptions` against the item catalogue (the `catalog` table) and',
'answers the matching records as a bare array. The match is on the WHOLE `AvatarItemDesc`, so a',
'colourway is not found by its base asset alone.',
'An empty or absent list answers the WHOLE catalogue, which is the reference’s own',
'behaviour rather than a degenerate empty match.',
@@ -2754,12 +2694,22 @@ const app = new Hono<App>({ strict: false })
const requested = Array.isArray(body?.AvatarItemDescriptions)
? body.AvatarItemDescriptions.filter((d): d is string => typeof d === 'string')
: []
if (requested.length === 0) return c.json(avatarItemCatalog)
// The whole kind from the `catalog` table, in LOAD order — which is store order, the
// closest thing to the reference's "catalogue order" there is — then filtered here.
// One statement whatever was asked: the client posts hundreds of descs at a time, and
// D1 binds at most a hundred parameters, so an `IN (...)` of the request cannot be.
const { results } = await c.env.DB.prepare(
'SELECT * FROM catalog WHERE kind = ?1 ORDER BY rowid'
)
.bind(CatalogKind.AvatarItem)
.all<CatalogRow>()
const catalogue = results.map(toCatalogAvatarItem)
if (requested.length === 0) return c.json(catalogue)
// A Set rather than `Array.includes` per item: the client posts hundreds of descs
// against a catalogue of thousands, and the reference's nested scan is quadratic.
// A Set rather than `Array.includes` per item: the reference's nested scan is
// quadratic.
const wanted = new Set(requested)
return c.json(avatarItemCatalog.filter((item) => wanted.has(item.AvatarItemDesc)))
return c.json(catalogue.filter((item) => wanted.has(item.AvatarItemDesc)))
}
)
@@ -4496,10 +4446,10 @@ const app = new Hono<App>({ strict: false })
'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.',
'also depend on the caller’s build (`rn.ver`): storefront `3` serves the 2023',
'`sf3.json` to builds up to 20230414 and the 2025 store dump `sf3-2025.json` 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: [
{
@@ -4771,32 +4721,10 @@ const app = new Hono<App>({ strict: false })
const allowPartial = body.AllowPartialSuccess === true
const skipGiftBox = body.BypassGiftPackages === true
// 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] }
// One catalog read for the bag; every line resolves against it in memory. The file is
// the whole catalog: the ids the discovery rows hand out (`lists`) are the store's own
// `PurchasableItemId`s, so a numbered line resolves here or nowhere.
const bagCatalog = await loadStorefront(c, storefrontType as number)
// The bag may also name CUSTOM avatar items — a line whose id is a `Guid`, the item's
// `CustomAvatarItemId` — which resolve against the `custom_avatar_item` table rather than
// the catalog. One read for the items the bag names and one for which of them the buyer
+359 -219
View File
@@ -31,12 +31,12 @@ import { NotificationType } from '../../../../notify/src/notification-types'
// 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'
// 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 carriedItems from '../../../static/db/consumables.json'
import skinsJson from '../../../static/db/skins.json'
// The 2025 store DUMP, the source `runx storefront build` and `runx catalog load` both read.
import storeDump from '../../../static/db/Watch_EnumValue_3.json'
import questRewards from '../../../static/quest-rewards.json'
// Both general-store files, read as FILES: which one the route serves depends on the caller's
// build, and these assertions are about each file's CONTENTS.
import sf32025 from '../../../static/storefronts/sf3-2025.json'
import sf3 from '../../../static/storefronts/sf3.json'
import { SCHEMA_DDL } from '../../avatar-db'
@@ -66,7 +66,14 @@ import {
searchCatalog,
toCatalogSkin,
} from '../../catalog-db'
import { CATALOG_ID_BASE } from '../../catalog-load'
import {
collapseRepeatedListings,
GOLD_SKIN_PRICE,
isGoldSkin,
UNLISTED_SKIN_ID_BASE,
UNLISTED_SKIN_RARITY,
unlistedSkinListing,
} 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
@@ -84,37 +91,51 @@ import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db'
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
import type { Env } from '../../context'
/**
* The GENERATED half of a store file — the items built from the item catalog, as opposed to the
* equipment, consumables and boxes carried across from the 2023 capture.
*
* Split on membership in the CARRIED ids rather than on `CATALOG_ID_BASE`. The two happen to
* agree now that the equipment skins are gone — the carried ids run 2168-2458, well below the
* base — but they did not while a skin carried id 20756767, and asking the real question costs
* nothing.
* The store dump's listings with the verbatim repeats collapsed — what `runx storefront build`
* writes out as sf3-2025.json, sale stripped. The file test below holds the two to each other.
*/
const capturedIds = new Set(carriedItems.map((i) => i.PurchasableItemId))
const catalogItems = () => sf32025.StoreItems.filter((i) => !capturedIds.has(i.PurchasableItemId))
const dumpListings = collapseRepeatedListings(storeDump.StoreItems as StoreListing[]).listings
/** The served store's listings, which is what `runx catalog load` reads. */
const storeListings = sf32025.StoreItems as StoreListing[]
/**
* An item ONLY the newer store sells — created after the cutoff, so it is in sf3-2025 and not in
* sf3. The build gate is only observable through such an item: everything else is in both files
* and buys identically either way.
* An avatar item ONLY the 2025 store sells, priced in tokens: an id sf3-2025 lists and sf3
* does not. The build gate is only observable through such an item — the two files number their
* stores differently, so nearly any 2025 listing qualifies; the first tokens-priced avatar item
* is as good as any.
*/
const sf3Ids = new Set(sf3.StoreItems.map((i) => i.PurchasableItemId))
const NEWER_ONLY = (() => {
const item = sf32025.StoreItems.find((i) => !sf3Ids.has(i.PurchasableItemId))
if (item === undefined) throw new Error('sf3-2025 sells nothing sf3 does not')
const item = storeListings.find(
(i) =>
!sf3Ids.has(i.PurchasableItemId) &&
i.GiftDrop.AvatarItemDesc !== '' &&
i.Prices.some((p) => p.CurrencyType === 2)
)
if (item === undefined)
throw new Error('sf3-2025 sells no tokens-priced avatar item sf3 does not')
return {
id: item.PurchasableItemId,
price: item.Prices[0]!.Price,
price: item.Prices.find((p) => p.CurrencyType === 2)!.Price,
name: item.GiftDrop.FriendlyName,
}
})()
/**
* The avatar items the suite-wide catalog holds — a slice of the real load, seeded below so the
* catalogue reads (`lockeditems/bulk`) have something in store order to answer from. A slice
* rather than the whole load: the table is rebuilt per test file, and fifty rows say what three
* thousand would.
*/
const SEEDED_AVATAR_ITEMS = buildCatalogLoad(storeListings)
.rows.filter((r) => r.values[CATALOG_INSERT_COLUMNS.indexOf('kind')] === CatalogKind.AvatarItem)
.slice(0, 50)
/**
* Items the generated `sf3.json` sells, resolved FROM the file rather than hardcoded.
*
@@ -217,6 +238,16 @@ beforeAll(async () => {
.bind(skin.ModificationGuid, 70_001 + i, skin.FriendlyName, skin.Rarity, skin.PrefabName)
.run()
}
// A slice of the real avatar-item load, in load order, for the catalogue reads. One batch:
// these are the loader's own rows, so a row it builds that the table refuses fails here.
await env.DB.batch(
SEEDED_AVATAR_ITEMS.map((row) =>
env.DB.prepare(
`INSERT OR IGNORE INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
).bind(...row.values.map((v) => v ?? null))
)
)
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
@@ -1990,10 +2021,9 @@ describe('econ endpoints', () => {
})
test('bulkpurchase buys a merged-store catalog item from storefront 3', async () => {
// The exact request the client sends, verbatim: a catalog id under storefront 3, which is
// what the merged sf3-2025 lists it as. It resolves because `loadStorefront` picks the file
// by the caller's build, so what the store page offered is what the purchase is checked
// against.
// The exact request the client sends, verbatim: a 2025 store id under storefront 3, which
// is what sf3-2025 lists it as. It resolves because `loadStorefront` picks the file by the
// caller's build, so what the store page offered is what the purchase is checked against.
const res = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
@@ -2024,18 +2054,18 @@ describe('econ endpoints', () => {
Value: { Balance: number } | null
}
// It used to answer `{ Success: false, Error: "Item not found" }` — storefront 3 resolved
// to the captured sf3, which has no id in the catalog range.
// to the 2023 sf3, which does not list this id.
expect(body.Error).not.toBe('Item not found')
expect(body.Success).toBe(true)
// The price the client posts is the one the file lists, because the file it browsed and the
// purchase it made are priced from the same shared rarity table. A second pricing anywhere
// would 409 every purchase as "Price has changed".
const item = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
expect(item?.Prices[0]?.Price).toBe(NEWER_ONLY.price)
// The price the client posts is the one the file lists — the dump's list price, its
// captured sale stripped. A second pricing anywhere would 409 every purchase as "Price
// has changed".
const item = storeListings.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
expect(item?.Prices.find((p) => p.CurrencyType === 2)?.Price).toBe(NEWER_ONLY.price)
// The SAME request from an old build still fails: its storefront 3 is generated to the
// cutoff, and this item postdates it.
// The SAME request from an old build still fails: its storefront 3 is the 2023 store,
// which does not list this id.
const legacy = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
@@ -2058,9 +2088,11 @@ describe('econ endpoints', () => {
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
})
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), bought straight off
// the `catalog` table; plus a skin and a developer-tier row, neither of which may be.
test('bulkpurchase resolves numbered lines against the storefront file alone', async () => {
// Rows the `catalog` table holds that the store does NOT list — an avatar item, a skin and
// a developer-tier item — none of which may be bought: the file is the whole catalog, and
// the ids the discovery rows hand out are the store's own, so a numbered line resolves
// there or nowhere.
const AVATAR_ID = 20_001
const SKIN_ID = 20_002
const DEV_ID = 20_003
@@ -2076,15 +2108,19 @@ describe('econ endpoints', () => {
)
.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()
// None of those three numbers is a 2025 listing, or the test would be about the file.
for (const id of [AVATAR_ID, SKIN_ID, DEV_ID]) {
expect(
sf32025.StoreItems.find((i) => i.PurchasableItemId === id),
String(id)
).toBeUndefined()
}
const buy = async (
version: string | undefined,
@@ -2107,43 +2143,35 @@ describe('econ endpoints', () => {
})),
}),
})
const outcome = async (res: Response) =>
((await res.json()) as { Success: boolean; Error?: string }).Success
// 600 (rarity 10) is the generated storefront's own price. It 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 }])
// A 2025 listing buys at its list price — the dump's, its sale stripped. The price MUST
// match: `priceCheck` refuses a line whose posted price differs.
const res = await buy('20250718.01', [{ id: NEWER_ONLY.id, price: NEWER_ONLY.price }])
expect(res.status).toBe(200)
const body = (await res.json()) as { Success: boolean; Value: { Balance: number } | null }
expect(body.Success).toBe(true)
// A SKIN is refused even though the catalog holds it: skins are awarded from weekly
// challenges rather than sold, so no storefront lists one and the bag will not resolve one
// off the table either.
const skin = await buy('20250718.01', [{ id: SKIN_ID, price: 150 }])
expect(((await skin.json()) as { Success: boolean }).Success).toBe(false)
expect(await outcome(res)).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)
expect(await outcome(await buy('20250718.01', [{ id: NEWER_ONLY.id, price: 1 }]))).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)
// Table-only rows are not for sale, whatever their kind and at any price. The catalog says
// what a thing IS; only the storefront says what is sold.
for (const [id, price] of [
[AVATAR_ID, 600],
[SKIN_ID, 150],
[DEV_ID, 150],
] as const) {
expect(await outcome(await buy('20250718.01', [{ id, price }])), String(id)).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)
// A bag may MIX an item the STOREFRONT FILE lists with one resolved straight off the
// `catalog` table.
const mixed = await buy('20250718.01', [
{ id: SF3_ITEM.id, price: SF3_ITEM.price },
{ id: AVATAR_ID, price: 600 },
])
expect(((await mixed.json()) as { Success: boolean }).Success).toBe(true)
// And an old build never sees a 2025 number: its storefront 3 is the 2023 file.
expect(
await outcome(await buy('20230414', [{ id: NEWER_ONLY.id, price: NEWER_ONLY.price }]))
).toBe(false)
expect(
await outcome(await buy(undefined, [{ id: NEWER_ONLY.id, price: NEWER_ONLY.price }]))
).toBe(false)
// Cleaned up: the `catalog` block below counts every row in the table, so rows left behind
// here would change what it sees.
@@ -2165,12 +2193,14 @@ describe('econ endpoints', () => {
return (await res.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
}
// Three real catalogue entries, deliberately asked for OUT of catalogue order.
const [first, second, third] = [
avatarItemsJson[0]!,
avatarItemsJson[40]!,
avatarItemsJson[900]!,
]
// Three real catalogue entries — rows of the seeded slice, which are the loader's own —
// deliberately asked for OUT of catalogue order.
const seeded = SEEDED_AVATAR_ITEMS.map((r) => ({ AvatarItemDesc: r.key }))
const [first, second, third] = [seeded[0]!, seeded[20]!, seeded[45]!]
// The catalogue the route answers with is every avatar item in the table — the seeded
// slice, plus whatever the catalog block below has not yet replaced it with.
const catalogueSize = (await countCatalog(env.DB))[CatalogKind.AvatarItem] ?? 0
expect(catalogueSize).toBeGreaterThanOrEqual(SEEDED_AVATAR_ITEMS.length)
const got = await ask([third.AvatarItemDesc, first.AvatarItemDesc, second.AvatarItemDesc])
expect(got).toHaveLength(3)
@@ -2195,9 +2225,11 @@ describe('econ endpoints', () => {
expect(await ask(['no-such-desc,,,'])).toEqual([])
// EMPTY or absent means the WHOLE catalogue — the reference's "give me everything" case,
// not a degenerate match-nothing.
expect(await ask([])).toHaveLength(avatarItemsJson.length)
expect(await ask(undefined)).toHaveLength(avatarItemsJson.length)
// not a degenerate match-nothing. In the catalogue's record shape: the desc IS the key.
const whole = await ask([])
expect(whole).toHaveLength(catalogueSize)
expect(whole[0]).toMatchObject({ AvatarItemDesc: first.AvatarItemDesc, PlatformMask: -1 })
expect(await ask(undefined)).toHaveLength(catalogueSize)
// No auth needed, and a body that will not parse falls back to the catalogue rather than
// erroring.
@@ -2207,7 +2239,7 @@ describe('econ endpoints', () => {
body: 'not json',
})
expect(junk.status).toBe(200)
expect(((await junk.json()) as unknown[]).length).toBe(avatarItemsJson.length)
expect(((await junk.json()) as unknown[]).length).toBe(catalogueSize)
})
test('POST /api/items/purchaseInfos prices custom avatar items in tokens', async () => {
@@ -2492,79 +2524,166 @@ describe('econ endpoints', () => {
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.
// Later builds get the 2025 store — bigger, 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)
const newer = await store(await at(version))
expect(newer.StorefrontType, version).toBe(3)
expect(newer.StoreItems.length, version).toBe(sf32025.StoreItems.length)
expect(newer.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: SF3_ITEM.id, // an avatar item the generated sf3 sells
CurrencyType: 2,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(bowtie.status).toBe(200)
// And a CATALOG item can be bought from storefront 3 by a newer build — the half a
// A 2025 listing 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 = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
expect(catalogItem).toBeDefined()
const listing = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
expect(listing).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,
PurchasableItemId: NEWER_ONLY.id,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
RequestedPrice: NEWER_ONLY.price,
}),
})
expect(bought.status).toBe(200)
// The SAME item is not for sale to an old build: it postdates the cutoff, so sf3 — which
// is generated to that date — does not list it.
// The SAME id is not for sale to an old build: its store is the 2023 one, which numbers
// its items differently and 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,
PurchasableItemId: NEWER_ONLY.id,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
RequestedPrice: NEWER_ONLY.price,
}),
})
expect(refused.status).toBe(404)
// And the 2023 store's own items still buy for the old build exactly as they always did.
const legacyBuy = 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: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(legacyBuy.status).toBe(200)
})
test('sf3 and sf3-2025 are the same store at two points in time', async () => {
// BOTH are generated from the item catalog now — sf3 is no longer a capture. They report
// the same storefront id, because they are two versions of ONE store and the client asks
// for 3 either way.
test('sf3 is the frozen 2023 store; sf3-2025 is the 2025 dump with its sale stripped', async () => {
// Two versions of ONE store: both report storefront 3, because the client asks for 3
// either way.
expect(sf3.StorefrontType).toBe(3)
expect(sf32025.StorefrontType).toBe(3)
// sf3 is a strict SUBSET of sf3-2025: same items, same ids, same prices — it just stops at
// the cutoff. Anything else would mean a player's store changed under them on upgrade.
const newer = new Map(sf32025.StoreItems.map((i) => [i.PurchasableItemId, i]))
for (const item of sf3.StoreItems) {
expect(newer.get(item.PurchasableItemId), String(item.PurchasableItemId)).toEqual(item)
}
expect(sf3.StoreItems.length).toBeLessThan(sf32025.StoreItems.length)
// sf3 is frozen — nothing regenerates it. What it was when it was cut: no equipment (skins
// are awarded from weekly challenges), the discount expressed only in `SubscriberPrices`,
// ids unique.
expect(sf3.SubscriberDiscountPercent).toBe(0)
expect(
sf3.StoreItems.filter((i) => (i.GiftDrop.EquipmentModificationGuid ?? '') !== '')
).toEqual([])
// Ids are unique within each file. The merge of carried and generated halves is only safe
// because their id spaces don't overlap, so a collision must fail rather than be resolved
// by array order.
// sf3-2025 is `Watch_EnumValue_3.json` — the live game's storefront 3 — listing for
// listing, with its verbatim repeats collapsed. Everything the dump says about an item is
// in the file: its id, its list price, its subscriber price, its gift drop.
expect(sf32025.StoreItems.length).toBeGreaterThan(dumpListings.length)
expect(sf32025.StoreItems.length).toBeGreaterThan(sf3.StoreItems.length)
const stripSale = (prices: Array<{ StorefrontSaleData: unknown }> | null) =>
prices?.map((p) => ({ ...p, StorefrontSaleData: null })) ?? null
for (const [i, listing] of dumpListings.entries()) {
const served = sf32025.StoreItems[i]!
expect(served.PurchasableItemId, String(listing.PurchasableItemId)).toBe(
listing.PurchasableItemId
)
// A skin's or a consumable's thumbnail is blanked — the dump names a PNG per item that
// this server does not have — and nothing else about a drop changes.
const blanked =
listing.GiftDrop.EquipmentModificationGuid !== '' ||
listing.GiftDrop.ConsumableItemDesc !== ''
expect(served.GiftDrop).toEqual(
blanked ? { ...listing.GiftDrop, ThumbnailImageName: '' } : listing.GiftDrop
)
// The other edit to a listing: the dump was taken mid-sale (80% off nearly everything,
// no end date), and the client posts the sale price it computes — which this server,
// which knows only list prices, would refuse as "Price has changed". The `Price` beside
// it is the list price and stands.
expect(served.Prices).toEqual(stripSale(listing.Prices))
expect(served.SubscriberPrices).toEqual(stripSale(listing.SubscriberPrices))
}
expect(sf32025.SubscriberDiscountPercent).toBe(storeDump.SubscriberDiscountPercent)
// After the dump's listings: the equipment skins the game's own store never sold, from
// `skins.json`, each a listing of its own in the dump's skin shape — rarity 50 and priced
// from it (the store's own rarity-50 skins run 1000-6000), numbered from a million in
// capture order. A capture guid the store lists is NOT added again: the capture's names
// and rarities are wrong wherever the two can be compared, so the listing is the record.
const dumpSkins = new Set(
dumpListings.map((l) => l.GiftDrop.EquipmentModificationGuid).filter((g) => g !== '')
)
const unlisted = skinsJson.filter((sk) => !dumpSkins.has(sk.ModificationGuid))
expect(unlisted.length).toBeGreaterThan(100)
// A gold skin is the one appended listing that differs from `unlistedSkinListing`: its
// price, checked on its own below.
const tail = sf32025.StoreItems.slice(dumpListings.length)
const goldPrice = { CurrencyType: 2, Price: GOLD_SKIN_PRICE, StorefrontSaleData: null }
const gilded = (l: StoreListing): StoreListing =>
isGoldSkin(l) ? { ...l, Prices: [goldPrice], SubscriberPrices: [goldPrice] } : l
expect(tail).toEqual(
unlisted.map((sk, i) => gilded(unlistedSkinListing(sk, UNLISTED_SKIN_ID_BASE + i)))
)
expect(tail[0]).toMatchObject({
GiftDrop: { Rarity: UNLISTED_SKIN_RARITY, EquipmentPrefabName: unlisted[0]!.PrefabName },
Prices: [{ CurrencyType: 2, Price: 3000, StorefrontSaleData: null }],
SubscriberPrices: [{ CurrencyType: 2, Price: 2700, StorefrontSaleData: null }],
})
expect(sf32025.StoreItems).toHaveLength(dumpListings.length + unlisted.length)
// Every skin the capture knows is now sold by this store, once — and no skin, the game's
// own included, names a thumbnail.
const soldSkins = storeListings.filter((i) => i.GiftDrop.EquipmentModificationGuid !== '')
const soldGuids = soldSkins.map((i) => i.GiftDrop.EquipmentModificationGuid)
expect(new Set(soldGuids).size).toBe(soldGuids.length)
expect(skinsJson.every((sk) => soldGuids.includes(sk.ModificationGuid))).toBe(true)
expect(soldSkins.every((i) => i.GiftDrop.ThumbnailImageName === '')).toBe(true)
expect(soldSkins.length).toBeGreaterThan(unlisted.length)
// A "(Gold)" skin — thirteen, all from the capture — is a prestige item: 100,000 tokens,
// the same for a subscriber, no sale. A "(Gold)" AVATAR item is priced as the dump has it.
const goldSkins = soldSkins.filter((i) => i.GiftDrop.FriendlyName.includes('(Gold)'))
expect(goldSkins.length).toBeGreaterThan(10)
for (const g of goldSkins) {
expect(g.Prices, g.GiftDrop.FriendlyName).toEqual([goldPrice])
expect(g.SubscriberPrices, g.GiftDrop.FriendlyName).toEqual([goldPrice])
}
expect(
soldSkins.filter((i) => !isGoldSkin(i)).every((i) => i.Prices[0]?.Price !== GOLD_SKIN_PRICE)
).toBe(true)
const goldAvatar = storeListings.find(
(i) => i.GiftDrop.AvatarItemDesc !== '' && i.GiftDrop.FriendlyName.includes('(Gold)')
)!
expect(goldAvatar.Prices[0]?.Price).not.toBe(GOLD_SKIN_PRICE)
// Consumables likewise; avatar items keep theirs.
const consumables = storeListings.filter((i) => i.GiftDrop.ConsumableItemDesc !== '')
expect(consumables.length).toBeGreaterThan(200)
expect(consumables.every((i) => i.GiftDrop.ThumbnailImageName === '')).toBe(true)
expect(
storeListings.filter(
(i) => i.GiftDrop.AvatarItemDesc !== '' && (i.GiftDrop.ThumbnailImageName ?? '') !== ''
).length
).toBeGreaterThan(2000)
// The dump's `NextUpdate` was the real store's next rotation, long past; the client
// refetches after it, and this file is regenerated by hand.
expect(sf32025.NextUpdate > '2200').toBe(true)
// Ids are unique within each file: `findStoreItem` resolves a purchase by id, so a
// repeated id would mean array order decides what the player bought.
for (const [label, file] of [
['sf3', sf3],
['sf3-2025', sf32025],
@@ -2573,57 +2692,18 @@ describe('econ endpoints', () => {
expect(new Set(ids).size, label).toBe(ids.length)
}
// The discount is expressed ONLY in `SubscriberPrices`; announcing it again at the top
// level risks a client taking 10% off an already-discounted price and posting through the
// server's own subscriber floor, refused as "Price has changed".
expect(sf3.SubscriberDiscountPercent).toBe(0)
expect(sf32025.SubscriberDiscountPercent).toBe(0)
// Every GENERATED item is priced from its rarity, and rarity -1 (the developer tier) is
// excluded rather than priced — an item listed here can be bought.
const priceByRarity = new Map([
[0, 150],
[10, 600],
[20, 700],
[30, 800],
[50, 3000],
])
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`.
expect(item.SubscriberPrices[0]).toMatchObject({
CurrencyType: 2,
Price: Math.floor((expected! * 90) / 100),
})
// `GiftDropId` echoes the id, as the capture did on all 1161 of its items.
expect(item.GiftDrop.GiftDropId).toBe(item.PurchasableItemId)
expect(item.PurchasableItemId).toBeGreaterThanOrEqual(CATALOG_ID_BASE)
}
expect(catalogItems().filter((i) => i.GiftDrop.Rarity === -1)).toEqual([])
// The CARRIED half — 30 consumables and 5 random boxes — comes from
// `static/db/consumables.json`, what survives of the 2023 capture. The item catalog does
// not model these, so they keep their own ids and prices.
const carried = sf3.StoreItems.filter((i) => capturedIds.has(i.PurchasableItemId))
expect(carried.length).toBeGreaterThan(0)
expect(carried.every((i) => (i.GiftDrop.AvatarItemDesc ?? '') === '')).toBe(true)
// And NO equipment skins anywhere in either file: they are awarded from weekly challenges,
// so a store listing one would sell something the game gives away.
for (const [label, file] of [
['sf3', sf3],
['sf3-2025', sf32025],
] as const) {
expect(
file.StoreItems.filter((i) => (i.GiftDrop.EquipmentModificationGuid ?? '') !== ''),
label
).toEqual([])
}
// A known listing, at the dump's own numbers: Candy Apples, 1801, 95 tokens, 85 for a
// subscriber — NOT the 2023 store's 2184, and NOT a price computed from its rarity.
const apples = sf32025.StoreItems.find((i) => i.GiftDrop.FriendlyName === 'Candy Apples')
expect(apples).toMatchObject({
PurchasableItemId: 1801,
GiftDrop: { GiftDropId: 3361, ConsumableItemDesc: 'EmPvh3I6L0uK_1i8Wy_ylQ' },
Prices: [{ CurrencyType: 2, Price: 95, StorefrontSaleData: null }],
SubscriberPrices: [{ CurrencyType: 2, Price: 85, StorefrontSaleData: null }],
})
// An id with no storefront still 404s, and 1704 is gone — it was a stand-in for a store
// that turned out to belong inside sf3.
// that turned out to be this one.
for (const id of [1705, 1704]) {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/${id}`)
expect(res.status, String(id)).toBe(404)
@@ -6010,8 +6090,8 @@ describe('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)
test('the loader maps the served store and the skin capture onto the columns it declares', async () => {
const { rows, collisions, skipped } = buildCatalogLoad(storeListings)
// 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
@@ -6021,60 +6101,120 @@ describe('catalog', () => {
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.
// One key space, every kind, no collisions between them. A listing loads as the kind of
// the ONE item id it carries; the rows come out in store order.
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)
const listed = (pick: (l: StoreListing) => string) =>
new Set(storeListings.map(pick).filter((k) => k !== '')).size
expect(kinds.filter((k) => k === 'avatar_item')).toHaveLength(
listed((l) => l.GiftDrop.AvatarItemDesc)
)
expect(kinds.filter((k) => k === 'consumable')).toHaveLength(
listed((l) => l.GiftDrop.ConsumableItemDesc)
)
expect(rows[0]?.label).toBe(`${storeListings[0]!.GiftDrop.FriendlyName} (avatar item)`)
// 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)
// Skins: every guid the served store lists, which is every guid the capture holds too —
// the store file carries the capture's unlisted skins as listings of their own (see the
// file test) — so the loader never reads `skins.json` at all. A skin the game's own store
// sold keeps the store's name and rarity, not the capture's, which disagree on every row.
const listedSkins = new Set(
storeListings.map((l) => l.GiftDrop.EquipmentModificationGuid).filter((g) => g !== '')
)
const capturedSkins = new Set(skinsJson.map((sk) => sk.ModificationGuid))
expect([...capturedSkins].every((g) => listedSkins.has(g))).toBe(true)
expect(kinds.filter((k) => k === 'skin')).toHaveLength(listedSkins.size)
const both = dumpListings.find(
(l) =>
l.GiftDrop.EquipmentModificationGuid !== '' &&
capturedSkins.has(l.GiftDrop.EquipmentModificationGuid)
)!
const row = rows.find((r) => r.key === both.GiftDrop.EquipmentModificationGuid)!
expect(row.id).toBe(both.PurchasableItemId)
expect(row.values[CATALOG_INSERT_COLUMNS.indexOf('friendly_name')]).toBe(
both.GiftDrop.FriendlyName
)
expect(row.values[CATALOG_INSERT_COLUMNS.indexOf('rarity')]).toBe(both.GiftDrop.Rarity)
// 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()
}
// What the store lists that is NOT a catalog item is counted, not loaded: first-party
// custom items (the `custom_avatar_item` table's), loot boxes, token bundles — and the
// PACKS, a consumable listed again under another number with a count. Every listing is
// accounted for: a row, a skipped count, or nothing else.
expect(skipped.custom_avatar_item).toBe(
storeListings.filter((l) => l.GiftDrop.CustomAvatarItemId).length
)
expect(skipped.query).toBe(storeListings.filter((l) => l.GiftDrop.IsQuery).length)
expect(skipped.consumable_pack).toBeGreaterThan(0)
expect(
storeListings.find((l) => l.GiftDrop.FriendlyName === 'Disco Dance Break 3-Pack')?.GiftDrop
.ConsumableItemDesc
).toBe(
storeListings.find((l) => l.GiftDrop.FriendlyName === 'Disco Dance Break')?.GiftDrop
.ConsumableItemDesc
)
expect(rows.length + Object.values(skipped).reduce((a, b) => a + b, 0)).toBe(
storeListings.length
)
// 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([])
// The served store repeats no key, so nothing collides. The report is still wired: 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, because a collision that vanishes
// quietly is the exact failure the single key exists to prevent. (The dump's own verbatim
// repeats never reach it: the served file has them collapsed.)
expect(collisions).toEqual([])
const first = storeListings[0]!
const twice = buildCatalogLoad([first, { ...first, PurchasableItemId: 999_999 }])
expect(twice.rows).toHaveLength(1)
expect(twice.rows[0]?.id).toBe(first.PurchasableItemId)
expect(twice.collisions).toEqual([
{
key: first.GiftDrop.AvatarItemDesc,
kept: twice.rows[0]!.label,
dropped: twice.rows[0]!.label,
},
])
// And one number naming two different things is refused outright, not resolved by order.
expect(() =>
buildCatalogLoad([
first,
{ ...storeListings[1]!, PurchasableItemId: first.PurchasableItemId },
])
).toThrow(/names two items/)
// The skin that was once pasted over an avatar item's row lives in skins.json where it
// belongs.
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)
test('catalog_id is the listing’s PurchasableItemId, always', async () => {
const { rows } = buildCatalogLoad(storeListings)
// 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)
// A row's number IS its listing's `PurchasableItemId` — the number sf3-2025.json sells it
// under — so nothing has to be kept in step between the file and the table. No row is
// numbered by the loader: the skins the game's own store never sold are LISTINGS in the
// served file, numbered there from `UNLISTED_SKIN_ID_BASE`.
const byId = new Map(sf32025.StoreItems.map((i) => [i.PurchasableItemId, i]))
expect(rows.length).toBeGreaterThan(2500)
for (const row of rows) {
const drop = byId.get(row.id)?.GiftDrop
expect(drop, String(row.id)).toBeDefined()
expect(
[drop!.AvatarItemDesc, drop!.ConsumableItemDesc, drop!.EquipmentModificationGuid],
String(row.id)
).toContain(row.key)
}
expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length)
const appended = rows.filter((r) => r.id >= UNLISTED_SKIN_ID_BASE)
expect(appended.length).toBeGreaterThan(0)
expect(appended.every((r) => r.values[CATALOG_INSERT_COLUMNS.indexOf('kind')] === 'skin')).toBe(
true
)
expect(
appended.every(
(r) => r.values[CATALOG_INSERT_COLUMNS.indexOf('rarity')] === UNLISTED_SKIN_RARITY
)
).toBe(true)
// 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.
@@ -6190,7 +6330,7 @@ describe('catalog', () => {
// 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)
const { rows } = buildCatalogLoad(storeListings)
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()