mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[playersettings] Drop Growth.YIR telemetry and MakerPenSessionCount on write (#145)
* [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 <noreply@anthropic.com> * [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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
> Emptying the map with DELETE puts the player back to a first read: the next
|
||||||
> `GET` re-seeds the defaults.
|
> `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
|
## KV namespace
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
|
|||||||
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
|
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
|
||||||
{ Key: 'FIRST_TIME_IN_FLAGS', Value: '0' },
|
{ 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<string> = 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain'
|
|||||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import { DEFAULT_SETTINGS } from './default-settings'
|
import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
formOrJson,
|
formOrJson,
|
||||||
@@ -196,7 +196,9 @@ const app = new Hono<App>()
|
|||||||
'player’s other settings alone. A JSON body is also accepted, as one object or an',
|
'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',
|
'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',
|
'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(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
|
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
|
||||||
@@ -209,7 +211,7 @@ const app = new Hono<App>()
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(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)
|
if (incoming.length === 0) return c.body(null, 200)
|
||||||
|
|
||||||
const patch: Record<string, string> = {}
|
const patch: Record<string, string> = {}
|
||||||
|
|||||||
@@ -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 () => {
|
it('PUT /playersettings 200s with no parseable settings', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
|
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
Reference in New Issue
Block a user