From b2942a0a51b8d52c45a1ae3c8f5320d48ba376e2 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 25 Sep 2026 12:17:11 -0400 Subject: [PATCH] [rooms] playerdata --- apps/rooms/migrations/0020_playerdata.sql | 15 +++++ apps/rooms/src/openapi.ts | 13 +++- apps/rooms/src/rooms.app.ts | 73 ++++++++++++++++++--- apps/rooms/src/test/integration/api.test.ts | 55 ++++++++++++++++ packages/domain/src/rooms-db.ts | 51 ++++++++++++++ 5 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 apps/rooms/migrations/0020_playerdata.sql diff --git a/apps/rooms/migrations/0020_playerdata.sql b/apps/rooms/migrations/0020_playerdata.sql new file mode 100644 index 0000000..70e3f14 --- /dev/null +++ b/apps/rooms/migrations/0020_playerdata.sql @@ -0,0 +1,15 @@ +-- Per-room, per-player variable data — what the client saves through +-- `PUT /rooms/{roomId}/playerdata/me` and reads back from `GET .../playerdata/me`. +-- +-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync. +-- +-- One row per (room, player). `data` is the client's blob exactly as posted (the `data` +-- form field, a base64 string); the server neither decodes nor validates it, and a +-- re-save overwrites the row in place, so the table holds only each player's latest. + +CREATE TABLE IF NOT EXISTS playerdata ( + room_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (room_id, player_id) + ); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 1ebcecb..2279fdc 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -982,9 +982,18 @@ export const PhotonAccessTokenDto = z.object({ .describe('The caller’s current instance, from presence; null when they’re in none'), }) -/** `GET /rooms/{roomId}/playerdata/me` — per-room player data. Nothing stores any yet. */ +/** + * `GET`/`PUT /rooms/{roomId}/playerdata/me` — the caller's per-room player data, as a + * bare one-key object. `Data` is the blob the client last posted, verbatim (a base64 + * string the server never decodes); empty when the player has never saved any. + */ export const PlayerDataDto = z.object({ - Data: z.string().describe('Always empty — no per-room player data is stored'), + Data: z.string().describe('The saved blob as posted; empty when nothing is saved'), +}) + +/** `PUT /rooms/{roomId}/playerdata/me` — the form body the client saves its data with. */ +export const PlayerDataRequest = z.object({ + data: z.string().describe('The blob to store, verbatim (the client posts base64)'), }) /** diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index fb45c6d..f0afd03 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -29,6 +29,7 @@ import { getHotRooms, getInteraction, getOrCreateDormRoom, + getPlayerData, getPlayerIdsInRoom, getPresence, getPublicRoomsByCreator, @@ -61,6 +62,7 @@ import { roomRoles, saveSubRoomData, searchRooms, + setPlayerData, setRoomDescription, setRoomImage, setRoomLeaderboard, @@ -125,6 +127,7 @@ import { pageParams, PhotonAccessTokenDto, PlayerDataDto, + PlayerDataRequest, playerIdParam, PublishSaveRequest, PublishStateConfigsEnvelope, @@ -4165,21 +4168,75 @@ const app = new Hono() } ) - // The caller's per-room player data. Stub → empty blob (client reads `Data`). + // The caller's per-room player data — the blob they last saved through the PUT below, + // or an empty one, which the client reads as "no saved data". Auth-gated (401): the + // answer is the caller's own. .get( '/rooms/:roomId{[0-9]+}/playerdata/me', describeRoute({ tags: ['Rooms'], summary: 'The caller’s per-room player data', description: [ - 'Per-room save data for the calling player. Nothing stores any yet, so this is a stub', - 'serving an empty blob — which the client reads as “no saved data”. No auth: there’s', - 'no caller-specific state to protect until something writes here.', + 'The blob the calling player last saved for this room through', + '`PUT /rooms/{roomId}/playerdata/me`, verbatim, as `Data`. A player who has never', + 'saved any — or an unknown room — gets an EMPTY `Data`, which the client reads as', + '“no saved data”; a 404 here would stall the room load.', ].join(' '), + security: AUTHED, parameters: [roomIdParam], - responses: { 200: json(PlayerDataDto, 'An empty data blob') }, + responses: { + 200: json(PlayerDataDto, 'The caller’s saved blob, or an empty one'), + 401: UNAUTHORIZED_RESPONSE, + }, }), - (c) => c.json({ Data: '' }) + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const roomId = Number.parseInt(c.req.param('roomId'), 10) + return c.json({ Data: (await getPlayerData(c.env.DB, roomId, accountId)) ?? '' }) + } + ) + + // Save the caller's per-room player data (form field `data`, a base64 blob the client + // builds and the server never decodes). Auth-gated (401). One row per (room, player) — + // a re-save overwrites, so the table holds only the latest. Answers the same bare + // `{ Data }` the GET serves, carrying what was stored. + .put( + '/rooms/:roomId{[0-9]+}/playerdata/me', + describeRoute({ + tags: ['Rooms'], + summary: 'Save the caller’s per-room player data', + description: [ + 'Stores the `data` form field — a base64 blob the client builds; the server neither', + 'decodes nor validates it — as the calling player’s data for this room, in the', + '`playerdata` table. One row per (room, player): a re-save overwrites the blob in', + 'place, so only the latest is kept. Any caller may save to any room; nothing here is', + 'owner-gated, since the data is the caller’s own. An unknown room is a 404.', + '', + 'Answers the same bare `{ Data }` the GET serves, carrying what was stored. That', + 'shape is an ASSUMPTION — what the client does with this response has not been', + 'observed; it has only been seen to read the GET.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam], + requestBody: form(PlayerDataRequest, 'The blob to store'), + responses: { + 200: json(PlayerDataDto, 'The stored blob'), + 401: UNAUTHORIZED_RESPONSE, + 404: { description: 'No such room (empty body)' }, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + if (!(await getRoomById(c.env.DB, roomId))) return c.body(null, 404) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const data = typeof body.data === 'string' ? body.data : '' + return c.json({ Data: await setPlayerData(c.env.DB, roomId, accountId, data) }) + } ) // A room's XP settings — whether players earn experience there and how much of it counts @@ -4218,8 +4275,8 @@ const app = new Hono() description: [ 'Per-room experience/progression for the calling player. Nothing tracks any yet, so', 'this is an empty list — which the client reads as “no progress in this room”, where', - 'a 404 would stall the room load. No auth, matching `playerdata/me`: the answer is', - 'the same for every caller until something writes here.', + 'a 404 would stall the room load. No auth: the answer is the same for every caller', + 'until something writes here.', ].join(' '), parameters: [roomIdParam], responses: { 200: json(RoomExperiencePlayer, 'An empty list') }, diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index f43b3d1..3a37a7d 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -2537,6 +2537,60 @@ describe('rooms endpoints', () => { .run() }) + it('GET/PUT /rooms/:id/playerdata/me stores and serves the caller’s per-room data', async () => { + const get = async (path: string, sub?: string) => + SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub) : {} }) + const rowsOf = async (roomId: number) => + ( + await env.DB.prepare( + 'SELECT player_id, data FROM playerdata WHERE room_id = ?1 ORDER BY player_id' + ) + .bind(roomId) + .all() + ).results + + // The real client body, verbatim: a base64 blob the server stores without decoding. + const blob = 'CAVKGhoYChIKEPpVnzZ50iJLpI8BPCdUjjoaAhAAUggKAAoACgAKAA==' + + // No token → 401 on both: the data is the caller's own. + expect((await get('/rooms/2/playerdata/me')).status).toBe(401) + expect((await putForm('/rooms/2/playerdata/me', { data: blob })).status).toBe(401) + // Unknown room → 404 on the write; the read serves an empty blob (a 404 would stall + // the room load). + expect((await putForm('/rooms/99999/playerdata/me', { data: blob }, '205')).status).toBe(404) + expect(await (await get('/rooms/99999/playerdata/me', '205')).json()).toEqual({ Data: '' }) + + // Nothing saved yet → empty `Data`. + expect(await (await get('/rooms/2/playerdata/me', '205')).json()).toEqual({ Data: '' }) + + // Any player may save to any room — 205 owns nothing here. The reply carries what + // was stored, and the GET reads it back verbatim. + const put = await putForm('/rooms/2/playerdata/me', { data: blob }, '205') + expect(put.status).toBe(200) + expect(await put.json()).toEqual({ Data: blob }) + expect(await (await get('/rooms/2/playerdata/me', '205')).json()).toEqual({ Data: blob }) + expect(await rowsOf(2)).toEqual([{ player_id: 205, data: blob }]) + + // A re-save overwrites the one row rather than appending. + expect(await (await putForm('/rooms/2/playerdata/me', { data: 'CAA=' }, '205')).json()).toEqual( + { Data: 'CAA=' } + ) + expect(await rowsOf(2)).toEqual([{ player_id: 205, data: 'CAA=' }]) + + // Keyed per (room, player): another player and another room are their own rows, + // and neither reads the other's. + expect((await putForm('/rooms/2/playerdata/me', { data: blob }, '1')).status).toBe(200) + expect((await putForm('/rooms/3/playerdata/me', { data: blob }, '205')).status).toBe(200) + expect(await rowsOf(2)).toEqual([ + { player_id: 1, data: blob }, + { player_id: 205, data: 'CAA=' }, + ]) + expect(await (await get('/rooms/3/playerdata/me', '205')).json()).toEqual({ Data: blob }) + expect(await (await get('/rooms/3/playerdata/me', '1')).json()).toEqual({ Data: '' }) + + await env.DB.prepare('DELETE FROM playerdata WHERE room_id IN (2, 3)').run() + }) + it('POST/DELETE /rooms/:id/leaderboards/:lid configures and removes a room’s leaderboard slots', async () => { // RecCenter (room 2) is owned by account 1, with account 2 as co-owner. const del = async (path: string, sub?: string) => @@ -5392,6 +5446,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/loadscreen', 'PUT /rooms/{roomId}/max_player_calculation_mode', 'PUT /rooms/{roomId}/name', + 'PUT /rooms/{roomId}/playerdata/me', 'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}/invite', diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 490d59c..e9063e3 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -143,6 +143,16 @@ export const ROOM_SCHEMA_DDL: string[] = [ sort_ascending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (room_id, leaderboard_id) )`, + // Per-room, per-player variable data (migrations/0020_playerdata.sql) — what the client + // saves through `PUT /rooms/:id/playerdata/me` and reads back from the GET. One row per + // (room, player); `data` is the posted blob verbatim (a base64 string the server never + // decodes), and a re-save overwrites it, so only each player's latest is kept. + `CREATE TABLE IF NOT EXISTS playerdata ( + room_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (room_id, player_id) + )`, ] /** @@ -655,6 +665,45 @@ export async function deleteRoomLeaderboard( return row ? toRoomLeaderboard(row) : null } +/** + * A player's saved data for one room — the blob the client posted, verbatim. Null when + * the player has never saved anything there. + */ +export async function getPlayerData( + db: D1Database, + roomId: number, + playerId: number +): Promise { + const row = await db + .prepare('SELECT data FROM playerdata WHERE room_id = ?1 AND player_id = ?2') + .bind(roomId, playerId) + .first<{ data: string }>() + return row ? row.data : null +} + +/** + * Store a player's data for one room, returning what was stored. One row per (room, + * player): a re-save overwrites the blob in place rather than keeping history, so the + * call is idempotent. + */ +export async function setPlayerData( + db: D1Database, + roomId: number, + playerId: number, + data: string +): Promise { + const row = await db + .prepare( + `INSERT INTO playerdata (room_id, player_id, data) VALUES (?1, ?2, ?3) + ON CONFLICT(room_id, player_id) DO UPDATE SET data = ?3 + RETURNING data` + ) + .bind(roomId, playerId, data) + .first<{ data: string }>() + // RETURNING always yields the upserted row. + return row!.data +} + /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given @@ -2599,6 +2648,8 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise ) .bind(roomId), db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId), + // Each player's saved data for the room: keyed by room, meaningless without it. + db.prepare('DELETE FROM playerdata WHERE room_id = ?1').bind(roomId), ]) }