Files
recflare/apps/lists/src/curated-lists.ts
T
Devin Zuczek c13f62bb91 [store] wip
2026-08-28 01:56:14 -04:00

226 lines
9.7 KiB
TypeScript

import { DEFAULT_LIST_IMAGE } from '@repo/domain'
import curatedLists from '../static/curated-lists.json'
import type { CuratedList } from '@repo/domain'
export type { CuratedList }
/**
* The PAGE-SOURCE enum, kept for reference. NOT what a list's `Type` is — see the note on
* `resolveCuratedList`: the captures and the client's own queries both put the
* `ListEntityType` there (what the `ItemIds` ARE), not the page. Nothing reads this.
*
* `None` is the client's unset sentinel; it never reaches the wire, and nothing is captured
* under it.
*/
export const CuratedListType = {
WatchHome: 0,
PlayHighlight: 1,
CommunityBoard: 2,
MobileHome: 3,
StoreFeatured: 4,
StoreClothing: 5,
StoreConsumables: 6,
PlayCategories: 7,
StoreInventions: 8,
TitleScreen: 9,
PlayMenuTabs: 10,
OrientationStoreFeatured: 11,
AppNavPortalPanel: 12,
WWPanelList: 13,
RecRoomPlusBenefits: 14,
RecCenterStorefront: 15,
RecCenterCommunityContent: 16,
None: 999,
} as const
/**
* Every list this server serves, all of them in `static/curated-lists.json` — drop a
* capture into that array and it is served; nothing else needs editing. (One file per list
* would read better, but wrangler bundles with esbuild, which has no glob import: a
* directory can only be picked up by naming every file in an `import`. `import.meta.glob`
* is a Vite feature — vitest runs through Vite and would resolve it, while the deployed
* build ships the call verbatim and throws at runtime, so tests would pass and the worker
* would not.)
*
* Nothing curates lists here, so these are static captures; ORDER matters only in that the
* first list of a given type is that page's default (see `BY_TYPE`).
*
* `ItemIds` are the discovery ROWS each page is built from (the section keys the `discovery`
* worker serves under `/sections/pagesource/*`), not room or item ids: the client resolves
* each row itself.
*/
const CURATED_LISTS: CuratedList[] = curatedLists
/**
* A list's `ListId` is the reference's own, and those are 64-bit
* (`624765592684307326`) — past what a JS number holds exactly, so parsing one rounds it
* (…307326 → …307300). The captures therefore carry it as a STRING, which survives the
* round trip, and this puts the digits back on the wire unquoted: the client's field is a
* number, and a quoted id fails its parser.
*
* Only a run of digits is unquoted, so a malformed id is left alone rather than corrupting
* the JSON — the integration tests assert every capture has one.
*/
export function serializeCuratedList(list: CuratedList): string {
return JSON.stringify(list).replace(/"ListId":"(\d+)"/, '"ListId":$1')
}
/** Names are matched case-insensitively — the casing that reaches us is the client's. */
function nameKey(name: string): string {
return name.toLowerCase()
}
/** All three keys the query carries. The most specific match wins. */
const BY_CREATOR_TYPE_NAME = new Map<string, CuratedList>()
/** Same list without the creator — the client sometimes asks with a creator nothing owns. */
const BY_TYPE_NAME = new Map<string, CuratedList>()
/** By name alone, for a name whose `type` doesn't line up with what it is captured under. */
const BY_NAME = new Map<string, CuratedList>()
/**
* By type alone: the page's DEFAULT list, which answers a request that names NO list — only
* a request that names none. A request that DOES name one and matches nothing is a miss and
* answers an empty placeholder; handing it the page default answers a question nobody asked,
* under a heading that belongs to another list.
*/
const BY_TYPE = new Map<number, CuratedList>()
for (const list of CURATED_LISTS) {
const name = nameKey(list.Name)
BY_CREATOR_TYPE_NAME.set(`${list.CreatorAccountId}/${list.Type}/${name}`, list)
if (!BY_TYPE_NAME.has(`${list.Type}/${name}`)) BY_TYPE_NAME.set(`${list.Type}/${name}`, list)
if (!BY_NAME.has(name)) BY_NAME.set(name, list)
// First captured wins, so a page with more than one list defaults to whichever sits
// earliest in `static/curated-lists.json`.
if (!BY_TYPE.has(list.Type)) BY_TYPE.set(list.Type, list)
}
/**
* The prefix the reference gives a list the CLIENT owns and creates for itself, rather than
* one a person named — `__SavedForLater_Rooms` is the one in play. It matters because these
* are the only names that must be allowed to come back EMPTY: see `resolveCuratedList`.
*/
const RESERVED_LIST_PREFIX = '__'
/** Whether a name is one of the client's own reserved playlists rather than a curated page. */
export function isReservedListName(name: string | undefined): boolean {
return (name ?? '').startsWith(RESERVED_LIST_PREFIX)
}
/**
* The list a reserved name answers when nobody owns one yet — the name asked for, with no
* items. A player who has saved nothing has an empty Saved for Later, not somebody else's
* list; `CreatorAccountId` is echoed from the query so the client still sees the list it
* asked for. `ListId` is 0: nothing was stored, so there is no id to hand back, and the
* client's field is a non-nullable number.
*/
function emptyReservedList(creatorAccountId: string | undefined, type: number, name: string) {
return {
ListId: '0',
CreatorAccountId: Number.parseInt(creatorAccountId ?? '', 10) || 0,
Name: name,
Description: null,
ImageName: DEFAULT_LIST_IMAGE,
Type: type,
ItemIds: [],
Accessibility: 1,
CreatedAt: new Date(0).toISOString(),
} satisfies CuratedList
}
/**
* The image a placeholder list carries.
*
* NOT `DEFAULT_LIST_IMAGE` (`DefaultRoomImage.jpg`), which is what a stored list and the
* reserved-playlist answer use. This name came off the client-facing shape for the
* placeholder, and the two are deliberately left as they were found rather than unified on a
* guess: the reserved path is known to work with the room image, and this one is what the
* placeholder was specified with. If a capture ever settles which the reference sends, they
* should converge on that.
*/
const PLACEHOLDER_LIST_IMAGE = 'DefaultListImage.jpg'
/**
* The `CreatedAt` a placeholder carries. Fixed rather than `new Date()` so the same missing
* list reads the same on every request — a placeholder that appears to have been created
* moments ago, differently each time, is worse than one that plainly never was.
*/
const PLACEHOLDER_CREATED_AT = '2026-08-24T18:44:52.438Z'
/**
* The list served for a name this server has nothing under.
*
* A well-formed but EMPTY list, echoing the name and type asked for. It must be a whole list
* object: the client BREAKS on a bare `{}`, and it broke differently on the 404 this used to
* send, so "there is no such list" has to be expressed as a list with nothing in it. `ItemIds`
* empty is what stops it rendering as content — a page whose rows this server has no capture
* of shows as an empty row rather than as another page's rows under its heading.
*
* Distinct from {@link emptyReservedList} despite the near-identical shape, and the two must
* not be merged: that one is a REAL list a player simply has not saved into yet (`ListId` 0,
* `Accessibility` 1, the shared default image), while this is a stand-in for a list that does
* not exist. Every field they differ in was arrived at against the live client.
*/
export function placeholderCuratedList(
creatorAccountId: string | undefined,
type: string | undefined,
name: string | undefined
) {
const parsedType = Number.parseInt(type ?? '', 10)
return {
// A string here and a NUMBER on the wire — see `serializeCuratedList`, which is why every
// id in this file is carried as one.
ListId: '1',
CreatorAccountId: Number.parseInt(creatorAccountId ?? '', 10) || 0,
Name: name ?? '',
Description: null,
ImageName: PLACEHOLDER_LIST_IMAGE,
Type: Number.isInteger(parsedType) ? parsedType : 0,
ItemIds: [],
Accessibility: 0,
CreatedAt: PLACEHOLDER_CREATED_AT,
} satisfies CuratedList
}
/**
* The list behind `GET /curatedlists?creatorAccountId=&type=&name=` once D1 has been asked
* and had nothing — the static captures, resolved most-specific first. UNDEFINED when
* nothing matches, which the route turns into a {@link placeholderCuratedList}: a name this
* server has nothing under is a list that does not exist, and answering it with an unrelated
* capture puts one page's rows under another page's heading.
*
* `type` is the `ListEntityType`: what the `ItemIds` ARE. (The client asks for
* `__SavedForLater_Rooms` with `type=1`, Rooms, and every capture here is `type=7`,
* DiscoverySection — which is exactly what their ItemIds hold. It is NOT the page-source
* enum, whose 7 is PlayCategories and would make two of the three captures wrong.)
*
* Two things still answer without a name match:
*
* - A RESERVED name (`__SavedForLater_Rooms`), which comes back EMPTY rather than missing:
* it is a list the client creates for itself, so "the player has saved nothing" is the
* right answer until they do, and an empty list hides the row the way its
* `minItemsToShowSection` asks for.
* - A request naming no list at all, which gets the page default for its type — the only
* list it could be asking for.
*/
export function resolveCuratedList(
creatorAccountId: string | undefined,
type: string | undefined,
name: string | undefined
): CuratedList | undefined {
const key = nameKey(name ?? '')
const parsedType = Number.parseInt(type ?? '', 10)
const hasType = Number.isInteger(parsedType)
return (
(hasType ? BY_CREATOR_TYPE_NAME.get(`${creatorAccountId}/${parsedType}/${key}`) : undefined) ??
(hasType ? BY_TYPE_NAME.get(`${parsedType}/${key}`) : undefined) ??
BY_NAME.get(key) ??
(isReservedListName(name)
? emptyReservedList(creatorAccountId, hasType ? parsedType : 0, name ?? '')
: undefined) ??
(key === '' && hasType ? BY_TYPE.get(parsedType) : undefined)
)
}