mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add a few admin endpoints for matching into instances
This commit is contained in:
+146
-4
@@ -29,6 +29,7 @@ import {
|
|||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
|
setRoomInstancePrivate,
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
@@ -1013,6 +1014,93 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
|
||||||
|
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
|
||||||
|
// where they pick a session of their room and drop into it. Unlike every other
|
||||||
|
// matchmake this targets a fixed instance: nothing is reused, nothing is created,
|
||||||
|
// and a full or in-progress instance is still entered (moderating a full instance
|
||||||
|
// is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the
|
||||||
|
// listing — the Photon room id is the join coordinate, so an open version of this
|
||||||
|
// would let anyone warp into any private session by guessing an id. Registered
|
||||||
|
// before the `/matchmake/room/…` routes so `instance` isn't read as a room name.
|
||||||
|
.post(
|
||||||
|
'/matchmake/instance/:instanceId{[0-9]+}',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Navigation'],
|
||||||
|
summary: 'Join a specific instance (owner only)',
|
||||||
|
description: [
|
||||||
|
'Places the caller into one specific live instance of their own room, picked by id',
|
||||||
|
'from the owner’s instance listing. Gated to the room’s creator or a co-owner.',
|
||||||
|
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
|
||||||
|
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
|
||||||
|
'instance or its room is gone, or the caller doesn’t manage that room; errorCode 55',
|
||||||
|
'when banned.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'instanceId',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'Room instance id (digits only)',
|
||||||
|
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: json(
|
||||||
|
MatchmakeResponse,
|
||||||
|
'The instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||||
|
),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
|
||||||
|
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||||
|
// One opaque refusal for "no such instance", "no such room" and "not yours":
|
||||||
|
// a distinct code for the last would confirm which instance ids are live.
|
||||||
|
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||||
|
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
if (!canManageRoom(room, id)) {
|
||||||
|
logger.info('instance matchmake refused: not the room’s owner', {
|
||||||
|
roomInstanceId: instanceId,
|
||||||
|
roomId: stored.roomId,
|
||||||
|
accountId: id,
|
||||||
|
})
|
||||||
|
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like the follow-a-friend path, this hands out a Photon room id without going
|
||||||
|
// through resolveRoomInstance, so the room's bans are checked here too. An owner
|
||||||
|
// can't ban themselves out of their own room in practice, but a co-owner can be
|
||||||
|
// banned, and a ban must beat every route that yields join coordinates.
|
||||||
|
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
|
||||||
|
logger.info('instance matchmake refused: player banned from room', {
|
||||||
|
roomId: stored.roomId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
|
||||||
|
// this instance's own id and Photon room, so the owner lands in exactly the
|
||||||
|
// session they picked rather than a new one alongside it.
|
||||||
|
const instance = roomInstanceFromRoom(
|
||||||
|
room,
|
||||||
|
stored.isPrivate,
|
||||||
|
stored.roomInstanceId,
|
||||||
|
stored.photonRoomId,
|
||||||
|
stored.subRoomId
|
||||||
|
)
|
||||||
|
await enterRoom(c, id, instance)
|
||||||
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
||||||
// — the client uses this to enter a room's other scenes). The subroom decides the
|
// — the client uses this to enter a room's other scenes). The subroom decides the
|
||||||
// scene the client loads and which instances are joinable, so it must be carried
|
// scene the client loads and which instances are joinable, so it must be carried
|
||||||
@@ -1221,16 +1309,20 @@ const app = new Hono<App>()
|
|||||||
(c) => c.body(null, 200)
|
(c) => c.body(null, 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
// The room owner flips the instance's in-progress flag once the session starts
|
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
|
||||||
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
|
// begins). Deliberately NOT owner-gated, unlike the other room-instance mutations:
|
||||||
|
// this is set by whoever in the room starts the game, not by the room's owner — a
|
||||||
|
// gate here would break game starts for everyone else. Body is a form post:
|
||||||
|
// `inProgress=True|False`.
|
||||||
.put(
|
.put(
|
||||||
'/roominstance/:id/inprogress',
|
'/roominstance/:id/inprogress',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Room instance'],
|
tags: ['Room instance'],
|
||||||
summary: 'Set instance in-progress flag',
|
summary: 'Set instance in-progress flag',
|
||||||
description: [
|
description: [
|
||||||
'The room owner flips the instance’s in-progress flag when a session starts (e.g. a',
|
'Flips the instance’s in-progress flag when a session starts (e.g. a round begins).',
|
||||||
'round begins). Body is `inProgress=True|False`.',
|
'Set by whoever in the room starts the game — any authenticated player, not just the',
|
||||||
|
'room’s owner. Body is `inProgress=True|False`.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
||||||
@@ -1258,6 +1350,56 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
|
||||||
|
// makes the session they're running private, so public matchmaking stops feeding new
|
||||||
|
// players into it (getJoinableInstance only reuses non-private instances). Everyone
|
||||||
|
// already inside stays put; this shuts the door rather than clearing the room.
|
||||||
|
// OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a
|
||||||
|
// session is open is the room owner's call, not a passer-by's. Generic empty ack.
|
||||||
|
.post(
|
||||||
|
'/roominstance/:id/markprivate',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room instance'],
|
||||||
|
summary: 'Mark an instance private (owner only)',
|
||||||
|
description: [
|
||||||
|
'Marks a live instance private, so public matchmaking stops placing new players',
|
||||||
|
'into it. Players already inside are unaffected. Auth-gated and gated to the',
|
||||||
|
'instance’s room’s creator or a co-owner (403 otherwise). Empty ack.',
|
||||||
|
].join(' '),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'Room instance id',
|
||||||
|
schema: { type: 'string' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: EMPTY_OK,
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||||
|
404: { description: 'Non-numeric id or no such instance (empty body)' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const instanceId = Number.parseInt(c.req.param('id'), 10)
|
||||||
|
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
||||||
|
|
||||||
|
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||||
|
if (!stored) return c.body(null, 404)
|
||||||
|
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||||
|
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
|
||||||
|
|
||||||
|
await setRoomInstancePrivate(c.env.DB, instanceId, true)
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 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 a
|
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
|
||||||
|
|||||||
@@ -1018,6 +1018,141 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
|
||||||
|
// A player with no role on room 3 spins up an instance of it, which the room's
|
||||||
|
// owner should then be able to drop into by id.
|
||||||
|
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('43'),
|
||||||
|
})
|
||||||
|
const spawned = (await spawn.json()) as {
|
||||||
|
roomInstance: { roomInstanceId: number; photonRoomId: string }
|
||||||
|
}
|
||||||
|
const instanceId = spawned.roomInstance.roomInstanceId
|
||||||
|
|
||||||
|
// No token → 401.
|
||||||
|
expect(
|
||||||
|
(await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' }))
|
||||||
|
.status
|
||||||
|
).toBe(401)
|
||||||
|
|
||||||
|
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
||||||
|
// so instance ids can't be probed for live private sessions.
|
||||||
|
const stranger = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('999'),
|
||||||
|
})
|
||||||
|
expect(stranger.status).toBe(200)
|
||||||
|
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||||
|
|
||||||
|
// Unknown instance → same refusal.
|
||||||
|
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||||
|
|
||||||
|
// Park the owner somewhere else first, so this is a real transition.
|
||||||
|
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// The owner lands in that exact instance — same id AND same Photon room as the
|
||||||
|
// player already in it, which is what makes it the same session.
|
||||||
|
const joined = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
expect(joined.status).toBe(200)
|
||||||
|
const body = (await joined.json()) as {
|
||||||
|
errorCode: number
|
||||||
|
roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number }
|
||||||
|
}
|
||||||
|
expect(body.errorCode).toBe(0)
|
||||||
|
expect(body.roomInstance.roomInstanceId).toBe(instanceId)
|
||||||
|
expect(body.roomInstance.photonRoomId).toBe(spawned.roomInstance.photonRoomId)
|
||||||
|
expect(body.roomInstance.roomId).toBe(3)
|
||||||
|
|
||||||
|
// It's now the owner's presence, and the listing shows both of them in there.
|
||||||
|
const listed = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
||||||
|
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
||||||
|
const target = listed.find((i) => i.roomInstanceId === instanceId)
|
||||||
|
expect(target?.playerIds).toEqual([42, 43])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /roominstance/:id/markprivate closes the instance, owner-only', async () => {
|
||||||
|
// Room 77 subroom 34 — its own instance, so marking it private can't affect the
|
||||||
|
// instances the other tests matchmake into.
|
||||||
|
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
const { roomInstance } = (await spawn.json()) as { roomInstance: { roomInstanceId: number } }
|
||||||
|
const instanceId = roomInstance.roomInstanceId
|
||||||
|
|
||||||
|
// No token → 401.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(401)
|
||||||
|
|
||||||
|
// Unknown instance → 404.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/roominstance/9999999/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(404)
|
||||||
|
|
||||||
|
// Room 77 has no creator and no roles, so nobody manages it → 403 even for 42.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(403)
|
||||||
|
|
||||||
|
// Room 3 is account 42's, so its instances are theirs to close.
|
||||||
|
const owned = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('43'),
|
||||||
|
})
|
||||||
|
const ownedId = ((await owned.json()) as { roomInstance: { roomInstanceId: number } })
|
||||||
|
.roomInstance.roomInstanceId
|
||||||
|
const marked = await exports.default.fetch(`${ORIGIN}/roominstance/${ownedId}/markprivate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('42'),
|
||||||
|
})
|
||||||
|
expect(marked.status).toBe(200)
|
||||||
|
expect(await marked.text()).toBe('')
|
||||||
|
|
||||||
|
// Closed to strangers: a public matchmake into room 3 no longer reuses it, so a
|
||||||
|
// new player lands in a different instance.
|
||||||
|
const after = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await bearer('999'),
|
||||||
|
})
|
||||||
|
const afterId = ((await after.json()) as { roomInstance: { roomInstanceId: number } })
|
||||||
|
.roomInstance.roomInstanceId
|
||||||
|
expect(afterId).not.toBe(ownedId)
|
||||||
|
|
||||||
|
// The player already inside is untouched — this shuts the door, it doesn't clear
|
||||||
|
// the room.
|
||||||
|
const listed = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
||||||
|
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
||||||
|
expect(listed.find((i) => i.roomInstanceId === ownedId)?.playerIds).toContain(43)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
||||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||||
type Sent = {
|
type Sent = {
|
||||||
@@ -1420,6 +1555,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'POST /invite',
|
'POST /invite',
|
||||||
'POST /matchmake/club/{clubId}',
|
'POST /matchmake/club/{clubId}',
|
||||||
'POST /matchmake/dorm',
|
'POST /matchmake/dorm',
|
||||||
|
'POST /matchmake/instance/{instanceId}',
|
||||||
'POST /matchmake/player/{playerId}',
|
'POST /matchmake/player/{playerId}',
|
||||||
'POST /matchmake/room/{roomId}',
|
'POST /matchmake/room/{roomId}',
|
||||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||||
@@ -1428,6 +1564,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'POST /player/login',
|
'POST /player/login',
|
||||||
'POST /player/logout',
|
'POST /player/logout',
|
||||||
'POST /player/notifydisconnect',
|
'POST /player/notifydisconnect',
|
||||||
|
'POST /roominstance/{id}/markprivate',
|
||||||
'POST /roominstance/{id}/reportjoinresult',
|
'POST /roominstance/{id}/reportjoinresult',
|
||||||
'PUT /player/gameserverregionpings',
|
'PUT /player/gameserverregionpings',
|
||||||
'PUT /player/photonregionpings',
|
'PUT /player/photonregionpings',
|
||||||
|
|||||||
Reference in New Issue
Block a user