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:
+26
-23
@@ -3,9 +3,9 @@ import { CatalogKind } from './catalog-load'
|
||||
import type { CatalogKindValue } from './catalog-load'
|
||||
|
||||
/**
|
||||
* The item CATALOG on the shared `recflare` D1 database — every avatar item and every
|
||||
* equipment skin the game knows about, one row each, loaded once by migration rather than
|
||||
* written at runtime. Nothing here is per-player: ownership lives in `inventory` and
|
||||
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
|
||||
* equipment skin the game knows about, one row each, loaded by a CLI rather than written at
|
||||
* runtime. Nothing here is per-player: ownership lives in `inventory` and
|
||||
* `equipment`, and this table only says what a thing IS.
|
||||
*
|
||||
* It exists to be QUERIED. The catalogs were previously reachable only by parsing a whole
|
||||
@@ -14,15 +14,16 @@ import type { CatalogKindValue } from './catalog-load'
|
||||
* nothing can be searched by name or filtered by tag at all. A row with indexes answers those
|
||||
* in one statement.
|
||||
*
|
||||
* BOTH kinds share one table because they share most of a record — a display name, a tooltip, a
|
||||
* ALL kinds share one table because they share most of a record — a display name, a tooltip, a
|
||||
* rarity, a platform mask, a thumbnail — and because the interesting queries ("what is called
|
||||
* X", "what is this thing the player owns") run across both. `kind` discriminates, and the
|
||||
* columns each kind alone carries are nullable and empty on the other. Read a row through
|
||||
* {@link toCatalogAvatarItem}/{@link toCatalogSkin} rather than serving it raw: the client's
|
||||
* two DTOs share no key order and differ in what they omit.
|
||||
*
|
||||
* Where the rows come from: `static/db/avatar-items.json` and `static/db/skins.json`, both
|
||||
* captured from the reference and loaded by `runx catalog load`.
|
||||
* Where the rows come from: the served 2025 general store (`static/storefronts/sf3-2025.json`)
|
||||
* and `static/db/skins.json`, loaded by `runx catalog load`. A listed row's `catalog_id` is
|
||||
* its store `PurchasableItemId`.
|
||||
*
|
||||
* The migration builds the TABLE ONLY — it holds no rows. The catalog changes as the game's
|
||||
* item list changes, and that is not a schema change: a migration per refresh would mean a
|
||||
@@ -42,12 +43,15 @@ export {
|
||||
buildCatalogLoad,
|
||||
CATALOG_INSERT_COLUMNS,
|
||||
CatalogKind,
|
||||
type AvatarItemCapture,
|
||||
listingKind,
|
||||
type CatalogCollision,
|
||||
type CatalogKindValue,
|
||||
type CatalogLoad,
|
||||
type CatalogLoadRow,
|
||||
type CatalogValue,
|
||||
type SkinCapture,
|
||||
type StoreListing,
|
||||
type StorefrontDump,
|
||||
} from './catalog-load'
|
||||
|
||||
/**
|
||||
@@ -66,12 +70,11 @@ export {
|
||||
* avatar items and repeated on 9 more (id 9503 alone covers five unrelated developer items), so
|
||||
* it can neither key nor reliably find anything.
|
||||
*
|
||||
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy, and is
|
||||
* what a generated storefront lists a row under as its `PurchasableItemId`. It is assigned by
|
||||
* the loader from `CATALOG_ID_BASE` (10000, clear of every captured storefront's own ids) and
|
||||
* renumbered by every load, so it identifies a row only within one load — see the field's own
|
||||
* note. It is unique where set, and the migration that adds it is 0016, since 0015 was already
|
||||
* applied.
|
||||
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
|
||||
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
|
||||
* sells it under — and for a skin the store never listed it is handed out by the loader from
|
||||
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
|
||||
* adds it is 0016, since 0015 was already applied.
|
||||
*
|
||||
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
|
||||
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
|
||||
@@ -113,13 +116,14 @@ export interface CatalogRow {
|
||||
item_key: string
|
||||
/**
|
||||
* A small numeric handle for the row — for the surfaces that need to name an item as a
|
||||
* number rather than as a comma-laden `AvatarItemDesc` or a guid, and the
|
||||
* `PurchasableItemId` a generated storefront lists it under.
|
||||
* number rather than as a comma-laden `AvatarItemDesc` or a guid. For a listed item it is
|
||||
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
|
||||
* `sf3-2025.json` alike.
|
||||
*
|
||||
* A LOAD-ORDER SURROGATE, not an identity: `runx catalog load` assigns it, so it is stable
|
||||
* only until the next load. Never store it, never reference it across a load, never treat it
|
||||
* as what an item IS — `item_key` is that, and it is what the inventory holds. NULL only
|
||||
* between a row existing and a load numbering it.
|
||||
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
|
||||
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
|
||||
* and it is what the inventory holds. NULL only between a row existing and a load numbering
|
||||
* it.
|
||||
*/
|
||||
catalog_id: number | null
|
||||
kind: string
|
||||
@@ -244,11 +248,10 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog row by its numeric handle.
|
||||
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
|
||||
*
|
||||
* Only meaningful WITHIN a load: the number is reassigned every time the catalog is loaded, so
|
||||
* a caller holding one from before a reload will get a different item or nothing at all. Fine
|
||||
* for a request that looked the number up moments ago; never for anything stored.
|
||||
* Fine for a request that looked the number up moments ago; never for anything stored, since
|
||||
* an unlisted skin's number is reassigned by every load.
|
||||
*/
|
||||
export async function getCatalogItemById(
|
||||
db: D1Database,
|
||||
|
||||
+358
-137
@@ -10,6 +10,15 @@
|
||||
* The mapping lives beside the schema it fills (rather than in the CLI) so that a column added
|
||||
* to the table and a column added to the loader cannot drift apart — a test pins that they
|
||||
* agree, and the loader renders values POSITIONALLY, so a mismatch is a silent mis-load.
|
||||
*
|
||||
* WHERE THE ROWS COME FROM. The 2025 general store as this worker serves it, and nothing else:
|
||||
* `static/storefronts/sf3-2025.json`, the live game's storefront 3 dumped
|
||||
* (`static/db/Watch_EnumValue_3.json`) and written out by `runx storefront build` with its
|
||||
* captured sale stripped and the skins the store never listed (`static/db/skins.json`)
|
||||
* appended as listings of their own. Loading the served file is what makes a row here and a
|
||||
* listing there one record: same asset id, same number, always. An earlier `avatar-items.json`
|
||||
* capture used to be the source and a storefront was generated FROM the table with invented
|
||||
* ids; the dump made both unnecessary.
|
||||
*/
|
||||
|
||||
/** What a catalog row IS — the discriminator, and what says which id `item_key` holds. */
|
||||
@@ -18,34 +27,78 @@ export const CatalogKind = {
|
||||
AvatarItem: 'avatar_item',
|
||||
/** An equipment skin: a re-skin of a held prefab. Its `item_key` is the `ModificationGuid`. */
|
||||
Skin: 'skin',
|
||||
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
|
||||
Consumable: 'consumable',
|
||||
} as const
|
||||
|
||||
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
|
||||
|
||||
/**
|
||||
* The capture's avatar-item record (`static/db/avatar-items.json`).
|
||||
*
|
||||
* Everything from `TagList` down is absent on some rows — the 22 permanent hair dyes, copied
|
||||
* in from the 2025 storefront dump (`static/db/Watch_EnumValue_3.json`), carry no `CreatedAt`
|
||||
* because a store listing records none — so those are optional rather than nullable. The
|
||||
* distinction matters: a missing key and a null value both land as NULL, but only one of them
|
||||
* is a field the capture actually recorded.
|
||||
*/
|
||||
export interface AvatarItemCapture {
|
||||
AvatarItemDesc: string
|
||||
AvatarItemType: number
|
||||
PlatformMask: number
|
||||
FriendlyName: string
|
||||
Tooltip: string | null
|
||||
Rarity: number
|
||||
TagList?: string | null
|
||||
AvatarItemId?: number
|
||||
IsBaseAvatarItem?: boolean
|
||||
CreatedAt?: string
|
||||
ThumbnailImage?: string | null
|
||||
/** One price on a store listing. `StorefrontSaleData` is a discount the dump captured live. */
|
||||
export interface StoreListingPrice {
|
||||
CurrencyType: number
|
||||
Price: number
|
||||
StorefrontSaleData: {
|
||||
SalePercent: number
|
||||
SaleStartDate: string | null
|
||||
SaleEndDate: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** The capture's skin record (`static/db/skins.json`). Every field is present on every row. */
|
||||
/**
|
||||
* A store listing's `GiftDrop` as the 2025 dump records it — the client's own `GiftDrop` class.
|
||||
* Only the members the loader reads are typed; the rest ride along untouched.
|
||||
*
|
||||
* Exactly ONE of the four item ids is set on a listing that carries an item: `AvatarItemDesc`,
|
||||
* `ConsumableItemDesc`, `EquipmentModificationGuid` or `CustomAvatarItemId`. A query box
|
||||
* (`IsQuery`) and a token bundle (`Currency` > 0) carry none.
|
||||
*/
|
||||
export interface StoreListingGiftDrop {
|
||||
GiftDropId: number
|
||||
FriendlyName: string
|
||||
Tooltip: string | null
|
||||
TagList: string | null
|
||||
ConsumableItemDesc: string
|
||||
AvatarItemDesc: string
|
||||
CustomAvatarItemId: string | null
|
||||
AvatarItemType: number | null
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
IsQuery: boolean
|
||||
Rarity: number
|
||||
Currency: number
|
||||
CurrencyType: number
|
||||
AvatarItemId: number
|
||||
ThumbnailImageName: string | null
|
||||
[extra: string]: unknown
|
||||
}
|
||||
|
||||
/** One listing of the store dump — a `StoreItems[]` entry, as the client reads it. */
|
||||
export interface StoreListing {
|
||||
PurchasableItemId: number
|
||||
GiftDrop: StoreListingGiftDrop
|
||||
Prices: StoreListingPrice[]
|
||||
SubscriberPrices: StoreListingPrice[] | null
|
||||
[extra: string]: unknown
|
||||
}
|
||||
|
||||
/** One storefront page — the dump (`static/db/Watch_EnumValue_3.json`) or the served file. */
|
||||
export interface StorefrontDump {
|
||||
StoreItems: StoreListing[]
|
||||
NextUpdate: string
|
||||
StorefrontType: number
|
||||
SubscriberDiscountPercent: number
|
||||
[extra: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture's skin record (`static/db/skins.json`). Every field is present on every row.
|
||||
*
|
||||
* A thin capture: every `Rarity` is 0 or 1, every `UnlockedLevel` 0, every `ThumbnailImage`
|
||||
* empty, and where a guid is also in the store the store's name and rarity are the real ones
|
||||
* and disagree with these on every row. So it is read by `runx storefront build` ALONE, for
|
||||
* the skins the store does not list — see {@link unlistedSkinListing} — and the loader never
|
||||
* sees it.
|
||||
*/
|
||||
export interface SkinCapture {
|
||||
PrefabName: string
|
||||
ModificationGuid: string
|
||||
@@ -59,16 +112,146 @@ export interface SkinCapture {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `catalog_id` a load hands out.
|
||||
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
|
||||
*
|
||||
* The catalog needs ids that cannot be confused with any captured storefront's, because a
|
||||
* generated storefront lists a row under its `catalog_id` DIRECTLY — one number, no second
|
||||
* numbering and no arithmetic between them. Every real captured `PurchasableItemId` is 2764 or
|
||||
* below (one sf3 outlier at 20756767 aside), so numbering from 1 would have collided with sf3's
|
||||
* own head-on and the same id would mean two different items depending on which storefront the
|
||||
* client read it from. Starting at 10000 puts the whole catalog somewhere nothing else uses.
|
||||
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
|
||||
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
|
||||
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
|
||||
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
|
||||
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
|
||||
* consumable listed twice, and the catalog is of things, not of listings.
|
||||
*/
|
||||
export const CATALOG_ID_BASE = 10_000
|
||||
export type ListingKind =
|
||||
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
|
||||
|
||||
/** Classify one listing by which of its item ids is set. */
|
||||
export function listingKind(listing: StoreListing): ListingKind {
|
||||
const drop = listing.GiftDrop
|
||||
if (drop.AvatarItemDesc !== '') return CatalogKind.AvatarItem
|
||||
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
|
||||
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
|
||||
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
|
||||
return 'custom_avatar_item'
|
||||
}
|
||||
if (drop.IsQuery) return 'query'
|
||||
if (drop.Currency > 0) return 'currency'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* The dump's listings with its verbatim repeats collapsed — twenty listings appear twice, byte
|
||||
* for byte. One copy is kept, in first position; a repeated id whose copies DIFFER is refused,
|
||||
* since order would then decide what the id means. The storefront file is written from this,
|
||||
* and the catalog load reads that file, so neither sees a repeat as a duplicate item.
|
||||
*/
|
||||
export function collapseRepeatedListings(listings: StoreListing[]): {
|
||||
listings: StoreListing[]
|
||||
repeats: number
|
||||
} {
|
||||
const byId = new Map<number, string>()
|
||||
const kept: StoreListing[] = []
|
||||
let repeats = 0
|
||||
for (const listing of listings) {
|
||||
const json = JSON.stringify(listing)
|
||||
const first = byId.get(listing.PurchasableItemId)
|
||||
if (first === undefined) {
|
||||
byId.set(listing.PurchasableItemId, json)
|
||||
kept.push(listing)
|
||||
} else if (first === json) {
|
||||
repeats++
|
||||
} else {
|
||||
throw new Error(
|
||||
`PurchasableItemId ${listing.PurchasableItemId} is listed twice with different contents`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { listings: kept, repeats }
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `PurchasableItemId` given to a skin the store does NOT list.
|
||||
*
|
||||
* Every other number in `sf3-2025.json` is the game's own. The skins the 2025 store never sold
|
||||
* still have to be listed — the weekly gift pool and the discovery rows draw skins from the
|
||||
* store, and a `catalog` row IS a listing — so they are appended with numbers of their own,
|
||||
* from here, in `skins.json` order. The store's ids top out below 40 000; a million is clear of
|
||||
* anything a fresher dump could bring.
|
||||
*/
|
||||
export const UNLISTED_SKIN_ID_BASE = 1_000_000
|
||||
|
||||
/** The rarity an unlisted skin is sold at: the top tier, where the store puts most of its own. */
|
||||
export const UNLISTED_SKIN_RARITY = 50
|
||||
|
||||
/**
|
||||
* What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the
|
||||
* capture. A prestige price, and the same for a subscriber: no discount, no sale.
|
||||
*/
|
||||
export const GOLD_SKIN_PRICE = 100_000
|
||||
|
||||
/** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */
|
||||
export const isGoldSkin = (listing: StoreListing): boolean =>
|
||||
listing.GiftDrop.EquipmentModificationGuid !== '' &&
|
||||
listing.GiftDrop.FriendlyName.includes('(Gold)')
|
||||
|
||||
/** RecCenterTokens — the currency the store sells in. */
|
||||
const CURRENCY_TYPE_TOKENS = 2
|
||||
|
||||
/**
|
||||
* A skin the store does not list, AS a listing — the dump's own skin shape, so the client reads
|
||||
* it like the rest. Rarity {@link UNLISTED_SKIN_RARITY} and priced from it: the store's own
|
||||
* rarity-50 skins run 1000-6000 tokens, and {@link PRICE_BY_RARITY} puts this in the middle.
|
||||
* The capture's name and tooltip. `ThumbnailImageName` is `""` — as it is on EVERY skin the
|
||||
* served store lists, the game's own included (see `runx storefront build`).
|
||||
*/
|
||||
export function unlistedSkinListing(skin: SkinCapture, id: number): StoreListing {
|
||||
const price = priceForRarity(UNLISTED_SKIN_RARITY)
|
||||
const priced = (p: number): StoreListingPrice => ({
|
||||
CurrencyType: CURRENCY_TYPE_TOKENS,
|
||||
Price: p,
|
||||
StorefrontSaleData: null,
|
||||
})
|
||||
return {
|
||||
GiftDrop: {
|
||||
GiftDropId: id,
|
||||
FriendlyName: skin.FriendlyName,
|
||||
Tooltip: skin.Tooltip ?? '',
|
||||
TagList: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
CustomAvatarItemId: null,
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: skin.PrefabName,
|
||||
EquipmentModificationGuid: skin.ModificationGuid,
|
||||
IsQuery: false,
|
||||
QueryRedirectContext: null,
|
||||
QueryRedirectTag: null,
|
||||
QueryRedirectRarity: null,
|
||||
Unique: false,
|
||||
SubscribersOnly: false,
|
||||
Rarity: UNLISTED_SKIN_RARITY,
|
||||
Context: 1003,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
ItemCount: 1,
|
||||
ItemSetId: null,
|
||||
ItemSetFriendlyName: '',
|
||||
AvatarItemId: 0,
|
||||
EquipmentItemId: 0,
|
||||
ThumbnailImageName: '',
|
||||
AvatarItemInfo: null,
|
||||
},
|
||||
PurchasableItemId: id,
|
||||
Type: 0,
|
||||
Prices: [priced(price)],
|
||||
SubscriberPrices: [priced(subscriberPriceFor(price))],
|
||||
IsFeatured: false,
|
||||
NewUntil: null,
|
||||
AvailableAt: null,
|
||||
AvailableUntil: null,
|
||||
CanBeGifted: true,
|
||||
OnlyAvailableThroughCv2: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** The columns a load writes, in the order {@link toCatalogInsertRow} returns values. */
|
||||
export const CATALOG_INSERT_COLUMNS = [
|
||||
@@ -95,7 +278,7 @@ export type CatalogValue = string | number | boolean | null | undefined
|
||||
/** One row of a load, plus enough to name it in a collision report. */
|
||||
export interface CatalogLoadRow {
|
||||
key: string
|
||||
/** Its `catalog_id`: 1-based position in this load, also present inside `values`. */
|
||||
/** Its `catalog_id`: the listing's `PurchasableItemId`. */
|
||||
id: number
|
||||
label: string
|
||||
values: CatalogValue[]
|
||||
@@ -108,126 +291,185 @@ export interface CatalogCollision {
|
||||
dropped: string
|
||||
}
|
||||
|
||||
function avatarItemRow(i: AvatarItemCapture): Omit<CatalogLoadRow, 'id'> {
|
||||
/** What {@link buildCatalogLoad} produced, and what it left out. */
|
||||
export interface CatalogLoad {
|
||||
rows: CatalogLoadRow[]
|
||||
collisions: CatalogCollision[]
|
||||
/** Listings that load as nothing, counted by {@link ListingKind}. */
|
||||
skipped: Partial<Record<ListingKind, number>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The store records no platform mask — every listing is for every platform, which is what the
|
||||
* `platform_mask` column's `-1` default means.
|
||||
*/
|
||||
const ALL_PLATFORMS = -1
|
||||
|
||||
/**
|
||||
* A listed avatar item. `is_base_avatar_item` and `created_at` are NULL because a store listing
|
||||
* records neither; a NULL there is "not recorded", which is true.
|
||||
*/
|
||||
function avatarItemRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
return {
|
||||
key: i.AvatarItemDesc,
|
||||
label: `${i.FriendlyName} (avatar item)`,
|
||||
key: drop.AvatarItemDesc,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (avatar item)`,
|
||||
values: [
|
||||
i.AvatarItemDesc,
|
||||
// Filled in by buildCatalogLoad once the de-duplicated order is known.
|
||||
null,
|
||||
drop.AvatarItemDesc,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.AvatarItem,
|
||||
i.FriendlyName,
|
||||
i.Tooltip,
|
||||
i.Rarity,
|
||||
i.PlatformMask,
|
||||
i.ThumbnailImage,
|
||||
i.AvatarItemType,
|
||||
i.AvatarItemId,
|
||||
i.IsBaseAvatarItem ?? false,
|
||||
i.TagList,
|
||||
i.CreatedAt,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
drop.AvatarItemType ?? 0,
|
||||
// 0 is the dump's "none"; the column's is NULL.
|
||||
drop.AvatarItemId || null,
|
||||
null,
|
||||
drop.TagList,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function skinRow(s: SkinCapture): Omit<CatalogLoadRow, 'id'> {
|
||||
/** A listed consumable: the shared columns only, keyed by its `ConsumableItemDesc`. */
|
||||
function consumableRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
return {
|
||||
key: s.ModificationGuid,
|
||||
label: `${s.FriendlyName} (skin, ${s.PrefabName})`,
|
||||
key: drop.ConsumableItemDesc,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (consumable)`,
|
||||
values: [
|
||||
s.ModificationGuid,
|
||||
// Filled in by buildCatalogLoad once the de-duplicated order is known.
|
||||
drop.ConsumableItemDesc,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.Consumable,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
drop.TagList,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** A listed skin: what the listing carries. */
|
||||
function listedSkinRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
return {
|
||||
key: drop.EquipmentModificationGuid,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (skin, ${drop.EquipmentPrefabName})`,
|
||||
values: [
|
||||
drop.EquipmentModificationGuid,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.Skin,
|
||||
s.FriendlyName,
|
||||
s.Tooltip,
|
||||
s.Rarity,
|
||||
s.PlatformMask,
|
||||
s.ThumbnailImage,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
s.PrefabName,
|
||||
s.UnlockedLevel,
|
||||
drop.EquipmentPrefabName,
|
||||
null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn both captures into the rows a load writes, de-duplicated on `item_key`.
|
||||
* Turn the served store into the rows a load writes, de-duplicated on `item_key`.
|
||||
*
|
||||
* `item_key` is unique across BOTH kinds, so a repeat is a defect in the capture rather than
|
||||
* `item_key` is unique across ALL kinds, so a repeat is a defect in the source rather than
|
||||
* something the table should model. First occurrence wins and the rest are RETURNED rather
|
||||
* than dropped on the floor: the caller has to report them, because a collision that vanishes
|
||||
* quietly is the exact failure the single key exists to prevent.
|
||||
* quietly is the exact failure the single key exists to prevent. (The dump's verbatim repeats
|
||||
* are collapsed by {@link collapseRepeatedListings} before the served file is written, so they
|
||||
* never get here; a consumable's PACK is counted, not reported — see {@link ListingKind}.)
|
||||
*
|
||||
* Each surviving row is also numbered — `catalog_id`, from {@link CATALOG_ID_BASE} upward in
|
||||
* capture order, the small numeric handle the site uses in place of a comma-laden desc or a
|
||||
* guid, and the `PurchasableItemId` a generated storefront lists it under. It is assigned here
|
||||
* rather than by the database so the caller can render it into the same INSERT, and it is a
|
||||
* LOAD-ORDER surrogate: the next load renumbers, and nothing may store it.
|
||||
* Rows come out in STORE ORDER, one per listing that sells a catalog item, and `catalog_id` is
|
||||
* the listing's `PurchasableItemId` — a real, stable number, the one the client buys by. A
|
||||
* store id naming two different keys is refused outright: it cannot be resolved by order, and
|
||||
* nothing in the dump does it.
|
||||
*/
|
||||
export function buildCatalogLoad(
|
||||
avatarItems: AvatarItemCapture[],
|
||||
skins: SkinCapture[]
|
||||
): { rows: CatalogLoadRow[]; collisions: CatalogCollision[] } {
|
||||
export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
|
||||
const seen = new Map<string, string>()
|
||||
const idOwner = new Map<number, string>()
|
||||
const rows: CatalogLoadRow[] = []
|
||||
const collisions: CatalogCollision[] = []
|
||||
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
|
||||
for (const row of [...avatarItems.map(avatarItemRow), ...skins.map(skinRow)]) {
|
||||
const skipped: Partial<Record<ListingKind, number>> = {}
|
||||
|
||||
/** Keep the row, or report it as a repeat of one already kept. */
|
||||
const push = (row: CatalogLoadRow): void => {
|
||||
const kept = seen.get(row.key)
|
||||
if (kept !== undefined) {
|
||||
collisions.push({ key: row.key, kept, dropped: row.label })
|
||||
continue
|
||||
return
|
||||
}
|
||||
const owner = idOwner.get(row.id)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`catalog_id ${row.id} names two items: ${owner} and ${row.label}`)
|
||||
}
|
||||
seen.set(row.key, row.label)
|
||||
// Numbered AFTER de-duplication and from {@link CATALOG_ID_BASE}, so a load's ids are
|
||||
// exactly BASE..BASE+rows.length-1 with no gaps — a dropped duplicate must not burn a
|
||||
// number.
|
||||
const id = CATALOG_ID_BASE + rows.length
|
||||
row.values[idAt] = id
|
||||
rows.push({ ...row, id })
|
||||
idOwner.set(row.id, row.label)
|
||||
rows.push(row)
|
||||
}
|
||||
return { rows, collisions }
|
||||
|
||||
for (const listing of listings) {
|
||||
const kind = listingKind(listing)
|
||||
switch (kind) {
|
||||
case CatalogKind.AvatarItem:
|
||||
push(avatarItemRow(listing))
|
||||
break
|
||||
case CatalogKind.Consumable:
|
||||
// The pack of a consumable already listed singly: one thing, one row.
|
||||
if (seen.has(listing.GiftDrop.ConsumableItemDesc)) {
|
||||
skipped.consumable_pack = (skipped.consumable_pack ?? 0) + 1
|
||||
break
|
||||
}
|
||||
push(consumableRow(listing))
|
||||
break
|
||||
case CatalogKind.Skin:
|
||||
push(listedSkinRow(listing))
|
||||
break
|
||||
default:
|
||||
skipped[kind] = (skipped[kind] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, collisions, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rarities that are NOT sold, and so never appear in a generated storefront.
|
||||
* Rarities that mark the developer/unreleased tier.
|
||||
*
|
||||
* `-1` is the developer/unreleased tier. The items carrying it stay in the `catalog` table —
|
||||
* that is a record of what EXISTS — but a storefront is a record of what is for SALE, and an
|
||||
* item listed in one can be bought: `findStoreItem` resolves a purchase against the catalog
|
||||
* file itself, so listing them at any price would put them on sale.
|
||||
*
|
||||
* Lives here rather than in the storefront generator because two things need the same answer:
|
||||
* the generator, which omits them, and anything that hands the client a PurchasableItem id,
|
||||
* which must not name one the storefront never listed. A client asked to resolve an id no
|
||||
* storefront sells renders nothing, indistinguishably from an id it failed to parse.
|
||||
* `-1`. The 2025 store lists a handful of such items and this server serves that store
|
||||
* verbatim, so they CAN be bought; the tier is kept out of the discovery rows the `lists`
|
||||
* worker draws from the catalog, which is the one place this is still read.
|
||||
*/
|
||||
export const UNSELLABLE_RARITIES: readonly number[] = [-1]
|
||||
|
||||
/** Whether an item of this rarity may appear in a storefront. */
|
||||
/** Whether an item of this rarity is outside the developer tier. */
|
||||
export const isSellableRarity = (rarity: number): boolean => !UNSELLABLE_RARITIES.includes(rarity)
|
||||
|
||||
/**
|
||||
* What a catalog item costs, by rarity — the pricing every surface that sells a catalog row
|
||||
* must agree on.
|
||||
*
|
||||
* Shared rather than living in the storefront generator alone because a purchase is CHECKED
|
||||
* against the price the client was shown: `priceCheck` compares the posted `RequestedPrice`
|
||||
* with the catalog's, and a server that priced a buy differently from the file it listed would
|
||||
* refuse every purchase as "Price has changed". One table, both sides.
|
||||
*
|
||||
* The tiers 0/10/30/50 were specified; 20 sits between its neighbours at 700 (sf3's own
|
||||
* rarity-20 items cluster at 400-600, but 600 is taken by rarity 10 here, and two tiers sharing
|
||||
* a price makes the rarity invisible). {@link UNSELLABLE_RARITIES} is priced by nothing — those
|
||||
* items are not sold at all.
|
||||
* What an item costs by rarity, in RecCenterTokens — for the things the store dump does not
|
||||
* price: the skins it never listed ({@link unlistedSkinListing}) and a first-party custom
|
||||
* avatar item it does not list (`apps/api/scripts/price-custom-avatar-items.ts`). Everything
|
||||
* the dump lists carries its real price, verbatim.
|
||||
*/
|
||||
export const PRICE_BY_RARITY: Record<number, number> = {
|
||||
0: 150,
|
||||
@@ -238,52 +480,31 @@ export const PRICE_BY_RARITY: Record<number, number> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free.
|
||||
*
|
||||
* Only reachable if a future capture introduces a rarity nobody has priced. A floor rather than
|
||||
* a skip because an unpriced item silently vanishing from the store is harder to notice than
|
||||
* one that turns up cheap; a rarity meant to be unsellable belongs in
|
||||
* {@link UNSELLABLE_RARITIES}, where a load reports it.
|
||||
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free. A
|
||||
* floor rather than a skip because an unpriced item silently vanishing is harder to notice
|
||||
* than one that turns up cheap.
|
||||
*/
|
||||
export const DEFAULT_PRICE = 150
|
||||
|
||||
/** What one catalog row costs, in RecCenterTokens. */
|
||||
/** What one item of this rarity costs, in RecCenterTokens. */
|
||||
export const priceForRarity = (rarity: number): number => PRICE_BY_RARITY[rarity] ?? DEFAULT_PRICE
|
||||
|
||||
/**
|
||||
* What Rec Room Plus takes off, in percent. The same number the server's own `subscriberFloor`
|
||||
* allows, which is what makes a subscriber's discounted `RequestedPrice` land inside the band
|
||||
* rather than through the floor.
|
||||
* What Rec Room Plus takes off, in percent — the same number the econ worker's own
|
||||
* `subscriberFloor` allows, which is what makes a subscriber's discounted `RequestedPrice` land
|
||||
* inside the band rather than through the floor.
|
||||
*/
|
||||
export const SUBSCRIBER_DISCOUNT_PERCENT = 10
|
||||
|
||||
/** The subscriber price for a regular one. Floored, matching the server's `subscriberFloor`. */
|
||||
/** The subscriber price for a regular one. Floored, matching the worker's `subscriberFloor`. */
|
||||
export const subscriberPriceFor = (regular: number): number =>
|
||||
Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
|
||||
|
||||
/**
|
||||
* The last client build treated as the 2023-era one, as a build number and as the ISO date the
|
||||
* item catalogue records `CreatedAt` in.
|
||||
* The last client build served the 2023 general store.
|
||||
*
|
||||
* One constant in two forms because two things key off the same moment: the econ worker decides
|
||||
* which storefront FILE a caller is served by comparing their token's `rn.ver` to the number,
|
||||
* and the storefront generator decides which ITEMS go in the 2023 file by comparing each item's
|
||||
* `CreatedAt` to the date. They must not drift — a build served the old store but a store built
|
||||
* to a different date would sell items that build has never heard of.
|
||||
* The econ worker compares a caller's token `rn.ver` to this to pick which storefront FILE
|
||||
* they get for storefront 3: `sf3.json` (the 2023 store, frozen) up to and including this
|
||||
* build, `sf3-2025.json` (the 2025 store dump) after it.
|
||||
*/
|
||||
export const LEGACY_CLIENT_BUILD = 20_230_414
|
||||
|
||||
/** {@link LEGACY_CLIENT_BUILD} as `YYYY-MM-DD`, for comparing against a `CreatedAt`. */
|
||||
export const LEGACY_CLIENT_BUILD_DATE = '2023-04-14'
|
||||
|
||||
/**
|
||||
* Whether an item existed by the time of {@link LEGACY_CLIENT_BUILD} — i.e. whether the 2023
|
||||
* store should sell it.
|
||||
*
|
||||
* An item with NO `CreatedAt` is treated as too new and left out: three catalogue rows carry
|
||||
* none, and there is no way to show they predate the cutoff. Excluding is the conservative
|
||||
* direction — the 2023 store missing three items nobody noticed is better than it offering
|
||||
* something that build cannot render.
|
||||
*/
|
||||
export const existedByLegacyBuild = (createdAt: string | null | undefined): boolean =>
|
||||
typeof createdAt === 'string' && createdAt.slice(0, 10) < LEGACY_CLIENT_BUILD_DATE
|
||||
|
||||
+58
-130
@@ -39,7 +39,6 @@ import { censorSwears } from '../../api/src/sanitize'
|
||||
import { BalanceAddType } from '../../notify/src/notification-payloads'
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||
import avatarItemCatalog from '../static/db/avatar-items.json'
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
import defaultAvatar from '../static/default-avatar.json'
|
||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||
@@ -56,18 +55,10 @@ import {
|
||||
isSpendable,
|
||||
spendCurrency,
|
||||
} from './balance-db'
|
||||
import { getCatalogItem } from './catalog-db'
|
||||
import { getCatalogItem, toCatalogAvatarItem } from './catalog-db'
|
||||
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
|
||||
// which store FILE a caller is served here, and which ITEMS go in that file there. The two must
|
||||
// name the same moment or a build gets a store built to a different cutoff.
|
||||
import {
|
||||
CATALOG_ID_BASE,
|
||||
CatalogKind,
|
||||
isSellableRarity,
|
||||
LEGACY_CLIENT_BUILD,
|
||||
priceForRarity,
|
||||
subscriberPriceFor,
|
||||
} from './catalog-load'
|
||||
// which store FILE a caller is served here, and is what the generator's help names.
|
||||
import { CatalogKind, LEGACY_CLIENT_BUILD } from './catalog-load'
|
||||
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
|
||||
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
|
||||
import {
|
||||
@@ -861,8 +852,8 @@ interface GiftRequest {
|
||||
*/
|
||||
const STOREFRONT_ALIASES: Record<string, string> = {
|
||||
// Empty. 1704 was here for a while, standing in for a 2025 gift-drop storefront nobody had
|
||||
// captured; the items it was meant to sell turned out to belong in the general store, so
|
||||
// they are in `sf3-2025.json` and served as storefront 3 — see {@link STOREFRONT_BY_BUILD}.
|
||||
// captured; the 2025 general store has since been dumped and is served as storefront 3 —
|
||||
// see {@link STOREFRONT_BY_BUILD}.
|
||||
// That is a per-BUILD variant of one storefront rather than an alias between two ids, which
|
||||
// is why nothing is listed here.
|
||||
}
|
||||
@@ -871,16 +862,17 @@ const STOREFRONT_ALIASES: Record<string, string> = {
|
||||
* Storefronts that have a SECOND file for newer clients, keyed by the id the client asks for
|
||||
* and naming the file a build past {@link LEGACY_CLIENT_BUILD} is served instead.
|
||||
*
|
||||
* `3` is the general store, and BOTH files are generated from the item catalog by
|
||||
* `runx storefront build` — the same store at two points in time. `sf3.json` holds what existed
|
||||
* by {@link LEGACY_CLIENT_BUILD}; `sf3-2025.json` holds everything. 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 between them.
|
||||
* `3` is the general store at two points in time. `sf3.json` is the 2023 store, frozen;
|
||||
* `sf3-2025.json` is the real 2025 store — a dump of the live game's storefront 3, written by
|
||||
* `runx storefront build` with its captured sale stripped (see that command). 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. The two files number their items differently — each is
|
||||
* the store its build actually had — and nothing crosses between them.
|
||||
*
|
||||
* Resolved in {@link storefrontAssetPath}, which BOTH the listing route and
|
||||
* {@link loadStorefront} go through, so browsing and buying always read the same file. That is
|
||||
* the whole reason it is not done at the route: a newer client shown the merged store and then
|
||||
* charged against the captured one would have every catalog item 404 as "no such storefront".
|
||||
* the whole reason it is not done at the route: a newer client shown the 2025 store and then
|
||||
* charged against the 2023 one would have every purchase 404 as "no such storefront".
|
||||
*/
|
||||
const STOREFRONT_BY_BUILD: Record<string, string> = {
|
||||
'3': 'sf3-2025',
|
||||
@@ -909,8 +901,8 @@ function storefrontAssetPath(id: string, build: number | null): string {
|
||||
* build, see {@link storefrontAssetPath}. Null when there is no such storefront.
|
||||
*
|
||||
* Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
|
||||
* storefront reads (and parses) it once: sf3 alone is over a thousand items and the merged
|
||||
* sf3-2025 is four, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
|
||||
* storefront reads (and parses) it once: sf3 alone is over two thousand items and sf3-2025
|
||||
* over three, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
|
||||
*/
|
||||
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
|
||||
const build = await authedBuild(c)
|
||||
@@ -1317,10 +1309,12 @@ const ROLL_STOREFRONT_TYPE = 3
|
||||
* Every item a roll or a weekly gift may draw, or `[]` if it can't be read (a roll then yields
|
||||
* nothing).
|
||||
*
|
||||
* The storefront PLUS every equipment skin, which no storefront sells: skins are awarded from
|
||||
* weekly challenges rather than bought, so they were taken out of sf3 — and the weekly gift pool
|
||||
* is exactly the equipment in this list, which would otherwise be empty. They come from the
|
||||
* `catalog` table, whose skins are the same rows `static/db/skins.json` holds.
|
||||
* The storefront PLUS every equipment skin the storefront does not sell. The 2025 store lists
|
||||
* every skin (`runx storefront build` appends the ones the game's own store never sold), the
|
||||
* 2023 store none — and the weekly gift pool is exactly the equipment in this list, which for
|
||||
* an older build would otherwise be empty. The rest come from the `catalog` table, which is
|
||||
* loaded from the 2025 store; one already listed is not added again, or a roll would weight it
|
||||
* double.
|
||||
*
|
||||
* Being in this list does NOT make an item purchasable. `findStoreItem` and the bulk bag resolve
|
||||
* a purchase against the storefront file, never against this.
|
||||
@@ -1332,7 +1326,9 @@ async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
|
||||
)
|
||||
.bind(CatalogKind.Skin)
|
||||
.all<CatalogRow>()
|
||||
return [...(storefront?.StoreItems ?? []), ...results.map(toSkinStoreItem)]
|
||||
const listed = storefront?.StoreItems ?? []
|
||||
const sold = new Set(listed.map((item) => item.GiftDrop.EquipmentModificationGuid))
|
||||
return [...listed, ...results.filter((row) => !sold.has(row.item_key)).map(toSkinStoreItem)]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1380,10 +1376,11 @@ const WEEKLY_GIFT_EXCLUDED_PREFABS = ['[Sandbox_']
|
||||
* equipment — the captured rotation's is a camera skin — and that guid is exactly what marks an
|
||||
* entry as equipment.
|
||||
*
|
||||
* The pool comes from the catalog's SKINS now rather than from sf3, which no longer sells
|
||||
* equipment at all: skins are awarded here, not bought. See {@link loadRollCatalog}.
|
||||
* The pool is the roll catalog's equipment — the skins the caller's store sells plus the
|
||||
* catalog's, see {@link loadRollCatalog} — so a week can be themed on a skin the 2023 store
|
||||
* never sold.
|
||||
*
|
||||
* `GiftDropId` comes off `PurchasableItemId`, which for a skin is its `catalog_id`.
|
||||
* `GiftDropId` comes off `PurchasableItemId`, which is a skin's `catalog_id` as well.
|
||||
*/
|
||||
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
|
||||
return catalog
|
||||
@@ -1610,6 +1607,12 @@ async function grantGiftDrop(
|
||||
) {
|
||||
await grantEquipment(db, accountId, toEquipment(giftDrop))
|
||||
}
|
||||
// A first-party custom avatar item: the 2025 store lists five hundred of them, each a plain
|
||||
// numbered listing whose drop names the item by guid. Ownership is the same row a bag's
|
||||
// guid-keyed line writes; without this the line charges and the box opens on nothing.
|
||||
if (typeof giftDrop.CustomAvatarItemId === 'string' && giftDrop.CustomAvatarItemId !== '') {
|
||||
await grantCustomAvatarItem(db, accountId, giftDrop.CustomAvatarItemId)
|
||||
}
|
||||
const isConsumable =
|
||||
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
|
||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT * (options.copies ?? 1) : 0
|
||||
@@ -1880,69 +1883,6 @@ function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog rows as STORE ITEMS, so a bag can be resolved against the `catalog` table the same
|
||||
* way it is resolved against an `sf{N}.json` file.
|
||||
*
|
||||
* The generated storefront (`sf3-2025.json`) is built from these very rows with this very
|
||||
* pricing, so an item bought here costs exactly what that file lists it at. That is not a
|
||||
* nicety: `priceCheck` refuses a line whose posted `RequestedPrice` doesn't match, so two
|
||||
* pricings would 409 every purchase the client made from the page it was shown.
|
||||
*
|
||||
* Mostly redundant now that the merged store carries every sellable AVATAR ITEM — a newer
|
||||
* build's bag resolves those straight out of the file. What it still reaches that the file does
|
||||
* not is SKINS, which the generator leaves out, keyed the way a gift-drop keys equipment
|
||||
* (`EquipmentPrefabName` +
|
||||
* `EquipmentModificationGuid`) rather than as an avatar item — which is what lets a skin be
|
||||
* bought at all, since no generated storefront file lists one.
|
||||
*
|
||||
* {@link isSellableRarity} is applied here as well as in the generator: the developer tier is
|
||||
* absent from the file, and resolving a bag straight off the table would otherwise sell items
|
||||
* the store never offered.
|
||||
*/
|
||||
async function catalogStoreItems(db: D1Database, catalogIds: number[]): Promise<StoreItem[]> {
|
||||
if (catalogIds.length === 0) return []
|
||||
const placeholders = catalogIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM catalog WHERE catalog_id IN (${placeholders})`)
|
||||
.bind(...catalogIds)
|
||||
.all<CatalogRow>()
|
||||
|
||||
return results
|
||||
.filter(
|
||||
(row) =>
|
||||
row.catalog_id !== null &&
|
||||
row.kind === CatalogKind.AvatarItem &&
|
||||
isSellableRarity(row.rarity)
|
||||
)
|
||||
.map((row) => {
|
||||
const price = priceForRarity(row.rarity)
|
||||
return {
|
||||
GiftDrop: {
|
||||
FriendlyName: row.friendly_name,
|
||||
// The client's field is a string; the catalog keeps NULL and "" apart.
|
||||
Tooltip: row.tooltip ?? '',
|
||||
ConsumableItemDesc: '',
|
||||
// `item_key` IS the `AvatarItemDesc` for an avatar item — that is what makes it the
|
||||
// key. The equipment fields stay empty: only avatar items reach here.
|
||||
AvatarItemDesc: row.item_key,
|
||||
AvatarItemType: row.avatar_item_type ?? 0,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: row.rarity,
|
||||
Context: 0,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
},
|
||||
Prices: [{ CurrencyType: CurrencyType.RecCenterTokens, Price: price }],
|
||||
SubscriberPrices: [
|
||||
{ CurrencyType: CurrencyType.RecCenterTokens, Price: subscriberPriceFor(price) },
|
||||
],
|
||||
PurchasableItemId: row.catalog_id as number,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one line against the bag's catalog: what it wants, how many, and at what price.
|
||||
* Returns the failure — with the `UpdateResponse` its entry will carry — instead when the
|
||||
@@ -2733,8 +2673,8 @@ const app = new Hono<App>({ strict: false })
|
||||
tags: ['Avatar'],
|
||||
summary: 'Locked avatar items in bulk',
|
||||
description: [
|
||||
'Resolves `AvatarItemDescriptions` against the bundled item catalogue and answers the',
|
||||
'matching records as a bare array. The match is on the WHOLE `AvatarItemDesc`, so a',
|
||||
'Resolves `AvatarItemDescriptions` against the item catalogue (the `catalog` table) and',
|
||||
'answers the matching records as a bare array. The match is on the WHOLE `AvatarItemDesc`, so a',
|
||||
'colourway is not found by its base asset alone.',
|
||||
'An empty or absent list answers the WHOLE catalogue, which is the reference’s own',
|
||||
'behaviour rather than a degenerate empty match.',
|
||||
@@ -2754,12 +2694,22 @@ const app = new Hono<App>({ strict: false })
|
||||
const requested = Array.isArray(body?.AvatarItemDescriptions)
|
||||
? body.AvatarItemDescriptions.filter((d): d is string => typeof d === 'string')
|
||||
: []
|
||||
if (requested.length === 0) return c.json(avatarItemCatalog)
|
||||
// The whole kind from the `catalog` table, in LOAD order — which is store order, the
|
||||
// closest thing to the reference's "catalogue order" there is — then filtered here.
|
||||
// One statement whatever was asked: the client posts hundreds of descs at a time, and
|
||||
// D1 binds at most a hundred parameters, so an `IN (...)` of the request cannot be.
|
||||
const { results } = await c.env.DB.prepare(
|
||||
'SELECT * FROM catalog WHERE kind = ?1 ORDER BY rowid'
|
||||
)
|
||||
.bind(CatalogKind.AvatarItem)
|
||||
.all<CatalogRow>()
|
||||
const catalogue = results.map(toCatalogAvatarItem)
|
||||
if (requested.length === 0) return c.json(catalogue)
|
||||
|
||||
// A Set rather than `Array.includes` per item: the client posts hundreds of descs
|
||||
// against a catalogue of thousands, and the reference's nested scan is quadratic.
|
||||
// A Set rather than `Array.includes` per item: the reference's nested scan is
|
||||
// quadratic.
|
||||
const wanted = new Set(requested)
|
||||
return c.json(avatarItemCatalog.filter((item) => wanted.has(item.AvatarItemDesc)))
|
||||
return c.json(catalogue.filter((item) => wanted.has(item.AvatarItemDesc)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4496,10 +4446,10 @@ const app = new Hono<App>({ strict: false })
|
||||
'with no capture of its own may stand in for another storefront’s catalog (see',
|
||||
'`STOREFRONT_ALIASES`, currently empty), and such an alias applies to purchases from',
|
||||
'that storefront too, not just to this listing. Which FILE a storefront reads from can',
|
||||
'also depend on the caller’s build (`rn.ver`): storefront `3` serves the captured',
|
||||
'`sf3.json` to builds up to 20230414 and the merged `sf3-2025.json` — that same store',
|
||||
'plus every sellable row of the item catalog — to later ones. The id does not change,',
|
||||
'and the same resolution applies to purchases, so what is browsed is what is charged.',
|
||||
'also depend on the caller’s build (`rn.ver`): storefront `3` serves the 2023',
|
||||
'`sf3.json` to builds up to 20230414 and the 2025 store dump `sf3-2025.json` to later',
|
||||
'ones. The id does not change, and the same resolution applies to purchases, so what',
|
||||
'is browsed is what is charged.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
@@ -4771,32 +4721,10 @@ const app = new Hono<App>({ strict: false })
|
||||
const allowPartial = body.AllowPartialSuccess === true
|
||||
const skipGiftBox = body.BypassGiftPackages === true
|
||||
|
||||
// One catalog read for the bag; every line resolves against it in memory.
|
||||
const storefront = await loadStorefront(c, storefrontType as number)
|
||||
|
||||
// Past LEGACY_CLIENT_BUILD the bag may also name CATALOG rows — the ids the generated
|
||||
// storefront and the discovery rows hand out (10000 and up) — so those are looked up in
|
||||
// the `catalog` table and appended. One extra query for the whole bag.
|
||||
//
|
||||
// Appended rather than replacing the file: the two id spaces do not overlap
|
||||
// (`CATALOG_ID_BASE` is above every captured id), so a bag may mix them and a newer
|
||||
// client buying from a captured storefront still works. An older build is not offered
|
||||
// catalog ids anywhere, so it is left resolving exactly what it always did.
|
||||
const build = await authedBuild(c)
|
||||
const catalogItems =
|
||||
build !== null && build > LEGACY_CLIENT_BUILD
|
||||
? await catalogStoreItems(
|
||||
c.env.DB,
|
||||
lines.flatMap((line) => {
|
||||
const numberId = toPurchaseMethodId(line.ItemPurchaseMethodId).NumberId
|
||||
return numberId !== null && numberId >= CATALOG_ID_BASE ? [numberId] : []
|
||||
})
|
||||
)
|
||||
: []
|
||||
const bagCatalog: Storefront | null =
|
||||
catalogItems.length === 0
|
||||
? storefront
|
||||
: { StoreItems: [...(storefront?.StoreItems ?? []), ...catalogItems] }
|
||||
// One catalog read for the bag; every line resolves against it in memory. The file is
|
||||
// the whole catalog: the ids the discovery rows hand out (`lists`) are the store's own
|
||||
// `PurchasableItemId`s, so a numbered line resolves here or nowhere.
|
||||
const bagCatalog = await loadStorefront(c, storefrontType as number)
|
||||
// The bag may also name CUSTOM avatar items — a line whose id is a `Guid`, the item's
|
||||
// `CustomAvatarItemId` — which resolve against the `custom_avatar_item` table rather than
|
||||
// the catalog. One read for the items the bag names and one for which of them the buyer
|
||||
|
||||
@@ -31,12 +31,12 @@ import { NotificationType } from '../../../../notify/src/notification-types'
|
||||
// SQL, not modules: they are never executed here, only read.
|
||||
import catalogStructureSql from '../../../migrations/0015_catalog.sql?raw'
|
||||
import catalogIdSql from '../../../migrations/0016_catalog_id.sql?raw'
|
||||
import avatarItemsJson from '../../../static/db/avatar-items.json'
|
||||
// The merged 2025 general store, read as a FILE: which file the route serves depends on the
|
||||
// caller's build, and these assertions are about the file's CONTENTS.
|
||||
import carriedItems from '../../../static/db/consumables.json'
|
||||
import skinsJson from '../../../static/db/skins.json'
|
||||
// The 2025 store DUMP, the source `runx storefront build` and `runx catalog load` both read.
|
||||
import storeDump from '../../../static/db/Watch_EnumValue_3.json'
|
||||
import questRewards from '../../../static/quest-rewards.json'
|
||||
// Both general-store files, read as FILES: which one the route serves depends on the caller's
|
||||
// build, and these assertions are about each file's CONTENTS.
|
||||
import sf32025 from '../../../static/storefronts/sf3-2025.json'
|
||||
import sf3 from '../../../static/storefronts/sf3.json'
|
||||
import { SCHEMA_DDL } from '../../avatar-db'
|
||||
@@ -66,7 +66,14 @@ import {
|
||||
searchCatalog,
|
||||
toCatalogSkin,
|
||||
} from '../../catalog-db'
|
||||
import { CATALOG_ID_BASE } from '../../catalog-load'
|
||||
import {
|
||||
collapseRepeatedListings,
|
||||
GOLD_SKIN_PRICE,
|
||||
isGoldSkin,
|
||||
UNLISTED_SKIN_ID_BASE,
|
||||
UNLISTED_SKIN_RARITY,
|
||||
unlistedSkinListing,
|
||||
} from '../../catalog-load'
|
||||
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
|
||||
// The live weekly rotation, generated the same way the worker generates it, so the challenge
|
||||
// tests exercise whatever this week actually holds instead of ids from a rotation that has
|
||||
@@ -84,37 +91,51 @@ import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
||||
import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db'
|
||||
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
|
||||
|
||||
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
|
||||
import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
|
||||
import type { Env } from '../../context'
|
||||
|
||||
/**
|
||||
* The GENERATED half of a store file — the items built from the item catalog, as opposed to the
|
||||
* equipment, consumables and boxes carried across from the 2023 capture.
|
||||
*
|
||||
* Split on membership in the CARRIED ids rather than on `CATALOG_ID_BASE`. The two happen to
|
||||
* agree now that the equipment skins are gone — the carried ids run 2168-2458, well below the
|
||||
* base — but they did not while a skin carried id 20756767, and asking the real question costs
|
||||
* nothing.
|
||||
* The store dump's listings with the verbatim repeats collapsed — what `runx storefront build`
|
||||
* writes out as sf3-2025.json, sale stripped. The file test below holds the two to each other.
|
||||
*/
|
||||
const capturedIds = new Set(carriedItems.map((i) => i.PurchasableItemId))
|
||||
const catalogItems = () => sf32025.StoreItems.filter((i) => !capturedIds.has(i.PurchasableItemId))
|
||||
const dumpListings = collapseRepeatedListings(storeDump.StoreItems as StoreListing[]).listings
|
||||
|
||||
/** The served store's listings, which is what `runx catalog load` reads. */
|
||||
const storeListings = sf32025.StoreItems as StoreListing[]
|
||||
|
||||
/**
|
||||
* An item ONLY the newer store sells — created after the cutoff, so it is in sf3-2025 and not in
|
||||
* sf3. The build gate is only observable through such an item: everything else is in both files
|
||||
* and buys identically either way.
|
||||
* An avatar item ONLY the 2025 store sells, priced in tokens: an id sf3-2025 lists and sf3
|
||||
* does not. The build gate is only observable through such an item — the two files number their
|
||||
* stores differently, so nearly any 2025 listing qualifies; the first tokens-priced avatar item
|
||||
* is as good as any.
|
||||
*/
|
||||
const sf3Ids = new Set(sf3.StoreItems.map((i) => i.PurchasableItemId))
|
||||
const NEWER_ONLY = (() => {
|
||||
const item = sf32025.StoreItems.find((i) => !sf3Ids.has(i.PurchasableItemId))
|
||||
if (item === undefined) throw new Error('sf3-2025 sells nothing sf3 does not')
|
||||
const item = storeListings.find(
|
||||
(i) =>
|
||||
!sf3Ids.has(i.PurchasableItemId) &&
|
||||
i.GiftDrop.AvatarItemDesc !== '' &&
|
||||
i.Prices.some((p) => p.CurrencyType === 2)
|
||||
)
|
||||
if (item === undefined)
|
||||
throw new Error('sf3-2025 sells no tokens-priced avatar item sf3 does not')
|
||||
return {
|
||||
id: item.PurchasableItemId,
|
||||
price: item.Prices[0]!.Price,
|
||||
price: item.Prices.find((p) => p.CurrencyType === 2)!.Price,
|
||||
name: item.GiftDrop.FriendlyName,
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* The avatar items the suite-wide catalog holds — a slice of the real load, seeded below so the
|
||||
* catalogue reads (`lockeditems/bulk`) have something in store order to answer from. A slice
|
||||
* rather than the whole load: the table is rebuilt per test file, and fifty rows say what three
|
||||
* thousand would.
|
||||
*/
|
||||
const SEEDED_AVATAR_ITEMS = buildCatalogLoad(storeListings)
|
||||
.rows.filter((r) => r.values[CATALOG_INSERT_COLUMNS.indexOf('kind')] === CatalogKind.AvatarItem)
|
||||
.slice(0, 50)
|
||||
|
||||
/**
|
||||
* Items the generated `sf3.json` sells, resolved FROM the file rather than hardcoded.
|
||||
*
|
||||
@@ -217,6 +238,16 @@ beforeAll(async () => {
|
||||
.bind(skin.ModificationGuid, 70_001 + i, skin.FriendlyName, skin.Rarity, skin.PrefabName)
|
||||
.run()
|
||||
}
|
||||
// A slice of the real avatar-item load, in load order, for the catalogue reads. One batch:
|
||||
// these are the loader's own rows, so a row it builds that the table refuses fails here.
|
||||
await env.DB.batch(
|
||||
SEEDED_AVATAR_ITEMS.map((row) =>
|
||||
env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
|
||||
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
|
||||
).bind(...row.values.map((v) => v ?? null))
|
||||
)
|
||||
)
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||
.run()
|
||||
@@ -1990,10 +2021,9 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
|
||||
test('bulkpurchase buys a merged-store catalog item from storefront 3', async () => {
|
||||
// The exact request the client sends, verbatim: a catalog id under storefront 3, which is
|
||||
// what the merged sf3-2025 lists it as. It resolves because `loadStorefront` picks the file
|
||||
// by the caller's build, so what the store page offered is what the purchase is checked
|
||||
// against.
|
||||
// The exact request the client sends, verbatim: a 2025 store id under storefront 3, which
|
||||
// is what sf3-2025 lists it as. It resolves because `loadStorefront` picks the file by the
|
||||
// caller's build, so what the store page offered is what the purchase is checked against.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2024,18 +2054,18 @@ describe('econ endpoints', () => {
|
||||
Value: { Balance: number } | null
|
||||
}
|
||||
// It used to answer `{ Success: false, Error: "Item not found" }` — storefront 3 resolved
|
||||
// to the captured sf3, which has no id in the catalog range.
|
||||
// to the 2023 sf3, which does not list this id.
|
||||
expect(body.Error).not.toBe('Item not found')
|
||||
expect(body.Success).toBe(true)
|
||||
|
||||
// The price the client posts is the one the file lists, because the file it browsed and the
|
||||
// purchase it made are priced from the same shared rarity table. A second pricing anywhere
|
||||
// would 409 every purchase as "Price has changed".
|
||||
const item = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
|
||||
expect(item?.Prices[0]?.Price).toBe(NEWER_ONLY.price)
|
||||
// The price the client posts is the one the file lists — the dump's list price, its
|
||||
// captured sale stripped. A second pricing anywhere would 409 every purchase as "Price
|
||||
// has changed".
|
||||
const item = storeListings.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
|
||||
expect(item?.Prices.find((p) => p.CurrencyType === 2)?.Price).toBe(NEWER_ONLY.price)
|
||||
|
||||
// The SAME request from an old build still fails: its storefront 3 is generated to the
|
||||
// cutoff, and this item postdates it.
|
||||
// The SAME request from an old build still fails: its storefront 3 is the 2023 store,
|
||||
// which does not list this id.
|
||||
const legacy = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2058,9 +2088,11 @@ describe('econ endpoints', () => {
|
||||
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
|
||||
})
|
||||
|
||||
test('bulkpurchase resolves catalog ids for newer builds, at the storefront’s price', async () => {
|
||||
// A catalog row the generated storefront would list at 600 (rarity 10), bought straight off
|
||||
// the `catalog` table; plus a skin and a developer-tier row, neither of which may be.
|
||||
test('bulkpurchase resolves numbered lines against the storefront file alone', async () => {
|
||||
// Rows the `catalog` table holds that the store does NOT list — an avatar item, a skin and
|
||||
// a developer-tier item — none of which may be bought: the file is the whole catalog, and
|
||||
// the ids the discovery rows hand out are the store's own, so a numbered line resolves
|
||||
// there or nowhere.
|
||||
const AVATAR_ID = 20_001
|
||||
const SKIN_ID = 20_002
|
||||
const DEV_ID = 20_003
|
||||
@@ -2076,15 +2108,19 @@ describe('econ endpoints', () => {
|
||||
)
|
||||
.bind(SKIN_ID)
|
||||
.run()
|
||||
// Rarity -1 is the developer tier: in the catalog, absent from the storefront, and so not
|
||||
// for sale here either — resolving straight off the table must not sell what the store
|
||||
// never offered.
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
|
||||
VALUES ('bulk-buy-dev,,,', ?1, 'avatar_item', 'Bulk Buy Dev Item', '', -1, -1, 0)`
|
||||
)
|
||||
.bind(DEV_ID)
|
||||
.run()
|
||||
// None of those three numbers is a 2025 listing, or the test would be about the file.
|
||||
for (const id of [AVATAR_ID, SKIN_ID, DEV_ID]) {
|
||||
expect(
|
||||
sf32025.StoreItems.find((i) => i.PurchasableItemId === id),
|
||||
String(id)
|
||||
).toBeUndefined()
|
||||
}
|
||||
|
||||
const buy = async (
|
||||
version: string | undefined,
|
||||
@@ -2107,43 +2143,35 @@ describe('econ endpoints', () => {
|
||||
})),
|
||||
}),
|
||||
})
|
||||
const outcome = async (res: Response) =>
|
||||
((await res.json()) as { Success: boolean; Error?: string }).Success
|
||||
|
||||
// 600 (rarity 10) is the generated storefront's own price. It MUST match: `priceCheck`
|
||||
// refuses a line whose posted price differs, so a server pricing a buy differently from the
|
||||
// file it listed would 409 every purchase.
|
||||
const res = await buy('20250718.01', [{ id: AVATAR_ID, price: 600 }])
|
||||
// A 2025 listing buys at its list price — the dump's, its sale stripped. The price MUST
|
||||
// match: `priceCheck` refuses a line whose posted price differs.
|
||||
const res = await buy('20250718.01', [{ id: NEWER_ONLY.id, price: NEWER_ONLY.price }])
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { Success: boolean; Value: { Balance: number } | null }
|
||||
expect(body.Success).toBe(true)
|
||||
|
||||
// A SKIN is refused even though the catalog holds it: skins are awarded from weekly
|
||||
// challenges rather than sold, so no storefront lists one and the bag will not resolve one
|
||||
// off the table either.
|
||||
const skin = await buy('20250718.01', [{ id: SKIN_ID, price: 150 }])
|
||||
expect(((await skin.json()) as { Success: boolean }).Success).toBe(false)
|
||||
expect(await outcome(res)).toBe(true)
|
||||
|
||||
// A price the storefront does not list is refused, not quietly charged.
|
||||
const wrongPrice = await buy('20250718.01', [{ id: AVATAR_ID, price: 1 }])
|
||||
expect(((await wrongPrice.json()) as { Success: boolean }).Success).toBe(false)
|
||||
expect(await outcome(await buy('20250718.01', [{ id: NEWER_ONLY.id, price: 1 }]))).toBe(false)
|
||||
|
||||
// The developer-tier row is not for sale.
|
||||
const dev = await buy('20250718.01', [{ id: DEV_ID, price: 150 }])
|
||||
expect(((await dev.json()) as { Success: boolean }).Success).toBe(false)
|
||||
// Table-only rows are not for sale, whatever their kind and at any price. The catalog says
|
||||
// what a thing IS; only the storefront says what is sold.
|
||||
for (const [id, price] of [
|
||||
[AVATAR_ID, 600],
|
||||
[SKIN_ID, 150],
|
||||
[DEV_ID, 150],
|
||||
] as const) {
|
||||
expect(await outcome(await buy('20250718.01', [{ id, price }])), String(id)).toBe(false)
|
||||
}
|
||||
|
||||
// An OLD build is left resolving exactly what it always did — the storefront file — so a
|
||||
// catalog id means nothing to it. Nothing offers those ids to that build anyway.
|
||||
const legacy = await buy('20230414', [{ id: AVATAR_ID, price: 600 }])
|
||||
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
|
||||
const unversioned = await buy(undefined, [{ id: AVATAR_ID, price: 600 }])
|
||||
expect(((await unversioned.json()) as { Success: boolean }).Success).toBe(false)
|
||||
|
||||
// A bag may MIX an item the STOREFRONT FILE lists with one resolved straight off the
|
||||
// `catalog` table.
|
||||
const mixed = await buy('20250718.01', [
|
||||
{ id: SF3_ITEM.id, price: SF3_ITEM.price },
|
||||
{ id: AVATAR_ID, price: 600 },
|
||||
])
|
||||
expect(((await mixed.json()) as { Success: boolean }).Success).toBe(true)
|
||||
// And an old build never sees a 2025 number: its storefront 3 is the 2023 file.
|
||||
expect(
|
||||
await outcome(await buy('20230414', [{ id: NEWER_ONLY.id, price: NEWER_ONLY.price }]))
|
||||
).toBe(false)
|
||||
expect(
|
||||
await outcome(await buy(undefined, [{ id: NEWER_ONLY.id, price: NEWER_ONLY.price }]))
|
||||
).toBe(false)
|
||||
|
||||
// Cleaned up: the `catalog` block below counts every row in the table, so rows left behind
|
||||
// here would change what it sees.
|
||||
@@ -2165,12 +2193,14 @@ describe('econ endpoints', () => {
|
||||
return (await res.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
|
||||
}
|
||||
|
||||
// Three real catalogue entries, deliberately asked for OUT of catalogue order.
|
||||
const [first, second, third] = [
|
||||
avatarItemsJson[0]!,
|
||||
avatarItemsJson[40]!,
|
||||
avatarItemsJson[900]!,
|
||||
]
|
||||
// Three real catalogue entries — rows of the seeded slice, which are the loader's own —
|
||||
// deliberately asked for OUT of catalogue order.
|
||||
const seeded = SEEDED_AVATAR_ITEMS.map((r) => ({ AvatarItemDesc: r.key }))
|
||||
const [first, second, third] = [seeded[0]!, seeded[20]!, seeded[45]!]
|
||||
// The catalogue the route answers with is every avatar item in the table — the seeded
|
||||
// slice, plus whatever the catalog block below has not yet replaced it with.
|
||||
const catalogueSize = (await countCatalog(env.DB))[CatalogKind.AvatarItem] ?? 0
|
||||
expect(catalogueSize).toBeGreaterThanOrEqual(SEEDED_AVATAR_ITEMS.length)
|
||||
|
||||
const got = await ask([third.AvatarItemDesc, first.AvatarItemDesc, second.AvatarItemDesc])
|
||||
expect(got).toHaveLength(3)
|
||||
@@ -2195,9 +2225,11 @@ describe('econ endpoints', () => {
|
||||
expect(await ask(['no-such-desc,,,'])).toEqual([])
|
||||
|
||||
// EMPTY or absent means the WHOLE catalogue — the reference's "give me everything" case,
|
||||
// not a degenerate match-nothing.
|
||||
expect(await ask([])).toHaveLength(avatarItemsJson.length)
|
||||
expect(await ask(undefined)).toHaveLength(avatarItemsJson.length)
|
||||
// not a degenerate match-nothing. In the catalogue's record shape: the desc IS the key.
|
||||
const whole = await ask([])
|
||||
expect(whole).toHaveLength(catalogueSize)
|
||||
expect(whole[0]).toMatchObject({ AvatarItemDesc: first.AvatarItemDesc, PlatformMask: -1 })
|
||||
expect(await ask(undefined)).toHaveLength(catalogueSize)
|
||||
|
||||
// No auth needed, and a body that will not parse falls back to the catalogue rather than
|
||||
// erroring.
|
||||
@@ -2207,7 +2239,7 @@ describe('econ endpoints', () => {
|
||||
body: 'not json',
|
||||
})
|
||||
expect(junk.status).toBe(200)
|
||||
expect(((await junk.json()) as unknown[]).length).toBe(avatarItemsJson.length)
|
||||
expect(((await junk.json()) as unknown[]).length).toBe(catalogueSize)
|
||||
})
|
||||
|
||||
test('POST /api/items/purchaseInfos prices custom avatar items in tokens', async () => {
|
||||
@@ -2492,79 +2524,166 @@ describe('econ endpoints', () => {
|
||||
expect((await store(await bearer())).StoreItems).toHaveLength(sf3.StoreItems.length)
|
||||
expect((await store(await at('not-a-build'))).StoreItems).toHaveLength(sf3.StoreItems.length)
|
||||
|
||||
// Later builds get the merged store — bigger than either half, and still storefront 3.
|
||||
// Later builds get the 2025 store — bigger, and still storefront 3.
|
||||
for (const version of ['20230616', '20250424.01', '20250718.01']) {
|
||||
const merged = await store(await at(version))
|
||||
expect(merged.StorefrontType, version).toBe(3)
|
||||
expect(merged.StoreItems.length, version).toBe(sf32025.StoreItems.length)
|
||||
expect(merged.StoreItems.length, version).toBeGreaterThan(sf3.StoreItems.length)
|
||||
const newer = await store(await at(version))
|
||||
expect(newer.StorefrontType, version).toBe(3)
|
||||
expect(newer.StoreItems.length, version).toBe(sf32025.StoreItems.length)
|
||||
expect(newer.StoreItems.length, version).toBeGreaterThan(sf3.StoreItems.length)
|
||||
}
|
||||
|
||||
// The id does not change and nothing is renumbered: sf3's own items are in the merged file
|
||||
// unchanged, so a newer client buying one is charged the same as an older client would be.
|
||||
const bowtie = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 3,
|
||||
PurchasableItemId: SF3_ITEM.id, // an avatar item the generated sf3 sells
|
||||
CurrencyType: 2,
|
||||
RequestedPrice: SF3_ITEM.price,
|
||||
}),
|
||||
})
|
||||
expect(bowtie.status).toBe(200)
|
||||
|
||||
// And a CATALOG item can be bought from storefront 3 by a newer build — the half a
|
||||
// A 2025 listing can be bought from storefront 3 by a newer build — the half a
|
||||
// listing-only swap breaks. `findStoreItem` resolves the purchase through the same
|
||||
// build-aware path the listing does, so an item on the page is an item that can be bought.
|
||||
// From the catalog half — see `catalogItems`, which is why this is not an id comparison.
|
||||
const catalogItem = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
|
||||
expect(catalogItem).toBeDefined()
|
||||
const listing = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
|
||||
expect(listing).toBeDefined()
|
||||
const bought = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 3,
|
||||
PurchasableItemId: catalogItem!.PurchasableItemId,
|
||||
PurchasableItemId: NEWER_ONLY.id,
|
||||
CurrencyType: 2,
|
||||
RequestedPrice: catalogItem!.Prices[0]!.Price,
|
||||
RequestedPrice: NEWER_ONLY.price,
|
||||
}),
|
||||
})
|
||||
expect(bought.status).toBe(200)
|
||||
|
||||
// The SAME item is not for sale to an old build: it postdates the cutoff, so sf3 — which
|
||||
// is generated to that date — does not list it.
|
||||
// The SAME id is not for sale to an old build: its store is the 2023 one, which numbers
|
||||
// its items differently and does not list it.
|
||||
const refused = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await at('20230414')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 3,
|
||||
PurchasableItemId: catalogItem!.PurchasableItemId,
|
||||
PurchasableItemId: NEWER_ONLY.id,
|
||||
CurrencyType: 2,
|
||||
RequestedPrice: catalogItem!.Prices[0]!.Price,
|
||||
RequestedPrice: NEWER_ONLY.price,
|
||||
}),
|
||||
})
|
||||
expect(refused.status).toBe(404)
|
||||
|
||||
// And the 2023 store's own items still buy for the old build exactly as they always did.
|
||||
const legacyBuy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await at('20230414')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 3,
|
||||
PurchasableItemId: SF3_ITEM.id,
|
||||
CurrencyType: 2,
|
||||
RequestedPrice: SF3_ITEM.price,
|
||||
}),
|
||||
})
|
||||
expect(legacyBuy.status).toBe(200)
|
||||
})
|
||||
|
||||
test('sf3 and sf3-2025 are the same store at two points in time', async () => {
|
||||
// BOTH are generated from the item catalog now — sf3 is no longer a capture. They report
|
||||
// the same storefront id, because they are two versions of ONE store and the client asks
|
||||
// for 3 either way.
|
||||
test('sf3 is the frozen 2023 store; sf3-2025 is the 2025 dump with its sale stripped', async () => {
|
||||
// Two versions of ONE store: both report storefront 3, because the client asks for 3
|
||||
// either way.
|
||||
expect(sf3.StorefrontType).toBe(3)
|
||||
expect(sf32025.StorefrontType).toBe(3)
|
||||
|
||||
// sf3 is a strict SUBSET of sf3-2025: same items, same ids, same prices — it just stops at
|
||||
// the cutoff. Anything else would mean a player's store changed under them on upgrade.
|
||||
const newer = new Map(sf32025.StoreItems.map((i) => [i.PurchasableItemId, i]))
|
||||
for (const item of sf3.StoreItems) {
|
||||
expect(newer.get(item.PurchasableItemId), String(item.PurchasableItemId)).toEqual(item)
|
||||
}
|
||||
expect(sf3.StoreItems.length).toBeLessThan(sf32025.StoreItems.length)
|
||||
// sf3 is frozen — nothing regenerates it. What it was when it was cut: no equipment (skins
|
||||
// are awarded from weekly challenges), the discount expressed only in `SubscriberPrices`,
|
||||
// ids unique.
|
||||
expect(sf3.SubscriberDiscountPercent).toBe(0)
|
||||
expect(
|
||||
sf3.StoreItems.filter((i) => (i.GiftDrop.EquipmentModificationGuid ?? '') !== '')
|
||||
).toEqual([])
|
||||
|
||||
// Ids are unique within each file. The merge of carried and generated halves is only safe
|
||||
// because their id spaces don't overlap, so a collision must fail rather than be resolved
|
||||
// by array order.
|
||||
// sf3-2025 is `Watch_EnumValue_3.json` — the live game's storefront 3 — listing for
|
||||
// listing, with its verbatim repeats collapsed. Everything the dump says about an item is
|
||||
// in the file: its id, its list price, its subscriber price, its gift drop.
|
||||
expect(sf32025.StoreItems.length).toBeGreaterThan(dumpListings.length)
|
||||
expect(sf32025.StoreItems.length).toBeGreaterThan(sf3.StoreItems.length)
|
||||
const stripSale = (prices: Array<{ StorefrontSaleData: unknown }> | null) =>
|
||||
prices?.map((p) => ({ ...p, StorefrontSaleData: null })) ?? null
|
||||
for (const [i, listing] of dumpListings.entries()) {
|
||||
const served = sf32025.StoreItems[i]!
|
||||
expect(served.PurchasableItemId, String(listing.PurchasableItemId)).toBe(
|
||||
listing.PurchasableItemId
|
||||
)
|
||||
// A skin's or a consumable's thumbnail is blanked — the dump names a PNG per item that
|
||||
// this server does not have — and nothing else about a drop changes.
|
||||
const blanked =
|
||||
listing.GiftDrop.EquipmentModificationGuid !== '' ||
|
||||
listing.GiftDrop.ConsumableItemDesc !== ''
|
||||
expect(served.GiftDrop).toEqual(
|
||||
blanked ? { ...listing.GiftDrop, ThumbnailImageName: '' } : listing.GiftDrop
|
||||
)
|
||||
// The other edit to a listing: the dump was taken mid-sale (80% off nearly everything,
|
||||
// no end date), and the client posts the sale price it computes — which this server,
|
||||
// which knows only list prices, would refuse as "Price has changed". The `Price` beside
|
||||
// it is the list price and stands.
|
||||
expect(served.Prices).toEqual(stripSale(listing.Prices))
|
||||
expect(served.SubscriberPrices).toEqual(stripSale(listing.SubscriberPrices))
|
||||
}
|
||||
expect(sf32025.SubscriberDiscountPercent).toBe(storeDump.SubscriberDiscountPercent)
|
||||
|
||||
// After the dump's listings: the equipment skins the game's own store never sold, from
|
||||
// `skins.json`, each a listing of its own in the dump's skin shape — rarity 50 and priced
|
||||
// from it (the store's own rarity-50 skins run 1000-6000), numbered from a million in
|
||||
// capture order. A capture guid the store lists is NOT added again: the capture's names
|
||||
// and rarities are wrong wherever the two can be compared, so the listing is the record.
|
||||
const dumpSkins = new Set(
|
||||
dumpListings.map((l) => l.GiftDrop.EquipmentModificationGuid).filter((g) => g !== '')
|
||||
)
|
||||
const unlisted = skinsJson.filter((sk) => !dumpSkins.has(sk.ModificationGuid))
|
||||
expect(unlisted.length).toBeGreaterThan(100)
|
||||
// A gold skin is the one appended listing that differs from `unlistedSkinListing`: its
|
||||
// price, checked on its own below.
|
||||
const tail = sf32025.StoreItems.slice(dumpListings.length)
|
||||
const goldPrice = { CurrencyType: 2, Price: GOLD_SKIN_PRICE, StorefrontSaleData: null }
|
||||
const gilded = (l: StoreListing): StoreListing =>
|
||||
isGoldSkin(l) ? { ...l, Prices: [goldPrice], SubscriberPrices: [goldPrice] } : l
|
||||
expect(tail).toEqual(
|
||||
unlisted.map((sk, i) => gilded(unlistedSkinListing(sk, UNLISTED_SKIN_ID_BASE + i)))
|
||||
)
|
||||
expect(tail[0]).toMatchObject({
|
||||
GiftDrop: { Rarity: UNLISTED_SKIN_RARITY, EquipmentPrefabName: unlisted[0]!.PrefabName },
|
||||
Prices: [{ CurrencyType: 2, Price: 3000, StorefrontSaleData: null }],
|
||||
SubscriberPrices: [{ CurrencyType: 2, Price: 2700, StorefrontSaleData: null }],
|
||||
})
|
||||
expect(sf32025.StoreItems).toHaveLength(dumpListings.length + unlisted.length)
|
||||
// Every skin the capture knows is now sold by this store, once — and no skin, the game's
|
||||
// own included, names a thumbnail.
|
||||
const soldSkins = storeListings.filter((i) => i.GiftDrop.EquipmentModificationGuid !== '')
|
||||
const soldGuids = soldSkins.map((i) => i.GiftDrop.EquipmentModificationGuid)
|
||||
expect(new Set(soldGuids).size).toBe(soldGuids.length)
|
||||
expect(skinsJson.every((sk) => soldGuids.includes(sk.ModificationGuid))).toBe(true)
|
||||
expect(soldSkins.every((i) => i.GiftDrop.ThumbnailImageName === '')).toBe(true)
|
||||
expect(soldSkins.length).toBeGreaterThan(unlisted.length)
|
||||
// A "(Gold)" skin — thirteen, all from the capture — is a prestige item: 100,000 tokens,
|
||||
// the same for a subscriber, no sale. A "(Gold)" AVATAR item is priced as the dump has it.
|
||||
const goldSkins = soldSkins.filter((i) => i.GiftDrop.FriendlyName.includes('(Gold)'))
|
||||
expect(goldSkins.length).toBeGreaterThan(10)
|
||||
for (const g of goldSkins) {
|
||||
expect(g.Prices, g.GiftDrop.FriendlyName).toEqual([goldPrice])
|
||||
expect(g.SubscriberPrices, g.GiftDrop.FriendlyName).toEqual([goldPrice])
|
||||
}
|
||||
expect(
|
||||
soldSkins.filter((i) => !isGoldSkin(i)).every((i) => i.Prices[0]?.Price !== GOLD_SKIN_PRICE)
|
||||
).toBe(true)
|
||||
const goldAvatar = storeListings.find(
|
||||
(i) => i.GiftDrop.AvatarItemDesc !== '' && i.GiftDrop.FriendlyName.includes('(Gold)')
|
||||
)!
|
||||
expect(goldAvatar.Prices[0]?.Price).not.toBe(GOLD_SKIN_PRICE)
|
||||
|
||||
// Consumables likewise; avatar items keep theirs.
|
||||
const consumables = storeListings.filter((i) => i.GiftDrop.ConsumableItemDesc !== '')
|
||||
expect(consumables.length).toBeGreaterThan(200)
|
||||
expect(consumables.every((i) => i.GiftDrop.ThumbnailImageName === '')).toBe(true)
|
||||
expect(
|
||||
storeListings.filter(
|
||||
(i) => i.GiftDrop.AvatarItemDesc !== '' && (i.GiftDrop.ThumbnailImageName ?? '') !== ''
|
||||
).length
|
||||
).toBeGreaterThan(2000)
|
||||
// The dump's `NextUpdate` was the real store's next rotation, long past; the client
|
||||
// refetches after it, and this file is regenerated by hand.
|
||||
expect(sf32025.NextUpdate > '2200').toBe(true)
|
||||
|
||||
// Ids are unique within each file: `findStoreItem` resolves a purchase by id, so a
|
||||
// repeated id would mean array order decides what the player bought.
|
||||
for (const [label, file] of [
|
||||
['sf3', sf3],
|
||||
['sf3-2025', sf32025],
|
||||
@@ -2573,57 +2692,18 @@ describe('econ endpoints', () => {
|
||||
expect(new Set(ids).size, label).toBe(ids.length)
|
||||
}
|
||||
|
||||
// The discount is expressed ONLY in `SubscriberPrices`; announcing it again at the top
|
||||
// level risks a client taking 10% off an already-discounted price and posting through the
|
||||
// server's own subscriber floor, refused as "Price has changed".
|
||||
expect(sf3.SubscriberDiscountPercent).toBe(0)
|
||||
expect(sf32025.SubscriberDiscountPercent).toBe(0)
|
||||
|
||||
// Every GENERATED item is priced from its rarity, and rarity -1 (the developer tier) is
|
||||
// excluded rather than priced — an item listed here can be bought.
|
||||
const priceByRarity = new Map([
|
||||
[0, 150],
|
||||
[10, 600],
|
||||
[20, 700],
|
||||
[30, 800],
|
||||
[50, 3000],
|
||||
])
|
||||
for (const item of catalogItems()) {
|
||||
const expected = priceByRarity.get(item.GiftDrop.Rarity)
|
||||
expect(expected, `rarity ${item.GiftDrop.Rarity}`).toBeDefined()
|
||||
expect(item.Prices[0]).toMatchObject({ CurrencyType: 2, Price: expected })
|
||||
// Floored, matching the server's own `subscriberFloor`.
|
||||
expect(item.SubscriberPrices[0]).toMatchObject({
|
||||
CurrencyType: 2,
|
||||
Price: Math.floor((expected! * 90) / 100),
|
||||
})
|
||||
// `GiftDropId` echoes the id, as the capture did on all 1161 of its items.
|
||||
expect(item.GiftDrop.GiftDropId).toBe(item.PurchasableItemId)
|
||||
expect(item.PurchasableItemId).toBeGreaterThanOrEqual(CATALOG_ID_BASE)
|
||||
}
|
||||
expect(catalogItems().filter((i) => i.GiftDrop.Rarity === -1)).toEqual([])
|
||||
|
||||
// The CARRIED half — 30 consumables and 5 random boxes — comes from
|
||||
// `static/db/consumables.json`, what survives of the 2023 capture. The item catalog does
|
||||
// not model these, so they keep their own ids and prices.
|
||||
const carried = sf3.StoreItems.filter((i) => capturedIds.has(i.PurchasableItemId))
|
||||
expect(carried.length).toBeGreaterThan(0)
|
||||
expect(carried.every((i) => (i.GiftDrop.AvatarItemDesc ?? '') === '')).toBe(true)
|
||||
|
||||
// And NO equipment skins anywhere in either file: they are awarded from weekly challenges,
|
||||
// so a store listing one would sell something the game gives away.
|
||||
for (const [label, file] of [
|
||||
['sf3', sf3],
|
||||
['sf3-2025', sf32025],
|
||||
] as const) {
|
||||
expect(
|
||||
file.StoreItems.filter((i) => (i.GiftDrop.EquipmentModificationGuid ?? '') !== ''),
|
||||
label
|
||||
).toEqual([])
|
||||
}
|
||||
// A known listing, at the dump's own numbers: Candy Apples, 1801, 95 tokens, 85 for a
|
||||
// subscriber — NOT the 2023 store's 2184, and NOT a price computed from its rarity.
|
||||
const apples = sf32025.StoreItems.find((i) => i.GiftDrop.FriendlyName === 'Candy Apples')
|
||||
expect(apples).toMatchObject({
|
||||
PurchasableItemId: 1801,
|
||||
GiftDrop: { GiftDropId: 3361, ConsumableItemDesc: 'EmPvh3I6L0uK_1i8Wy_ylQ' },
|
||||
Prices: [{ CurrencyType: 2, Price: 95, StorefrontSaleData: null }],
|
||||
SubscriberPrices: [{ CurrencyType: 2, Price: 85, StorefrontSaleData: null }],
|
||||
})
|
||||
|
||||
// An id with no storefront still 404s, and 1704 is gone — it was a stand-in for a store
|
||||
// that turned out to belong inside sf3.
|
||||
// that turned out to be this one.
|
||||
for (const id of [1705, 1704]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/${id}`)
|
||||
expect(res.status, String(id)).toBe(404)
|
||||
@@ -6010,8 +6090,8 @@ describe('catalog', () => {
|
||||
expect(migrations).not.toContain('DELETE FROM catalog')
|
||||
})
|
||||
|
||||
test('the loader maps both captures onto the columns it declares', async () => {
|
||||
const { rows, collisions } = buildCatalogLoad(avatarItemsJson, skinsJson)
|
||||
test('the loader maps the served store and the skin capture onto the columns it declares', async () => {
|
||||
const { rows, collisions, skipped } = buildCatalogLoad(storeListings)
|
||||
|
||||
// Every row carries exactly one value per declared column, in that order — the loader
|
||||
// renders them positionally, so a column added to one side and not the other is a silent
|
||||
@@ -6021,60 +6101,120 @@ describe('catalog', () => {
|
||||
const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
|
||||
expect(rows.every((r) => r.values[keyAt] === r.key)).toBe(true)
|
||||
|
||||
// One key space, both kinds, no collisions between them.
|
||||
// One key space, every kind, no collisions between them. A listing loads as the kind of
|
||||
// the ONE item id it carries; the rows come out in store order.
|
||||
expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length)
|
||||
const kinds = rows.map((r) => r.values[kindAt])
|
||||
expect(kinds.filter((k) => k === 'avatar_item')).toHaveLength(avatarItemsJson.length)
|
||||
const listed = (pick: (l: StoreListing) => string) =>
|
||||
new Set(storeListings.map(pick).filter((k) => k !== '')).size
|
||||
expect(kinds.filter((k) => k === 'avatar_item')).toHaveLength(
|
||||
listed((l) => l.GiftDrop.AvatarItemDesc)
|
||||
)
|
||||
expect(kinds.filter((k) => k === 'consumable')).toHaveLength(
|
||||
listed((l) => l.GiftDrop.ConsumableItemDesc)
|
||||
)
|
||||
expect(rows[0]?.label).toBe(`${storeListings[0]!.GiftDrop.FriendlyName} (avatar item)`)
|
||||
|
||||
// Skins are the one place the counts may legitimately differ: the capture holds five guids
|
||||
// twice. A repeat is a defect rather than something the table models, so the loader keeps
|
||||
// the first and RETURNS the rest for the caller to report — dropping them silently is the
|
||||
// exact failure the single key exists to prevent.
|
||||
const distinctSkinKeys = new Set(skinsJson.map((s) => s.ModificationGuid)).size
|
||||
expect(kinds.filter((k) => k === 'skin')).toHaveLength(distinctSkinKeys)
|
||||
expect(collisions).toHaveLength(skinsJson.length - distinctSkinKeys)
|
||||
expect(collisions.every((c) => c.kept !== c.dropped)).toBe(true)
|
||||
// Skins: every guid the served store lists, which is every guid the capture holds too —
|
||||
// the store file carries the capture's unlisted skins as listings of their own (see the
|
||||
// file test) — so the loader never reads `skins.json` at all. A skin the game's own store
|
||||
// sold keeps the store's name and rarity, not the capture's, which disagree on every row.
|
||||
const listedSkins = new Set(
|
||||
storeListings.map((l) => l.GiftDrop.EquipmentModificationGuid).filter((g) => g !== '')
|
||||
)
|
||||
const capturedSkins = new Set(skinsJson.map((sk) => sk.ModificationGuid))
|
||||
expect([...capturedSkins].every((g) => listedSkins.has(g))).toBe(true)
|
||||
expect(kinds.filter((k) => k === 'skin')).toHaveLength(listedSkins.size)
|
||||
const both = dumpListings.find(
|
||||
(l) =>
|
||||
l.GiftDrop.EquipmentModificationGuid !== '' &&
|
||||
capturedSkins.has(l.GiftDrop.EquipmentModificationGuid)
|
||||
)!
|
||||
const row = rows.find((r) => r.key === both.GiftDrop.EquipmentModificationGuid)!
|
||||
expect(row.id).toBe(both.PurchasableItemId)
|
||||
expect(row.values[CATALOG_INSERT_COLUMNS.indexOf('friendly_name')]).toBe(
|
||||
both.GiftDrop.FriendlyName
|
||||
)
|
||||
expect(row.values[CATALOG_INSERT_COLUMNS.indexOf('rarity')]).toBe(both.GiftDrop.Rarity)
|
||||
|
||||
// And the rows really do go in: the same table these tests built accepts a sample of the
|
||||
// real load unchanged, so a capture that would be rejected in production fails here. Rows
|
||||
// this file already seeded are skipped — they are real capture rows too, and re-inserting
|
||||
// one would trip the key constraint on the seed rather than on anything under test.
|
||||
const sample: CatalogLoadRow[] = []
|
||||
for (const row of [rows[0], rows[1], rows[rows.length - 2], rows[rows.length - 1]]) {
|
||||
if (row && (await getCatalogItem(env.DB, row.key)) === null) sample.push(row)
|
||||
}
|
||||
expect(sample.length).toBeGreaterThan(0)
|
||||
for (const row of sample) {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
|
||||
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
|
||||
)
|
||||
.bind(...row.values.map((v) => v ?? null))
|
||||
.run()
|
||||
expect((await getCatalogItem(env.DB, row.key))?.friendly_name).toBe(
|
||||
row.values[CATALOG_INSERT_COLUMNS.indexOf('friendly_name')]
|
||||
)
|
||||
await env.DB.prepare('DELETE FROM catalog WHERE item_key = ?1').bind(row.key).run()
|
||||
}
|
||||
// What the store lists that is NOT a catalog item is counted, not loaded: first-party
|
||||
// custom items (the `custom_avatar_item` table's), loot boxes, token bundles — and the
|
||||
// PACKS, a consumable listed again under another number with a count. Every listing is
|
||||
// accounted for: a row, a skipped count, or nothing else.
|
||||
expect(skipped.custom_avatar_item).toBe(
|
||||
storeListings.filter((l) => l.GiftDrop.CustomAvatarItemId).length
|
||||
)
|
||||
expect(skipped.query).toBe(storeListings.filter((l) => l.GiftDrop.IsQuery).length)
|
||||
expect(skipped.consumable_pack).toBeGreaterThan(0)
|
||||
expect(
|
||||
storeListings.find((l) => l.GiftDrop.FriendlyName === 'Disco Dance Break 3-Pack')?.GiftDrop
|
||||
.ConsumableItemDesc
|
||||
).toBe(
|
||||
storeListings.find((l) => l.GiftDrop.FriendlyName === 'Disco Dance Break')?.GiftDrop
|
||||
.ConsumableItemDesc
|
||||
)
|
||||
expect(rows.length + Object.values(skipped).reduce((a, b) => a + b, 0)).toBe(
|
||||
storeListings.length
|
||||
)
|
||||
|
||||
// The row the capture had a skin pasted over. It is an avatar item, and the skin that
|
||||
// overwrote its name lives in skins.json where it belongs.
|
||||
expect(avatarItemsJson.filter((i) => i.FriendlyName === 'Disc (Coop)')).toEqual([])
|
||||
// The served store repeats no key, so nothing collides. The report is still wired: a
|
||||
// repeat is a defect rather than something the table models, so the loader keeps the
|
||||
// first and RETURNS the rest for the caller to report, because a collision that vanishes
|
||||
// quietly is the exact failure the single key exists to prevent. (The dump's own verbatim
|
||||
// repeats never reach it: the served file has them collapsed.)
|
||||
expect(collisions).toEqual([])
|
||||
const first = storeListings[0]!
|
||||
const twice = buildCatalogLoad([first, { ...first, PurchasableItemId: 999_999 }])
|
||||
expect(twice.rows).toHaveLength(1)
|
||||
expect(twice.rows[0]?.id).toBe(first.PurchasableItemId)
|
||||
expect(twice.collisions).toEqual([
|
||||
{
|
||||
key: first.GiftDrop.AvatarItemDesc,
|
||||
kept: twice.rows[0]!.label,
|
||||
dropped: twice.rows[0]!.label,
|
||||
},
|
||||
])
|
||||
// And one number naming two different things is refused outright, not resolved by order.
|
||||
expect(() =>
|
||||
buildCatalogLoad([
|
||||
first,
|
||||
{ ...storeListings[1]!, PurchasableItemId: first.PurchasableItemId },
|
||||
])
|
||||
).toThrow(/names two items/)
|
||||
|
||||
// The skin that was once pasted over an avatar item's row lives in skins.json where it
|
||||
// belongs.
|
||||
expect(skinsJson.filter((s) => s.FriendlyName === 'Disc (Coop)')).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('catalog_id is a contiguous, unique, load-order handle from 10000', async () => {
|
||||
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
|
||||
test('catalog_id is the listing’s PurchasableItemId, always', async () => {
|
||||
const { rows } = buildCatalogLoad(storeListings)
|
||||
|
||||
// BASE..BASE+N-1 with no gaps, in capture order — avatar items first, then skins. Numbered
|
||||
// AFTER de-duplication, so a dropped duplicate must not burn a number and leave a hole.
|
||||
//
|
||||
// From 10000 rather than 1 because a generated storefront lists a row under this very
|
||||
// number as its `PurchasableItemId`, and every captured storefront's ids are 2764 or below
|
||||
// — numbering from 1 would have made one id mean two different items.
|
||||
expect(rows.map((r) => r.id)).toEqual(rows.map((_, i) => CATALOG_ID_BASE + i))
|
||||
expect(Math.min(...rows.map((r) => r.id))).toBe(CATALOG_ID_BASE)
|
||||
// A row's number IS its listing's `PurchasableItemId` — the number sf3-2025.json sells it
|
||||
// under — so nothing has to be kept in step between the file and the table. No row is
|
||||
// numbered by the loader: the skins the game's own store never sold are LISTINGS in the
|
||||
// served file, numbered there from `UNLISTED_SKIN_ID_BASE`.
|
||||
const byId = new Map(sf32025.StoreItems.map((i) => [i.PurchasableItemId, i]))
|
||||
expect(rows.length).toBeGreaterThan(2500)
|
||||
for (const row of rows) {
|
||||
const drop = byId.get(row.id)?.GiftDrop
|
||||
expect(drop, String(row.id)).toBeDefined()
|
||||
expect(
|
||||
[drop!.AvatarItemDesc, drop!.ConsumableItemDesc, drop!.EquipmentModificationGuid],
|
||||
String(row.id)
|
||||
).toContain(row.key)
|
||||
}
|
||||
expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length)
|
||||
const appended = rows.filter((r) => r.id >= UNLISTED_SKIN_ID_BASE)
|
||||
expect(appended.length).toBeGreaterThan(0)
|
||||
expect(appended.every((r) => r.values[CATALOG_INSERT_COLUMNS.indexOf('kind')] === 'skin')).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
appended.every(
|
||||
(r) => r.values[CATALOG_INSERT_COLUMNS.indexOf('rarity')] === UNLISTED_SKIN_RARITY
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// The id in the row object and the id in the values it renders are the same number — the
|
||||
// loader binds `values` positionally, so a mismatch would write one and report the other.
|
||||
@@ -6190,7 +6330,7 @@ describe('catalog', () => {
|
||||
|
||||
// Replace: `DELETE FROM catalog` first, and the sentinel goes with everything else. That is
|
||||
// why it is opt-in — pointed at a partial capture it removes whatever the file omits.
|
||||
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
|
||||
const { rows } = buildCatalogLoad(storeListings)
|
||||
await env.DB.prepare('DELETE FROM catalog').run()
|
||||
await upsert((rows[0] as CatalogLoadRow).values)
|
||||
expect(await getCatalogItem(env.DB, 'untouched-by-any-load')).toBeNull()
|
||||
|
||||
Reference in New Issue
Block a user