mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] skins and consumables
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user