diff --git a/apps/econ/src/catalog-db.ts b/apps/econ/src/catalog-db.ts index 84db659..60bfab7 100644 --- a/apps/econ/src/catalog-db.ts +++ b/apps/econ/src/catalog-db.ts @@ -149,6 +149,14 @@ export interface CatalogRow { * * `AvatarItemId` is nullable and `Tooltip` may be null; both are true of the captured data and * the client reads them that way. Do not tighten either to keep a projection simple. + * + * `CreatedAt` and `ThumbnailImage` are OPTIONAL, never null: the capture this record mirrors + * carried a date on most rows and simply had no key on the rest — the permanent hair dyes + * among them — and never `null`. A row loaded from a store listing has no date at all, and + * serving it as `"CreatedAt": null` on every record (as `lockeditems/bulk` did once the + * loader moved to the store) broke the dorm mirror's hair dyes: the client resolves locked + * hair colours through that route, and a null where it expects a date costs it the whole + * response, not the field. Leave the key out, as the capture did. */ export interface CatalogAvatarItem { AvatarItemDesc: string @@ -160,8 +168,8 @@ export interface CatalogAvatarItem { TagList: string | null AvatarItemId: number | null IsBaseAvatarItem: boolean - CreatedAt: string | null - ThumbnailImage: string | null + CreatedAt?: string + ThumbnailImage?: string } /** @@ -201,8 +209,9 @@ export function toCatalogAvatarItem(row: CatalogRow): CatalogAvatarItem { TagList: row.tag_list, AvatarItemId: row.avatar_item_id, IsBaseAvatarItem: row.is_base_avatar_item === 1, - CreatedAt: row.created_at, - ThumbnailImage: row.thumbnail_image, + // Absent rather than null when the row has none — see the record's note. + ...(row.created_at !== null ? { CreatedAt: row.created_at } : {}), + ...(row.thumbnail_image !== null ? { ThumbnailImage: row.thumbnail_image } : {}), } } diff --git a/apps/econ/static/storefronts/sf3-2025.json b/apps/econ/static/storefronts/sf3-2025.json index 1f44e74..4839cb2 100644 --- a/apps/econ/static/storefronts/sf3-2025.json +++ b/apps/econ/static/storefronts/sf3-2025.json @@ -152622,7 +152622,7 @@ "Unique": true, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -152675,7 +152675,7 @@ "Unique": true, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -152728,7 +152728,7 @@ "Unique": true, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -152781,7 +152781,7 @@ "Unique": true, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -152834,7 +152834,7 @@ "Unique": true, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -152887,7 +152887,7 @@ "Unique": true, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -156881,7 +156881,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -156940,7 +156940,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -156999,7 +156999,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157058,7 +157058,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157117,7 +157117,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157176,7 +157176,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157235,7 +157235,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157294,7 +157294,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157353,7 +157353,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157412,7 +157412,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157471,7 +157471,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157530,7 +157530,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157589,7 +157589,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157648,7 +157648,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157707,7 +157707,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157766,7 +157766,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157825,7 +157825,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157884,7 +157884,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -157943,7 +157943,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -158002,7 +158002,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -158061,7 +158061,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -158120,7 +158120,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 50, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -158946,7 +158946,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -158999,7 +158999,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -159052,7 +159052,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -159105,7 +159105,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -159158,7 +159158,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -159211,7 +159211,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -165512,7 +165512,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -165565,7 +165565,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -165618,7 +165618,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -165671,7 +165671,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -165724,7 +165724,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -172898,7 +172898,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -172951,7 +172951,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -173004,7 +173004,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -173057,7 +173057,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -173110,7 +173110,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -173163,7 +173163,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 20, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, @@ -183947,7 +183947,7 @@ "Unique": false, "SubscribersOnly": false, "Rarity": 30, - "Context": -1, + "Context": 0, "Currency": 0, "CurrencyType": 0, "ItemCount": 1, diff --git a/packages/tools/src/cmd/storefront.cmd.ts b/packages/tools/src/cmd/storefront.cmd.ts index bfd4cf1..1b260cf 100644 --- a/packages/tools/src/cmd/storefront.cmd.ts +++ b/packages/tools/src/cmd/storefront.cmd.ts @@ -59,6 +59,14 @@ import type { StoreListing } from '../../../../apps/econ/src/catalog-load' * 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. + * context a drop whose `Context` is `NO_CONTEXT` (-1) gets 0. The dump carries -1 on + * exactly 46 listings — the 22 permanent hair dyes and the 24 dice skins, the + * things the live game sells from a surface of its own rather than the store page + * — and every other listing says 0 or a positive category. Served with -1 the hair + * dyes did not work; swapping those 22 listings for the ones the earlier generated + * store carried (Context 0, ids and prices of its own) fixed them, and -1 is the + * one member that set the dyes apart from the thousands of listings that work. + * See `withStoreContext`. * * Everything else — `SubscriberDiscountPercent`, `SubscriberPrices`, custom-item listings, the * token bundles, the developer-tier items, `AvailableAt` dates — is the store as the game had @@ -78,6 +86,22 @@ const NEXT_UPDATE = '2226-06-14T00:12:20.1324853Z' export const blanksThumbnail = (listing: StoreListing): boolean => listing.GiftDrop.EquipmentModificationGuid !== '' || listing.GiftDrop.ConsumableItemDesc !== '' +/** The `Context` the dump puts on a drop the store page does not sell: the hair dyes, the dice skins. */ +export const NO_CONTEXT = -1 + +/** The `Context` every ordinary listing carries — what the generated store gave the hair dyes. */ +const STORE_CONTEXT = 0 + +/** Whether a listing's drop carries the dump's out-of-store `Context`. */ +export const hasNoContext = (listing: StoreListing): boolean => + listing.GiftDrop.Context === NO_CONTEXT + +/** The listing with its drop's `Context` brought in from {@link NO_CONTEXT} to the store's. */ +export const withStoreContext = (listing: StoreListing): StoreListing => ({ + ...listing, + GiftDrop: { ...listing.GiftDrop, Context: STORE_CONTEXT }, +}) + /** A price list with its sale removed: the list price, nothing on top. */ const withoutSale = (prices: T[] | null): T[] | null => prices === null ? null : prices.map((p) => ({ ...p, StorefrontSaleData: null })) @@ -130,6 +154,14 @@ const build = new Command('build') item.SubscriberPrices = [priced] } + // Out-of-store drops brought onto the store page, see `withStoreContext`. + let recontexted = 0 + for (const [i, item] of items.entries()) { + if (!hasNoContext(item)) continue + recontexted++ + items[i] = withStoreContext(item) + } + writeFileSync( STOREFRONT_FILE, `${JSON.stringify({ ...dump, StoreItems: items, NextUpdate: NEXT_UPDATE }, null, '\t')}\n` @@ -152,7 +184,8 @@ const build = new Command('build') `✓ ${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}` + `${blanked} skin/consumable thumbnail(s) blanked, ${gold} gold skin(s) at ${GOLD_SKIN_PRICE}, ` + + `${recontexted} drop(s) with Context ${NO_CONTEXT} set to ${STORE_CONTEXT}` ) ) console.log( @@ -176,8 +209,9 @@ 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 +skin sells at ${GOLD_SKIN_PRICE} tokens with no discount; a drop with Context ${NO_CONTEXT} (the hair +dyes and the dice skins) is served with Context 0. 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.