[leaderboard] fix ranks

This commit is contained in:
Devin Zuczek
2026-08-26 12:12:44 -04:00
parent 35350ffa19
commit e2ced3e797
6 changed files with 101 additions and 61 deletions
+6
View File
@@ -169,6 +169,12 @@ inconsistency here without checking the client first.
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success. - The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use; That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
the two live side by side in the same worker and must not be unified. the two live side by side in the same worker and must not be unified.
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
place and the top of a board must be 0. Its own slice says the same: it asks for the first
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
(99999) precisely because 0 is now a real rank, first place.
- Accessibility is sent as the `RoomAccessibility` enum NAME on - Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+1 -1
View File
@@ -40,7 +40,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
| GameLogs | `gamelogs` | — | Not yet implemented | | GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented | | Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) | | Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Stub — deploys and answers, no leaderboard endpoints yet | | Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet | | Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) | | Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) | | Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
+19 -14
View File
@@ -11,10 +11,14 @@
* the room's tracked stats this is, and `stat_value` is what it posts as `StatValue`. What a * 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. * 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 * Ranks are 0-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 * 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. * with the same score never share a rank and a rank is stable between two reads.
* *
* Zero-based because the client adds one before it draws — it renders `Rank` 0 as "#1", so a
* `Rank` of 1 shows up in the game as second place. This is the same convention the client's
* own `GetRanks` body uses: it asks for the first ten rows as `RankStart` 0, `RankEnd` 9.
*
* A board can be read through a FRIENDS filter (the client's `FilterType` 1): the same rows * 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 — * 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` * so a player who is 40th globally can be 2nd among friends. Friendship is the `api`
@@ -42,10 +46,10 @@ export const SCHEMA_DDL: string[] = [
] ]
/** /**
* The rank a player who isn't on the board gets. `Rank` is 1-based: a 0 would render as * The rank a player who isn't on the board gets. Ranks are 0-based, so 0 IS first place and
* first place and a negative one may not render at all. A number far past the end of any * a negative one may not render at all — the sentinel has to be a big number. One far past
* real board reads as last, which is what an unscored player is, and is recognisable in a * the end of any real board reads as last, which is what an unscored player is, and is
* log or a screenshot as a sentinel rather than a real standing. * recognisable in a log or a screenshot as a sentinel rather than a real standing.
*/ */
export const UNRANKED = 99999 export const UNRANKED = 99999
@@ -111,8 +115,9 @@ function order(sortAscending: boolean): string {
} }
/** /**
* A page of a board: ranks `rankStart`..`rankEnd`, both 1-based and inclusive. * A page of a board: ranks `rankStart`..`rankEnd`, both 0-based and inclusive — 0..9 is the
* A `rankStart` below 1 is clamped to the top; an empty or inverted range is an empty page. * first ten rows, the slice the client actually asks for.
* A `rankStart` below 0 is clamped to the top; an empty or inverted range is an empty page.
*/ */
export async function getRanks( export async function getRanks(
db: D1Database, db: D1Database,
@@ -121,7 +126,7 @@ export async function getRanks(
rankEnd: number, rankEnd: number,
sortAscending: boolean sortAscending: boolean
): Promise<LeaderboardEntry[]> { ): Promise<LeaderboardEntry[]> {
const start = Math.max(1, rankStart) const start = Math.max(0, rankStart)
const limit = rankEnd - start + 1 const limit = rankEnd - start + 1
if (limit <= 0) return [] if (limit <= 0) return []
const s = scope(board) const s = scope(board)
@@ -130,17 +135,17 @@ export async function getRanks(
`SELECT player_id, stat_value FROM leaderboard WHERE ${s.where} `SELECT player_id, stat_value FROM leaderboard WHERE ${s.where}
ORDER BY ${order(sortAscending)} LIMIT ?${s.next} OFFSET ?${s.next + 1}` ORDER BY ${order(sortAscending)} LIMIT ?${s.next} OFFSET ?${s.next + 1}`
) )
.bind(...s.binds, limit, start - 1) .bind(...s.binds, limit, start)
.all<LeaderboardRow>() .all<LeaderboardRow>()
return results.map((r, i) => ({ PlayerId: r.player_id, Score: r.stat_value, Rank: start + i })) 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 * One player's standing on a board: their score and 0-based rank, or
* {@link UNRANKED} with {@link NO_SCORE} when they have no row there. * {@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 * The rank is the count of players placed ahead — a higher score, or the same score and a
* same score and a lower id — so it matches the position {@link getRanks} would give. * lower id — so it matches the position {@link getRanks} would give.
*/ */
export async function getPlayerRank( export async function getPlayerRank(
db: D1Database, db: D1Database,
@@ -162,7 +167,7 @@ export async function getPlayerRank(
.prepare(`SELECT COUNT(*) AS n FROM leaderboard WHERE ${s.where} AND ${ahead}`) .prepare(`SELECT COUNT(*) AS n FROM leaderboard WHERE ${s.where} AND ${ahead}`)
.bind(...s.binds, playerId, mine.stat_value) .bind(...s.binds, playerId, mine.stat_value)
.first<{ n: number }>() .first<{ n: number }>()
return { PlayerId: playerId, Score: mine.stat_value, Rank: (count?.n ?? 0) + 1 } return { PlayerId: playerId, Score: mine.stat_value, Rank: count?.n ?? 0 }
} }
/** The most rows either side of a player `GetNearbyScores` will serve, whatever it asks. */ /** The most rows either side of a player `GetNearbyScores` will serve, whatever it asks. */
@@ -182,7 +187,7 @@ export async function getNearbyScores(
): Promise<LeaderboardEntry[]> { ): Promise<LeaderboardEntry[]> {
const window = Math.min(Math.max(windowSize, 1), MAX_WINDOW) const window = Math.min(Math.max(windowSize, 1), MAX_WINDOW)
const mine = await getPlayerRank(db, board, playerId, sortAscending) const mine = await getPlayerRank(db, board, playerId, sortAscending)
if (mine.Rank === UNRANKED) return getRanks(db, board, 1, window * 2 + 1, sortAscending) if (mine.Rank === UNRANKED) return getRanks(db, board, 0, window * 2, sortAscending)
return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending) return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending)
} }
+14 -10
View File
@@ -162,9 +162,10 @@ 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. Ranks are 1-based; a `RankStart` of 0 is read as the top. `FilterType` 1 // be present. Ranks are 0-based, as the client's own slice is (it asks for the first ten
// ranks the viewer and their friends among themselves. An unreadable body is answered // as `RankStart` 0, `RankEnd` 9) and as it renders them (it draws `Rank` 0 as "#1").
// with an empty board, never an error. // `FilterType` 1 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({
@@ -177,8 +178,8 @@ const app = new Hono<App>()
'`SortAscending`).', '`SortAscending`).',
'', '',
'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`', 'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`',
'names (1-based; 0 is read as the top), highest value first unless `SortAscending`. An', 'names (0-based, so 0..9 is the first ten), highest value first unless `SortAscending`.',
'empty `Rows` means "this leaderboard has no scores"; the key is always present.', 'An empty `Rows` means "this leaderboard has no scores"; the key is always present.',
'`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked', '`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked',
'among themselves.', 'among themselves.',
].join(' '), ].join(' '),
@@ -192,8 +193,8 @@ const app = new Hono<App>()
const rows = await getRanks( const rows = await getRanks(
c.env.DB, c.env.DB,
board(body), board(body),
int(body.RankStart, 1), int(body.RankStart, 0),
int(body.RankEnd, 10), int(body.RankEnd, 9),
body.SortAscending === true body.SortAscending === true
) )
return c.json({ Rows: rows }) return c.json({ Rows: rows })
@@ -223,8 +224,10 @@ const app = new Hono<App>()
'`FilterType`: Global 0, Friends 1).', '`FilterType`: Global 0, Friends 1).',
'', '',
'`Score` is the players value on the board `RoomId` + `StatChannel` names and `Rank`', '`Score` is the players value on the board `RoomId` + `StatChannel` names and `Rank`',
`their 1-based position on it. A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`, `their 0-based position on it — the client adds one before it draws, so \`Rank\` 0 is`,
'unranked (ranks are 1-based, so a 0 would render as first place), and `Score` 0.', `shown as "#1". A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`,
'unranked (0 being a real rank, first place, the sentinel has to be a big number), and',
'`Score` 0.',
'', '',
'`FilterType` 1 (Friends) ranks the player among their friends only.', '`FilterType` 1 (Friends) ranks the player among their friends only.',
'', '',
@@ -329,7 +332,8 @@ app.get(
'', '',
'One board per (room, stat channel): `CheckAndSetStat` stores the callers value on', 'One board per (room, stat channel): `CheckAndSetStat` stores the callers value on',
'one, and the reads rank them — highest first unless `SortAscending`, ties broken on', 'one, and the reads rank them — highest first unless `SortAscending`, ties broken on',
'the lower player id, ranks 1-based. `FilterType` 1 reads a board as the viewer and', 'the lower player id, ranks 0-based (the client adds one before it draws, so `Rank` 0',
'is shown as "#1"). `FilterType` 1 reads a board as the viewer and',
'their friends only (the `api` workers `relationship` table), ranked among', 'their friends only (the `api` workers `relationship` table), ranked among',
'themselves.', 'themselves.',
'', '',
+7 -3
View File
@@ -58,17 +58,21 @@ export const LeaderboardRows = z.object({
/** /**
* `POST /leaderboard/GetPlayerRank` — one player's standing on one board, e.g. * `POST /leaderboard/GetPlayerRank` — one player's standing on one board, e.g.
* `{"PlayerId":205,"Score":4200,"Rank":17}`. * `{"PlayerId":205,"Score":4200,"Rank":16}` for the 17th place the client draws.
* *
* 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. A player with no row gets * client matches the answer to the question by having asked it. A player with no row gets
* the 99999 sentinel with a zero score — see the route for why that rather than a rank of * the 99999 sentinel with a zero score — see the route for why that rather than a rank of
* 0, which would read as "first place". * 0, which IS first place: the client adds one before it draws.
*/ */
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 players value on the board; 0 when they have no row there'), Score: z.int().describe('The players value on the board; 0 when they have no row there'),
Rank: z.int().describe('1-based position on the board; 99999 when the player isnt on it'), Rank: z
.int()
.describe(
'0-based position on the board — the client adds one to draw it, so 0 is “#1”; 99999 when the player isnt on it'
),
}) })
/** /**
@@ -115,8 +115,8 @@ describe('an empty board', () => {
}) })
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.
// Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would // Ranks are 0-based the client adds one to draw them — so 0 IS first place and the
// render the unranked caller as first place. // sentinel has to be a big number rather than 0.
expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 }) expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 })
}) })
@@ -205,7 +205,7 @@ describe('a scored board', () => {
expect(await res.json()).toEqual({ Rows: [] }) expect(await res.json()).toEqual({ Rows: [] })
}) })
it('ranks highest value first with 1-based ranks', async () => { it('ranks highest value first with 0-based ranks', async () => {
const res = await post('GetRanks', { const res = await post('GetRanks', {
RankStart: 0, RankStart: 0,
RankEnd: 9, RankEnd: 9,
@@ -217,10 +217,10 @@ describe('a scored board', () => {
}) })
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 2, Score: 30, Rank: 1 }, { PlayerId: 2, Score: 30, Rank: 0 },
{ PlayerId: 3, Score: 20, Rank: 2 }, { PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 3 }, { PlayerId: 4, Score: 20, Rank: 2 },
{ PlayerId: 1, Score: 10, Rank: 4 }, { PlayerId: 1, Score: 10, Rank: 3 },
], ],
}) })
}) })
@@ -237,8 +237,8 @@ describe('a scored board', () => {
}) })
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 3, Score: 20, Rank: 2 }, { PlayerId: 4, Score: 20, Rank: 2 },
{ PlayerId: 4, Score: 20, Rank: 3 }, { PlayerId: 1, Score: 10, Rank: 3 },
], ],
}) })
}) })
@@ -255,8 +255,8 @@ describe('a scored board', () => {
}) })
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 1, Score: 10, Rank: 1 }, { PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 3, Score: 20, Rank: 2 }, { PlayerId: 4, Score: 20, Rank: 2 },
], ],
}) })
}) })
@@ -269,7 +269,7 @@ describe('a scored board', () => {
FilterType: 0, FilterType: 0,
SortAscending: false, SortAscending: false,
}) })
expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 3 }) expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 2 })
}) })
it('answers a player with no row in the room as unranked', async () => { it('answers a player with no row in the room as unranked', async () => {
@@ -294,9 +294,9 @@ describe('a scored board', () => {
}) })
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 3, Score: 20, Rank: 2 }, { PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 3 }, { PlayerId: 4, Score: 20, Rank: 2 },
{ PlayerId: 1, Score: 10, Rank: 4 }, { PlayerId: 1, Score: 10, Rank: 3 },
], ],
}) })
}) })
@@ -312,9 +312,9 @@ describe('a scored board', () => {
}) })
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 2, Score: 30, Rank: 1 }, { PlayerId: 2, Score: 30, Rank: 0 },
{ PlayerId: 3, Score: 20, Rank: 2 }, { PlayerId: 3, Score: 20, Rank: 1 },
{ PlayerId: 4, Score: 20, Rank: 3 }, { PlayerId: 4, Score: 20, Rank: 2 },
], ],
}) })
}) })
@@ -323,7 +323,7 @@ describe('a scored board', () => {
describe('the friends filter', () => { describe('the friends filter', () => {
// Room 200, channel 2: players 11..15 score 50, 40, 30, 20, 10. Player 14 is friends // 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 // 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. // friend with no score. Globally 14 is 4th (`Rank` 3); among friends 2nd (`Rank` 1).
const ROOM = 200 const ROOM = 200
const board = (extra: object) => ({ const board = (extra: object) => ({
StatChannel: 2, StatChannel: 2,
@@ -350,42 +350,43 @@ describe('the friends filter', () => {
}) })
it('ranks the viewer among their friends on GetRanks', async () => { it('ranks the viewer among their friends on GetRanks', async () => {
const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 1, RankEnd: 10 })) const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 0, RankEnd: 9 }))
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 11, Score: 50, Rank: 1 }, { PlayerId: 11, Score: 50, Rank: 0 },
{ PlayerId: 14, Score: 20, Rank: 2 }, { PlayerId: 14, Score: 20, Rank: 1 },
{ PlayerId: 15, Score: 10, Rank: 3 }, { PlayerId: 15, Score: 10, Rank: 2 },
], ],
}) })
}) })
it('gives the friends rank on GetPlayerRank', async () => { it('gives the friends rank on GetPlayerRank', async () => {
const res = await post('GetPlayerRank', board({ PlayerId: 14 })) const res = await post('GetPlayerRank', board({ PlayerId: 14 }))
expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 2 }) expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 1 })
const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 })) const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 }))
expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 4 }) expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 3 })
}) })
it('centres GetNearbyScores on the viewer within their friends', async () => { it('centres GetNearbyScores on the viewer within their friends', async () => {
const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 })) const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 }))
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
Rows: [ Rows: [
{ PlayerId: 11, Score: 50, Rank: 1 }, { PlayerId: 11, Score: 50, Rank: 0 },
{ PlayerId: 14, Score: 20, Rank: 2 }, { PlayerId: 14, Score: 20, Rank: 1 },
{ PlayerId: 15, Score: 10, Rank: 3 }, { PlayerId: 15, Score: 10, Rank: 2 },
], ],
}) })
}) })
it('shows a player with no friends only themself', async () => { it('shows a player with no friends only themself', async () => {
const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 1, RankEnd: 10 })) const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 0, RankEnd: 9 }))
expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 1 }] }) expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 0 }] })
}) })
}) })
describe('the nearby window', () => { describe('the nearby window', () => {
// Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 13). // Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 12,
// the 13th row — ranks are 0-based).
const ROOM = 300 const ROOM = 300
beforeAll(async () => { beforeAll(async () => {
for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p) for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p)
@@ -402,8 +403,28 @@ describe('the nearby window', () => {
}) })
const { Rows } = (await res.json()) as { Rows: { Rank: number }[] } const { Rows } = (await res.json()) as { Rows: { Rank: number }[] }
expect(Rows).toHaveLength(21) expect(Rows).toHaveLength(21)
expect(Rows[0]?.Rank).toBe(3) expect(Rows[0]?.Rank).toBe(2)
expect(Rows[20]?.Rank).toBe(23) expect(Rows[20]?.Rank).toBe(22)
})
// The slice the client actually asks for: 0..9 is TEN rows, starting at the top. Read as
// 1-based this served nine rows starting at the second one.
it('serves ten rows for the clients RankStart 0, RankEnd 9', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 33,
StatChannel: 2,
RoomId: ROOM,
FilterType: 0,
SortAscending: false,
})
const { Rows } = (await res.json()) as {
Rows: Array<{ PlayerId: number; Score: number; Rank: number }>
}
expect(Rows).toHaveLength(10)
expect(Rows[0]).toEqual({ PlayerId: 21, Score: 25, Rank: 0 })
expect(Rows[9]?.Rank).toBe(9)
}) })
it('defaults WindowSize to 10 when absent', async () => { it('defaults WindowSize to 10 when absent', async () => {