[rooms] playerdata

This commit is contained in:
Devin Zuczek
2026-09-25 12:17:11 -04:00
parent 9c4c1bdd3a
commit b2942a0a51
5 changed files with 197 additions and 10 deletions
+15
View File
@@ -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)
);
+11 -2
View File
@@ -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)'),
})
/**
+65 -8
View File
@@ -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<App>()
}
)
// 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<string, unknown>
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<App>()
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') },
@@ -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',