Files
recflare/apps/match/src/test/integration/api.test.ts
T

4133 lines
161 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
adminSecretsStore,
createExecutionContext,
createScheduledController,
env,
waitOnExecutionContext,
} from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
countPlayersInInstance,
createRoomInstance,
EMPTY_INSTANCE_GRACE_SECONDS,
GAME_VERSION,
getRoomInstance,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
setPresence,
STAT_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
import {
banFromReport,
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { scheduled } from '../../match.app'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
/** What a matchmake answers with when the request named no `CorrelationId`. */
const EMPTY_CORRELATION_ID = '00000000-0000-0000-0000-000000000000'
/**
* A refused matchmake, whole: the code under both names the client reads (`result` and
* the legacy `errorCode`, always equal), a null instance, and the correlation echo — an
* empty GUID here, since these requests carry no `CorrelationId`. A refusal has to
* correlate too, or the client goes on waiting for a response it never matches up.
*/
function refused(code: number) {
return {
errorCode: code,
result: code,
roomInstance: null,
correlationId: EMPTY_CORRELATION_ID,
}
}
// Matchmaking into a room resolves its real scene from the shared recflare D1.
// Seed the schema + a couple of rooms (matching the rooms worker's migration).
const RECCENTER_SCENE = 'cbad71af-0831-44d8-b8ef-69edafa841f6'
const SECOND_SUBROOM_SCENE = '3f0f6cd0-5c9f-42b2-9c07-2a5a2a1c9f11'
const TEST_ROOMS = [
{
RoomId: 1,
Name: 'DormRoom',
IsDorm: true,
Accessibility: 2,
SubRooms: [{ SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163' }],
},
{
RoomId: 2,
Name: 'RecCenter',
IsDorm: false,
Accessibility: 1,
SubRooms: [{ SubRoomId: 2, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 12 }],
},
{
RoomId: 3,
Name: 'TestersRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 42,
// Account 43 is a co-owner (Role 30) — it may view the room's instances too.
Roles: [{ AccountId: 43, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
},
{
// UNPUBLISHED (Private): admits its creator, its role holders and its invitees only.
RoomId: 6,
Name: 'UnpublishedRoom',
IsDorm: false,
Accessibility: 0,
CreatorAccountId: 42,
Roles: [
{ AccountId: 42, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
// A host — the lowest tier, and still somebody working on the room.
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
// Invited to a role but holding none yet: not admitted by the role.
{ AccountId: 46, Role: 0, LastChangedByAccountId: 42, InvitedRole: 30 },
],
SubRooms: [{ SubRoomId: 6, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
},
{
// Unlisted: published, just not listed — anyone holding the id may enter.
RoomId: 7,
Name: 'UnlistedRoom',
IsDorm: false,
Accessibility: 2,
CreatorAccountId: 42,
SubRooms: [{ SubRoomId: 7, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
},
{
// A single-seat room so one player fills its instance (fullness tests).
RoomId: 5,
Name: 'SoloRoom',
IsDorm: false,
Accessibility: 1,
SubRooms: [{ SubRoomId: 5, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 1 }],
},
{
// Two subrooms (separate scenes) — matchmaking into one must not land you in
// the other.
RoomId: 77,
Name: 'MultiRoom',
IsDorm: false,
Accessibility: 1,
SubRooms: [
{ SubRoomId: 34, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 },
{ SubRoomId: 35, UnitySceneId: SECOND_SUBROOM_SCENE, MaxPlayers: 6 },
],
},
]
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The rooms worker's schema (room + interaction) — reading a room aggregates its
// cheer/favorite Stats from `interaction`, so both tables have to be here.
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Subrooms live in their own table now; seed each room and split its subrooms into it.
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Room invites (owned by this worker) — POST /invite mints a row per invite.
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Stats (owned by this worker) — the presence cron samples the online count into it.
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
await env.DB.batch([
insertAccount.bind(JSON.stringify({ accountId: 42, username: 'Tester' })),
insertAccount.bind(JSON.stringify({ accountId: 43, username: 'Roomie' })),
])
// Club tables (owned by the clubs worker) — matchmake/club reads the clubhouse
// room and the caller's membership from them.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club (
data TEXT NOT NULL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL
)`
).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club_member (
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
club_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
membership_type INTEGER NOT NULL DEFAULT 0,
created_at TEXT
)`
).run()
const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)')
await env.DB.batch([
// Club 4 has room 2 as its clubhouse; club 5 has none set. Both public and ordinary
// (`ClubType` 0), which is what the clubhouse search lists.
insertClub.bind(
JSON.stringify({
ClubId: 4,
Name: 'Clubbers',
ClubhouseRoomId: 2,
Visibility: 1,
ClubType: 0,
})
),
insertClub.bind(
JSON.stringify({
ClubId: 5,
Name: 'Homeless',
ClubhouseRoomId: null,
Visibility: 1,
ClubType: 0,
})
),
])
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([
insertMember.bind(4, 120, 100), // creator
insertMember.bind(4, 121, 10), // member
insertMember.bind(4, 122, 1), // pending request — not a member yet
insertMember.bind(4, 123, -1), // banned
insertMember.bind(5, 120, 100),
])
// Player-event tables (owned by the api worker) — matchmake/event reads the event
// for its room and the caller's invite row for access.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
const insertEvent = env.DB.prepare('INSERT OR IGNORE INTO event (data) VALUES (?1)')
const event = (id: number, accessibility: number, extra?: Record<string, unknown>) =>
JSON.stringify({
PlayerEventId: id,
CreatorPlayerId: 300,
ImageName: null,
RoomId: 2,
SubRoomId: null,
ClubId: null,
Name: `Event ${id}`,
Description: '',
StartTime: '2020-11-29T22:00:00Z',
EndTime: '2020-11-29T23:00:00Z',
AttendeeCount: 1,
State: 0,
Accessibility: accessibility,
IsMultiInstance: false,
SupportMultiInstanceRoomChat: false,
DefaultBroadcastPermissions: 0,
CanRequestBroadcastPermissions: 0,
...extra,
})
await env.DB.batch([
insertEvent.bind(event(8, 0)), // private
insertEvent.bind(event(9, 1)), // public
insertEvent.bind(event(10, 2)), // unlisted — listings only, still joinable
// A private one in the two-subroom room, pinning the SECOND subroom.
insertEvent.bind(event(11, 0, { RoomId: 77, SubRoomId: 35 })),
])
const insertAttendee = env.DB.prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, '2020-11-29T21:00:00Z')`
)
await env.DB.batch([
insertAttendee.bind(8, 300, 0), // the creator, Going from create
insertAttendee.bind(8, 301, 0), // invited
insertAttendee.bind(8, 302, 2), // invited, but declined — still allowed in
insertAttendee.bind(11, 301, 0),
])
// Relationship table (owned by the api worker) — matchmake reads it to push a
// presence update to the player's friends. Seed friendships for player 9700.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS relationship (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relationship_type INTEGER NOT NULL DEFAULT 0
)`
).run()
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([
insertRel.bind(9700, 9701, 3), // friends (9700 requested) — friend is the target
insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester
insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend
])
// Report table (owned by the api worker) — an account-wide ban is a report row with
// `banned` set, and every matchmake is refused for a player who has one.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links (owned by the auth worker) — a ban also reaches the
// accounts sharing a proven identity with the banned one.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
* Ban a player account-wide the way a moderator would: file a report against them and
* convert it. `banExpires` null is a permanent ban.
*/
async function banAccount(playerId: number, banExpires: string | null = null): Promise<void> {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
await banFromReport(env.DB, row.id, { banExpires })
}
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
// match worker's validation accepts it. Kept inline to avoid a cross-package
// import.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// `version` mints the `rn.ver` claim auth stamps from the client's posted `ver`; left
// off, the token carries none — which is what a token issued before the claim carried the
// client's own build looks like to presence.
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600, ...(version && { 'rn.ver': version }) })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
describe('public endpoints', () => {
test('POST /player/login returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST' })
expect(res.status).toBe(200)
})
test('POST /player/exclusivelogin returns { errorCode: 0 }', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ errorCode: 0 })
})
test('POST /player/notifydisconnect returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/notifydisconnect`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'PlayerId=155&RoomInstanceId=1000001',
})
expect(res.status).toBe(200)
})
test('GET /player?id=N synthesizes a player payload for that id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player?id=99`)
expect(res.status).toBe(200)
// The full presence shape the client deserializes — including the connection
// fields, which only ever carry values in a matchmaking response.
expect(await res.json()).toEqual([
{
appVersion: GAME_VERSION,
deviceClass: 0,
errorCode: 0,
isOnline: false,
playerId: 99,
roomInstance: null,
statusVisibility: 0,
vrMovementMode: 1,
platform: 0,
photonAuthToken: null,
photonRealtimeAppId: null,
photonVoiceAppId: null,
photonChatAppId: null,
photonRegion: null,
photonRoomId: null,
voiceConnectionInfo: null,
voiceServerId: null,
experiments: null,
},
])
})
test('GET /clubhousesearch/mostactivenow lists clubhouses with players in them', async () => {
// Ungated, like /tachyon — no bearer token anywhere in this test.
const busiest = async () => {
const res = await exports.default.fetch(`${ORIGIN}/clubhousesearch/mostactivenow`)
expect(res.status).toBe(200)
return (await res.json()) as Array<{ RoomId: number; ClubId: number; PlayerCount: number }>
}
// Nobody is in club 4's clubhouse (room 2) yet, and an empty clubhouse is absent
// rather than listed at zero — so a quiet server answers [].
expect(await busiest()).toEqual([])
const at = (accountId: number, roomId: number | null, ttl = 900) =>
JSON.stringify({
accountId,
roomInstance: roomId === null ? null : { roomInstanceId: 1000000 + accountId, roomId },
expiresAt: Math.floor(Date.now() / 1000) + ttl,
})
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
await env.DB.batch([
seed.bind(at(7001, 2)),
seed.bind(at(7002, 2)),
// Room 3 is nobody's clubhouse, and a lobby presence is in no room at all: neither
// can put a club in the list.
seed.bind(at(7003, 3)),
seed.bind(at(7004, null)),
])
expect(await busiest()).toEqual([{ RoomId: 2, ClubId: 4, PlayerCount: 2 }])
// Expired presence is nobody standing there.
await env.DB.prepare(
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
WHERE account_id BETWEEN 7001 AND 7004`
)
.bind(Math.floor(Date.now() / 1000) - 60)
.run()
expect(await busiest()).toEqual([])
await env.DB.prepare('DELETE FROM presence WHERE account_id BETWEEN 7001 AND 7004').run()
})
test('GET /tachyon?id=N answers the bare instance id the player is in', async () => {
// Ungated — no bearer token anywhere in this test. The player is named by the query.
const tachyon = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/tachyon${query}`)
expect(res.status).toBe(200)
return res.json()
}
// Nobody has presence for 4242, so they are in nothing. 0 rather than null: the body
// is a number, and a real instance id is never 0.
expect(await tachyon('?id=4242')).toBe(0)
// Put someone in a room the ordinary way, and the id is the one the matchmake handed
// them — the same field `/player` serves inside the whole presence blob.
const matchmake = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: { ...(await bearer('4243')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
const { roomInstance } = (await matchmake.json()) as {
roomInstance: { roomInstanceId: number } | null
}
expect(roomInstance).not.toBeNull()
expect(await tachyon('?id=4243')).toBe(roomInstance!.roomInstanceId)
// A synthetic instance id is a real answer and passes through — -2 is the Orientation
// presence `auth` seeds a new player with.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 4244,
roomInstance: { roomInstanceId: -2 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
expect(await tachyon('?id=4244')).toBe(-2)
// An expired row is not presence, so its player is in nothing.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 4245,
roomInstance: { roomInstanceId: 987 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) - 60,
})
)
.run()
expect(await tachyon('?id=4245')).toBe(0)
// No id, and an unparseable one, answer the same 0 rather than erroring.
expect(await tachyon('')).toBe(0)
expect(await tachyon('?id=notanumber')).toBe(0)
})
test('GET /player?id=&id= returns one payload per id, in order', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player?id=1070&id=1380`)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
expect(players.map((p) => p.playerId)).toEqual([1070, 1380])
// Neither has presence → both offline.
expect(players.every((p) => p.isOnline === false)).toBe(true)
})
test('POST /player reads the ids from a form body', async () => {
// The 2023 client asks about its friends list as a POST — the ids are in a
// form-urlencoded body, not the query string. GET-only left it a 404.
const res = await exports.default.fetch(`${ORIGIN}/player`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'id=1070&id=1380&id=1070',
})
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
// One entry per id, deduped, in request order.
expect(players.map((p) => p.playerId)).toEqual([1070, 1380])
})
test('POST /player answers more ids than D1 will bind at once', async () => {
// D1 caps a statement at 100 bound parameters and the expiry check takes one of
// them, so a real friends list overruns an unchunked `IN (…)` and 500s.
const ids = Array.from({ length: 250 }, (_, i) => 4000 + i)
const res = await exports.default.fetch(`${ORIGIN}/player`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: ids.map((id) => `id=${id}`).join('&'),
})
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number }>
expect(players.map((p) => p.playerId)).toEqual(ids)
})
test('POST /player without an id returns the default payload', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player`, { method: 'POST' })
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
})
test('GET /player without an id returns the default payload', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player`)
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
})
// The "avoid juniors" preference lives in the playersettings KV map, not in presence.
// The body is a BARE boolean — the client reads the whole body as the value.
describe('GET /player/avoidjuniors', () => {
const settings = async (playerId: number, map: Record<string, string>) =>
env.RECFLARE_PLAYER_SETTINGS.put(`player:${playerId}`, JSON.stringify(map))
const read = async (playerId: number) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
headers: await bearer(String(playerId)),
})
expect(res.status).toBe(200)
return res.json()
}
test('reads the stored setting', async () => {
await settings(3100, { avoidJuniors: 'True', 'Recroom.OOBE': '77' })
expect(await read(3100)).toBe(true)
await settings(3101, { avoidJuniors: 'False' })
expect(await read(3101)).toBe(false)
})
test('the key match ignores casing and separators', async () => {
await settings(3102, { AVOID_JUNIORS: '1' })
expect(await read(3102)).toBe(true)
await settings(3103, { avoidjuniors: 'yes' })
expect(await read(3103)).toBe(true)
})
// A player who never touched the setting, and one whose value is junk, both read
// false — the read gates matchmaking, so it must not fail closed.
test('defaults to false when unset or unparseable', async () => {
expect(await read(3104)).toBe(false)
await settings(3105, { 'Recroom.OOBE': '77' })
expect(await read(3105)).toBe(false)
await settings(3106, { avoidJuniors: 'maybe' })
expect(await read(3106)).toBe(false)
})
test('is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`)
expect(res.status).toBe(401)
})
})
describe('PUT /player/avoidjuniors', () => {
const stored = async (playerId: number) =>
env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`player:${playerId}`, 'json')
const write = async (playerId: number, body: string) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
method: 'PUT',
headers: {
...(await bearer(String(playerId))),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
expect(res.status).toBe(200)
return res.json()
}
const read = async (playerId: number) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
headers: await bearer(String(playerId)),
})
return res.json()
}
// The body the client posts. The response is the resulting value, and the GET agrees.
test('stores the posted preference and answers it', async () => {
expect(await write(3200, 'avoidJuniors=True')).toBe(true)
expect(await read(3200)).toBe(true)
expect(await write(3200, 'avoidJuniors=False')).toBe(false)
expect(await read(3200)).toBe(false)
})
// The map holds every setting the player has, so the write must not replace it.
test('merges into the player’s other settings', async () => {
await env.RECFLARE_PLAYER_SETTINGS.put(
'player:3201',
JSON.stringify({ 'Recroom.OOBE': '77', TUTORIAL_COMPLETE_MASK: '11' })
)
await write(3201, 'avoidJuniors=True')
expect(await stored(3201)).toEqual({
'Recroom.OOBE': '77',
TUTORIAL_COMPLETE_MASK: '11',
avoidJuniors: 'True',
})
})
// Whichever spelling the player's map already carries is the one overwritten —
// two keys for one preference would make the read depend on their order.
test('overwrites an existing key rather than adding a second one', async () => {
await env.RECFLARE_PLAYER_SETTINGS.put(
'player:3202',
JSON.stringify({ AVOID_JUNIORS: 'True' })
)
expect(await write(3202, 'avoidJuniors=False')).toBe(false)
expect(await stored(3202)).toEqual({ AVOID_JUNIORS: 'False' })
})
// Re-posting the stored value writes nothing to KV — writes are the cost, and the
// client posts this freely. The raw value is seeded with whitespace JSON.stringify
// never produces; surviving the PUT untouched means no write happened. Holds across
// the loose key match too: `AVOID_JUNIORS: 'True'` already says what `avoidJuniors=True`
// asks for.
test('does not write KV when the value is already stored', async () => {
const padded = '{ "Recroom.OOBE": "77", "AVOID_JUNIORS": "True" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:3205', padded)
expect(await write(3205, 'avoidJuniors=True')).toBe(true)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:3205', 'text')).toBe(padded)
expect(await write(3205, 'avoidJuniors=False')).toBe(false)
expect(await stored(3205)).toEqual({ 'Recroom.OOBE': '77', AVOID_JUNIORS: 'False' })
})
test('accepts a JSON body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
method: 'PUT',
headers: {
...(await bearer('3203')),
'Content-Type': 'application/json',
},
body: JSON.stringify({ avoidJuniors: true }),
})
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
expect(await read(3203)).toBe(true)
})
// An unreadable body leaves the stored setting alone and answers it — a no-op 200,
// not a 400 and not a write of `false`.
test('a body with no readable value is a no-op', async () => {
await write(3204, 'avoidJuniors=True')
expect(await write(3204, 'avoidJuniors=maybe')).toBe(true)
expect(await write(3204, '')).toBe(true)
expect(await stored(3204)).toEqual({ avoidJuniors: 'True' })
})
test('is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'avoidJuniors=True',
})
expect(res.status).toBe(401)
})
})
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`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomId: number; location: string; isPrivate: boolean; name: string }
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
location: RECCENTER_SCENE,
isPrivate: true,
})
})
test('a matchmake counts a visit against the room', async () => {
const visits = async (roomId: number): Promise<number> =>
(await env.DB.prepare('SELECT visits FROM room WHERE room_id = ?1')
.bind(roomId)
.first<{ visits: number }>())!.visits
const enter = async (path: string, player: string) => {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(await bearer(player)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
expect(res.status).toBe(200)
}
// Counted per matchmake, whichever route got the player there — the two-segment
// room form and the subroom form both land in room 77.
const before = await visits(77)
await enter('/matchmake/room/77', '94')
expect(await visits(77)).toBe(before + 1)
await enter('/matchmake/room/77/35', '95')
expect(await visits(77)).toBe(before + 2)
// Same player entering again is another visit (VisitCount is visits, not visitors),
// and it's the entered room that's counted — not every room.
const otherBefore = await visits(2)
await enter('/matchmake/room/77', '94')
expect(await visits(77)).toBe(before + 3)
expect(await visits(2)).toBe(otherBefore)
// A refused matchmake counts nothing: an unknown room has no row to bump.
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
method: 'POST',
headers: await bearer('96'),
})
expect(((await res.json()) as { errorCode: number }).errorCode).toBe(20)
})
test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => {
// A screen player (deviceClass 2, recorded by auth at login) matchmaking with no
// live presence: without the account fallback they'd enter the room as deviceClass
// 0 (VR) until their next heartbeat, and everyone in the room would see that.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 55, username: 'Screenie', deviceClass: 2, platform: 0 }))
.run()
const headers = await bearer('55')
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
expect(res.status).toBe(200)
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(55)
.first<{ data: string }>()
const presence = JSON.parse(row!.data) as { deviceClass: number }
expect(presence.deviceClass).toBe(2)
})
test('POST /matchmake/room/:roomId/:subRoomId enters that subroom', async () => {
type Instance = {
roomId: number
subRoomId: number
location: string
maxCapacity: number
roomInstanceId: number
}
const matchmake = async (path: string, sub: string): Promise<Instance> => {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
// The client's real body: JoinMode 0 (public) plus flags we ignore.
body: 'BypassMovementModeRestriction=True&MaxPersistenceVersion=41&JoinMode=0&ClientJoinData=%7B%22WelcomeMatName%22%3A%22%22%7D&AdditionalPlayersAutoFollow=False',
})
expect(res.status).toBe(200)
const body = (await res.json()) as { errorCode: number; roomInstance: Instance }
expect(body.errorCode).toBe(0)
return body.roomInstance
}
// Subroom 35 → that subroom's own scene and capacity, not the first subroom's.
const second = await matchmake('/matchmake/room/77/35', '90')
expect(second).toMatchObject({
roomId: 77,
subRoomId: 35,
location: SECOND_SUBROOM_SCENE,
maxCapacity: 6,
})
// A second player asking for the same subroom joins the same instance...
const alsoSecond = await matchmake('/matchmake/room/77/35', '91')
expect(alsoSecond.roomInstanceId).toBe(second.roomInstanceId)
// ...but the other subroom is a separate place, with its own instance + scene.
const first = await matchmake('/matchmake/room/77/34', '92')
expect(first.roomInstanceId).not.toBe(second.roomInstanceId)
expect(first).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE, maxCapacity: 10 })
// An unknown subroom falls back to the room's first (its default entrance).
const unknown = await matchmake('/matchmake/room/77/999', '93')
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
})
test('the /matchmake/v2 routes take a JSON body and answer the PascalCase envelope', async () => {
// The newer client posts JSON with real types (JoinMode a number, AdditionalPlayerIds
// null when alone) and reads back `ErrorCode`/`CorrelationId`/`RoomInstance`.
type V2Instance = {
RoomInstanceId: number
RoomId: number
SubRoomId: number
Location: string
MaxCapacity: number
IsPrivate: boolean
RoomInstanceType: number
MatchmakingPolicy: number
}
type V2Body = { ErrorCode: number; CorrelationId: string; RoomInstance: V2Instance | null }
const correlationId = 'e3f1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b'
const matchmake = async (
path: string,
player: string,
body: Record<string, unknown> = {}
): Promise<V2Body> => {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(player)), 'Content-Type': 'application/json' },
// The client's real body, verbatim.
body: JSON.stringify({
AdditionalPlayerIds: null,
BypassMovementModeRestriction: false,
MaxPersistenceVersion: 12,
Ugc1SubVersion: 0,
Ugc2SubVersion: 0,
VoiceServerVersion: '1.0',
LoginLock: EMPTY_CORRELATION_ID,
ClientJoinData: null,
CorrelationId: correlationId,
JoinMode: 0,
InviteMode: 0,
ShouldKeepPlayerWithParty: true,
PlayerScores: null,
...body,
}),
})
expect(res.status).toBe(200)
return (await res.json()) as V2Body
}
const room = await matchmake('/matchmake/v2/room/2', '8901')
expect(room.ErrorCode).toBe(0)
// The JSON body's CorrelationId is read and echoed — a form-only body read would
// have lost it here and the client would never match the response to its attempt.
expect(room.CorrelationId).toBe(correlationId)
expect(room.RoomInstance).toMatchObject({
RoomId: 2,
Location: RECCENTER_SCENE,
IsPrivate: false,
MatchmakingPolicy: 0,
})
// The v2 instance is a strict field set: no camelCase twins, no DataBlob, no Photon
// coordinates (the reference server sends none).
expect(Object.keys(room.RoomInstance!).sort()).toEqual(
[
'ClubId',
'EncryptVoiceChat',
'EventId',
'IsFull',
'IsInProgress',
'IsPrivate',
'Location',
'MaxCapacity',
'MatchmakingPolicy',
'Name',
'RoomCode',
'RoomId',
'RoomInstanceId',
'RoomInstanceType',
'SubRoomId',
].sort()
)
// ...and the envelope has no `result`/`roomInstance` camelCase twins either.
expect(Object.keys(room).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance'])
// By name, and the subroom form carries the subroom through.
expect((await matchmake('/matchmake/v2/room/RecCenter', '8902')).RoomInstance).toMatchObject({
RoomId: 2,
})
expect((await matchmake('/matchmake/v2/room/77/35', '8903')).RoomInstance).toMatchObject({
RoomId: 77,
SubRoomId: 35,
Location: SECOND_SUBROOM_SCENE,
})
// JoinMode is a NUMBER here: 2 still means a private instance.
const priv = await matchmake('/matchmake/v2/room/2', '8905', { JoinMode: 2 })
expect(priv.RoomInstance).toMatchObject({ IsPrivate: true })
// A v2 and a v1 player asking for the same public room land in the SAME instance —
// only the wire shape differs. Its own room, so the two players it seats don't count
// against another test's capacity.
await seedRoomWithSubRooms(env.DB, {
RoomId: 79,
Name: 'V2Room',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 8907,
SubRooms: [{ SubRoomId: 37, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 }],
} as unknown as Record<string, unknown>)
const v1 = await exports.default.fetch(`${ORIGIN}/matchmake/room/79`, {
method: 'POST',
headers: {
...(await bearer('8906')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '0' }).toString(),
})
const v1Instance = (
(await v1.json()) as { roomInstance: { roomInstanceId: number; roomId: number } }
).roomInstance
expect(v1Instance.roomId).toBe(79)
const v2 = await matchmake('/matchmake/v2/room/79', '8907')
expect(v2.RoomInstance!.RoomInstanceId).toBe(v1Instance.roomInstanceId)
// Refusals answer in v2 too, correlation id echoed — including the ban gate, which
// answers before any route runs.
const unknown = await matchmake('/matchmake/v2/room/99999', '8904')
expect(unknown).toEqual({ ErrorCode: 20, CorrelationId: correlationId, RoomInstance: null })
// Unauthenticated is a 401, not a matchmake refusal.
const anon = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, { method: 'POST' })
expect(anon.status).toBe(401)
})
test('matchmaking serves the PUBLISHED save to everyone, creator included', async () => {
// The client offers the owner "latest or published" itself, from the
// `/subrooms/{id}/saves` list — matchmaking never picks. Serving a staged blob to
// the creator here would put them on a different version to everyone else in the
// same instance.
const room = {
RoomId: 78,
Name: 'StagedRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 400,
Roles: [{ AccountId: 401, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
SubRooms: [
{
SubRoomId: 36,
UnitySceneId: RECCENTER_SCENE,
MaxPlayers: 10,
// Seeded as the published save (seedRoomWithSubRooms mirrors the backfill).
CurrentSave: { DataBlob: 'published.room' },
},
],
}
await seedRoomWithSubRooms(env.DB, room as unknown as Record<string, unknown>)
// Stage a newer save the creator hasn't published.
const staged = await env.DB.prepare(
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
)
.bind(36, JSON.stringify({ DataBlob: 'staged.room' }))
.first<{ sub_room_data_save_id: number }>()
await env.DB.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
.bind(36, staged!.sub_room_data_save_id)
.run()
const matchmake = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/78/36`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
expect(res.status).toBe(200)
return ((await res.json()) as { roomInstance: { dataBlob: string } }).roomInstance
}
// Creator, co-owner and ordinary player all land on the same published version —
// having a newer staged save changes nothing here.
expect((await matchmake('400')).dataBlob).toBe('published.room')
expect((await matchmake('401')).dataBlob).toBe('published.room')
expect((await matchmake('402')).dataBlob).toBe('published.room')
})
test('POST /matchmake/club/:clubId places members into the clubhouse', async () => {
const matchmake = async (path: string, sub?: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
type Body = {
errorCode: number
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
}
// A member lands in an instance of the club's clubhouse (room 2)...
const res = await matchmake('/matchmake/club/4', '121')
expect(res.status).toBe(200)
const body = (await res.json()) as Body
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
// ...and it's recorded as their presence, like any other matchmake.
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(121)
.first<{ data: string }>()
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
expect(presence.roomInstance.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// The creator is a member too, and joins the same public instance.
const creator = (await (await matchmake('/matchmake/club/4', '120')).json()) as Body
expect(creator.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// Everyone who isn't a member is turned away with the same answer: a non-member,
// a pending request, a banned account, a club with no clubhouse, an unknown club.
for (const [path, sub] of [
['/matchmake/club/4', '199'],
['/matchmake/club/4', '122'],
['/matchmake/club/4', '123'],
['/matchmake/club/5', '120'],
['/matchmake/club/9999', '120'],
] as const) {
expect(await (await matchmake(path, sub)).json()).toEqual(refused(20))
}
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/club/4')).status).toBe(401)
})
test('POST /matchmake/event/:eventId gates a private event on the invite list', async () => {
const matchmake = async (path: string, sub?: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
type Body = {
errorCode: number
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
}
const join = async (path: string, sub?: string) =>
(await (await matchmake(path, sub)).json()) as Body
// An invited player lands in an instance of the event's room (2)...
const invited = await join('/matchmake/event/8', '301')
expect(invited.errorCode).toBe(0)
expect(invited.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
// ...recorded as their presence, like any other matchmake.
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(301)
.first<{ data: string }>()
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
expect(presence.roomInstance.roomInstanceId).toBe(invited.roomInstance!.roomInstanceId)
// The creator gets in, and so does someone who was invited and DECLINED — the row
// is the invite, whatever the answer.
expect((await join('/matchmake/event/8', '300')).errorCode).toBe(0)
expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0)
// A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20.
expect(await join('/matchmake/event/8', '399')).toEqual(refused(35))
// Public and unlisted are open to anyone: unlisted only keeps an event out of the
// listings, it doesn't close it.
expect((await join('/matchmake/event/9', '399')).errorCode).toBe(0)
expect((await join('/matchmake/event/10', '399')).errorCode).toBe(0)
// An unknown event is the opaque NoSuchRoom, so ids can't be probed.
expect(await join('/matchmake/event/9999', '399')).toEqual(refused(20))
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/event/9')).status).toBe(401)
})
test('POST /matchmake/event/:eventId enters the subroom the event pins', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/event/11`, {
method: 'POST',
headers: { ...(await bearer('301')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'JoinMode=0',
})
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomId: number; subRoomId: number; location: string } | null
}
// Room 77's SECOND subroom (35), not its first — the event pins the scene.
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
roomId: 77,
subRoomId: 35,
location: SECOND_SUBROOM_SCENE,
})
})
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
method: 'POST',
headers: await bearer('88'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(refused(20))
})
test('ROOM_REDIRECTS switches a matchmake out to another room', async () => {
// `env` is shared by every test in this file, so restore the knob in `finally`.
const original = env.ROOM_REDIRECTS
// Substitution only applies to builds newer than 20230414, so matchmake as the 2025 one
// unless the case says otherwise.
const matchmake = async (path: string, player: string, version = '20250718.01') =>
(await (
await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(await bearer(player, version)),
'Content-Type': 'application/x-www-form-urlencoded',
},
// Private, so each call gets a fresh instance of whatever room it landed in.
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
).json()) as {
errorCode: number
roomInstance: { roomId: number; subRoomId: number; location: string; name: string } | null
}
try {
env.ROOM_REDIRECTS = '2=MultiRoom'
// The room asked for is never entered; the substitute is, scene and all.
expect((await matchmake('/matchmake/room/2', '8801')).roomInstance).toMatchObject({
roomId: 77,
name: '^MultiRoom',
location: RECCENTER_SCENE,
})
// Matched on the resolved room, not the path segment, so the name spelling of the
// same room is substituted too.
expect((await matchmake('/matchmake/room/RecCenter', '8802')).roomInstance).toMatchObject({
roomId: 77,
})
// The requested subroom is dropped — 35 is a subroom of the substitute, not of the
// room asked for — so entry falls back to the substitute's default subroom (34).
expect((await matchmake('/matchmake/room/2/35', '8803')).roomInstance).toMatchObject({
roomId: 77,
subRoomId: 34,
location: RECCENTER_SCENE,
})
// Club 4's clubhouse is room 2, and it resolves through the same path: a
// substituted room is substituted wherever a matchmake names it.
expect((await matchmake('/matchmake/club/4', '121')).roomInstance).toMatchObject({
roomId: 77,
})
// The 2023 build and anything older is never substituted, nor is a token naming no
// build (it matchmakes as GAME_VERSION): they enter the room they asked for.
for (const [player, version] of [
['8808', '20230414'],
['8809', '20230414.01'],
['8810', '20221117'],
]) {
expect((await matchmake('/matchmake/room/2', player, version)).roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
})
}
expect((await matchmake('/matchmake/room/2', '8811', '')).roomInstance).toMatchObject({
roomId: 2,
})
// Targeting by id works the same, and substitution is a single hop: 2 and 77
// swap rather than bouncing between each other.
env.ROOM_REDIRECTS = '2=77,77=2'
expect((await matchmake('/matchmake/room/2', '8804')).roomInstance).toMatchObject({
roomId: 77,
})
expect((await matchmake('/matchmake/room/77', '8805')).roomInstance).toMatchObject({
roomId: 2,
})
// A target that doesn't resolve leaves the requested room in place — a typo'd
// knob must not make the room unreachable.
env.ROOM_REDIRECTS = '2=NoSuchRoomHere'
expect((await matchmake('/matchmake/room/2', '8806')).roomInstance).toMatchObject({
roomId: 2,
})
// Unset: everyone enters the room they asked for.
env.ROOM_REDIRECTS = undefined
expect((await matchmake('/matchmake/room/2', '8807')).roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
})
} finally {
env.ROOM_REDIRECTS = original
}
})
test('PUT /player/statusvisibility returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
expect(res.status).toBe(200)
})
test('GET /player/connection-info 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`)
expect(res.status).toBe(401)
})
test('GET /player/qos returns the probe targets', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/qos`)
expect(res.status).toBe(200)
// A bare array, not the { success, value, error } envelope connection-info uses.
expect(await res.json()).toEqual([
{ id: 'us-west1', address: '34.169.254.144:50000' },
{ id: 'europe-west1', address: '35.205.141.119:50000' },
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
{ id: 'us-east1', address: '34.73.244.122:50000' },
{ id: 'us-central1', address: '34.69.179.51:50000' },
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
])
})
test('PUT /player/photonregionpings returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
expect(res.status).toBe(200)
})
test('POST /roominstance/:id/reportjoinresult returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/roominstance/5/reportjoinresult`, {
method: 'POST',
})
expect(res.status).toBe(200)
})
})
describe('auth-gated endpoints', () => {
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/room/2`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: joinMode ? new URLSearchParams({ JoinMode: joinMode }).toString() : undefined,
})
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
// Two *different* players matchmaking into the same room share the reused
// instance (population grouping). Distinct accounts here, since re-matchmaking as
// the *same* player deliberately moves them to a fresh instance — see below.
const a = await matchmake('900')
const b = await matchmake('901')
expect(a.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
expect(b.roomInstance.photonRoomId).toBe(a.roomInstance.photonRoomId)
expect(b.roomInstance.roomInstanceId).toBe(a.roomInstance.roomInstanceId)
// A private matchmake (JoinMode 2) gets its own distinct instance — and so does the
// next one: a PUBLISHED room may have any number of private instances. Only an
// unpublished room collapses them into one shared session (see the accessibility
// tests below).
const priv = await matchmake('902', '2')
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
const priv2 = await matchmake('903', '2')
expect(priv2.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
expect(priv2.roomInstance.photonRoomId).not.toBe(priv.roomInstance.photonRoomId)
expect(priv2.roomInstance.roomInstanceId).not.toBe(priv.roomInstance.roomInstanceId)
})
test('POST /matchmake/room/:roomId only pools players on the same client build', async () => {
const matchmake = async (sub: string, version: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
// Two players on one build share an instance, exactly as before — the build scoping
// groups players, it doesn't stop grouping them.
const oldA = await matchmake('910', '20250424.01')
const oldB = await matchmake('911', '20250424.01')
expect(oldB.roomInstance.roomInstanceId).toBe(oldA.roomInstance.roomInstanceId)
// A player on a different build asking for the same room gets their own instance:
// the live one is running a version of the room their client can't render, so it
// isn't somebody to join.
const next = await matchmake('912', '20250718.01')
expect(next.roomInstance.roomInstanceId).not.toBe(oldA.roomInstance.roomInstanceId)
expect(next.roomInstance.photonRoomId).not.toBe(oldA.roomInstance.photonRoomId)
// ...and they pool with their own build in turn.
const nextB = await matchmake('913', '20250718.01')
expect(nextB.roomInstance.roomInstanceId).toBe(next.roomInstance.roomInstanceId)
})
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('960'),
})
).json()) as { roomInstance: { photonRoomId: string } }
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('960'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: true,
value: {
// A signed JWT, not an opaque id — three base64url segments.
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
// Empty until the operator names their own Photon apps — this server ships none,
// so there is no id to hand out.
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
// Matches the region every room instance is stamped with. This one DOES have a
// default: an instance stamped with an empty region can't be connected to.
photonRegion: 'us',
// The room the client is told to join has to be the one matchmaking placed
// them in, or they end up alone in a room of their own.
photonRoomId: matchmaked.roomInstance.photonRoomId,
// No TACHYON_HOST_PORT pool configured, so there is no server to name. Empty
// strings, not nulls — unlike the presence payload's connection fields, which
// stay null (they never carry credentials).
voiceConnectionInfo: '',
voiceServerId: '',
experiments: {
networkTransformSyncInterval: 10,
shouldUseUnreliableOnChange: false,
shouldAvoidDiscontinuityRPCs: true,
shouldAvoidRedundantDiscontinuity: false,
r2RuntimeStaticBaking: true,
r2AutoEmbodiment: true,
r2RuntimeStaticBakingMinShapeThreshold: 1,
r2UseCheapReplicas: true,
// true would send the client to a local game server instead of Photon.
shouldUseGameServerNetworking: false,
},
},
error: null,
})
})
test('the Photon apps and region come from the operator’s vars', async () => {
// Unset, every value is the shipped default — asserted by the test above. Set, the
// vars win, and the region has to reach BOTH the connection info and the instance:
// the client authenticates against the app named here and connects to the region on
// its instance, so a mismatch is a session it can't join.
const original = {
realtime: env.PHOTON_REALTIME_APP_ID,
voice: env.PHOTON_VOICE_APP_ID,
chat: env.PHOTON_CHAT_APP_ID,
region: env.PHOTON_REGION,
}
try {
env.PHOTON_REALTIME_APP_ID = '11111111-1111-4111-8111-111111111111'
env.PHOTON_VOICE_APP_ID = '22222222-2222-4222-8222-222222222222'
env.PHOTON_CHAT_APP_ID = '33333333-3333-4333-8333-333333333333'
env.PHOTON_REGION = 'eu'
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('961'),
})
).json()) as { roomInstance: { photonRegion: string; photonRegionId: string } }
expect(matchmaked.roomInstance).toMatchObject({ photonRegion: 'eu', photonRegionId: 'eu' })
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
expect((await res.json()) as { value: Record<string, unknown> }).toMatchObject({
value: {
photonRealtimeAppId: '11111111-1111-4111-8111-111111111111',
photonVoiceAppId: '22222222-2222-4222-8222-222222222222',
photonChatAppId: '33333333-3333-4333-8333-333333333333',
photonRegion: 'eu',
},
})
// A whitespace-only var is not a value: the app id reads as unset (empty) rather
// than as a blank-but-present id, and the region falls back to its default.
env.PHOTON_REALTIME_APP_ID = ' '
env.PHOTON_REGION = ' '
const blank = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
expect((await blank.json()) as { value: Record<string, unknown> }).toMatchObject({
value: { photonRealtimeAppId: '', photonRegion: 'us' },
})
} finally {
env.PHOTON_REALTIME_APP_ID = original.realtime
env.PHOTON_VOICE_APP_ID = original.voice
env.PHOTON_CHAT_APP_ID = original.chat
env.PHOTON_REGION = original.region
}
})
test('GET /player/connection-info mints a token carrying the caller’s id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
const body = (await res.json()) as {
value: { photonAuthToken: string; photonRealtimeAppId: string }
}
const claims = JSON.parse(atob(body.value.photonAuthToken.split('.')[1]!)) as {
sub: string
aud: string
exp: number
'rn.env': string
}
expect(claims.sub).toBe('961')
// Scoped to the realtime app the same response hands out. Asserted as agreement
// rather than a pinned literal: PHOTON_APPS is hardcoded until it moves to wrangler
// vars, and a token minted for a different app than the client is handed is the bug
// worth catching here.
expect(claims.aud).toBe(body.value.photonRealtimeAppId)
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
// The client is built against prod regardless of which environment we run in.
expect(claims['rn.env']).toBe('prod')
})
test('GET /player/connection-info falls back to ?roomInstanceId when presence has no room', async () => {
// Player 962 never matchmade, so there's no presence to read the room from; the
// param names the instance they're trying to connect to.
const instance = await createRoomInstance(env.DB, {
roomId: 2,
subRoomId: 2,
roomInstanceType: 0,
photonRoomId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
maxCapacity: 12,
isPrivate: false,
ownerAccountId: 962,
})
const res = await exports.default.fetch(
`${ORIGIN}/player/connection-info?roomInstanceId=${instance.roomInstanceId}`,
{ headers: await bearer('962') }
)
expect(res.status).toBe(200)
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
})
test('GET /player/connection-info serves an empty photonRoomId when nothing resolves', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('963'),
})
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('')
})
test('the Tachyon server is assigned per room instance, not per request', async () => {
// tachyon-1/-2 and tachyon-4/-5 are slots on one host apiece: a server id names a
// slot, which is why it's generated from the entry's position and not from its
// address. The blank entry and the stray spaces below are dropped — a list edited
// by hand shouldn't hand anyone an empty address.
const pool = [
{ voiceConnectionInfo: '198.51.100.10:7777', voiceServerId: 'tachyon-1' },
{ voiceConnectionInfo: '198.51.100.10:7778', voiceServerId: 'tachyon-2' },
{ voiceConnectionInfo: '198.51.100.11:7777', voiceServerId: 'tachyon-3' },
{ voiceConnectionInfo: '203.0.113.20:7777', voiceServerId: 'tachyon-4' },
{ voiceConnectionInfo: '203.0.113.20:7778', voiceServerId: 'tachyon-5' },
]
const original = env.TACHYON_HOST_PORT
try {
env.TACHYON_HOST_PORT =
'198.51.100.10:7777, 198.51.100.10:7778 ,,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778'
const voiceFor = async (player: string, roomInstanceId?: number) => {
const res = await exports.default.fetch(
roomInstanceId === undefined
? `${ORIGIN}/player/connection-info`
: `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
{ headers: await bearer(player) }
)
const body = (await res.json()) as {
value: { voiceConnectionInfo: string; voiceServerId: string }
}
return {
voiceConnectionInfo: body.value.voiceConnectionInfo,
voiceServerId: body.value.voiceServerId,
}
}
// The player who opened the session reads their server off their presence...
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 970,
roomId: 2,
photonRoomId: 'tachyon-instance-a',
maxCapacity: 12,
})
await setPresence(env.DB, {
accountId: 970,
roomInstance: instance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
const assigned = pool[instance.roomInstanceId % pool.length]!
expect(await voiceFor('970')).toEqual(assigned)
// ...and a joiner asking by instance id, before their own presence has landed,
// is sent to the same one. Two players in a session on two servers is the whole
// failure this is arranged to avoid.
expect(await voiceFor('971', instance.roomInstanceId)).toEqual(assigned)
// The next session opened goes to the next server along — instance ids are
// sequential, so the pool is walked round-robin as instances are created.
const next = await createRoomInstance(env.DB, {
ownerAccountId: 972,
roomId: 2,
photonRoomId: 'tachyon-instance-b',
maxCapacity: 12,
})
expect(next.roomInstanceId).toBe(instance.roomInstanceId + 1)
const alongside = await voiceFor('972', next.roomInstanceId)
expect(alongside).toEqual(pool[next.roomInstanceId % pool.length])
expect(alongside.voiceServerId).not.toBe(assigned.voiceServerId)
// A player in no instance gets no server, pool or no pool — there is nothing for
// them to be on the same server as, and the fields stay empty strings.
expect(await voiceFor('973')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' })
} finally {
env.TACHYON_HOST_PORT = original
}
})
test('a sandbox account is served out of the sandbox Tachyon pool', async () => {
// Sandbox accounts run against their own Tachyon servers, so the whole pool is swapped
// for them; selection inside it is the same instance-id round-robin as the live pool.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 974, username: 'Sandboxer', isSandbox: true }))
.run()
const original = env.TACHYON_HOST_PORT
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
try {
env.TACHYON_HOST_PORT = '198.51.100.12:7777'
env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778'
const voiceFor = async (player: string, roomInstanceId?: number) => {
const res = await exports.default.fetch(
roomInstanceId === undefined
? `${ORIGIN}/player/connection-info`
: `${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
{ headers: await bearer(player) }
)
const body = (await res.json()) as {
value: { voiceConnectionInfo: string; voiceServerId: string }
}
return {
voiceConnectionInfo: body.value.voiceConnectionInfo,
voiceServerId: body.value.voiceServerId,
}
}
const sandboxPool = [
{ voiceConnectionInfo: '203.0.113.30:7777', voiceServerId: 'dev-1' },
{ voiceConnectionInfo: '203.0.113.30:7778', voiceServerId: 'dev-2' },
]
for (const photonRoomId of ['tachyon-sandbox-a', 'tachyon-sandbox-b']) {
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 974,
roomId: 2,
photonRoomId,
maxCapacity: 12,
})
expect(await voiceFor('974', instance.roomInstanceId)).toEqual(
sandboxPool[instance.roomInstanceId % sandboxPool.length]
)
// Everyone else in that instance keeps the live pool.
expect(await voiceFor('975', instance.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
}
// In no instance there is no server, sandbox or not.
expect(await voiceFor('974')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' })
// With no sandbox pool configured, a sandbox account falls back to the live one.
env.TACHYON_HOST_PORT_SANDBOX = undefined
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 974,
roomId: 2,
photonRoomId: 'tachyon-sandbox-unset',
maxCapacity: 12,
})
expect(await voiceFor('974', instance.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
} finally {
env.TACHYON_HOST_PORT = original
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
}
})
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
// The client keys the room transition off a changing roomInstanceId; handing back
// the instance the player is already in hangs their join. RecCenter (cap 12) so
// the instance isn't full — the naive "reuse the oldest joinable" would otherwise
// return the same id the player already has.
const first = await matchmakeInto('2', '950')
const second = await matchmakeInto('2', '950')
expect(second).not.toBe(first)
})
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)
})
test('POST /matchmake/dorm returns the same personal dorm (idempotent)', async () => {
// First entry (fresh account 43) creates the dorm; a second returns the same one.
const first = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43'),
})
).json()) as { roomInstance: { roomId: number; photonRoomId: string; roomInstanceId: number } }
expect(first.roomInstance.roomId).toBeGreaterThan(2)
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: {
name: string
location: string
isPrivate: boolean
roomId: number
photonRoomId: string
roomInstanceId: number
}
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: "@Roomie's Dorm",
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
// Same dorm room + reused instance (stable id + Photon room), not a new one.
roomId: first.roomInstance.roomId,
photonRoomId: first.roomInstance.photonRoomId,
roomInstanceId: first.roomInstance.roomInstanceId,
})
})
test('POST /matchmake/dorm gives each client build its own dorm instance', async () => {
const dorm = async (version: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43', version),
})
).json()) as {
roomInstance: { roomId: number; photonRoomId: string; roomInstanceId: number }
}
// Same dorm ROOM whichever build the owner is on — it's their one dorm...
const older = await dorm('20250424.01')
const newer = await dorm('20250718.01')
expect(newer.roomInstance.roomId).toBe(older.roomInstance.roomId)
// ...but a separate session per build. A dorm takes guests, so a mixed one is a
// room where neither side can see what the other spawned; the owner on a new build
// and a guest still on the old one are deliberately kept apart.
expect(newer.roomInstance.roomInstanceId).not.toBe(older.roomInstance.roomInstanceId)
expect(newer.roomInstance.photonRoomId).not.toBe(older.roomInstance.photonRoomId)
// Each build's instance is still the stable one it re-enters (id + Photon room),
// and coming back on the older build doesn't hand back the newer session.
expect(await dorm('20250424.01')).toMatchObject({ roomInstance: older.roomInstance })
expect(await dorm('20250718.01')).toMatchObject({ roomInstance: newer.roomInstance })
})
test('a matchmake echoes the request’s CorrelationId (and mirrors errorCode as result)', async () => {
// The client tags each attempt with a GUID and won't accept a session whose
// response doesn't carry the same one back ("Unable to connect to game session").
const correlationId = 'b71abbbb-93e1-4d67-94da-64e6f554863a'
const dorm = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: {
...(await bearer('43')),
'content-type': 'application/x-www-form-urlencoded',
},
// Verbatim from the client, unread fields included.
body: `BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=${correlationId}&MaxPersistenceVersion=227`,
})
).json()) as { errorCode: number; result: number; correlationId: string }
expect(dorm.correlationId).toBe(correlationId)
// Both names for the one code, always in agreement.
expect(dorm.result).toBe(0)
expect(dorm.errorCode).toBe(0)
// A room matchmake echoes it too, and so does a refusal — a refused attempt the
// client can't correlate is one it goes on waiting for.
const room = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/999999`, {
method: 'POST',
headers: {
...(await bearer('43')),
'content-type': 'application/x-www-form-urlencoded',
},
body: `JoinMode=0&CorrelationId=${correlationId}`,
})
).json()) as { errorCode: number; result: number; roomInstance: null; correlationId: string }
expect(room).toEqual({
errorCode: 20,
result: 20,
roomInstance: null,
correlationId,
})
})
test('a matchmake with no CorrelationId answers the empty GUID, not null', async () => {
// The client reads correlationId as a Guid, not a nullable one — an older client
// that sends none still has to get a parseable value back.
const body = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43'),
})
).json()) as { correlationId: string }
expect(body.correlationId).toBe(EMPTY_CORRELATION_ID)
})
test('POST /matchmake/none 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => {
const none = async (sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } }
// Account 44 has never entered a room → their personal dorm, and a second call is
// idempotent now that presence holds it.
const fresh = await none('44')
expect(fresh.errorCode).toBe(0)
expect(fresh.roomInstance.roomId).toBeGreaterThan(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: fresh.roomInstance.roomId,
roomInstanceId: fresh.roomInstance.roomInstanceId,
})
// Once in a real room, `none` must NOT warp them out of it — that is the whole
// point of the endpoint, since the client posts it while sitting in Orientation.
const entered = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('44'),
})
).json()) as { roomInstance: { roomId: number; roomInstanceId: number } }
expect(entered.roomInstance.roomId).toBe(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: 2,
roomInstanceId: entered.roomInstance.roomInstanceId,
})
})
test('each player’s dorm gets a distinct global subroom id', async () => {
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
// With subrooms minted from the global sequence, each dorm gets its own unique id.
const dormSubRoomId = async (sub: string): Promise<number> => {
const body = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { roomInstance: { subRoomId: number } }
return body.roomInstance.subRoomId
}
const a = await dormSubRoomId('7001')
const b = await dormSubRoomId('7002')
expect(a).not.toBe(b)
// Neither reuses the seed dorm template's SubRoomId (1).
expect(a).not.toBe(1)
expect(b).not.toBe(1)
})
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(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
roomInstance: { roomId: number; name: string; location: string; isPrivate: boolean }
}
expect(body.roomInstance).toMatchObject({
roomId: 2,
name: '^RecCenter',
location: RECCENTER_SCENE,
isPrivate: true,
})
})
test('POST /player/heartbeat 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /player/heartbeat reports no presence before matchmake', async () => {
// Fresh token (sub 7) with no stored presence → not in a room.
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...(await bearer('7')), 'Content-Type': 'application/json' },
body: JSON.stringify({ statusVisibility: 2, platform: 5 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
playerId: 7,
roomInstance: null,
isOnline: false,
})
})
test('matchmake then heartbeat replays the stored instance (in sync)', async () => {
const headers = await bearer()
const mm = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
).json()) as { roomInstance: Record<string, unknown> }
// LoginLock form heartbeat (no presence fields) still gets the stored room.
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'LoginLock=abc',
})
).json()) as { roomInstance: Record<string, unknown>; isOnline: boolean }
expect(hb.isOnline).toBe(true)
expect(hb.roomInstance).toEqual(mm.roomInstance)
})
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 (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ statusVisibility: 2, platform: 5, appVersion: '20210129' }),
})
).json()) as {
statusVisibility: number
platform: number
appVersion: string
isOnline: boolean
}
// Posted fields are NOT merged — the stored dorm presence (its defaults) is returned.
expect(hb).toMatchObject({
statusVisibility: 0,
platform: 0,
appVersion: GAME_VERSION,
isOnline: true,
})
})
// The build a player reports is the one their TOKEN carries (`rn.ver`, from the `ver`
// they posted to /connect/token) — not this server's GAME_VERSION, which is only the
// fallback for a token that names none.
test('presence reports the build from the caller’s token', async () => {
const headers = await bearer('9710', '20250718.01')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { appVersion: string }
expect(hb.appVersion).toBe('20250718.01')
// And it is what everyone else sees of them, since it was written to the row.
const [player] = (await (
await exports.default.fetch(`${ORIGIN}/player?id=9710`)
).json()) as Array<{ appVersion: string }>
expect(player.appVersion).toBe('20250718.01')
})
// A player who quit and relaunched on a new build heartbeats with a NEW token against
// the row the old session left behind; the heartbeat adopts it rather than waiting for
// a re-matchmake.
test('a heartbeat on a new build updates the stored version', async () => {
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('9711', '20250424.01'),
})
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('9711', '20250718.01'),
})
).json()) as { appVersion: string }
expect(hb.appVersion).toBe('20250718.01')
const [player] = (await (
await exports.default.fetch(`${ORIGIN}/player?id=9711`)
).json()) as Array<{ appVersion: string }>
expect(player.appVersion).toBe('20250718.01')
})
// A token issued before the claim carried the client's build still has to produce a
// usable version — an empty one breaks the client's presence handling.
test('a token with no rn.ver falls back to GAME_VERSION', async () => {
const headers = await bearer('9712')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { appVersion: string }
expect(hb.appVersion).toBe(GAME_VERSION)
})
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')
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,
})
expect(res.status).toBe(200)
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
// 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 instance and `expiresAt` (epoch
// seconds), so the TTL branches can be exercised deterministically (independent of
// timing) and a player can be planted in an instance without matchmaking there.
const seedPresenceInInstance = (id: number, roomInstanceId: number, expiresAt: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
roomInstance: { roomInstanceId, roomId: 1 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
expiresAt,
})
)
.run()
// A stand-in instance id for presence rows whose instance is beside the point. Kept
// far above what createRoomInstance hands out (ID_BASE + 1, climbing by one per
// instance) so it can never collide with a real one — a live presence row pointing at
// a real instance makes that instance look occupied, which quietly breaks whichever
// test is watching the empty-instance sweep.
const UNRELATED_INSTANCE_ID = 1_900_042
const seedPresence = (id: number, expiresAt: number) =>
seedPresenceInInstance(id, UNRELATED_INSTANCE_ID, expiresAt)
const storedExpiresAt = async (id: number): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(id)
.first<{ data: string }>()
return (JSON.parse(row!.data) as { expiresAt: number }).expiresAt
}
const nowSeconds = () => Math.floor(Date.now() / 1000)
/** Rows for an account, expired ones included — the sweep should leave none. */
const countPresenceRows = async (id: number): Promise<number> => {
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
.bind(id)
.first<{ n: number }>()
return row?.n ?? 0
}
test('heartbeat refreshes presence when its TTL is close to lapsing', async () => {
// TTL about to lapse (well inside the refresh window).
const nearExpiry = nowSeconds() + 10
await seedPresence(700, nearExpiry)
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('700'),
})
// The heartbeat re-wrote the row, pushing expiry ~PRESENCE_TTL_SECONDS ahead.
expect(await storedExpiresAt(700)).toBeGreaterThan(nearExpiry + 60)
})
test('heartbeat skips the write when nothing changed and the TTL is healthy', async () => {
// A distinctive, far-future expiry (outside the refresh window) survives
// untouched — proving the unchanged heartbeat did not re-write the row.
const healthyExpiry = nowSeconds() + 800
await seedPresence(701, healthyExpiry)
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('701'),
})
expect(await storedExpiresAt(701)).toBe(healthyExpiry)
})
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
// Three players in instance 1900099 (synthetic, like UNRELATED_INSTANCE_ID above) —
// two live, one expired.
await seedPresenceInInstance(710, 1_900_099, nowSeconds() + 800)
await seedPresenceInInstance(711, 1_900_099, nowSeconds() + 800)
await seedPresenceInInstance(712, 1_900_099, nowSeconds() - 10) // expired → not counted
expect(await countPlayersInInstance(env.DB, 1_900_099)).toBe(2)
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
})
// 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/${room}`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { roomInstance: { roomInstanceId: number } }
return res.roomInstance.roomInstanceId
}
test('matchmaking flags an instance full once it reaches capacity, and routes the next player elsewhere', async () => {
// SoloRoom (RoomId 5, MaxPlayers 1): one player fills its instance.
const first = await matchmakeInto('5', '820')
expect((await getRoomInstance(env.DB, first))?.isFull).toBe(true)
// A second player can't join the full instance — matchmaking makes a fresh one.
const second = await matchmakeInto('5', '821')
expect(second).not.toBe(first)
expect((await getRoomInstance(env.DB, second))?.isFull).toBe(true)
})
test('matchmaking leaves an instance not full below capacity', async () => {
// RecCenter (RoomId 2, MaxPlayers 12): one player does not fill it.
const instanceId = await matchmakeInto('2', '822')
expect((await getRoomInstance(env.DB, instanceId))?.isFull).toBe(false)
})
test('leaving a full instance clears its full flag', async () => {
// Fill SoloRoom, then the same player matchmakes into RecCenter — the SoloRoom
// instance they left should no longer be full.
const solo = await matchmakeInto('5', '823')
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true)
await matchmakeInto('2', '823')
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
})
test('the cron sweep purges expired presence and frees the instance those players were in', async () => {
// A player fills SoloRoom, then vanishes without matchmaking out (a crash) —
// nothing recomputes fullness, so the instance sits full with nobody in it.
const solo = await matchmakeInto('5', '824')
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true)
await env.DB.prepare(
"UPDATE presence SET data = json_set(data, '$.expiresAt', ?2) WHERE account_id = ?1"
)
.bind(824, nowSeconds() - 10)
.run()
// Driven through the module's own export rather than the `exports` proxy — a
// ScheduledController can't cross the isolate boundary the proxy serializes over.
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
// Expired row gone, and the instance is joinable again.
expect(await countPresenceRows(824)).toBe(0)
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
})
test('records an `online` stat sample of the live presence count on each run', async () => {
await env.DB.prepare('DELETE FROM stat').run()
const before = (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE expires_at > ?1')
.bind(nowSeconds())
.first<{ n: number }>())!.n
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
const rows = (
await env.DB.prepare('SELECT stat_type, value, datetime FROM stat').all<{
stat_type: string
value: number
datetime: string
}>()
).results
expect(rows).toHaveLength(1)
expect(rows[0]!.stat_type).toBe('online')
expect(rows[0]!.value).toBe(before)
// Stamped with the current time, as ISO-8601.
expect(Math.abs(Date.parse(rows[0]!.datetime) - Date.now())).toBeLessThan(10_000)
})
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
// (the generated `created_at` column follows the blob), so the empty-instance sweep
// can be exercised without waiting out the grace window.
const backdateInstance = (id: number, secondsAgo = EMPTY_INSTANCE_GRACE_SECONDS + 60) =>
env.DB.prepare(
"UPDATE room_instance SET data = json_set(data, '$.createdAt', ?2) WHERE id = ?1"
)
.bind(id, new Date(Date.now() - secondsAgo * 1000).toISOString())
.run()
const expirePresence = (accountId: number) =>
env.DB.prepare(
"UPDATE presence SET data = json_set(data, '$.expiresAt', ?2) WHERE account_id = ?1"
)
.bind(accountId, nowSeconds() - 10)
.run()
test('the cron sweep deletes instances nobody is left standing in', async () => {
// Two instances built directly rather than by matchmaking, so neither is one a
// previous test's player is still standing in (public matchmakes reuse instances).
// One holds a player who crashed out — an expired row the sweep purges first,
// leaving the instance empty — the other a live player.
const abandoned = await createRoomInstance(env.DB, {
ownerAccountId: 830,
roomId: 2,
photonRoomId: 'abandoned-instance',
maxCapacity: 12,
})
await seedPresenceInInstance(830, abandoned.roomInstanceId, nowSeconds() - 10)
const occupied = await createRoomInstance(env.DB, {
ownerAccountId: 831,
roomId: 2,
photonRoomId: 'occupied-instance',
maxCapacity: 12,
})
await seedPresenceInInstance(831, occupied.roomInstanceId, nowSeconds() + 800)
await backdateInstance(abandoned.roomInstanceId)
await backdateInstance(occupied.roomInstanceId)
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await getRoomInstance(env.DB, abandoned.roomInstanceId)).toBeNull()
expect(await getRoomInstance(env.DB, occupied.roomInstanceId)).not.toBeNull()
})
test('the cron sweep spares a freshly created instance nobody has joined yet', async () => {
// The instance and its creator's presence are written by the same request but not
// atomically — a sweep landing in between must not delete the instance the player
// is being handed. `createdAt` is left alone, so it's inside the grace window.
const fresh = await createRoomInstance(env.DB, {
ownerAccountId: 832,
roomId: 2,
photonRoomId: 'fresh-instance',
maxCapacity: 12,
})
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await getRoomInstance(env.DB, fresh.roomInstanceId)).not.toBeNull()
})
test('the cron sweep retires an empty dorm instance like any other', async () => {
// A dorm gets no exemption: once its owner is elsewhere the session is an empty
// Photon room nobody can be pointed at, exactly like a public instance everyone
// left. What persists about a dorm is the ROOM and the scene saved in it.
const headers = await bearer('833')
const enterDorm = async () =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
).json()) as {
roomInstance: { roomInstanceId: number; roomId: number; photonRoomId: string }
}
const dorm = await enterDorm()
await expirePresence(833)
await backdateInstance(dorm.roomInstance.roomInstanceId)
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await getRoomInstance(env.DB, dorm.roomInstance.roomInstanceId)).toBeNull()
// And the owner walks back into their own dorm regardless — same room, a fresh
// session of it. Freshness is read off the Photon room (a new GUID per instance)
// rather than the id: ids come from MAX(id) + 1, so retiring the newest row hands
// its number straight back to the next instance created.
const again = await enterDorm()
expect(again.roomInstance.roomId).toBe(dorm.roomInstance.roomId)
expect(again.roomInstance.photonRoomId).not.toBe(dorm.roomInstance.photonRoomId)
expect(await getRoomInstance(env.DB, again.roomInstance.roomInstanceId)).not.toBeNull()
})
test('player/login and exclusivelogin preserve presence', async () => {
const headers = await bearer('9')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
// These acks must not wipe presence — the client fires exclusivelogin when going
// online, and clearing here would bounce the player to the dorm.
await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST', headers })
await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers })
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { roomInstance: { name: string } | null; isOnline: boolean }
expect(hb.isOnline).toBe(true)
// Presence is preserved: the heartbeat replays their personal dorm. Account 9
// has no seeded username, so the name falls back to `@Player9's Dorm`.
expect(hb.roomInstance?.name).toBe("@Player9's Dorm")
})
test('player/logout clears presence and frees the instance the player was in', async () => {
// Fill SoloRoom (cap 1) so its instance is full, then log out.
const solo = await matchmakeInto('5', '960')
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true)
const headers = await bearer('960')
await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers })
// Presence is gone → the heartbeat reports offline with no room.
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { roomInstance: unknown; isOnline: boolean }
expect(hb.isOnline).toBe(false)
expect(hb.roomInstance).toBeNull()
expect(await countPresenceRows(960)).toBe(0)
// The instance they left is no longer full.
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
})
test('player/logout preserves a new player still in Orientation (account-creation bootstrap)', async () => {
// Mirror the auth worker's Orientation seed: presence pointing at instance -2.
// The client's spurious bootstrap logout must NOT wipe it, or the new player is
// bounced out of Orientation to the dorm.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 961,
roomInstance: { roomInstanceId: -2, roomId: 13, name: '^Orientation' },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: nowSeconds() + 800,
})
)
.run()
await exports.default.fetch(`${ORIGIN}/player/logout`, {
method: 'POST',
headers: await bearer('961'),
})
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('961'),
})
).json()) as { roomInstance: { roomInstanceId: number } | null; isOnline: boolean }
expect(hb.isOnline).toBe(true)
expect(hb.roomInstance?.roomInstanceId).toBe(-2)
})
test('GET /player?id reports stored presence per id', async () => {
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('55'),
})
const res = await exports.default.fetch(`${ORIGIN}/player?id=55`)
expect(res.status).toBe(200)
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
expect(players[0]).toMatchObject({ playerId: 55, isOnline: true })
})
test('GET /room/:id/instances is auth-gated, owner/co-owner-only, and lists the room’s instances', async () => {
// No token → 401.
expect((await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status).toBe(401)
// A valid token but no role on the room (room 3 is owned by account 42, with
// account 43 as co-owner) → 403.
expect(
(
await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
headers: await bearer('999'),
})
).status
).toBe(403)
// Unknown room → 404.
expect(
(
await exports.default.fetch(`${ORIGIN}/room/99999/instances`, {
headers: await bearer('42'),
})
).status
).toBe(404)
// Matchmaking into room 3 creates an instance the owner can then see.
await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
method: 'POST',
headers: await bearer('42'),
})
const res = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
headers: await bearer('42'),
})
expect(res.status).toBe(200)
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.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.
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
headers: await bearer('43'),
})
expect(coOwner.status).toBe(200)
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
})
test('POST /matchmake/instance/:id refuses an instance running another client build', async () => {
const spawn = async (sub: string, version: string) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()) as { roomInstance: { roomInstanceId: number } }
).roomInstance.roomInstanceId
const join = async (instanceId: number, version: string) =>
(
await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
method: 'POST',
headers: await bearer('42', version),
})
).json()
// The owner of room 3 (42) can't drop into a session running a build their own
// client isn't: owning the room doesn't make an older client able to render it.
// Their build is behind the instance's, so they're told to update (16) rather than
// given the opaque refusal.
const newer = await spawn('914', '20250718.01')
expect(await join(newer, '20250424.01')).toEqual(refused(16))
// The other direction has no code of its own — there's no "the people in there must
// update" — so it's the opaque NoSuchRoom every other unjoinable thing answers.
const older = await spawn('915', '20250424.01')
expect(await join(older, '20250718.01')).toEqual(refused(20))
// Same build → in they go.
const same = await spawn('916', '20250718.01')
expect(await join(same, '20250718.01')).toMatchObject({ errorCode: 0 })
})
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(refused(20))
// 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(refused(20))
// 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 () => {
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
type Sent = {
playerId: number
notificationType: number
data: {
Id: number
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
SentTime: string
RoomId: number | null
}
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const reset = () => hub().fetch('http://do/all', { method: 'DELETE' })
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
await reset()
// A live instance of room 2 to invite the target into.
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 42,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
const invite = async (body: string, sub?: string): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
// The client's exact request: player 42 invites 153 into their instance.
const res = await invite(`playerId=153&roomInstanceId=${instance.roomInstanceId}`, '42')
expect(res.status).toBe(200)
// The response is the `room_invite` row the invite just created.
const created = (await res.json()) as Record<string, unknown>
expect(created).toMatchObject({ FromPlayerId: 42, ToPlayerId: 153, RoomId: 2 })
expect(created.RoomInviteId).toBeGreaterThan(0)
// ...and the row is really there, `created_at` stamped in epoch seconds so the
// eventual expiry sweep can compare it.
const row = await env.DB.prepare(
'SELECT from_player_id, to_player_id, room_id, created_at FROM room_invite WHERE room_invite_id = ?1'
)
.bind(created.RoomInviteId)
.first<{
from_player_id: number
to_player_id: number
room_id: number | null
created_at: number
}>()
expect(row).toMatchObject({ from_player_id: 42, to_player_id: 153, room_id: 2 })
expect(row!.created_at).toBeGreaterThan(1_700_000_000)
const notes = await sent()
expect(notes).toHaveLength(1)
expect(notes[0].playerId).toBe(153) // delivered to the invitee, not the caller
expect(notes[0].notificationType).toBe(2) // NotificationType.MessageReceived
expect(notes[0].data).toMatchObject({
FromPlayerId: 42, // the caller
ToPlayerId: 153,
Type: 0, // MessageType.GameInvite
Data: String(instance.roomInstanceId), // raw roomInstanceId string
RoomId: 2, // resolved from the instance
})
expect(notes[0].data.Id).toBeGreaterThan(0)
expect(typeof notes[0].data.SentTime).toBe('string')
// A missing token is a 401 and a missing/zero/non-numeric playerId a 400 — and
// none of them push a notification.
await reset()
expect((await invite('playerId=153')).status).toBe(401)
expect((await invite('playerId=0', '42')).status).toBe(400)
expect((await invite('playerId=abc', '42')).status).toBe(400)
expect(await sent()).toHaveLength(0)
// An unknown (or absent) room instance still delivers the invite — just with a null
// RoomId (which the real hub drops from the frame).
const noRoom = await invite('playerId=153&roomInstanceId=999999', '42')
expect(noRoom.status).toBe(200)
const noRoomInvite = (await noRoom.json()) as Record<string, unknown>
expect(noRoomInvite).toMatchObject({ RoomId: null })
// Each invite gets its own id.
expect(noRoomInvite.RoomInviteId).not.toBe(created.RoomInviteId)
const after = await sent()
expect(after).toHaveLength(1)
expect(after[0].data.RoomId).toBeNull()
expect(after[0].data.Data).toBe('999999')
})
test('POST /invite picks the invite message type off the token’s build', async () => {
type Sent = { data: { Type: number; Data: string; RoomId: number | null } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 42,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
// The one frame an invite from a client on `version` pushes, with the RoomInviteId
// the call answered — a v2 invite names it in its Data.
const inviteFrom = async (version?: string): Promise<{ frame: Sent; roomInviteId: number }> => {
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('42', version)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=153&roomInstanceId=${instance.roomInstanceId}`,
})
expect(res.status).toBe(200)
const { RoomInviteId } = (await res.json()) as { RoomInviteId: number }
const notes = await sent()
expect(notes).toHaveLength(1)
return { frame: notes[0], roomInviteId: RoomInviteId }
}
const typeFor = async (version?: string): Promise<number> =>
(await inviteFrom(version)).frame.data.Type
// The cutoff build and anything newer → GameInviteV2 (6). The cutoff is INCLUSIVE, and
// the point release after the date is not part of the comparison.
expect(await typeFor('20250718.01')).toBe(6)
expect(await typeFor('20230415')).toBe(6)
expect(await typeFor('20230414')).toBe(6)
expect(await typeFor('20230414.99')).toBe(6)
// Only builds OLDER than the cutoff get the original (0) — including the day before.
expect(await typeFor('20230413')).toBe(0)
expect(await typeFor('20230413.99')).toBe(0)
expect(await typeFor('20220101')).toBe(0)
// No `rn.ver` on the token, or one that isn't a build at all: fall back to the
// original rather than move an unknown client onto v2.
expect(await typeFor()).toBe(0)
expect(await typeFor('not-a-build')).toBe(0)
// v2 carries an escaped JSON object as its Data — a STRING holding JSON, not a
// nested object — naming the instance and the invite row behind it.
const v2 = await inviteFrom('20250718.01')
expect(typeof v2.frame.data.Data).toBe('string')
expect(JSON.parse(v2.frame.data.Data)).toEqual({
InviteId: v2.roomInviteId, // the row POST /invite just answered with
Name: '^RecCenter', // the instance's `^`-prefixed wire name
InviteMode: 22, // verbatim, as observed off the client
})
// RoomId still rides on the message itself, in both versions.
expect(v2.frame.data.RoomId).toBe(2)
// v1 is unchanged: the bare roomInstanceId as a string, no JSON.
const v1 = await inviteFrom('20220101')
expect(v1.frame.data.Data).toBe(String(instance.roomInstanceId))
expect(v1.frame.data.RoomId).toBe(2)
})
test('matchmake pushes SubscriptionUpdatePresence to the player’s friends', async () => {
// The notify DO is stubbed to record every send (see vitest.config). The friend
// fan-out is a single batch call carrying the friend ids.
type Batch = {
playerIds: number[]
notificationType: number | string
data: {
playerId: number
statusVisibility: number
isOnline: boolean
appVersion: string
roomInstance: Record<string, unknown> | null
}
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// 9700 enters RecCenter (room 2, public).
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('9700'),
})
expect(res.status).toBe(200)
const mm = (await res.json()) as { roomInstance: { roomInstanceId: number } }
const sent = (await (await hub().fetch('http://do/all')).json()) as Batch[]
expect(sent).toHaveLength(1)
const batch = sent[0]
// Delivered to the two friends (in both graph directions), not the pending-request
// player (9703).
expect(batch.playerIds.slice().sort((a, b) => a - b)).toEqual([9701, 9702])
expect(batch.notificationType).toBe('PresenceUpdate') // NotificationType.SubscriptionUpdatePresence
expect(batch.data).toMatchObject({
playerId: 9700,
statusVisibility: 0, // Everyone — not hidden from friends
isOnline: true,
appVersion: GAME_VERSION, // a STRING, matching the client build
})
expect(typeof batch.data.appVersion).toBe('string')
// The redacted instance the friends see: the room just entered (read back from the
// player's stored presence). photonRoomId and dataBlob are blanked so a friend can't
// use the leaked Photon room id to join a private instance directly; photonRegion is
// dropped entirely.
expect(batch.data.roomInstance).toMatchObject({
roomId: 2,
roomInstanceId: mm.roomInstance.roomInstanceId,
photonRoomId: '', // blanked
dataBlob: '', // blanked
})
expect(batch.data.roomInstance).not.toHaveProperty('photonRegion')
expect(batch.data.roomInstance).toHaveProperty('photonRegionId')
// A player with no friends triggers no fan-out (empty list → no hub call).
await hub().fetch('http://do/all', { method: 'DELETE' })
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('9999'),
})
expect(await (await hub().fetch('http://do/all')).json()).toEqual([])
})
test('logout fires SubscriptionUpdatePresence (offline) to friends', async () => {
type Batch = {
playerIds: number[]
notificationType: number | string
data: { playerId: number; isOnline: boolean; roomInstance: Record<string, unknown> | null }
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sent = async (): Promise<Batch[]> =>
(await (await hub().fetch('http://do/all')).json()) as Batch[]
// 9900 is friends with 9901.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
.bind(9900, 9901, 3)
.run()
// 9900 enters a room (presence created), then reset the hub so we isolate the
// logout push from the entry push.
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('9900'),
})
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/player/logout`, {
method: 'POST',
headers: await bearer('9900'),
})
expect(res.status).toBe(200)
// The friend gets an offline presence snapshot: no room, isOnline false.
const batch = await sent()
expect(batch).toHaveLength(1)
expect(batch[0].playerIds).toEqual([9901])
expect(batch[0].notificationType).toBe('PresenceUpdate') // SubscriptionUpdatePresence
expect(batch[0].data).toMatchObject({ playerId: 9900, isOnline: false, roomInstance: null })
// Presence is actually cleared — a second logout (no presence) fires nothing.
await hub().fetch('http://do/all', { method: 'DELETE' })
await exports.default.fetch(`${ORIGIN}/player/logout`, {
method: 'POST',
headers: await bearer('9900'),
})
expect(await sent()).toEqual([])
// An unauthenticated logout is a no-op too.
await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST' })
expect(await sent()).toEqual([])
})
test('POST /matchmake/player/:id refuses a friend on another client build', async () => {
// 9810 is friends with 9811, 9812 with 9813 — one pair per direction of the build gap.
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([insertRel.bind(9810, 9811, 3), insertRel.bind(9812, 9813, 3)])
const enter = async (sub: string, version: string) =>
exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub, version),
})
const follow = async (targetId: number, sub: string, version: string) =>
(
await exports.default.fetch(`${ORIGIN}/matchmake/player/${targetId}`, {
method: 'POST',
headers: await bearer(sub, version),
})
).json()
// The friend is standing in a session of a newer build: following them would put
// two builds in one Photon room, so the follower is told to update (16) instead.
await enter('9811', '20250718.01')
expect(await follow(9811, '9810', '20250424.01')).toEqual(refused(16))
// Following someone on an OLDER build is refused too — opaquely, since there's no
// code for "they're the ones who need to update".
await enter('9813', '20250424.01')
expect(await follow(9813, '9812', '20250718.01')).toEqual(refused(20))
})
test('POST /matchmake/player/:id follows a friend into their room, friends only', async () => {
// 9800 is friends with 9801 (in a room) and 9803 (not in any room); 9802 is not a
// friend.
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([insertRel.bind(9800, 9801, 3), insertRel.bind(9803, 9800, 3)])
const follow = async (targetId: number, sub?: string): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/matchmake/player/${targetId}`, {
method: 'POST',
...(sub === undefined ? {} : { headers: await bearer(sub) }),
})
type Result = {
errorCode: number
roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number } | null
}
// The friend (9801) enters RecCenter → they now have a presence with an instance.
const friendMM = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('9801'),
})
).json()) as Result
// 9800 follows 9801 → placed into the SAME instance, with the real (un-redacted)
// Photon room id, since they're authorized to join.
const res = await follow(9801, '9800')
expect(res.status).toBe(200)
const body = (await res.json()) as Result
expect(body.errorCode).toBe(0)
expect(body.roomInstance?.roomInstanceId).toBe(friendMM.roomInstance?.roomInstanceId)
expect(body.roomInstance?.photonRoomId).toBe(friendMM.roomInstance?.photonRoomId)
expect(body.roomInstance?.photonRoomId).not.toBe('')
// And 9800's presence now points at that instance (the heartbeat replays it).
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: { ...(await bearer('9800')), 'Content-Type': 'application/json' },
body: '{}',
})
).json()) as { roomInstance: { roomInstanceId: number } | null }
expect(hb.roomInstance?.roomInstanceId).toBe(friendMM.roomInstance?.roomInstanceId)
// A non-friend can't be followed → NoSuchRoom, null instance (no leak of their state).
expect(await (await follow(9802, '9800')).json()).toEqual(refused(20))
// You can't follow yourself.
expect(await (await follow(9800, '9800')).json()).toEqual(refused(20))
// A friend who isn't in any room → nothing to join.
expect(await (await follow(9803, '9800')).json()).toEqual(refused(20))
// No token → 401.
expect((await follow(9801)).status).toBe(401)
// A ban on the room blocks the follow too: this path hands out a Photon room id
// without going through resolveRoomInstance, so it carries its own ban check —
// otherwise following a friend in would be a way around a ban.
await env.DB.prepare(
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
).run()
try {
expect(await (await follow(9801, '9800')).json()).toEqual(refused(55))
} finally {
await env.DB.prepare(
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
).run()
}
})
test('POST /matchmake/room/:roomId refuses an unpublished room to anyone it does not admit', async () => {
// Every spelling of the room matchmake, in both join modes — a PRIVATE instance
// of an unpublished room is still a way into it, so JoinMode 2 is gated too.
const v1 = async (path: string, sub: string, joinMode: string) =>
(await (
await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ JoinMode: joinMode }).toString(),
})
).json()) as { errorCode: number; roomInstance: { roomId: number } | null }
const v2 = async (path: string, sub: string, joinMode: number) =>
(await (
await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ JoinMode: joinMode }),
})
).json()) as { ErrorCode: number; RoomInstance: { RoomId: number } | null }
// A stranger: RoomIsPrivate (25) — told plainly, since they hold the id already —
// with no instance, on every route and in either join mode.
for (const joinMode of ['1', '2']) {
expect(await v1('/matchmake/room/6', '9880', joinMode)).toEqual(refused(25))
expect(await v1('/matchmake/room/6/6', '9880', joinMode)).toEqual(refused(25))
}
for (const joinMode of [1, 2]) {
expect(await v2('/matchmake/v2/room/6', '9880', joinMode)).toMatchObject({
ErrorCode: 25,
RoomInstance: null,
})
expect(await v2('/matchmake/v2/room/6/6', '9880', joinMode)).toMatchObject({
ErrorCode: 25,
RoomInstance: null,
})
}
// Refused before any instance or presence exists for them.
expect(
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9880').first()
).toBeNull()
expect(
await env.DB.prepare('SELECT 1 AS hit FROM room_instance WHERE room_id = 6').first()
).toBeNull()
// The creator and a role holder (a Host, the lowest tier) get in; a pending role
// invite (Role 0) is not a role.
expect((await v1('/matchmake/room/6', '42', '2')).roomInstance?.roomId).toBe(6)
expect((await v2('/matchmake/v2/room/6', '44', 2)).RoomInstance?.RoomId).toBe(6)
expect(await v1('/matchmake/room/6', '46', '2')).toEqual(refused(25))
// A live room invite INTO THAT ROOM admits the invitee — and is not consumed, so a
// retry works. An invite to some other room is no key to this one.
await env.DB.prepare(
`INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at)
VALUES (42, 9881, 2, ?1), (42, 9880, 6, ?1)`
)
.bind(Math.floor(Date.now() / 1000))
.run()
expect(await v1('/matchmake/room/6', '9881', '2')).toEqual(refused(25))
expect((await v1('/matchmake/room/6', '9880', '2')).roomInstance?.roomId).toBe(6)
expect((await v2('/matchmake/v2/room/6', '9880', 2)).RoomInstance?.RoomId).toBe(6)
await env.DB.prepare('DELETE FROM room_invite WHERE to_player_id IN (9880, 9881)').run()
// Published rooms are untouched: Unlisted is reachable by whoever holds the id.
expect((await v1('/matchmake/room/7', '9880', '1')).roomInstance?.roomId).toBe(7)
expect((await v2('/matchmake/v2/room/2', '9880', 2)).RoomInstance?.RoomId).toBe(2)
})
test('an unpublished room has ONE private instance, shared by everyone it admits', async () => {
// Start clean: an earlier case may have left room 6 sessions and presence behind,
// and which instance a matchmake lands in is the whole point here.
await env.DB.prepare('DELETE FROM room_instance WHERE room_id = 6').run()
await env.DB.prepare('DELETE FROM presence WHERE account_id IN (42, 44, 9880, 9885)').run()
type V2 = {
ErrorCode: number
RoomInstance: { RoomInstanceId: number; RoomId: number; IsPrivate: boolean } | null
}
const v2 = async (path: string, sub: string, joinMode: number) =>
(await (
await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ JoinMode: joinMode }),
})
).json()) as V2
const stored = async (instanceId: number | undefined) =>
(
await env.DB.prepare('SELECT is_private FROM room_instance WHERE id = ?1')
.bind(instanceId ?? 0)
.first<{ is_private: number }>()
)?.is_private
// The creator walking through their own room's subroom door posts JoinMode 0 — the
// body the client was seen sending. Room 6 is Private, so the session is too: in the
// answer, and in the row every other route reads.
const own = await v2('/matchmake/v2/room/6/6', '42', 0)
expect(own.ErrorCode).toBe(0)
expect(own.RoomInstance).toMatchObject({ RoomId: 6, IsPrivate: true })
expect(await stored(own.RoomInstance?.RoomInstanceId)).toBe(1)
// The 2023 spelling, and JoinMode 1, are the same. The creator is already standing
// in the session above, and a matchmake never hands back the instance the caller is
// in (the client hangs on an unchanged id), so this one is a FRESH private session —
// and the first now sits empty, within the sweep's grace window.
const v1 = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/6`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'JoinMode=1',
})
).json()) as { roomInstance: { roomInstanceId: number; isPrivate: boolean } | null }
expect(v1.roomInstance?.isPrivate).toBe(true)
expect(await stored(v1.roomInstance?.roomInstanceId)).toBe(1)
expect(v1.roomInstance?.roomInstanceId).not.toBe(own.RoomInstance?.RoomInstanceId)
// A role holder joins the creator's session: an unpublished room has ONE private
// instance its people share, so a Host asking for a public session is placed WITH
// the creator — in the instance they're standing in, not the empty one they left,
// and never in a private instance of their own. Nothing public exists to reuse.
const host = await v2('/matchmake/v2/room/6', '44', 0)
expect(host.RoomInstance?.IsPrivate).toBe(true)
expect(host.RoomInstance?.RoomInstanceId).toBe(v1.roomInstance?.roomInstanceId)
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_instance WHERE room_id = 6 AND is_private = 0'
).first<{ n: number }>()
).toEqual({ n: 0 })
// JoinMode 2 does not fork an unpublished room either: an invitee asking for a
// private session is put in the same one, in both spellings of the route.
await env.DB.prepare(
'INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at) VALUES (42, 9880, 6, ?1)'
)
.bind(Math.floor(Date.now() / 1000))
.run()
try {
const invitee = await v2('/matchmake/v2/room/6/6', '9880', 2)
expect(invitee.RoomInstance?.RoomInstanceId).toBe(v1.roomInstance?.roomInstanceId)
// Standing in it now, so the 2023 spelling moves them to the only other
// session — the one the creator abandoned — rather than back into the same id.
const again = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/6`, {
method: 'POST',
headers: {
...(await bearer('9880')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=2',
})
).json()) as { roomInstance: { roomInstanceId: number; isPrivate: boolean } | null }
expect(again.roomInstance?.isPrivate).toBe(true)
expect(again.roomInstance?.roomInstanceId).toBe(own.RoomInstance?.RoomInstanceId)
// Still exactly the two sessions: nothing was minted for anyone it admitted.
expect(
await env.DB.prepare('SELECT COUNT(*) AS n FROM room_instance WHERE room_id = 6').first<{
n: number
}>()
).toEqual({ n: 2 })
} finally {
await env.DB.prepare('DELETE FROM room_invite WHERE to_player_id = 9880').run()
await env.DB.prepare('DELETE FROM presence WHERE account_id = 9880').run()
}
// A FRIEND of the creator is not one of the room's people: neither follow gets them
// in — the 2023 route by the room's gate, the v2 route because the session is private.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 9885, 3)'
).run()
try {
const follow = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/player/42`, {
method: 'POST',
headers: await bearer('9885'),
})
).json()) as { errorCode: number; roomInstance: unknown }
expect(follow).toEqual(refused(20))
const followV2 = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/42`, {
method: 'POST',
headers: { ...(await bearer('9885')), 'Content-Type': 'application/json' },
body: '{}',
})
).json()) as V2
expect(followV2.ErrorCode).not.toBe(0)
expect(followV2.RoomInstance).toBeNull()
expect(
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9885').first()
).toBeNull()
// One of the room's own people who is also a friend may follow: the room admits
// them itself. Here the creator steps out to a public room, then follows the Host
// back into the shared session.
await env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (42, 44, 3)'
).run()
expect((await v2('/matchmake/v2/room/2', '42', 0)).ErrorCode).toBe(0)
const ownFollow = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/player/44`, {
method: 'POST',
headers: await bearer('42'),
})
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
expect(ownFollow.errorCode).toBe(0)
expect(ownFollow.roomInstance?.roomInstanceId).toBe(host.RoomInstance?.RoomInstanceId)
} finally {
await env.DB.prepare(
'DELETE FROM relationship WHERE requester_id = 42 AND target_id IN (9885, 44)'
).run()
}
// A published room still hands out a public session for the same request.
const open = await v2('/matchmake/v2/room/2', '9885', 0)
expect(open.RoomInstance?.IsPrivate).toBe(false)
})
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
const matchmake = async (sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
// Not banned yet → a normal join.
expect((await matchmake('9700')).errorCode).toBe(0)
await env.DB.prepare(
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
VALUES (2, 9701, 0, 1, '2026-01-01T00:00:00.000Z')`
).run()
// The ban is the whole enforcement: no instance means no Photon room id, so there
// is nothing for the banned player to join. errorCode 55 rather than the opaque
// NoSuchRoom every other refusal answers — a banned player already knows the room
// exists, so the client can say why. Applies to the subroom path as well.
expect(await matchmake('9701')).toEqual(refused(55))
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
method: 'POST',
headers: await bearer('9701'),
})
expect(await sub.json()).toEqual(refused(55))
// Refused before any instance is created, and no presence was recorded for them.
expect(
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9701').first()
).toBeNull()
// The ban is per-room — another room is unaffected.
const other = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, {
method: 'POST',
headers: await bearer('9701'),
})
).json()) as { errorCode: number }
expect(other.errorCode).toBe(0)
// Lifting the ban lets them in again.
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9701').run()
expect((await matchmake('9701')).errorCode).toBe(0)
})
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
// Party invites go out as game invites over notifyPlayer (see vitest stub). 9850 has
// no friends, so the only recorded sends are the party invites (no presence fan-out).
type Invite = {
playerId: number
notificationType: number
data: {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
RoomId: number | null
}
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const reset = () => hub().fetch('http://do/all', { method: 'DELETE' })
const sent = async (): Promise<Invite[]> =>
(await (await hub().fetch('http://do/all')).json()) as Invite[]
const matchmake = async (body: string, sub = '9850'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
// The client's exact request shape (the extra fields are accepted and ignored), one
// party member.
await reset()
const res = await matchmake(
'BypassMovementModeRestriction=False&LoginLock=abc&AdditionalPlayerIds=153&MaxPersistenceVersion=51&JoinMode=0'
)
expect(res.status).toBe(200)
const instance = ((await res.json()) as { roomInstance: { roomInstanceId: number } })
.roomInstance
const invites = await sent()
expect(invites).toHaveLength(1)
expect(invites[0].playerId).toBe(153) // delivered to the party member
expect(invites[0].notificationType).toBe(2) // NotificationType.MessageReceived
expect(invites[0].data).toMatchObject({
FromPlayerId: 9850, // the party leader (caller)
ToPlayerId: 153,
Type: 0, // MessageType.GameInvite
Data: String(instance.roomInstanceId), // the instance the leader landed in
RoomId: 2,
})
// The fan-out RECORDS each invite, like POST /invite: the row is what the invitee
// redeems the frame against, so a frame without one is expired on arrival.
const row = await env.DB.prepare(
'SELECT room_invite_id, room_id FROM room_invite WHERE from_player_id = 9850 AND to_player_id = 153'
).first<{ room_invite_id: number; room_id: number }>()
expect(row).not.toBeNull()
expect(row?.room_id).toBe(2)
// Multiple ids (repeated fields, not comma-separated), de-duplicated, and the leader
// themselves is skipped.
await reset()
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])
// No AdditionalPlayerIds → nobody is invited.
await reset()
await matchmake('JoinMode=0')
expect(await sent()).toEqual([])
})
test('a v2 party matchmake mints invites the members can actually redeem', async () => {
// The reported bug: /matchmake/v2/room/:id fanned the party out as frames with no
// `room_invite` row behind them, so the member's join answered 40 (RoomInviteExpired)
// while a manual POST /invite worked.
type Sent = { playerId: number; data: { Type: number; Data: string } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
const res = await exports.default.fetch(`${ORIGIN}/matchmake/v2/room/2`, {
method: 'POST',
headers: {
...(await bearer('9860', '20250718.01')),
'Content-Type': 'application/json',
},
body: JSON.stringify({
AdditionalPlayerIds: [9861],
CorrelationId: '3c60e657-21c4-46be-815c-57ee51add506',
JoinMode: 2,
InviteMode: 20,
}),
})
expect(res.status).toBe(200)
const leader = (await res.json()) as { RoomInstance: { RoomInstanceId: number } }
// The party member's frame is a v2 invite naming a REAL row id.
const frames = (await (await hub().fetch('http://do/all')).json()) as Sent[]
const invite = frames.find((f) => f.playerId === 9861)
expect(invite?.data.Type).toBe(6) // MessageType.GameInviteV2
const { InviteId } = JSON.parse(invite?.data.Data ?? '{}') as { InviteId: number }
expect(InviteId).toBeGreaterThan(0)
// Redeeming it puts the member in the leader's instance rather than answering 40.
const joined = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${InviteId}`, {
method: 'POST',
headers: { ...(await bearer('9861', '20250718.01')) },
})
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
expect(joined.ErrorCode).toBe(0)
expect(joined.RoomInstance?.RoomInstanceId).toBe(leader.RoomInstance.RoomInstanceId)
// The by-sender redemption the newer client falls back to works off the same row.
await env.DB.prepare('DELETE FROM presence WHERE account_id = 9861').run()
const bySender = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/9860`, {
method: 'POST',
headers: { ...(await bearer('9861', '20250718.01')) },
})
).json()) as { ErrorCode: number }
expect(bySender.ErrorCode).toBe(0)
})
test('POST /matchmake/invite/:id lands the invitee in the inviter’s instance', async () => {
// 8801 invites 8802. The invite row is what POST /invite answers with.
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8801,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
const stand = async (accountId: number, roomInstance: unknown) =>
setPresence(env.DB, {
accountId,
roomInstance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await stand(8801, instance)
const invite = async (sub: string) =>
exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8802&roomInstanceId=${instance.roomInstanceId}`,
})
const accept = async (inviteId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/matchmake/invite/${inviteId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'CorrelationId=acc97dc2-be74-4722-99c3-36530491f5ff',
})
const { RoomInviteId } = (await (await invite('8801')).json()) as { RoomInviteId: number }
// Anyone who isn't the addressee is refused, the INVITER included — the row is the
// authorization, and an invite id is a small integer somebody else is holding.
for (const stranger of ['8801', '8803']) {
const res = await accept(RoomInviteId, stranger)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ ErrorCode: 76, RoomInstance: null })
}
// The addressee lands in the instance the inviter is standing in, answered in the
// PascalCase envelope with the CorrelationId echoed back.
const ok = await accept(RoomInviteId, '8802')
expect(ok.status).toBe(200)
expect(await ok.json()).toMatchObject({
ErrorCode: 0,
CorrelationId: 'acc97dc2-be74-4722-99c3-36530491f5ff',
RoomInstance: {
RoomInstanceId: instance.roomInstanceId,
RoomId: 2,
Name: '^RecCenter',
MatchmakingPolicy: 0,
},
})
// ...and it really moved them: presence now names that instance.
const presence = await exports.default.fetch(`${ORIGIN}/player?id=8802`, {
headers: await bearer('8802'),
})
expect(
((await presence.json()) as Array<{ roomInstance: { roomInstanceId: number } | null }>)[0]
?.roomInstance?.roomInstanceId
).toBe(instance.roomInstanceId)
// Standing there already is 17, not a second join.
const again = await accept(RoomInviteId, '8802')
expect(await again.json()).toMatchObject({ ErrorCode: 17, RoomInstance: null })
// An invite id that isn't there is 40 — expiry deletes rows, so "gone" and "expired"
// are one answer.
expect(await (await accept(99_999_999, '8802')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
// The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee is
// moved out of the instance first so the AlreadyIn check doesn't answer ahead of it.)
await stand(8802, null)
await env.DB.prepare('DELETE FROM presence WHERE account_id = 8801').run()
const { RoomInviteId: staleId } = (await (await invite('8801')).json()) as {
RoomInviteId: number
}
expect(await (await accept(staleId, '8802')).json()).toMatchObject({
ErrorCode: 2,
RoomInstance: null,
})
// Unauthenticated is a 401, not a refusal code.
expect(
(
await exports.default.fetch(`${ORIGIN}/matchmake/invite/${RoomInviteId}`, {
method: 'POST',
})
).status
).toBe(401)
})
test('POST /matchmake/v2/player/:id joins the inviter, invite row required', async () => {
// 8811 stands in an instance and invites 8812 (writing the room_invite row).
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8811,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
})
await setPresence(env.DB, {
accountId: 8811,
roomInstance: instance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
const join = async (targetId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'BypassMovementModeRestriction=False&LoginLock=40bacd8f-7c60-4d49-93f9-462b096602de&VoiceServerVersion=gameserver-2&CorrelationId=82c12c19-a3fc-4734-9abc-e912aeb1f351&MaxPersistenceVersion=227&PlayerIsPartyMember=False',
})
// No invite from the target yet → 40, and nothing about their state leaks.
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8811')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
})
// An invite from a DIFFERENT player doesn't authorize this target: 8813 holds no
// invite from 8811.
expect(await (await join(8811, '8813')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
// The invitee lands in the inviter's instance, in the PascalCase v2 envelope with
// the CorrelationId echoed back.
const ok = await join(8811, '8812')
expect(ok.status).toBe(200)
const okBody = (await ok.json()) as Record<string, unknown>
expect(okBody).toMatchObject({
ErrorCode: 0,
CorrelationId: '82c12c19-a3fc-4734-9abc-e912aeb1f351',
RoomInstance: {
RoomInstanceId: instance.roomInstanceId,
RoomId: 2,
Name: '^RecCenter',
MatchmakingPolicy: 0,
},
})
// The exact wire shape, confirmed against the live client: the three-key envelope
// and the 15-key v2 instance, nothing extra (no Photon coordinates, no DataBlob).
expect(Object.keys(okBody).sort()).toEqual(['CorrelationId', 'ErrorCode', 'RoomInstance'])
expect(Object.keys(okBody.RoomInstance as object).sort()).toEqual(
[
'RoomInstanceId',
'RoomId',
'SubRoomId',
'Location',
'EventId',
'ClubId',
'RoomCode',
'Name',
'MaxCapacity',
'IsFull',
'IsPrivate',
'IsInProgress',
'EncryptVoiceChat',
'RoomInstanceType',
'MatchmakingPolicy',
].sort()
)
// The invite was spent by that join: the row is gone, so the same call now reads as
// "no invite" (40) rather than authorizing a second entry off the same invite.
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8811 AND to_player_id = 8812'
).first<{ n: number }>()
).toMatchObject({ n: 0 })
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 40,
RoomInstance: null,
})
// With a fresh invite, standing there already is 17, not a second join — and a
// refusal leaves that invite standing, which the PlayerNotOnline case below redeems.
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8811')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
})
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 17,
RoomInstance: null,
})
// The inviter walking out leaves nothing to join: 2, PlayerNotOnline. (The invitee
// is moved out first so the AlreadyIn check doesn't answer ahead of it.)
await setPresence(env.DB, {
accountId: 8812,
roomInstance: null,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await env.DB.prepare('DELETE FROM presence WHERE account_id = 8811').run()
expect(await (await join(8811, '8812')).json()).toMatchObject({
ErrorCode: 2,
RoomInstance: null,
})
// Unauthenticated is a 401, not a refusal code.
expect(
(await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/8811`, { method: 'POST' })).status
).toBe(401)
})
test('POST /matchmake/v2/player/:id lets a friend join a public instance without an invite', async () => {
// 8821 stands in a PUBLIC instance; 8822 is a mutual friend (8821 requested), 8823
// has a pending request only, 8824 is a stranger.
const insertRel = env.DB.prepare(
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([insertRel.bind(8821, 8822, 3), insertRel.bind(8823, 8821, 1)])
const instance = await createRoomInstance(env.DB, {
ownerAccountId: 8821,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
isPrivate: false,
})
const stand = (accountId: number, roomInstance: typeof instance | null) =>
setPresence(env.DB, {
accountId,
roomInstance,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
})
await stand(8821, instance)
const join = async (targetId: number, sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/v2/player/${targetId}`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'CorrelationId=5d1f0a4e-9a5b-4a3e-8d2b-6f1c2e3a4b5c',
})
).json()) as { ErrorCode: number; RoomInstance: { RoomInstanceId: number } | null }
// Not a friend, no invite: 40, whichever way round the non-friendship is.
expect(await join(8821, '8824')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
expect(await join(8821, '8823')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
// A friend lands in the public instance with no invite row at all…
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821'
).first<{ n: number }>()
).toMatchObject({ n: 0 })
expect(await join(8821, '8822')).toMatchObject({
ErrorCode: 0,
RoomInstance: { RoomInstanceId: instance.roomInstanceId, IsPrivate: false },
})
// …and is standing there afterwards, so the same call now says so (17).
expect(await join(8821, '8822')).toMatchObject({ ErrorCode: 17, RoomInstance: null })
// A friend's PRIVATE instance still takes an invite: the same opaque 40 a stranger
// gets, so the code doesn't confirm where the friend is.
const closed = await createRoomInstance(env.DB, {
ownerAccountId: 8821,
roomId: 2,
subRoomId: 2,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 12,
isPrivate: true,
})
await stand(8821, closed)
await stand(8822, null)
expect(await join(8821, '8822')).toMatchObject({ ErrorCode: 40, RoomInstance: null })
// With an invite the private instance opens, and the invite (not the friendship)
// is what's spent: the row is gone after the join.
await exports.default.fetch(`${ORIGIN}/invite`, {
method: 'POST',
headers: {
...(await bearer('8821')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `playerId=8822&roomInstanceId=${closed.roomInstanceId}`,
})
expect(await join(8821, '8822')).toMatchObject({
ErrorCode: 0,
RoomInstance: { RoomInstanceId: closed.roomInstanceId, IsPrivate: true },
})
expect(
await env.DB.prepare(
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8821 AND to_player_id = 8822'
).first<{ n: number }>()
).toMatchObject({ n: 0 })
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
const spec = (await res.json()) as {
openapi: string
paths: Record<string, Record<string, { summary?: string }>>
}
expect(spec.openapi).toMatch(/^3\.1/)
// The spec route hides itself.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /clubhousesearch/mostactivenow',
'GET /player',
'GET /player/avoidjuniors',
'GET /player/connection-info',
'GET /player/qos',
'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus',
'GET /tachyon',
'POST /invite',
'POST /matchmake/club/{clubId}',
'POST /matchmake/dorm',
'POST /matchmake/event/{eventId}',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/invite/{inviteId}',
'POST /matchmake/none',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
'POST /matchmake/v2/player/{playerId}',
'POST /matchmake/v2/room/{roomId}',
'POST /matchmake/v2/room/{roomId}/{subRoomId}',
'POST /player',
'POST /player/exclusivelogin',
'POST /player/heartbeat',
'POST /player/login',
'POST /player/logout',
'POST /player/notifydisconnect',
'POST /roominstance/{id}/markprivate',
'POST /roominstance/{id}/reportjoinresult',
'PUT /player/avoidjuniors',
'PUT /player/gameserverregionpings',
'PUT /player/photonregionpings',
'PUT /player/statusvisibility',
'PUT /roominstance/{id}/inprogress',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
// An ACCOUNT ban (a `report` row with `banned` set, owned by the api worker) is not
// about any one room, so it is enforced across every matchmake rather than per route —
// see the /matchmake/* gate in match.app.ts. It answers the same BannedFromRoom (55) the
// per-room bans do, which is the code the client renders as "you are banned".
describe('account bans', () => {
const matchmake = async (path: string, player: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: await bearer(player),
})
test('every matchmake route but the load-in ones is refused for a banned account', async () => {
await banAccount(6001)
// One live instance of room 2 and one club membership, so each route would
// otherwise have somewhere to put them.
for (const path of [
'/matchmake/room/2',
'/matchmake/room/77/34',
'/matchmake/club/4',
'/matchmake/player/9701',
'/matchmake/instance/1',
]) {
const res = await matchmake(path, '6001')
expect(res.status, path).toBe(200)
expect(await res.json(), path).toEqual(refused(55))
}
})
// The client has to finish loading in to draw the block screen that tells the player they
// are banned — `auth` issues them a token for exactly that reason. Refused their own dorm
// too, they never get that far and just see a game that will not start.
test('a banned account may still matchmake into its own dorm', async () => {
await banAccount(6008)
const res = await matchmake('/matchmake/dorm', '6008')
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomInstanceId: number; roomId: number } | null
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).not.toBeNull()
// Their OWN dorm, the same one every time — not a room anyone else can reach.
const again = (await (await matchmake('/matchmake/dorm', '6008')).json()) as {
roomInstance: { roomInstanceId: number } | null
}
expect(again.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// And it is only the dorm — the ban is otherwise untouched.
expect(await (await matchmake('/matchmake/room/2', '6008')).json()).toEqual(refused(55))
})
// `/matchmake/none` is the other load-in call, and which one the client makes depends on
// the build. It normally answers the instance the caller's presence names — so for a banned
// caller it must answer the DORM instead, or a stale presence row walks them straight back
// into the public room they were standing in.
test('a banned account’s /matchmake/none answers the dorm, not their stale presence', async () => {
await banAccount(6011)
// Standing in a live instance of room 2, the way a player banned mid-session is.
const joined = (await (await matchmake('/matchmake/room/2', '9701')).json()) as {
roomInstance: { roomInstanceId: number; roomId: number }
}
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6011,
roomInstance: joined.roomInstance,
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const body = (await (await matchmake('/matchmake/none', '6011')).json()) as {
errorCode: number
roomInstance: { roomInstanceId: number; roomId: number } | null
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).not.toBeNull()
// NOT the room they were standing in.
expect(body.roomInstance?.roomInstanceId).not.toBe(joined.roomInstance.roomInstanceId)
expect(body.roomInstance?.roomId).not.toBe(joined.roomInstance.roomId)
// It is their dorm — the same instance `/matchmake/dorm` answers.
const dorm = (await (await matchmake('/matchmake/dorm', '6011')).json()) as {
roomInstance: { roomInstanceId: number } | null
}
expect(dorm.roomInstance?.roomInstanceId).toBe(body.roomInstance?.roomInstanceId)
})
// An evader signs in like anyone else (`auth` issues them a token), so they must be able to
// finish loading in — otherwise the client hangs on a failed login, which is the failure the
// dorm exception exists to fix. They are held to the dorm by the same gate.
test('an account caught by ban evasion gets the dorm and nothing else', async () => {
const linkTo = async (id: number, platformId: string) =>
env.DB.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, 0, ?2, ?3)`
)
.bind(id, platformId, new Date().toISOString())
.run()
await linkTo(6009, 'steam-dorm-evader')
await banAccount(6009)
// The replacement account: different id, same headset.
await linkTo(6010, 'steam-dorm-evader')
const dorm = (await (await matchmake('/matchmake/dorm', '6010')).json()) as {
errorCode: number
roomInstance: unknown
}
expect(dorm.errorCode).toBe(0)
expect(dorm.roomInstance).not.toBeNull()
// Every room is still closed to them — which is the ban.
expect(await (await matchmake('/matchmake/room/2', '6010')).json()).toEqual(refused(55))
expect(await (await matchmake('/matchmake/club/4', '6010')).json()).toEqual(refused(55))
})
// The refusal is the ban's, not the room's: nothing is entered, so no presence is
// written and the player stays where they were (nowhere).
test('a refused matchmake leaves no presence behind', async () => {
await banAccount(6002)
expect((await matchmake('/matchmake/room/2', '6002')).status).toBe(200)
const player = (await (
await exports.default.fetch(`${ORIGIN}/player?id=6002`, { headers: await bearer('6002') })
).json()) as Array<{ isOnline: boolean; roomInstance: unknown }>
expect(player[0]?.roomInstance ?? null).toBeNull()
})
// A timed ban lifts itself once its expiry passes — nothing clears the flag.
test('an expired ban no longer blocks a matchmake', async () => {
await banAccount(6003, '2020-01-01T00:00:00.000Z')
const res = await matchmake('/matchmake/room/2', '6003')
const body = (await res.json()) as { errorCode: number; roomInstance: unknown }
expect(body.errorCode).toBe(0)
expect(body.roomInstance).not.toBeNull()
})
test('a ban that has not expired yet blocks a matchmake', async () => {
await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString())
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual(refused(55))
})
// A report on its own is not a ban — only a moderator converting it is.
test('an unbanned report does not block a matchmake', async () => {
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6005 })
const body = (await (await matchmake('/matchmake/room/2', '6005')).json()) as {
errorCode: number
}
expect(body.errorCode).toBe(0)
})
// Filing the report doesn't touch the reporter, so they still play.
test('the reporter is not banned by the report they filed', async () => {
await banAccount(6006)
const body = (await (await matchmake('/matchmake/room/2', '1')).json()) as { errorCode: number }
expect(body.errorCode).toBe(0)
})
// The gate must not turn a missing token into "banned" — that's still a 401.
test('an unauthenticated matchmake is still a 401', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST' })
expect(res.status).toBe(401)
})
// Only the matchmakes are gated: presence and the rest of the surface keep working,
// so a banned player's client isn't left hammering a dead heartbeat.
test('the gate does not touch non-matchmake routes', async () => {
await banAccount(6007)
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('6007'),
})
expect(res.status).toBe(200)
})
})
// A 2023 client (`rn.ver` 20230414) can't load a scene saved at persistence version 227 or
// later, so any room with such a subroom refuses it with UpdateRequired. Every other build
// gets in as usual.
describe('persistence version gate for the 2023 client', () => {
const matchmake = async (roomId: number, sub: string, version?: string) => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/${roomId}`, {
method: 'POST',
headers: {
...(await bearer(sub, version)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '0' }).toString(),
})
expect(res.status).toBe(200)
return (await res.json()) as {
errorCode: number
result: number
roomInstance: { roomId: number } | null
}
}
beforeAll(async () => {
// Published scene at 227 — the first version the 2023 client can't load.
await seedRoomWithSubRooms(env.DB, {
RoomId: 7227,
Name: 'NewFormatRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 7300,
SubRooms: [
{ SubRoomId: 7227, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 },
{
SubRoomId: 7228,
UnitySceneId: SECOND_SUBROOM_SCENE,
MaxPlayers: 10,
CurrentSave: { DataBlob: 'new.room', PersistenceVersion: 227 },
},
],
} as unknown as Record<string, unknown>)
// Published at 226 — still loadable.
await seedRoomWithSubRooms(env.DB, {
RoomId: 7226,
Name: 'OldFormatRoom',
IsDorm: false,
Accessibility: 1,
CreatorAccountId: 7300,
SubRooms: [
{
SubRoomId: 7226,
UnitySceneId: RECCENTER_SCENE,
MaxPlayers: 10,
CurrentSave: { DataBlob: 'old.room', PersistenceVersion: 226 },
},
],
} as unknown as Record<string, unknown>)
})
test('the 2023 build is refused a room with a subroom at 227+', async () => {
const res = await matchmake(7227, '7301', '20230414')
expect(res.errorCode).toBe(16) // UpdateRequired
expect(res.result).toBe(16)
expect(res.roomInstance).toBeNull()
// A point release of the same build is the same client.
expect((await matchmake(7227, '7302', '20230414.02')).errorCode).toBe(16)
})
test('the 2023 build still enters a room saved below 227', async () => {
const res = await matchmake(7226, '7303', '20230414')
expect(res.errorCode).toBe(0)
expect(res.roomInstance?.roomId).toBe(7226)
})
test('newer builds and unversioned tokens are not gated', async () => {
expect((await matchmake(7227, '7304', '20250718.01')).errorCode).toBe(0)
expect((await matchmake(7227, '7305')).errorCode).toBe(0)
})
})
// The ban follows the player past the account it was written on: a new account sharing a
// proven platform identity or an IP with a banned one is refused the same way. See
// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.
describe('ban evasion at matchmake', () => {
const matchmake = async (player: string, ip?: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: { ...(await bearer(player)), ...(ip ? { 'CF-Connecting-IP': ip } : {}) },
})
).json()) as { errorCode: number; roomInstance: unknown }
/** Seed an account row carrying the IPs it signed up / last logged in from. */
const account = async (id: number, ips: Record<string, string> = {}) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, ...ips }))
.run()
}
const link = async (id: number, platform: number, platformId: string) => {
await env.DB.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(id, platform, platformId, new Date().toISOString())
.run()
}
test('a new account sharing a banned account’s platform identity is refused', async () => {
await account(6201)
await link(6201, 0, 'steam-evader')
await banAccount(6201)
// The replacement account: different id, same headset.
await account(6202)
await link(6202, 0, 'steam-evader')
expect(await matchmake('6202')).toEqual(refused(55))
})
test('a new account sharing a banned account’s signup IP is refused', async () => {
await account(6203, { signupIp: '203.0.113.203' })
await banAccount(6203)
await account(6204, { signupIp: '203.0.113.203' })
expect(await matchmake('6204')).toEqual(refused(55))
})
// The address the request arrives from counts too, so an account that has never
// logged in from the banned network before is caught on the first matchmake.
test('the request’s own IP is matched even when the account has none stored', async () => {
await account(6205, { signupIp: '203.0.113.205' })
await banAccount(6205)
await account(6206)
expect(await matchmake('6206', '203.0.113.205')).toEqual(refused(55))
// From anywhere else, that same account plays.
expect((await matchmake('6206', '198.51.100.50')).errorCode).toBe(0)
})
test('an unrelated account is unaffected', async () => {
await account(6207, { signupIp: '203.0.113.207' })
await banAccount(6207)
await account(6208, { signupIp: '198.51.100.208' })
await link(6208, 0, 'steam-innocent')
expect((await matchmake('6208')).errorCode).toBe(0)
})
// BAN_EVASION_MATCH is the operator's answer to the IP arm's false positives: the
// housemate of a banned player gets back in, the evader on the same headset does not.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
const original = env.BAN_EVASION_MATCH
await account(6210, { signupIp: '203.0.113.210' })
await link(6210, 0, 'steam-knob')
await banAccount(6210)
await account(6211, { signupIp: '203.0.113.210' }) // housemate
await account(6212)
await link(6212, 0, 'steam-knob') // same headset
try {
env.BAN_EVASION_MATCH = 'platform'
expect((await matchmake('6211')).errorCode).toBe(0)
expect(await matchmake('6212')).toEqual(refused(55))
// The banned account itself is still refused, whatever the knob says.
expect(await matchmake('6210')).toEqual(refused(55))
env.BAN_EVASION_MATCH = 'off'
expect((await matchmake('6211')).errorCode).toBe(0)
expect((await matchmake('6212')).errorCode).toBe(0)
expect(await matchmake('6210')).toEqual(refused(55))
} finally {
env.BAN_EVASION_MATCH = original
}
})
})