[2026] rollout support

This commit is contained in:
Devin Zuczek
2026-10-12 14:59:14 -04:00
parent 4d6a88f20e
commit a0345af765
8 changed files with 413 additions and 16 deletions
+8
View File
@@ -75,6 +75,14 @@ export type Env = SharedHonoEnv & {
* accounts use the live pool. See `tachyonPoolFor` in match.app.ts.
*/
TACHYON_HOST_PORT_SANDBOX?: string
/**
* The Tachyon pool a caller on a build under staged rollout (`ROLLOUT_GAME_VERSIONS` in
* @repo/domain, read off their token's `rn.ver`) is served from
* (`RECFLARE_TACHYON_HOST_PORT_ROLLOUT` in `.env`) — same list format, ids `rollout-1`,
* `rollout-2`, …. A sandbox account is served the sandbox pool even on that build. Unset
* means rollout callers use the live pool. See `tachyonPoolFor` in match.app.ts.
*/
TACHYON_HOST_PORT_ROLLOUT?: string
/**
* The Photon region every session is pinned to — both the region named in the connection
* info and the one stamped on every room instance, which must agree. Optional; unlike the
+46 -15
View File
@@ -38,6 +38,7 @@ import {
isClubMember,
isMatchmakingPolicy,
isPlayerBannedFromRoom,
isRolloutGameVersion,
MatchmakingErrorCode,
MessageType,
MOST_ACTIVE_CLUBHOUSE_LIMIT,
@@ -241,23 +242,46 @@ function tachyonServerFor(pool: TachyonServer[], roomInstanceId: number): Tachyo
/**
* The prefix of a sandbox server's generated id (see {@link tachyonPoolFor}) — `dev-1`,
* `dev-2` rather than the live pool's `tachyon-N`, so the (cosmetic) id the client
* displays says which pool it is on.
* displays says which pool it is on. The rollout pool's is `rollout-N` for the same reason.
*/
const SANDBOX_TACHYON_SERVER_ID_PREFIX = 'dev-'
const ROLLOUT_TACHYON_SERVER_ID_PREFIX = 'rollout-'
/** What decides which Tachyon pool a caller is served from; see {@link tachyonPoolFor}. */
interface TachyonCaller {
/** The account's `isSandbox` flag. */
isSandbox: boolean
/** Running a build under staged rollout (`ROLLOUT_GAME_VERSIONS`, off the token's `rn.ver`). */
isRollout: boolean
}
/**
* The pool the caller's instance is picked out of. A SANDBOX account runs against its own
* Tachyon servers, apart from the live pool, so the whole list is swapped for
* `TACHYON_HOST_PORT_SANDBOX` rather than any one entry: selection within it is the same
* instance-id round-robin {@link tachyonServerFor} applies to the live pool. When that var
* is unset a sandbox account falls back to the live pool, so an operator who runs no
* sandbox servers doesn't leave those accounts without voice.
* The pool the caller's instance is picked out of — one of three, each the same
* comma-separated list, with selection inside it the same instance-id round-robin
* {@link tachyonServerFor} applies to all of them:
*
* - `TACHYON_HOST_PORT_SANDBOX` for a SANDBOX account, which runs against its own servers
* apart from the live pool;
* - `TACHYON_HOST_PORT_ROLLOUT` for a caller on a build under staged rollout — the build
* `auth` only lets Discord supporters sign in on, which gets its own voice servers while
* it rolls out. Sessions are already split by build ({@link callerGameVersion}), so a
* rollout instance's players are all on that build and all land here together;
* - `TACHYON_HOST_PORT`, the live pool, for everyone else.
*
* Sandbox wins when both apply: a sandbox account on the rollout build is still a sandbox
* account. Either special pool left unset falls back to the live one, so an operator who
* runs no sandbox or rollout servers doesn't leave those callers without voice.
*/
function tachyonPoolFor(env: Env, isSandbox: boolean): TachyonServer[] {
function tachyonPoolFor(env: Env, caller: TachyonCaller): TachyonServer[] {
const sandbox = varOr(env.TACHYON_HOST_PORT_SANDBOX, '')
return isSandbox && sandbox.trim() !== ''
? tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX)
: tachyonPool(varOr(env.TACHYON_HOST_PORT, ''))
if (caller.isSandbox && sandbox.trim() !== '') {
return tachyonPool(sandbox, SANDBOX_TACHYON_SERVER_ID_PREFIX)
}
const rollout = varOr(env.TACHYON_HOST_PORT_ROLLOUT, '')
if (!caller.isSandbox && caller.isRollout && rollout.trim() !== '') {
return tachyonPool(rollout, ROLLOUT_TACHYON_SERVER_ID_PREFIX)
}
return tachyonPool(varOr(env.TACHYON_HOST_PORT, ''))
}
/**
@@ -3088,8 +3112,11 @@ const app = new Hono<App>()
'`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. A',
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the same way,',
'with ids `dev-1`, `dev-2`, ….',
'sandbox account is picked out of the `TACHYON_HOST_PORT_SANDBOX` pool instead, the',
'same way, with ids `dev-1`, `dev-2`, …; a caller whose token’s `rn.ver` is a build',
'under staged rollout (`ROLLOUT_GAME_VERSIONS`, currently `20260323.14`) out of',
'`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when',
'both apply, and either special pool left unset falls back to the live one.',
'`experiments` carries the client’s networking flags.',
].join(' '),
security: AUTHED,
@@ -3130,9 +3157,13 @@ const app = new Hono<App>()
}
const account = await getAccount(c.env.DB, id)
// The instance's server, the same one every other player in it is handed — except
// that a SANDBOX account is picked out of the sandbox pool instead of the live one.
// that a SANDBOX account is picked out of the sandbox pool, and a caller on a
// rollout build out of the rollout pool, instead of the live one.
const tachyon = tachyonServerFor(
tachyonPoolFor(c.env, account?.isSandbox === true),
tachyonPoolFor(c.env, {
isSandbox: account?.isSandbox === true,
isRollout: isRolloutGameVersion(await callerVersion(c)),
}),
roomInstanceId
)
+64 -1
View File
@@ -1615,7 +1615,7 @@ describe('auth-gated endpoints', () => {
}
})
test('a sandbox account is served out of the sandbox Tachyon pool', async () => {
test('a sandbox account is served out of the sandbox Tachyon pool, a rollout build out of the rollout one', 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)')
@@ -1623,6 +1623,7 @@ describe('auth-gated endpoints', () => {
.run()
const original = env.TACHYON_HOST_PORT
const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX
const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT
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'
@@ -1667,6 +1668,67 @@ describe('auth-gated endpoints', () => {
// In no instance there is no server, sandbox or not.
expect(await voiceFor('974')).toEqual({ voiceConnectionInfo: '', voiceServerId: '' })
// A caller on a build under staged rollout gets the ROLLOUT pool, a third list with
// its own ids, read off the token's rn.ver. A sandbox account on that build still
// gets the sandbox pool: sandbox wins.
env.TACHYON_HOST_PORT_ROLLOUT = '198.51.100.13:7777,198.51.100.13:7778'
const rolloutPool = [
{ voiceConnectionInfo: '198.51.100.13:7777', voiceServerId: 'rollout-1' },
{ voiceConnectionInfo: '198.51.100.13:7778', voiceServerId: 'rollout-2' },
]
const rolloutVoiceFor = async (player: string, version: string, roomInstanceId: number) => {
const res = await exports.default.fetch(
`${ORIGIN}/player/connection-info?roomInstanceId=${roomInstanceId}`,
{ headers: await bearer(player, version) }
)
const body = (await res.json()) as {
value: { voiceConnectionInfo: string; voiceServerId: string }
}
return {
voiceConnectionInfo: body.value.voiceConnectionInfo,
voiceServerId: body.value.voiceServerId,
}
}
for (const photonRoomId of ['tachyon-rollout-a', 'tachyon-rollout-b']) {
const rollout = await createRoomInstance(env.DB, {
ownerAccountId: 976,
roomId: 2,
photonRoomId,
maxCapacity: 12,
})
expect(await rolloutVoiceFor('976', '20260323.14', rollout.roomInstanceId)).toEqual(
rolloutPool[rollout.roomInstanceId % rolloutPool.length]
)
expect(await rolloutVoiceFor('974', '20260323.14', rollout.roomInstanceId)).toEqual(
sandboxPool[rollout.roomInstanceId % sandboxPool.length]
)
// Released builds — the default, the beta, and a token carrying no build — stay live.
for (const version of [GAME_VERSION, '20250718.01']) {
expect(await rolloutVoiceFor('976', version, rollout.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
}
expect(await voiceFor('976', rollout.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
}
// With no rollout pool configured, a rollout build falls back to the live one, not
// the sandbox one.
env.TACHYON_HOST_PORT_ROLLOUT = undefined
const unsetRollout = await createRoomInstance(env.DB, {
ownerAccountId: 976,
roomId: 2,
photonRoomId: 'tachyon-rollout-unset',
maxCapacity: 12,
})
expect(await rolloutVoiceFor('976', '20260323.14', unsetRollout.roomInstanceId)).toEqual({
voiceConnectionInfo: '198.51.100.12:7777',
voiceServerId: 'tachyon-1',
})
// 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, {
@@ -1682,6 +1744,7 @@ describe('auth-gated endpoints', () => {
} finally {
env.TACHYON_HOST_PORT = original
env.TACHYON_HOST_PORT_SANDBOX = originalSandbox
env.TACHYON_HOST_PORT_ROLLOUT = originalRollout
}
})