From f9ffbcd374656082cd3980cc6412610d27af6c26 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 24 Aug 2026 23:58:01 -0400 Subject: [PATCH] [match] tachyon support endpoint --- apps/match/src/match.app.ts | 59 +++++++++++++++++- apps/match/src/openapi.ts | 12 ++++ apps/match/src/test/integration/api.test.ts | 66 +++++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index f7a4251..f313753 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -61,6 +61,7 @@ import { ExclusiveLoginResponse, form, InProgressRequest, + InstanceIdResponse, InviteRequest, InviteResponse, JoinModeRequest, @@ -612,7 +613,6 @@ function nextLiveMessageId(): number { return Date.now() } - /** * Deliver a game invite from `fromId` to `toId` for a room instance — a `MessageReceived` * frame carrying a game-invite `Message` the client renders the join prompt from. `data` @@ -662,6 +662,13 @@ async function sendGameInvite( */ const ORIENTATION_INSTANCE_ID = -2 +/** + * What `/tachyon` answers for a player who is not in an instance. A stored + * `room_instance` id is never 0 — the table is AUTOINCREMENT from 1 — so the sentinel can't + * be mistaken for a real instance, and the body stays a number the way the caller reads it. + */ +const NO_INSTANCE = 0 + /** * Instance-relevant fields pulled from a stored room (scene, name, capacity, …). * The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location @@ -1389,6 +1396,56 @@ const app = new Hono() } ) + // Which room instance a player is in, as a bare number (`/tachyon?id=123`). The whole + // presence blob is what `/player` serves; this answers the one field out of it, for a + // caller that only needs to know where someone is. + // + // UNGATED, unlike the rest of this worker's presence surface: it takes the player id from + // the query rather than from a token, so anyone can ask about anyone. What it discloses + // is an instance id and nothing else — no room, no name, no status — and `/player` is + // already ungated on the same rows. + // + // 0 for "not in an instance": the body is a number, so absence has to be one too, and a + // real instance id is never 0 (the `room_instance` table is AUTOINCREMENT from 1). The + // synthetic negatives ARE real answers and pass through — -2 is the Orientation seed. + .get( + '/tachyon', + describeRoute({ + tags: ['Presence'], + summary: 'The room instance a player is in', + description: [ + 'The `roomInstanceId` from a player’s live presence (`?id=123`), as a BARE NUMBER —', + 'the whole body is the id, not an object around it. The single field out of what', + '`/player` serves whole.', + '', + '0 means not in an instance: no live presence, an expired row, or no usable `id`. A', + 'real instance id is never 0, so the sentinel can’t collide with one. Synthetic ids', + 'pass through as they stand — -2 is the Orientation presence the `auth` worker seeds', + 'a brand-new player with.', + '', + 'Ungated: the player is named by the query rather than by a token, so anyone may ask', + 'about anyone. It discloses an instance id and nothing else.', + ].join(' '), + parameters: [ + { + name: 'id', + in: 'query', + required: false, + description: 'The account to look up. Absent or unparseable answers 0', + schema: { type: 'integer' }, + }, + ], + responses: { 200: json(InstanceIdResponse, 'The instance id, or 0') }, + }), + async (c) => { + const playerId = Number.parseInt(c.req.query('id') ?? '', 10) + if (!Number.isInteger(playerId)) return c.json(NO_INSTANCE) + + const presence = await getPresence(c.env.DB, playerId) + return c.json(presence?.roomInstance?.roomInstanceId ?? NO_INSTANCE) + } + ) + .get( '/player', describeRoute({ diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index c5f1abe..49fa848 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -401,3 +401,15 @@ export const InviteResponse = z.object({ .nullable() .describe('The room the invite points at; null when the room instance didn’t resolve'), }) + +/** + * `GET /tachyon?id=…` — the room instance a player is in, as a BARE NUMBER: the whole body + * is the id, with no object around it. + * + * 0 means "not in one" — no live presence for that account, an expired row, or no `id` + * given. Presence rows carry synthetic ids too, which are passed through as they stand: + * -2 is the Orientation seed the `auth` worker writes for a brand-new player. + */ +export const InstanceIdResponse = z + .int() + .describe('The player’s room instance id, or 0 when they are not in one') diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index c7cf9b7..1985d85 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -332,6 +332,71 @@ describe('public endpoints', () => { ]) }) + 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 }> @@ -2654,6 +2719,7 @@ describe('auth-gated endpoints', () => { 'GET /room/{roomId}/instances', 'GET /rooms/requiring/developer', 'GET /rooms/requiring/rrplus', + 'GET /tachyon', 'POST /invite', 'POST /matchmake/club/{clubId}', 'POST /matchmake/dorm',