ability to swap out a room

This commit is contained in:
Devin Zuczek
2026-08-09 22:25:27 -04:00
parent 60505a2519
commit 37489d05dc
9 changed files with 199 additions and 9 deletions
+11
View File
@@ -60,6 +60,17 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10 # RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10 # RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
# pairs, where <to> 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`). # 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 — # 0 means players start broke. Applies only to players who haven't been granted yet —
# raising it later does NOT top up existing players. # raising it later does NOT top up existing players.
+1
View File
@@ -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_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_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_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: Then deploy just the worker that reads it:
+22
View File
@@ -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. solo Orientation room) and only falls back to the dorm when the player has none.
`goto/none` always goes to the dorm. `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 `<fromRoomId>=<to>` pairs where `<to>` 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 ## Bindings
| Binding | Type | Notes | | Binding | Type | Notes |
+11
View File
@@ -20,6 +20,17 @@ export type Env = SharedHonoEnv & {
* player. Used by `POST /invite` to deliver the game-invite message to the invitee. * player. Used by `POST /invite` to deliver the game-invite message to the invitee.
*/ */
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub> RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
/**
* Room substitutions applied at matchmake time, as comma-separated `<fromRoomId>=<to>`
* 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 */ /** Variables can be extended */
+70 -3
View File
@@ -517,24 +517,91 @@ type ResolvedInstance =
| { instance: RoomInstance; errorCode: MatchmakingErrorCode.Success } | { instance: RoomInstance; errorCode: MatchmakingErrorCode.Success }
| { instance: null; errorCode: MatchmakingErrorCode } | { 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 `<fromRoomId>=<to>` 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<number, string> {
const map = new Map<number, string>()
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<App>,
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 * 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` * 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: * 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. * 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( async function resolveRoomInstance(
c: Context<App>, c: Context<App>,
roomKey: string, roomKey: string,
isPrivate: boolean, isPrivate: boolean,
ownerId: number, ownerId: number,
subRoomId?: number requestedSubRoomId?: number
): Promise<ResolvedInstance> { ): Promise<ResolvedInstance> {
const id = Number.parseInt(roomKey, 10) const id = Number.parseInt(roomKey, 10)
const room = Number.isNaN(id) const requested = Number.isNaN(id)
? await getRoomByName(c.env.DB, roomKey) ? await getRoomByName(c.env.DB, roomKey)
: await getRoomById(c.env.DB, id) : 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) const f = instanceFieldsFromRoom(room, subRoomId)
@@ -501,6 +501,79 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null }) 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 () => { test('PUT /player/statusvisibility returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' }) const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
expect(res.status).toBe(200) expect(res.status).toBe(200)
+4
View File
@@ -52,6 +52,10 @@
"head_sampling_rate": 1 // 100% "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": { "vars": {
"ENVIRONMENT": "development", // overridden during deployment "ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment "SENTRY_RELEASE": "unknown" // overridden during deployment
+2 -2
View File
@@ -183,8 +183,8 @@ MINIFY="--minify"
# Deploy with wrangler using the extracted values as binding variables # Deploy with wrangler using the extracted values as binding variables
echo "Deploying worker $NAME version $VERSION to $HOST" echo "Deploying worker $NAME version $VERSION to $HOST"
# $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split, and every # $EXTRA_VARS is intentionally unquoted — it's a flag list to word-split, and no knob value
# value in it is an integer, so there's nothing to split on inside a value. # contains whitespace (see recflare_vars), so there's nothing to split on inside a value.
wrangler deploy \ wrangler deploy \
--config "$CONFIG" \ --config "$CONFIG" \
--var NAME:"$NAME" \ --var NAME:"$NAME" \
+5 -4
View File
@@ -53,10 +53,11 @@ recflare_load_env() {
# why a value set in the Cloudflare dashboard doesn't survive one.) # 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. # 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 # Knobs are numbers and short unquoted strings (`2=MyHub`), and real secrets belong in the
# bound in wrangler.jsonc, not passed through here), so this hasn't been worth the ceremony # Secrets Store (which is bound in wrangler.jsonc, not passed through here), so this hasn't
# of an array. Vars also arrive in the Worker as strings (`--var X:3` is "3", not 3), which # been worth the ceremony of an array. Vars also arrive in the Worker as strings (`--var
# is why the workers parse them through `intVar` rather than reading them as numbers. # X:3` is "3", not 3), which is why a numeric knob is parsed through `intVar` rather than
# read as a number.
recflare_vars() { recflare_vars() {
# The sed only ever yields [A-Z0-9_] names, so the eval below can't expand anything else. # 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 for _name in $(env | sed -n 's/^RECFLARE_\([A-Z0-9_][A-Z0-9_]*\)=.*/\1/p'); do