diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index f313753..a63e1e0 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -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() } ) + // 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({ diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 49fa848..ceb7c38 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -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'), +}) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1985d85..2c47c4e 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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', diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index cdf64d6..2a8bd5a 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -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 diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 2c0ed4f..1f509f3 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() } ) + // 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. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 066ae46..380ea53 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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', diff --git a/packages/domain/src/clubs-db.ts b/packages/domain/src/clubs-db.ts index d1e62d7..f7e60f0 100644 --- a/packages/domain/src/clubs-db.ts +++ b/packages/domain/src/clubs-db.ts @@ -799,6 +799,63 @@ export async function deleteClub(db: D1Database, clubId: number): Promise { + // 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() + 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" diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 908cf04..63f2027 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -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() + 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