[econ] skins and consumables

This commit is contained in:
Devin Zuczek
2026-09-24 13:55:47 -04:00
parent 5013c5d4e6
commit 13b80f9748
12 changed files with 202173 additions and 159452 deletions
+11 -16
View File
@@ -322,22 +322,18 @@ const DEFAULT_STORE_ROW_RULE: StoreRowRule = { kind: CatalogKind.AvatarItem }
* hand-picked list pretended to be a ranking. It also exercises the whole catalog instead of
* the same ten ids forever, which is the point of pointing it at the table.
*
* {@link isSellableRarity} is applied whatever the kind: the generated storefront omits the
* developer/unreleased tier — `catalog_id` 10002 is in the table and NOT in sf1704 — and an id
* no storefront sells renders as nothing, indistinguishable from an id the client failed to
* parse. No skin carries that rarity today, so the clause costs them nothing and stops a future
* capture that does from leaking one into a row.
* `UNSELLABLE_RARITIES` is applied whatever the kind: the developer/unreleased tier is kept out
* of the discovery rows. No skin carries that rarity today, so the clause costs them nothing
* and stops a future capture that does from leaking one into a row.
*
* The number in each id is the `catalog_id`, which is exactly what the generated storefront
* lists an avatar item under as its `PurchasableItemId` — one number resolves a row entity and a
* store item, with no second numbering between them. It is clear of every captured storefront's
* own ids by construction (see `CATALOG_ID_BASE`). It is a LOAD-ORDER surrogate, reassigned by
* every catalog load, which is fine for a row built and consumed within one request but is why
* nothing may store one.
* The number in each id is the `catalog_id`, which for a listed item IS its store
* `PurchasableItemId` — `sf3-2025.json` and the `catalog` table are loaded from the same dump —
* so one number resolves a row entity and a store item, with no second numbering between
* them. Nothing may store one all the same: an unlisted row's number is a load-order
* surrogate.
*
* WORTH KNOWING for skins: no generated storefront lists them — sf1704 is avatar items only —
* so a client handed a skin's id has nothing to resolve it against yet, and the row will draw
* as nothing until a storefront carries them.
* Skins included: the served 2025 store lists every one (the game's own 99 plus the rest at
* rarity 50, numbered from a million), so a `skinsitems` id resolves like any other.
*
* An empty result is served as an empty row rather than falling back to canned ids: the table
* being empty means the catalog was never loaded (`runx catalog load`), and a fallback would
@@ -859,8 +855,7 @@ const app = new Hono<App>()
'“earrings”/“hearing aids”, “shoes”/“sneakers”/“sandals”/“boots”, “pants”/“shorts”,',
'“quiver”/“backpack”/“sword”. Every other row returns the whole avatar-item catalog.',
'The name match is a stand-in — the catalog records no',
'category — so it takes anything the word appears in. No generated storefront lists',
'skins yet, so a client has nothing to resolve those ids against.',
'category — so it takes anything the word appears in.',
].join('\n'),
parameters: [ALGORITHMIC_LIST_PARAM, ALGORITHMIC_TYPE_PARAM],
responses: { 200: json(AlgorithmicList, 'The row’s entities, possibly none') },
+1 -1
View File
@@ -245,7 +245,7 @@ export const ListEntityDto = z.object({
Id: z
.string()
.describe(
'The room/item id the client resolves itself. On a GENERIC row (`type=5`) it is instead a `<prefix>.<id>` composite with EXACTLY ONE dot — `0.<int>` a purchasable item, `1.<guid>` a custom avatar item — since such a row can name things of more than one sort. The store rows serve `1.<guid>`, the `CustomAvatarItemId` of a first-party item. The integer after `0.` is a `catalog_id`, which is exactly what the generated storefront lists the item under as its `PurchasableItemId` — catalog ids start at 10000 so they cannot collide with a captured storefront’s own numbering.'
'The room/item id the client resolves itself. On a GENERIC row (`type=5`) it is instead a `<prefix>.<id>` composite with EXACTLY ONE dot — `0.<int>` a purchasable item, `1.<guid>` a custom avatar item — since such a row can name things of more than one sort. The store rows serve `1.<guid>`, the `CustomAvatarItemId` of a first-party item. The integer after `0.` is a `catalog_id`, which for a listed item is its store `PurchasableItemId` — the number `sf3-2025.json` sells it under.'
),
Context: z.string().nullable().describe('Ranking attribution; always null here'),
})
+9 -4
View File
@@ -11,7 +11,6 @@ import {
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
import { CATALOG_ID_BASE } from '../../../../econ/src/catalog-load'
import curatedLists from '../../../static/curated-lists.json'
import type { Env } from '../../context'
@@ -28,6 +27,13 @@ const ORIGIN = 'https://example.com'
*/
const CATALOG_SEEDED = 120
/**
* Where the numbered seeds' ids start. A real load numbers a listed row by its store
* `PurchasableItemId` — a few thousand at most — so these are shaped like store ids, and only
* the ranges below need to stay clear of each other.
*/
const CATALOG_SEED_ID = 10_000
/** The row size the worker serves — see `GENERIC_ROW_SIZE`. */
const GENERIC_ROW_SIZE = 50
@@ -81,7 +87,7 @@ const CATALOG_NAMED_ID = 80_001
* accept either range while still rejecting the unsellable row and every skin.
*/
const isSeededAvatarItem = (n: number): boolean =>
(n >= CATALOG_ID_BASE && n < CATALOG_ID_BASE + CATALOG_SEEDED) ||
(n >= CATALOG_SEED_ID && n < CATALOG_SEED_ID + CATALOG_SEEDED) ||
(n >= CATALOG_NAMED_ID && n < CATALOG_NAMED_ID + NAMED_SEEDS.length)
/** Where the seeded skins' ids start — clear of the avatar items above. */
@@ -150,8 +156,7 @@ beforeAll(async () => {
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES (?1, ?2, 'avatar_item', ?3, 0, -1)`
)
// Numbered from the base, exactly as a real load numbers them.
.bind(`seed-desc-${i},,,`, CATALOG_ID_BASE + i, `Seeded Item ${i}`)
.bind(`seed-desc-${i},,,`, CATALOG_SEED_ID + i, `Seeded Item ${i}`)
.run()
}
// Named items for the category rows, which filter on the NAME for want of anything better.