mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[match] implement junior match pref
This commit is contained in:
@@ -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<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>`
|
||||
* 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 {
|
||||
AUTHED,
|
||||
AvoidJuniorsRequest,
|
||||
AvoidJuniorsResponse,
|
||||
EMPTY_OK,
|
||||
ExclusiveLoginResponse,
|
||||
form,
|
||||
@@ -133,6 +135,113 @@ function unauthorized(c: Context<App>) {
|
||||
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). */
|
||||
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 -----------------------------------------------------
|
||||
// Each matchmake persists the resulting instance as the player's presence so the
|
||||
// heartbeat can replay it (keeping client presence in sync).
|
||||
|
||||
@@ -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') })
|
||||
|
||||
|
||||
@@ -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<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 () => {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user