[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',
+57
View File
@@ -799,6 +799,63 @@ export async function deleteClub(db: D1Database, clubId: number): Promise<boolea
return true
}
/**
* One row of the "most active clubhouses right now" search: a club, the room its
* clubhouse is, and how many players are standing in that room this second.
*/
export interface ActiveClubhouse {
RoomId: number
ClubId: number
PlayerCount: number
}
/**
* The most a single "most active now" answer carries. It fills a carousel, not a
* directory — nobody scrolls past the busiest few clubhouses — and the query behind it
* scans live presence, so it stays bounded rather than growing with the club table.
*/
export const MOST_ACTIVE_CLUBHOUSE_LIMIT = 50
/**
* Clubhouses with players in them right now, busiest first — what
* `match`'s `/clubhousesearch/mostactivenow` serves.
*
* Active means someone is THERE: a club whose clubhouse is empty is absent from the
* result rather than listed with a `PlayerCount` of 0, and a club with no clubhouse at
* all can never appear (nothing joins to a null room). So a quiet server answers `[]`.
*
* Same eligibility as {@link searchClubs}, since this is a search too: public,
* non-subscription clubs only. Ties break on ClubId so equally busy clubhouses hold a
* stable order between calls. Counts unexpired presence only, and ignores lobby presence
* (no instance) the way every other head-count here does.
*/
export async function getMostActiveClubhouses(
db: D1Database,
limit = MOST_ACTIVE_CLUBHOUSE_LIMIT,
now = Math.floor(Date.now() / 1000)
): Promise<ActiveClubhouse[]> {
// One grouped join rather than a count per club: the club blobs never cross the wire,
// only the clubhouse id `json_extract` pulls out of each.
const { results } = await db
.prepare(
`SELECT c.club_id AS ClubId,
json_extract(c.data, '$.ClubhouseRoomId') AS RoomId,
COUNT(*) AS PlayerCount
FROM club c
JOIN presence p ON p.room_id = json_extract(c.data, '$.ClubhouseRoomId')
WHERE c.visibility = ?1
AND json_extract(c.data, '$.ClubType') != ?2
AND p.expires_at > ?3
AND p.room_instance_id IS NOT NULL
GROUP BY c.club_id
ORDER BY PlayerCount DESC, c.club_id
LIMIT ?4`
)
.bind(ClubVisibility.Public, SUBSCRIPTION_CLUB_TYPE, now, limit)
.all<ActiveClubhouse>()
return results
}
/**
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
* club you browse or list among your own — they're excluded from the "my clubs"
+41
View File
@@ -2464,6 +2464,47 @@ export async function getRecommendedRooms(
)
}
/**
* Trending ("rising") rooms — the listable rooms someone is standing in RIGHT NOW, busiest
* first. What the `rising` carousel is filled from.
*
* This is the one feed where live presence FILTERS rather than merely ranks: the hot feed
* sorts by head-count but still lists the empty rooms underneath it, and a carousel of
* rooms nobody is in is not trending. So a quiet server serves an EMPTY carousel rather
* than falling back to stored engagement — a room with no one in it has not risen.
*
* Ties break the way the hot feed's do (stored engagement, then RoomId), so equally busy
* rooms page stably.
*/
export async function getTrendingRooms(
db: D1Database,
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const players = await countPlayersByRoom(db)
// Nobody anywhere: nothing can be trending, and the room table needn't be read at all.
if (players.size === 0) return { Results: [], TotalResults: 0 }
const { results } = await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>()
const stats = await getRoomStats(db)
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
const rooms = parseAll(results)
.filter((r) => isListable(r) && playerCount(r) > 0)
.sort(
(a, b) =>
playerCount(b) - playerCount(a) ||
hotScore(b, stats) - hotScore(a, stats) ||
roomIdOf(a) - roomIdOf(b)
)
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
TotalResults: rooms.length,
}
}
/** Compact room projection carried by a featured-room group. */
export interface FeaturedRoom {
RoomId: number