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() /** Same list without the creator — the client sometimes asks with a creator nothing owns. */ const BY_TYPE_NAME = new Map() /** By name alone, for a name whose `type` doesn't line up with what it is captured under. */ const BY_NAME = new Map() /** * 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() 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) ) }