mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[rooms,match] add a couple missing endpoints
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -413,3 +413,16 @@ export const InviteResponse = z.object({
|
||||
export const InstanceIdResponse = z
|
||||
.int()
|
||||
.describe('The player’s 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 club’s 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'),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user