diff --git a/apps/discovery/static/PlayHighlight.json b/apps/discovery/static/PlayHighlight.json index 77570b2..8940b53 100644 --- a/apps/discovery/static/PlayHighlight.json +++ b/apps/discovery/static/PlayHighlight.json @@ -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\"]}" }, { diff --git a/apps/discovery/static/PlayMenuTabs.json b/apps/discovery/static/PlayMenuTabs.json index a1b8ced..58503c3 100644 --- a/apps/discovery/static/PlayMenuTabs.json +++ b/apps/discovery/static/PlayMenuTabs.json @@ -23,4 +23,4 @@ "sourceMetadata": "Discovery.PageSource.PlayLibrary", "displayMetadata": "{\"DisplayTitle\":\"Library\", \"unsupportedPlatforms\":[]}" } -] \ No newline at end of file +] diff --git a/apps/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 016a4dd..226493c 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -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 2–6, 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 2–6, 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 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() // `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 0–255 can't round-trip, so a nonsense `?type=` gets the default instead of a @@ -182,13 +237,12 @@ 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 - // `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 })), diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index ccee5d2..72eb487 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -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) + } + // 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) } + + // 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 { + 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 { + 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 { + 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 { 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 { + 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() +})