Split The code is public; the app answers only to the crew.
The match you just played has no page: `GET /m/<id>`, linked from every row on the board and reachable from the terminal #242
scout/cs:scout/173-the-match-you-just-played-has-no-page-ge → main 1 commit fork
12 files
changed +294
−20
README.md +11 −3
| @@ -171,7 +171,10 @@ wants one, but the running server never reads it. | ||
| 171 | 171 | credited for the kill, the way blowing yourself up with your own grenade |
| 172 | 172 | never is. |
| 173 | 173 | A footstep only carries so far, but the bomb's beep and the blast that ends it |
| 174 | carry across the whole map. The leaderboard is a real SQLite table under | |
| 174 | carry across the whole map. Every row on the board and the match-over card | |
| 175 | both link to that one match's own page at `GET /m/<id>` — the same numbers | |
| 176 | the board already shows, held at an address worth coming back to. The | |
| 177 | leaderboard is a real SQLite table under | |
| 175 | 178 | `FORGE_APP_DATA`, also served as plain JSON from `GET /api/leaderboard` for |
| 176 | 179 | the terminal or any other client — with a `guest` flag per row instead of the |
| 177 | 180 | identity it comes from, because a guest's identity is the session cookie that |
| @@ -673,7 +676,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured. | ||
| 673 | 676 | browser may claim, `src/who.ts` verifies the forge's signed identity |
| 674 | 677 | headers, `src/player.ts` turns "who is asking" into who a match is credited |
| 675 | 678 | to, `src/limit.ts` is the rate limit `POST /api/match` stands behind, and |
| 676 | `src/pages/game.ts` renders the page. | |
| 679 | `src/pages/game.ts` renders the page, and `src/pages/match.ts` renders one | |
| 680 | match's own page at `GET /m/<id>` — the id is a uuid, checked before it ever | |
| 681 | reaches `src/db.ts`'s `matchById`, so a malformed one is a 404 that never | |
| 682 | queries and an unknown one reads the same as a malformed one. | |
| 677 | 683 | - `src/bundle.ts` bundles the client; `src/server.ts` runs it at start-up and |
| 678 | 684 | serves the result from memory as `/static/app.<hash>.js`, cached for a year. |
| 679 | 685 | `scripts/build.ts` is a thin CLI over the same function, for a file on disk. |
| @@ -692,7 +698,9 @@ on a real pty over the same database: the same standings, the same matches, a | ||
| 692 | 698 | `who` column that says `signed` or `guest` (a terminal has no markup, so the |
| 693 | 699 | tag a name cannot fake is a column of its own), and `x` removes one. |
| 694 | 700 | <kbd>Tab</kbd> walks the three tabs — the third is the policy card the page |
| 695 | shows, which the tab key could not reach until now. | |
| 701 | shows, which the tab key could not reach until now. On the matches tab, | |
| 702 | <kbd>Enter</kbd> opens the selected row full-screen — the same fields | |
| 703 | `GET /m/<id>` renders — and <kbd>Esc</kbd> goes back. | |
| 696 | 704 | |
| 697 | 705 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 698 | 706 | `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH`, `FORGE_APP_COMMIT` and |
src/client/main.test.ts +4
| @@ -92,3 +92,7 @@ test("every selector main.ts runs against the page it did not write matches some | ||
| 92 | 92 | expect(html).toContain(`<${selector}`); |
| 93 | 93 | } |
| 94 | 94 | }); |
| 95 | ||
| 96 | test("the match-over card is pointed at the match that just finished, not the board", () => { | |
| 97 | expect(mainSource).toContain('$<HTMLAnchorElement>("summary-match").href = `/m/${matchId}`;'); | |
| 98 | }); |
src/client/main.ts +1
| @@ -757,6 +757,7 @@ function boot(): void { | ||
| 757 | 757 | $("summary-detail").textContent = summary.detail; |
| 758 | 758 | $("summary-score").textContent = summary.score; |
| 759 | 759 | $("summary-line").textContent = `${summary.name} — ${summary.line}`; |
| 760 | $<HTMLAnchorElement>("summary-match").href = `/m/${matchId}`; | |
| 760 | 761 | // Built once from board — already escaped — never patched frame by frame. |
| 761 | 762 | $("summary-board").innerHTML = finalBoardHtml(board); |
| 762 | 763 | show(summaryPanel, true); |
src/db.test.ts +16 −1
| @@ -8,7 +8,7 @@ import { Database } from "bun:sqlite"; | ||
| 8 | 8 | import { mkdtempSync } from "node:fs"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { openDatabase, recentMatches, saveMatch, standings } from "./db.ts"; | |
| 11 | import { matchById, openDatabase, recentMatches, saveMatch, standings } from "./db.ts"; | |
| 12 | 12 | import type { Result } from "./result.ts"; |
| 13 | 13 | |
| 14 | 14 | const dir = (): string => mkdtempSync(join(tmpdir(), "db-")); |
| @@ -167,6 +167,21 @@ test("a match round-trips its state: finished, left, or still live", () => { | ||
| 167 | 167 | expect(recentMatches(db).find((m) => m.id === live.matchId)?.state).toBe("live"); |
| 168 | 168 | }); |
| 169 | 169 | |
| 170 | test("a known match is found by its id, with the same numbers recentMatches reads", () => { | |
| 171 | const db = openDatabase(dir()); | |
| 172 | const match = result({ kills: 12, damage: 900 }); | |
| 173 | expect(saveMatch(db, writer("user:u_1", "ada"), match)).toBe(true); | |
| 174 | const row = matchById(db, match.matchId); | |
| 175 | expect(row?.kills).toBe(12); | |
| 176 | expect(row?.damage).toBe(900); | |
| 177 | expect(row?.map).toBe("de_forge"); | |
| 178 | }); | |
| 179 | ||
| 180 | test("an id nothing was ever saved under is not found", () => { | |
| 181 | const db = openDatabase(dir()); | |
| 182 | expect(matchById(db, crypto.randomUUID())).toBe(null); | |
| 183 | }); | |
| 184 | ||
| 170 | 185 | test("a late periodic post cannot undo a leave that already landed", () => { |
| 171 | 186 | const db = openDatabase(dir()); |
| 172 | 187 | const w = writer("user:u_1", "ada"); |
src/db.ts +17
| @@ -222,6 +222,19 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] => | ||
| 222 | 222 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 223 | 223 | ).all(limit); |
| 224 | 224 | |
| 225 | /** | |
| 226 | * One match, by the id the browser generated for it — the same row shape as | |
| 227 | * `recentMatches`, so the page and the terminal's detail view read it the | |
| 228 | * same way they read the board. A match with no round played yet is `null` | |
| 229 | * here exactly as it is absent from the board: there is nothing to show. | |
| 230 | */ | |
| 231 | export const matchById = (db: Database, id: string): MatchRow | null => | |
| 232 | db.query<MatchRow, [string]>( | |
| 233 | `SELECT id, identity, session, name, map, kills, deaths, headshots, damage, rounds, | |
| 234 | score_mine, score_theirs, finished, walked_out, seconds, updated_at, ${STATE_SQL} | |
| 235 | FROM match WHERE id = ?1 AND rounds > 0`, | |
| 236 | ).get(id); | |
| 237 | ||
| 225 | 238 | /** |
| 226 | 239 | * The table people actually look at: one row an identity, best first. The name |
| 227 | 240 | * shown is the one on that identity's most recent match, kept current by |
| @@ -253,3 +266,7 @@ export const ratio = (kills: number, deaths: number): string => | ||
| 253 | 266 | |
| 254 | 267 | export const headshotShare = (headshots: number, kills: number): string => |
| 255 | 268 | kills === 0 ? "—" : `${Math.round((headshots / kills) * 100)}%`; |
| 269 | ||
| 270 | /** How long a match took, as minutes:seconds — the one place seconds becomes a clock. */ | |
| 271 | export const duration = (seconds: number): string => | |
| 272 | `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; |
src/pages/game.test.ts +9
| @@ -182,6 +182,15 @@ test("a recent match's row names the map it was played on", () => { | ||
| 182 | 182 | expect(html).toContain("de_forge"); |
| 183 | 183 | }); |
| 184 | 184 | |
| 185 | test("a recent match's row links to its own page", () => { | |
| 186 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 187 | const writer = { identity: "user:u_link", name: "reyes", guest: false, session: "f".repeat(32) }; | |
| 188 | const played = matchResult(); | |
| 189 | saveMatch(stats, writer, played); | |
| 190 | const html = statsHtml(stats, player); | |
| 191 | expect(html).toContain(`<a href="/m/${played.matchId}">`); | |
| 192 | }); | |
| 193 | ||
| 185 | 194 | test("the leaderboard table carries the hook and labels a phone-width card layout needs", () => { |
| 186 | 195 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); |
| 187 | 196 | const writer = { identity: "user:u_4", name: "sable", guest: false, session: "e".repeat(32) }; |
src/pages/game.ts +6 −3
| @@ -40,8 +40,8 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 40 | 40 | m.state === "finished" ? "" : m.state === "abandoned" ? " · abandoned" : " · in progress"; |
| 41 | 41 | const feed = recent |
| 42 | 42 | .map( |
| 43 | (m) => `<li><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` + | |
| 44 | `<span>${escape(m.map)} · ${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span> ` + | |
| 43 | (m) => `<li><a href="/m/${escape(m.id)}"><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` + | |
| 44 | `<span>${escape(m.map)} · ${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span></a> ` + | |
| 45 | 45 | `<time>${escape(m.updated_at)}</time></li>`, |
| 46 | 46 | ) |
| 47 | 47 | .join(""); |
| @@ -142,7 +142,7 @@ export function motionControl(id: "motion" | "pause-motion"): string { | ||
| 142 | 142 | return `<label>Motion<select id="${id}">${options}</select></label>`; |
| 143 | 143 | } |
| 144 | 144 | |
| 145 | const STYLE = ` | |
| 145 | export const STYLE = ` | |
| 146 | 146 | :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; } |
| 147 | 147 | * { box-sizing: border-box; } |
| 148 | 148 | body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; } |
| @@ -267,6 +267,8 @@ th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--li | ||
| 267 | 267 | th { color:var(--dim); font-weight:500; } |
| 268 | 268 | ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; } |
| 269 | 269 | ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; } |
| 270 | ul.recent a { color:inherit; text-decoration:none; display:flex; gap:.5rem; flex-wrap:wrap; align-items:baseline; } | |
| 271 | ul.recent a:hover, ul.recent a:focus { text-decoration:underline; } | |
| 270 | 272 | ul.recent span, ul.recent time, .empty, .you { color:var(--dim); } |
| 271 | 273 | .you { font-size:.86rem; margin:.2rem 0 .6rem; } |
| 272 | 274 | .you b { color:var(--text); } |
| @@ -416,6 +418,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 416 | 418 | <p id="summary-detail"></p> |
| 417 | 419 | <p class="score" id="summary-score"></p> |
| 418 | 420 | <p id="summary-line"></p> |
| 421 | <p><a id="summary-match" href="/">View this match</a></p> | |
| 419 | 422 | <div id="summary-board"></div> |
| 420 | 423 | <div class="row"> |
| 421 | 424 | <button type="button" class="primary" id="again">Play again</button> |
src/pages/match.ts added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | /** | |
| 2 | * One match's own page: everything the board already shows about it, held | |
| 3 | * still at an address you can come back to. Nothing here that is not already | |
| 4 | * a column the board reads — no session, no identity string, one id in and | |
| 5 | * one row out. | |
| 6 | */ | |
| 7 | ||
| 8 | import { duration, headshotShare, ratio, type MatchRow } from "../db.ts"; | |
| 9 | import { isGuest } from "../player.ts"; | |
| 10 | import { escape, STYLE } from "./game.ts"; | |
| 11 | ||
| 12 | const STATE_WORD: Record<MatchRow["state"], string> = { | |
| 13 | finished: "finished", | |
| 14 | abandoned: "abandoned", | |
| 15 | live: "in progress", | |
| 16 | }; | |
| 17 | ||
| 18 | export function matchPage(match: MatchRow): string { | |
| 19 | const tag = isGuest(match.identity) ? ` <span class="tag">guest</span>` : ""; | |
| 20 | const title = `${match.name}${isGuest(match.identity) ? " (guest)" : ""} on ${match.map}`; | |
| 21 | const row = (k: string, v: string): string => `<tr><th>${escape(k)}</th><td>${v}</td></tr>`; | |
| 22 | return `<!doctype html> | |
| 23 | <html lang="en"> | |
| 24 | <meta charset="utf-8"> | |
| 25 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 26 | <title>cs — ${escape(title)}</title> | |
| 27 | <style>${STYLE}</style> | |
| 28 | <header> | |
| 29 | <h1><a href="/">cs</a></h1> | |
| 30 | </header> | |
| 31 | <main> | |
| 32 | <p><a href="/">← Back to the board</a></p> | |
| 33 | <h2>${escape(match.name)}${tag} on ${escape(match.map)}</h2> | |
| 34 | <table> | |
| 35 | <tbody> | |
| 36 | ${row("Score", `${match.score_mine}–${match.score_theirs}`)} | |
| 37 | ${row("State", escape(STATE_WORD[match.state]))} | |
| 38 | ${row("Map", escape(match.map))} | |
| 39 | ${row("Rounds", String(match.rounds))} | |
| 40 | ${row("Kills", String(match.kills))} | |
| 41 | ${row("Deaths", String(match.deaths))} | |
| 42 | ${row("Headshots", `${match.headshots} (${headshotShare(match.headshots, match.kills)})`)} | |
| 43 | ${row("Damage", String(match.damage))} | |
| 44 | ${row("K/D", ratio(match.kills, match.deaths))} | |
| 45 | ${row("Duration", duration(match.seconds))} | |
| 46 | ${row("When", escape(match.updated_at))} | |
| 47 | </tbody> | |
| 48 | </table> | |
| 49 | </main> | |
| 50 | <footer>cs · <a href="/">back to the leaderboard</a></footer> | |
| 51 | </html> | |
| 52 | `; | |
| 53 | } |
src/server.test.ts +48
| @@ -395,6 +395,54 @@ test("the endpoint is rate limited, and one player's flood is their own", async | ||
| 395 | 395 | expect((await post(result(), "", forge("u_calm", "calm"))).status).toBe(200); |
| 396 | 396 | }); |
| 397 | 397 | |
| 398 | test("a known match has its own page, with the same numbers the board shows", async () => { | |
| 399 | const id = crypto.randomUUID(); | |
| 400 | await post(result({ matchId: id, map: "de_forge", kills: 17, deaths: 6, headshots: 4, damage: 2100, rounds: 8, scoreMine: 5, scoreTheirs: 3, seconds: 700 }), "", forge("u_m1", "Solo")); | |
| 401 | const response = await fetch(base + "/m/" + id); | |
| 402 | expect(response.status).toBe(200); | |
| 403 | const body = await response.text(); | |
| 404 | expect(body).toContain("Solo"); | |
| 405 | expect(body).toContain("de_forge"); | |
| 406 | expect(body).toContain("17"); | |
| 407 | expect(body).toContain("6"); | |
| 408 | expect(body).toContain("4"); | |
| 409 | expect(body).toContain("2100"); | |
| 410 | expect(body).toContain("5–3"); | |
| 411 | expect(body).toContain("finished"); | |
| 412 | }); | |
| 413 | ||
| 414 | test("a match page never shows a session id or an identity string", async () => { | |
| 415 | const id = crypto.randomUUID(); | |
| 416 | const session = "5".repeat(32); | |
| 417 | await post(result({ matchId: id }), "cs_session=" + session); | |
| 418 | const body = await (await fetch(base + "/m/" + id)).text(); | |
| 419 | expect(body).not.toContain(session); | |
| 420 | expect(body).not.toContain("guest:"); | |
| 421 | expect(body).toContain("guest"); | |
| 422 | }); | |
| 423 | ||
| 424 | test("an id nothing was ever posted under is a 404, and so is a malformed one", async () => { | |
| 425 | const unknown = await fetch(base + "/m/" + crypto.randomUUID()); | |
| 426 | expect(unknown.status).toBe(404); | |
| 427 | for (const bad of ["not-a-uuid", "12345678-1234-1234-1234-12345678901", "../etc/passwd", ""]) { | |
| 428 | const response = await fetch(base + "/m/" + bad); | |
| 429 | expect([bad, response.status]).toEqual([bad, 404]); | |
| 430 | } | |
| 431 | }); | |
| 432 | ||
| 433 | test("HEAD on a match page answers like GET, under the floor and out of shared caches", async () => { | |
| 434 | const id = crypto.randomUUID(); | |
| 435 | await post(result({ matchId: id }), "", forge("u_m2", "Headcheck")); | |
| 436 | const get = await server.fetch(new Request(base + "/m/" + id)); | |
| 437 | const head = await server.fetch(new Request(base + "/m/" + id, { method: "HEAD" })); | |
| 438 | expect(head.status).toBe(200); | |
| 439 | expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0)); | |
| 440 | expect(head.headers.get("content-length")).toBe(get.headers.get("content-length")); | |
| 441 | expect(head.headers.get("cache-control")).toBe("private, no-cache"); | |
| 442 | expect(head.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 443 | expect(head.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 444 | }); | |
| 445 | ||
| 398 | 446 | test("a guest is limited too, by the session they hold", async () => { |
| 399 | 447 | const cookie = "cs_session=" + "e".repeat(32); |
| 400 | 448 | let last = 200; |
src/server.ts +25 −1
| @@ -9,9 +9,10 @@ | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { bundle } from "./bundle.ts"; |
| 11 | 11 | import { acceptsGzip } from "./compress.ts"; |
| 12 | import { openDatabase, saveMatch } from "./db.ts"; | |
| 12 | import { matchById, openDatabase, saveMatch } from "./db.ts"; | |
| 13 | 13 | import { createLimiter, type Decision } from "./limit.ts"; |
| 14 | 14 | import { escape, gamePage, leaderboardJson, statsHtml } from "./pages/game.ts"; |
| 15 | import { matchPage } from "./pages/match.ts"; | |
| 15 | 16 | import { credit } from "./player.ts"; |
| 16 | 17 | import { parseResult } from "./result.ts"; |
| 17 | 18 | import { identityWarning, whoIs, type Who } from "./who.ts"; |
| @@ -120,6 +121,11 @@ async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | | ||
| 120 | 121 | return body; |
| 121 | 122 | } |
| 122 | 123 | |
| 124 | // A match id is a uuid the browser generated with crypto.randomUUID(): the | |
| 125 | // one shape /m/<id> is ever allowed to mean. Checked here, before the id | |
| 126 | // reaches the database — a malformed one is a 404 that never queries. | |
| 127 | const MATCH_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
| 128 | ||
| 123 | 129 | // A session is only ever a name for "the browser that started this match", so |
| 124 | 130 | // one browser cannot overwrite another's result. It carries nothing else. |
| 125 | 131 | const SESSION_COOKIE = "cs_session"; |
| @@ -230,6 +236,24 @@ export async function serve(): Promise<ReturnType<typeof Bun.serve>> { | ||
| 230 | 236 | : new Response(JSON.stringify(leaderboardJson(db)), { headers }); |
| 231 | 237 | } |
| 232 | 238 | |
| 239 | // One match, at the address every row on the board and the match-over | |
| 240 | // card link to. Not found and malformed read the same: a 404, and a | |
| 241 | // malformed id never reaches matchById at all. | |
| 242 | if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/m/")) { | |
| 243 | const id = url.pathname.slice("/m/".length); | |
| 244 | const match = MATCH_ID.test(id) ? matchById(db, id) : null; | |
| 245 | if (match === null) | |
| 246 | return html(`<h1>Not found</h1><p><a href="/">Back to ${escape("cs")}</a></p>`, 404, { | |
| 247 | "cache-control": "private, no-cache", | |
| 248 | }); | |
| 249 | const headers = { | |
| 250 | "content-type": "text/html; charset=utf-8", | |
| 251 | "cache-control": "private, no-cache", | |
| 252 | ...SECURITY, | |
| 253 | }; | |
| 254 | return encoded(request, encoder.encode(matchPage(match)), headers); | |
| 255 | } | |
| 256 | ||
| 233 | 257 | // HEAD is GET without the body: a health check sees the app up. |
| 234 | 258 | if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) { |
| 235 | 259 | // A first visit is given its session here, and the page is rendered |
src/tty.test.ts +48 −7
| @@ -62,14 +62,14 @@ test("tab walks the three tabs in src/tabs.ts's order and comes back round", () | ||
| 62 | 62 | |
| 63 | 63 | test("the cursor is clamped when the list is shorter than the cursor, including across a tab switch", () => { |
| 64 | 64 | const rows = data({ standings: [standing()], matches: [match(), match(), match()] }); |
| 65 | let state: State = { tab: "matches", cursor: 2, note: "" }; | |
| 65 | let state: State = { tab: "matches", cursor: 2, note: "", detail: null }; | |
| 66 | 66 | state = key(state, "j", rows, noRemove); |
| 67 | 67 | expect(state.cursor).toBe(2); // three matches: index 2 is the last one, "j" cannot go past it |
| 68 | 68 | state = key(state, "\t", rows, noRemove); |
| 69 | 69 | expect(state.tab).toBe("policy"); |
| 70 | 70 | expect(state.cursor).toBe(0); |
| 71 | 71 | // render() clamps too, independently of key(), for a state built by hand. |
| 72 | const wide: State = { tab: "standings", cursor: 5, note: "" }; | |
| 72 | const wide: State = { tab: "standings", cursor: 5, note: "", detail: null }; | |
| 73 | 73 | expect(() => render(wide, rows)).not.toThrow(); |
| 74 | 74 | const lines = render(wide, rows); |
| 75 | 75 | expect(lines.some((l) => l.includes("tester"))).toBe(true); |
| @@ -82,7 +82,7 @@ test("x removes the selected match and notes it; x on another tab does nothing", | ||
| 82 | 82 | removed.push(id); |
| 83 | 83 | return true; |
| 84 | 84 | }; |
| 85 | const state: State = { tab: "matches", cursor: 1, note: "" }; | |
| 85 | const state: State = { tab: "matches", cursor: 1, note: "", detail: null }; | |
| 86 | 86 | const next = key(state, "x", rows, remove); |
| 87 | 87 | expect(removed).toEqual(["b"]); |
| 88 | 88 | expect(next.note).toBe("removed Bob's match"); |
| @@ -90,7 +90,7 @@ test("x removes the selected match and notes it; x on another tab does nothing", | ||
| 90 | 90 | const failing = key(state, "x", rows, () => false); |
| 91 | 91 | expect(failing.note).toBe("nothing removed"); |
| 92 | 92 | |
| 93 | const onStandings = key({ tab: "standings", cursor: 0, note: "" }, "x", rows, remove); | |
| 93 | const onStandings = key({ tab: "standings", cursor: 0, note: "", detail: null }, "x", rows, remove); | |
| 94 | 94 | expect(removed).toEqual(["b"]); // unchanged: x only removes on the matches tab |
| 95 | 95 | expect(onStandings.note).toBe(""); |
| 96 | 96 | }); |
| @@ -98,16 +98,16 @@ test("x removes the selected match and notes it; x on another tab does nothing", | ||
| 98 | 98 | test("x past the end of the list removes nothing", () => { |
| 99 | 99 | const rows = data({ matches: [] }); |
| 100 | 100 | const remove = () => true; |
| 101 | const state: State = { tab: "matches", cursor: 0, note: "" }; | |
| 101 | const state: State = { tab: "matches", cursor: 0, note: "", detail: null }; | |
| 102 | 102 | const next = key(state, "x", rows, remove); |
| 103 | 103 | expect(next.note).toBe(""); |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | 106 | test("an unrecognised key, like q, leaves the tab and cursor exactly as they were", () => { |
| 107 | 107 | const rows = data({ standings: [standing(), standing()] }); |
| 108 | const state: State = { tab: "standings", cursor: 1, note: "stale" }; | |
| 108 | const state: State = { tab: "standings", cursor: 1, note: "stale", detail: null }; | |
| 109 | 109 | const next = key(state, "q", rows, noRemove); |
| 110 | expect(next).toEqual({ tab: "standings", cursor: 1, note: "" }); | |
| 110 | expect(next).toEqual({ tab: "standings", cursor: 1, note: "", detail: null }); | |
| 111 | 111 | }); |
| 112 | 112 | |
| 113 | 113 | test("the standings tab renders every row, and says so when there are none", () => { |
| @@ -181,6 +181,47 @@ test("a C1 control byte, like the 8-bit form of CSI, is stripped too", () => { | ||
| 181 | 181 | expect(line).not.toContain("\x9b"); |
| 182 | 182 | }); |
| 183 | 183 | |
| 184 | test("enter opens the selected match on the matches tab, and shows the same numbers the page does", () => { | |
| 185 | const rows = data({ | |
| 186 | matches: [match({ id: "target", name: "Vesper", map: "de_dust", kills: 17, deaths: 6, headshots: 4, damage: 2100, rounds: 8, score_mine: 5, score_theirs: 3, seconds: 700 })], | |
| 187 | }); | |
| 188 | const state: State = { tab: "matches", cursor: 0, note: "", detail: null }; | |
| 189 | const opened = key(state, "\r", rows, noRemove); | |
| 190 | expect(opened.detail).toBe("target"); | |
| 191 | const lines = render(opened, rows).join("\n"); | |
| 192 | expect(lines).toContain("Vesper"); | |
| 193 | expect(lines).toContain("de_dust"); | |
| 194 | expect(lines).toContain("5–3"); | |
| 195 | expect(lines).toContain("17"); | |
| 196 | expect(lines).toContain("6"); | |
| 197 | expect(lines).toContain("4"); | |
| 198 | expect(lines).toContain("2100"); | |
| 199 | expect(lines).toContain("finished"); | |
| 200 | expect(lines).toContain("esc back"); | |
| 201 | }); | |
| 202 | ||
| 203 | test("enter does nothing on the standings tab", () => { | |
| 204 | const rows = data(); | |
| 205 | const state: State = { tab: "standings", cursor: 0, note: "", detail: null }; | |
| 206 | expect(key(state, "\r", rows, noRemove).detail).toBe(null); | |
| 207 | }); | |
| 208 | ||
| 209 | test("esc closes the detail view and returns to the list, and nothing else does", () => { | |
| 210 | const rows = data({ matches: [match({ id: "target" })] }); | |
| 211 | const open: State = { tab: "matches", cursor: 0, note: "", detail: "target" }; | |
| 212 | expect(key(open, "j", rows, noRemove)).toEqual(open); | |
| 213 | const closed = key(open, "\x1b", rows, noRemove); | |
| 214 | expect(closed.detail).toBe(null); | |
| 215 | expect(render(closed, rows).join("\n")).toContain("tester"); | |
| 216 | }); | |
| 217 | ||
| 218 | test("opening a match id that has since vanished says so instead of throwing", () => { | |
| 219 | const rows = data({ matches: [] }); | |
| 220 | const open: State = { tab: "matches", cursor: 0, note: "", detail: "gone" }; | |
| 221 | expect(() => render(open, rows)).not.toThrow(); | |
| 222 | expect(render(open, rows).join("\n")).toContain("gone"); | |
| 223 | }); | |
| 224 | ||
| 184 | 225 | test("standings and matches say who a row belongs to, guest or signed", () => { |
| 185 | 226 | const rows = data({ |
| 186 | 227 | standings: [standing({ identity: "user:1", name: "guest aaaa" }), standing({ identity: "guest:aaaa", name: "guest aaaa" })], |
src/tty.ts +56 −5
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { |
| 16 | deleteMatch, headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, | |
| 16 | deleteMatch, duration, headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, | |
| 17 | 17 | type MatchRow, type Standing, |
| 18 | 18 | } from "./db.ts"; |
| 19 | 19 | import { isGuest } from "./player.ts"; |
| @@ -21,9 +21,14 @@ import { identityWarning } from "./who.ts"; | ||
| 21 | 21 | import { CARD } from "./client/weights.ts"; |
| 22 | 22 | import { nextTab, TABS, type Tab } from "./tabs.ts"; |
| 23 | 23 | |
| 24 | export type State = { tab: Tab; cursor: number; note: string }; | |
| 24 | /** | |
| 25 | * `detail` is the id of the match the matches tab is showing full-screen — | |
| 26 | * the same view `/m/<id>` renders — or `null` for the ordinary tab list. | |
| 27 | * Only Enter opens it, from a row on the matches tab, and only Esc closes it. | |
| 28 | */ | |
| 29 | export type State = { tab: Tab; cursor: number; note: string; detail: string | null }; | |
| 25 | 30 | |
| 26 | export const initialState: State = { tab: "standings", cursor: 0, note: "" }; | |
| 31 | export const initialState: State = { tab: "standings", cursor: 0, note: "", detail: null }; | |
| 27 | 32 | |
| 28 | 33 | /** Everything a frame needs to draw, read fresh from the database each time. */ |
| 29 | 34 | export type Data = { |
| @@ -63,9 +68,14 @@ const clampCursor = (cursor: number, rows: number): number => Math.max(0, Math.m | ||
| 63 | 68 | |
| 64 | 69 | /** What one keypress does to the state. Reads `data` but never the database itself. */ |
| 65 | 70 | export function key(state: State, input: string, data: Data, remove: Remove): State { |
| 71 | // The detail view is a dead end but for Esc: no scrolling one row, no tab | |
| 72 | // switch out from under it — back to the list is the only way out. | |
| 73 | if (state.detail !== null) | |
| 74 | return input === "\x1b" ? { tab: state.tab, cursor: state.cursor, note: "", detail: null } : state; | |
| 66 | 75 | let tab = state.tab; |
| 67 | 76 | let cursor = state.cursor; |
| 68 | 77 | let note = ""; |
| 78 | let detail: string | null = null; | |
| 69 | 79 | if (input === "j" || input === "\x1b[B") cursor += 1; |
| 70 | 80 | else if (input === "k" || input === "\x1b[A") cursor = Math.max(0, cursor - 1); |
| 71 | 81 | else if (input === "\t") { |
| @@ -74,13 +84,54 @@ export function key(state: State, input: string, data: Data, remove: Remove): St | ||
| 74 | 84 | } else if (input === "x" && tab === "matches") { |
| 75 | 85 | const match = data.matches[cursor]; |
| 76 | 86 | if (match !== undefined) note = remove(match.id) ? `removed ${match.name}'s match` : "nothing removed"; |
| 87 | } else if ((input === "\r" || input === "\n") && tab === "matches") { | |
| 88 | const match = data.matches[cursor]; | |
| 89 | if (match !== undefined) detail = match.id; | |
| 77 | 90 | } |
| 78 | 91 | cursor = clampCursor(cursor, rowCount(tab, data)); |
| 79 | return { tab, cursor, note }; | |
| 92 | return { tab, cursor, note, detail }; | |
| 93 | } | |
| 94 | ||
| 95 | // The word for a row's state, unchanged for the detail view — the page and | |
| 96 | // the terminal say the same thing. | |
| 97 | const stateWord = (m: MatchRow): string => | |
| 98 | m.state === "finished" ? "finished" : m.state === "abandoned" ? "abandoned" : "in progress"; | |
| 99 | ||
| 100 | /** | |
| 101 | * One match, full-screen: the same fields the page at /m/<id> shows, read the | |
| 102 | * same way — `data.matches` is where both this and the page's own query draw | |
| 103 | * their numbers from a row. | |
| 104 | */ | |
| 105 | function renderDetail(match: MatchRow | undefined): string[] { | |
| 106 | const lines: string[] = []; | |
| 107 | lines.push(`${esc}1mcs${esc}0m ${esc}2mmatch${esc}0m`); | |
| 108 | lines.push(""); | |
| 109 | if (match === undefined) { | |
| 110 | lines.push(` ${esc}2mthat match is gone${esc}0m`); | |
| 111 | } else { | |
| 112 | const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 12)}${esc}0m${v}`; | |
| 113 | lines.push(` ${esc}1m${safe(match.name)}${esc}0m ${esc}2m(${who(match.identity)})${esc}0m`); | |
| 114 | lines.push(""); | |
| 115 | lines.push(line("map", safe(match.map))); | |
| 116 | lines.push(line("score", `${match.score_mine}–${match.score_theirs}`)); | |
| 117 | lines.push(line("state", stateWord(match))); | |
| 118 | lines.push(line("rounds", String(match.rounds))); | |
| 119 | lines.push(line("kills", String(match.kills))); | |
| 120 | lines.push(line("deaths", String(match.deaths))); | |
| 121 | lines.push(line("headshots", `${match.headshots} (${headshotShare(match.headshots, match.kills)})`)); | |
| 122 | lines.push(line("damage", String(match.damage))); | |
| 123 | lines.push(line("k/d", ratio(match.kills, match.deaths))); | |
| 124 | lines.push(line("duration", duration(match.seconds))); | |
| 125 | lines.push(line("when", safe(match.updated_at))); | |
| 126 | } | |
| 127 | lines.push(""); | |
| 128 | lines.push(`${esc}2mesc back${esc}0m`); | |
| 129 | return lines; | |
| 80 | 130 | } |
| 81 | 131 | |
| 82 | 132 | /** The screen for a state and a frame of data, one line per row. */ |
| 83 | 133 | export function render(state: State, data: Data): string[] { |
| 134 | if (state.detail !== null) return renderDetail(data.matches.find((m) => m.id === state.detail)); | |
| 84 | 135 | const cursor = clampCursor(state.cursor, rowCount(state.tab, data)); |
| 85 | 136 | const lines: string[] = []; |
| 86 | 137 | lines.push(`${esc}1mcs${esc}0m ${esc}2m${safe(data.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`); |
| @@ -120,7 +171,7 @@ export function render(state: State, data: Data): string[] { | ||
| 120 | 171 | }); |
| 121 | 172 | } |
| 122 | 173 | lines.push(""); |
| 123 | lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`); | |
| 174 | lines.push(`${esc}2mj/k move · tab switch · enter open a match · x remove a match · r refresh · q quit${esc}0m`); | |
| 124 | 175 | lines.push(data.ssh === undefined |
| 125 | 176 | ? `${esc}2mthe app's terminal is off${esc}0m` |
| 126 | 177 | : `${esc}2mreach this terminal with: ${data.ssh}${esc}0m`); |