mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
update shape of room instance browser
This commit is contained in:
@@ -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<App>()
|
||||
|
||||
// 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<App>()
|
||||
},
|
||||
],
|
||||
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<App>()
|
||||
// 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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<Map<number, number[]>> {
|
||||
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<number, number[]>()
|
||||
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
|
||||
|
||||
@@ -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<RoomInstanceSummary[]> {
|
||||
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) ?? [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user