mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[econ] skins and consumables
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user