mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[match] tweak policy change for mitm
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
|||||||
hasRoomInviteTo,
|
hasRoomInviteTo,
|
||||||
InviteMode,
|
InviteMode,
|
||||||
isClubMember,
|
isClubMember,
|
||||||
|
isMatchmakingPolicy,
|
||||||
isPlayerBannedFromRoom,
|
isPlayerBannedFromRoom,
|
||||||
MatchmakingErrorCode,
|
MatchmakingErrorCode,
|
||||||
MessageType,
|
MessageType,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
roomRoles,
|
roomRoles,
|
||||||
setPresence,
|
setPresence,
|
||||||
setRoomInstanceInProgress,
|
setRoomInstanceInProgress,
|
||||||
|
setRoomInstanceMatchmakingPolicy,
|
||||||
setRoomInstancePrivate,
|
setRoomInstancePrivate,
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
@@ -88,6 +90,7 @@ import {
|
|||||||
MatchmakeRoomRequest,
|
MatchmakeRoomRequest,
|
||||||
MatchmakeRoomV2Request,
|
MatchmakeRoomV2Request,
|
||||||
MatchmakeV2Response,
|
MatchmakeV2Response,
|
||||||
|
MatchPolicyRequest,
|
||||||
NotifyDisconnectRequest,
|
NotifyDisconnectRequest,
|
||||||
PlayerDto,
|
PlayerDto,
|
||||||
QosRegion,
|
QosRegion,
|
||||||
@@ -1164,9 +1167,11 @@ async function readCorrelationId(c: Context<App>): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the instance is matched into, echoed on every v2 instance. The reference server
|
* How the instance is matched into, echoed on every v2 instance. The stored policy
|
||||||
* sends 0 and this server has no policy to express, so it is a constant — kept as a
|
* (`PUT /roominstance/:id/matchpolicy`) is not threaded through to the wire instance,
|
||||||
* named field rather than dropped, because the client's decoder wants the key.
|
* 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
|
const DEFAULT_MATCHMAKING_POLICY = 0
|
||||||
|
|
||||||
@@ -3311,6 +3316,56 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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<string, unknown>)
|
||||||
|
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<RoomInstance>(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
|
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
|
||||||
// makes the session they're running private, so public matchmaking stops feeding new
|
// makes the session they're running private, so public matchmaking stops feeding new
|
||||||
// players into it (getJoinableInstance only reuses non-private instances). Everyone
|
// players into it (getJoinableInstance only reuses non-private instances). Everyone
|
||||||
|
|||||||
@@ -274,6 +274,15 @@ export const InProgressRequest = z.object({
|
|||||||
inProgress: z.string().describe('"True" | "False" (case-insensitive)'),
|
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. */
|
/** `PUT /player/statusvisibility` form body. */
|
||||||
export const StatusVisibilityRequest = z.object({
|
export const StatusVisibilityRequest = z.object({
|
||||||
statusVisibility: z.string().describe('Integer string; non-numeric is ignored'),
|
statusVisibility: z.string().describe('Integer string; non-numeric is ignored'),
|
||||||
|
|||||||
@@ -2531,6 +2531,73 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(target?.playerIds).toEqual([42, 43])
|
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<number> => {
|
||||||
|
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 () => {
|
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
|
// Room 77 subroom 34 — its own instance, so marking it private can't affect the
|
||||||
// instances the other tests matchmake into.
|
// instances the other tests matchmake into.
|
||||||
@@ -3770,6 +3837,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'PUT /player/photonregionpings',
|
'PUT /player/photonregionpings',
|
||||||
'PUT /player/statusvisibility',
|
'PUT /player/statusvisibility',
|
||||||
'PUT /roominstance/{id}/inprogress',
|
'PUT /roominstance/{id}/inprogress',
|
||||||
|
'PUT /roominstance/{id}/matchpolicy',
|
||||||
])
|
])
|
||||||
|
|
||||||
// Every operation carries a summary — a path present but undescribed is not
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
|
|||||||
Reference in New Issue
Block a user