From 36c30a396cac1679675e45bb58dd28a79f095857 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 12 Aug 2026 14:16:21 -0400 Subject: [PATCH] [match] implement junior match pref --- apps/match/README.md | 62 +++---- apps/match/src/context.ts | 7 + apps/match/src/match.app.ts | 171 ++++++++++++++++++++ apps/match/src/openapi.ts | 17 ++ apps/match/src/test/integration/api.test.ts | 141 ++++++++++++++++ apps/match/worker-configuration.d.ts | 6 +- apps/match/wrangler.jsonc | 9 ++ 7 files changed, 383 insertions(+), 30 deletions(-) diff --git a/apps/match/README.md b/apps/match/README.md index 8f0386c..c624ecd 100644 --- a/apps/match/README.md +++ b/apps/match/README.md @@ -6,29 +6,31 @@ instances and presence all live in the shared `recflare` D1 database. ## Routes -| Method | Path | Auth | Description | -| ------ | ------------------------------------ | ---- | ------------------------------------------------ | -| POST | `/player/login` | | Login ack (no-op; must not touch presence) | -| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` | -| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) | -| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) | -| GET | `/player?id=1&id=2,3` | | Batch player presence lookup | -| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) | -| PUT | `/player/statusvisibility` | ✓\* | Set status visibility | -| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) | -| POST | `/matchmake/none` | | Preserve current instance, else dorm | -| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom | -| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) | -| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) | -| POST | `/goto/none` | | Go to the dorm | -| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) | -| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) | -| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) | -| PUT | `/roominstance/:id/inprogress` | ✓ | Set the instance's in-progress flag | -| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) | -| 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) | +| Method | Path | Auth | Description | +| ------ | ------------------------------------ | ---- | ----------------------------------------------------- | +| POST | `/player/login` | | Login ack (no-op; must not touch presence) | +| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` | +| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) | +| POST | `/player/notifydisconnect` | | Disconnect notification (no-op ack) | +| GET | `/player?id=1&id=2,3` | | Batch player presence lookup | +| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) | +| PUT | `/player/statusvisibility` | ✓\* | Set status visibility | +| GET | `/player/avoidjuniors` | ✓ | The player's "avoid juniors" setting → `true`/`false` | +| PUT | `/player/avoidjuniors` | ✓ | Set it (`avoidJuniors=True`) → the resulting value | +| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) | +| POST | `/matchmake/none` | | Preserve current instance, else dorm | +| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom | +| POST | `/matchmake/room/:roomId` | ✓ | Matchmake into a room (default subroom) | +| POST | `/matchmake/:room` | ✓ | Matchmake by id or name (`dorm` → personal dorm) | +| POST | `/goto/none` | | Go to the dorm | +| PUT | `/player/photonregionpings` | | Region ping report (no-op ack) | +| PUT | `/player/gameserverregionpings` | | Region ping report (no-op ack) | +| POST | `/roominstance/:id/reportjoinresult` | | Report join result (no-op ack) | +| PUT | `/roominstance/:id/inprogress` | ✓ | Set the instance's in-progress flag | +| GET | `/room/:roomId/instances` | ✓ | A room's live instances (owner/co-owner only) | +| 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 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 -| Binding | Type | Notes | -| ------------ | ------------- | ------------------------------------------------------------ | -| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence | -| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | +| Binding | Type | Notes | +| -------------------------- | ------------- | ------------------------------------------------------------ | +| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence | +| `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; -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 diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index b1b6707..e5f7a0c 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -20,6 +20,13 @@ export type Env = SharedHonoEnv & { * player. Used by `POST /invite` to deliver the game-invite message to the invitee. */ RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace + /** + * The per-player settings map the `playersettings` worker owns (`player:` → 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 `=` * pairs — e.g. `2=100` or `2=MyHub,3=100` — where `from` is the room id the client diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index bc2508b..01af585 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -47,6 +47,8 @@ import { banEvasionMatch, resolveBan } from '../../api/src/bans-db' import { NotificationType } from '../../notify/src/notification-types' import { AUTHED, + AvoidJuniorsRequest, + AvoidJuniorsResponse, EMPTY_OK, ExclusiveLoginResponse, form, @@ -133,6 +135,113 @@ function unauthorized(c: Context) { 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 | 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 | null> { + return env.RECFLARE_PLAYER_SETTINGS.get>( + `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 { + 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 { + const stored = (await getPlayerSettings(env, accountId)) ?? {} + const merged: Record = { ...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): Promise { + const contentType = c.req.header('content-type') ?? '' + const body = contentType.includes('application/json') + ? await c.req.json().catch(() => null) + : await c.req.parseBody().catch(() => null) + if (body === null || typeof body !== 'object') return undefined + + const key = findAvoidJuniorsKey(body as Record) + return key === undefined ? undefined : parseSettingBool((body as Record)[key]) +} + /** A synthesized room instance (same shape for dorm and other rooms). */ type RoomInstance = ReturnType @@ -1008,6 +1117,68 @@ const app = new Hono() } ) + // 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 player’s “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 player’s “avoid juniors” preference', + description: [ + 'Stores the posted preference in the authenticated player’s settings map (the', + '`playersettings` KV) and answers the resulting value as a bare JSON boolean. The', + 'write merges, so the player’s 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 ----------------------------------------------------- // Each matchmake persists the resulting instance as the player's presence so the // heartbeat can replay it (keeping client presence in sync). diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index d2133d6..2690191 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -150,6 +150,23 @@ export const MatchmakeResponse = z.object({ 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. */ export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 595f5e7..0474be4 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -279,6 +279,145 @@ describe('public endpoints', () => { 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) => + 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>(`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 player’s 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 () => { const headers = await bearer('88') const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { @@ -1761,6 +1900,7 @@ describe('auth-gated endpoints', () => { ) expect([...documented].sort()).toEqual([ 'GET /player', + 'GET /player/avoidjuniors', 'GET /room/{roomId}/instances', 'GET /rooms/requiring/developer', 'GET /rooms/requiring/rrplus', @@ -1778,6 +1918,7 @@ describe('auth-gated endpoints', () => { 'POST /player/notifydisconnect', 'POST /roominstance/{id}/markprivate', 'POST /roominstance/{id}/reportjoinresult', + 'PUT /player/avoidjuniors', 'PUT /player/gameserverregionpings', 'PUT /player/photonregionpings', 'PUT /player/statusvisibility', diff --git a/apps/match/worker-configuration.d.ts b/apps/match/worker-configuration.d.ts index b267e04..3187584 100644 --- a/apps/match/worker-configuration.d.ts +++ b/apps/match/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* 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 /*! ***************************************************************************** Copyright (c) Cloudflare. All rights reserved. @@ -420,6 +420,7 @@ interface TestController { interface ExecutionContext { waitUntil(promise: Promise): void; passThroughOnException(): void; + readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; readonly access?: CloudflareAccessContext; @@ -526,6 +527,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u } interface DurableObjectState { waitUntil(promise: Promise): void; + readonly exports: Cloudflare.Exports; readonly props: Props; readonly id: DurableObjectId; readonly storage: DurableObjectStorage; @@ -1643,7 +1645,7 @@ declare class Headers { value: string ]>; } -type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData; +type BodyInit = ReadableStream | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable | AsyncIterable; declare abstract class Body { /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ get body(): ReadableStream | null; diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index 1c0216c..3901ef6 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -15,6 +15,15 @@ "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 // expired ones, so this is housekeeping: it purges them, deletes the room // instances left with nobody in them, and recomputes the fullness of the