[econ] remove consumables from sf3, fix weekly

This commit is contained in:
Devin Zuczek
2026-08-28 14:03:56 -04:00
parent 77acb008b1
commit 750413f240
7 changed files with 110000 additions and 91916 deletions
+118 -64
View File
@@ -8,7 +8,10 @@ import {
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CatalogKind,
existedByLegacyBuild,
isSellableRarity,
LEGACY_CLIENT_BUILD,
LEGACY_CLIENT_BUILD_DATE,
priceForRarity,
subscriberPriceFor,
} from '../../../../apps/econ/src/catalog-load'
@@ -16,57 +19,69 @@ import { getRepoRoot } from '../path'
import { readCaptures } from './catalog.cmd'
/**
* Generate `apps/econ/static/storefronts/sf3-2025.json` — the general store as the 2025 client
* sees it: everything sf3 already sells, PLUS every sellable row of the item catalog.
* Generate the two general-store catalogues the econ worker serves, both from the item catalog.
*
* runx storefront build
*
* One merged file rather than a second storefront id. The client asks for storefront 3 either
* way; the econ worker picks WHICH file by the caller's build (`rn.ver`), so an older build
* keeps exactly the sf3 it has always had and a newer one gets the same store with the
* catalog's items added to it.
* 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`:
*
* A static catalog like every other `sf{N}.json`, because that is the only shape
* 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
*
* 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.
*
* 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.
*
* The two id spaces do not collide, which is what makes the merge safe: every captured sf3 id
* is 2764 or below (one outlier aside) and every catalog id starts at `CATALOG_ID_BASE`
* (10000). Nothing is renumbered, and an id means the same item in both files.
* `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 —
*
* 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
*/
const OUT_DIR = 'apps/econ/static/storefronts'
/** The captured general store, whose items the generated one is built on top of. */
const BASE_STOREFRONT = `${OUT_DIR}/sf3.json`
/** The merged store the newer client is served, and the `StorefrontType` it reports. */
const OUT_FILE = `${OUT_DIR}/sf3-2025.json`
const STOREFRONT_TYPE = 3
/**
* 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 sf3 carries. A real
* storefront rotates; this one is regenerated by hand, so it must not expire on its own.
* 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.
*/
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 },
]
const build = new Command('build')
.description('Generate the merged 2025 general store (sf3 + the item catalog)')
.description('Generate sf3.json and sf3-2025.json from the item catalog')
.action(async () => {
const { avatarItems, skins } = await readCaptures()
// sf3's own items, carried through UNCHANGED. They keep their ids, their prices and their
// gift-drops: the merge adds to the store the older client knows, it does not restate it.
const basePath = path.join(getRepoRoot(), BASE_STOREFRONT)
const base = JSON.parse(await fs.readFile(basePath, 'utf8')) as {
StoreItems: Array<{ PurchasableItemId: number }>
}
// The catalog ids come from the same loader the DB load uses, so the number in this file is
// the number in the table. An avatar item's `item_key` IS its `AvatarItemDesc`, which is
// 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')
@@ -74,6 +89,13 @@ const build = new Command('build')
rows.filter((r) => r.values[kindAt] === CatalogKind.AvatarItem).map((r) => [r.key, r.id])
)
// 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) => {
@@ -81,6 +103,7 @@ const build = new Command('build')
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) {
@@ -88,12 +111,10 @@ const build = new Command('build')
}
const price = priceForRarity(item.Rarity)
byRarity.set(item.Rarity, (byRarity.get(item.Rarity) ?? 0) + 1)
const priceEntry = (p: number) => [
{ CurrencyType: CURRENCY_TYPE_TOKENS, Price: p, StorefrontSaleData: null, Type: 0 },
]
return {
// Key order and every constant field mirror sf3, because the client's parser reads
// this shape and an sf that differs from the one known to work is a needless variable.
// 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,
@@ -104,7 +125,7 @@ const build = new Command('build')
EquipmentModificationGuid: '',
EquipmentPrefabName: '',
FriendlyName: item.FriendlyName,
// sf3 has GiftDropId === PurchasableItemId on all 1161 of its items; keep that.
// The capture has GiftDropId === PurchasableItemId on all 1161 of its items.
GiftDropId: catalogId,
IsQuery: false,
ItemSetFriendlyName: '',
@@ -119,37 +140,51 @@ const build = new Command('build')
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 storefront's ids (see `CATALOG_ID_BASE`), which is
// what lets it be used here as-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,
}
})
// An id colliding across the two would make one number mean two different items depending
// on which half answered first — refused rather than resolved by ordering, since the whole
// point of `CATALOG_ID_BASE` is that this cannot happen.
const baseIds = new Set(base.StoreItems.map((i) => i.PurchasableItemId))
const collisions = storeItems.filter((i) => baseIds.has(i.PurchasableItemId))
if (collisions.length > 0) {
throw new Error(
`${collisions.length} catalog id(s) collide with sf3's own, starting at ` +
`${collisions[0]?.PurchasableItemId}. The catalog must be renumbered above them.`
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 }
}
const storefront = {
NextUpdate: NEXT_UPDATE,
StoreItems: [...base.StoreItems, ...storeItems],
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,
}
writeFileSync(OUT_FILE, `${JSON.stringify(storefront, null, '\t')}\n`)
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)) {
@@ -165,13 +200,29 @@ const build = new Command('build')
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`
)
)
}
console.log(
chalk.green(
` wrote ${OUT_FILE}: ${storefront.StoreItems.length} items ` +
`(${base.StoreItems.length} from sf3 + ${storeItems.length} from the catalog, ` +
`${((await fs.stat(OUT_FILE)).size / 1024 / 1024).toFixed(1)} MB)`
`${sf3.out}: ${sf3.count} items ` +
`(${carried.length} carried + ${legacy.length} created before ${LEGACY_CLIENT_BUILD_DATE})`
)
)
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`
)
})
export const storefrontCmd = new Command('storefront')
@@ -183,14 +234,17 @@ export const storefrontCmd = new Command('storefront')
.addHelpText(
'after',
`
sf3-2025 is the general store as the 2025 client sees it: everything sf3 already sells plus
every sellable row of the item catalog, priced by rarity. The client asks for storefront 3
either way — the econ worker picks which file by the caller's build, so an older client keeps
the sf3 it has always had.
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.
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.
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.
Examples:
$ runx storefront build # write apps/econ/static/storefronts/sf3-2025.json`
$ runx storefront build`
)