From ed45f381374a01e541e3294c920bec60c1221331 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 20 Oct 2026 21:21:24 -0400 Subject: [PATCH] [2026] split 2026 to tachy hosts --- .env.example | 11 ++++- CLAUDE.md | 5 ++- README.md | 3 +- apps/match/src/context.ts | 18 ++++++-- apps/match/src/match.app.ts | 37 ++++++++++++---- apps/match/src/test/integration/api.test.ts | 47 +++++++++++++++++++++ 6 files changed, 106 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 2e7884e..e810bbe 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,14 @@ RECFLARE_DOMAIN=rec.example.com # out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players # use the pool above. # RECFLARE_TACHYON_HOST_PORT_ROLLOUT=198.51.100.13:7777 +# +# WHICH builds are served that rollout pool, when it should not simply follow the sign-in +# gate: same format as RECFLARE_ROLLOUT_GAME_VERSIONS (builds, commas; `off` for none; +# `20260323` and `20260323.14` name the same build). Unset, the rollout pool goes to the +# builds under staged rollout, so ending the rollout (ROLLOUT_GAME_VERSIONS=off) also moves +# those players onto the live pool. Set this to keep the 2026 build on its own voice +# servers after it opens to everyone — the 2025 and 2023 builds stay on the pool above. +# RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS=20260323 # The Photon region every session is pinned to (`match`). Unlike the app ids above this # does default, to `us` (us-east1) — an instance stamped with an empty region is one the @@ -245,7 +253,8 @@ RECFLARE_DOMAIN=rec.example.com # made to upgrade. A build in ROLLOUT is served too, whether or not SUPPORTED names it, but # only to staff and the Discord roles below. The lifecycle these exist for: # 1. Rollout (both unset): the 2026 build is for staff and supporters only. -# 2. General availability: ROLLOUT_GAME_VERSIONS=off — the 2026 build opens to everyone. +# 2. General availability: ROLLOUT_GAME_VERSIONS=off — the 2026 build opens to everyone +# (and joins the live voice pool unless TACHYON_ROLLOUT_GAME_VERSIONS above keeps it). # 3. Forced upgrade: SUPPORTED_GAME_VERSIONS=20230414,20260323 (with `off` above) — the # 2025 build is refused and has to update. # To pull a rollout back instead, list neither: SUPPORTED_GAME_VERSIONS=20230414,20250718 diff --git a/CLAUDE.md b/CLAUDE.md index a5afc08..f509927 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,10 @@ vars (`RECFLARE_*` in `.env`, reaching every worker) override them — read thro that gates on "is this build served / under rollout" (`auth` tokens, `api` versioncheck, `match` rollout voice pool). A build outside the supported set is told to update by `api` AND refused a token by `auth`; a rollout build is always served, but gated to staff and -Discord roles until `ROLLOUT_GAME_VERSIONS=off`. +Discord roles until `ROLLOUT_GAME_VERSIONS=off`. The rollout VOICE pool +(`TACHYON_HOST_PORT_ROLLOUT`) follows that list by default but `TACHYON_ROLLOUT_GAME_VERSIONS` +overrides it (`tachyonRolloutGameBuilds` in `match`), so a build can stay on its own Tachyon +servers after its rollout ends. - `20230414` — default, official. Manifest `7859140924515540835` (2023). - `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`; diff --git a/README.md b/README.md index fbda28b..5e372f2 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ The table is the default. An operator narrows it with `RECFLARE_SUPPORTED_GAME_V in `.env` (a build left out is told to update and refused a sign-in, which is how players are moved to a newer build), and `RECFLARE_ROLLOUT_GAME_VERSIONS` names the builds under staged rollout to Discord supporters — `20260323` by default, `off` to open it to -everyone. See `.env.example`. +everyone (`RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS` keeps a build on the rollout voice +servers past that). See `.env.example`. Generally speaking any client that effectively rewrites the nameserver with the right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server. diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index c761af3..6c246fa 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -87,11 +87,23 @@ export type Env = SharedHonoEnv & { * The builds under staged rollout, overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain: * a comma-separated list of builds, or `off` for none. Set for every worker from the * root `.env` (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so this agrees with the gate `auth` - * applies by construction. Read through `rolloutGameBuilds`; decides who is served the - * rollout Tachyon pool above. Ending the rollout (`off`) moves those callers to the - * live pool. + * applies by construction. Read through `rolloutGameBuilds`; the DEFAULT for which + * builds are served the rollout Tachyon pool above (see `TACHYON_ROLLOUT_GAME_VERSIONS`), + * so ending the rollout (`off`) moves those callers to the live pool unless that var + * keeps them. */ ROLLOUT_GAME_VERSIONS?: string + /** + * The builds served the rollout Tachyon pool, decoupled from the sign-in gate + * (`RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS` in `.env`): the same comma-separated list of + * builds, or `off` for none. Unset means `ROLLOUT_GAME_VERSIONS` decides, as above. Set, + * it is the only thing that does — so a build opened to everyone (`ROLLOUT_GAME_VERSIONS=off`) + * can stay on its own voice servers by naming it here. Matched as a BUILD: a listed + * `20260323` (or `20260323.14` — the point release is stripped) covers every point + * release of it, the way every other version gate compares. Read through + * `tachyonRolloutGameBuilds` in match.app.ts. + */ + TACHYON_ROLLOUT_GAME_VERSIONS?: 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 diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 532496f..1ffe16d 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -43,6 +43,7 @@ import { MatchmakingErrorCode, MessageType, MOST_ACTIVE_CLUBHOUSE_LIMIT, + parseGameBuilds, putPlayerSettingsIfChanged, readPlayerSettings, recordRoomVisit, @@ -254,10 +255,24 @@ const ROLLOUT_TACHYON_SERVER_ID_PREFIX = 'rollout-' interface TachyonCaller { /** The account's `isSandbox` flag. */ isSandbox: boolean - /** Running a build under staged rollout (`rolloutGameBuilds`, off the token's `rn.ver`). */ + /** Running a build served the rollout pool (`tachyonRolloutGameBuilds`, off the token's `rn.ver`). */ isRollout: boolean } +/** + * The builds served the rollout Tachyon pool: the operator's `TACHYON_ROLLOUT_GAME_VERSIONS` + * when set (`off` for none), otherwise the builds under staged rollout + * ({@link rolloutGameBuilds}, the same list `auth` gates sign-in on). The override exists + * because the two lists part ways at general availability: `ROLLOUT_GAME_VERSIONS=off` + * opens the new build to everyone, and without this it would also move every player on it + * onto the live voice servers. Entries are reduced to builds by {@link parseGameBuilds}, + * so `20260323.14` lists the same build as `20260323`, and the caller's version is + * compared the same way by {@link isRolloutGameVersion}. + */ +function tachyonRolloutGameBuilds(env: Env): string[] { + return parseGameBuilds(env.TACHYON_ROLLOUT_GAME_VERSIONS) ?? rolloutGameBuilds(env) +} + /** * 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 @@ -265,10 +280,12 @@ interface TachyonCaller { * * - `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_ROLLOUT` for a caller on a build in {@link tachyonRolloutGameBuilds} + * — by default the build under staged rollout, which `auth` only lets Discord supporters + * sign in on and which gets its own voice servers while it rolls out; + * `TACHYON_ROLLOUT_GAME_VERSIONS` keeps a build there after the gate opens. 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 @@ -3217,8 +3234,10 @@ const app = new Hono() '…). 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`, …; a caller whose token’s `rn.ver` is a build', - 'under staged rollout (`ROLLOUT_GAME_VERSIONS`, by default `20260323`) out of', - '`TACHYON_HOST_PORT_ROLLOUT`, with ids `rollout-1`, `rollout-2`, …. Sandbox wins when', + 'under staged rollout (`ROLLOUT_GAME_VERSIONS`, by default `20260323`; or whatever', + '`TACHYON_ROLLOUT_GAME_VERSIONS` names instead, so a build can keep its own voice', + 'servers after its rollout ends) 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(' '), @@ -3261,11 +3280,11 @@ const app = new Hono() 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, and a caller on a - // rollout build out of the rollout pool, instead of the live one. + // rollout-pool build out of the rollout pool, instead of the live one. const tachyon = tachyonServerFor( tachyonPoolFor(c.env, { isSandbox: account?.isSandbox === true, - isRollout: isRolloutGameVersion(await callerVersion(c), rolloutGameBuilds(c.env)), + isRollout: isRolloutGameVersion(await callerVersion(c), tachyonRolloutGameBuilds(c.env)), }), roomInstanceId ) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 5ff5264..51aac5c 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1815,8 +1815,10 @@ describe('auth-gated endpoints', () => { const originalSandbox = env.TACHYON_HOST_PORT_SANDBOX const originalRollout = env.TACHYON_HOST_PORT_ROLLOUT const originalRolloutVersions = env.ROLLOUT_GAME_VERSIONS + const originalTachyonRolloutVersions = env.TACHYON_ROLLOUT_GAME_VERSIONS try { env.TACHYON_HOST_PORT = '198.51.100.12:7777' + env.TACHYON_ROLLOUT_GAME_VERSIONS = undefined env.TACHYON_HOST_PORT_SANDBOX = '203.0.113.30:7777, 203.0.113.30:7778' const voiceFor = async (player: string, roomInstanceId?: number) => { @@ -1928,6 +1930,50 @@ describe('auth-gated endpoints', () => { voiceConnectionInfo: '198.51.100.12:7777', voiceServerId: 'tachyon-1', }) + + // TACHYON_ROLLOUT_GAME_VERSIONS decouples the pool from the gate: set, it alone + // decides which builds get the rollout pool, so a build opened to everyone + // (ROLLOUT_GAME_VERSIONS=off) keeps its own voice servers while the 2025 build + // stays live. Matched as a build — a listed point release covers every point + // release of it, and a token carrying the bare build matches a listed one. + env.ROLLOUT_GAME_VERSIONS = 'off' + const keptRollout = await createRoomInstance(env.DB, { + ownerAccountId: 976, + roomId: 2, + photonRoomId: 'tachyon-rollout-kept', + maxCapacity: 12, + }) + for (const listed of ['20260323', '20260323.14', ' 20260323.2 ,20231207']) { + env.TACHYON_ROLLOUT_GAME_VERSIONS = listed + for (const version of ['20260323', '20260323.14', '20260323.99']) { + expect(await rolloutVoiceFor('976', version, keptRollout.roomInstanceId)).toEqual( + rolloutPool[keptRollout.roomInstanceId % rolloutPool.length] + ) + } + expect(await rolloutVoiceFor('976', '20250718.01', keptRollout.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + } + // Sandbox still wins over it. + env.TACHYON_ROLLOUT_GAME_VERSIONS = '20260323' + expect(await rolloutVoiceFor('974', '20260323.14', keptRollout.roomInstanceId)).toEqual( + sandboxPool[keptRollout.roomInstanceId % sandboxPool.length] + ) + // Set, it overrides the gate's list in BOTH directions: `off` sends a build still + // under rollout to the live pool. + env.ROLLOUT_GAME_VERSIONS = '20260323' + env.TACHYON_ROLLOUT_GAME_VERSIONS = 'off' + expect(await rolloutVoiceFor('976', '20260323.14', keptRollout.roomInstanceId)).toEqual({ + voiceConnectionInfo: '198.51.100.12:7777', + voiceServerId: 'tachyon-1', + }) + // Blank is unset: the gate's list decides again. + env.TACHYON_ROLLOUT_GAME_VERSIONS = '' + expect(await rolloutVoiceFor('976', '20260323.14', keptRollout.roomInstanceId)).toEqual( + rolloutPool[keptRollout.roomInstanceId % rolloutPool.length] + ) + env.TACHYON_ROLLOUT_GAME_VERSIONS = undefined env.ROLLOUT_GAME_VERSIONS = originalRolloutVersions // With no rollout pool configured, a rollout build falls back to the live one, not @@ -1961,6 +2007,7 @@ describe('auth-gated endpoints', () => { env.TACHYON_HOST_PORT_SANDBOX = originalSandbox env.TACHYON_HOST_PORT_ROLLOUT = originalRollout env.ROLLOUT_GAME_VERSIONS = originalRolloutVersions + env.TACHYON_ROLLOUT_GAME_VERSIONS = originalTachyonRolloutVersions } })