[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
+81 -41
View File
@@ -1,20 +1,18 @@
import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'
import {
buildCatalogLoad,
CATALOG_ID_BASE,
CATALOG_INSERT_COLUMNS,
} from '../../../../apps/econ/src/catalog-load'
import { buildCatalogLoad, CATALOG_INSERT_COLUMNS } from '../../../../apps/econ/src/catalog-load'
import { execSql, execSqlFile, resolveRemote, target } from '../d1'
import { getRepoRoot } from '../path'
import type {
AvatarItemCapture,
CatalogCollision,
CatalogLoad,
CatalogLoadRow,
CatalogValue,
SkinCapture,
StorefrontDump,
StoreListing,
} from '../../../../apps/econ/src/catalog-load'
/**
@@ -37,8 +35,22 @@ import type {
/** The worker that owns the table — whose wrangler.jsonc and node_modules wrangler uses. */
const OWNER = 'econ'
const AVATAR_ITEMS = 'apps/econ/static/db/avatar-items.json'
const SKINS = 'apps/econ/static/db/skins.json'
/**
* The 2025 general store as the econ worker SERVES it — the ONLY file the loader reads, so a
* row's `catalog_id` and a listing's `PurchasableItemId` are one number by construction.
* Written by `runx storefront build` from {@link STORE_DUMP} and {@link SKINS}.
*/
export const STOREFRONT_FILE = 'apps/econ/static/storefronts/sf3-2025.json'
/**
* The 2025 general store as the live game served it, one storefront page dumped verbatim. Kept
* for reference and as the generator's input; nothing serves or loads it directly.
*/
export const STORE_DUMP = 'apps/econ/static/db/Watch_EnumValue_3.json'
/**
* The equipment skins, captured separately: the store lists only a third of them, and the
* generator appends the rest as listings of their own.
*/
export const SKINS = 'apps/econ/static/db/skins.json'
/**
* Rows per INSERT. Batched because thousands of single-row statements parse far more slowly
@@ -59,26 +71,52 @@ function lit(v: CatalogValue): string {
return `'${v.replaceAll("'", "''")}'`
}
/** Read one capture, tolerating the BOM the exports carry (`JSON.parse` rejects U+FEFF). */
async function readCapture<T>(relPath: string): Promise<T[]> {
/** Read one JSON file, tolerating the BOM the exports carry (`JSON.parse` rejects U+FEFF). */
async function readJson(relPath: string): Promise<unknown> {
const full = path.join(getRepoRoot(), relPath)
if (!(await fs.pathExists(full))) throw new Error(`no capture at ${relPath}`)
const text = (await fs.readFile(full, 'utf8')).replace(/^/, '')
const parsed = JSON.parse(text) as unknown
if (!Array.isArray(parsed)) throw new Error(`${relPath} is not a JSON array`)
return parsed as T[]
const text = (await fs.readFile(full, 'utf8')).replace(/^\ufeff/, '')
return JSON.parse(text) as unknown
}
/** Both captures, as the loader and the storefront generator read them. */
export async function readCaptures(): Promise<{
avatarItems: AvatarItemCapture[]
skins: SkinCapture[]
}> {
const [avatarItems, skins] = await Promise.all([
readCapture<AvatarItemCapture>(AVATAR_ITEMS),
readCapture<SkinCapture>(SKINS),
])
return { avatarItems, skins }
/** One storefront page (`{ StoreItems: [...] }`), checked to be that shape. */
export async function readStorefrontPage(relPath: string): Promise<StorefrontDump> {
const parsed = await readJson(relPath)
if (
typeof parsed !== 'object' ||
parsed === null ||
!Array.isArray((parsed as { StoreItems?: unknown }).StoreItems)
) {
throw new Error(`${relPath} is not a storefront page ({ StoreItems: [...] })`)
}
return parsed as StorefrontDump
}
/** The served store's listings — the only thing the loader reads. */
export async function readListings(): Promise<StoreListing[]> {
return (await readStorefrontPage(STOREFRONT_FILE)).StoreItems
}
/** The skin capture, for the generator: the skins the store never listed. */
export async function readSkins(): Promise<SkinCapture[]> {
const skins = await readJson(SKINS)
if (!Array.isArray(skins)) throw new Error(`${SKINS} is not a JSON array`)
return skins as SkinCapture[]
}
/** What a load is made of, for the console: rows per kind, and the listings that load as nothing. */
function describeLoad(load: CatalogLoad): string {
const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
const perKind = new Map<string, number>()
for (const r of load.rows) {
const k = String(r.values[kindAt])
perKind.set(k, (perKind.get(k) ?? 0) + 1)
}
const kinds = [...perKind].map(([k, n]) => `${n} ${k}`).join(', ')
const skipped = Object.entries(load.skipped)
.map(([k, n]) => `${n} ${k}`)
.join(', ')
return `${kinds}${skipped === '' ? '' : `; not loaded: ${skipped}`}`
}
/** Print the duplicate keys a load dropped. Never silent: that is the whole point of them. */
@@ -192,12 +230,12 @@ const load = new Command('load')
.option('--dry-run', 'Build and validate the SQL, print what it would do, change nothing.', false)
.action(async (opts) => {
const remote = resolveRemote(opts)
const { avatarItems, skins } = await readCaptures()
const { rows, collisions } = buildCatalogLoad(avatarItems, skins)
const load = buildCatalogLoad(await readListings())
const { rows, collisions } = load
console.log(
`${opts.replace ? 'Replacing the catalog with' : 'Merging'} ${rows.length} rows ` +
`(${avatarItems.length} avatar items, ${skins.length} skins) into ${target(remote)}`
`(${describeLoad(load)}) into ${target(remote)}`
)
reportCollisions(collisions)
@@ -224,11 +262,11 @@ const load = new Command('load')
statements.push('DELETE FROM catalog;')
} else {
// Clear every existing number BEFORE assigning any. `catalog_id` is unique, and this
// load is about to hand out 1..N: without this, a row already holding one of those
// numbers (because it was in an earlier load and this capture no longer mentions it)
// load carries the store's numbers: without this, a row already holding one of them
// (because it was in an earlier load under a different key, or the store renumbered)
// collides and the whole merge fails. Nulling first also means a row left un-numbered
// afterwards is visibly a row the captures did not mention, which the tail statement
// below then numbers above N.
// afterwards is visibly a row the store no longer lists, which the tail statement
// below then numbers above everything this load assigned.
statements.push('UPDATE catalog SET catalog_id = NULL;')
}
for (let start = 0; start < rows.length; start += CHUNK) {
@@ -242,11 +280,11 @@ const load = new Command('load')
)
}
if (!opts.replace) {
// Anything still un-numbered is a row the captures did not mention — a merge keeps those,
// Anything still un-numbered is a row the store no longer lists — a merge keeps those,
// so they need handles too. They go ABOVE the highest id this load assigned, so they can
// never collide with it, ordered by rowid for determinism. Usually this matches nothing
// at all, and it is a single statement either way.
const highest = CATALOG_ID_BASE + rows.length - 1
const highest = Math.max(0, ...rows.map((r) => r.id))
statements.push(
`UPDATE catalog SET catalog_id = ${highest} +\n` +
`\t(SELECT COUNT(*) FROM catalog c WHERE c.catalog_id IS NULL AND c.rowid <= catalog.rowid)\n` +
@@ -296,16 +334,16 @@ const load = new Command('load')
})
const check = new Command('check')
.description('Validate the captured JSON without touching any database')
.description('Validate the served store as a catalog load without touching any database')
.action(async () => {
const { avatarItems, skins } = await readCaptures()
const { rows, collisions } = buildCatalogLoad(avatarItems, skins)
const listings = await readListings()
const load = buildCatalogLoad(listings)
const { rows, collisions } = load
const table = new Table()
table.push(
{ 'avatar items': String(avatarItems.length) },
{ skins: String(skins.length) },
{ 'rows to load': String(rows.length) },
{ 'store listings': String(listings.length) },
{ 'rows to load': `${rows.length} (${describeLoad(load)})` },
{ 'duplicate keys': String(collisions.length) }
)
console.log(table.toString())
@@ -329,7 +367,9 @@ export const catalogCmd = new Command('catalog')
'after',
`
The catalog's structure is a migration; its contents are not — reload them here whenever
apps/econ/static/db/*.json changes, with no migration and no deploy.
apps/econ/static/storefronts/sf3-2025.json (the served 2025 store, see \`runx storefront
build\`) changes, with no migration and no deploy. That file is the loader's ONLY input, and
every row's catalog_id is its listing's PurchasableItemId.
load MERGES by default: new items are inserted, existing ones refreshed, and anything the
captures don't mention is left alone — so a partial capture of a few new items is a valid
@@ -339,7 +379,7 @@ them should be REMOVED.
Target --local (default) or --remote (production; needs RECFLARE_D1 in .env).
Examples:
$ runx catalog check # validate the JSON, touch nothing
$ runx catalog check # validate the store file, touch nothing
$ runx catalog load --dry-run # build the SQL, print what it would do
$ runx catalog load # merge into the local dev database
$ runx catalog load --remote # merge into production
+127 -189
View File
@@ -5,228 +5,163 @@ import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'
import {
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CatalogKind,
existedByLegacyBuild,
isSellableRarity,
collapseRepeatedListings,
GOLD_SKIN_PRICE,
isGoldSkin,
LEGACY_CLIENT_BUILD,
LEGACY_CLIENT_BUILD_DATE,
priceForRarity,
subscriberPriceFor,
listingKind,
UNLISTED_SKIN_ID_BASE,
UNLISTED_SKIN_RARITY,
unlistedSkinListing,
} from '../../../../apps/econ/src/catalog-load'
import { getRepoRoot } from '../path'
import { readCaptures } from './catalog.cmd'
import { readSkins, readStorefrontPage, SKINS, STORE_DUMP, STOREFRONT_FILE } from './catalog.cmd'
import type { StoreListing } from '../../../../apps/econ/src/catalog-load'
/**
* Generate the two general-store catalogues the econ worker serves, both from the item catalog.
* Write the 2025 general store the econ worker serves.
*
* runx storefront build
*
* They are the same store at two points in time, and the econ worker picks which file a caller
* gets from their token's `rn.ver`:
* Storefront 3 is served from one of two files, picked by the caller's token `rn.ver`:
*
* sf3.json items that existed by LEGACY_CLIENT_BUILD — what the 2023 client is served
* sf3-2025.json every sellable item — what a later build is served
* sf3.json the 2023 store — what builds up to LEGACY_CLIENT_BUILD are served. FROZEN:
* nothing regenerates it, and it is not touched here.
* sf3-2025.json the 2025 store — what a later build is served. Written here, from the dump
* of the real thing.
*
* 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.
* `sf3-2025.json` is `static/db/Watch_EnumValue_3.json` — storefront 3 as the live game served
* it in 2026, one page, 3300-odd listings — with three edits, none of which touches an id, an
* item or a list price, and one addition:
*
* Both are static catalogues like every other `sf{N}.json`, because that is the only shape
* `loadStorefront` reads and because browse and BUY have to agree: `findStoreItem` resolves a
* purchase against the very same file, so an item that is not in it cannot be bought.
* sales the dump was taken during a store-wide sale (80% off nearly everything, no end
* date). The client applies a listing's `StorefrontSaleData` itself and posts the
* discounted number as `RequestedPrice`; this server's price check knows only
* list prices, so a sale that stays in the file refuses every purchase as "Price
* has changed". Every `StorefrontSaleData` is nulled and the `Price` beside it —
* the list price — stands.
* repeats twenty listings appear twice, byte for byte. One copy is kept (`findStoreItem`
* resolves a purchase by `PurchasableItemId`, and a store showing an item twice is
* a dump artifact rather than a merchandising decision) — the catalog load
* collapses them the same way, see `collapseRepeatedListings`.
* NextUpdate the dump's is the real store's next rotation, already in the past. The client
* refetches after it; this file is regenerated by hand, so it carries a far-future
* sentinel like every other static storefront here.
*
* `sf3.json` used to be a CAPTURE of the real 2023 store and is now generated. What survives of
* that capture is `static/db/consumables.json`: the 35 store items the item catalog does not
* model as sellable entries. Everything else was dropped for a reason —
* skins the store lists 99 equipment skins; `static/db/skins.json` knows 272, and the
* weekly gift pool and the discovery rows draw skins from the store. The ones the
* store never listed are appended as listings of their own — rarity 50, priced
* from it, numbered from a million — see `unlistedSkinListing`. A skin in both
* takes the store's listing: the capture's names and rarities are wrong wherever
* the two can be compared.
* thumbnails every skin listing — the game's own 99 and the appended ones alike — and every
* consumable listing has its `ThumbnailImageName` set to `""`. The dump names a
* PNG per item that this server does not have.
* gold a skin whose name carries `(Gold)` sells at `GOLD_SKIN_PRICE`, 100,000 tokens,
* subscriber price the same: no discount, no sale.
*
* avatar items the catalog has them, and generating keeps ONE source of truth for an
* item's id and price
* equipment skins not sold at all: they are awarded from weekly challenges, so a store
* listing one would sell something the game gives away
* Everything else — `SubscriberDiscountPercent`, `SubscriberPrices`, custom-item listings, the
* token bundles, the developer-tier items, `AvailableAt` dates — is the store as the game had
* it. `runx catalog load` reads the file this writes and nothing else, which is what makes a
* listing's `PurchasableItemId` and a `catalog` row's `catalog_id` one number. The dump itself
* stays in `static/db` for reference and for the next time a fresher one turns up.
*/
const OUT_DIR = 'apps/econ/static/storefronts'
/**
* The store items the item catalog does not model, kept from the 2023 capture and copied into
* both files verbatim — ids, prices and gift-drops untouched.
*
* 30 consumables and the 5 random boxes. Nothing here carries an `AvatarItemDesc` (the catalog
* generates those) or an `EquipmentModificationGuid` (skins are awarded, not sold) — which is
* exactly what qualified the rest for removal.
*/
const CARRIED_ITEMS = 'apps/econ/static/db/consumables.json'
/** RecCenterTokens — the currency the avatar storefronts sell in. */
const CURRENCY_TYPE_TOKENS = 2
/** The storefront id BOTH files report. They are two versions of one store, not two stores. */
const STOREFRONT_TYPE = 3
/**
* Far enough out that the client never refetches — the same sentinel the capture carried. A real
* storefront rotates; these are regenerated by hand, so they must not expire on their own.
* Far enough out that the client never refetches — the sentinel every static storefront here
* carries. A real storefront rotates; this file is regenerated by hand, so it must not expire
* on its own.
*/
const NEXT_UPDATE = '2226-06-14T00:12:20.1324853Z'
/** One `Prices` / `SubscriberPrices` entry, in the shape the capture used. */
const priceEntry = (p: number) => [
{ CurrencyType: CURRENCY_TYPE_TOKENS, Price: p, StorefrontSaleData: null, Type: 0 },
]
/** Whether a listing is served without its thumbnail: every skin and every consumable. */
export const blanksThumbnail = (listing: StoreListing): boolean =>
listing.GiftDrop.EquipmentModificationGuid !== '' || listing.GiftDrop.ConsumableItemDesc !== ''
/** A price list with its sale removed: the list price, nothing on top. */
const withoutSale = <T extends { StorefrontSaleData: unknown }>(prices: T[] | null): T[] | null =>
prices === null ? null : prices.map((p) => ({ ...p, StorefrontSaleData: null }))
const build = new Command('build')
.description('Generate sf3.json and sf3-2025.json from the item catalog')
.description('Write sf3-2025.json from the 2025 store dump')
.action(async () => {
const { avatarItems, skins } = await readCaptures()
const [dump, skins] = await Promise.all([readStorefrontPage(STORE_DUMP), readSkins()])
// The catalog ids come from the same loader the DB load uses, so the number in these files
// is the number in the table. An avatar item's `item_key` IS its `AvatarItemDesc`, which is
// what lets the two be matched up without a second numbering scheme to keep in step.
const { rows } = buildCatalogLoad(avatarItems, skins)
const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
const catalogIdByDesc = new Map(
rows.filter((r) => r.values[kindAt] === CatalogKind.AvatarItem).map((r) => [r.key, r.id])
)
// One listing per id — the same collapse the catalog load applies, so the file and the
// table agree on what is a repeat and what is an item.
const { listings, repeats } = collapseRepeatedListings(dump.StoreItems)
// The items the catalog does not model — consumables and the random boxes. Carried across
// verbatim; the catalog has no prices for these and does not model a box at all. They were
// filtered when the file was cut, so nothing is re-checked here.
const carried = JSON.parse(
await fs.readFile(path.join(getRepoRoot(), CARRIED_ITEMS), 'utf8')
) as Array<{ PurchasableItemId: number }>
const byRarity = new Map<number, number>()
const excluded = new Map<number, number>()
const forSale = avatarItems.filter((item) => {
if (isSellableRarity(item.Rarity)) return true
excluded.set(item.Rarity, (excluded.get(item.Rarity) ?? 0) + 1)
return false
})
const storeItems = forSale.map((item) => {
const catalogId = catalogIdByDesc.get(item.AvatarItemDesc)
if (catalogId === undefined) {
throw new Error(`no catalog id for avatar item ${item.AvatarItemDesc}`)
}
const price = priceForRarity(item.Rarity)
byRarity.set(item.Rarity, (byRarity.get(item.Rarity) ?? 0) + 1)
let sales = 0
const items = listings.map((listing) => {
if (listing.Prices.some((p) => p.StorefrontSaleData !== null)) sales++
return {
// Key order and every constant field mirror the capture, because the client's parser
// reads this shape and a store that differs from the one known to work is a needless
// variable.
GiftDrop: {
AvatarItemDesc: item.AvatarItemDesc,
AvatarItemType: item.AvatarItemType,
ConsumableItemDesc: '',
Context: 0,
Currency: 0,
CurrencyType: 0,
EquipmentModificationGuid: '',
EquipmentPrefabName: '',
FriendlyName: item.FriendlyName,
// The capture has GiftDropId === PurchasableItemId on all 1161 of its items.
GiftDropId: catalogId,
IsQuery: false,
ItemSetFriendlyName: '',
ItemSetId: 0,
Rarity: item.Rarity,
SubscribersOnly: false,
// The catalog's tooltip is genuinely null on some rows; the client's field is a
// string, so this is the one place the distinction is flattened.
Tooltip: item.Tooltip ?? '',
Unique: true,
},
IsFeatured: false,
Prices: priceEntry(price),
// The catalog id, directly — one number, no second numbering to keep in step. It is
// already clear of every captured id (see `CATALOG_ID_BASE`), which is what lets the
// carried items and these share a file.
PurchasableItemId: catalogId,
SubscriberPrices: priceEntry(subscriberPriceFor(price)),
Type: 0,
// Not emitted — only used to split the two files below.
_createdAt: item.CreatedAt,
...listing,
Prices: withoutSale(listing.Prices) ?? [],
SubscriberPrices: withoutSale(listing.SubscriberPrices),
}
})
const write = (file: string, items: typeof storeItems) => {
const merged = [...carried, ...items.map(({ _createdAt, ...rest }) => rest)]
// An id meaning two different items depending on which half answered first is the one
// thing this merge must not do. Refused rather than resolved by ordering, since the whole
// point of `CATALOG_ID_BASE` is that it cannot happen.
const ids = merged.map((i) => i.PurchasableItemId)
if (new Set(ids).size !== ids.length) {
throw new Error(
`${file}: duplicate PurchasableItemId across the carried and generated halves`
)
}
const out = `${OUT_DIR}/${file}`
writeFileSync(
out,
`${JSON.stringify(
{
NextUpdate: NEXT_UPDATE,
StoreItems: merged,
StorefrontType: STOREFRONT_TYPE,
// Deliberately 0 — the discount is expressed ONLY in `SubscriberPrices`.
// Announcing it again here risks a client taking the 10% off an already
// discounted price and posting through the server's own subscriber floor,
// refused as "Price has changed".
SubscriberDiscountPercent: 0,
},
null,
'\t'
)}\n`
)
return { out, count: merged.length }
// The skins the store never listed, appended in capture order under numbers of their own.
// A capture guid the store lists is skipped: the listing is the real record. So is a guid
// the capture repeats.
const listedSkins = new Set(items.map((i) => i.GiftDrop.EquipmentModificationGuid))
let added = 0
for (const skin of skins) {
if (listedSkins.has(skin.ModificationGuid)) continue
listedSkins.add(skin.ModificationGuid)
items.push(unlistedSkinListing(skin, UNLISTED_SKIN_ID_BASE + added++))
}
const legacy = storeItems.filter((i) => existedByLegacyBuild(i._createdAt))
const sf3 = write('sf3.json', legacy)
const sf32025 = write('sf3-2025.json', storeItems)
const table = new Table({ head: ['rarity', 'items', 'price', 'subscriber'] })
for (const rarity of [...byRarity.keys()].sort((a, b) => a - b)) {
const price = priceForRarity(rarity)
table.push([
String(rarity),
String(byRarity.get(rarity)),
String(price),
String(subscriberPriceFor(price)),
])
// No skin and no consumable names a thumbnail — the dump's PNGs are not served here.
let blanked = 0
for (const item of items) {
if (!blanksThumbnail(item)) continue
if (item.GiftDrop.ThumbnailImageName !== '') blanked++
item.GiftDrop = { ...item.GiftDrop, ThumbnailImageName: '' }
}
// Gold skins at their prestige price, in tokens only, no discount.
let gold = 0
for (const item of items) {
if (!isGoldSkin(item)) continue
gold++
const priced = { CurrencyType: 2, Price: GOLD_SKIN_PRICE, StorefrontSaleData: null }
item.Prices = [priced]
item.SubscriberPrices = [priced]
}
writeFileSync(
STOREFRONT_FILE,
`${JSON.stringify({ ...dump, StoreItems: items, NextUpdate: NEXT_UPDATE }, null, '\t')}\n`
)
const perKind = new Map<string, number>()
for (const listing of items) {
const k = listingKind(listing)
perKind.set(k, (perKind.get(k) ?? 0) + 1)
}
const table = new Table({ head: ['listing', 'count'] })
for (const [k, n] of [...perKind].sort((a, b) => b[1] - a[1])) table.push([k, String(n)])
console.log(table.toString())
for (const [rarity, n] of [...excluded].sort((a, b) => a[0] - b[0])) {
console.log(chalk.yellow(`excluded ${n} item(s) of rarity ${rarity} — not for sale`))
}
const undated = storeItems.filter((i) => i._createdAt === undefined).length
if (undated > 0) {
console.log(
chalk.yellow(
`${undated} item(s) carry no CreatedAt and are left out of sf3 — nothing shows they predate the cutoff`
)
)
const unpriced = items.filter((i) => i.Prices.length === 0).length
if (unpriced > 0) {
console.log(chalk.yellow(`${unpriced} listing(s) carry no price and cannot be bought`))
}
console.log(
chalk.green(
`✓ ${sf3.out}: ${sf3.count} items ` +
`(${carried.length} carried + ${legacy.length} created before ${LEGACY_CLIENT_BUILD_DATE})`
`✓ ${STOREFRONT_FILE}: ${items.length} listings from ${STORE_DUMP} ` +
`(${repeats} repeated listing(s) dropped, ${sales} sale(s) removed) ` +
`+ ${added} skin(s) from ${SKINS} at rarity ${UNLISTED_SKIN_RARITY}, ` +
`${blanked} skin/consumable thumbnail(s) blanked, ${gold} gold skin(s) at ${GOLD_SKIN_PRICE}`
)
)
console.log(
chalk.green(
`✓ ${sf32025.out}: ${sf32025.count} items ` +
`(${carried.length} carried + ${storeItems.length} from the catalog)`
)
)
console.log(
` build ${LEGACY_CLIENT_BUILD} and earlier is served sf3.json; later builds sf3-2025.json`
` build ${LEGACY_CLIENT_BUILD} and earlier is served the frozen sf3.json; later builds this file`
)
})
export const storefrontCmd = new Command('storefront')
.description('Generate the storefront catalogs the econ worker serves')
.description('Write the 2025 general store the econ worker serves')
// Bare `storefront` (no subcommand) prints help and exits cleanly, rather than commander's
// default "missing command" error (exit 1).
.action((_opts, command: Command) => command.outputHelp())
@@ -234,16 +169,19 @@ export const storefrontCmd = new Command('storefront')
.addHelpText(
'after',
`
Both files are the general store at two points in time, generated from the item catalog and
served under storefront id 3. The econ worker picks which one a caller gets from their build:
sf3.json for ${LEGACY_CLIENT_BUILD} and earlier, sf3-2025.json for later.
Storefront 3 is served from two files by the caller's build: sf3.json (the 2023 store,
frozen) for ${LEGACY_CLIENT_BUILD} and earlier, sf3-2025.json for later builds.
The consumables and random boxes come from apps/econ/static/db/consumables.json — what survives
of the 2023 capture once its avatar items (the catalog has those) and its equipment skins
(awarded from weekly challenges, never sold) were dropped.
sf3-2025.json is the real 2025 store — ${STORE_DUMP} — with its captured
sale removed, repeated listings collapsed and a far-future NextUpdate, plus the equipment skins
the store never listed (${SKINS}) as rarity-${UNLISTED_SKIN_RARITY} listings numbered from
${UNLISTED_SKIN_ID_BASE}; every skin's and consumable's ThumbnailImageName is ""; a "(Gold)"
skin sells at ${GOLD_SKIN_PRICE} tokens with no discount. Ids, items and list prices are
otherwise the dump's. \`runx catalog
load\` reads the file this writes and nothing else, so a listing's PurchasableItemId is its
catalog_id.
Regenerate whenever apps/econ/static/db/*.json changes, and after every \`runx catalog load\`:
a load renumbers catalog_id, and a stale file would list the wrong items.
Regenerate whenever the dump or the skin capture changes, then reload the catalog.
Examples:
$ runx storefront build`