mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
clean up match code, remove old endpoints
This commit is contained in:
@@ -28,14 +28,13 @@ export async function parseFormIds(c: Context<App>): Promise<number[]> {
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Read integer ids from repeated/comma-separated `id` query params. The 2023
|
||||
* client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */
|
||||
/** Read integer ids from repeated `id` query params. The 2023 client passes these to
|
||||
* the bulk GET endpoints as one value per id (`?id=1&id=2`), never comma-separated. */
|
||||
export function queryIds(c: Context<App>): number[] {
|
||||
return (
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
?.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function defaultReputation(id: number) {
|
||||
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
|
||||
*/
|
||||
const BULK_ID_QUERY = [
|
||||
intQuery('id', 'Repeatable; each value may be a comma-separated list of account ids'),
|
||||
intQuery('id', 'Repeated once per account id (`?id=1&id=2`); not comma-separated'),
|
||||
]
|
||||
|
||||
/** The `Ids` form body the bulk POST forms take. */
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('public endpoints', () => {
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => {
|
||||
|
||||
+129
-276
@@ -40,14 +40,14 @@ import {
|
||||
EMPTY_OK,
|
||||
ExclusiveLoginResponse,
|
||||
form,
|
||||
HeartbeatRequest as HeartbeatRequestSchema,
|
||||
InProgressRequest,
|
||||
InviteRequest,
|
||||
JoinModeRequest,
|
||||
json,
|
||||
jsonBody,
|
||||
LoginLockRequest,
|
||||
MatchmakeResponse,
|
||||
MatchmakeRoomRequest,
|
||||
NotifyDisconnectRequest,
|
||||
PlayerDto,
|
||||
RoomInstanceDto,
|
||||
StatusVisibilityRequest,
|
||||
@@ -108,16 +108,6 @@ function playerPayload(playerId: number, presence?: Presence | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Heartbeat body posted by the client (all fields optional). */
|
||||
interface HeartbeatRequest {
|
||||
playerId?: number
|
||||
statusVisibility?: number
|
||||
deviceClass?: number
|
||||
vrMovementMode?: number
|
||||
appVersion?: string | null
|
||||
platform?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the account id from a Bearer token, mirroring the repeated
|
||||
* auth-header check. Returns `null` when the header is missing,
|
||||
@@ -133,7 +123,7 @@ function unauthorized(c: Context<App>) {
|
||||
}
|
||||
|
||||
/** A synthesized room instance (same shape for dorm and other rooms). */
|
||||
type RoomInstance = ReturnType<typeof dormRoomInstance>
|
||||
type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
|
||||
|
||||
/**
|
||||
* Stored presence for a player — the room instance they matchmade into plus the
|
||||
@@ -165,7 +155,7 @@ const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
|
||||
* `photonRoomId` would let anyone who can read your presence `JoinByName` the Photon
|
||||
* room directly, bypassing the private-instance invite check — the friend list only
|
||||
* needs `roomId`/`name`/`isPrivate` to render the row, and joins go back through
|
||||
* matchmaking (`/goto/player/:id`), which enforces access. `photonRegion` is omitted
|
||||
* matchmaking (`/matchmake/player/:playerId`), which enforces access. `photonRegion` is omitted
|
||||
* (not on the presence DTO); `name` is already the `^`-prefixed wire name.
|
||||
*/
|
||||
function redactInstanceForPresence(instance: RoomInstance) {
|
||||
@@ -260,6 +250,9 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
||||
vrMovementMode: prev?.vrMovementMode ?? 1,
|
||||
platform: prev?.platform ?? account?.platform ?? 0,
|
||||
appVersion: prev?.appVersion || GAME_VERSION,
|
||||
// Carry the session lock recorded at login forward, so matchmake doesn't wipe it
|
||||
// and the heartbeat can keep verifying against it.
|
||||
loginLock: prev?.loginLock,
|
||||
})
|
||||
// Keep the destination instance's is_full flag in sync with live presence (the
|
||||
// player's own presence, just written, is counted). Then re-evaluate the
|
||||
@@ -277,15 +270,6 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
||||
await notifyFriendsPresence(c, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed Photon room id for the dorm. With no RoomInstance DB we can't persist
|
||||
* the GUID minted at matchmake time, and the client's presence check compares
|
||||
* the *whole* instance (photonRoomId included). Every dorm entry point
|
||||
* (matchmake/goto, matchmake/none, the heartbeat) must therefore return the
|
||||
* exact same instance, so the id is a constant rather than random/per-account.
|
||||
*/
|
||||
const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001'
|
||||
|
||||
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||
const NO_SUCH_ROOM = 20
|
||||
|
||||
@@ -352,34 +336,6 @@ async function sendGameInvite(
|
||||
*/
|
||||
const ORIENTATION_INSTANCE_ID = -2
|
||||
|
||||
/**
|
||||
* The canonical dorm room instance (room 1, instance 1.1). Returned identically
|
||||
* by every dorm entry point and the presence heartbeat so the client's local
|
||||
* presence never reads as out-of-sync.
|
||||
*/
|
||||
function dormRoomInstance() {
|
||||
return {
|
||||
roomInstanceId: 1,
|
||||
roomId: 1,
|
||||
subRoomId: 1,
|
||||
roomInstanceType: RoomInstanceType.Dormroom,
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
dataBlob: '',
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
photonRegion: 'us',
|
||||
photonRegionId: 'us',
|
||||
photonRoomId: DORM_PHOTON_ROOM_ID,
|
||||
name: '^DormRoom',
|
||||
maxCapacity: 4,
|
||||
isFull: false,
|
||||
isPrivate: true,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
|
||||
* The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
|
||||
@@ -426,7 +382,7 @@ function roomInstanceFromRoom(
|
||||
instanceId: number,
|
||||
photonRoomId: string,
|
||||
subRoomId?: number
|
||||
): RoomInstance {
|
||||
) {
|
||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||
return {
|
||||
roomInstanceId: instanceId,
|
||||
@@ -450,6 +406,12 @@ function roomInstanceFromRoom(
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the session's `LoginLock` GUID from a form body (undefined when absent/empty). */
|
||||
async function readLoginLock(c: Context<App>): Promise<string | undefined> {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
return typeof body.LoginLock === 'string' && body.LoginLock ? body.LoginLock : undefined
|
||||
}
|
||||
|
||||
/** Read the `JoinMode` form field (2 = private instance). */
|
||||
async function readJoinMode(c: Context<App>): Promise<number> {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
@@ -460,9 +422,9 @@ async function readJoinMode(c: Context<App>): Promise<number> {
|
||||
* Read the room-matchmake form body once: `JoinMode` (2 = private) plus the party members
|
||||
* to pull along (`AdditionalPlayerIds`). The 2023 client posts its party on a room
|
||||
* matchmake so they can be invited into the instance the leader lands in;
|
||||
* `AdditionalPlayerIds` may repeat and/or be comma-separated, and ids are parsed
|
||||
* defensively, de-duplicated, and non-positive/garbage entries dropped. Parsed with
|
||||
* `{ all: true }` in one pass so repeated fields survive.
|
||||
* `AdditionalPlayerIds` is a repeated field (one id each, never comma-separated), and
|
||||
* ids are parsed defensively, de-duplicated, and non-positive/garbage entries dropped.
|
||||
* Parsed with `{ all: true }` in one pass so the repeated fields survive.
|
||||
*/
|
||||
async function readMatchmakeBody(
|
||||
c: Context<App>
|
||||
@@ -482,7 +444,6 @@ async function readMatchmakeBody(
|
||||
...new Set(
|
||||
values
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||
),
|
||||
@@ -613,29 +574,62 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// ---- Player presence -----------------------------------------------------
|
||||
// login/exclusivelogin are no-op acks and MUST NOT touch presence: the client
|
||||
// fires exclusivelogin when going online, and clearing presence there would bounce
|
||||
// the player to the dorm. Presence is overwritten by matchmake/goto and expires on
|
||||
// its own TTL.
|
||||
// login records the session's `LoginLock` in presence so the heartbeat can verify
|
||||
// each beat belongs to this login; it must otherwise leave presence intact (clearing
|
||||
// the room instance here would bounce the player to the dorm). Presence is overwritten
|
||||
// by matchmake — which carries the lock forward — and expires on its own TTL.
|
||||
.post(
|
||||
'/player/login',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'Login ack (no-op)',
|
||||
summary: 'Record the session login lock',
|
||||
description: [
|
||||
'A no-op ack. Must NOT touch presence — the client fires this going online, and',
|
||||
'clearing presence here would bounce the player to the dorm.',
|
||||
'Records the posted `LoginLock` in the player’s presence so later heartbeats can',
|
||||
'verify they still own the session. Updates the live presence row if there is one,',
|
||||
'otherwise seeds a lobby presence (no room) carrying the lock. Empty ack.',
|
||||
].join(' '),
|
||||
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
|
||||
responses: { 200: EMPTY_OK },
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) {
|
||||
const loginLock = await readLoginLock(c)
|
||||
if (loginLock !== undefined) {
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
if (presence) {
|
||||
presence.loginLock = loginLock
|
||||
await setPresence(c.env.DB, presence)
|
||||
} else {
|
||||
// No live presence yet — seed a lobby row (roomInstance null) holding the
|
||||
// lock, so it survives to the first matchmake (enterRoom carries it forward).
|
||||
const account = await getAccount(c.env.DB, id)
|
||||
await setPresence(c.env.DB, {
|
||||
accountId: id,
|
||||
roomInstance: null,
|
||||
statusVisibility: 0,
|
||||
deviceClass: account?.deviceClass ?? 0,
|
||||
vrMovementMode: 1,
|
||||
platform: account?.platform ?? 0,
|
||||
appVersion: GAME_VERSION,
|
||||
loginLock,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/player/exclusivelogin',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'Exclusive-login ack (no-op)',
|
||||
description: 'A no-op ack returning a zero error code. Like login, must not touch presence.',
|
||||
description: [
|
||||
'Player exclusive login. Carries the session `LoginLock` (as every presence',
|
||||
'lifecycle call does) but is currently a no-op ack. @todo implement login locking.',
|
||||
].join(' '),
|
||||
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
|
||||
responses: { 200: json(ExclusiveLoginResponse, 'errorCode 0') },
|
||||
}),
|
||||
(c) => c.json({ errorCode: 0 })
|
||||
@@ -649,7 +643,7 @@ const app = new Hono<App>()
|
||||
// worker writes that presence with instance id -2). Clearing presence there wipes
|
||||
// the seed and bounces the new player to the dorm — so a logout that still points
|
||||
// at Orientation is left as a no-op ack. An unauthenticated logout is also a no-op
|
||||
// (no player to clear).
|
||||
// (no player to clear). @kludge probably a better solution for this.
|
||||
.post(
|
||||
'/player/logout',
|
||||
describeRoute({
|
||||
@@ -657,10 +651,12 @@ const app = new Hono<App>()
|
||||
summary: 'Clear presence on logout',
|
||||
description: [
|
||||
'Clears the player’s presence so they read offline immediately and the instance',
|
||||
'they were in frees up. EXCEPTION: a logout whose presence still points at the',
|
||||
'they were in frees up. Carries the session `LoginLock` (as every presence',
|
||||
'lifecycle call does). EXCEPTION: a logout whose presence still points at the',
|
||||
'Orientation seed (instance -2) is left as a no-op, so the account-creation',
|
||||
'bootstrap isn’t wiped. An unauthenticated logout is also a no-op.',
|
||||
].join(' '),
|
||||
requestBody: form(LoginLockRequest, 'The session LoginLock GUID'),
|
||||
responses: { 200: EMPTY_OK },
|
||||
}),
|
||||
async (c) => {
|
||||
@@ -683,21 +679,35 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`).
|
||||
// The client posts this when it drops a room; we don't act on it — presence is
|
||||
// cleared by logout and otherwise expires on its own TTL — so just ack with 200.
|
||||
// Photon disconnect notification (form body `PlayerId`/`RoomInstanceId`) — posted when
|
||||
// Photon sees a player drop a room instance. We don't act on it yet (presence is cleared
|
||||
// by logout and otherwise expires on its TTL), but the fields are parsed and logged so
|
||||
// the hook is in place for a future background reconciliation check.
|
||||
.post(
|
||||
'/player/notifydisconnect',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'Disconnect notification (no-op ack)',
|
||||
summary: 'Photon disconnect notification',
|
||||
description: [
|
||||
'Posted when the client drops a room. Not acted on — presence is cleared by logout',
|
||||
'and otherwise expires on its TTL.',
|
||||
'Posted by Photon when it sees a player drop a room instance (form body',
|
||||
'`PlayerId`/`RoomInstanceId`). Currently just logged and acked — presence is cleared',
|
||||
'by logout and otherwise expires on its TTL — but the hook is here for a future check.',
|
||||
].join(' '),
|
||||
requestBody: form(NotifyDisconnectRequest, 'The disconnecting player and the instance they left'),
|
||||
responses: { 200: EMPTY_OK },
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
async (c) => {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const parseId = (v: unknown): number | null => {
|
||||
const n = typeof v === 'string' ? Number.parseInt(v, 10) : NaN
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
logger.info('player disconnect notification', {
|
||||
playerId: parseId(body.PlayerId),
|
||||
roomInstanceId: parseId(body.RoomInstanceId),
|
||||
})
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
|
||||
.get(
|
||||
@@ -706,27 +716,27 @@ const app = new Hono<App>()
|
||||
tags: ['Presence'],
|
||||
summary: 'Batch player presence lookup',
|
||||
description: [
|
||||
'Returns each requested player’s presence. `id` is repeatable and each value may',
|
||||
'be a comma-separated list. With no ids, serves a single default (online) player.',
|
||||
'Returns each requested player’s presence. `id` is a repeated query param',
|
||||
'(`?id=2&id=155&id=153`) — one value each, not comma-separated. With no ids, serves',
|
||||
'a single default (online) player.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Repeatable; each value may be a comma-separated list of player ids',
|
||||
description: 'Repeated once per player id (`?id=2&id=155`); not comma-separated',
|
||||
schema: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(PlayerDto.array(), 'One entry per requested player') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Returns each requested player's presence. Reads the `id` query param(s);
|
||||
// with none it serves the static getplayer.json default.
|
||||
// Returns each requested player's presence. Reads the repeated `id` query
|
||||
// param(s); with none it serves the static getplayer.json default.
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
?.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
|
||||
|
||||
@@ -743,16 +753,15 @@ const app = new Hono<App>()
|
||||
tags: ['Presence'],
|
||||
summary: 'Presence heartbeat',
|
||||
description: [
|
||||
'Merges the posted status fields into stored presence and echoes back the player',
|
||||
'payload. Re-writes the row (refreshing its TTL) only when something changed or the',
|
||||
'TTL is close to lapsing, so a still player isn’t written on every beat. With no',
|
||||
'stored presence the player isn’t in a room yet (roomInstance null, isOnline false).',
|
||||
'Returns the player’s current presence payload without mutating any stored fields —',
|
||||
'the only side effect is refreshing the row’s TTL, and even that only when the TTL',
|
||||
'is close to lapsing so a still player isn’t written on every beat. The posted',
|
||||
'`LoginLock` is verified against the one recorded at login: a heartbeat carrying a',
|
||||
'different lock is a superseded session and gets an empty body. With no stored',
|
||||
'presence the player isn’t in a room yet (roomInstance null, isOnline false).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(
|
||||
HeartbeatRequestSchema,
|
||||
'JSON status fields. A non-JSON (LoginLock) body is accepted and ignored.'
|
||||
),
|
||||
requestBody: form(LoginLockRequest, 'The session LoginLock GUID (verified, not stored)'),
|
||||
responses: {
|
||||
200: json(PlayerDto, 'The player’s current presence payload'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
@@ -762,80 +771,38 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// Body may be a JSON HeartbeatRequest or a form post (LoginLock); only JSON
|
||||
// carries presence/status fields.
|
||||
const raw = await c.req.text().catch(() => '')
|
||||
let hb: HeartbeatRequest = {}
|
||||
if (raw.trimStart().startsWith('{')) {
|
||||
try {
|
||||
hb = JSON.parse(raw) as HeartbeatRequest
|
||||
} catch {
|
||||
hb = {}
|
||||
}
|
||||
}
|
||||
// The body is either a JSON status blob (no longer read — presence is returned
|
||||
// verbatim) or a form carrying the session `LoginLock`. We only read the
|
||||
// LoginLock, to verify this beat still owns the session; a JSON body simply
|
||||
// yields no lock (parseBody fails and is swallowed).
|
||||
const postedLock = await readLoginLock(c)
|
||||
|
||||
// Return the player's stored presence (set by matchmake/goto), mirroring the
|
||||
// Return the player's stored presence (set at login/matchmake), mirroring the
|
||||
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
|
||||
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status
|
||||
// fields are merged back; the row is re-written (refreshing the TTL) only when
|
||||
// something changed or its TTL is close to lapsing — see below.
|
||||
// isn't in a room yet, so roomInstance=null / isOnline=false.
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
if (presence) {
|
||||
// Merge the posted status fields, tracking whether any actually changed.
|
||||
let changed = false
|
||||
const apply = <K extends keyof Presence>(key: K, value: Presence[K]) => {
|
||||
if (presence[key] !== value) {
|
||||
presence[key] = value
|
||||
changed = true
|
||||
}
|
||||
// A heartbeat whose LoginLock disagrees with the one recorded at login belongs
|
||||
// to a superseded session — return nothing so that stale client stops acting as
|
||||
// the live one. (No posted lock, or none recorded yet, skips the check.)
|
||||
if (
|
||||
postedLock !== undefined &&
|
||||
presence.loginLock !== undefined &&
|
||||
presence.loginLock !== postedLock
|
||||
) {
|
||||
return c.body(null, 200)
|
||||
}
|
||||
if (hb.statusVisibility !== undefined) apply('statusVisibility', hb.statusVisibility)
|
||||
if (hb.deviceClass !== undefined) apply('deviceClass', hb.deviceClass)
|
||||
if (hb.vrMovementMode !== undefined) apply('vrMovementMode', hb.vrMovementMode)
|
||||
if (hb.platform !== undefined) apply('platform', hb.platform)
|
||||
if (hb.appVersion) apply('appVersion', hb.appVersion)
|
||||
if (!presence.appVersion) apply('appVersion', GAME_VERSION)
|
||||
|
||||
// Extending the TTL means re-writing the row, so skip the write on an
|
||||
// unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD
|
||||
// (s) of lapsing — a still player is refreshed periodically rather than on
|
||||
// every beat. `expiresAt` is epoch seconds (set by setPresence).
|
||||
// The heartbeat's only side effect is refreshing the TTL, and only once it's
|
||||
// within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is refreshed
|
||||
// periodically rather than re-written on every beat. `expiresAt` is epoch seconds.
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD
|
||||
if (changed || dueForRefresh) {
|
||||
if (presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD) {
|
||||
await setPresence(c.env.DB, presence)
|
||||
}
|
||||
}
|
||||
|
||||
// The heartbeat echoes the same player payload `/player` serves; with no stored
|
||||
// presence it falls back to what the client just posted.
|
||||
const payload = {
|
||||
...playerPayload(hb.playerId ? hb.playerId : id, presence),
|
||||
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
|
||||
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
|
||||
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
||||
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
|
||||
platform: presence?.platform ?? hb.platform ?? 0,
|
||||
}
|
||||
|
||||
// Also push the presence over the websocket as a PresenceHeartbeatResponse,
|
||||
// mirroring the reference's ReturnHeartbeat. Sent ephemerally (never queued) —
|
||||
// a heartbeat fires on every beat, and a queued copy would pile up and arrive
|
||||
// stale. Best-effort: the HTTP body carries the same payload regardless.
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
|
||||
id,
|
||||
NotificationType.PresenceHeartbeatResponse,
|
||||
payload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PresenceHeartbeatResponse notification', {
|
||||
playerId: id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.json(payload)
|
||||
return c.json(playerPayload(id, presence))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -870,85 +837,9 @@ const app = new Hono<App>()
|
||||
)
|
||||
|
||||
// ---- Room navigation -----------------------------------------------------
|
||||
// Each matchmake/goto persists the resulting instance as the player's presence
|
||||
// so the heartbeat can replay it (keeping client presence in sync).
|
||||
.post(
|
||||
'/goto/room/:room',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Go to a room',
|
||||
description: [
|
||||
'Resolves the room (numeric id or name; `dormroom` → the player’s personal dorm),',
|
||||
'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a',
|
||||
'private instance.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||
parameters: [
|
||||
{
|
||||
name: 'room',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Room id, room name, or `dormroom`',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const room = c.req.param('room')
|
||||
const joinMode = await readJoinMode(c)
|
||||
const instance =
|
||||
room.toLowerCase() === 'dormroom'
|
||||
? await playerDormInstance(c, id)
|
||||
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Register the static `none` route before the `:room` param route so it
|
||||
// isn't swallowed by the auth-gated matchmake handler.
|
||||
.post(
|
||||
'/matchmake/none',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Matchmake with no target (preserve or dorm)',
|
||||
description: [
|
||||
'Returns the player’s current instance if they have one (so Orientation isn’t warped',
|
||||
'away), else their personal dorm when authed, or the shared offline dorm when not.',
|
||||
'Not auth-gated.',
|
||||
].join(' '),
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'Current, personal-dorm, or offline-dorm instance'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
// Return the player's *current* heartbeat here rather than forcing the dorm.
|
||||
// Orientation is a solo room the client establishes via matchmake/none; if we
|
||||
// force the dorm, the new player is warped out of Orientation within seconds.
|
||||
// So: preserve existing presence; only fall back to the offline dorm when the
|
||||
// player has none (e.g. the title screen before they've entered any room).
|
||||
if (id !== null) {
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
if (presence?.roomInstance) {
|
||||
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
|
||||
}
|
||||
}
|
||||
// Authed but no presence → their personal dorm; unauthenticated → offline dorm.
|
||||
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
|
||||
if (id !== null) await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
// Each matchmake persists the resulting instance as the player's presence so the
|
||||
// heartbeat can replay it (keeping client presence in sync).
|
||||
//
|
||||
// Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before
|
||||
// the single-segment `/matchmake/:room` route so `club` isn't read as a room name.
|
||||
// Members only: the clubhouse is the club's private space, so a non-member (or
|
||||
@@ -1152,28 +1043,18 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/matchmake/:room',
|
||||
'/matchmake/dorm',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Matchmake into a room by id or name',
|
||||
summary: 'Matchmake into the player’s dorm',
|
||||
description: [
|
||||
'Single-segment matchmake. `dorm` → the player’s personal dorm; otherwise resolves',
|
||||
'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses',
|
||||
'`dormroom`.)',
|
||||
'Single-segment matchmake into the caller’s personal dorm, stored as presence. The',
|
||||
'client only ever calls this with the `dorm` keyword — real rooms go through',
|
||||
'`/matchmake/room/:roomId`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||
parameters: [
|
||||
{
|
||||
name: 'room',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Room id, room name, or `dorm`',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||
200: json(MatchmakeResponse, 'The player’s personal dorm instance'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -1181,40 +1062,12 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const room = c.req.param('room')
|
||||
const joinMode = await readJoinMode(c)
|
||||
// The dorm check here is "dorm" (goto/room uses "dormroom").
|
||||
const instance =
|
||||
room.toLowerCase() === 'dorm'
|
||||
? await playerDormInstance(c, id)
|
||||
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
const instance = await playerDormInstance(c, id)
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
|
||||
.post(
|
||||
'/goto/none',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Go to the dorm',
|
||||
description: [
|
||||
'Authed → the player’s personal dorm (persisted as presence); unauthenticated → the',
|
||||
'shared offline dorm. Unlike matchmake/none, this always goes to the dorm.',
|
||||
].join(' '),
|
||||
responses: { 200: json(MatchmakeResponse, 'The dorm instance') },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
// Authed → their personal dorm; unauthenticated → the offline dorm.
|
||||
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
|
||||
if (id !== null) await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Region ping reports — accept-and-ack (the reference returns Ok()).
|
||||
.put(
|
||||
'/player/photonregionpings',
|
||||
|
||||
+22
-16
@@ -124,7 +124,7 @@ export const PlayerDto = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The matchmake/goto result envelope. `errorCode` 0 with a `roomInstance` is success;
|
||||
* The matchmake result envelope. `errorCode` 0 with a `roomInstance` is success;
|
||||
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
||||
*/
|
||||
export const MatchmakeResponse = z.object({
|
||||
@@ -136,17 +136,13 @@ export const MatchmakeResponse = z.object({
|
||||
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
||||
|
||||
/**
|
||||
* `POST /player/heartbeat` JSON body. All fields optional — the client also posts a
|
||||
* non-JSON (LoginLock form) body here, in which case none of these are read and stored
|
||||
* presence is echoed back unchanged.
|
||||
* The session `LoginLock` GUID form field. The client posts it on every presence
|
||||
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
|
||||
* and `/player/heartbeat` — so it's always present, not optional. Recorded in presence
|
||||
* at login and verified on each heartbeat (a mismatched lock is a superseded session).
|
||||
*/
|
||||
export const HeartbeatRequest = z.object({
|
||||
playerId: z.int().optional(),
|
||||
statusVisibility: z.int().optional(),
|
||||
deviceClass: z.int().optional(),
|
||||
vrMovementMode: z.int().optional(),
|
||||
appVersion: z.string().nullable().optional(),
|
||||
platform: z.int().optional(),
|
||||
export const LoginLockRequest = z.object({
|
||||
LoginLock: z.string().describe('The session login-lock GUID (always sent)'),
|
||||
})
|
||||
|
||||
/** `PUT /roominstance/:id/inprogress` form body. */
|
||||
@@ -160,7 +156,16 @@ export const StatusVisibilityRequest = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* The `JoinMode` form field the matchmake/goto routes read (`2` = a private instance;
|
||||
* `POST /player/notifydisconnect` form body — posted by Photon when it sees a player
|
||||
* drop a room instance. Both fields are integer strings.
|
||||
*/
|
||||
export const NotifyDisconnectRequest = z.object({
|
||||
PlayerId: z.string().describe('The account that disconnected'),
|
||||
RoomInstanceId: z.string().describe('The room instance they dropped'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `JoinMode` form field the matchmake routes read (`2` = a private instance;
|
||||
* anything else = public). Posted as a urlencoded/multipart body.
|
||||
*/
|
||||
export const JoinModeRequest = z.object({
|
||||
@@ -170,16 +175,17 @@ export const JoinModeRequest = z.object({
|
||||
/**
|
||||
* The room-matchmake form body (`/matchmake/room/:roomId[/:subRoomId]`). Beyond
|
||||
* `JoinMode` the 2023 client posts `AdditionalPlayerIds` — the caller's party — so each
|
||||
* of them is invited (a game invite) into the instance the leader lands in. May repeat
|
||||
* and/or be comma-separated. Other fields the client sends (`LoginLock`,
|
||||
* `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted and ignored.
|
||||
* of them is invited (a game invite) into the instance the leader lands in. It's a
|
||||
* repeated field (one id each, not comma-separated). Other fields the client sends
|
||||
* (`LoginLock`, `MaxPersistenceVersion`, `BypassMovementModeRestriction`) are accepted
|
||||
* and ignored.
|
||||
*/
|
||||
export const MatchmakeRoomRequest = z.object({
|
||||
JoinMode: z.string().optional().describe('"2" requests a private instance'),
|
||||
AdditionalPlayerIds: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Party members to invite into the room; repeatable and/or comma-separated'),
|
||||
.describe('Party members to invite into the room; repeated once per id'),
|
||||
})
|
||||
|
||||
/** `POST /invite` form body — invite a player into the caller's room instance. */
|
||||
|
||||
@@ -258,22 +258,6 @@ describe('public endpoints', () => {
|
||||
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
|
||||
})
|
||||
|
||||
test('POST /goto/none returns the offline dorm', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/goto/none`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { name: string; location: string; photonRoomId: string }
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
name: '^DormRoom',
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
isPrivate: true,
|
||||
})
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
|
||||
const headers = await bearer('88')
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
@@ -425,45 +409,6 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||
})
|
||||
|
||||
test('POST /matchmake/none returns the offline dorm when the player has no presence', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { name: string; location: string; isPrivate: boolean; photonRoomId: string }
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
name: '^DormRoom',
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
isPrivate: true,
|
||||
})
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
test('POST /matchmake/none preserves an existing presence (does not warp to the dorm)', async () => {
|
||||
const auth = await bearer('314')
|
||||
// Put the player in a room first (RecCenter), establishing presence.
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/2`, { method: 'POST', headers: auth })
|
||||
// matchmake/none must return that same room, not force the dorm — this is
|
||||
// what keeps a new player in the solo Orientation room.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
|
||||
method: 'POST',
|
||||
headers: auth,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { roomId: number; name: string; location: string }
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
name: '^RecCenter',
|
||||
location: RECCENTER_SCENE,
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /player/statusvisibility returns 200', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -483,80 +428,10 @@ describe('public endpoints', () => {
|
||||
})
|
||||
|
||||
describe('auth-gated endpoints', () => {
|
||||
test('POST /goto/room/:room 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /goto/room/dormroom creates and returns the player’s personal dorm', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: {
|
||||
name: string
|
||||
location: string
|
||||
isPrivate: boolean
|
||||
roomId: number
|
||||
photonRoomId: string
|
||||
}
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
// Named after the owner: `@<username>'s Dorm` (no `^` — the `@` is its prefix).
|
||||
name: "@Tester's Dorm",
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
isPrivate: true,
|
||||
})
|
||||
// The dorm gets its own unique Photon room id (isolated from other dorms).
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
// A personal dorm room was created (not the seeded template RoomId 1)…
|
||||
const roomId = body.roomInstance.roomId
|
||||
expect(roomId).toBeGreaterThan(2)
|
||||
// …owned by the player and flagged IsDorm so they can save it.
|
||||
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
||||
.bind(roomId)
|
||||
.first<{ data: string }>()
|
||||
expect(JSON.parse(row!.data)).toMatchObject({ CreatorAccountId: 42, IsDorm: true })
|
||||
})
|
||||
|
||||
test('POST /goto/room/:id resolves a real room scene from D1', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/goto/room/2`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
roomInstance: {
|
||||
roomId: number
|
||||
roomInstanceId: number
|
||||
isPrivate: boolean
|
||||
name: string
|
||||
location: string
|
||||
photonRoomId: string
|
||||
}
|
||||
}
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
name: '^RecCenter',
|
||||
location: RECCENTER_SCENE,
|
||||
isPrivate: true,
|
||||
})
|
||||
// The instance id is the room_instance table id (high-based, so it never
|
||||
// collides with the dorm's fixed instance id of 1).
|
||||
expect(body.roomInstance.roomInstanceId).toBeGreaterThan(1)
|
||||
// Every non-dorm instance gets a fresh random Photon room id (a bare UUID).
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room reuses a public instance across players; a private one is fresh', async () => {
|
||||
test('POST /matchmake/room/:roomId reuses a public instance across players; a private one is fresh', async () => {
|
||||
const matchmake = async (sub: string, joinMode?: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/2`, {
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
@@ -590,7 +465,7 @@ describe('auth-gated endpoints', () => {
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room 401s without a token', async () => {
|
||||
test('POST /matchmake/dorm 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
@@ -633,8 +508,8 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room resolves a room by name from D1', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/RecCenter`, {
|
||||
test('POST /matchmake/room/:roomId resolves a room by name from D1', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/RecCenter`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
||||
@@ -688,7 +563,7 @@ describe('auth-gated endpoints', () => {
|
||||
expect(hb.roomInstance).toEqual(mm.roomInstance)
|
||||
})
|
||||
|
||||
test('heartbeat merges posted status fields into stored presence', async () => {
|
||||
test('heartbeat ignores posted status fields — stored presence is returned unchanged', async () => {
|
||||
const headers = await bearer('8')
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
const hb = (await (
|
||||
@@ -703,37 +578,86 @@ describe('auth-gated endpoints', () => {
|
||||
appVersion: string
|
||||
isOnline: boolean
|
||||
}
|
||||
// Posted fields are NOT merged — the stored dorm presence (its defaults) is returned.
|
||||
expect(hb).toMatchObject({
|
||||
statusVisibility: 2,
|
||||
platform: 5,
|
||||
appVersion: '20210129',
|
||||
statusVisibility: 0,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
isOnline: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('heartbeat pushes a PresenceHeartbeatResponse over the websocket', async () => {
|
||||
test('heartbeat pushes no websocket frame', async () => {
|
||||
// The notify DO is stubbed to record every send (see vitest.config).
|
||||
type Sent = { playerId: number; notificationType: number; data: Record<string, unknown> }
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
|
||||
const headers = await bearer('9600')
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
// Clear whatever the matchmake fan-out recorded so we observe only the heartbeat.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusVisibility: 2 }),
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
|
||||
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
// Exactly one frame: PresenceHeartbeatResponse (4) to the beating player, whose
|
||||
// payload is the very presence the HTTP body carried.
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0].playerId).toBe(9600)
|
||||
expect(sent[0].notificationType).toBe(4) // NotificationType.PresenceHeartbeatResponse
|
||||
expect(sent[0].data).toEqual(body)
|
||||
// The heartbeat no longer echoes itself back over the websocket.
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('login records the LoginLock; a superseded heartbeat gets an empty body', async () => {
|
||||
const headers = await bearer('8100')
|
||||
// Enter a room so there's live presence, then record this session's lock at login.
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
await exports.default.fetch(`${ORIGIN}/player/login`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=session-one',
|
||||
})
|
||||
|
||||
// A heartbeat carrying the recorded lock gets the presence back.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=session-one',
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
expect(((await ok.json()) as { isOnline: boolean }).isOnline).toBe(true)
|
||||
|
||||
// A heartbeat from a superseded session (different lock) gets nothing.
|
||||
const stale = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=session-two',
|
||||
})
|
||||
expect(stale.status).toBe(200)
|
||||
expect(await stale.text()).toBe('')
|
||||
})
|
||||
|
||||
test('login with no live presence seeds a lobby row carrying the lock', async () => {
|
||||
const headers = await bearer('8200')
|
||||
await exports.default.fetch(`${ORIGIN}/player/login`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=lobby-lock',
|
||||
})
|
||||
// Online in the lobby (no room), and a mismatched heartbeat is rejected on the lock
|
||||
// recorded at login even though no matchmake ever ran.
|
||||
const hb = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=lobby-lock',
|
||||
})
|
||||
expect(((await hb.json()) as { isOnline: boolean; roomInstance: unknown }).isOnline).toBe(true)
|
||||
|
||||
const stale = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=other',
|
||||
})
|
||||
expect(await stale.text()).toBe('')
|
||||
})
|
||||
|
||||
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
|
||||
@@ -822,7 +746,7 @@ describe('auth-gated endpoints', () => {
|
||||
// Matchmake into a room, returning the resulting instance id.
|
||||
const matchmakeInto = async (room: string, sub: string): Promise<number> => {
|
||||
const res = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/${room}`, {
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/${room}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
@@ -1296,9 +1220,12 @@ describe('auth-gated endpoints', () => {
|
||||
RoomId: 2,
|
||||
})
|
||||
|
||||
// Multiple ids (comma-separated), de-duplicated, and the leader themselves is skipped.
|
||||
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
|
||||
// themselves is skipped.
|
||||
await reset()
|
||||
await matchmake('AdditionalPlayerIds=153,154,153,9850&JoinMode=0')
|
||||
await matchmake(
|
||||
'AdditionalPlayerIds=153&AdditionalPlayerIds=154&AdditionalPlayerIds=153&AdditionalPlayerIds=9850&JoinMode=0'
|
||||
)
|
||||
const many = await sent()
|
||||
expect(many.map((i) => i.playerId).sort((a, b) => a - b)).toEqual([153, 154])
|
||||
|
||||
@@ -1333,15 +1260,12 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /room/{roomId}/instances',
|
||||
'GET /rooms/requiring/developer',
|
||||
'GET /rooms/requiring/rrplus',
|
||||
'POST /goto/none',
|
||||
'POST /goto/room/{room}',
|
||||
'POST /invite',
|
||||
'POST /matchmake/club/{clubId}',
|
||||
'POST /matchmake/none',
|
||||
'POST /matchmake/dorm',
|
||||
'POST /matchmake/player/{playerId}',
|
||||
'POST /matchmake/room/{roomId}',
|
||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||
'POST /matchmake/{room}',
|
||||
'POST /player/exclusivelogin',
|
||||
'POST /player/heartbeat',
|
||||
'POST /player/login',
|
||||
|
||||
@@ -59,6 +59,13 @@ export interface PresenceInput<TRoomInstance = unknown> {
|
||||
vrMovementMode: number
|
||||
platform: number
|
||||
appVersion: string
|
||||
/**
|
||||
* The session's `LoginLock` GUID, bound from the form body at matchmake time. The
|
||||
* heartbeat posts it back purely to verify it still owns the session — a heartbeat
|
||||
* carrying a different lock is a superseded session and is rejected. Absent until a
|
||||
* matchmake supplies one.
|
||||
*/
|
||||
loginLock?: string
|
||||
}
|
||||
|
||||
/** A stored presence row — the input plus its absolute expiry (epoch seconds). */
|
||||
|
||||
Reference in New Issue
Block a user