mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] remove consumables from sf3, fix weekly
This commit is contained in:
@@ -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`
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user