mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[rooms] fix #50 room leaderboards
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
-- Per-room leaderboard definitions. Generated from packages/domain/src/rooms-db.ts
|
||||
-- (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One row per (room, leaderboard). `leaderboard_id` is the client's slot number — small
|
||||
-- ordinals (1, 2, 3…), unique only within the room, which is why the pair is the primary
|
||||
-- key rather than the id alone. Re-posting a slot (POST /rooms/:id/leaderboards/:lid)
|
||||
-- reconfigures it in place; DELETE on the same path removes it.
|
||||
--
|
||||
-- `sort_ascending` stores the client's `sortAscending=True/False` as 1/0; `stat_format`
|
||||
-- is the client's `statFormat` int, echoed back as stored.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_leaderboard (
|
||||
room_id INTEGER NOT NULL,
|
||||
leaderboard_id INTEGER NOT NULL,
|
||||
leaderboard_title TEXT NOT NULL,
|
||||
stat_format INTEGER NOT NULL DEFAULT 0,
|
||||
sort_ascending INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (room_id, leaderboard_id)
|
||||
);
|
||||
@@ -98,6 +98,12 @@ export const playerIdParam = idParam('playerId', 'The account whose list to read
|
||||
/** The `:playerId` path parameter on the unban route. */
|
||||
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban')
|
||||
|
||||
/** The `:leaderboardId` path parameter on the room leaderboard routes. */
|
||||
export const leaderboardIdParam = idParam(
|
||||
'leaderboardId',
|
||||
'The leaderboard slot within the room — small ordinals (1, 2, 3…), unique per room only'
|
||||
)
|
||||
|
||||
/** An optional string query parameter. */
|
||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||
@@ -609,6 +615,28 @@ export const RoomBanEnvelope = z.object({
|
||||
value: RoomBanDto.nullable().describe('Null on a rejection'),
|
||||
})
|
||||
|
||||
/** `POST /rooms/{roomId}/leaderboards/{leaderboardId}` — configure one leaderboard slot. */
|
||||
export const LeaderboardRequest = z.object({
|
||||
leaderboardTitle: z.string().describe('The title the board displays'),
|
||||
statFormat: z.string().optional().describe('The stat-format int; defaults to 0'),
|
||||
sortAscending: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('`True` / `False` — whether lower scores rank first. Defaults to `False`'),
|
||||
})
|
||||
|
||||
/**
|
||||
* What both leaderboard routes answer: a bare success/failure carrying no entity.
|
||||
* PascalCase `Success`/`Error` with a lowercase `error_id` — the same mixed casing the
|
||||
* unprefixed isBanned envelope has ({@link IsBannedPascalEnvelope}), NOT the room
|
||||
* mutations' lowercase `{ success, error, value }`.
|
||||
*/
|
||||
export const LeaderboardResultEnvelope = z.object({
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('The message shown on a rejection; null on success'),
|
||||
error_id: z.string().nullable().describe('Null. Lowercase, unlike its siblings'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/warning`. */
|
||||
export const WarningRequest = z.object({
|
||||
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
countRoomsByCreator,
|
||||
createSubRoom,
|
||||
deleteRoom,
|
||||
deleteRoomLeaderboard,
|
||||
deleteSubRoom,
|
||||
findSubRoom,
|
||||
getBaseRooms,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
searchRooms,
|
||||
setRoomDescription,
|
||||
setRoomImage,
|
||||
setRoomLeaderboard,
|
||||
setRoomName,
|
||||
setRoomRole,
|
||||
setSubRoomPermissions,
|
||||
@@ -90,6 +92,9 @@ import {
|
||||
IsBannedPascalEnvelope,
|
||||
json,
|
||||
jsonBody,
|
||||
leaderboardIdParam,
|
||||
LeaderboardRequest,
|
||||
LeaderboardResultEnvelope,
|
||||
LoadScreenRequest,
|
||||
MissingLookupParam,
|
||||
ModifySubRoomRequest,
|
||||
@@ -643,6 +648,15 @@ function roomEnvelope(c: Context<App>, value: unknown, error = '') {
|
||||
*/
|
||||
const banEnvelope = roomEnvelope
|
||||
|
||||
/**
|
||||
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
|
||||
* no entity. PascalCase with a lowercase `error_id` — the same mixed casing the
|
||||
* unprefixed isBanned route serves — NOT the room mutations' lowercase envelope.
|
||||
*/
|
||||
function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
|
||||
return c.json({ Success: error === null, Error: error, error_id: null })
|
||||
}
|
||||
|
||||
/** Rooms created/owned by the authed caller (shared by the createdby routes). */
|
||||
async function ownedRooms(c: Context<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
@@ -2236,6 +2250,111 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Configure one of a room's leaderboard slots (form body `leaderboardTitle` +
|
||||
// `statFormat` + `sortAscending`). Auth-gated (401) and owner/co-owner-only (403).
|
||||
// One row per (room, slot) — re-posting a slot reconfigures it, so the call is
|
||||
// idempotent.
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Room settings'],
|
||||
summary: 'Configure a room leaderboard',
|
||||
description: [
|
||||
'Creates or reconfigures one leaderboard slot in the `room_leaderboard` table — one',
|
||||
'row per (room, slot), so re-posting a slot rewrites its title, format and direction',
|
||||
'rather than adding a second. The slot number in the path is the client’s small',
|
||||
'ordinal (1, 2, 3…), unique only within the room. Owner or co-owner only (403',
|
||||
'otherwise).',
|
||||
'',
|
||||
'`statFormat` is stored verbatim (default 0); `sortAscending` is the client’s',
|
||||
'`True`/`False` string (default `False`).',
|
||||
'',
|
||||
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
|
||||
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
|
||||
'mutations’ lowercase `{ success, error, value }`.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, leaderboardIdParam],
|
||||
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
|
||||
responses: {
|
||||
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return leaderboardEnvelope(c, 'This room does not exist!')
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const title = typeof body.leaderboardTitle === 'string' ? body.leaderboardTitle : ''
|
||||
const statFormat =
|
||||
typeof body.statFormat === 'string' ? Number.parseInt(body.statFormat, 10) : Number.NaN
|
||||
// The client sends .NET-style `True`/`False`; anything but a `true` reads false.
|
||||
const sortAscending =
|
||||
typeof body.sortAscending === 'string' && body.sortAscending.toLowerCase() === 'true'
|
||||
|
||||
await setRoomLeaderboard(
|
||||
c.env.DB,
|
||||
roomId,
|
||||
leaderboardId,
|
||||
title,
|
||||
Number.isNaN(statFormat) ? 0 : statFormat,
|
||||
sortAscending
|
||||
)
|
||||
return leaderboardEnvelope(c)
|
||||
}
|
||||
)
|
||||
|
||||
// Remove one of a room's leaderboard slots. Auth-gated (401) and owner/co-owner-only
|
||||
// (403). The client fires these blindly for every slot when tearing boards down, so a
|
||||
// slot that isn't configured is a rejection envelope, not an HTTP error.
|
||||
.delete(
|
||||
'/rooms/:roomId{[0-9]+}/leaderboards/:leaderboardId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Room settings'],
|
||||
summary: 'Remove a room leaderboard',
|
||||
description: [
|
||||
'Removes the slot’s `room_leaderboard` row. Owner or co-owner only (403 otherwise).',
|
||||
'',
|
||||
'Removing a slot that isn’t configured is a rejection (`Success: false`), not a',
|
||||
'silent success — the caller asked to undo something that was not there. The client',
|
||||
'deletes slots blindly when tearing boards down and tolerates the refusal.',
|
||||
'',
|
||||
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, leaderboardIdParam],
|
||||
responses: {
|
||||
200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return leaderboardEnvelope(c, 'This room does not exist!')
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
|
||||
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
|
||||
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
|
||||
return leaderboardEnvelope(c)
|
||||
}
|
||||
)
|
||||
|
||||
// Set a room's content warning: the `WarningMask` bit flags plus an optional
|
||||
// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||
// the `warningMask` form field (an integer) and an optional `customWarning` string
|
||||
|
||||
@@ -1818,6 +1818,90 @@ describe('rooms endpoints', () => {
|
||||
.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) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: sub ? await bearer(sub) : {},
|
||||
})
|
||||
const slotsOf = async (roomId: number) =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT leaderboard_id, leaderboard_title, stat_format, sort_ascending FROM room_leaderboard WHERE room_id = ?1 ORDER BY leaderboard_id'
|
||||
)
|
||||
.bind(roomId)
|
||||
.all()
|
||||
).results
|
||||
|
||||
// The real client body, verbatim.
|
||||
const body = { leaderboardTitle: 'full name', statFormat: '1', sortAscending: 'False' }
|
||||
|
||||
// No token → 401 (auth gate).
|
||||
expect((await postForm('/rooms/2/leaderboards/1', body)).status).toBe(401)
|
||||
expect((await del('/rooms/2/leaderboards/1')).status).toBe(401)
|
||||
// A valid token but no role on the room → 403.
|
||||
expect((await postForm('/rooms/2/leaderboards/1', body, '999')).status).toBe(403)
|
||||
expect((await del('/rooms/2/leaderboards/1', '999')).status).toBe(403)
|
||||
// The envelope both routes answer — PascalCase `Success`/`Error`, lowercase
|
||||
// `error_id`, no entity. NOT the room mutations' lowercase `{ success, error, value }`.
|
||||
const OK = { Success: true, Error: null, error_id: null }
|
||||
|
||||
// Unknown room → failure envelope.
|
||||
expect(await (await postForm('/rooms/99999/leaderboards/1', body, '1')).json()).toEqual({
|
||||
Success: false,
|
||||
Error: 'This room does not exist!',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
// The owner configures slot 1 — a bare success, with the row persisted.
|
||||
const ok = await postForm('/rooms/2/leaderboards/1', body, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await ok.json()).toEqual(OK)
|
||||
expect(await slotsOf(2)).toEqual([
|
||||
{ leaderboard_id: 1, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
|
||||
])
|
||||
|
||||
// Re-posting the slot reconfigures the one row rather than appending — and the
|
||||
// co-owner may do it. `sortAscending=True` parses case-insensitively.
|
||||
const rewrite = await postForm(
|
||||
'/rooms/2/leaderboards/1',
|
||||
{ leaderboardTitle: 'lap time', statFormat: '2', sortAscending: 'True' },
|
||||
'2'
|
||||
)
|
||||
expect(rewrite.status).toBe(200)
|
||||
expect(await rewrite.json()).toEqual(OK)
|
||||
expect(await slotsOf(2)).toEqual([
|
||||
{ leaderboard_id: 1, leaderboard_title: 'lap time', stat_format: 2, sort_ascending: 1 },
|
||||
])
|
||||
|
||||
// Slots are per room: slot 2 here and slot 1 of another room are their own rows.
|
||||
expect((await postForm('/rooms/2/leaderboards/2', body, '1')).status).toBe(200)
|
||||
expect((await postForm('/rooms/3/leaderboards/1', body, '1')).status).toBe(200)
|
||||
expect(await slotsOf(2)).toHaveLength(2)
|
||||
expect(await slotsOf(3)).toHaveLength(1)
|
||||
|
||||
// DELETE removes exactly the named slot.
|
||||
const removed = await del('/rooms/2/leaderboards/1', '1')
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toEqual(OK)
|
||||
expect(await slotsOf(2)).toEqual([
|
||||
{ leaderboard_id: 2, leaderboard_title: 'full name', stat_format: 1, sort_ascending: 0 },
|
||||
])
|
||||
expect(await slotsOf(3)).toHaveLength(1)
|
||||
|
||||
// Deleting a slot that isn't configured is a rejection, not an HTTP error — the
|
||||
// client tears boards down by deleting every slot blindly.
|
||||
expect(await (await del('/rooms/2/leaderboards/1', '1')).json()).toEqual({
|
||||
Success: false,
|
||||
Error: 'This room has no such leaderboard!',
|
||||
error_id: null,
|
||||
})
|
||||
|
||||
// Clean up the surviving rows so this test leaves no trace.
|
||||
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/bans kicks the banned player', async () => {
|
||||
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
@@ -3972,6 +4056,7 @@ describe('rooms endpoints', () => {
|
||||
'DELETE /rooms/{roomId}/bans/{playerId}',
|
||||
'DELETE /rooms/{roomId}/interactionby/me/cheer',
|
||||
'DELETE /rooms/{roomId}/interactionby/me/favorite',
|
||||
'DELETE /rooms/{roomId}/leaderboards/{leaderboardId}',
|
||||
'DELETE /rooms/{roomId}/subrooms/{subRoomId}',
|
||||
'GET /',
|
||||
'GET /Room_server/rooms/{roomId}/bans/{playerId}/isBanned',
|
||||
@@ -4010,6 +4095,7 @@ describe('rooms endpoints', () => {
|
||||
'POST /rooms/bulk',
|
||||
'POST /rooms/{roomId}/bans',
|
||||
'POST /rooms/{roomId}/clone',
|
||||
'POST /rooms/{roomId}/leaderboards/{leaderboardId}',
|
||||
'POST /rooms/{roomId}/subrooms',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/clone',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||
|
||||
Reference in New Issue
Block a user