From 619b4c772cd616dd73bf4010a5c91b953ca856b1 Mon Sep 17 00:00:00 2001 From: devin Date: Mon, 26 Oct 2026 23:47:04 -0400 Subject: [PATCH] [playersettings] Drop Growth.YIR telemetry and MakerPenSessionCount on write (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [playersettings] drop Growth.YIR telemetry on write The client posts its Year-In-Review counters (Growth.YIR.UGC.TubeDistance, Growth.YIR.DistanceTraveled, …) through PUT /playersettings with a new value every time, so the change-detection dedupe never skips them. A two-minute tail at ~500 CCU showed them as about half of all KV writes (3.7 writes/s overall, 448 writes from 448 PUTs). Nothing server-side reads them and the client has none for a new player, so a PUT naming one is answered 200 and nothing is stored. Keys stay unseeded for a player whose only posts are telemetry. Co-Authored-By: Claude Fable 5.1 * [playersettings] also drop MakerPenSessionCount A per-session counter the client bumps on every post, like the Growth.YIR telemetry; nothing reads it. The maker pen PREFERENCES (MakerPen_SnappingMode and the other MakerPen_* keys) are kept — the client reads those back. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- apps/playersettings/README.md | 7 ++++ apps/playersettings/src/default-settings.ts | 18 ++++++++++ apps/playersettings/src/playersettings.app.ts | 8 +++-- .../src/test/integration/api.test.ts | 36 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/playersettings/README.md b/apps/playersettings/README.md index adf0141..33c568a 100644 --- a/apps/playersettings/README.md +++ b/apps/playersettings/README.md @@ -22,6 +22,13 @@ Player-settings worker served on the `playersettings` subdomain. > Emptying the map with DELETE puts the player back to a first read: the next > `GET` re-seeds the defaults. +> Keys under `Growth.YIR.` (the client's Year-In-Review telemetry — distance +> drawn, time with friends, …) and `MakerPenSessionCount` are **dropped** on +> `PUT`, not stored. The client posts them with a new value every time, so they +> were over half of all writes and nothing reads them. Real maker pen preferences +> (`MakerPen_SnappingMode`, …) are kept. See `IGNORED_SETTING_KEYS` / +> `IGNORED_SETTING_PREFIXES` in `default-settings.ts`. + ## KV namespace ```sh diff --git a/apps/playersettings/src/default-settings.ts b/apps/playersettings/src/default-settings.ts index d460b1b..86a9774 100644 --- a/apps/playersettings/src/default-settings.ts +++ b/apps/playersettings/src/default-settings.ts @@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [ { Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' }, { Key: 'FIRST_TIME_IN_FLAGS', Value: '0' }, ] + +/** + * Settings the server DROPS on write, by exact key and by prefix. The client posts its + * Year-In-Review telemetry (`Growth.YIR.UGC.TubeDistance`, `Growth.YIR.DistanceTraveled`, …) + * and the `MakerPenSessionCount` counter through this endpoint as the player plays, and the + * values change every time, so they can never be deduplicated — measured at ~500 CCU they + * were over half of all settings writes. Nothing server-side reads them and the client works + * without them (a new player has none), so a PUT naming one is answered 200 and nothing is + * stored. Real maker pen preferences (`MakerPen_SnappingMode` and the other `MakerPen_*` + * keys) are kept: the client reads those back. + */ +export const IGNORED_SETTING_KEYS: ReadonlySet = new Set(['MakerPenSessionCount']) +export const IGNORED_SETTING_PREFIXES: readonly string[] = ['Growth.YIR.'] + +/** Whether a posted setting is one the server drops rather than stores. */ +export function isIgnoredSetting(key: string): boolean { + return IGNORED_SETTING_KEYS.has(key) || IGNORED_SETTING_PREFIXES.some((p) => key.startsWith(p)) +} diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts index efe5512..199abbe 100644 --- a/apps/playersettings/src/playersettings.app.ts +++ b/apps/playersettings/src/playersettings.app.ts @@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' -import { DEFAULT_SETTINGS } from './default-settings' +import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings' import { AUTHED, formOrJson, @@ -196,7 +196,9 @@ const app = new Hono() '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 to KV. Keys under `Growth.YIR.` (the', + 'client’s Year-In-Review telemetry) and `MakerPenSessionCount`, which change on every', + 'post and nothing reads, are dropped rather than stored. Empty body on success.', ].join(' '), security: AUTHED, requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'), @@ -209,7 +211,7 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) - const incoming = await parseSettings(c) + const incoming = (await parseSettings(c)).filter((s) => !isIgnoredSetting(s.key)) if (incoming.length === 0) return c.body(null, 200) const patch: Record = {} diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts index 7324fb5..edad86a 100644 --- a/apps/playersettings/src/test/integration/api.test.ts +++ b/apps/playersettings/src/test/integration/api.test.ts @@ -164,6 +164,42 @@ describe('playersettings endpoints', () => { ) }) + it('PUT /playersettings drops Growth.YIR and MakerPenSessionCount without writing', async () => { + const stored = '{ "PlayerSessionCount": "5" }' + await env.RECFLARE_PLAYER_SETTINGS.put('player:11', stored) + + const form = await SELF.fetch( + `${ORIGIN}/playersettings`, + putForm({ key: 'Growth.YIR.UGC.TubeDistance', value: '1234.5' }, await bearer('11')) + ) + expect(form.status).toBe(200) + expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'text')).toBe(stored) + + // A JSON batch keeps the real settings (a maker pen PREFERENCE included) and drops + // only the telemetry and the session counter. + const json = await SELF.fetch(`${ORIGIN}/playersettings`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...(await bearer('11')) }, + body: JSON.stringify([ + { key: 'Growth.YIR.DistanceTraveled', value: '99' }, + { key: 'MakerPenSessionCount', value: '3' }, + { key: 'MakerPen_SnappingMode', value: '2' }, + ]), + }) + expect(json.status).toBe(200) + expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'json')).toEqual({ + PlayerSessionCount: '5', + MakerPen_SnappingMode: '2', + }) + + // A player with nothing stored stays unseeded: no key is created for telemetry alone. + await SELF.fetch( + `${ORIGIN}/playersettings`, + putForm({ key: 'Growth.YIR.TimeSpentWithFriends', value: '7' }, await bearer('12')) + ) + expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:12', 'text')).toBeNull() + }) + it('PUT /playersettings 200s with no parseable settings', async () => { const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9'))) expect(res.status).toBe(200)