mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[match] implement junior match pref
This commit is contained in:
+34
-28
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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 -----------------------------------------------------
|
// ---- 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).
|
||||||
|
|||||||
@@ -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') })
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 () => {
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user