[rooms,match] add a couple missing endpoints

This commit is contained in:
Devin Zuczek
2026-08-25 14:02:39 -04:00
parent 599730379c
commit 5cb2cbf967
8 changed files with 336 additions and 4 deletions
+33
View File
@@ -17,6 +17,7 @@ import {
getExpiredPresenceInstanceIds,
getFriendIds,
getJoinableInstance,
getMostActiveClubhouses,
getOrCreateDormRoom,
getPresence,
getPresences,
@@ -30,6 +31,7 @@ import {
isPlayerBannedFromRoom,
MatchmakingErrorCode,
MessageType,
MOST_ACTIVE_CLUBHOUSE_LIMIT,
recordRoomVisit,
refreshInstanceFullness,
RoomInstanceType,
@@ -52,6 +54,7 @@ import { getEventById, getEventResponse } from '../../api/src/events-db'
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
import { NotificationType } from '../../notify/src/notification-types'
import {
ActiveClubhouseDto,
AUTHED,
AvoidJuniorsRequest,
AvoidJuniorsResponse,
@@ -1446,6 +1449,36 @@ const app = new Hono<App>()
}
)
// The busiest clubhouses right now — a bare array of `{ RoomId, ClubId, PlayerCount }`,
// which is why it lives here rather than in `clubs`: the answer is live presence, and
// presence is this worker's. Active means someone is THERE, so an empty clubhouse is
// absent rather than listed at zero and a quiet server answers `[]`.
//
// Ungated, like `/tachyon`: nothing in it is per-caller, and it names only public clubs
// and how busy they are.
.get(
'/clubhousesearch/mostactivenow',
describeRoute({
tags: ['Presence'],
summary: 'The busiest clubhouses right now',
description: [
'One row per clubhouse with players in it this second, busiest first — a bare array',
'of `{ RoomId, ClubId, PlayerCount }`.',
'',
'Live presence FILTERS here rather than merely ranking: a club whose clubhouse is',
'empty is absent rather than listed with a `PlayerCount` of 0, and a club with no',
'clubhouse can never appear at all, so this is `[]` when nobody is anywhere. Public,',
'non-subscription clubs only — the same eligibility `clubs` `/club/search` applies,',
`since this is a search too. Ties break on ClubId, and at most ${MOST_ACTIVE_CLUBHOUSE_LIMIT} rows`,
'come back: it fills a carousel, not a directory.',
'',
'Ungated — nothing in the answer is per-caller.',
].join(' '),
responses: { 200: json(ActiveClubhouseDto.array(), 'The busiest clubhouses, or []') },
}),
async (c) => c.json(await getMostActiveClubhouses(c.env.DB))
)
.get(
'/player',
describeRoute({
+13
View File
@@ -413,3 +413,16 @@ export const InviteResponse = z.object({
export const InstanceIdResponse = z
.int()
.describe('The players room instance id, or 0 when they are not in one')
/**
* `GET /clubhousesearch/mostactivenow` — one row per clubhouse someone is standing in
* right now, busiest first.
*
* A bare array, and only the clubs with players in them: an empty clubhouse is absent
* rather than listed with a `PlayerCount` of 0, so a quiet server answers `[]`.
*/
export const ActiveClubhouseDto = z.object({
RoomId: z.int().describe('The clubs clubhouse room'),
ClubId: z.int().describe('The club that clubhouse belongs to'),
PlayerCount: z.int().describe('How many players are in the room this second'),
})
+65 -4
View File
@@ -143,7 +143,8 @@ beforeAll(async () => {
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club (
data TEXT NOT NULL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL
)`
).run()
await env.DB.prepare(
@@ -157,9 +158,26 @@ beforeAll(async () => {
).run()
const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)')
await env.DB.batch([
// Club 4 has room 2 as its clubhouse; club 5 has none set.
insertClub.bind(JSON.stringify({ ClubId: 4, Name: 'Clubbers', ClubhouseRoomId: 2 })),
insertClub.bind(JSON.stringify({ ClubId: 5, Name: 'Homeless', ClubhouseRoomId: null })),
// Club 4 has room 2 as its clubhouse; club 5 has none set. Both public and ordinary
// (`ClubType` 0), which is what the clubhouse search lists.
insertClub.bind(
JSON.stringify({
ClubId: 4,
Name: 'Clubbers',
ClubhouseRoomId: 2,
Visibility: 1,
ClubType: 0,
})
),
insertClub.bind(
JSON.stringify({
ClubId: 5,
Name: 'Homeless',
ClubhouseRoomId: null,
Visibility: 1,
ClubType: 0,
})
),
])
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type) VALUES (?1, ?2, ?3)'
@@ -332,6 +350,48 @@ describe('public endpoints', () => {
])
})
test('GET /clubhousesearch/mostactivenow lists clubhouses with players in them', async () => {
// Ungated, like /tachyon — no bearer token anywhere in this test.
const busiest = async () => {
const res = await exports.default.fetch(`${ORIGIN}/clubhousesearch/mostactivenow`)
expect(res.status).toBe(200)
return (await res.json()) as Array<{ RoomId: number; ClubId: number; PlayerCount: number }>
}
// Nobody is in club 4's clubhouse (room 2) yet, and an empty clubhouse is absent
// rather than listed at zero — so a quiet server answers [].
expect(await busiest()).toEqual([])
const at = (accountId: number, roomId: number | null, ttl = 900) =>
JSON.stringify({
accountId,
roomInstance: roomId === null ? null : { roomInstanceId: 1000000 + accountId, roomId },
expiresAt: Math.floor(Date.now() / 1000) + ttl,
})
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
await env.DB.batch([
seed.bind(at(7001, 2)),
seed.bind(at(7002, 2)),
// Room 3 is nobody's clubhouse, and a lobby presence is in no room at all: neither
// can put a club in the list.
seed.bind(at(7003, 3)),
seed.bind(at(7004, null)),
])
expect(await busiest()).toEqual([{ RoomId: 2, ClubId: 4, PlayerCount: 2 }])
// Expired presence is nobody standing there.
await env.DB.prepare(
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
WHERE account_id BETWEEN 7001 AND 7004`
)
.bind(Math.floor(Date.now() / 1000) - 60)
.run()
expect(await busiest()).toEqual([])
await env.DB.prepare('DELETE FROM presence WHERE account_id BETWEEN 7001 AND 7004').run()
})
test('GET /tachyon?id=N answers the bare instance id the player is in', async () => {
// Ungated — no bearer token anywhere in this test. The player is named by the query.
const tachyon = async (query: string) => {
@@ -2712,6 +2772,7 @@ describe('auth-gated endpoints', () => {
)
)
expect([...documented].sort()).toEqual([
'GET /clubhousesearch/mostactivenow',
'GET /player',
'GET /player/avoidjuniors',
'GET /player/connection-info',
+9
View File
@@ -815,6 +815,15 @@ export const RoomExperiencePlayer = z
.array(z.unknown())
.describe('Always empty — no per-room experience is tracked')
/**
* `GET /rooms/curated_playlists` — the curated room playlists the discovery pages'
* playlist sections draw from. Nothing curates one on this server, so the list is always
* empty and the element shape is unknown until something fills it.
*/
export const CuratedPlaylists = z
.array(z.unknown())
.describe('Always empty — nothing curates a room playlist yet')
/**
* `GET /publishState/configs` — the limits the client enforces on republishing a room:
* how many updates are allowed in the rolling window, and the cooldown/expiry around
+54
View File
@@ -34,6 +34,7 @@ import {
getSubRoomPermissions,
getSubRoomSaveById,
getSubRoomSaves,
getTrendingRooms,
getVisitedRooms,
isPlayerBannedFromRoom,
modifySubRoom,
@@ -76,6 +77,7 @@ import {
CloneRoomRequest,
CloningRequest,
CreateSubRoomRequest,
CuratedPlaylists,
DescriptionRequest,
DormRoomId,
FeaturedRoomGroupDto,
@@ -864,6 +866,58 @@ const app = new Hono<App>()
}
)
// Curated room playlists — the editorially grouped room lists the discovery pages'
// `PlaylistById` sections draw from. Nothing curates one yet, so this is an empty array:
// the client reads that as "no playlists" and simply draws no playlist rows, where a 404
// leaves it retrying a feed that isn't coming.
.get(
'/rooms/curated_playlists',
describeRoute({
tags: ['Discovery'],
summary: 'Curated room playlists',
description: [
'The curated room playlists the discovery pages playlist sections draw from. There',
'is no editorial curation on this server yet, so this is always an empty array —',
'which the client reads as “no playlists” and draws nothing, rather than the 404 it',
'would keep retrying.',
].join(' '),
responses: { 200: json(CuratedPlaylists, 'Always an empty list') },
}),
(c) => c.json([])
)
// The `rising` carousel — the discovery pages fill a `CarouselEndpoint` section by
// slug, and this is the one the client asks for by name. Trending means someone is IN
// the room right now: unlike the hot feed, which ranks by head-count but still lists
// the empty rooms underneath, this one FILTERS on live presence, so a quiet server
// serves an empty carousel rather than a stale one.
//
// Paged like the hot feed (`skip`/`take`, take defaults to 100) and answers the same
// `{ Results, TotalResults }` envelope its sibling feeds do. Only `rising` is served —
// the other slugs in the discovery catalogue (`foryou`, `staffpicks`, the
// `*_algoendpoint` rows) keep 404ing until each is given a feed of its own.
.get(
'/rooms/carousel/rising',
describeRoute({
tags: ['Discovery'],
summary: 'The “rising” rooms carousel',
description: [
'The rooms players are in RIGHT NOW, busiest first — the trending carousel. Live',
'presence is a filter here, not just a sort: a room nobody is standing in is absent',
'entirely, so this is empty when the server is quiet rather than falling back to',
'stored engagement the way `/rooms/hot` does. Ties break on engagement and then',
'RoomId, so equally busy rooms page stably. Public, non-dorm, listable rooms only.',
].join(' '),
parameters: pageParams(100),
responses: { 200: json(PagedRooms, 'The carousel page') },
}),
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getTrendingRooms(c.env.DB, skip, take))
}
)
// "Base" rooms — template rooms (tagged `base`) the client offers when creating
// a room. Returned regardless of accessibility. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
@@ -890,6 +890,68 @@ describe('rooms endpoints', () => {
).run()
})
it('GET /rooms/curated_playlists is an empty list, not a 404', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/curated_playlists`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
it('GET /rooms/carousel/rising serves only rooms players are in, busiest first', async () => {
const rising = async (qs = '?skip=0&take=100') =>
(await (await SELF.fetch(`${ORIGIN}/rooms/carousel/rising${qs}`)).json()) as {
Results: Array<{ RoomId: number; IsDorm?: boolean }>
TotalResults: number
}
// Nobody is anywhere in the fixture, and an empty carousel is the honest answer —
// this feed does NOT fall back to engagement the way /rooms/hot does.
expect(await rising()).toEqual({ Results: [], TotalResults: 0 })
// Pick from the tail of the hot feed so the order below can only come from presence.
const hot = (
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
Results: Array<{ RoomId: number }>
}
).Results.map((r) => r.RoomId)
const busiest = hot[hot.length - 1]
const quieter = hot[hot.length - 2]
const expiresAt = Math.floor(Date.now() / 1000) + 900
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
await env.DB.batch(
[
{ accountId: 90101, roomInstance: { roomInstanceId: 1001001, roomId: busiest } },
{ accountId: 90102, roomInstance: { roomInstanceId: 1001002, roomId: busiest } },
{ accountId: 90103, roomInstance: { roomInstanceId: 1001003, roomId: quieter } },
// The dorm nobody may list, and a lobby presence in no room at all: neither
// puts a room in the carousel.
{ accountId: 90104, roomInstance: { roomInstanceId: 1001004, roomId: 1 } },
{ accountId: 90105, roomInstance: null },
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
)
const busy = await rising()
expect(busy.Results.map((r) => r.RoomId)).toEqual([busiest, quieter])
expect(busy.TotalResults).toBe(2)
expect(busy.Results.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
// Paged like its sibling feeds: TotalResults stays the full count.
expect(await rising('?skip=0&take=1')).toMatchObject({ TotalResults: 2 })
expect((await rising('?skip=0&take=1')).Results.map((r) => r.RoomId)).toEqual([busiest])
expect((await rising('?skip=1&take=100')).Results.map((r) => r.RoomId)).toEqual([quieter])
// Presence that has expired is nobody standing there.
await env.DB.prepare(
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
WHERE account_id BETWEEN 90101 AND 90105`
)
.bind(Math.floor(Date.now() / 1000) - 1)
.run()
expect(await rising()).toEqual({ Results: [], TotalResults: 0 })
await env.DB.prepare('DELETE FROM presence WHERE account_id BETWEEN 90101 AND 90105').run()
})
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
const aliased = (await (
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
@@ -3746,8 +3808,10 @@ describe('rooms endpoints', () => {
'GET /rooms/autocomplete_search',
'GET /rooms/base',
'GET /rooms/bulk',
'GET /rooms/carousel/rising',
'GET /rooms/contributedby/me',
'GET /rooms/createdby/me',
'GET /rooms/curated_playlists',
'GET /rooms/favoritedby/me',
'GET /rooms/hot',
'GET /rooms/ownedby/me',