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) // 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) => 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 { 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> { 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) => 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>(`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 => (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 => { 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 = {} ): Promise => { 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) 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) // 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 }).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 }).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 => { 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 } // 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; 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 } 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 => { 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 => { 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 => { 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 => (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 => 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 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 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 => (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 => (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 | 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 | null } } const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') const sent = async (): Promise => (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 => 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 => (await (await hub().fetch('http://do/all')).json()) as Invite[] const matchmake = async (body: string, sub = '9850'): Promise => 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 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> } 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) // 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) }) 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 = {}) => { 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 } }) })