Split The code is public; the app answers only to the crew.

Your own row on the board, wherever you rank #240

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/cs:scout/95-your-own-row-on-the-board-wherever-you-r → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/95-your-own-row-on-the-board-wherever-you-r was merged into main as aa27f94.

Review

Sign in to review this change.
7 files changed +203 −26
README.md +9 −1
@@ -175,7 +175,15 @@ wants one, but the running server never reads it.
175175 `FORGE_APP_DATA`, also served as plain JSON from `GET /api/leaderboard` for
176176 the terminal or any other client — with a `guest` flag per row instead of the
177177 identity it comes from, because a guest's identity is the session cookie that
178 owns their matches and that does not travel. A row is **live**, **finished** or
178 owns their matches and that does not travel. The board itself is the top ten,
179 but a visitor sitting eleventh or below still gets their own row: highlighted
180 in place when they are inside it, pinned below the table (and in the `you`
181 field of the JSON, `null` for someone who has not played) with their true
182 rank when they are not — a rank shared with anyone else tied on the same
183 kills and damage, so a pinned row can read the same number as the table
184 row above it. The terminal has no visitor to highlight — an
185 operator's pty, not a browser's session — so it shows the same top ten and
186 nothing pinned beneath it. A row is **live**, **finished** or
179187 **abandoned** — one word, computed once in `src/db.ts` and read by the page,
180188 `GET /api/leaderboard` and the terminal alike, never worked out twice. Live is
181189 a match still posting; finished is one that played to a result; abandoned is
src/db.test.ts +28 −1
@@ -8,7 +8,7 @@ import { Database } from "bun:sqlite";
88 import { mkdtempSync } from "node:fs";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { openDatabase, recentMatches, saveMatch, standings } from "./db.ts";
11 import { openDatabase, recentMatches, saveMatch, standingFor, standings } from "./db.ts";
1212 import type { Result } from "./result.ts";
1313
1414 const dir = (): string => mkdtempSync(join(tmpdir(), "db-"));
@@ -243,6 +243,33 @@ test("standings answers in a budget that does not grow with the table: 20,000 ma
243243 expect(performance.now() - start).toBeLessThan(10);
244244 });
245245
246 test("standingFor answers one identity's own rank and totals, off the bottom of the table or on it", () => {
247 const db = openDatabase(dir());
248 // Twelve identities, best first: the eleventh (index 10) sits at rank 11.
249 for (let i = 0; i < 12; i++) {
250 expect(saveMatch(db, writer(`user:u_${i}`, `player ${i}`), result({ kills: 100 - i, damage: 900 - i }))).toBe(true);
251 }
252 const eleventh = standingFor(db, "user:u_10");
253 expect(eleventh?.rank).toBe(11);
254 expect(eleventh?.name).toBe("player 10");
255 expect(eleventh?.kills).toBe(90);
256 expect(eleventh?.matches).toBe(1);
257 expect(standingFor(db, "user:u_0")?.rank).toBe(1);
258 expect(standingFor(db, "nobody:home")).toBe(null);
259 });
260
261 test("standingFor ties resolve the same way the table's order does: equal kills and damage share a rank", () => {
262 const db = openDatabase(dir());
263 expect(saveMatch(db, writer("user:a", "a"), result({ kills: 10, damage: 500 }))).toBe(true);
264 expect(saveMatch(db, writer("user:b", "b"), result({ kills: 10, damage: 500 }))).toBe(true);
265 // Neither outranks the other — nobody with strictly more kills or damage — so both read rank 1.
266 expect(standingFor(db, "user:a")?.rank).toBe(1);
267 expect(standingFor(db, "user:b")?.rank).toBe(1);
268 // Behind them, damage breaks the tie in kills.
269 expect(saveMatch(db, writer("user:c", "c"), result({ kills: 10, damage: 400 }))).toBe(true);
270 expect(standingFor(db, "user:c")?.rank).toBe(3);
271 });
272
246273 test("a row nobody ever closed reads as abandoned once it goes stale", () => {
247274 const db = openDatabase(dir());
248275 const stale = result({ finished: false, left: false });
src/db.ts +19
@@ -237,6 +237,25 @@ export const standings = (db: Database, limit = 10): Standing[] =>
237237 LIMIT ?1`,
238238 ).all(limit);
239239
240 export type Ranked = Standing & { rank: number };
241
242 /**
243 * One identity's own row, wherever it sits — for a visitor off the bottom of
244 * `standings`' top `limit`, still their record and their place. The rank is
245 * the count of identities `standing` orders ahead of this one under the same
246 * `kills DESC, damage DESC` the table itself sorts by, plus one, so a tie
247 * with the row above reads the same rank there as it would in the full list.
248 * One query, costed by the board's size — never the match table behind it.
249 */
250 export function standingFor(db: Database, identity: string): Ranked | null {
251 return db.query<Ranked, [string]>(
252 `SELECT s.identity, s.name, s.last, s.matches, s.kills, s.deaths, s.headshots, s.damage, s.wins, s.rounds,
253 (SELECT COUNT(*) FROM standing o
254 WHERE o.kills > s.kills OR (o.kills = s.kills AND o.damage > s.damage)) + 1 AS rank
255 FROM standing s WHERE s.identity = ?1`,
256 ).get(identity);
257 }
258
240259 export const matchCount = (db: Database): number =>
241260 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0;
242261
src/pages/game.test.ts +38
@@ -161,6 +161,44 @@ test("a recent match's row names the map it was played on", () => {
161161 expect(html).toContain("de_forge");
162162 });
163163
164 test("a visitor outside the top ten gets their own row pinned below it, at their true rank", () => {
165 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
166 // Twelve identities, best first: the eleventh (index 10) sits at rank 11.
167 for (let i = 0; i < 12; i++) {
168 const writer = { identity: `user:u_${i}`, name: `player ${i}`, guest: false, session: `${i}`.repeat(32) };
169 saveMatch(stats, writer, matchResult({ kills: 100 - i, damage: 900 - i }));
170 }
171 const eleventh = { identity: "user:u_10", name: "player 10", guest: false };
172 const html = statsHtml(stats, eleventh);
173 expect(html).toContain("<tfoot>");
174 const tfoot = html.slice(html.indexOf("<tfoot>"), html.indexOf("</tfoot>") + "</tfoot>".length);
175 expect(tfoot).toContain("<td>11</td>");
176 expect(tfoot).toContain(">player 10<");
177 expect(tfoot).toContain(`class="you"`);
178 // The table itself is still only the top ten, unchanged by the pin below it.
179 expect(html.slice(html.indexOf("<tbody>"), html.indexOf("</tbody>"))).not.toContain("player 10");
180 });
181
182 test("a visitor inside the top ten has their own row highlighted in place, not pinned again below", () => {
183 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
184 const writer = { identity: "user:top", name: "topper", guest: false, session: "f".repeat(32) };
185 saveMatch(stats, writer, matchResult({ kills: 50 }));
186 const html = statsHtml(stats, { identity: "user:top", name: "topper", guest: false });
187 expect(html).toContain(`<tr class="you">`);
188 expect(html).not.toContain("<tfoot>");
189 });
190
191 test("a visitor who has played nothing gets no invented row, only the line saying who they are", () => {
192 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
193 const writer = { identity: "user:someone", name: "someone", guest: false, session: "9".repeat(32) };
194 saveMatch(stats, writer, matchResult());
195 const nobody = { identity: "user:nobody", name: "nobody", guest: true };
196 const html = statsHtml(stats, nobody);
197 expect(html).not.toContain(`<tr class="you">`);
198 expect(html).not.toContain("<tfoot>");
199 expect(html).toContain("You are playing as <b>nobody</b>");
200 });
201
164202 test("the leaderboard table carries the hook and labels a phone-width card layout needs", () => {
165203 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
166204 const writer = { identity: "user:u_4", name: "sable", guest: false, session: "e".repeat(32) };
src/pages/game.ts +36 −20
@@ -9,7 +9,7 @@ import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts
99 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts";
1010 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
1111 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
12 import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts";
12 import { headshotShare, ratio, recentMatches, standingFor, standings, type MatchRow, type Standing } from "../db.ts";
1313 import { isGuest, type Player } from "../player.ts";
1414 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
1515 import { CARD } from "../client/weights.ts";
@@ -28,14 +28,19 @@ export function statsHtml(db: Database, player: Player): string {
2828 const table = standings(db, 10);
2929 const recent = recentMatches(db, 8);
3030 const tag = (identity: string): string => (isGuest(identity) ? ` <span class="tag">guest</span>` : "");
31 const rows = table
32 .map(
33 (s, i) => `<tr><td>${i + 1}</td><td>${escape(s.name)}${tag(s.identity)}</td>` +
34 `<td data-label="matches">${s.matches}</td><td data-label="kills">${s.kills}</td>` +
35 `<td data-label="K/D">${ratio(s.kills, s.deaths)}</td><td data-label="HS">${headshotShare(s.headshots, s.kills)}</td>` +
36 `<td data-label="wins">${s.wins}</td></tr>`,
37 )
38 .join("");
31 const row = (rank: number, s: Standing, mine: boolean): string =>
32 `<tr${mine ? ` class="you"` : ""}><td>${rank}</td><td>${escape(s.name)}${tag(s.identity)}</td>` +
33 `<td data-label="matches">${s.matches}</td><td data-label="kills">${s.kills}</td>` +
34 `<td data-label="K/D">${ratio(s.kills, s.deaths)}</td><td data-label="HS">${headshotShare(s.headshots, s.kills)}</td>` +
35 `<td data-label="wins">${s.wins}</td></tr>`;
36 const inTable = table.some((s) => s.identity === player.identity);
37 // Nowhere in the top ten: their own row, wherever it really sits — pinned
38 // below the table instead of invented or left off it. A visitor who has
39 // never finished a match has no row in `standing` either, so this stays
40 // null and the table renders exactly as it did before this row existed.
41 const you = inTable ? null : standingFor(db, player.identity);
42 const rows = table.map((s, i) => row(i + 1, s, s.identity === player.identity)).join("");
43 const pinned = you === null ? "" : `<tfoot>${row(you.rank, you, true)}</tfoot>`;
3944 const status = (m: MatchRow): string =>
4045 m.state === "finished" ? "" : m.state === "abandoned" ? " · abandoned" : " · in progress";
4146 const feed = recent
@@ -52,7 +57,7 @@ export function statsHtml(db: Database, player: Player): string {
5257 : `Signed in as <b>${escape(player.name)}</b> — your matches land under that name.`}</p>
5358 ${table.length === 0
5459 ? `<p class="empty">No matches yet. Play one and it lands here.</p>`
55 : `<table class="board"><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody></table>`}
60 : `<table class="board"><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody>${rows}</tbody>${pinned}</table>`}
5661 <h3>Recent matches</h3>
5762 <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`;
5863 }
@@ -60,22 +65,28 @@ ${table.length === 0
6065 export type LeaderboardJson = {
6166 standings: (Omit<Standing, "identity"> & { guest: boolean })[];
6267 recent: (Omit<MatchRow, "session" | "identity" | "walked_out"> & { guest: boolean })[];
68 you: (Omit<Standing, "identity"> & { guest: boolean; rank: number }) | null;
6369 };
6470
71 function withoutIdentity<T extends { identity: string }>(row: T): Omit<T, "identity"> & { guest: boolean } {
72 const { identity, ...rest } = row;
73 return { ...rest, guest: isGuest(identity) };
74 }
75
6576 /**
6677 * The same leaderboard as statsHtml, as data: what GET /api/leaderboard
6778 * answers. The session never travels, and neither does the identity that
6879 * holds it — a guest's is `guest:<session>`, which is the cookie that owns
6980 * their matches. What a reader needs from it is the one bit the page shows as
70 * a tag, so that is what goes out.
81 * a tag, so that is what goes out — `you` included, null for a visitor with
82 * no row in `standing` yet.
7183 */
72 export function leaderboardJson(db: Database): LeaderboardJson {
84 export function leaderboardJson(db: Database, player: Player): LeaderboardJson {
85 const mine = standingFor(db, player.identity);
7386 return {
74 standings: standings(db, 10).map(({ identity, ...standing }) => ({ ...standing, guest: isGuest(identity) })),
75 recent: recentMatches(db, 8).map(({ session: _session, identity, walked_out: _walkedOut, ...match }) => ({
76 ...match,
77 guest: isGuest(identity),
78 })),
87 standings: standings(db, 10).map(withoutIdentity),
88 recent: recentMatches(db, 8).map(({ session: _session, walked_out: _walkedOut, ...match }) => withoutIdentity(match)),
89 you: mine === null ? null : withoutIdentity(mine),
7990 };
8091 }
8192
@@ -248,11 +259,16 @@ main section { margin:1.5rem 0; }
248259 table { width:100%; border-collapse:collapse; font-size:.9rem; }
249260 th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); }
250261 th { color:var(--dim); font-weight:500; }
262 /* The visitor's own row: highlighted where it already sits in the top ten,
263 or — table.board tfoot — pinned below with a double border standing in
264 for the rows between it and the table above, wherever its rank really is. */
265 table.board tr.you td { background:#1b2432; }
266 table.board tfoot td { border-top:3px double var(--line); border-bottom:none; }
251267 ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; }
252268 ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; }
253 ul.recent span, ul.recent time, .empty, .you { color:var(--dim); }
254 .you { font-size:.86rem; margin:.2rem 0 .6rem; }
255 .you b { color:var(--text); }
269 ul.recent span, ul.recent time, .empty, p.you { color:var(--dim); }
270 p.you { font-size:.86rem; margin:.2rem 0 .6rem; }
271 p.you b { color:var(--text); }
256272 .tag { font-size:.7rem; color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:0 .35rem; font-weight:400; }
257273 kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; }
258274 footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; }
src/server.test.ts +60
@@ -141,6 +141,11 @@ test("the first paint is kept out of shared caches; the bundle is kept in them",
141141 expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
142142 });
143143
144 test("the leaderboard JSON is per-visitor too, so it stays out of shared caches", async () => {
145 const response = await fetch(base + "/api/leaderboard");
146 expect(response.headers.get("cache-control")).toContain("private");
147 });
148
144149 test("a result is stored and comes back as the leaderboard", async () => {
145150 const response = await post(result(), "", forge("u_1", "Vesper"));
146151 expect(response.status).toBe(200);
@@ -371,6 +376,61 @@ test("the leaderboard JSON hands out no session, in a cookie's clothing or other
371376 }
372377 });
373378
379 test("your own row: highlighted in place inside the top ten, or pinned below it at your true rank — in the HTML and the JSON alike", async () => {
380 // Every match a browser can report is capped at 150 kills (30 rounds of
381 // 5), so one identity's true total is the sum across several matches — the
382 // same sum `standing` keeps. Seeded well above what any other test in this
383 // file can accumulate under the 30-posts-a-minute gate (9 kills a post, so
384 // at most 270), twelve such identities — best first — keep their order
385 // safe from whatever else this shared server and database have already
386 // recorded: the eleventh (index 10) sits at rank 11, outranked by exactly
387 // the ten ahead of it and nothing else in the table.
388 let firstHtml = "";
389 let eleventhHtml = "";
390 for (let i = 0; i < 12; i += 1) {
391 const who = forge(`u_rank${i}`, `ranker${i}`);
392 let last: Response | undefined;
393 for (const k of [150, 150, 150 - i]) {
394 last = await post(
395 result({
396 matchId: crypto.randomUUID(), rounds: 30, scoreMine: 30, scoreTheirs: 0,
397 kills: k, deaths: 5, headshots: Math.min(20, k), damage: 9000, seconds: 3600,
398 }),
399 "",
400 who,
401 );
402 expect(last.status).toBe(200);
403 }
404 const text = await last?.text();
405 if (i === 0) firstHtml = text ?? "";
406 if (i === 10) eleventhHtml = text ?? "";
407 }
408 expect(firstHtml).toContain(`<tr class="you">`);
409 expect(firstHtml).not.toContain("<tfoot>");
410 expect(eleventhHtml).toContain("<tfoot>");
411 expect(eleventhHtml).toContain("<td>11</td>");
412 expect(eleventhHtml).toContain(">ranker10<");
413
414 // The same story from the JSON endpoint, for the same eleventh identity:
415 // its rank, its own numbers, and only the guest flag — never the identity.
416 const response = await fetch(base + "/api/leaderboard", { headers: forge("u_rank10", "ranker10") });
417 const body = (await response.json()) as { you: { name: string; rank: number; kills: number; guest: boolean } | null };
418 expect(body.you).not.toBeNull();
419 expect(body.you?.rank).toBe(11);
420 expect(body.you?.name).toBe("ranker10");
421 expect(body.you?.kills).toBe(440);
422 expect(body.you?.guest).toBe(false);
423 const text = JSON.stringify(body);
424 expect(text).not.toContain("guest:");
425 expect(text).not.toContain("u_rank10");
426 });
427
428 test("the leaderboard JSON answers you: null for a visitor who has played nothing", async () => {
429 const response = await fetch(base + "/api/leaderboard", { headers: forge("u_never_played", "ghost") });
430 const body = (await response.json()) as { you: unknown };
431 expect(body.you).toBeNull();
432 });
433
374434 test("a result parses to exactly what was sent, and nothing else", () => {
375435 const parsed = parseResult({ ...result(), extra: "ignored", name: "nobody" });
376436 expect(parsed?.kills).toBe(9);
src/server.ts +13 −4
@@ -224,10 +224,19 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> {
224224 // The same leaderboard as the page's first paint, as data: what a
225225 // terminal or another client reads instead of scraping the HTML.
226226 if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/api/leaderboard") {
227 const headers = { "content-type": "application/json; charset=utf-8", ...SECURITY };
228 return request.method === "HEAD"
229 ? new Response(null, { headers })
230 : new Response(JSON.stringify(leaderboardJson(db)), { headers });
227 const headers = {
outdated

Changes requested — one thing to fix first, plus one to check before the next round. (Ignore the stray "x" comment on line 229: it was a slip while I was testing the review API, and the forge has no way to delete it.)

Fix first (high: per-user data leaking through shared caches) — this line. Until now /api/leaderboard returned the same response to every visitor. With this change it is personal: you is worked out from the signed forge identity and the cs_session cookie. But its headers are still only content-type + SECURITY, with no cache-control. The / route a few lines below sends "cache-control": "private, no-cache" for exactly this reason ("Per-visitor … kept out of shared caches"). The preview sits behind Cloudflare, so any shared cache between the browser and the app could store one visitor's you (their name, rank and totals) and serve it to the next visitor. Add "cache-control": "private, no-cache" to these headers, which covers both GET and HEAD. Then add a server test that checks GET /api/leaderboard returns a cache-control containing private, like the existing check for the / page. Without the header, that test fails.

Check: the preview does not answer. The preview's / and /api/leaderboard both return 404 not found at this head, so I could not see the pinned row working. Push the fix and confirm that / loads and that /api/leaderboard includes a you field.

Low, no fix needed now: the table numbers its rows by position (i + 1), while standingFor gives tied players the same rank. A visitor tied on kills and damage with row 10 but left out of the top ten gets a pinned row reading 10 under a table row that also reads 10. It's defensible, but one sentence in the README saying ties share a rank would make it read as intended rather than as a bug.

Everything else looks right. Identity comes only from whoIs and credit, and the JSON drops identity and session in favour of the guest flag. A read without a cookie mints no session and resolves to guest:, which matches no row. The SQL uses bound parameters and the names are escaped. The query is one indexed count. The README explains why the terminal has no visitor row. The tests cover the in-top-ten, off-the-board and never-played cases on the page and in the JSON.

228 "content-type": "application/json; charset=utf-8",
229 // Per-visitor (a `you` row worked out from sign-in and session):
outdated

x

230 // kept out of shared caches, same reason as the `/` page below.
231 "cache-control": "private, no-cache",
232 ...SECURITY,
233 };
234 if (request.method === "HEAD") return new Response(null, { headers });
235 // No cookie is set here: a read never mints a session. A visitor
236 // with none yet has played nothing under any identity this could
237 // find, so `you` comes back null exactly as it should.
238 const player = credit(whoIs(request.headers), readSession(request) ?? "");
239 return new Response(JSON.stringify(leaderboardJson(db, player)), { headers });
231240 }
232241
233242 // HEAD is GET without the body: a health check sees the app up.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs