mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[leaderboard] fix ranks
This commit is contained in:
@@ -11,10 +11,14 @@
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* 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`
|
||||
@@ -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
|
||||
* 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.
|
||||
* The rank a player who isn't on the board gets. Ranks are 0-based, so 0 IS first place and
|
||||
* a negative one may not render at all — the sentinel has to be a big number. One 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
|
||||
|
||||
@@ -111,8 +115,9 @@ function order(sortAscending: boolean): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A page of a board: ranks `rankStart`..`rankEnd`, both 0-based and inclusive — 0..9 is the
|
||||
* 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(
|
||||
db: D1Database,
|
||||
@@ -121,7 +126,7 @@ export async function getRanks(
|
||||
rankEnd: number,
|
||||
sortAscending: boolean
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
const start = Math.max(1, rankStart)
|
||||
const start = Math.max(0, rankStart)
|
||||
const limit = rankEnd - start + 1
|
||||
if (limit <= 0) return []
|
||||
const s = scope(board)
|
||||
@@ -130,17 +135,17 @@ export async function getRanks(
|
||||
`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)
|
||||
.bind(...s.binds, limit, start)
|
||||
.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
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* The rank is 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,
|
||||
@@ -162,7 +167,7 @@ export async function getPlayerRank(
|
||||
.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 }
|
||||
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. */
|
||||
@@ -182,7 +187,7 @@ export async function getNearbyScores(
|
||||
): 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)
|
||||
if (mine.Rank === UNRANKED) return getRanks(db, board, 0, window * 2, sortAscending)
|
||||
return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,9 +162,10 @@ const app = new Hono<App>()
|
||||
//
|
||||
// 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
|
||||
// be present. Ranks are 1-based; a `RankStart` of 0 is read as the top. `FilterType` 1
|
||||
// ranks the viewer and their friends among themselves. An unreadable body is answered
|
||||
// with an empty board, never an error.
|
||||
// be present. Ranks are 0-based, as the client's own slice is (it asks for the first ten
|
||||
// as `RankStart` 0, `RankEnd` 9) and as it renders them (it draws `Rank` 0 as "#1").
|
||||
// `FilterType` 1 ranks the viewer and their friends among themselves. An unreadable body
|
||||
// is answered with an empty board, never an error.
|
||||
.post(
|
||||
'/leaderboard/GetRanks',
|
||||
describeRoute({
|
||||
@@ -177,8 +178,8 @@ const app = new Hono<App>()
|
||||
'`SortAscending`).',
|
||||
'',
|
||||
'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',
|
||||
'empty `Rows` means "this leaderboard has no scores"; the key is always present.',
|
||||
'names (0-based, so 0..9 is the first ten), highest value first unless `SortAscending`.',
|
||||
'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',
|
||||
'among themselves.',
|
||||
].join(' '),
|
||||
@@ -192,8 +193,8 @@ const app = new Hono<App>()
|
||||
const rows = await getRanks(
|
||||
c.env.DB,
|
||||
board(body),
|
||||
int(body.RankStart, 1),
|
||||
int(body.RankEnd, 10),
|
||||
int(body.RankStart, 0),
|
||||
int(body.RankEnd, 9),
|
||||
body.SortAscending === true
|
||||
)
|
||||
return c.json({ Rows: rows })
|
||||
@@ -223,8 +224,10 @@ const app = new Hono<App>()
|
||||
'`FilterType`: Global 0, Friends 1).',
|
||||
'',
|
||||
'`Score` is the player’s 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`,
|
||||
'unranked (ranks are 1-based, so a 0 would render as first place), and `Score` 0.',
|
||||
`their 0-based position on it — the client adds one before it draws, so \`Rank\` 0 is`,
|
||||
`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.',
|
||||
'',
|
||||
@@ -329,7 +332,8 @@ app.get(
|
||||
'',
|
||||
'One board per (room, stat channel): `CheckAndSetStat` stores the caller’s value 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` worker’s `relationship` table), ranked among',
|
||||
'themselves.',
|
||||
'',
|
||||
|
||||
@@ -58,17 +58,21 @@ export const LeaderboardRows = z.object({
|
||||
|
||||
/**
|
||||
* `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
|
||||
* 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
|
||||
* 0, which would read as "first place".
|
||||
* 0, which IS first place: the client adds one before it draws.
|
||||
*/
|
||||
export const PlayerRank = z.object({
|
||||
PlayerId: z.int().describe('Echoed from the request — whose rank this is'),
|
||||
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; 99999 when the player isn’t 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 isn’t on it'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,8 +115,8 @@ describe('an empty board', () => {
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// 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
|
||||
// render the unranked caller as first place.
|
||||
// Ranks are 0-based — the client adds one to draw them — so 0 IS first place and the
|
||||
// sentinel has to be a big number rather than 0.
|
||||
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: [] })
|
||||
})
|
||||
|
||||
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', {
|
||||
RankStart: 0,
|
||||
RankEnd: 9,
|
||||
@@ -217,10 +217,10 @@ describe('a scored board', () => {
|
||||
})
|
||||
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 },
|
||||
{ PlayerId: 2, Score: 30, Rank: 0 },
|
||||
{ PlayerId: 3, Score: 20, Rank: 1 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 1, Score: 10, Rank: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -237,8 +237,8 @@ describe('a scored board', () => {
|
||||
})
|
||||
expect(await res.json()).toEqual({
|
||||
Rows: [
|
||||
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 3 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 1, Score: 10, Rank: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -255,8 +255,8 @@ describe('a scored board', () => {
|
||||
})
|
||||
expect(await res.json()).toEqual({
|
||||
Rows: [
|
||||
{ PlayerId: 1, Score: 10, Rank: 1 },
|
||||
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 3, Score: 20, Rank: 1 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -269,7 +269,7 @@ describe('a scored board', () => {
|
||||
FilterType: 0,
|
||||
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 () => {
|
||||
@@ -294,9 +294,9 @@ describe('a scored board', () => {
|
||||
})
|
||||
expect(await res.json()).toEqual({
|
||||
Rows: [
|
||||
{ PlayerId: 3, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 3 },
|
||||
{ PlayerId: 1, Score: 10, Rank: 4 },
|
||||
{ PlayerId: 3, Score: 20, Rank: 1 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 1, Score: 10, Rank: 3 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -312,9 +312,9 @@ describe('a scored board', () => {
|
||||
})
|
||||
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: 2, Score: 30, Rank: 0 },
|
||||
{ PlayerId: 3, Score: 20, Rank: 1 },
|
||||
{ PlayerId: 4, Score: 20, Rank: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -323,7 +323,7 @@ describe('a scored board', () => {
|
||||
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.
|
||||
// friend with no score. Globally 14 is 4th (`Rank` 3); among friends 2nd (`Rank` 1).
|
||||
const ROOM = 200
|
||||
const board = (extra: object) => ({
|
||||
StatChannel: 2,
|
||||
@@ -350,42 +350,43 @@ describe('the friends filter', () => {
|
||||
})
|
||||
|
||||
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({
|
||||
Rows: [
|
||||
{ PlayerId: 11, Score: 50, Rank: 1 },
|
||||
{ PlayerId: 14, Score: 20, Rank: 2 },
|
||||
{ PlayerId: 15, Score: 10, Rank: 3 },
|
||||
{ PlayerId: 11, Score: 50, Rank: 0 },
|
||||
{ PlayerId: 14, Score: 20, Rank: 1 },
|
||||
{ PlayerId: 15, Score: 10, Rank: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 1 })
|
||||
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 () => {
|
||||
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 },
|
||||
{ PlayerId: 11, Score: 50, Rank: 0 },
|
||||
{ PlayerId: 14, Score: 20, Rank: 1 },
|
||||
{ PlayerId: 15, Score: 10, Rank: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
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 }] })
|
||||
const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 0, RankEnd: 9 }))
|
||||
expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 0 }] })
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
beforeAll(async () => {
|
||||
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 }[] }
|
||||
expect(Rows).toHaveLength(21)
|
||||
expect(Rows[0]?.Rank).toBe(3)
|
||||
expect(Rows[20]?.Rank).toBe(23)
|
||||
expect(Rows[0]?.Rank).toBe(2)
|
||||
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 client’s 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user