mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
Leaderboards (#43)
* leaderboards * [leaderboard] add basic leaderboards - will probably have to clean up later but data is collected now
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
-- A room's leaderboards: each player's value on each of a room's stat channels. Owned by
|
||||||
|
-- the `leaderboard` worker, which is the only reader and the only writer —
|
||||||
|
-- `POST /leaderboard/CheckAndSetStat` writes a row and the three reads
|
||||||
|
-- (`GetRanks`, `GetNearbyScores`, `GetPlayerRank`) rank them. Generated from
|
||||||
|
-- src/leaderboard-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- One row per (player, room, channel). A row is created the first time a player posts a
|
||||||
|
-- stat for that channel of the room; a player with no row is simply not on that board,
|
||||||
|
-- which the reads answer as an empty slice / the unranked sentinel rather than by
|
||||||
|
-- inserting on a read.
|
||||||
|
--
|
||||||
|
-- `stat_channel` is the client's `StatChannel` — which of the room's tracked stats this
|
||||||
|
-- is (a room keeps one board per channel) — and `stat_value` is what it posts as
|
||||||
|
-- `StatValue`. What a channel counts (wins, laps, a time) is the room's business; the
|
||||||
|
-- server only orders on it, highest first unless the client asks for ascending, ties
|
||||||
|
-- broken on the lower `player_id` so a rank is stable between two reads.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS leaderboard (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
stat_channel INTEGER NOT NULL,
|
||||||
|
stat_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (player_id, room_id, stat_channel)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The reads walk one board (room + channel) ordered by value; the primary key serves the
|
||||||
|
-- point lookup but not that scan.
|
||||||
|
CREATE INDEX IF NOT EXISTS leaderboard_board_value
|
||||||
|
ON leaderboard (room_id, stat_channel, stat_value DESC, player_id);
|
||||||
@@ -12,10 +12,13 @@
|
|||||||
"deploy": "run-wrangler-deploy",
|
"deploy": "run-wrangler-deploy",
|
||||||
"dev": "run-wrangler-dev",
|
"dev": "run-wrangler-dev",
|
||||||
"fix:workers-types": "run-wrangler-types",
|
"fix:workers-types": "run-wrangler-types",
|
||||||
|
"migrate": "run-wrangler-migrate",
|
||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
|
"@repo/jwt": "workspace:*",
|
||||||
"@standard-community/standard-json": "0.3.5",
|
"@standard-community/standard-json": "0.3.5",
|
||||||
"@standard-community/standard-openapi": "0.2.9",
|
"@standard-community/standard-openapi": "0.2.9",
|
||||||
"hono": "4.12.27",
|
"hono": "4.12.27",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type Env = SharedHonoEnv & {
|
|||||||
* `auth` verify here.
|
* `auth` verify here.
|
||||||
*/
|
*/
|
||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
|
/** Shared `recflare` D1 database; this worker owns the `leaderboard` table. */
|
||||||
|
DB: D1Database
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* A room's leaderboards on the shared `recflare` D1 database: each player's value on each
|
||||||
|
* of a room's stat channels.
|
||||||
|
*
|
||||||
|
* One table, one row per (player, room, channel), created the first time a player posts a
|
||||||
|
* stat for that channel of the room via `POST /leaderboard/CheckAndSetStat`. A player with
|
||||||
|
* no row is not on that board: the reads answer that as an empty slice or {@link UNRANKED}
|
||||||
|
* rather than inserting on a read.
|
||||||
|
*
|
||||||
|
* A board is a (room, channel) pair — `stat_channel` is the client's `StatChannel`, which of
|
||||||
|
* the room's tracked stats this is, and `stat_value` is what it posts as `StatValue`. What a
|
||||||
|
* channel counts (wins, laps, a time) is the room's business; the server only orders on it.
|
||||||
|
*
|
||||||
|
* Ranks are 1-based and total: the board is ordered by `stat_value` (highest first unless
|
||||||
|
* the client asks for ascending) with ties broken on the lower `player_id`, so two players
|
||||||
|
* with the same score never share a rank and a rank is stable between two reads.
|
||||||
|
*
|
||||||
|
* A board can be read through a FRIENDS filter (the client's `FilterType` 1): the same rows
|
||||||
|
* restricted to the viewer and the people they are friends with, ranked among themselves —
|
||||||
|
* so a player who is 40th globally can be 2nd among friends. Friendship is the `api`
|
||||||
|
* worker's `relationship` table on the same database, read here through a subquery rather
|
||||||
|
* than an `IN (...)` list so a player with hundreds of friends doesn't hit D1's bind limit.
|
||||||
|
*
|
||||||
|
* The `leaderboard` worker owns the schema/migrations (migrations/0001_leaderboard.sql,
|
||||||
|
* applied under its own `migrations_table` so they don't clash with the other workers'
|
||||||
|
* migrations that share the database).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RelationshipType } from '@repo/domain'
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0001_leaderboard.sql). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS leaderboard (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
stat_channel INTEGER NOT NULL,
|
||||||
|
stat_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (player_id, room_id, stat_channel)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS leaderboard_board_value
|
||||||
|
ON leaderboard (room_id, stat_channel, stat_value DESC, player_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank a player who isn't on the board gets. `Rank` is 1-based: a 0 would render as
|
||||||
|
* first place and a negative one may not render at all. A number far past the end of any
|
||||||
|
* real board reads as last, which is what an unscored player is, and is recognisable in a
|
||||||
|
* log or a screenshot as a sentinel rather than a real standing.
|
||||||
|
*/
|
||||||
|
export const UNRANKED = 99999
|
||||||
|
|
||||||
|
/** The score behind {@link UNRANKED}: no stat has ever been stored, and 0 is "no score". */
|
||||||
|
export const NO_SCORE = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a board as the client renders it — the same three fields `GetPlayerRank`
|
||||||
|
* answers, so a row and a standing are the one shape.
|
||||||
|
*/
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
PlayerId: number
|
||||||
|
Score: number
|
||||||
|
Rank: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
player_id: number
|
||||||
|
stat_value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which board: one of a room's stat channels, optionally seen through one player's friends. */
|
||||||
|
export interface Board {
|
||||||
|
roomId: number
|
||||||
|
statChannel: number
|
||||||
|
/** When set, only this player and their friends are on the board. */
|
||||||
|
friendsOf?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WHERE clause selecting a board's rows, with the room and channel bound as `?1`/`?2`
|
||||||
|
* and, for a friends board, the viewer as `?3`. Callers number any further parameters from
|
||||||
|
* {@link Scope.next}.
|
||||||
|
*/
|
||||||
|
interface Scope {
|
||||||
|
where: string
|
||||||
|
binds: number[]
|
||||||
|
next: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function scope(board: Board): Scope {
|
||||||
|
if (board.friendsOf === undefined) {
|
||||||
|
return {
|
||||||
|
where: 'room_id = ?1 AND stat_channel = ?2',
|
||||||
|
binds: [board.roomId, board.statChannel],
|
||||||
|
next: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
where: `room_id = ?1 AND stat_channel = ?2 AND (player_id = ?3 OR player_id IN (
|
||||||
|
SELECT CASE WHEN requester_id = ?3 THEN target_id ELSE requester_id END
|
||||||
|
FROM relationship
|
||||||
|
WHERE relationship_type = ${RelationshipType.Friend} AND (requester_id = ?3 OR target_id = ?3)
|
||||||
|
))`,
|
||||||
|
binds: [board.roomId, board.statChannel, board.friendsOf],
|
||||||
|
next: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ORDER BY for a board. Ties break on the lower player id either way. */
|
||||||
|
function order(sortAscending: boolean): string {
|
||||||
|
return sortAscending ? 'stat_value ASC, player_id ASC' : 'stat_value DESC, player_id ASC'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of a board: ranks `rankStart`..`rankEnd`, both 1-based and inclusive.
|
||||||
|
* A `rankStart` below 1 is clamped to the top; an empty or inverted range is an empty page.
|
||||||
|
*/
|
||||||
|
export async function getRanks(
|
||||||
|
db: D1Database,
|
||||||
|
board: Board,
|
||||||
|
rankStart: number,
|
||||||
|
rankEnd: number,
|
||||||
|
sortAscending: boolean
|
||||||
|
): Promise<LeaderboardEntry[]> {
|
||||||
|
const start = Math.max(1, rankStart)
|
||||||
|
const limit = rankEnd - start + 1
|
||||||
|
if (limit <= 0) return []
|
||||||
|
const s = scope(board)
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT player_id, stat_value FROM leaderboard WHERE ${s.where}
|
||||||
|
ORDER BY ${order(sortAscending)} LIMIT ?${s.next} OFFSET ?${s.next + 1}`
|
||||||
|
)
|
||||||
|
.bind(...s.binds, limit, start - 1)
|
||||||
|
.all<LeaderboardRow>()
|
||||||
|
return results.map((r, i) => ({ PlayerId: r.player_id, Score: r.stat_value, Rank: start + i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One player's standing on a board: their score and 1-based rank, or
|
||||||
|
* {@link UNRANKED} with {@link NO_SCORE} when they have no row there.
|
||||||
|
*
|
||||||
|
* The rank is one more than the count of players placed ahead — a higher score, or the
|
||||||
|
* same score and a lower id — so it matches the position {@link getRanks} would give.
|
||||||
|
*/
|
||||||
|
export async function getPlayerRank(
|
||||||
|
db: D1Database,
|
||||||
|
board: Board,
|
||||||
|
playerId: number,
|
||||||
|
sortAscending: boolean
|
||||||
|
): Promise<LeaderboardEntry> {
|
||||||
|
const s = scope(board)
|
||||||
|
const mine = await db
|
||||||
|
.prepare(`SELECT stat_value FROM leaderboard WHERE ${s.where} AND player_id = ?${s.next}`)
|
||||||
|
.bind(...s.binds, playerId)
|
||||||
|
.first<{ stat_value: number }>()
|
||||||
|
if (!mine) return { PlayerId: playerId, Score: NO_SCORE, Rank: UNRANKED }
|
||||||
|
const [pid, val] = [s.next, s.next + 1]
|
||||||
|
const ahead = sortAscending
|
||||||
|
? `(stat_value < ?${val} OR (stat_value = ?${val} AND player_id < ?${pid}))`
|
||||||
|
: `(stat_value > ?${val} OR (stat_value = ?${val} AND player_id < ?${pid}))`
|
||||||
|
const count = await db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM leaderboard WHERE ${s.where} AND ${ahead}`)
|
||||||
|
.bind(...s.binds, playerId, mine.stat_value)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
return { PlayerId: playerId, Score: mine.stat_value, Rank: (count?.n ?? 0) + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most rows either side of a player `GetNearbyScores` will serve, whatever it asks. */
|
||||||
|
export const MAX_WINDOW = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows around one player on a board: `windowSize` entries on either side of their
|
||||||
|
* rank (at most {@link MAX_WINDOW}), clamped to the board. A player who isn't on the board
|
||||||
|
* gets the top of it — a full window's worth — so the screen still draws something.
|
||||||
|
*/
|
||||||
|
export async function getNearbyScores(
|
||||||
|
db: D1Database,
|
||||||
|
board: Board,
|
||||||
|
playerId: number,
|
||||||
|
windowSize: number,
|
||||||
|
sortAscending: boolean
|
||||||
|
): Promise<LeaderboardEntry[]> {
|
||||||
|
const window = Math.min(Math.max(windowSize, 1), MAX_WINDOW)
|
||||||
|
const mine = await getPlayerRank(db, board, playerId, sortAscending)
|
||||||
|
if (mine.Rank === UNRANKED) return getRanks(db, board, 1, window * 2 + 1, sortAscending)
|
||||||
|
return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compare-and-set of one player's value on one board, the write behind
|
||||||
|
* `POST /leaderboard/CheckAndSetStat`.
|
||||||
|
*
|
||||||
|
* `expected` is what the client believes is stored: with a number, the row is written only
|
||||||
|
* if it still holds that value, which is how a stale client is kept from walking a board
|
||||||
|
* backwards; with null the client believes nothing is stored, and the value is written
|
||||||
|
* regardless — a row that exists is overwritten rather than the write being refused, since
|
||||||
|
* the client's belief about a fresh room is the one it can't have checked.
|
||||||
|
*
|
||||||
|
* Returns whether the write landed.
|
||||||
|
*/
|
||||||
|
export async function checkAndSetStat(
|
||||||
|
db: D1Database,
|
||||||
|
board: Board,
|
||||||
|
playerId: number,
|
||||||
|
value: number,
|
||||||
|
expected: number | null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard (player_id, room_id, stat_channel, stat_value)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT (player_id, room_id, stat_channel) DO UPDATE SET stat_value = excluded.stat_value
|
||||||
|
WHERE ?5 IS NULL OR stat_value = ?5`
|
||||||
|
)
|
||||||
|
.bind(playerId, board.roomId, board.statChannel, value, expected)
|
||||||
|
.run()
|
||||||
|
return (result.meta.changes ?? 0) > 0
|
||||||
|
}
|
||||||
@@ -3,6 +3,17 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import {
|
||||||
|
checkAndSetStat,
|
||||||
|
getNearbyScores,
|
||||||
|
getPlayerRank,
|
||||||
|
getRanks,
|
||||||
|
MAX_WINDOW,
|
||||||
|
NO_SCORE,
|
||||||
|
UNRANKED,
|
||||||
|
} from './leaderboard-db'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CheckAndSetStatBody,
|
CheckAndSetStatBody,
|
||||||
@@ -17,26 +28,54 @@ import {
|
|||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
import type { Board } from './leaderboard-db'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the
|
* Leaderboard Worker. One board per (room, stat channel), stored in the `leaderboard` table
|
||||||
* client parses, with no rows, no rank and no stored stats behind them.
|
* (see leaderboard-db.ts): `CheckAndSetStat` writes the caller's value on one, and the three
|
||||||
|
* reads rank them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/** A board selector as the client posts it. Every field is optional on the wire — a body
|
||||||
* The rank a player who isn't on the board gets. Nothing is scored here, so every caller is
|
* that names nothing still gets an answer, it's just an empty one. */
|
||||||
* unranked — but "unranked" has to be said in the client's own vocabulary, and `Rank` is
|
interface BoardBody {
|
||||||
* 1-based: a 0 would render as first place and a negative one may not render at all. A
|
PlayerId?: number
|
||||||
* number far past the end of any real board reads as last, which is what an unscored player
|
RoomId?: number
|
||||||
* is, and is recognisable in a log or a screenshot as a sentinel rather than a real standing.
|
StatChannel?: number
|
||||||
*/
|
FilterType?: number
|
||||||
const UNRANKED = 99999
|
SortAscending?: boolean
|
||||||
|
RankStart?: number
|
||||||
|
RankEnd?: number
|
||||||
|
WindowSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a JSON body leniently: an unreadable one is `{}`, never an error — a board that
|
||||||
|
* fails to draw is worse than one that draws empty. */
|
||||||
|
async function readBody<T extends object>(c: { req: { json<U>(): Promise<U> } }): Promise<T> {
|
||||||
|
return c.req.json<T>().catch(() => ({}) as T)
|
||||||
|
}
|
||||||
|
|
||||||
|
const int = (v: unknown, fallback: number) => (Number.isInteger(v) ? (v as number) : fallback)
|
||||||
|
|
||||||
|
/** The client's `FilterType`: who a board counts. */
|
||||||
|
const enum FilterType {
|
||||||
|
Global = 0,
|
||||||
|
Friends = 1,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The score behind {@link UNRANKED}. Zero rather than a second sentinel: no stat has ever
|
* The board a body names: `RoomId` + `StatChannel`, each 0 when absent, seen through
|
||||||
* been stored, and 0 is what "no score" means in the client's own units.
|
* `PlayerId`'s friends when `FilterType` is Friends. A friends board with no `PlayerId`
|
||||||
|
* has nobody to be friends of, so it falls back to the global one rather than to nothing.
|
||||||
*/
|
*/
|
||||||
const NO_SCORE = 0
|
function board(body: BoardBody): Board {
|
||||||
|
const playerId = int(body.PlayerId, 0)
|
||||||
|
return {
|
||||||
|
roomId: int(body.RoomId, 0),
|
||||||
|
statChannel: int(body.StatChannel, 0),
|
||||||
|
...(int(body.FilterType, 0) === FilterType.Friends && playerId !== 0 && { friendsOf: playerId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
@@ -77,34 +116,41 @@ const app = new Hono<App>()
|
|||||||
// a blank board instead of failing. The key must be present — a bare `{}` trips its
|
// a blank board instead of failing. The key must be present — a bare `{}` trips its
|
||||||
// parser.
|
// parser.
|
||||||
//
|
//
|
||||||
// Nothing is ranked or stored yet, so the request body is ignored. It IS logged: the
|
// The body is GetPlayerRank's plus `WindowSize`: the rows `WindowSize` either side of
|
||||||
// body's shape hasn't been recovered from the client, and this route is how it gets
|
// the player's rank (capped at MAX_WINDOW whatever the client asks — the client asks for
|
||||||
// watched. Read it as text — the shape is unknown, so parsing it would only invent one —
|
// 10), or the top of the board when they aren't on it. `FilterType` 1 restricts the
|
||||||
// and never fail on it, since an unreadable body must not cost the client its board.
|
// board to the player and their friends, ranked among themselves. An unreadable body is
|
||||||
|
// answered with an empty board, never an error.
|
||||||
.post(
|
.post(
|
||||||
'/leaderboard/GetNearbyScores',
|
'/leaderboard/GetNearbyScores',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Leaderboard'],
|
tags: ['Leaderboard'],
|
||||||
summary: 'The scores around a player',
|
summary: 'The scores around a player',
|
||||||
description: [
|
description: [
|
||||||
'What the client shows when it opens a leaderboard ON someone rather than at the top.',
|
'What the client shows when it opens a leaderboard ON someone rather than at the top:',
|
||||||
|
`the rows \`WindowSize\` (at most ${MAX_WINDOW}, the default) either side of \`PlayerId\`’s`,
|
||||||
|
'rank on the board `RoomId` + `StatChannel` names, or the top of the board when the',
|
||||||
|
'player isn’t on it. `FilterType` 1 (Friends) restricts the board to `PlayerId` and',
|
||||||
|
'their friends, ranked among themselves.',
|
||||||
'',
|
'',
|
||||||
'Nothing is scored or stored on this server yet, so `Rows` is always empty — a complete',
|
'An empty `Rows` is a complete answer meaning "this leaderboard has no scores", which',
|
||||||
'answer meaning "this leaderboard has no scores", which the client renders as a blank',
|
'the client renders as a blank board rather than failing. The key is always present; a',
|
||||||
'board rather than failing. The key is always present; a bare `{}` trips its parser.',
|
'bare `{}` trips its parser. An unreadable body is answered with an empty board.',
|
||||||
'',
|
|
||||||
'The request body is IGNORED, and logged rather than parsed: its shape has not been',
|
|
||||||
'recovered from the client, so this route is how it gets watched. An unreadable body is',
|
|
||||||
'not an error either — it must not cost the client its board.',
|
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: jsonBody(GetNearbyScoresBody, 'Ignored and logged; shape not yet recovered'),
|
requestBody: jsonBody(GetNearbyScoresBody, 'The player and the board to centre on'),
|
||||||
responses: { 200: json(LeaderboardRows, 'The board, always with no rows') },
|
responses: { 200: json(LeaderboardRows, 'The rows around the player') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = await c.req.text().catch(() => '<unreadable>')
|
const body = await readBody<BoardBody>(c)
|
||||||
logger.info('GetNearbyScores', { body })
|
logger.info('GetNearbyScores', { body })
|
||||||
|
|
||||||
const rows: unknown[] = []
|
const rows = await getNearbyScores(
|
||||||
|
c.env.DB,
|
||||||
|
board(body),
|
||||||
|
int(body.PlayerId, 0),
|
||||||
|
int(body.WindowSize, MAX_WINDOW),
|
||||||
|
body.SortAscending === true
|
||||||
|
)
|
||||||
return c.json({ Rows: rows })
|
return c.json({ Rows: rows })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -116,8 +162,9 @@ const app = new Hono<App>()
|
|||||||
//
|
//
|
||||||
// Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY
|
// Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY
|
||||||
// `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must
|
// `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must
|
||||||
// be present. Nothing is ranked or stored yet, so the body is ignored — only logged, and
|
// be present. Ranks are 1-based; a `RankStart` of 0 is read as the top. `FilterType` 1
|
||||||
// read as text so an unreadable body can never cost the client its board.
|
// ranks the viewer and their friends among themselves. An unreadable body is answered
|
||||||
|
// with an empty board, never an error.
|
||||||
.post(
|
.post(
|
||||||
'/leaderboard/GetRanks',
|
'/leaderboard/GetRanks',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -129,20 +176,26 @@ const app = new Hono<App>()
|
|||||||
'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,',
|
'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,',
|
||||||
'`SortAscending`).',
|
'`SortAscending`).',
|
||||||
'',
|
'',
|
||||||
'Answers exactly what `GetNearbyScores` answers, under the same rules: `Rows` is always',
|
'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`',
|
||||||
'empty because nothing is scored or stored here yet, and the key is always present.',
|
'names (1-based; 0 is read as the top), highest value first unless `SortAscending`. An',
|
||||||
'',
|
'empty `Rows` means "this leaderboard has no scores"; the key is always present.',
|
||||||
'The body is IGNORED — it is logged, not parsed — so the fields are documented as the',
|
'`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked',
|
||||||
'record of what the client asks for rather than as anything the handler reads.',
|
'among themselves.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'),
|
requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'),
|
||||||
responses: { 200: json(LeaderboardRows, 'The board, always with no rows') },
|
responses: { 200: json(LeaderboardRows, 'The requested slice of the board') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = await c.req.text().catch(() => '<unreadable>')
|
const body = await readBody<BoardBody>(c)
|
||||||
logger.info('GetRanks', { body })
|
logger.info('GetRanks', { body })
|
||||||
|
|
||||||
const rows: unknown[] = []
|
const rows = await getRanks(
|
||||||
|
c.env.DB,
|
||||||
|
board(body),
|
||||||
|
int(body.RankStart, 1),
|
||||||
|
int(body.RankEnd, 10),
|
||||||
|
body.SortAscending === true
|
||||||
|
)
|
||||||
return c.json({ Rows: rows })
|
return c.json({ Rows: rows })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -156,9 +209,9 @@ const app = new Hono<App>()
|
|||||||
// therefore the one field read out of the body: answering with a different player's id
|
// therefore the one field read out of the body: answering with a different player's id
|
||||||
// would be answering a question nobody asked.
|
// would be answering a question nobody asked.
|
||||||
//
|
//
|
||||||
// Nothing is scored here, so every caller is unranked and gets {@link UNRANKED} with a
|
// A player with no row in the room gets {@link UNRANKED} with a zero score. A body that
|
||||||
// zero score. A body that can't be read still gets an answer — a board that fails to draw
|
// can't be read still gets an answer — a board that fails to draw is worse than one that
|
||||||
// is worse than one that draws the player as unranked — so `PlayerId` falls back to 0.
|
// draws the player as unranked — so `PlayerId` falls back to 0.
|
||||||
.post(
|
.post(
|
||||||
'/leaderboard/GetPlayerRank',
|
'/leaderboard/GetPlayerRank',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -169,24 +222,28 @@ const app = new Hono<App>()
|
|||||||
'the board — the body names the player and the board (`RoomId` + `StatChannel` +',
|
'the board — the body names the player and the board (`RoomId` + `StatChannel` +',
|
||||||
'`FilterType`: Global 0, Friends 1).',
|
'`FilterType`: Global 0, Friends 1).',
|
||||||
'',
|
'',
|
||||||
'Nothing is scored or stored on this server yet, so the answer is always the same:',
|
'`Score` is the player’s value on the board `RoomId` + `StatChannel` names and `Rank`',
|
||||||
`\`Rank\` ${UNRANKED}, a sentinel meaning unranked (ranks are 1-based, so a 0 would`,
|
`their 1-based position on it. A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`,
|
||||||
'render as first place), and `Score` 0.',
|
'unranked (ranks are 1-based, so a 0 would render as first place), and `Score` 0.',
|
||||||
'',
|
'',
|
||||||
'`PlayerId` is echoed from the request and is the only field read out of it — the',
|
'`FilterType` 1 (Friends) ranks the player among their friends only.',
|
||||||
'response carries no board selectors, so the client matches the answer to its own',
|
'',
|
||||||
'question. An unreadable body is answered rather than rejected, with a `PlayerId` of 0.',
|
'`PlayerId` is echoed from the request — the response carries no board selectors, so',
|
||||||
|
'the client matches the answer to its own question. An unreadable body is answered',
|
||||||
|
'rather than rejected, with a `PlayerId` of 0.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'),
|
requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'),
|
||||||
responses: { 200: json(PlayerRank, 'The player’s standing — always unranked') },
|
responses: { 200: json(PlayerRank, 'The player’s standing') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = await c.req
|
const body = await readBody<BoardBody>(c)
|
||||||
.json<{ PlayerId?: number }>()
|
|
||||||
.catch(() => ({}) as { PlayerId?: number })
|
|
||||||
logger.info('GetPlayerRank', { body })
|
logger.info('GetPlayerRank', { body })
|
||||||
|
|
||||||
return c.json({ PlayerId: body.PlayerId ?? 0, Score: NO_SCORE, Rank: UNRANKED })
|
const playerId = int(body.PlayerId, 0)
|
||||||
|
if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED })
|
||||||
|
return c.json(
|
||||||
|
await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -196,9 +253,11 @@ const app = new Hono<App>()
|
|||||||
// how a room's high-score board avoids being walked backwards by a stale client. There is
|
// how a room's high-score board avoids being walked backwards by a stale client. There is
|
||||||
// no `PlayerId`: the stat belongs to whoever is calling.
|
// no `PlayerId`: the stat belongs to whoever is calling.
|
||||||
//
|
//
|
||||||
// Nothing is stored yet, so the write is accepted and dropped. The answer is a BARE `0` —
|
// The caller comes from the bearer token — no token, 401, since a stat with no owner has
|
||||||
// not an envelope, not `{ value: 0 }` — which is what the live service returns and so what
|
// nowhere to go. The write lands in `leaderboard` as the caller's value on the board
|
||||||
// the client's parser expects. The body is logged, not read.
|
// `RoomId` + `StatChannel` names. The answer is a BARE `0` — not an
|
||||||
|
// envelope, not `{ value: 0 }` — which is what the live service returns and so what the
|
||||||
|
// client's parser expects; it is 0 even when the compare failed and nothing was written.
|
||||||
.post(
|
.post(
|
||||||
'/leaderboard/CheckAndSetStat',
|
'/leaderboard/CheckAndSetStat',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -209,19 +268,45 @@ const app = new Hono<App>()
|
|||||||
'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes',
|
'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes',
|
||||||
'nothing is). No `PlayerId` — the stat belongs to the caller.',
|
'nothing is). No `PlayerId` — the stat belongs to the caller.',
|
||||||
'',
|
'',
|
||||||
'Nothing is stored on this server yet, so the write is accepted and dropped. The',
|
'Stores `StatValue` as the caller’s value on the board `RoomId` + `StatChannel` names',
|
||||||
'response is the BARE number `0`, not an envelope and not a `{ value }` wrapper — what',
|
'(the caller is the Bearer token; 401 without one). With a numeric `CurrentStatValue`',
|
||||||
'the live service answers, and what the client’s parser expects.',
|
'the row is written only if it still holds that value; with null it is written',
|
||||||
|
'regardless.',
|
||||||
'',
|
'',
|
||||||
'The body is IGNORED and logged, which is how these shapes get recovered from a live',
|
'The response is the BARE number `0`, not an envelope and not a `{ value }` wrapper —',
|
||||||
'client.',
|
'what the live service answers, and what the client’s parser expects — whether or not',
|
||||||
|
'the compare passed.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'),
|
requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'),
|
||||||
responses: { 200: json(CheckAndSetStatResponse, 'Always the bare number 0') },
|
responses: {
|
||||||
|
200: json(CheckAndSetStatResponse, 'Always the bare number 0'),
|
||||||
|
401: { description: 'No valid bearer token' },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = await c.req.text().catch(() => '<unreadable>')
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
logger.info('CheckAndSetStat', { body })
|
if (accountId === null) return c.body(null, 401)
|
||||||
|
|
||||||
|
const body = await readBody<{
|
||||||
|
RoomId?: number
|
||||||
|
StatChannel?: number
|
||||||
|
StatValue?: number
|
||||||
|
CurrentStatValue?: number | null
|
||||||
|
}>(c)
|
||||||
|
logger.info('CheckAndSetStat', { accountId, body })
|
||||||
|
|
||||||
|
const target = board(body)
|
||||||
|
if (target.roomId !== 0 && typeof body.StatValue === 'number') {
|
||||||
|
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
|
||||||
|
const written = await checkAndSetStat(
|
||||||
|
c.env.DB,
|
||||||
|
target,
|
||||||
|
accountId,
|
||||||
|
Math.trunc(body.StatValue),
|
||||||
|
expected
|
||||||
|
)
|
||||||
|
if (!written) logger.info('CheckAndSetStat: stale, not written', { accountId, ...target })
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(0)
|
return c.json(0)
|
||||||
}
|
}
|
||||||
@@ -242,17 +327,20 @@ app.get(
|
|||||||
'Leaderboards for recflare, a private-server reimplementation of the Rec Room',
|
'Leaderboards for recflare, a private-server reimplementation of the Rec Room',
|
||||||
'backend — the boards a room keeps for the stats it tracks.',
|
'backend — the boards a room keeps for the stats it tracks.',
|
||||||
'',
|
'',
|
||||||
'NOTHING IS SCORED HERE YET, and every route answers accordingly rather than',
|
'One board per (room, stat channel): `CheckAndSetStat` stores the caller’s value on',
|
||||||
'failing: the two board reads answer `{ "Rows": [] }`, an empty list being a',
|
'one, and the reads rank them — highest first unless `SortAscending`, ties broken on',
|
||||||
'complete answer meaning "this leaderboard has no scores" (the `Rows` key is always',
|
'the lower player id, ranks 1-based. `FilterType` 1 reads a board as the viewer and',
|
||||||
'present — a bare `{}` trips the client’s parser); `GetPlayerRank` answers a rank of',
|
'their friends only (the `api` worker’s `relationship` table), ranked among',
|
||||||
'99999, the sentinel for unranked, with a score of 0; and `CheckAndSetStat` accepts',
|
'themselves.',
|
||||||
'a stat write, drops it, and answers the bare number `0`.',
|
|
||||||
'',
|
'',
|
||||||
'Only `GetPlayerRank` reads anything out of its request body, and only the',
|
'The two board reads answer `{ "Rows": [ { PlayerId, Score, Rank } ] }`, an empty',
|
||||||
'`PlayerId` it echoes back. Every route logs the body verbatim, which is how these',
|
'list being a complete answer meaning "this leaderboard has no scores" (the `Rows`',
|
||||||
'shapes get recovered from a live client; `GetNearbyScores`’ body is still unknown',
|
'key is always present — a bare `{}` trips the client’s parser); `GetPlayerRank`',
|
||||||
'for exactly that reason. No route needs a token today.',
|
'answers a player with no row a rank of 99999, the sentinel for unranked, with a',
|
||||||
|
'score of 0; and `CheckAndSetStat` answers the bare number `0`.',
|
||||||
|
'',
|
||||||
|
'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.',
|
||||||
|
'Unreadable bodies are answered (empty board / unranked), never rejected.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
|
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function json(schema: z.ZodType, description: string) {
|
|||||||
/**
|
/**
|
||||||
* Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s
|
* Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s
|
||||||
* `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and
|
* `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and
|
||||||
* `additionalProperties: false` are dropped — the handlers read nothing out of these
|
* `additionalProperties: false` are dropped — the handlers ignore fields they don't read,
|
||||||
* bodies at all, so a closed object would misreport them as stricter than they are.
|
* so a closed object would misreport them as stricter than they are.
|
||||||
*/
|
*/
|
||||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||||
@@ -45,18 +45,15 @@ export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.Re
|
|||||||
/**
|
/**
|
||||||
* Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`.
|
* Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`.
|
||||||
*
|
*
|
||||||
* `Rows` is EMPTY on this server — nothing scores anything yet — and an empty list is a
|
* An EMPTY `Rows` is a complete answer meaning "this leaderboard has no scores", which the
|
||||||
* complete answer meaning "this leaderboard has no scores", which the client renders as a
|
* client renders as a blank board rather than failing. The key must be present; a bare `{}`
|
||||||
* blank board rather than failing. The key must be present; a bare `{}` trips its parser.
|
* trips its parser. Each row is the same `{ PlayerId, Score, Rank }` {@link PlayerRank}
|
||||||
*
|
* answers — the shape both reference servers serve.
|
||||||
* The row shape is therefore undocumented: no live response has ever carried one, so
|
|
||||||
* describing a row here would be inventing it. It is typed as an open object rather than
|
|
||||||
* `unknown` so a viewer shows an object in the array.
|
|
||||||
*/
|
*/
|
||||||
export const LeaderboardRows = z.object({
|
export const LeaderboardRows = z.object({
|
||||||
Rows: z
|
Rows: z
|
||||||
.array(z.looseObject({}))
|
.array(z.lazy(() => PlayerRank))
|
||||||
.describe('The board’s rows. Always empty — nothing is scored or stored yet.'),
|
.describe('The board’s rows, in rank order. Empty when the board has no scores.'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,14 +61,14 @@ export const LeaderboardRows = z.object({
|
|||||||
* `{"PlayerId":205,"Score":4200,"Rank":17}`.
|
* `{"PlayerId":205,"Score":4200,"Rank":17}`.
|
||||||
*
|
*
|
||||||
* Three fields only: none of the board selectors the request names are echoed back, so the
|
* Three fields only: none of the board selectors the request names are echoed back, so the
|
||||||
* client matches the answer to the question by having asked it. Nothing is scored here yet,
|
* client matches the answer to the question by having asked it. A player with no row gets
|
||||||
* so `Rank` is a constant sentinel and `Score` is zero — see the route for why that pairing
|
* the 99999 sentinel with a zero score — see the route for why that rather than a rank of
|
||||||
* rather than a rank of 0, which would read as "first place".
|
* 0, which would read as "first place".
|
||||||
*/
|
*/
|
||||||
export const PlayerRank = z.object({
|
export const PlayerRank = z.object({
|
||||||
PlayerId: z.int().describe('Echoed from the request — whose rank this is'),
|
PlayerId: z.int().describe('Echoed from the request — whose rank this is'),
|
||||||
Score: z.int().describe('The stat value behind the rank. Always 0 — nothing is scored yet'),
|
Score: z.int().describe('The player’s value on the board; 0 when they have no row there'),
|
||||||
Rank: z.int().describe('1-based position on the board. Always 99999 — an unranked sentinel'),
|
Rank: z.int().describe('1-based position on the board; 99999 when the player isn’t on it'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,9 +88,8 @@ export const CheckAndSetStatResponse = z
|
|||||||
* The body the client posts to `GetRanks`, e.g.
|
* The body the client posts to `GetRanks`, e.g.
|
||||||
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
|
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
|
||||||
*
|
*
|
||||||
* Recovered from a live client, not from a spec, and IGNORED by the handler today — the
|
* Recovered from a live client, not from a spec. `FilterType` 1 restricts the board to
|
||||||
* board is empty whatever it says. It is documented because it is the record of what the
|
* `PlayerId` and their friends.
|
||||||
* client asks for, which is what an implementation will have to answer.
|
|
||||||
*/
|
*/
|
||||||
export const GetRanksBody = z.object({
|
export const GetRanksBody = z.object({
|
||||||
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
|
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
|
||||||
@@ -110,7 +106,7 @@ export const GetRanksBody = z.object({
|
|||||||
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
|
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
|
||||||
*
|
*
|
||||||
* The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about
|
* The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about
|
||||||
* one player rather than a page. Only `PlayerId` is read today, to echo it back.
|
* one player rather than a page.
|
||||||
*/
|
*/
|
||||||
export const GetPlayerRankBody = z.object({
|
export const GetPlayerRankBody = z.object({
|
||||||
PlayerId: z.int().describe('The player whose rank is being asked for'),
|
PlayerId: z.int().describe('The player whose rank is being asked for'),
|
||||||
@@ -139,11 +135,13 @@ export const CheckAndSetStatBody = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client —
|
* The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`. This
|
||||||
* the handler logs the raw text precisely so it can be — so this documents an open object
|
* is the reference servers' shape (the client's `GetNearbyScoresRequestDTO` extends its
|
||||||
* rather than guessing fields. Expect it to name a player and a board the way
|
* rank request with `WindowSize`); it has not yet been watched from a live client here,
|
||||||
* {@link GetRanksBody} does.
|
* which is why the handler still logs it.
|
||||||
*/
|
*/
|
||||||
export const GetNearbyScoresBody = z
|
export const GetNearbyScoresBody = GetPlayerRankBody.extend({
|
||||||
.looseObject({})
|
WindowSize: z
|
||||||
.describe('Unknown shape — logged by the handler so it can be recovered from a live client.')
|
.int()
|
||||||
|
.describe('How many rows either side of the player to return; default and maximum 10'),
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,82 @@
|
|||||||
import { SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { expect, it } from 'vitest'
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { RELATIONSHIP_SCHEMA_DDL, RelationshipType } from '@repo/domain'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL } from '../../leaderboard-db'
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||||
|
await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET)
|
||||||
|
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// The `api` worker's relationship table — the friends filter reads it.
|
||||||
|
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
})
|
||||||
|
|
||||||
|
const befriend = (a: number, b: number) =>
|
||||||
|
env.DB.prepare(
|
||||||
|
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||||
|
)
|
||||||
|
.bind(a, b, RelationshipType.Friend)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
|
||||||
|
// into the JWT_SECRET store, so this worker's validation accepts it.
|
||||||
|
const TEST_SECRET = 'test-signing-key'
|
||||||
|
|
||||||
|
function b64url(input: ArrayBuffer | string): string {
|
||||||
|
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bearer(sub: number): Promise<Record<string, string>> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||||
|
JSON.stringify({ sub: String(sub), exp: now + 3600 })
|
||||||
|
)}`
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(TEST_SECRET),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
)
|
||||||
|
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
|
||||||
|
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = (path: string, body: unknown, headers: Record<string, string> = {}) =>
|
||||||
|
SELF.fetch(`https://example.com/leaderboard/${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', ...headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function setStat(
|
||||||
|
player: number,
|
||||||
|
RoomId: number,
|
||||||
|
StatValue: number,
|
||||||
|
CurrentStatValue: number | null = null,
|
||||||
|
StatChannel = 2
|
||||||
|
) {
|
||||||
|
const res = await post(
|
||||||
|
'CheckAndSetStat',
|
||||||
|
{ StatChannel, RoomId, StatValue, CurrentStatValue },
|
||||||
|
await bearer(player)
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// The whole body is the number — not an envelope, not `{ value: 0 }`.
|
||||||
|
expect(await res.text()).toBe('0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = (player: number, room: number, channel = 2) =>
|
||||||
|
env.DB.prepare(
|
||||||
|
'SELECT stat_value FROM leaderboard WHERE player_id = ?1 AND room_id = ?2 AND stat_channel = ?3'
|
||||||
|
)
|
||||||
|
.bind(player, room, channel)
|
||||||
|
.first<{ stat_value: number }>()
|
||||||
|
|
||||||
it('response with hello world', async () => {
|
it('response with hello world', async () => {
|
||||||
const res = await SELF.fetch('https://example.com')
|
const res = await SELF.fetch('https://example.com')
|
||||||
@@ -7,42 +84,34 @@ it('response with hello world', async () => {
|
|||||||
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
|
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('an empty board', () => {
|
||||||
it('answers GetNearbyScores with an empty row list', async () => {
|
it('answers GetNearbyScores with an empty row list', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/leaderboard/GetNearbyScores', {
|
const res = await post('GetNearbyScores', { PlayerId: 1, RoomId: 999, StatChannel: 1 })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ Rows: [] })
|
expect(await res.json()).toEqual({ Rows: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('answers GetRanks with an empty row list', async () => {
|
it('answers GetRanks with an empty row list', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', {
|
const res = await post('GetRanks', {
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
RankStart: 0,
|
RankStart: 0,
|
||||||
RankEnd: 9,
|
RankEnd: 9,
|
||||||
PlayerId: 2,
|
PlayerId: 2,
|
||||||
StatChannel: 1,
|
StatChannel: 1,
|
||||||
RoomId: 6,
|
RoomId: 999,
|
||||||
FilterType: 0,
|
FilterType: 0,
|
||||||
SortAscending: false,
|
SortAscending: false,
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ Rows: [] })
|
expect(await res.json()).toEqual({ Rows: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('answers GetPlayerRank with the unranked sentinel and the caller’s own id', async () => {
|
it('answers GetPlayerRank with the unranked sentinel and the caller’s own id', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
|
const res = await post('GetPlayerRank', {
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
PlayerId: 205,
|
PlayerId: 205,
|
||||||
StatChannel: 2,
|
StatChannel: 2,
|
||||||
RoomId: 14,
|
RoomId: 999,
|
||||||
FilterType: 0,
|
FilterType: 0,
|
||||||
SortAscending: false,
|
SortAscending: false,
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
// Three fields, no board selectors: the client pairs the answer with its own question.
|
// Three fields, no board selectors: the client pairs the answer with its own question.
|
||||||
@@ -61,14 +130,293 @@ it('answers GetPlayerRank even when the body is unreadable', async () => {
|
|||||||
expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 })
|
expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('answers CheckAndSetStat with a bare 0', async () => {
|
it('answers GetRanks with an empty board when the body is unreadable', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/leaderboard/CheckAndSetStat', {
|
const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ StatChannel: 2, RoomId: 14, StatValue: 1, CurrentStatValue: null }),
|
body: 'not json',
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
// The whole body is the number — not an envelope, not `{ value: 0 }`.
|
expect(await res.json()).toEqual({ Rows: [] })
|
||||||
expect(await res.text()).toBe('0')
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CheckAndSetStat', () => {
|
||||||
|
it('refuses a write with no bearer token', async () => {
|
||||||
|
const res = await post('CheckAndSetStat', {
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: 14,
|
||||||
|
StatValue: 1,
|
||||||
|
CurrentStatValue: null,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the caller’s value on the board and answers a bare 0', async () => {
|
||||||
|
await setStat(42, 14, 3)
|
||||||
|
expect(await stored(42, 14)).toEqual({ stat_value: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites when the client believes nothing is stored', async () => {
|
||||||
|
await setStat(43, 14, 3)
|
||||||
|
await setStat(43, 14, 7, null)
|
||||||
|
expect(await stored(43, 14)).toEqual({ stat_value: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes only when CurrentStatValue matches what is stored', async () => {
|
||||||
|
await setStat(44, 14, 3)
|
||||||
|
await setStat(44, 14, 4, 3)
|
||||||
|
expect(await stored(44, 14)).toEqual({ stat_value: 4 })
|
||||||
|
// A stale client that still believes 3 is stored doesn't walk the board backwards.
|
||||||
|
await setStat(44, 14, 1, 3)
|
||||||
|
expect(await stored(44, 14)).toEqual({ stat_value: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps rooms and channels apart', async () => {
|
||||||
|
await setStat(45, 14, 3)
|
||||||
|
await setStat(45, 15, 9)
|
||||||
|
await setStat(45, 14, 5, null, 7)
|
||||||
|
expect(await stored(45, 14)).toEqual({ stat_value: 3 })
|
||||||
|
expect(await stored(45, 15)).toEqual({ stat_value: 9 })
|
||||||
|
expect(await stored(45, 14, 7)).toEqual({ stat_value: 5 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a scored board', () => {
|
||||||
|
// Room 100: player 1 has 10, player 2 has 30, player 3 has 20, player 4 has 20 (ties
|
||||||
|
// break on the lower id, so 3 ranks ahead of 4).
|
||||||
|
const ROOM = 100
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setStat(1, ROOM, 10)
|
||||||
|
await setStat(2, ROOM, 30)
|
||||||
|
await setStat(3, ROOM, 20)
|
||||||
|
await setStat(4, ROOM, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers another channel of the same room as its own, empty board', async () => {
|
||||||
|
const res = await post('GetRanks', {
|
||||||
|
RankStart: 0,
|
||||||
|
RankEnd: 9,
|
||||||
|
PlayerId: 1,
|
||||||
|
StatChannel: 3,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({ Rows: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ranks highest value first with 1-based ranks', async () => {
|
||||||
|
const res = await post('GetRanks', {
|
||||||
|
RankStart: 0,
|
||||||
|
RankEnd: 9,
|
||||||
|
PlayerId: 1,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 2, Score: 30, Rank: 1 },
|
||||||
|
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||||
|
{ PlayerId: 4, Score: 20, Rank: 3 },
|
||||||
|
{ PlayerId: 1, Score: 10, Rank: 4 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pages the board by rank, inclusive at both ends', async () => {
|
||||||
|
const res = await post('GetRanks', {
|
||||||
|
RankStart: 2,
|
||||||
|
RankEnd: 3,
|
||||||
|
PlayerId: 1,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||||
|
{ PlayerId: 4, Score: 20, Rank: 3 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ranks lowest first when asked to sort ascending', async () => {
|
||||||
|
const res = await post('GetRanks', {
|
||||||
|
RankStart: 1,
|
||||||
|
RankEnd: 2,
|
||||||
|
PlayerId: 1,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: true,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 1, Score: 10, Rank: 1 },
|
||||||
|
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers a player’s own rank consistently with the page', async () => {
|
||||||
|
const res = await post('GetPlayerRank', {
|
||||||
|
PlayerId: 4,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers a player with no row in the room as unranked', async () => {
|
||||||
|
const res = await post('GetPlayerRank', {
|
||||||
|
PlayerId: 77,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({ PlayerId: 77, Score: 0, Rank: 99999 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers the rows around a player, clamped to the board', async () => {
|
||||||
|
const res = await post('GetNearbyScores', {
|
||||||
|
PlayerId: 4,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
WindowSize: 1,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||||
|
{ PlayerId: 4, Score: 20, Rank: 3 },
|
||||||
|
{ PlayerId: 1, Score: 10, Rank: 4 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers the top of the board around a player who isn’t on it', async () => {
|
||||||
|
const res = await post('GetNearbyScores', {
|
||||||
|
PlayerId: 77,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
WindowSize: 1,
|
||||||
|
})
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 2, Score: 30, Rank: 1 },
|
||||||
|
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||||
|
{ PlayerId: 4, Score: 20, Rank: 3 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the friends filter', () => {
|
||||||
|
// Room 200, channel 2: players 11..15 score 50, 40, 30, 20, 10. Player 14 is friends
|
||||||
|
// with 11 (14 requested) and 15 (15 requested); 12 and 13 are strangers, and 16 is a
|
||||||
|
// friend with no score. Globally 14 is 4th; among friends 2nd.
|
||||||
|
const ROOM = 200
|
||||||
|
const board = (extra: object) => ({
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 1,
|
||||||
|
SortAscending: false,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setStat(11, ROOM, 50)
|
||||||
|
await setStat(12, ROOM, 40)
|
||||||
|
await setStat(13, ROOM, 30)
|
||||||
|
await setStat(14, ROOM, 20)
|
||||||
|
await setStat(15, ROOM, 10)
|
||||||
|
await befriend(14, 11)
|
||||||
|
await befriend(15, 14)
|
||||||
|
await befriend(14, 16)
|
||||||
|
// A pending request is not a friendship.
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (14, 12, ?1)'
|
||||||
|
)
|
||||||
|
.bind(RelationshipType.FriendRequestSent)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ranks the viewer among their friends on GetRanks', async () => {
|
||||||
|
const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 1, RankEnd: 10 }))
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 11, Score: 50, Rank: 1 },
|
||||||
|
{ PlayerId: 14, Score: 20, Rank: 2 },
|
||||||
|
{ PlayerId: 15, Score: 10, Rank: 3 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives the friends rank on GetPlayerRank', async () => {
|
||||||
|
const res = await post('GetPlayerRank', board({ PlayerId: 14 }))
|
||||||
|
expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 2 })
|
||||||
|
const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 }))
|
||||||
|
expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('centres GetNearbyScores on the viewer within their friends', async () => {
|
||||||
|
const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 }))
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Rows: [
|
||||||
|
{ PlayerId: 11, Score: 50, Rank: 1 },
|
||||||
|
{ PlayerId: 14, Score: 20, Rank: 2 },
|
||||||
|
{ PlayerId: 15, Score: 10, Rank: 3 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a player with no friends only themself', async () => {
|
||||||
|
const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 1, RankEnd: 10 }))
|
||||||
|
expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 1 }] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the nearby window', () => {
|
||||||
|
// Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 13).
|
||||||
|
const ROOM = 300
|
||||||
|
beforeAll(async () => {
|
||||||
|
for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps WindowSize at 10 either side', async () => {
|
||||||
|
const res = await post('GetNearbyScores', {
|
||||||
|
PlayerId: 33,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
WindowSize: 100,
|
||||||
|
})
|
||||||
|
const { Rows } = (await res.json()) as { Rows: { Rank: number }[] }
|
||||||
|
expect(Rows).toHaveLength(21)
|
||||||
|
expect(Rows[0]?.Rank).toBe(3)
|
||||||
|
expect(Rows[20]?.Rank).toBe(23)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults WindowSize to 10 when absent', async () => {
|
||||||
|
const res = await post('GetNearbyScores', {
|
||||||
|
PlayerId: 33,
|
||||||
|
StatChannel: 2,
|
||||||
|
RoomId: ROOM,
|
||||||
|
FilterType: 0,
|
||||||
|
SortAscending: false,
|
||||||
|
})
|
||||||
|
const { Rows } = (await res.json()) as { Rows: unknown[] }
|
||||||
|
expect(Rows).toHaveLength(21)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serves an openapi spec with no dangling refs', async () => {
|
it('serves an openapi spec with no dangling refs', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,18 @@
|
|||||||
"main": "src/leaderboard.app.ts",
|
"main": "src/leaderboard.app.ts",
|
||||||
"compatibility_date": "2026-06-16",
|
"compatibility_date": "2026-06-16",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
// Shared `recflare` D1 database. This worker owns the `leaderboard` table
|
||||||
|
// (migrations/), applied under its own migrations table so they don't clash with the
|
||||||
|
// other workers' migrations that share the database.
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "recflare",
|
||||||
|
"database_id": "local",
|
||||||
|
"migrations_dir": "migrations",
|
||||||
|
"migrations_table": "d1_migrations_leaderboard"
|
||||||
|
}
|
||||||
|
],
|
||||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||||
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||||
|
|||||||
Generated
+3
@@ -669,6 +669,9 @@ importers:
|
|||||||
|
|
||||||
apps/leaderboard:
|
apps/leaderboard:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@repo/domain':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/domain
|
||||||
'@repo/hono-helpers':
|
'@repo/hono-helpers':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/hono-helpers
|
version: link:../../packages/hono-helpers
|
||||||
|
|||||||
Reference in New Issue
Block a user