[match] implement junior match pref

This commit is contained in:
Devin Zuczek
2026-08-12 14:16:21 -04:00
parent da27b7e797
commit 36c30a396c
7 changed files with 383 additions and 30 deletions
+34 -28
View File
@@ -6,29 +6,31 @@ instances and presence all live in the shared `recflare` D1 database.
## Routes ## Routes
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
| ------ | ------------------------------------ | ---- | ------------------------------------------------ | | ------ | ------------------------------------ | ---- | ----------------------------------------------------- |
| POST | `/player/login` | | Login ack (no-op; must not touch presence) | | POST | `/player/login` | | Login ack (no-op; must not touch presence) |
| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` | | POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` |
| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) | | POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) |
| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) | | POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) |
| GET | `/player?id=1&id=2,3` | | Batch player presence lookup | | GET | `/player?id=1&id=2,3` | | Batch player presence lookup |
| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) | | POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) |
| PUT | `/player/statusvisibility` | ✓\* | Set status visibility | | PUT | `/player/statusvisibility` | ✓\* | Set status visibility |
| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) | | GET | `/player/avoidjuniors` | ✓ | The player's "avoid juniors" setting → `true`/`false` |
| POST | `/matchmake/none` | | Preserve current instance, else dorm | | PUT | `/player/avoidjuniors` | ✓ | Set it (`avoidJuniors=True`) → the resulting value |
| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom | | POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) |
| POST | `/matchmake/room/:roomId` | | Matchmake into a room (default subroom) | | POST | `/matchmake/none` | | Preserve current instance, else dorm |
| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) | | POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom |
| POST | `/goto/none` | | Go to the dorm | | POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) |
| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) | | POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) |
| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) | | POST | `/goto/none` | | Go to the dorm |
| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) | | PUT | `/player/photonregionpings` | | Region ping report (no-op ack) |
| PUT | `/roominstance/:id/inprogress` | | Set the instance's in-progress flag | | PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) |
| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) | | POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) |
| GET | `/rooms/requiring/developer` | | Rooms requiring a developer → `[]` | | PUT | `/roominstance/:id/inprogress` | | Set the instance's in-progress flag |
| GET | `/rooms/requiring/rrplus` | | Rooms requiring RR+ → `[]` | | GET | `/room/:roomId/instances` | | A room's live instances (owner/co-owner only) |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | | GET | `/rooms/requiring/developer` | | Rooms requiring a developer → `[]` |
| GET | `/rooms/requiring/rrplus` | | Rooms requiring RR+ → `[]` |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
\* `logout` and `statusvisibility` read the token when present but never 401 — an \* `logout` and `statusvisibility` read the token when present but never 401 — an
unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when
@@ -115,13 +117,17 @@ substitutes the same as asking by id.
## Bindings ## Bindings
| Binding | Type | Notes | | Binding | Type | Notes |
| ------------ | ------------- | ------------------------------------------------------------ | | -------------------------- | ------------- | ------------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence | | `DB` | D1 | Shared `recflare` database — rooms, room instances, presence |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | | `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `RECFLARE_PLAYER_SETTINGS` | KV | The `playersettings` map — `/player/avoidjuniors` |
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker; The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker;
this worker has no migrations of its own. this worker has no migrations of its own. The settings KV is owned by the
`playersettings` worker; this worker touches exactly one key in it, the "avoid juniors"
preference, and its write merges (as that worker's own PUT does) so the rest of the
player's settings survive.
## Known gaps ## Known gaps
+7
View File
@@ -20,6 +20,13 @@ 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>
/**
* The per-player settings map the `playersettings` worker owns (`player:<id>` → JSON
* `{ key: value }`). Read-only here, and only by `GET /player/avoidjuniors`: the
* "avoid juniors" preference is a matchmaking question the client asks this worker,
* but it is stored with the rest of the player's settings, not in presence.
*/
RECFLARE_PLAYER_SETTINGS: KVNamespace
/** /**
* Room substitutions applied at matchmake time, as comma-separated `<fromRoomId>=<to>` * 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 * pairs — e.g. `2=100` or `2=MyHub,3=100` — where `from` is the room id the client
+171
View File
@@ -47,6 +47,8 @@ import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
import { import {
AUTHED, AUTHED,
AvoidJuniorsRequest,
AvoidJuniorsResponse,
EMPTY_OK, EMPTY_OK,
ExclusiveLoginResponse, ExclusiveLoginResponse,
form, form,
@@ -133,6 +135,113 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401) return c.body(null, 401)
} }
/**
* The "avoid juniors" preference, normalized. The player's settings are a free-form
* `{ key: value }` bag written by the client through the `playersettings` worker, and the
* exact spelling it writes this key under is reverse-engineered — so the lookup is
* case- and separator-insensitive (`AvoidJuniors`, `avoidjuniors`, `AVOID_JUNIORS` all
* resolve to this one preference) rather than betting on one casing and silently reading
* false forever if it's wrong.
*/
const AVOID_JUNIORS_SETTING = 'avoidjuniors'
/**
* The spelling a NEW setting is written under. Only used when the player's map doesn't
* already carry the key under some other spelling — the write overwrites whichever one is
* there, so a player never ends up with two keys for the one preference (which would make
* the read depend on their order in the map).
*/
const AVOID_JUNIORS_KEY = 'AvoidJuniors'
/** Lowercase and drop separators, so keys compare on their letters alone. */
function normalizeSettingKey(key: string): string {
return key.toLowerCase().replaceAll(/[^a-z0-9]/g, '')
}
/** The player's existing spelling of the setting key, if their map has one. */
function findAvoidJuniorsKey(stored: Record<string, unknown>): string | undefined {
return Object.keys(stored).find((key) => normalizeSettingKey(key) === AVOID_JUNIORS_SETTING)
}
/**
* Settings values are strings, so a boolean arrives as `True`/`false`/`1`/`0` (the client
* isn't consistent about which). `undefined` for anything unrecognized, which the read and
* the write treat differently: a stored value that won't parse is a false preference, but a
* posted one that won't parse is a body worth ignoring rather than a write of `false`.
*/
function parseSettingBool(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value
switch (String(value).trim().toLowerCase()) {
case 'true':
case '1':
case 'yes':
return true
case 'false':
case '0':
case 'no':
return false
default:
return undefined
}
}
/** The player's settings map from the KV the `playersettings` worker owns. */
async function getPlayerSettings(
env: Env,
accountId: number
): Promise<Record<string, string> | null> {
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
}
/**
* Read a player's "avoid juniors" preference. Absent settings, an absent key, and an
* unparseable value are all false: the client asks this before matchmaking, so a read that
* can't answer must not keep a player out of rooms.
*/
async function readAvoidJuniors(env: Env, accountId: number): Promise<boolean> {
const stored = await getPlayerSettings(env, accountId)
if (!stored) return false
const key = findAvoidJuniorsKey(stored)
return key === undefined ? false : (parseSettingBool(stored[key]) ?? false)
}
/**
* Write a player's "avoid juniors" preference back into their settings map.
*
* The write MERGES, exactly as the `playersettings` worker's own PUT does: the map holds
* every setting the player has (OOBE state, tutorial mask, …), so storing this one on its
* own would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true of
* the settings worker, and two writers racing over one player's own settings means that
* player toggling two options in the same instant.
*/
async function writeAvoidJuniors(env: Env, accountId: number, value: boolean): Promise<void> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
const merged: Record<string, string> = { ...stored }
merged[findAvoidJuniorsKey(merged) ?? AVOID_JUNIORS_KEY] = value ? 'True' : 'False'
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
}
/**
* The posted preference, out of a form (`avoidJuniors=True`, what the client sends) or a
* JSON body. The field name is matched the same loose way the stored key is, so the casing
* the client picks can't silently miss. `undefined` when the body carries no readable
* value — the caller leaves the setting alone rather than writing a guess.
*/
async function readAvoidJuniorsBody(c: Context<App>): Promise<boolean | undefined> {
const contentType = c.req.header('content-type') ?? ''
const body = contentType.includes('application/json')
? await c.req.json<unknown>().catch(() => null)
: await c.req.parseBody().catch(() => null)
if (body === null || typeof body !== 'object') return undefined
const key = findAvoidJuniorsKey(body as Record<string, unknown>)
return key === undefined ? undefined : parseSettingBool((body as Record<string, unknown>)[key])
}
/** A synthesized room instance (same shape for dorm and other rooms). */ /** A synthesized room instance (same shape for dorm and other rooms). */
type RoomInstance = ReturnType<typeof roomInstanceFromRoom> type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
@@ -1008,6 +1117,68 @@ const app = new Hono<App>()
} }
) )
// The caller's "avoid juniors" preference. It's asked of this worker because it's a
// matchmaking question, but it isn't matchmaking state: the setting is written by the
// client through the `playersettings` worker, so this reads that worker's KV map
// directly (read-only) rather than keeping a second copy of the same toggle here.
.get(
'/player/avoidjuniors',
describeRoute({
tags: ['Player settings'],
summary: 'The players “avoid juniors” preference',
description: [
'Whether the authenticated player asked to be kept away from junior accounts, read',
'from their settings map in the `playersettings` KV. The body is a bare JSON boolean',
'(`true`/`false`), not an envelope. A player who never set it reads `false`.',
].join(' '),
security: AUTHED,
responses: {
200: json(AvoidJuniorsResponse, 'The preference; `false` when never set'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await readAvoidJuniors(c.env, id))
}
)
// Set the preference. Answers the RESULTING value rather than an empty ack, the way the
// GET does — the client has just changed a toggle it renders, and a body it can read
// back can't disagree with what was stored.
.put(
'/player/avoidjuniors',
describeRoute({
tags: ['Player settings'],
summary: 'Set the players “avoid juniors” preference',
description: [
'Stores the posted preference in the authenticated players settings map (the',
'`playersettings` KV) and answers the resulting value as a bare JSON boolean. The',
'write merges, so the players other settings are left alone. A body with no readable',
'`avoidJuniors` value leaves the setting as it was and answers the stored value — a',
'no-op 200, not a 400.',
].join(' '),
security: AUTHED,
requestBody: form(AvoidJuniorsRequest, 'The preference to store'),
responses: {
200: json(AvoidJuniorsResponse, 'The preference now stored'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const posted = await readAvoidJuniorsBody(c)
if (posted === undefined) return c.json(await readAvoidJuniors(c.env, id))
await writeAvoidJuniors(c.env, id, posted)
return c.json(posted)
}
)
// ---- Room navigation ----------------------------------------------------- // ---- Room navigation -----------------------------------------------------
// Each matchmake persists the resulting instance as the player's presence so the // Each matchmake persists the resulting instance as the player's presence so the
// heartbeat can replay it (keeping client presence in sync). // heartbeat can replay it (keeping client presence in sync).
+17
View File
@@ -150,6 +150,23 @@ export const MatchmakeResponse = z.object({
roomInstance: RoomInstanceDto.nullable(), roomInstance: RoomInstanceDto.nullable(),
}) })
/**
* `GET /player/avoidjuniors` — a BARE JSON boolean (`true`/`false`), not an envelope and
* not a `{ value }` wrapper. The whole body is the preference.
*/
export const AvoidJuniorsResponse = z
.boolean()
.describe('Whether the player asked to be kept away from junior accounts')
/**
* `PUT /player/avoidjuniors` form body. The client posts `avoidJuniors=True`; the field is
* matched case-insensitively and `True`/`false`/`1`/`0`/`yes`/`no` all parse, since neither
* the casing nor the spelling of the boolean is guaranteed across the client's surfaces.
*/
export const AvoidJuniorsRequest = z.object({
avoidJuniors: z.string().describe('`True`/`False` (also `1`/`0`, `yes`/`no`)'),
})
/** `POST /player/exclusivelogin` — a bare error code. */ /** `POST /player/exclusivelogin` — a bare error code. */
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') }) export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
+141
View File
@@ -279,6 +279,145 @@ describe('public endpoints', () => {
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION }) expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
}) })
// The "avoid juniors" preference lives in the playersettings KV map, not in presence.
// The body is a BARE boolean — the client reads the whole body as the value.
describe('GET /player/avoidjuniors', () => {
const settings = async (playerId: number, map: Record<string, string>) =>
env.RECFLARE_PLAYER_SETTINGS.put(`player:${playerId}`, JSON.stringify(map))
const read = async (playerId: number) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
headers: await bearer(String(playerId)),
})
expect(res.status).toBe(200)
return res.json()
}
test('reads the stored setting', async () => {
await settings(3100, { AvoidJuniors: 'True', 'Recroom.OOBE': '77' })
expect(await read(3100)).toBe(true)
await settings(3101, { AvoidJuniors: 'False' })
expect(await read(3101)).toBe(false)
})
test('the key match ignores casing and separators', async () => {
await settings(3102, { AVOID_JUNIORS: '1' })
expect(await read(3102)).toBe(true)
await settings(3103, { avoidjuniors: 'yes' })
expect(await read(3103)).toBe(true)
})
// A player who never touched the setting, and one whose value is junk, both read
// false — the read gates matchmaking, so it must not fail closed.
test('defaults to false when unset or unparseable', async () => {
expect(await read(3104)).toBe(false)
await settings(3105, { 'Recroom.OOBE': '77' })
expect(await read(3105)).toBe(false)
await settings(3106, { AvoidJuniors: 'maybe' })
expect(await read(3106)).toBe(false)
})
test('is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`)
expect(res.status).toBe(401)
})
})
describe('PUT /player/avoidjuniors', () => {
const stored = async (playerId: number) =>
env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`player:${playerId}`, 'json')
const write = async (playerId: number, body: string) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
method: 'PUT',
headers: {
...(await bearer(String(playerId))),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
expect(res.status).toBe(200)
return res.json()
}
const read = async (playerId: number) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
headers: await bearer(String(playerId)),
})
return res.json()
}
// The body the client posts. The response is the resulting value, and the GET agrees.
test('stores the posted preference and answers it', async () => {
expect(await write(3200, 'avoidJuniors=True')).toBe(true)
expect(await read(3200)).toBe(true)
expect(await write(3200, 'avoidJuniors=False')).toBe(false)
expect(await read(3200)).toBe(false)
})
// The map holds every setting the player has, so the write must not replace it.
test('merges into the players other settings', async () => {
await env.RECFLARE_PLAYER_SETTINGS.put(
'player:3201',
JSON.stringify({ 'Recroom.OOBE': '77', TUTORIAL_COMPLETE_MASK: '11' })
)
await write(3201, 'avoidJuniors=True')
expect(await stored(3201)).toEqual({
'Recroom.OOBE': '77',
TUTORIAL_COMPLETE_MASK: '11',
AvoidJuniors: 'True',
})
})
// Whichever spelling the player's map already carries is the one overwritten —
// two keys for one preference would make the read depend on their order.
test('overwrites an existing key rather than adding a second one', async () => {
await env.RECFLARE_PLAYER_SETTINGS.put(
'player:3202',
JSON.stringify({ AVOID_JUNIORS: 'True' })
)
expect(await write(3202, 'avoidJuniors=False')).toBe(false)
expect(await stored(3202)).toEqual({ AVOID_JUNIORS: 'False' })
})
test('accepts a JSON body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
method: 'PUT',
headers: {
...(await bearer('3203')),
'Content-Type': 'application/json',
},
body: JSON.stringify({ avoidJuniors: true }),
})
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
expect(await read(3203)).toBe(true)
})
// An unreadable body leaves the stored setting alone and answers it — a no-op 200,
// not a 400 and not a write of `false`.
test('a body with no readable value is a no-op', async () => {
await write(3204, 'avoidJuniors=True')
expect(await write(3204, 'avoidJuniors=maybe')).toBe(true)
expect(await write(3204, '')).toBe(true)
expect(await stored(3204)).toEqual({ AvoidJuniors: 'True' })
})
test('is auth-gated', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'avoidJuniors=True',
})
expect(res.status).toBe(401)
})
})
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => { test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
const headers = await bearer('88') const headers = await bearer('88')
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
@@ -1761,6 +1900,7 @@ describe('auth-gated endpoints', () => {
) )
expect([...documented].sort()).toEqual([ expect([...documented].sort()).toEqual([
'GET /player', 'GET /player',
'GET /player/avoidjuniors',
'GET /room/{roomId}/instances', 'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer', 'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus', 'GET /rooms/requiring/rrplus',
@@ -1778,6 +1918,7 @@ describe('auth-gated endpoints', () => {
'POST /player/notifydisconnect', 'POST /player/notifydisconnect',
'POST /roominstance/{id}/markprivate', 'POST /roominstance/{id}/markprivate',
'POST /roominstance/{id}/reportjoinresult', 'POST /roominstance/{id}/reportjoinresult',
'PUT /player/avoidjuniors',
'PUT /player/gameserverregionpings', 'PUT /player/gameserverregionpings',
'PUT /player/photonregionpings', 'PUT /player/photonregionpings',
'PUT /player/statusvisibility', 'PUT /player/statusvisibility',
+4 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
// Runtime types generated with workerd@1.20260625.1 2025-09-20 nodejs_compat // Runtime types generated with workerd@1.20260625.1 2026-06-16 nodejs_compat
// Begin runtime types // Begin runtime types
/*! ***************************************************************************** /*! *****************************************************************************
Copyright (c) Cloudflare. All rights reserved. Copyright (c) Cloudflare. All rights reserved.
@@ -420,6 +420,7 @@ interface TestController {
interface ExecutionContext<Props = unknown> { interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
passThroughOnException(): void; passThroughOnException(): void;
readonly exports: Cloudflare.Exports;
readonly props: Props; readonly props: Props;
cache?: CacheContext; cache?: CacheContext;
readonly access?: CloudflareAccessContext; readonly access?: CloudflareAccessContext;
@@ -526,6 +527,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
} }
interface DurableObjectState<Props = unknown> { interface DurableObjectState<Props = unknown> {
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
readonly exports: Cloudflare.Exports;
readonly props: Props; readonly props: Props;
readonly id: DurableObjectId; readonly id: DurableObjectId;
readonly storage: DurableObjectStorage; readonly storage: DurableObjectStorage;
@@ -1643,7 +1645,7 @@ declare class Headers {
value: string value: string
]>; ]>;
} }
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData; type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
declare abstract class Body { declare abstract class Body {
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
get body(): ReadableStream | null; get body(): ReadableStream | null;
+9
View File
@@ -15,6 +15,15 @@
"database_id": "local" "database_id": "local"
} }
], ],
// Per-player settings KV, owned by the `playersettings` worker. Read-only here, for
// GET /player/avoidjuniors. The "local" id placeholder is replaced with the real id
// from RECFLARE_KV at deploy time.
"kv_namespaces": [
{
"binding": "RECFLARE_PLAYER_SETTINGS",
"id": "local"
}
],
// Presence sweep. Rows expire on their own TTL (15m) and reads already ignore // Presence sweep. Rows expire on their own TTL (15m) and reads already ignore
// expired ones, so this is housekeeping: it purges them, deletes the room // expired ones, so this is housekeeping: it purges them, deletes the room
// instances left with nobody in them, and recomputes the fullness of the // instances left with nobody in them, and recomputes the fullness of the