[lists] fixup lists a bit

This commit is contained in:
Devin Zuczek
2026-08-20 15:14:27 -04:00
parent b60e422580
commit cf1f09a22e
4 changed files with 331 additions and 46 deletions
+1 -9
View File
@@ -1,12 +1,4 @@
[
{
"id": "Rooms_RoomBanner_SketchyShowdown",
"sectionType": 6,
"sectionSubType": "RoomBanner",
"source": "2477031627165896495",
"sourceMetadata": "2477031627165896495",
"displayMetadata": null
},
{
"id": "Rooms_ForYou_PlayHighlight",
"sectionType": 0,
@@ -60,7 +52,7 @@
"sectionType": 13,
"sectionSubType": "featured_creator",
"source": "PlayerCreatedRooms",
"sourceMetadata": "1",
"sourceMetadata": "2",
"displayMetadata": "{\"DisplayTitle\":\"Featured Creator\", \"descriptionText\":\"Making moments you can play again and again!\",\"itemCount\":\"4\", \"unsupportedPlatforms\":[\"Switch\"]}"
},
{
+83 -29
View File
@@ -1,13 +1,14 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { getHotRooms } from '@repo/domain'
import { getHotRooms, getNewRooms, getRecentlyUpdatedRooms } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
import type { Context } from 'hono'
import type { Room } from '@repo/domain'
import type { App } from './context'
/**
@@ -50,10 +51,10 @@ const ListEntityType = {
const MAX_LIST_ENTITY_TYPE = 255
/**
* The entities an algorithmic list hands back (`GET /algorithmiclists/:list`) — ROOMS, which
* is what a Play/Explore row is built from. Nothing ranks anything here yet, so one canned
* set answers every row: rooms 26, the low ids this server's own rooms occupy, so a
* discovery row resolves to something real instead of five dead ids.
* The fallback entities for a row with no live feed behind it (`GET /algorithmiclists/:list`)
* — ROOMS, which is what a Play/Explore row is built from. Rooms 26, the low ids this
* server's own rooms occupy, so an unranked row resolves to something real instead of five
* dead ids. The rows in `ROW_FEEDS` are ranked for real and never reach this.
*
* `Id` is a STRING even though a room id is a number, and `Context` is where the reference
* server attributes the ranking/experiment that produced the entity. Nothing produced these,
@@ -68,19 +69,11 @@ const ALGORITHMIC_LIST_ENTITIES: Array<{ Id: string; Context: string | null }> =
'6',
].map((Id) => ({ Id, Context: null }))
/**
* The row key that serves the LIVE hot-room ranking rather than the canned entities — the
* same feed the rooms worker's `/rooms/hot` answers, which is what a "Hot" row on a
* discovery page is supposed to show. Matched case-insensitively, since the key reaches us
* from a curated page's `ItemIds` and its casing is the reference's, not ours.
*/
const HOT_LIST_KEY = 'hotlist'
/** How many rooms the hot row carries. A discovery carousel shows a page, not the world. */
const HOT_LIST_SIZE = 20
/** How many rooms a row carries. A discovery carousel shows a page, not the world. */
const LIST_SIZE = 20
/**
* The feed the hot row is drawn from: `community`, which is the hot ranking with the rooms
* 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
* a "Hot" row that is mostly Rec Center is a row about the server rather than about what
* players are doing. The pseudo-tag is the rooms worker's own — see `getHotRooms` — so the
@@ -88,6 +81,68 @@ const HOT_LIST_SIZE = 20
*/
const HOT_LIST_FEED = 'community'
/**
* 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
* category lead. `getHotRooms` already means "rooms with this tag, most active first" when
* handed a real tag, so a category row is that call with the tag pinned.
*
* 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)
/**
* The rows that serve a LIVE ranking, keyed by the row slug, each answering the rooms that
* fill it. Everything not in this table falls back to the canned entities, so adding a real
* row is adding a line here rather than another branch in the handler.
*
* Keys are lowercase and looked up folded: a slug reaches us from a curated page's `ItemIds`
* or a discovery section's `sourceMetadata`, and the casing there is the reference's rather
* than ours (`HotList`, `recentlyupdated`).
*
* Every row here yields ROOMS — a discovery carousel is a room carousel — and only the ids
* travel, which is why each of these reads a ranking and throws the room blobs away: the
* client resolves each room against the `rooms` worker itself.
*
* The three "what's happening" rows — Hot, Recently Updated, New — share one notion of
* which rooms are eligible (public, listable, and made by a PLAYER rather than by the Coach
* account), so none of them can show a room its siblings hide. A CATEGORY row deliberately
* does not: `quest` is carried by five rooms on this server and every one of them is the
* Coach's, so filtering them out would leave the Quests carousel permanently empty. A
* category row asks what a room is about, not who made it.
*
* 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[] }>> = {
// 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),
// 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),
// 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),
// 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
// `quest` is singular, while the six below happen to match. Deriving would quietly invent
// a `quests` tag no room carries and serve an empty carousel under a category heading.
quests_algoendpoint: tagRow('quest'),
battle_algoendpoint: tagRow('battle'),
roleplay_algoendpoint: tagRow('roleplay'),
horror_algoendpoint: tagRow('horror'),
hangout_algoendpoint: tagRow('hangout'),
casual_algoendpoint: tagRow('casual'),
explore_algoendpoint: tagRow('explore'),
}
/**
* 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
@@ -169,12 +224,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` is ranked for real — the same feed the rooms worker's `/rooms/hot` serves.
// 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`). 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
@@ -182,13 +237,12 @@ 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
// `HotList` is real: it serves the same ranking the rooms worker's `/rooms/hot` feed
// does — live player count first, then engagement — so the Hot row on a discovery page
// shows the rooms people are actually in, minus the Coach account's stock rooms (see
// HOT_LIST_FEED). Only the ids travel; the client resolves each room itself, which is
// why this reads the ranking and throws the room blobs away.
if (c.req.param('list').toLowerCase() === HOT_LIST_KEY) {
const { Results } = await getHotRooms(c.env.DB, HOT_LIST_FEED, 0, HOT_LIST_SIZE)
// 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()]
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 })),
+246 -7
View File
@@ -21,24 +21,118 @@ beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The shared room schema (owned by the `rooms` worker) plus a few public rooms — the
// HotList row ranks these. Presence is what makes a room "hot", so its table is here too.
// live rows rank these. Presence is what makes a room "hot", so its table is here too.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Creation order and publish order are deliberately near-REVERSES of each other, so the
// `new` row and the `recentlyupdated` row can't both be passing on the same ordering.
//
// room created published → new order: 7, 4, 8, 3
// 3 2026-01-01 2026-06-01 recentlyupdated: 3, 4, 8, 7
// 8 2026-01-15 2026-04-01
// 4 2026-02-01 2026-05-01
// 7 2026-03-01 never
for (const room of [
// Account 1 is the Coach — its rooms are this server's stock ones, which the discovery
// rows leave out.
//
// Tags fill the CATEGORY rows. Room 2 carries `quest` on purpose: every quest-tagged
// room this server ships with is the Coach's, so a category row that dropped them
// would be permanently empty.
{
RoomId: 2,
Name: 'RecCenter',
CreatorAccountId: 1,
CreatedAt: '2026-04-01T00:00:00Z',
Tags: [
{ Tag: 'rro', Type: 2 },
{ Tag: 'Quest', Type: 0 },
],
},
{
RoomId: 3,
Name: 'DodgeBall',
CreatorAccountId: 500,
CreatedAt: '2026-01-01T00:00:00Z',
Tags: [{ Tag: 'quest', Type: 0 }],
},
{
RoomId: 4,
Name: 'Quietly',
CreatorAccountId: 501,
CreatedAt: '2026-02-01T00:00:00Z',
Tags: [
{ Tag: 'pvp', Type: 0 },
{ Tag: 'horror', Type: 0 },
],
},
// Never published a save, so `recentlyupdated` falls back to its creation time — which
// is the newest of the lot, so it leads `new` and trails `recentlyupdated`.
{ RoomId: 7, Name: 'FreshRoom', CreatorAccountId: 503, CreatedAt: '2026-03-01T00:00:00Z' },
{ RoomId: 8, Name: 'Staged', CreatorAccountId: 504, CreatedAt: '2026-01-15T00:00:00Z' },
]) {
await seedRoomWithSubRooms(env.DB, {
...room,
Accessibility: 1,
IsDorm: false,
SubRooms: [],
} as Record<string, unknown>)
}
// Non-public and a dorm: neither belongs in a discovery row.
for (const room of [
// Account 1 is the Coach — its rooms are this server's stock ones, which the hot row
// leaves out.
{ RoomId: 2, Name: 'RecCenter', CreatorAccountId: 1, Accessibility: 1, IsDorm: false },
{ RoomId: 3, Name: 'DodgeBall', CreatorAccountId: 500, Accessibility: 1, IsDorm: false },
{ RoomId: 4, Name: 'Quietly', CreatorAccountId: 501, Accessibility: 1, IsDorm: false },
// Non-public and a dorm: neither belongs in a discovery row.
{ RoomId: 5, Name: 'SecretRoom', CreatorAccountId: 500, Accessibility: 0, IsDorm: false },
{ RoomId: 6, Name: '@Dorm', CreatorAccountId: 502, Accessibility: 1, IsDorm: true },
]) {
await seedRoomWithSubRooms(env.DB, { ...room, SubRooms: [] } as Record<string, unknown>)
}
// What a room's "last updated" reads from: the save its subroom currently PUBLISHES.
await publishSave(3, '2026-06-01T00:00:00Z')
await publishSave(4, '2026-05-01T00:00:00Z')
await publishSave(8, '2026-04-01T00:00:00Z')
// …and a STAGED save far in the future on room 8, which must not move it. Nothing anyone
// else can load has changed, so a row about updates must not float it to the top.
await stageSave(8, '2026-12-01T00:00:00Z')
})
/**
* Give a room a subroom whose published save was created at `createdAt`. Written straight
* to the shared tables rather than through the `rooms` worker's save route, which this
* worker has no way to call.
*/
async function publishSave(roomId: number, createdAt: string): Promise<void> {
await env.DB.prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?1, ?2)')
.bind(roomId, JSON.stringify({ SubRoomId: roomId, RoomId: roomId, Name: 'Home' }))
.run()
const id = await insertSave(roomId, createdAt)
await env.DB.prepare('UPDATE subroom SET current_save_id = ?2 WHERE sub_room_id = ?1')
.bind(roomId, id)
.run()
}
/** Attach an UNPUBLISHED save to a subroom that already exists. */
async function stageSave(subRoomId: number, createdAt: string): Promise<void> {
const id = await insertSave(subRoomId, createdAt)
await env.DB.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
.bind(subRoomId, id)
.run()
}
/** Append a save row and return its (globally unique) id. */
async function insertSave(subRoomId: number, createdAt: string): Promise<number> {
const row = await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
)
.bind(
subRoomId,
JSON.stringify({ SubRoomId: subRoomId, DataBlob: 'scene', CreatedAt: createdAt })
)
.first<{ sub_room_data_save_id: number }>()
return row!.sub_room_data_save_id
}
/** Put a player in a room, the way the `match` heartbeat would — this is what ranks it. */
async function putInRoom(accountId: number, roomId: number): Promise<void> {
const now = Math.floor(Date.now() / 1000)
@@ -278,6 +372,103 @@ it('serves the live hot-room ranking for /algorithmiclists/HotList', async () =>
expect(((await lower.json()) as { Entities: unknown[] }).Entities).toEqual(body.Entities)
})
/** Fetch a discovery row and return the room ids it serves, in order. */
async function rowIds(list: string): Promise<string[]> {
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${list}?type=1`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
Type: number
Entities: Array<{ Id: string; Context: null }>
}
expect(body.Type).toBe(1)
// Same entity shape as any other row: ids as STRINGS, `Context` null.
expect(body.Entities.every((e) => e.Context === null)).toBe(true)
return body.Entities.map((e) => e.Id)
}
it('orders /algorithmiclists/recentlyupdated by when each room last PUBLISHED', async () => {
// Publish order, newest first — room 7 last because it has never published and falls back
// to its own creation time. Note this is nearly the reverse of the `new` row below: the
// two rows read different timestamps, not the same one twice.
expect(await rowIds('recentlyupdated')).toEqual(['3', '4', '8', '7'])
})
it('does not let a STAGED save float a room up recentlyupdated', async () => {
// Room 8's staged save is dated December, later than every published save here. It stays
// third all the same: staging changes nothing another player can load, so a row about
// updates must not react to it.
const ids = await rowIds('recentlyupdated')
expect(ids.indexOf('8')).toBe(2)
})
it('orders /algorithmiclists/new by creation time', async () => {
expect(await rowIds('new')).toEqual(['7', '4', '8', '3'])
})
it.each(['recentlyupdated', 'new'])(
'leaves stock, private and dorm rooms out of %s',
async (list) => {
const ids = await rowIds(list)
// Room 2 is the Coach's — this server's stock rooms, which a row about what players have
// been building must not be full of.
expect(ids).not.toContain('2')
// Not public, and a dorm.
expect(ids).not.toContain('5')
expect(ids).not.toContain('6')
}
)
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.
expect([...ids].sort()).toEqual(['2', '3'])
// Room 2 is the COACH's and belongs here all the same. Every quest-tagged room this
// server ships with is the Coach's, so applying the player-made filter the Hot/New rows
// use would leave this carousel empty — a category row asks what a room is about, not
// who made it.
expect(ids).toContain('2')
// Tagged `pvp`, not `quest`.
expect(ids).not.toContain('4')
// A category row is still a discovery row: the private room and the dorm stay out.
expect(ids).not.toContain('5')
expect(ids).not.toContain('6')
})
// One row per category, each selecting on its own tag. The slugs and tags line up here,
// but the table maps them explicitly — see ROW_FEEDS.
it.each([
['horror_algoendpoint', ['4']],
// Nothing carries these tags yet, so the rows are genuinely EMPTY rather than falling
// back to the canned entities. An empty category is the honest answer; the fallback would
// show five unrelated rooms under a category heading.
['battle_algoendpoint', []],
['roleplay_algoendpoint', []],
['hangout_algoendpoint', []],
['casual_algoendpoint', []],
['explore_algoendpoint', []],
])('serves the %s category row', async (list, expected) => {
expect(await rowIds(list)).toEqual(expected)
})
it('matches the tag case-insensitively', async () => {
// Room 2 carries `Quest` capitalised — tags are matched lowercased, so casing a player
// typed must not decide whether their room is in the category.
expect(await rowIds('quests_algoendpoint')).toContain('2')
})
it.each([
['RecentlyUpdated', 'recentlyupdated'],
['New', 'new'],
['HOTLIST', 'hotlist'],
['Quests_AlgoEndpoint', 'quests_algoendpoint'],
])('matches the row key %s case-insensitively', async (asked, canonical) => {
// The slug reaches us from a curated page's ItemIds or a section's sourceMetadata, whose
// casing is the reference's rather than ours.
expect(await rowIds(asked)).toEqual(await rowIds(canonical))
})
it('echoes the requested type and answers an unknown row', async () => {
const other = await SELF.fetch(`${ORIGIN}/algorithmiclists/Nothing_Ranks_This_Row?type=4`)
expect(other.status).toBe(200)
@@ -319,3 +510,51 @@ it('401s the contextual-features post without a bearer token', async () => {
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
// D1 caps a statement at 100 bound parameters. The seed above has a handful of rooms, so
// every per-room `IN (…)` list fitted and the cap went unnoticed until a real server
// crossed it: a discovery row reads every room to rank it and attaches tags to all of
// them, so the bind list grew with the database until the query failed with "variable
// number must be between ?1 and ?100". The ranking lives in `@repo/domain`, which this
// worker bundles from source — so this is the same fix the `rooms` worker got, asserted
// again from the worker that reported it.
it('serves the rows when there are more rooms than D1 allows bound parameters', async () => {
const FIRST = 20000
const COUNT = 150
for (let i = 0; i < COUNT; i++) {
const roomId = FIRST + i
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: roomId,
Name: `BulkRoom${i}`,
CreatorAccountId: 700,
IsDorm: false,
Accessibility: 1,
CreatedAt: '2026-05-01T00:00:00Z',
})
)
.run()
if (i % 3 === 0) {
await env.DB.prepare('INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, 0)')
.bind(roomId, 'quest')
.run()
}
}
// The row that reported the error, plus the three others that read every room.
for (const list of ['HotList', 'new', 'recentlyupdated']) {
const res = await SELF.fetch(`${ORIGIN}/algorithmiclists/${list}?type=1`)
expect(res.status, `${list} above the bound-parameter cap`).toBe(200)
const body = (await res.json()) as { Entities: unknown[] }
expect(body.Entities.length).toBeGreaterThan(0)
}
// And a category row, which narrows on the tag index rather than reading every room.
const quests = await SELF.fetch(`${ORIGIN}/algorithmiclists/quests_algoendpoint?type=1`)
expect(quests.status).toBe(200)
expect(((await quests.json()) as { Entities: unknown[] }).Entities.length).toBeGreaterThan(0)
await env.DB.prepare('DELETE FROM room WHERE room_id >= ?1').bind(FIRST).run()
await env.DB.prepare('DELETE FROM room_tag WHERE room_id >= ?1').bind(FIRST).run()
})