This commit is contained in:
Devin Zuczek
2026-10-05 15:05:33 -04:00
parent 95538e89ae
commit e286532ac6
37 changed files with 5383 additions and 1559 deletions
+6 -2
View File
@@ -6,8 +6,12 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
/** Per-player settings store. Key `player:<id>` → JSON map of `{ key: value }`. */
RECFLARE_PLAYER_SETTINGS: KVNamespace
/**
* Shared `recflare` D1. This worker owns the `player_settings` table — one row per
* player holding their whole settings map as a JSON `{ key: value }` object (see
* `@repo/domain`'s player-settings-db).
*/
DB: D1Database
}
/** Variables can be extended */
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Default player settings seeded on a player's first read, ported verbatim from
* the reference's `PlayerSettingsController.GetPlayerSettings`. Ordered; written to KV the
* the reference's `PlayerSettingsController.GetPlayerSettings`. Ordered; written to the player’s row the
* first time a player has no stored settings.
*/
export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
+27 -30
View File
@@ -2,7 +2,12 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { mergePlayerSettings } from '@repo/domain'
import {
mergePlayerSettings,
putPlayerSettingsIfChanged,
readPlayerSettings,
writePlayerSettings,
} from '@repo/domain'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -25,9 +30,11 @@ import type { App } from './context'
/**
* Player Settings Worker. Serves the small key/value settings bag the game client reads
* on load and writes back as the player toggles options. Backed by a per-player KV map
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
* their first read.
* on load and writes back as the player toggles options. Backed by one row per player in
* the `player_settings` table on the shared D1 — the whole map as a JSON object, read and
* written through `@repo/domain`'s player-settings-db, which `api`, `match` and `chat` also
* merge their own keys into. A player with nothing stored is seeded with the reference
* defaults on their first read.
*
* Every `/playersettings` route is auth-gated on the Bearer JWT issued by the `auth` worker.
*/
@@ -147,7 +154,7 @@ const app = new Hono<App>()
)
// The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads
// the per-player KV map; seeds (and persists) the defaults on first read.
// the player's row; seeds (and persists) the defaults on first read.
.get(
'/playersettings',
describeRoute({
@@ -155,7 +162,7 @@ const app = new Hono<App>()
summary: 'The player’s settings',
description: [
'The authenticated player’s settings as `{ PlayerId, Key, Value }` entries, read from',
'their KV map. A player with nothing stored is seeded with the reference defaults',
'their stored map. A player with nothing stored is seeded with the reference defaults',
'(Recroom.OOBE, TUTORIAL_COMPLETE_MASK, FIRST_TIME_IN_FLAGS), which are persisted on',
'that first read.',
].join(' '),
@@ -169,34 +176,33 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const kvKey = `player:${id}`
let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json')
let stored = await readPlayerSettings(c.env.DB, id)
if (!stored || Object.keys(stored).length === 0) {
stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value]))
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored))
await writePlayerSettings(c.env.DB, id, stored)
}
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
}
)
// Upsert player settings into KV, keyed by the authenticated player id.
// Upsert player settings into the player's row, keyed by the authenticated player id.
// A full replace would overwrite the player's entire set; we merge so individual key PUTs
// (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest. The client re-posts values
// it already has at every login and menu change, so a PUT that changes nothing is a
// read only — KV writes are what cost money here.
// read only — the row is written only when a value changed.
.put(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'Write the player’s settings',
description: [
'Upserts the posted setting(s) into the caller’s KV map. The write MERGES: a single',
'Upserts the posted setting(s) into the caller’s settings map. The write MERGES: a single',
'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the',
'player’s other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
'values are already stored — nothing is written to KV. Empty body on success.',
'values are already stored — nothing is written. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
@@ -215,7 +221,7 @@ const app = new Hono<App>()
const patch: Record<string, string> = {}
for (const { key, value } of incoming) patch[key] = value
await mergePlayerSettings(c.env.RECFLARE_PLAYER_SETTINGS, id, patch)
await mergePlayerSettings(c.env.DB, id, patch)
return c.body(null, 200)
}
)
@@ -229,7 +235,7 @@ const app = new Hono<App>()
tags: ['Player Settings'],
summary: 'Delete a player setting',
description: [
'Removes the named setting(s) from the caller’s KV map. The client sends a bare',
'Removes the named setting(s) from the caller’s settings map. The client sends a bare',
'form-urlencoded `key=PlayerShoppingBagId` (no `value`); a JSON body — a string, a',
'`{ key }` object, or an array of either — and a `?key=` query param are also read.',
'Deleting a key that isn’t stored, or sending nothing to delete, is a no-op 200, not a',
@@ -252,22 +258,13 @@ const app = new Hono<App>()
const keys = await parseDeleteKeys(c)
if (keys.length === 0) return c.body(null, 200)
const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
const existing = await readPlayerSettings(c.env.DB, id)
if (!existing) return c.body(null, 200)
const remaining = { ...existing }
let removed = false
for (const key of keys) {
if (key in remaining) {
delete remaining[key]
removed = true
}
}
if (removed) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(remaining))
for (const key of keys) delete remaining[key]
// Nothing stored was named: no write.
await putPlayerSettingsIfChanged(c.env.DB, id, existing, remaining)
return c.body(null, 200)
}
@@ -287,8 +284,8 @@ app.get(
description: [
'The player key/value settings bag for recflare, a private-server reimplementation of',
'the Rec Room backend. The client reads these on load and writes them back as the',
'player toggles options; they are stored in a per-player KV map, seeded with the',
'reference defaults on a player’s first read.',
'player toggles options; they are stored as one JSON row per player on the shared',
'D1, seeded with the reference defaults on a player’s first read.',
].join('\n'),
},
servers: [{ url: 'https://playersettings.recflare.net', description: 'Production' }],
@@ -3,6 +3,8 @@ import { beforeAll, describe, expect, it } from 'vitest'
import '../../playersettings.app'
import { PLAYER_SETTINGS_SCHEMA_DDL, readPlayerSettings } from '@repo/domain'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -14,8 +16,33 @@ const ORIGIN = 'https://example.com'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The player_settings table this worker owns (mirror of migrations/0001).
for (const stmt of PLAYER_SETTINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/** The player's stored map, as the worker reads it. */
const stored = (playerId: number) => readPlayerSettings(env.DB, playerId)
/**
* The row's `data` exactly as stored. The "nothing was written" tests seed it with whitespace
* the worker's own JSON.stringify never produces: if that survives a request, no write happened.
*/
async function rawData(playerId: number): Promise<string | null> {
const row = await env.DB.prepare('SELECT data FROM player_settings WHERE account_id = ?1')
.bind(playerId)
.first<{ data: string }>()
return row?.data ?? null
}
async function seedRaw(playerId: number, data: string): Promise<void> {
await env.DB.prepare(
`INSERT INTO player_settings (account_id, data) VALUES (?1, ?2)
ON CONFLICT (account_id) DO UPDATE SET data = excluded.data`
)
.bind(playerId, data)
.run()
}
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
@@ -85,12 +112,9 @@ describe('playersettings endpoints', () => {
expect(settings.find((s) => s.Key === 'Recroom.OOBE')?.Value).toBe('77')
expect(settings.find((s) => s.Key === 'TUTORIAL_COMPLETE_MASK')?.Value).toBe('11')
// Defaults were persisted to KV.
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:100',
'json'
)
expect(stored?.['Recroom.OOBE']).toBe('77')
// Defaults were persisted.
const map = await stored(100)
expect(map?.['Recroom.OOBE']).toBe('77')
})
it('GET /playersettings reflects a value written by PUT', async () => {
@@ -109,18 +133,15 @@ describe('playersettings endpoints', () => {
expect(res.status).toBe(401)
})
it('PUT /playersettings persists the form key/value into KV', async () => {
it('PUT /playersettings persists the form key/value', async () => {
const res = await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'PlayerSessionCount', value: '1' }, await bearer('7'))
)
expect(res.status).toBe(200)
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:7',
'json'
)
expect(stored).toEqual({ PlayerSessionCount: '1' })
const map = await stored(7)
expect(map).toEqual({ PlayerSessionCount: '1' })
})
it('PUT /playersettings merges instead of replacing', async () => {
@@ -133,33 +154,30 @@ describe('playersettings endpoints', () => {
putForm({ key: 'B', value: '2' }, await bearer('8'))
)
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:8',
'json'
)
expect(stored).toEqual({ A: '1', B: '2' })
const map = await stored(8)
expect(map).toEqual({ A: '1', B: '2' })
})
// KV writes are the cost here, and the client re-posts settings it already has at every
// login. The raw value is seeded with whitespace the worker's own JSON.stringify would
// never produce: if it survives the PUT, nothing was written.
it('PUT /playersettings does not write KV when the value is already stored', async () => {
// The client re-posts settings it already has at every login, and a write costs more
// than a read. The raw value is seeded with whitespace the worker's own JSON.stringify
// would never produce: if it survives the PUT, nothing was written.
it('PUT /playersettings does not write when the value is already stored', async () => {
const padded = '{ "PlayerSessionCount": "5", "Recroom.OOBE": "77" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:10', padded)
await seedRaw(10, padded)
const same = await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'PlayerSessionCount', value: '5' }, await bearer('10'))
)
expect(same.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:10', 'text')).toBe(padded)
expect(await rawData(10)).toBe(padded)
// A real change is still written — and compacted, which is how we know.
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'PlayerSessionCount', value: '6' }, await bearer('10'))
)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:10', 'text')).toBe(
expect(await rawData(10)).toBe(
JSON.stringify({ PlayerSessionCount: '6', 'Recroom.OOBE': '77' })
)
})
@@ -193,11 +211,8 @@ describe('playersettings endpoints', () => {
)
expect(res.status).toBe(200)
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:20',
'json'
)
expect(stored).toEqual({ PlayerSessionCount: '3' })
const map = await stored(20)
expect(map).toEqual({ PlayerSessionCount: '3' })
})
it('DELETE /playersettings reads a body with no content-type', async () => {
@@ -213,11 +228,8 @@ describe('playersettings endpoints', () => {
})
expect(res.status).toBe(200)
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:21',
'json'
)
expect(stored).toEqual({})
const map = await stored(21)
expect(map).toEqual({})
})
it('DELETE /playersettings 200s for an unknown key and an empty body', async () => {
@@ -236,11 +248,8 @@ describe('playersettings endpoints', () => {
expect(empty.status).toBe(200)
// Neither call touched the stored map.
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
'player:22',
'json'
)
expect(stored).toEqual({ A: '1' })
const map = await stored(22)
expect(map).toEqual({ A: '1' })
})
it('GET /openapi.json documents every route', async () => {