mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
update shape of room instance browser
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
|||||||
getRoomByName,
|
getRoomByName,
|
||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
getRoomInstancesByRoom,
|
getRoomInstancesByRoom,
|
||||||
|
getRoomInstanceSummariesByRoom,
|
||||||
isClubMember,
|
isClubMember,
|
||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
MessageType,
|
MessageType,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
NotifyDisconnectRequest,
|
NotifyDisconnectRequest,
|
||||||
PlayerDto,
|
PlayerDto,
|
||||||
RoomInstanceDto,
|
RoomInstanceDto,
|
||||||
|
RoomInstanceSummaryDto,
|
||||||
StatusVisibilityRequest,
|
StatusVisibilityRequest,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from './openapi'
|
} 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.
|
// 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
|
// 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
|
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
|
||||||
// bare RoomInstance DTO array (empty when the room has no live instances).
|
// 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(
|
.get(
|
||||||
'/room/:roomId{[0-9]+}/instances',
|
'/room/:roomId{[0-9]+}/instances',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room instance'],
|
tags: ['Room instance'],
|
||||||
summary: 'A room’s live instances',
|
summary: 'A room’s live instances',
|
||||||
description: [
|
description: [
|
||||||
'The owner’s view of active sessions of their room. Auth-gated and gated to the',
|
'The owner’s view of active sessions of their room — each instance with the',
|
||||||
'room’s creator or a co-owner (403 otherwise). Unknown room → 404.',
|
'players currently in it. Auth-gated and gated to the room’s creator or a',
|
||||||
|
'co-owner (403 otherwise). Unknown room → 404.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1280,7 +1286,7 @@ const app = new Hono<App>()
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
|
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||||
404: { description: 'No such room (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.
|
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
||||||
if (!canManageRoom(room, id)) return c.body(null, 403)
|
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(),
|
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`).
|
* 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
|
* `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'),
|
headers: await bearer('42'),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
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.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
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.
|
// The co-owner (account 43, Role 30) may view the instances too.
|
||||||
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||||
headers: await bearer('43'),
|
headers: await bearer('43'),
|
||||||
|
|||||||
@@ -174,6 +174,35 @@ export async function countPlayersByRoom(
|
|||||||
return new Map(results.map((r) => [r.roomId, r.n]))
|
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
|
* 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
|
* player was in when they stopped heartbeating (a crash or a hard quit, where no
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* the client DTO (`toDto`).
|
* the client DTO (`toDto`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { countPlayersInInstance } from './presence-db'
|
import { countPlayersInInstance, getPlayerIdsByRoomInstance } from './presence-db'
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
||||||
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
|
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
|
||||||
@@ -69,6 +69,22 @@ export interface RoomInstanceDto {
|
|||||||
createdAt: string
|
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). */
|
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
|
||||||
interface StoredRoomInstance extends RoomInstanceDto {
|
interface StoredRoomInstance extends RoomInstanceDto {
|
||||||
ownerAccountId: number
|
ownerAccountId: number
|
||||||
@@ -292,3 +308,34 @@ export async function getRoomInstancesByRoom(
|
|||||||
.all<{ data: string }>()
|
.all<{ data: string }>()
|
||||||
return results.map((r) => toDto(parse(r.data)))
|
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