diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index cfdb205..3d2f446 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -35,6 +35,7 @@ import { hasRoomInviteTo, InviteMode, isClubMember, + isMatchmakingPolicy, isPlayerBannedFromRoom, MatchmakingErrorCode, MessageType, @@ -49,6 +50,7 @@ import { roomRoles, setPresence, setRoomInstanceInProgress, + setRoomInstanceMatchmakingPolicy, setRoomInstancePrivate, subRoomDataBlob, } from '@repo/domain' @@ -88,6 +90,7 @@ import { MatchmakeRoomRequest, MatchmakeRoomV2Request, MatchmakeV2Response, + MatchPolicyRequest, NotifyDisconnectRequest, PlayerDto, QosRegion, @@ -1164,9 +1167,11 @@ async function readCorrelationId(c: Context): Promise { } /** - * How the instance is matched into, echoed on every v2 instance. The reference server - * sends 0 and this server has no policy to express, so it is a constant — kept as a - * named field rather than dropped, because the client's decoder wants the key. + * How the instance is matched into, echoed on every v2 instance. The stored policy + * (`PUT /roominstance/:id/matchpolicy`) is not threaded through to the wire instance, + * which is rebuilt from the room; a matchmake only ever lands in an instance that is not + * `Ignore`, so this echoes Default. Kept as a named field rather than dropped, because + * the client's decoder wants the key. */ const DEFAULT_MATCHMAKING_POLICY = 0 @@ -3311,6 +3316,56 @@ const app = new Hono() } ) + // How matchmaking treats this instance (`policy=0|1|2`: Default, Avoid, Ignore — + // see MatchmakingPolicy). Gated on STANDING IN the instance rather than owning the + // room: whoever is in a session decides whether more players should be matched into + // it, the way in-progress is set by whoever starts the game. `Ignore` drops the + // instance from both reuse searches; invites, room codes and the owner's session + // picker still reach it. Empty ack. + .put( + '/roominstance/:id/matchpolicy', + describeRoute({ + tags: ['Room instance'], + summary: 'Set an instance’s matchmaking policy', + description: [ + 'Sets the instance’s `matchmakingPolicy`: 0 Default (matchmaking places players in', + 'it), 1 Avoid (it ranks behind every Default instance), 2 Ignore (matchmaking never', + 'places anyone in it; invites, room codes and the owner’s session picker still do).', + 'Players already inside are unaffected. Auth-gated and gated to a caller whose live', + 'presence puts them IN this instance (403 otherwise) — not the room’s owner. Body is', + '`policy=<0|1|2>`; anything else is a 400. Empty ack.', + ].join(' '), + security: AUTHED, + requestBody: form(MatchPolicyRequest, 'The policy'), + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 200: EMPTY_OK, + 400: { description: '`policy` missing or not 0, 1 or 2 (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'The caller is not in this instance (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 body = await c.req.parseBody().catch(() => ({}) as Record) + const policy = typeof body.policy === 'string' ? Number.parseInt(body.policy, 10) : Number.NaN + if (!isMatchmakingPolicy(policy)) return c.body(null, 400) + + if (!(await getRoomInstance(c.env.DB, instanceId))) return c.body(null, 404) + const presence = await getPresence(c.env.DB, id) + if (presence?.roomInstance?.roomInstanceId !== instanceId) return c.body(null, 403) + + await setRoomInstanceMatchmakingPolicy(c.env.DB, instanceId, policy) + return c.body(null, 200) + } + ) + // 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 diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index 51c8c91..8554db3 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -274,6 +274,15 @@ export const InProgressRequest = z.object({ inProgress: z.string().describe('"True" | "False" (case-insensitive)'), }) +/** `PUT /roominstance/:id/matchpolicy` form body. */ +export const MatchPolicyRequest = z.object({ + policy: z + .string() + .describe( + '0 Default (accepts players), 1 Avoid (deprioritised), 2 Ignore (never matched into)' + ), +}) + /** `PUT /player/statusvisibility` form body. */ export const StatusVisibilityRequest = z.object({ statusVisibility: z.string().describe('Integer string; non-numeric is ignored'), diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index f08a227..38a035a 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -2531,6 +2531,73 @@ describe('auth-gated endpoints', () => { expect(target?.playerIds).toEqual([42, 43]) }) + test('PUT /roominstance/:id/matchpolicy is set from inside, and Ignore stops matchmaking into it', async () => { + // Room 77 subroom 34 — public, 10 seats, nobody's — so whoever stands in an instance + // is the one who may set its policy, and matchmaking reuses the oldest open one. + const spawn = async (player: string): Promise => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, { + method: 'POST', + headers: await bearer(player), + }) + return ((await res.json()) as { roomInstance: { roomInstanceId: number } }).roomInstance + .roomInstanceId + } + const setPolicy = async (instanceId: number | string, policy: string, player?: string) => + exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/matchpolicy`, { + method: 'PUT', + headers: { + ...(player ? await bearer(player) : {}), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ policy }).toString(), + }) + const policyOf = async (instanceId: number) => + ( + await env.DB.prepare('SELECT matchmaking_policy AS p FROM room_instance WHERE id = ?1') + .bind(instanceId) + .first<{ p: number }>() + )?.p + + const a = await spawn('4201') + expect(await policyOf(a)).toBe(0) + + // No token → 401. Unknown instance → 404. A bad policy → 400, before any lookup. + expect((await setPolicy(a, '2')).status).toBe(401) + expect((await setPolicy(9999999, '2', '4201')).status).toBe(404) + expect((await setPolicy(a, '7', '4201')).status).toBe(400) + expect((await setPolicy(a, 'Ignore', '4201')).status).toBe(400) + // A player who is not standing in the instance → 403, however they relate to the room. + expect((await setPolicy(a, '2', '4299')).status).toBe(403) + expect(await policyOf(a)).toBe(0) + + // The player inside sets Ignore (the client's body is `policy=2`): empty ack, stored. + const set = await setPolicy(a, '2', '4201') + expect(set.status).toBe(200) + expect(await set.text()).toBe('') + expect(await policyOf(a)).toBe(2) + + // Excluded from matchmaking entirely: the next player gets a different instance even + // though A is open and has nine free seats. The one inside stays put. + const b = await spawn('4202') + expect(b).not.toBe(a) + expect(b).toBeGreaterThan(a) + // B's occupant cannot touch A's policy either. + expect((await setPolicy(a, '0', '4202')).status).toBe(403) + + // Back to Default, A is the oldest open instance again and is reused. + expect((await setPolicy(a, '0', '4201')).status).toBe(200) + expect(await spawn('4203')).toBe(a) + + // Avoid keeps A joinable but ranks it behind B, the only Default instance. + expect((await setPolicy(a, '1', '4201')).status).toBe(200) + expect(await policyOf(a)).toBe(1) + expect(await spawn('4204')).toBe(b) + // With B set to Ignore too, Avoid is still better than nothing: A is reused rather + // than a third instance opened. + expect((await setPolicy(b, '2', '4202')).status).toBe(200) + expect(await spawn('4205')).toBe(a) + }) + 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. @@ -3770,6 +3837,7 @@ describe('auth-gated endpoints', () => { 'PUT /player/photonregionpings', 'PUT /player/statusvisibility', 'PUT /roominstance/{id}/inprogress', + 'PUT /roominstance/{id}/matchpolicy', ]) // Every operation carries a summary — a path present but undescribed is not