[lists] more lists

This commit is contained in:
Devin Zuczek
2026-08-20 15:55:15 -04:00
parent cf1f09a22e
commit 663fdaf857
3 changed files with 136 additions and 29 deletions
+8 -8
View File
@@ -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,
+69 -21
View File
@@ -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<Room[]>
/**
* 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<string, (db: D1Database) => Promise<{ Results: Room[] }>> = {
const ROW_FEEDS: Record<string, RowFeed> = {
// 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<string, (db: D1Database) => 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<string, (db: D1Database, accountId: number) => Promise<Room[]>> = {
// 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<App>()
// `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 0255 can't round-trip, so a nonsense `?type=` gets the default instead of a
@@ -237,16 +275,26 @@ const app = new Hono<App>()
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 })
@@ -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<void> {
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<string, string>): Promise<string[]> {
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 callers 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 players recentlyvisited row out of anothers', 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.