mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 619b4c772c |
@@ -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
|
||||
|
||||
@@ -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<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 { 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<App>()
|
||||
'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<App>()
|
||||
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<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 () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
Reference in New Issue
Block a user