mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
ability to swap out a room
This commit is contained in:
@@ -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 <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`).
|
||||
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||
# raising it later does NOT top up existing players.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 `<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
|
||||
|
||||
| Binding | Type | Notes |
|
||||
|
||||
@@ -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<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 */
|
||||
|
||||
@@ -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 `<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
|
||||
* 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<App>,
|
||||
roomKey: string,
|
||||
isPrivate: boolean,
|
||||
ownerId: number,
|
||||
subRoomId?: number
|
||||
requestedSubRoomId?: number
|
||||
): Promise<ResolvedInstance> {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user