mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[lists] fixup lists a bit
This commit is contained in:
@@ -1,12 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"id": "Rooms_RoomBanner_SketchyShowdown",
|
|
||||||
"sectionType": 6,
|
|
||||||
"sectionSubType": "RoomBanner",
|
|
||||||
"source": "2477031627165896495",
|
|
||||||
"sourceMetadata": "2477031627165896495",
|
|
||||||
"displayMetadata": null
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "Rooms_ForYou_PlayHighlight",
|
"id": "Rooms_ForYou_PlayHighlight",
|
||||||
"sectionType": 0,
|
"sectionType": 0,
|
||||||
@@ -60,7 +52,7 @@
|
|||||||
"sectionType": 13,
|
"sectionType": 13,
|
||||||
"sectionSubType": "featured_creator",
|
"sectionSubType": "featured_creator",
|
||||||
"source": "PlayerCreatedRooms",
|
"source": "PlayerCreatedRooms",
|
||||||
"sourceMetadata": "1",
|
"sourceMetadata": "2",
|
||||||
"displayMetadata": "{\"DisplayTitle\":\"Featured Creator\", \"descriptionText\":\"Making moments you can play again and again!\",\"itemCount\":\"4\", \"unsupportedPlatforms\":[\"Switch\"]}"
|
"displayMetadata": "{\"DisplayTitle\":\"Featured Creator\", \"descriptionText\":\"Making moments you can play again and again!\",\"itemCount\":\"4\", \"unsupportedPlatforms\":[\"Switch\"]}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,4 +23,4 @@
|
|||||||
"sourceMetadata": "Discovery.PageSource.PlayLibrary",
|
"sourceMetadata": "Discovery.PageSource.PlayLibrary",
|
||||||
"displayMetadata": "{\"DisplayTitle\":\"Library\", \"unsupportedPlatforms\":[]}"
|
"displayMetadata": "{\"DisplayTitle\":\"Library\", \"unsupportedPlatforms\":[]}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+83
-29
@@ -1,13 +1,14 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
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 { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
|
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import type { Room } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,10 +51,10 @@ const ListEntityType = {
|
|||||||
const MAX_LIST_ENTITY_TYPE = 255
|
const MAX_LIST_ENTITY_TYPE = 255
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entities an algorithmic list hands back (`GET /algorithmiclists/:list`) — ROOMS, which
|
* The fallback entities for a row with no live feed behind it (`GET /algorithmiclists/:list`)
|
||||||
* is what a Play/Explore row is built from. Nothing ranks anything here yet, so one canned
|
* — ROOMS, which is what a Play/Explore row is built from. Rooms 2–6, the low ids this
|
||||||
* set answers every row: rooms 2–6, the low ids this server's own rooms occupy, so a
|
* server's own rooms occupy, so an unranked row resolves to something real instead of five
|
||||||
* discovery row resolves to something real instead of five dead ids.
|
* 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
|
* `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,
|
* 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',
|
'6',
|
||||||
].map((Id) => ({ Id, Context: null }))
|
].map((Id) => ({ Id, Context: null }))
|
||||||
|
|
||||||
/**
|
/** How many rooms a row carries. A discovery carousel shows a page, not the world. */
|
||||||
* The row key that serves the LIVE hot-room ranking rather than the canned entities — the
|
const LIST_SIZE = 20
|
||||||
* 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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* 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
|
* 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
|
* 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'
|
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
|
* 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
|
* 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
|
// `Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore`), and the answer is the
|
||||||
// ranked entities that fill it, which the client then resolves by id itself.
|
// 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.
|
// `HotList`, `recentlyupdated` and `new` are ranked for real (see `ROW_FEEDS`). Every
|
||||||
// Every other row still serves the canned entities, and an unknown row key gets them too
|
// other row still serves the canned entities, and an unknown row key gets them too rather
|
||||||
// rather than a 404, which the client renders as a row that failed to load. `Type` is
|
// than a 404, which the client renders as a row that failed to load. `Type` is echoed
|
||||||
// echoed back from the query: it tells the client what the `Id`s ARE (rooms, players, …),
|
// back from the query: it tells the client what the `Id`s ARE (rooms, players, …), so
|
||||||
// so answering with a type the caller didn't ask for would have it resolve the ids
|
// answering with a type the caller didn't ask for would have it resolve the ids against
|
||||||
// against the wrong service.
|
// the wrong service.
|
||||||
.get('/algorithmiclists/:list', async (c) => {
|
.get('/algorithmiclists/:list', async (c) => {
|
||||||
// Echoed, but only when it fits the byte the client reads it back into — anything
|
// 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
|
// 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<App>()
|
|||||||
const type = Number.parseInt(c.req.query('type') ?? '', 10)
|
const type = Number.parseInt(c.req.query('type') ?? '', 10)
|
||||||
const echoed = type >= 0 && type <= MAX_LIST_ENTITY_TYPE ? type : DEFAULT_ALGORITHMIC_LIST_TYPE
|
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
|
// A row with a live feed behind it serves that; everything else gets the canned
|
||||||
// does — live player count first, then engagement — so the Hot row on a discovery page
|
// entities. Only the ids travel — the client resolves each room itself — so the
|
||||||
// shows the rooms people are actually in, minus the Coach account's stock rooms (see
|
// ranking is read for its order and the room blobs are thrown away.
|
||||||
// HOT_LIST_FEED). Only the ids travel; the client resolves each room itself, which is
|
const feed = ROW_FEEDS[c.req.param('list').toLowerCase()]
|
||||||
// why this reads the ranking and throws the room blobs away.
|
if (feed !== undefined) {
|
||||||
if (c.req.param('list').toLowerCase() === HOT_LIST_KEY) {
|
const { Results } = await feed(c.env.DB)
|
||||||
const { Results } = await getHotRooms(c.env.DB, HOT_LIST_FEED, 0, HOT_LIST_SIZE)
|
|
||||||
return c.json({
|
return c.json({
|
||||||
Type: echoed,
|
Type: echoed,
|
||||||
Entities: Results.map((room) => ({ Id: String(room.RoomId), Context: null })),
|
Entities: Results.map((room) => ({ Id: String(room.RoomId), Context: null })),
|
||||||
|
|||||||
@@ -21,24 +21,118 @@ beforeAll(async () => {
|
|||||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||||
|
|
||||||
// The shared room schema (owned by the `rooms` worker) plus a few public rooms — the
|
// 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 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 SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of PRESENCE_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 [
|
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: 5, Name: 'SecretRoom', CreatorAccountId: 500, Accessibility: 0, IsDorm: false },
|
||||||
{ RoomId: 6, Name: '@Dorm', CreatorAccountId: 502, Accessibility: 1, IsDorm: true },
|
{ RoomId: 6, Name: '@Dorm', CreatorAccountId: 502, Accessibility: 1, IsDorm: true },
|
||||||
]) {
|
]) {
|
||||||
await seedRoomWithSubRooms(env.DB, { ...room, SubRooms: [] } as Record<string, unknown>)
|
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. */
|
/** 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> {
|
async function putInRoom(accountId: number, roomId: number): Promise<void> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
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)
|
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 () => {
|
it('echoes the requested type and answers an unknown row', async () => {
|
||||||
const other = await SELF.fetch(`${ORIGIN}/algorithmiclists/Nothing_Ranks_This_Row?type=4`)
|
const other = await SELF.fetch(`${ORIGIN}/algorithmiclists/Nothing_Ranks_This_Row?type=4`)
|
||||||
expect(other.status).toBe(200)
|
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(res.status).toBe(401)
|
||||||
expect(await res.text()).toBe('')
|
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()
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user