mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] playerdata
This commit is contained in:
@@ -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)
|
||||||
|
);
|
||||||
@@ -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)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user