/** * Fills in `Price` on an export of first-party custom avatar items from a storefront dump, * rewriting the export in place so `import-custom-avatar-items.ts` / `just admin cai-load` * carry the price into the table. * * bun apps/api/scripts/price-custom-avatar-items.ts * * The export (e.g. `apps/econ/static/db/2025-1-cai.json`) mostly carries `Price: 0`. The * storefront (e.g. `Watch_EnumValue_3.json`, a `{ StoreItems: [...] }` page) prices the same * items, joined on `GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`. * The price taken is the base token price (`CurrencyType` 2), ignoring any sale. The storefront * only prices what the export already holds: nothing is added from it. * * An item the storefront doesn't list keeps the price it had, unless that price is 0, which * would make it free to buy. Those are the Cryptid Creek event items and the Evergrown Hip Pack, * all rarity-50 rewards that were never sold, so they get the catalog's rarity-50 price. */ import { readFileSync, writeFileSync } from 'node:fs' import { priceForRarity } from '../../econ/src/catalog-load' const TOKENS = 2 /** The rarity an item missing from the storefront is priced as, when it has no price. */ const UNLISTED_RARITY = 50 interface StoreItem { GiftDrop?: { AvatarItemInfo?: { DownloadableAvatarItemId?: string | null } | null } Prices?: Array<{ CurrencyType: number; Price: number }> } const [exportPath, storefrontPath] = process.argv.slice(2) if (!exportPath || !storefrontPath) { console.error( 'usage: bun apps/api/scripts/price-custom-avatar-items.ts ' ) process.exit(2) } // Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF). const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8').replace(/^/, '')) const items: Array<{ CustomAvatarItemId: string; Name: string; Price: number }> = readJson(exportPath) const storefront: { StoreItems: StoreItem[] } = readJson(storefrontPath) const prices = new Map() for (const storeItem of storefront.StoreItems) { const id = storeItem.GiftDrop?.AvatarItemInfo?.DownloadableAvatarItemId?.toLowerCase() const price = storeItem.Prices?.find((p) => p.CurrencyType === TOKENS)?.Price if (id && price !== undefined && !prices.has(id)) { prices.set(id, price) } } let priced = 0 let changed = 0 const unmatched: string[] = [] for (const item of items) { let price = prices.get(item.CustomAvatarItemId.toLowerCase()) if (price === undefined) { unmatched.push(item.Name) if (item.Price) continue price = priceForRarity(UNLISTED_RARITY) } priced++ if (item.Price !== price) { if (item.Price) { console.warn(`${item.Name}: ${item.Price} -> ${price}`) } item.Price = price changed++ } } writeFileSync(exportPath, JSON.stringify(items, null, 2) + '\n') console.log(`priced ${priced}/${items.length} items (${changed} changed) in ${exportPath}`) if (unmatched.length) { console.log( `not in the storefront (kept their price, or rarity ${UNLISTED_RARITY} if free): ${unmatched.join(', ')}` ) }