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:
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 player’s value on the board `RoomId` + `StatChannel` names and `Rank`',
|
'`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`,
|
`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 caller’s value on',
|
'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',
|
'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',
|
'their friends only (the `api` worker’s `relationship` table), ranked among',
|
||||||
'themselves.',
|
'themselves.',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -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 player’s value on the board; 0 when they have no row there'),
|
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)
|
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 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 () => {
|
it('defaults WindowSize to 10 when absent', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user