[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',