From 663fdaf857cda03ed23cdfc486f282d1bafccc55 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 20 Aug 2026 15:55:15 -0400 Subject: [PATCH] [lists] more lists --- apps/discovery/static/sections.json | 16 ++-- apps/lists/src/lists.app.ts | 90 ++++++++++++++++----- apps/lists/src/test/integration/api.test.ts | 59 ++++++++++++++ 3 files changed, 136 insertions(+), 29 deletions(-) diff --git a/apps/discovery/static/sections.json b/apps/discovery/static/sections.json index 8942a04..75e3204 100644 --- a/apps/discovery/static/sections.json +++ b/apps/discovery/static/sections.json @@ -15,6 +15,14 @@ "sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky", "displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}" }, + { + "id": "Rooms_ContinuePlaying_PlayLibrary", + "sectionType": 0, + "sectionSubType": "Rooms_ContinuePlaying", + "source": "Recent", + "sourceMetadata": null, + "displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" + }, { "id": "Rooms_RecentlyUpdated_TabsTest_Explore", "sectionType": 0, @@ -79,14 +87,6 @@ "sourceMetadata": "explore_algoendpoint", "displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" }, - { - "id": "Rooms_ContinuePlaying_PlayLibrary", - "sectionType": 0, - "sectionSubType": "Rooms_ContinuePlaying", - "source": "Recent", - "sourceMetadata": null, - "displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}" - }, { "id": "Rooms_SavedForLater_PlayHighlight", "sectionType": 0, diff --git a/apps/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 226493c..6387d5c 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { getHotRooms, getNewRooms, getRecentlyUpdatedRooms } from '@repo/domain' +import { getHotRooms, getNewRooms, getRecentlyUpdatedRooms, getVisitedRooms } from '@repo/domain' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' @@ -72,6 +72,15 @@ const ALGORITHMIC_LIST_ENTITIES: Array<{ Id: string; Context: string | null }> = /** How many rooms a row carries. A discovery carousel shows a page, not the world. */ const LIST_SIZE = 20 +/** + * A row's rooms as the entities the client reads back. Only the ids travel — it resolves + * each room against the `rooms` worker itself — so `Id` is the room id as a STRING and + * `Context` (the ranking attribution) is null, exactly as in `ALGORITHMIC_LIST_ENTITIES`. + */ +function toEntities(rooms: Room[]): Array<{ Id: string; Context: string | null }> { + return rooms.map((room) => ({ Id: String(room.RoomId), Context: null })) +} + /** * The feed the Hot row is drawn from: `community`, which is the hot ranking with the rooms * the Coach account (id 1) created dropped. Those are this server's stock/seeded rooms, and @@ -81,6 +90,19 @@ const LIST_SIZE = 20 */ const HOT_LIST_FEED = 'community' +/** What fills one row: the rooms it serves, in the order it serves them. */ +type RowFeed = (db: D1Database) => Promise + +/** + * The browse feeds answer a `{ Results, TotalResults }` PAGE; a row serves a bare list, and + * the total is meaningless here — a carousel shows what it shows. This unwraps one so the + * table below reads as a list of rankings rather than of destructurings. + */ +const ranked = + (feed: (db: D1Database) => Promise<{ Results: Room[] }>): RowFeed => + async (db) => + (await feed(db)).Results + /** * A CATEGORY row: the public, listable rooms carrying one tag, ordered the way the hot feed * orders anything — live player count first, then engagement — so the busiest rooms in the @@ -90,10 +112,7 @@ const HOT_LIST_FEED = 'community' * Only the `new`/`community` pseudo-tags get special treatment in there, so a category row * must never be given one of those names. */ -const tagRow = - (tag: string) => - (db: D1Database): Promise<{ Results: Room[] }> => - getHotRooms(db, tag, 0, LIST_SIZE) +const tagRow = (tag: string): RowFeed => ranked((db) => getHotRooms(db, tag, 0, LIST_SIZE)) /** * The rows that serve a LIVE ranking, keyed by the row slug, each answering the rooms that @@ -117,18 +136,18 @@ const tagRow = * * The definitions live in `@repo/domain` next to the browse feeds they are cousins of. */ -const ROW_FEEDS: Record Promise<{ Results: Room[] }>> = { +const ROW_FEEDS: Record = { // The same ranking the rooms worker's `/rooms/hot` serves — live player count first, // then engagement — so the Hot row shows the rooms people are actually in. - hotlist: (db) => getHotRooms(db, HOT_LIST_FEED, 0, LIST_SIZE), + hotlist: ranked((db) => getHotRooms(db, HOT_LIST_FEED, 0, LIST_SIZE)), // Ordered by when each room's live scene was last PUBLISHED. A staged save doesn't // count: nothing anyone else can load has changed, so it must not float the room. - recentlyupdated: (db) => getRecentlyUpdatedRooms(db, 0, LIST_SIZE), + recentlyupdated: ranked((db) => getRecentlyUpdatedRooms(db, 0, LIST_SIZE)), // Newest player-made rooms by creation time. Distinct from the browse screen's `tag=new` // chip, which selects on the RRO flag instead — see `getNewRooms`. - new: (db) => getNewRooms(db, 0, LIST_SIZE), + new: ranked((db) => getNewRooms(db, 0, LIST_SIZE)), // The category rows. Each names its tag OUTRIGHT rather than deriving one from the slug, // because the mapping is not mechanical — `quests_algoendpoint` is plural and its tag @@ -143,6 +162,25 @@ const ROW_FEEDS: Record Promise<{ Results: Room[] }> explore_algoendpoint: tagRow('explore'), } +/** + * Rows whose contents are a PROPERTY OF THE CALLER rather than a ranking — the same slug + * answers a different list for every player, so these are looked up separately and only + * these ever read the token. A row here is answered from the caller's own account id; there + * is nothing sensible to serve a caller who has no token (see the handler). + * + * Deliberately NOT filtered to public/listable/player-made the way the `ROW_FEEDS` rankings + * are. This is the player's own history: a room they visited that has since gone private is + * still a room they can get back to, and hiding it here while + * `rooms` `GET /rooms/visitedby/me` still lists it would have the same history read two ways. + */ +const PERSONAL_ROW_FEEDS: Record Promise> = { + // Rooms the caller has been in, most recently visited first — the "Continue Playing" + // carousel as an algorithmic row. Backed by the `interaction` table's `last_visited_at`, + // which the `match` heartbeat stamps, and served straight from `getVisitedRooms` so this + // row and `rooms` `GET /rooms/visitedby/me` can never disagree about where someone has been. + recentlyvisited: (db, accountId) => getVisitedRooms(db, accountId, 0, LIST_SIZE), +} + /** * The entity type an algorithmic list reports when the query names none. The client always * sends `?type=`, and `Rooms` is what it asks for; falling back to `Accounts` (0, the enum's @@ -224,12 +262,12 @@ const app = new Hono() // `Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore`), and the answer is the // ranked entities that fill it, which the client then resolves by id itself. // - // `HotList`, `recentlyupdated` and `new` are ranked for real (see `ROW_FEEDS`). Every - // other row still serves the canned entities, and an unknown row key gets them too rather - // than a 404, which the client renders as a row that failed to load. `Type` is echoed - // back from the query: it tells the client what the `Id`s ARE (rooms, players, …), so - // answering with a type the caller didn't ask for would have it resolve the ids against - // the wrong service. + // `HotList`, `recentlyupdated` and `new` are ranked for real (see `ROW_FEEDS`), and + // `recentlyvisited` is per-caller (see `PERSONAL_ROW_FEEDS`). Every other row still serves + // the canned entities, and an unknown row key gets them too rather than a 404, which the + // client renders as a row that failed to load. `Type` is echoed back from the query: it + // tells the client what the `Id`s ARE (rooms, players, …), so answering with a type the + // caller didn't ask for would have it resolve the ids against the wrong service. .get('/algorithmiclists/:list', async (c) => { // Echoed, but only when it fits the byte the client reads it back into — anything // outside 0–255 can't round-trip, so a nonsense `?type=` gets the default instead of a @@ -237,16 +275,26 @@ const app = new Hono() const type = Number.parseInt(c.req.query('type') ?? '', 10) const echoed = type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE + const key = c.req.param('list').toLowerCase() + + // A per-caller row needs to know who is asking, so it is the one kind of row that + // reads the token. No token — or one that doesn't resolve — answers an EMPTY row + // rather than 401ing or falling through to the canned entities: this is a row about + // what the caller has done, and canned rooms would claim they visited rooms they + // never did. An empty carousel is also what a brand-new account legitimately has. + const personal = PERSONAL_ROW_FEEDS[key] + if (personal !== undefined) { + const accountId = await authedId(c) + const rooms = accountId === null ? [] : await personal(c.env.DB, accountId) + return c.json({ Type: echoed, Entities: toEntities(rooms) }) + } + // A row with a live feed behind it serves that; everything else gets the canned // entities. Only the ids travel — the client resolves each room itself — so the // ranking is read for its order and the room blobs are thrown away. - const feed = ROW_FEEDS[c.req.param('list').toLowerCase()] + const feed = ROW_FEEDS[key] if (feed !== undefined) { - const { Results } = await feed(c.env.DB) - return c.json({ - Type: echoed, - Entities: Results.map((room) => ({ Id: String(room.RoomId), Context: null })), - }) + return c.json({ Type: echoed, Entities: toEntities(await feed(c.env.DB)) }) } return c.json({ Type: echoed, Entities: ALGORITHMIC_LIST_ENTITIES }) diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index 72eb487..6467e71 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -418,6 +418,65 @@ it.each(['recentlyupdated', 'new'])( } ) +/** Stamp a visit, the way the `match` heartbeat's interaction write would. */ +async function recordVisit(playerId: number, roomId: number, at: string): Promise { + await env.DB.prepare( + 'INSERT OR REPLACE INTO interaction (player_id, room_id, last_visited_at) VALUES (?1, ?2, ?3)' + ) + .bind(playerId, roomId, at) + .run() +} + +/** The row ids `list` serves to the caller `headers` authenticate as. */ +async function personalRowIds(list: string, headers: Record): Promise { + const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${list}?type=1`, { headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + Type: number + Entities: Array<{ Id: string; Context: null }> + } + expect(body.Type).toBe(1) + expect(body.Entities.every((e) => e.Context === null)).toBe(true) + return body.Entities.map((e) => e.Id) +} + +it('orders /algorithmiclists/recentlyvisited by the caller’s last visit, newest first', async () => { + // Player 42 (what `bearer()` mints by default) has been in three rooms; the timestamps + // are deliberately unrelated to creation and publish order, so this row can't pass on + // either of the orderings `new` and `recentlyupdated` assert. + await recordVisit(42, 3, '2026-07-01T00:00:00Z') + await recordVisit(42, 8, '2026-07-03T00:00:00Z') + await recordVisit(42, 4, '2026-07-02T00:00:00Z') + + expect(await personalRowIds('recentlyvisited', await bearer('42'))).toEqual(['8', '4', '3']) +}) + +it('keeps one player’s recentlyvisited row out of another’s', async () => { + // Player 43 has been somewhere else entirely. The slug is the same for everyone, so the + // row has to be resolved from the TOKEN rather than from the row key. + await recordVisit(43, 7, '2026-07-04T00:00:00Z') + expect(await personalRowIds('recentlyvisited', await bearer('43'))).toEqual(['7']) +}) + +it('serves recentlyvisited a room the caller can still get back to, private or stock', async () => { + // Room 5 is private and room 2 is the Coach's — both are dropped from the RANKED rows, + // and both belong here: this is where the caller has actually been, not a recommendation. + await recordVisit(44, 5, '2026-07-05T00:00:00Z') + await recordVisit(44, 2, '2026-07-06T00:00:00Z') + expect(await personalRowIds('recentlyvisited', await bearer('44'))).toEqual(['2', '5']) +}) + +it('answers recentlyvisited empty for a caller with no history and no token', async () => { + // A brand-new account has been nowhere, and an untokened caller is nobody — both get an + // EMPTY row rather than the canned entities, which would claim visits that never happened, + // and rather than a 401, which the client renders as a row that failed to load. + expect(await personalRowIds('recentlyvisited', await bearer('999'))).toEqual([]) + + const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/RecentlyVisited?type=1`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Type: 1, Entities: [] }) +}) + it('serves the rooms tagged `quest` for /algorithmiclists/quests_algoendpoint', async () => { const ids = await rowIds('quests_algoendpoint') // Ordering is the hot feed's (live players, then engagement), so assert membership.