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,
|
getExpiredPresenceInstanceIds,
|
||||||
getFriendIds,
|
getFriendIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
|
getMostActiveClubhouses,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
getPresence,
|
getPresence,
|
||||||
getPresences,
|
getPresences,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
MatchmakingErrorCode,
|
MatchmakingErrorCode,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
MOST_ACTIVE_CLUBHOUSE_LIMIT,
|
||||||
recordRoomVisit,
|
recordRoomVisit,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
RoomInstanceType,
|
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.
|
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||||
import { NotificationType } from '../../notify/src/notification-types'
|
import { NotificationType } from '../../notify/src/notification-types'
|
||||||
import {
|
import {
|
||||||
|
ActiveClubhouseDto,
|
||||||
AUTHED,
|
AUTHED,
|
||||||
AvoidJuniorsRequest,
|
AvoidJuniorsRequest,
|
||||||
AvoidJuniorsResponse,
|
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(
|
.get(
|
||||||
'/player',
|
'/player',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -413,3 +413,16 @@ export const InviteResponse = z.object({
|
|||||||
export const InstanceIdResponse = z
|
export const InstanceIdResponse = z
|
||||||
.int()
|
.int()
|
||||||
.describe('The player’s room instance id, or 0 when they are not in one')
|
.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(
|
await env.DB.prepare(
|
||||||
`CREATE TABLE IF NOT EXISTS club (
|
`CREATE TABLE IF NOT EXISTS club (
|
||||||
data TEXT NOT NULL,
|
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()
|
).run()
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
@@ -157,9 +158,26 @@ beforeAll(async () => {
|
|||||||
).run()
|
).run()
|
||||||
const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)')
|
const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)')
|
||||||
await env.DB.batch([
|
await env.DB.batch([
|
||||||
// Club 4 has room 2 as its clubhouse; club 5 has none set.
|
// Club 4 has room 2 as its clubhouse; club 5 has none set. Both public and ordinary
|
||||||
insertClub.bind(JSON.stringify({ ClubId: 4, Name: 'Clubbers', ClubhouseRoomId: 2 })),
|
// (`ClubType` 0), which is what the clubhouse search lists.
|
||||||
insertClub.bind(JSON.stringify({ ClubId: 5, Name: 'Homeless', ClubhouseRoomId: null })),
|
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(
|
const insertMember = env.DB.prepare(
|
||||||
'INSERT INTO club_member (club_id, account_id, membership_type) VALUES (?1, ?2, ?3)'
|
'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 () => {
|
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.
|
// Ungated — no bearer token anywhere in this test. The player is named by the query.
|
||||||
const tachyon = async (query: string) => {
|
const tachyon = async (query: string) => {
|
||||||
@@ -2712,6 +2772,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect([...documented].sort()).toEqual([
|
expect([...documented].sort()).toEqual([
|
||||||
|
'GET /clubhousesearch/mostactivenow',
|
||||||
'GET /player',
|
'GET /player',
|
||||||
'GET /player/avoidjuniors',
|
'GET /player/avoidjuniors',
|
||||||
'GET /player/connection-info',
|
'GET /player/connection-info',
|
||||||
|
|||||||
@@ -815,6 +815,15 @@ export const RoomExperiencePlayer = z
|
|||||||
.array(z.unknown())
|
.array(z.unknown())
|
||||||
.describe('Always empty — no per-room experience is tracked')
|
.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:
|
* `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
|
* how many updates are allowed in the rolling window, and the cooldown/expiry around
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
getSubRoomPermissions,
|
getSubRoomPermissions,
|
||||||
getSubRoomSaveById,
|
getSubRoomSaveById,
|
||||||
getSubRoomSaves,
|
getSubRoomSaves,
|
||||||
|
getTrendingRooms,
|
||||||
getVisitedRooms,
|
getVisitedRooms,
|
||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
CloneRoomRequest,
|
CloneRoomRequest,
|
||||||
CloningRequest,
|
CloningRequest,
|
||||||
CreateSubRoomRequest,
|
CreateSubRoomRequest,
|
||||||
|
CuratedPlaylists,
|
||||||
DescriptionRequest,
|
DescriptionRequest,
|
||||||
DormRoomId,
|
DormRoomId,
|
||||||
FeaturedRoomGroupDto,
|
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
|
// "Base" rooms — template rooms (tagged `base`) the client offers when creating
|
||||||
// a room. Returned regardless of accessibility. Paginated via skip/take (take
|
// a room. Returned regardless of accessibility. Paginated via skip/take (take
|
||||||
// defaults to 100). Returns a bare array.
|
// defaults to 100). Returns a bare array.
|
||||||
|
|||||||
@@ -890,6 +890,68 @@ describe('rooms endpoints', () => {
|
|||||||
).run()
|
).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 () => {
|
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
|
||||||
const aliased = (await (
|
const aliased = (await (
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
|
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
|
||||||
@@ -3746,8 +3808,10 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/autocomplete_search',
|
'GET /rooms/autocomplete_search',
|
||||||
'GET /rooms/base',
|
'GET /rooms/base',
|
||||||
'GET /rooms/bulk',
|
'GET /rooms/bulk',
|
||||||
|
'GET /rooms/carousel/rising',
|
||||||
'GET /rooms/contributedby/me',
|
'GET /rooms/contributedby/me',
|
||||||
'GET /rooms/createdby/me',
|
'GET /rooms/createdby/me',
|
||||||
|
'GET /rooms/curated_playlists',
|
||||||
'GET /rooms/favoritedby/me',
|
'GET /rooms/favoritedby/me',
|
||||||
'GET /rooms/hot',
|
'GET /rooms/hot',
|
||||||
'GET /rooms/ownedby/me',
|
'GET /rooms/ownedby/me',
|
||||||
|
|||||||
@@ -799,6 +799,63 @@ export async function deleteClub(db: D1Database, clubId: number): Promise<boolea
|
|||||||
return true
|
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
|
* 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"
|
* club you browse or list among your own — they're excluded from the "my clubs"
|
||||||
|
|||||||
@@ -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. */
|
/** Compact room projection carried by a featured-room group. */
|
||||||
export interface FeaturedRoom {
|
export interface FeaturedRoom {
|
||||||
RoomId: number
|
RoomId: number
|
||||||
|
|||||||
Reference in New Issue
Block a user