[rooms] fix #50 room leaderboards

This commit is contained in:
Devin Zuczek
2026-09-02 00:45:08 -04:00
parent 9c63f077b8
commit 6adb3ab741
5 changed files with 336 additions and 0 deletions
+119
View File
@@ -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 clients 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 clients',
'`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 slots `room_leaderboard` row. Owner or co-owner only (403 otherwise).',
'',
'Removing a slot that isnt 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