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
+5 -8
View File
@@ -13,20 +13,17 @@ export type Env = SharedHonoEnv & {
// player's personal dorm room on first entry, and holds player presence — the
// room instance each player is currently in (written by matchmake/heartbeat,
// read by the heartbeat and the batch `/player` lookup). See @repo/domain's
// presence-db (table owned/migrated by the `rooms` worker).
// presence-db (table owned/migrated by the `rooms` worker). Also holds the
// per-player settings map (`player_settings`, owned by the `playersettings`
// worker), touched only by `GET|PUT /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.
DB: D1Database
/**
* The `notify` worker's NotificationsHub DO — pushes websocket notifications to a
* 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
+5 -5
View File
@@ -468,12 +468,12 @@ function parseSettingBool(value: unknown): boolean | undefined {
}
}
/** The player's settings map from the KV the `playersettings` worker owns. */
/** The player's settings map from the `player_settings` table the `playersettings` worker owns. */
async function getPlayerSettings(
env: Env,
accountId: number
): Promise<Record<string, string> | null> {
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
return readPlayerSettings(env.DB, accountId).catch(() => null)
}
/**
@@ -496,14 +496,14 @@ async function readAvoidJuniors(env: Env, accountId: number): Promise<boolean> {
* every setting the player has (OOBE state, tutorial mask, …), so storing this one on its
* own would wipe the rest. The key is whichever spelling the map already carries, which is
* why this doesn't go through `mergePlayerSettings` — but like it, re-posting the stored
* value writes nothing. A KV read failure throws (a 500) rather than reading as an empty
* value writes nothing. A read failure throws (a 500) rather than reading as an empty
* map, which would store this one key over everything the player had.
*/
async function writeAvoidJuniors(env: Env, accountId: number, value: boolean): Promise<void> {
const stored = await readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId)
const stored = await readPlayerSettings(env.DB, accountId)
const merged: Record<string, string> = { ...stored }
merged[findAvoidJuniorsKey(merged) ?? AVOID_JUNIORS_KEY] = value ? 'True' : 'False'
await putPlayerSettingsIfChanged(env.RECFLARE_PLAYER_SETTINGS, accountId, stored, merged)
await putPlayerSettingsIfChanged(env.DB, accountId, stored, merged)
}
/**
+31 -14
View File
@@ -15,7 +15,9 @@ import {
GAME_VERSION,
getRoomInstance,
modifySubRoom,
PLAYER_SETTINGS_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
readPlayerSettings,
recordRoomVisit,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
@@ -138,6 +140,23 @@ const TEST_ROOMS = [
},
]
/** Seed a player's `player_settings` row with raw JSON text — what the KV value used to be. */
const seedSettings = (playerId: number, data: string) =>
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()
/** The row's `data` exactly as stored — a seeded value surviving a request means no write happened. */
const rawSettings = async (playerId: number) =>
(
await env.DB.prepare('SELECT data FROM player_settings WHERE account_id = ?1')
.bind(playerId)
.first<{ data: string }>()
)?.data ?? null
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')
@@ -155,6 +174,8 @@ beforeAll(async () => {
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Stats (owned by this worker) — the presence cron samples the online count into it.
for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Player settings (owned by the playersettings worker) — /player/avoidjuniors is one key.
for (const stmt of PLAYER_SETTINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as.
@@ -548,11 +569,11 @@ 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 "avoid juniors" preference lives in the playersettings 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))
seedSettings(playerId, JSON.stringify(map))
const read = async (playerId: number) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
@@ -597,8 +618,7 @@ describe('public endpoints', () => {
})
describe('PUT /player/avoidjuniors', () => {
const stored = async (playerId: number) =>
env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`player:${playerId}`, 'json')
const stored = async (playerId: number) => readPlayerSettings(env.DB, playerId)
const write = async (playerId: number, body: string) => {
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
@@ -631,8 +651,8 @@ describe('public endpoints', () => {
// 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',
await seedSettings(
3201,
JSON.stringify({ 'Recroom.OOBE': '77', TUTORIAL_COMPLETE_MASK: '11' })
)
await write(3201, 'avoidJuniors=True')
@@ -646,24 +666,21 @@ describe('public endpoints', () => {
// 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' })
)
await seedSettings(3202, JSON.stringify({ AVOID_JUNIORS: 'True' }))
expect(await write(3202, 'avoidJuniors=False')).toBe(false)
expect(await stored(3202)).toEqual({ AVOID_JUNIORS: 'False' })
})
// Re-posting the stored value writes nothing to KV — writes are the cost, and the
// Re-posting the stored value writes nothing — writes are the cost, and the
// client posts this freely. The raw value is seeded with whitespace JSON.stringify
// never produces; surviving the PUT untouched means no write happened. Holds across
// the loose key match too: `AVOID_JUNIORS: 'True'` already says what `avoidJuniors=True`
// asks for.
test('does not write KV when the value is already stored', async () => {
test('does not write when the value is already stored', async () => {
const padded = '{ "Recroom.OOBE": "77", "AVOID_JUNIORS": "True" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:3205', padded)
await seedSettings(3205, padded)
expect(await write(3205, 'avoidJuniors=True')).toBe(true)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:3205', 'text')).toBe(padded)
expect(await rawSettings(3205)).toBe(padded)
expect(await write(3205, 'avoidJuniors=False')).toBe(false)
expect(await stored(3205)).toEqual({ 'Recroom.OOBE': '77', AVOID_JUNIORS: 'False' })