diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 0aa6c15..c55f146 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -229,6 +229,40 @@ function tachyonServerFor(env: Env, roomInstanceId: number): TachyonServer { return pool[roomInstanceId % pool.length] ?? NO_TACHYON_SERVER } +/** + * The Tachyon port a DEVELOPER is handed, in place of the port on the pool entry their + * instance resolved to. Developers connect to a separate Tachyon build running beside the + * live one on the same box, so only the PORT moves: the HOST stays whichever one + * {@link tachyonServerFor} picked, rather than scattering developers off the pool. + */ +const DEVELOPER_TACHYON_PORT = '7778' + +/** + * The id displayed for that dev server. Not `tachyon-N` — the generated ids are positional + * names for entries in the pool ({@link tachyonPool}), and the dev build is not one of + * them: it is a different server that happens to share a host. The id is cosmetic (the + * client displays it and never sends it anywhere), so it only has to say which server the + * player is on, and "dev" is that. + */ +const DEVELOPER_TACHYON_SERVER_ID = 'dev' + +/** + * `server` with its port replaced by {@link DEVELOPER_TACHYON_PORT} and its id by + * {@link DEVELOPER_TACHYON_SERVER_ID}. An empty address is left alone — the pool is unset or + * the caller is in no instance, so there is no server to point at another port, and writing + * a bare `:7778` would hand the client an address that answers nothing (and name a server + * that isn't there). Splits on the LAST colon so a bracketed IPv6 host survives. + */ +function developerTachyonServer(server: TachyonServer): TachyonServer { + if (server.hostPort === '') return server + const colon = server.hostPort.lastIndexOf(':') + const host = colon === -1 ? server.hostPort : server.hostPort.slice(0, colon) + return { + hostPort: `${host}:${DEVELOPER_TACHYON_PORT}`, + serverId: DEVELOPER_TACHYON_SERVER_ID, + } +} + /** * Networking feature flags the client reads off its connection info. Verbatim from * the reference server — the client changes how it replicates based on these, so they @@ -2872,7 +2906,9 @@ const app = new Hono() 'fields name the Tachyon server that instance was assigned — one entry out of the', '`TACHYON_HOST_PORT` pool, chosen by instance id so every player in a session is', 'handed the same one, with a generated `voiceServerId` (`tachyon-1`, `tachyon-2`,', - '…). Both are empty when the pool is unset or the caller is in no instance.', + '…). Both are empty when the pool is unset or the caller is in no instance. A', + 'caller holding the DEVELOPER role gets that same host on port 7778, the dev', + 'Tachyon build, with a `voiceServerId` of `dev`.', '`experiments` carries the client’s networking flags.', ].join(' '), security: AUTHED, @@ -2911,8 +2947,13 @@ const app = new Hono() photonRoomId = instance?.photonRoomId ?? '' } } - // The instance's server, the same one every other player in it is handed. - const tachyon = tachyonServerFor(c.env, roomInstanceId) + const account = await getAccount(c.env.DB, id) + // The instance's server, the same one every other player in it is handed — except + // for a DEVELOPER, who is sent to the same host on the dev port, under the id + // `dev` rather than the pool entry's positional name. + const assigned = tachyonServerFor(c.env, roomInstanceId) + const tachyon = + account?.isDeveloper === true ? developerTachyonServer(assigned) : assigned // Identifies the player to Photon. Signed with the shared JWT secret; the token's // `aud` is the realtime app it's for. Nothing verifies it while Photon is @@ -2920,7 +2961,7 @@ const app = new Hono() const photonAuthToken = await generatePhotonAuthToken( id, { - platformId: (await getAccount(c.env.DB, id))?.platformId ?? '', + platformId: account?.platformId ?? '', platform: presence?.platform ?? 0, deviceClass: presence?.deviceClass ?? 0, audience: apps.photonRealtimeAppId, @@ -2936,7 +2977,8 @@ const app = new Hono() photonRoomId, // The Tachyon server this instance runs on, picked out of the operator's // pool by {@link tachyonServerFor} — empty strings when the pool is empty - // or the caller is in no instance. Empty rather than null: the client's + // or the caller is in no instance, and on the dev port (id `dev`) for a + // developer. Empty rather than null: the client's // decoder is likelier to accept a missing-value string than a null on a // string field. The presence payload's NULL_CONNECTION_INFO keeps its // nulls — that one never carries credentials. diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index c79407d..b10086e 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -216,8 +216,9 @@ export const ConnectionExperiments = z.object({ * and `photonRoomId`, the Photon room of the instance their presence says they're in * — the same name every other player in that instance is handed. The voice fields name * the Tachyon server that instance was assigned out of the `TACHYON_HOST_PORT` pool — - * likewise the same for everyone in the session — and are empty when the pool is unset - * or the caller is in no instance. `photonRegion` matches the one stamped + * likewise the same for everyone in the session, except that a caller holding the + * DEVELOPER role is sent to that host's port 7778 as `dev` — and are empty when the pool + * is unset or the caller is in no instance. `photonRegion` matches the one stamped * on every room instance, so the two can't disagree. */ export const ConnectionInfo = z.object({ @@ -229,10 +230,14 @@ export const ConnectionInfo = z.object({ photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'), voiceConnectionInfo: z .string() - .describe('The instance’s Tachyon server, `host:port`; empty when none is configured'), + .describe( + 'The instance’s Tachyon server, `host:port` (port 7778 for a developer); empty when none is configured' + ), voiceServerId: z .string() - .describe('That server’s generated id (`tachyon-1`, …); cosmetic, empty when there is none'), + .describe( + 'That server’s generated id (`tachyon-1`, …; `dev` for a developer); cosmetic, empty when there is none' + ), experiments: ConnectionExperiments, }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 091d567..1f969e4 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1491,6 +1491,64 @@ describe('auth-gated endpoints', () => { } }) + test('a developer is sent to the same Tachyon host on the dev port', async () => { + // Developers run against a Tachyon build listening on 7778 beside the live one, so + // only the host carries over from the instance's assignment — the dev build is not an + // entry in the pool, so it is named `dev` rather than borrowing that entry's + // positional `tachyon-N`. + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 974, username: 'Devver', isDeveloper: true })) + .run() + const original = env.TACHYON_HOST_PORT + try { + // One entry, so the assignment can't happen to land on 7778 by itself. + env.TACHYON_HOST_PORT = '198.51.100.12:7777' + + const voiceFor = async (player: string, roomInstanceId: number) => { + const res = await exports.default.fetch( + `${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 instance = await createRoomInstance(env.DB, { + ownerAccountId: 974, + roomId: 2, + photonRoomId: 'tachyon-instance-dev', + maxCapacity: 12, + }) + expect(await voiceFor('974', instance.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7778', + voiceServerId: 'dev', + }) + // Everyone else in that instance keeps the pool entry's own port and id. + expect(await voiceFor('975', instance.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + + // In no instance there is no server to move the port of: a bare ':7778' would be + // an address that answers nothing, named `dev`. + const none = await exports.default.fetch(`${ORIGIN}/player/connection-info`, { + headers: await bearer('974'), + }) + const body = (await none.json()) as { + value: { voiceConnectionInfo: string; voiceServerId: string } + } + expect(body.value.voiceConnectionInfo).toBe('') + expect(body.value.voiceServerId).toBe('') + } finally { + env.TACHYON_HOST_PORT = original + } + }) + 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