[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'), .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({ 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, getHotRooms,
getInteraction, getInteraction,
getOrCreateDormRoom, getOrCreateDormRoom,
getPlayerData,
getPlayerIdsInRoom, getPlayerIdsInRoom,
getPresence, getPresence,
getPublicRoomsByCreator, getPublicRoomsByCreator,
@@ -61,6 +62,7 @@ import {
roomRoles, roomRoles,
saveSubRoomData, saveSubRoomData,
searchRooms, searchRooms,
setPlayerData,
setRoomDescription, setRoomDescription,
setRoomImage, setRoomImage,
setRoomLeaderboard, setRoomLeaderboard,
@@ -125,6 +127,7 @@ import {
pageParams, pageParams,
PhotonAccessTokenDto, PhotonAccessTokenDto,
PlayerDataDto, PlayerDataDto,
PlayerDataRequest,
playerIdParam, playerIdParam,
PublishSaveRequest, PublishSaveRequest,
PublishStateConfigsEnvelope, 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( .get(
'/rooms/:roomId{[0-9]+}/playerdata/me', '/rooms/:roomId{[0-9]+}/playerdata/me',
describeRoute({ describeRoute({
tags: ['Rooms'], tags: ['Rooms'],
summary: 'The caller’s per-room player data', summary: 'The caller’s per-room player data',
description: [ description: [
'Per-room save data for the calling player. Nothing stores any yet, so this is a stub', 'The blob the calling player last saved for this room through',
'serving an empty blob — which the client reads as “no saved data”. No auth: there’s', '`PUT /rooms/{roomId}/playerdata/me`, verbatim, as `Data`. A player who has never',
'no caller-specific state to protect until something writes here.', '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(' '), ].join(' '),
security: AUTHED,
parameters: [roomIdParam], 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 // 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: [ description: [
'Per-room experience/progression for the calling player. Nothing tracks any yet, so', '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', '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', 'a 404 would stall the room load. No auth: the answer is the same for every caller',
'the same for every caller until something writes here.', 'until something writes here.',
].join(' '), ].join(' '),
parameters: [roomIdParam], parameters: [roomIdParam],
responses: { 200: json(RoomExperiencePlayer, 'An empty list') }, responses: { 200: json(RoomExperiencePlayer, 'An empty list') },
@@ -2537,6 +2537,60 @@ describe('rooms endpoints', () => {
.run() .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 () => { 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. // RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
const del = async (path: string, sub?: string) => const del = async (path: string, sub?: string) =>
@@ -5392,6 +5446,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/loadscreen', 'PUT /rooms/{roomId}/loadscreen',
'PUT /rooms/{roomId}/max_player_calculation_mode', 'PUT /rooms/{roomId}/max_player_calculation_mode',
'PUT /rooms/{roomId}/name', 'PUT /rooms/{roomId}/name',
'PUT /rooms/{roomId}/playerdata/me',
'PUT /rooms/{roomId}/restrictions', 'PUT /rooms/{roomId}/restrictions',
'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/roles/{accountId}',
'PUT /rooms/{roomId}/roles/{accountId}/invite', 'PUT /rooms/{roomId}/roles/{accountId}/invite',
+51
View File
@@ -143,6 +143,16 @@ export const ROOM_SCHEMA_DDL: string[] = [
sort_ascending INTEGER NOT NULL DEFAULT 0, sort_ascending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, leaderboard_id) 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 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<string | null> {
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<string> {
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 * 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 * 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<void>
) )
.bind(roomId), .bind(roomId),
db.prepare('DELETE FROM subroom WHERE room_id = ?1').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),
]) ])
} }