From 7d300fa83658acfa9d18484830835f38bbab6948 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 17:19:03 -0400 Subject: [PATCH] update shape of room instance browser --- apps/match/src/match.app.ts | 18 +++++--- apps/match/src/openapi.ts | 16 +++++++ apps/match/src/test/integration/api.test.ts | 24 +++++++++- packages/domain/src/presence-db.ts | 29 ++++++++++++ packages/domain/src/room-instance-db.ts | 49 ++++++++++++++++++++- 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 3ab019f..33b0471 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -21,6 +21,7 @@ import { getRoomByName, getRoomInstance, getRoomInstancesByRoom, + getRoomInstanceSummariesByRoom, isClubMember, isPlayerBannedFromRoom, MessageType, @@ -51,6 +52,7 @@ import { NotifyDisconnectRequest, PlayerDto, RoomInstanceDto, + RoomInstanceSummaryDto, StatusVisibilityRequest, UNAUTHORIZED_RESPONSE, } from './openapi' @@ -1258,16 +1260,20 @@ const app = new Hono() // The room's live instances — the owner's view of active sessions of their room. // Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's - // creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the - // bare RoomInstance DTO array (empty when the room has no live instances). + // creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a + // summary per instance (empty when the room has no live instances) — id, subroom, + // fullness, creation time and who's currently in it — not the client's + // RoomInstance DTO: this is a management listing, so it answers "who's in there" + // and withholds the connection details of a session the owner isn't joining. .get( '/room/:roomId{[0-9]+}/instances', describeRoute({ tags: ['Room instance'], summary: 'A room’s live instances', description: [ - 'The owner’s view of active sessions of their room. Auth-gated and gated to the', - 'room’s creator or a co-owner (403 otherwise). Unknown room → 404.', + 'The owner’s view of active sessions of their room — each instance with the', + 'players currently in it. Auth-gated and gated to the room’s creator or a', + 'co-owner (403 otherwise). Unknown room → 404.', ].join(' '), security: AUTHED, parameters: [ @@ -1280,7 +1286,7 @@ const app = new Hono() }, ], responses: { - 200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'), + 200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'), 401: UNAUTHORIZED_RESPONSE, 403: { description: 'Not the room’s creator or a co-owner (empty body)' }, 404: { description: 'No such room (empty body)' }, @@ -1297,7 +1303,7 @@ const app = new Hono() // same owner-or-co-owner gate the rooms worker uses for room-admin actions. if (!canManageRoom(room, id)) return c.body(null, 403) - return c.json(await getRoomInstancesByRoom(c.env.DB, roomId)) + return c.json(await getRoomInstanceSummariesByRoom(c.env.DB, roomId)) } ) diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 21ba00a..d2133d6 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -95,6 +95,22 @@ export const RoomInstanceDto = z.object({ EncryptVoiceChat: z.boolean(), }) +/** + * One live instance in the owner's management listing (`GET /room/:roomId/instances`). + * Not the client `RoomInstanceDto`: it carries who's in there and drops the connection + * details (photon ids, data blob, room code) of a session the owner isn't in. + */ +export const RoomInstanceSummaryDto = z.object({ + roomInstanceId: z.int(), + roomId: z.int(), + subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'), + isFull: z.boolean(), + createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'), + playerIds: z + .array(z.int()) + .describe('Accounts currently in the instance (live presence); empty when nobody is'), +}) + /** * A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`). * `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1d16b51..56cc61e 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -984,10 +984,32 @@ describe('auth-gated endpoints', () => { headers: await bearer('42'), }) expect(res.status).toBe(200) - const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }> + const instances = (await res.json()) as Array<{ + roomInstanceId: number + roomId: number + subRoomId: number + isFull: boolean + createdAt: string + playerIds: number[] + }> expect(instances.length).toBeGreaterThanOrEqual(1) expect(instances.every((i) => i.roomId === 3)).toBe(true) + // The summary projection: id/subroom/fullness/createdAt plus who's in there — + // and none of the client DTO's connection fields. + const instance = instances.find((i) => i.playerIds.includes(42)) + expect(instance).toBeDefined() + expect(Object.keys(instance!).sort()).toEqual([ + 'createdAt', + 'isFull', + 'playerIds', + 'roomId', + 'roomInstanceId', + 'subRoomId', + ]) + expect(instance!.isFull).toBe(false) + expect(Number.isNaN(Date.parse(instance!.createdAt))).toBe(false) + // The co-owner (account 43, Role 30) may view the instances too. const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('43'), diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 0111d27..83dd165 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -174,6 +174,35 @@ export async function countPlayersByRoom( return new Map(results.map((r) => [r.roomId, r.n])) } +/** + * Who is standing in each of a room's instances right now, keyed by instance id — + * one grouped query rather than a lookup per instance, so the owner's instance list + * stays a single read. Reads only unexpired presence; instances nobody is in are + * simply absent from the map (callers default to an empty list), and lobby + * (null-instance) presence is excluded. + */ +export async function getPlayerIdsByRoomInstance( + db: D1Database, + roomId: number, + now = nowSeconds() +): Promise> { + const { results } = await db + .prepare( + `SELECT room_instance_id AS instanceId, account_id AS accountId FROM presence + WHERE room_id = ?1 AND expires_at > ?2 AND room_instance_id IS NOT NULL + ORDER BY account_id` + ) + .bind(roomId, now) + .all<{ instanceId: number; accountId: number }>() + const out = new Map() + for (const r of results) { + const players = out.get(r.instanceId) + if (players) players.push(r.accountId) + else out.set(r.instanceId, [r.accountId]) + } + return out +} + /** * The room instances that expired presence rows still point at — the instances a * player was in when they stopped heartbeating (a crash or a hard quit, where no diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts index 8c73148..e002923 100644 --- a/packages/domain/src/room-instance-db.ts +++ b/packages/domain/src/room-instance-db.ts @@ -12,7 +12,7 @@ * the client DTO (`toDto`). */ -import { countPlayersInInstance } from './presence-db' +import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db' /** Schema DDL (mirror of migrations/0004_room_instance.sql). */ export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [ @@ -69,6 +69,22 @@ export interface RoomInstanceDto { createdAt: string } +/** + * The owner's view of one live instance of their room (`match`: + * `GET /room/:roomId/instances`). Deliberately NOT the client `RoomInstanceDto`: + * it's a management listing, so it carries who is in there (`playerIds`, from live + * presence) and drops the connection details (photon ids, data blob, room code) an + * owner has no business reading for a session they aren't in. + */ +export interface RoomInstanceSummary { + roomInstanceId: number + roomId: number + subRoomId: number + isFull: boolean + createdAt: string + playerIds: number[] +} + /** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */ interface StoredRoomInstance extends RoomInstanceDto { ownerAccountId: number @@ -292,3 +308,34 @@ export async function getRoomInstancesByRoom( .all<{ data: string }>() return results.map((r) => toDto(parse(r.data))) } + +/** + * A room's instances as the owner's management listing sees them — the + * {@link RoomInstanceSummary} projection, each with the players currently standing + * in it. Presence is read once for the whole room (one grouped query), so this stays + * two reads regardless of how many instances are live; an instance nobody is in + * (everyone timed out, or it was just created) gets an empty `playerIds`. + */ +export async function getRoomInstanceSummariesByRoom( + db: D1Database, + roomId: number +): Promise { + const [{ results }, playersByInstance] = await Promise.all([ + db + .prepare('SELECT data FROM room_instance WHERE room_id = ?1 ORDER BY id') + .bind(roomId) + .all<{ data: string }>(), + getPlayerIdsByRoomInstance(db, roomId), + ]) + return results.map((r) => { + const s = parse(r.data) + return { + roomInstanceId: s.roomInstanceId, + roomId: s.roomId, + subRoomId: s.subRoomId, + isFull: s.isFull, + createdAt: s.createdAt, + playerIds: playersByInstance.get(s.roomInstanceId) ?? [], + } + }) +}