diff --git a/.env.example b/.env.example index 3877b8d..5a22e48 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,17 @@ RECFLARE_DOMAIN=rec.example.com # RECFLARE_MAX_ROOMS_PER_ACCOUNT=10 # RECFLARE_MAX_CLUBS_PER_ACCOUNT=10 +# Rooms to switch out at matchmake time (`match`), as comma-separated = +# pairs, where is a room id or room name. This is how a stock RRO room is replaced +# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the +# room named MyHub instead, whether the client asked for it by id or by name, and whether +# it came through the room list, a club's clubhouse, or a party. Substitution is a single +# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the +# substitute's default one, and a target that doesn't exist leaves the original room in +# place. Following a friend or joining a specific instance is unaffected — those join a +# live instance, which is already in whichever room it was created in. +# RECFLARE_ROOM_REDIRECTS=2=MyHub + # RecCenterTokens a new player is granted, the first time their balance is read (`econ`). # 0 means players start broke. Applies only to players who haven't been granted yet — # raising it later does NOT top up existing players. diff --git a/DEPLOYING.md b/DEPLOYING.md index a3708b7..907476d 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -191,6 +191,7 @@ edit the value, then re-deploy the worker that reads them. | `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. | | `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. | | `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. | +| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. | Then deploy just the worker that reads it: diff --git a/apps/match/README.md b/apps/match/README.md index cb03c87..8f0386c 100644 --- a/apps/match/README.md +++ b/apps/match/README.md @@ -91,6 +91,28 @@ Several behaviours are load-bearing and reverse-engineered from the client: solo Orientation room) and only falls back to the dorm when the player has none. `goto/none` always goes to the dorm. +### Switching a room out (`ROOM_REDIRECTS`) + +An operator can substitute one room for another at matchmake time — the way to replace a +stock RRO room, typically the Rec Center (room 2), with a room of their own without +touching the client. The knob is `RECFLARE_ROOM_REDIRECTS` in the root `.env` (see +`.env.example`), comma-separated `=` pairs where `` is a room id or +name: `2=MyHub`, or `2=100,3=MyHub`. + +Substitution happens where a matchmake resolves a named room, so it covers every route +that names one — the two- and three-segment room matchmakes and a club's clubhouse — and +everything downstream (the ban check, presence, the visit count) sees only the room +actually entered. Matching is on the resolved room id, so asking by name (`RecCenter`) +substitutes the same as asking by id. + +- **A requested subroom is dropped** when a substitution fires: the id addresses a subroom + of the room the client asked for, so entry falls back to the substitute's default one. +- **One hop only** — `2=3,3=2` swaps the two rooms rather than looping. +- **An unresolvable target leaves the original room in place** (logged), so a typo doesn't + make a room unreachable. +- **Following a friend and joining a specific instance are unaffected** — those enter a + live instance, which is already in whichever room it was created in. + ## Bindings | Binding | Type | Notes | diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index 8038d86..2c71322 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -20,6 +20,17 @@ export type Env = SharedHonoEnv & { * player. Used by `POST /invite` to deliver the game-invite message to the invitee. */ RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace + /** + * Room substitutions applied at matchmake time, as comma-separated `=` + * pairs — e.g. `2=100` or `2=MyHub,3=100` — where `from` is the room id the client + * asks for and `to` is the room it actually enters (id or room name). Optional; unset + * means every matchmake enters the room it asked for. + * + * The point of it is swapping out a stock RRO room for a custom one: `2=MyHub` sends + * everyone who matchmakes into the Rec Center (room 2) to `MyHub` instead, without + * touching the client. See `roomRedirects` in match.app.ts. + */ + ROOM_REDIRECTS?: string } /** Variables can be extended */ diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 99e84b9..053dab9 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -517,24 +517,91 @@ type ResolvedInstance = | { instance: RoomInstance; errorCode: MatchmakingErrorCode.Success } | { instance: null; errorCode: MatchmakingErrorCode } +/** + * The operator's room substitutions, parsed from the `ROOM_REDIRECTS` var: a map of + * the room id the client asks for to the room it actually enters (id or room name). + * The var is comma-separated `=` pairs, e.g. `2=MyHub,3=100`. + * + * Keyed on the source's numeric id rather than the path segment because the client can + * matchmake by either id or name (`/matchmake/room/2` and `/matchmake/room/RecCenter` + * are the same room), so the substitution is matched against the room D1 resolved — + * one entry then covers both spellings. Unparseable pairs are skipped rather than + * failing the matchmake: a typo in the knob must not take room entry down. + */ +function roomRedirects(env: Env): Map { + const map = new Map() + if (typeof env.ROOM_REDIRECTS !== 'string') return map + for (const pair of env.ROOM_REDIRECTS.split(',')) { + const eq = pair.indexOf('=') + if (eq === -1) continue + const from = Number(pair.slice(0, eq).trim()) + const to = pair.slice(eq + 1).trim() + if (!Number.isInteger(from) || to === '') continue + map.set(from, to) + } + return map +} + +/** + * Apply the operator's `ROOM_REDIRECTS` substitution to a room the client asked for. + * Answers the room to actually enter, plus the subroom to enter it by. + * + * A substituted room drops the requested subroom: the id the client sent addresses a + * subroom of the room it *asked* for, and the same number in the target room is a + * different place entirely (or nothing at all), so entry falls back to the target's + * default subroom. Substitution is a single hop — `2=3,3=2` swaps the two rooms rather + * than looping — and an unresolvable target leaves the original room in place, so a + * typo'd knob degrades to "no substitution" instead of a dead hub. + */ +async function substituteRoom( + c: Context, + room: Room, + subRoomId?: number +): Promise<{ room: Room; subRoomId?: number }> { + const fromId = typeof room.RoomId === 'number' ? room.RoomId : NaN + const to = roomRedirects(c.env).get(fromId) + if (to === undefined) return { room, subRoomId } + + const toId = Number.parseInt(to, 10) + const target = Number.isNaN(toId) + ? await getRoomByName(c.env.DB, to) + : await getRoomById(c.env.DB, toId) + if (!target) { + logger.warn('room redirect target not found; entering the requested room', { + roomId: fromId, + target: to, + }) + return { room, subRoomId } + } + + logger.info('room redirected', { roomId: fromId, target: to }) + return { room: target, subRoomId: undefined } +} + /** * Resolve a room by `:room` path segment (numeric id or name) from D1, then find a * joinable instance of it (public matchmakes reuse one via the `room_instance` * table) or create a new one. A null instance carries the error code to answer: * NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned. + * + * Every matchmake that names a room lands here, so this is also where the operator's + * room substitutions apply (`ROOM_REDIRECTS`) — everything downstream, from the ban + * check to presence and the visit count, sees only the room actually entered. */ async function resolveRoomInstance( c: Context, roomKey: string, isPrivate: boolean, ownerId: number, - subRoomId?: number + requestedSubRoomId?: number ): Promise { const id = Number.parseInt(roomKey, 10) - const room = Number.isNaN(id) + const requested = Number.isNaN(id) ? await getRoomByName(c.env.DB, roomKey) : await getRoomById(c.env.DB, id) - if (!room) return { instance: null, errorCode: NO_SUCH_ROOM } + if (!requested) return { instance: null, errorCode: NO_SUCH_ROOM } + + const { room, subRoomId } = await substituteRoom(c, requested, requestedSubRoomId) const f = instanceFieldsFromRoom(room, subRoomId) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1d45b81..3276add 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -501,6 +501,79 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null }) }) + 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 + const matchmake = async (path: string, player: string) => + (await ( + await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + ...(await bearer(player)), + '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, + }) + + // 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) diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index cde9b03..1c0216c 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -52,6 +52,10 @@ "head_sampling_rate": 1 // 100% } }, + // The room substitutions (ROOM_REDIRECTS) are deliberately NOT set here. They're + // injected at deploy time from the gitignored .env (RECFLARE_ROOM_REDIRECTS, see + // .env.example), so swapping a room out never means editing a versioned file. Unset — + // the default — means every matchmake enters the room it asked for. "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown" // overridden during deployment diff --git a/packages/tools/bin/run-wrangler-deploy b/packages/tools/bin/run-wrangler-deploy index fb22994..1315f03 100755 --- a/packages/tools/bin/run-wrangler-deploy +++ b/packages/tools/bin/run-wrangler-deploy @@ -183,8 +183,8 @@ MINIFY="--minify" # Deploy with wrangler using the extracted values as binding variables echo "Deploying worker $NAME version $VERSION to $HOST" -# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split, and every -# value in it is an integer, so there's nothing to split on inside a value. +# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split, and no knob value +# contains whitespace (see recflare_vars), so there's nothing to split on inside a value. wrangler deploy \ --config "$CONFIG" \ --var NAME:"$NAME" \ diff --git a/packages/tools/src/sh/env.sh b/packages/tools/src/sh/env.sh index b7efc5b..bfa5a62 100644 --- a/packages/tools/src/sh/env.sh +++ b/packages/tools/src/sh/env.sh @@ -53,10 +53,11 @@ recflare_load_env() { # why a value set in the Cloudflare dashboard doesn't survive one.) # # Values must not contain whitespace: the result is a flag list the caller word-splits. -# Knobs are numbers and short enums, and real secrets belong in the Secrets Store (which is -# bound in wrangler.jsonc, not passed through here), so this hasn't been worth the ceremony -# of an array. Vars also arrive in the Worker as strings (`--var X:3` is "3", not 3), which -# is why the workers parse them through `intVar` rather than reading them as numbers. +# Knobs are numbers and short unquoted strings (`2=MyHub`), and real secrets belong in the +# Secrets Store (which is bound in wrangler.jsonc, not passed through here), so this hasn't +# been worth the ceremony of an array. Vars also arrive in the Worker as strings (`--var +# X:3` is "3", not 3), which is why a numeric knob is parsed through `intVar` rather than +# read as a number. recflare_vars() { # The sed only ever yields [A-Z0-9_] names, so the eval below can't expand anything else. for _name in $(env | sed -n 's/^RECFLARE_\([A-Z0-9_][A-Z0-9_]*\)=.*/\1/p'); do