Public: anyone can read this; the crew can change it.
forked from trevato/cs
A match nobody finished stops saying "in progress": the feed tells the truth about an abandoned game #253
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/162-a-match-nobody-finished-stops-saying-in → main 1 commit fork
10 files
changed +239
−14
README.md +26 −2
| @@ -267,6 +267,29 @@ whatever the name field said, and a result posted without one is a **guest**. | ||
| 267 | 267 | the only sign-in this app has: no password, no cookie of its own that names a |
| 268 | 268 | person, nothing a page can set. |
| 269 | 269 | |
| 270 | ## A match nobody finished | |
| 271 | ||
| 272 | A result is posted as every round is scored, so the newest rows under **Recent | |
| 273 | matches** are often matches somebody is still playing — and one of them is | |
| 274 | sometimes a tab that was closed hours ago. A row is read against the clock | |
| 275 | rather than believed: `liveness(row, now)` in `src/db.ts` calls a row | |
| 276 | **finished**, **live** or **abandoned**, and a match that has not been written | |
| 277 | to for `IDLE_SECONDS` — ten minutes — is abandoned wherever it is rendered: | |
| 278 | `· abandoned` in the feed on `/`, `(abandoned)` in the terminal's matches tab. | |
| 279 | One function, two renderers, so the page and the terminal cannot disagree. | |
| 280 | ||
| 281 | Ten minutes is derived, not picked. The longest round the rules allow is 172 | |
| 282 | seconds (freeze 12 + round 115 + bomb 40 + end delay 5), and a post that has to | |
| 283 | be retried adds at most 14 more (`src/client/pending.ts`), so the gap between | |
| 284 | two posts of a live match has a ceiling of 186 seconds; ten minutes is over | |
| 285 | three times it. | |
| 286 | ||
| 287 | Nothing is written, deleted or rewritten to say any of it — this is how a row is | |
| 288 | *read*. A tab that was asleep posts again, `updated_at` moves, and the same row | |
| 289 | reads as live with no flag to clear and no special case anywhere. The standings | |
| 290 | are untouched either way: an abandoned match counts the rounds, kills and damage | |
| 291 | it scored, exactly as it did before. | |
| 292 | ||
| 270 | 293 | ## Playing |
| 271 | 294 | |
| 272 | 295 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
| @@ -1085,7 +1108,7 @@ runs (the app is the login name, the forge is the host). It opens `src/tty.ts` | ||
| 1085 | 1108 | on a real pty over the same database the web page uses. Three tabs, and |
| 1086 | 1109 | <kbd>Tab</kbd> cycles them: **standings** (the leaderboard, one row a name), |
| 1087 | 1110 | **matches** (the recent matches, newest first, the map each was played on, a |
| 1088 | live one marked, and under the cursor the id — and the map name, when it was a | |
| 1111 | live one marked and an abandoned one said to be, and under the cursor the id — and the map name, when it was a | |
| 1089 | 1112 | generated one — to paste back into the menu) and |
| 1090 | 1113 | **policy** (the card the bots were trained with — trained-at, simulation ticks |
| 1091 | 1114 | and throughput, PPO updates, duels and both win rates, and the command that |
| @@ -1097,7 +1120,8 @@ off when it is unset. | ||
| 1097 | 1120 | |
| 1098 | 1121 | `src/tty.ts` is the wiring only — the database, raw stdin, stdout, resize and |
| 1099 | 1122 | exit. What it draws is `src/terminal/screen.ts` (`screen(view, data)` returns |
| 1100 | the lines, opening nothing and reading no environment) and what it answers is | |
| 1123 | the lines, opening nothing, reading no environment and asking no clock — the | |
| 1124 | moment the rows were read is `data.now`, handed in with them) and what it answers is | |
| 1101 | 1125 | `src/terminal/keys.ts` (`press(view, key, data)` returns the next view and one |
| 1102 | 1126 | command: nothing, quit, or delete this match). Both are plain data in and out, |
| 1103 | 1127 | so their tests press keys and read lines with no terminal and no database — |
src/db.test.ts +46 −1
| @@ -9,7 +9,9 @@ import { Database } from "bun:sqlite"; | ||
| 9 | 9 | import { mkdtempSync } from "node:fs"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { displayName, openDatabase, type Player, recentMatches, saveMatch, standings } from "./db.ts"; | |
| 12 | import { | |
| 13 | displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, saveMatch, standings, | |
| 14 | } from "./db.ts"; | |
| 13 | 15 | import type { Result } from "./result.ts"; |
| 14 | 16 | |
| 15 | 17 | const dir = (): string => mkdtempSync(join(tmpdir(), "db-")); |
| @@ -123,6 +125,49 @@ test("a database written before the map column keeps its rows and calls them de_ | ||
| 123 | 125 | expect(recentMatches(db).find((m) => m.id === fresh)?.map).toBe("gen_ir1"); |
| 124 | 126 | }); |
| 125 | 127 | |
| 128 | // The stamp SQLite writes, for a moment `n` seconds before `NOW`: UTC, to the | |
| 129 | // second, in the format `datetime('now')` hands back. | |
| 130 | const NOW = Date.parse("2026-09-21T12:00:00Z"); | |
| 131 | const secondsAgo = (n: number): string => new Date(NOW - n * 1000).toISOString().slice(0, 19).replace("T", " "); | |
| 132 | ||
| 133 | test("a match nobody has posted to for the idle time reads as abandoned, not in progress", () => { | |
| 134 | const live = { finished: 0, updated_at: secondsAgo(30) }; | |
| 135 | expect(liveness(live, NOW)).toBe("live"); | |
| 136 | // The same row, older. Nothing about it changed but the clock. | |
| 137 | expect(liveness({ ...live, updated_at: secondsAgo(IDLE_SECONDS + 60) }, NOW)).toBe("abandoned"); | |
| 138 | // A match that got to the end is neither, however long ago it was played. | |
| 139 | expect(liveness({ finished: 1, updated_at: secondsAgo(30) }, NOW)).toBe("finished"); | |
| 140 | expect(liveness({ finished: 1, updated_at: secondsAgo(IDLE_SECONDS * 100) }, NOW)).toBe("finished"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the boundary is checked on both sides, and a row that resumes is live again", () => { | |
| 144 | expect(liveness({ finished: 0, updated_at: secondsAgo(IDLE_SECONDS - 1) }, NOW)).toBe("live"); | |
| 145 | expect(liveness({ finished: 0, updated_at: secondsAgo(IDLE_SECONDS) }, NOW)).toBe("abandoned"); | |
| 146 | expect(liveness({ finished: 0, updated_at: secondsAgo(IDLE_SECONDS + 1) }, NOW)).toBe("abandoned"); | |
| 147 | // A tab that was asleep posts again: `saveMatch` moves `updated_at` and the | |
| 148 | // same row reads as live, with nothing to undo and no flag to clear. | |
| 149 | const db = openDatabase(dir()); | |
| 150 | const id = crypto.randomUUID(); | |
| 151 | expect(saveMatch(db, guest("napper"), result({ matchId: id, finished: false }))).toBe(true); | |
| 152 | db.query("UPDATE match SET updated_at = ?1 WHERE id = ?2").run(secondsAgo(IDLE_SECONDS + 600), id); | |
| 153 | expect(liveness(recentMatches(db)[0]!, NOW)).toBe("abandoned"); | |
| 154 | expect(saveMatch(db, guest("napper"), result({ matchId: id, finished: false, kills: 11 }))).toBe(true); | |
| 155 | expect(liveness(recentMatches(db)[0]!, Date.now())).toBe("live"); | |
| 156 | // And the row is read, never rewritten: the match is still there, with the | |
| 157 | // rounds it scored, which is what the standings count. | |
| 158 | expect(standings(db)[0]?.matches).toBe(1); | |
| 159 | expect(standings(db)[0]?.kills).toBe(11); | |
| 160 | }); | |
| 161 | ||
| 162 | test("a timestamp that is no time reads as abandoned rather than as a live match", () => { | |
| 163 | // Nothing this app writes looks like this, but a hand on the database can: | |
| 164 | // a row we cannot place in time is no evidence anyone is playing it. | |
| 165 | expect(liveness({ finished: 0, updated_at: "" }, NOW)).toBe("abandoned"); | |
| 166 | expect(liveness({ finished: 0, updated_at: "yesterday" }, NOW)).toBe("abandoned"); | |
| 167 | // A clock that ran ahead of the row is not a reason to bury it either. | |
| 168 | expect(liveness({ finished: 0, updated_at: secondsAgo(-60) }, NOW)).toBe("live"); | |
| 169 | }); | |
| 170 | ||
| 126 | 171 | test("opening a database twice migrates once and breaks nothing", () => { |
| 127 | 172 | const home = dir(); |
| 128 | 173 | openDatabase(home).close(); |
src/db.ts +40
| @@ -102,6 +102,46 @@ export type Player = { | ||
| 102 | 102 | export const displayName = (row: { name: string; user_id: string }): string => |
| 103 | 103 | row.user_id === "" ? `${row.name} (guest)` : row.name; |
| 104 | 104 | |
| 105 | /** | |
| 106 | * How long a row may go without a post before it stops reading as live. | |
| 107 | * | |
| 108 | * A match posts a result as every round is scored (`postResult` in | |
| 109 | * `src/client/main.ts`), so the gap between two posts of a match somebody is | |
| 110 | * playing has a known ceiling: the longest round the rules allow is freeze 12 s | |
| 111 | * + round 115 s + bomb 40 s + end delay 5 s = 172 s (`COMPETITIVE` in | |
| 112 | * `src/client/match.ts`), and a post that has to be retried adds at most another | |
| 113 | * 14 s (`MAX_TRIES` attempts of `waitAfter` in `src/client/pending.ts`) — 186 s | |
| 114 | * in all. Ten minutes is over three times that: long enough that a slow round, | |
| 115 | * a retried post or a tab the browser throttled is never called abandoned, | |
| 116 | * short enough that a closed tab stops claiming to be live while anyone is | |
| 117 | * still looking at the page. | |
| 118 | */ | |
| 119 | export const IDLE_SECONDS = 600; | |
| 120 | ||
| 121 | /** How a match row reads right now. `finished` is a row that got to the end. */ | |
| 122 | export type Liveness = "finished" | "live" | "abandoned"; | |
| 123 | ||
| 124 | /** | |
| 125 | * The one rule for that, so the page and the terminal cannot disagree: a row | |
| 126 | * that is not finished and has not been written to for `IDLE_SECONDS` is a | |
| 127 | * match nobody is playing any more. | |
| 128 | * | |
| 129 | * It reads a row; it never writes one. A match that resumes — a tab that was | |
| 130 | * asleep — posts again, `updated_at` moves, and the same row reads as live with | |
| 131 | * no special case anywhere. `updated_at` is SQLite's `datetime('now')`: UTC, in | |
| 132 | * `YYYY-MM-DD HH:MM:SS`, which is not what `Date.parse` treats as UTC on its | |
| 133 | * own, so it is spelled into an instant here rather than left to the parser's | |
| 134 | * idea of local time. A timestamp that is no time at all is no evidence a match | |
| 135 | * is live, and this page would rather say less than claim what it cannot check: | |
| 136 | * it reads as abandoned. | |
| 137 | */ | |
| 138 | export function liveness(row: { finished: number; updated_at: string }, now = Date.now()): Liveness { | |
| 139 | if (row.finished === 1) return "finished"; | |
| 140 | const at = Date.parse(`${row.updated_at.replace(" ", "T")}Z`); | |
| 141 | if (Number.isNaN(at)) return "abandoned"; | |
| 142 | return now - at >= IDLE_SECONDS * 1000 ? "abandoned" : "live"; | |
| 143 | } | |
| 144 | ||
| 105 | 145 | /** |
| 106 | 146 | * Write a result. A match belongs to whoever started it: a signed-in player's |
| 107 | 147 | * match is theirs whichever browser they are on, and a guest's is that |
src/pages/game.test.ts +38 −2
| @@ -15,11 +15,11 @@ import { expect, test } from "bun:test"; | ||
| 15 | 15 | import { mkdtempSync } from "node:fs"; |
| 16 | 16 | import { tmpdir } from "node:os"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | import { openDatabase, type Player, saveMatch } from "../db.ts"; | |
| 18 | import { IDLE_SECONDS, openDatabase, type Player, saveMatch, standings } from "../db.ts"; | |
| 19 | 19 | import type { Result } from "../result.ts"; |
| 20 | 20 | import { CARD, type Card } from "../client/weights.ts"; |
| 21 | 21 | import { GAMES } from "../games.ts"; |
| 22 | import { gamePage, policyHtml, shareHref, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts"; | |
| 22 | import { gamePage, policyHtml, shareHref, statsHtml, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts"; | |
| 23 | 23 | import { escape } from "./shell.ts"; |
| 24 | 24 | |
| 25 | 25 | const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "game-"))); |
| @@ -95,6 +95,42 @@ test("a recent match names the map it was played on, and the name is a link to i | ||
| 95 | 95 | expect(html).toContain(`<a class="map" href="/?map=de_forge">`); |
| 96 | 96 | }); |
| 97 | 97 | |
| 98 | test("a match that stopped posting reads as abandoned in the feed, and still counts what it scored", () => { | |
| 99 | const home = db(); | |
| 100 | const player: Player = { userId: "u-1", session: "s1", name: "quitter" }; | |
| 101 | const result: Result = { | |
| 102 | matchId: crypto.randomUUID(), name: "ignored", map: "de_forge", rounds: 3, scoreMine: 2, scoreTheirs: 1, | |
| 103 | kills: 6, deaths: 4, headshots: 2, damage: 800, seconds: 300, finished: false, | |
| 104 | }; | |
| 105 | expect(saveMatch(home, player, result)).toBe(true); | |
| 106 | // Written a moment ago: somebody is playing it, and the feed says so. | |
| 107 | const live = statsHtml(home, Date.now()); | |
| 108 | expect(live).toContain("800 damage · in progress"); | |
| 109 | expect(live).not.toContain("abandoned"); | |
| 110 | ||
| 111 | // The same row, read later. Nothing was written, deleted or rewritten — | |
| 112 | // only the clock the page reads it against moved past the idle time. | |
| 113 | const later = Date.now() + (IDLE_SECONDS + 60) * 1000; | |
| 114 | const stale = statsHtml(home, later); | |
| 115 | expect(stale).toContain("800 damage · abandoned"); | |
| 116 | expect(stale).not.toContain("in progress"); | |
| 117 | // One second before it, it is still in progress: the boundary is the constant. | |
| 118 | expect(statsHtml(home, Date.now() + (IDLE_SECONDS - 1) * 1000)).toContain("· in progress"); | |
| 119 | ||
| 120 | // And the standings are untouched by any of it: an abandoned match counts | |
| 121 | // the rounds and kills it scored, exactly as it did before this rule existed. | |
| 122 | expect(standings(home)[0]?.kills).toBe(6); | |
| 123 | expect(standings(home)[0]?.matches).toBe(1); | |
| 124 | expect(stale).toContain("<td>quitter</td>"); | |
| 125 | ||
| 126 | // A finished match is neither word, however long ago it was played. | |
| 127 | const over = db(); | |
| 128 | expect(saveMatch(over, player, { ...result, finished: true })).toBe(true); | |
| 129 | const done = statsHtml(over, later); | |
| 130 | expect(done).not.toContain("in progress"); | |
| 131 | expect(done).not.toContain("abandoned"); | |
| 132 | }); | |
| 133 | ||
| 98 | 134 | test("the screen a match ends on is in the page, hidden, with the menu's own sentence on it", () => { |
| 99 | 135 | const html = gamePage(db(), options); |
| 100 | 136 | // An overlay like the menu and the pause, rendered hidden: the first paint is |
src/pages/game.ts +20 −4
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; | |
| 13 | import { displayName, headshotShare, liveness, type Liveness, ratio, recentMatches, standings } from "../db.ts"; | |
| 14 | 14 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 15 | 15 | import { CARD, type Card } from "../client/weights.ts"; |
| 16 | 16 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| @@ -20,8 +20,24 @@ import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.t | ||
| 20 | 20 | |
| 21 | 21 | export { escape }; |
| 22 | 22 | |
| 23 | /** The leaderboard block. Also what POST /api/match answers with. */ | |
| 24 | export function statsHtml(db: Database): string { | |
| 23 | /** | |
| 24 | * What a row in the feed says about itself after its numbers. A match that is | |
| 25 | * still posting is in progress; one that stopped posting says so rather than | |
| 26 | * claiming to be live for ever (`liveness` in `src/db.ts`); a finished one is | |
| 27 | * neither, because its score is the whole story. | |
| 28 | */ | |
| 29 | const STATE_SAID: Record<Liveness, string> = { | |
| 30 | finished: "", | |
| 31 | live: " · in progress", | |
| 32 | abandoned: " · abandoned", | |
| 33 | }; | |
| 34 | ||
| 35 | /** | |
| 36 | * The leaderboard block. Also what POST /api/match answers with. `now` is the | |
| 37 | * moment it is rendered at, which only the feed's in-progress line reads; a | |
| 38 | * test passes its own rather than waiting ten minutes. | |
| 39 | */ | |
| 40 | export function statsHtml(db: Database, now = Date.now()): string { | |
| 25 | 41 | const table = standings(db, 10); |
| 26 | 42 | const recent = recentMatches(db, 8); |
| 27 | 43 | const rows = table |
| @@ -38,7 +54,7 @@ export function statsHtml(db: Database): string { | ||
| 38 | 54 | // is built rather than copied. |
| 39 | 55 | (m) => `<li><b>${escape(displayName(m))}</b> ${m.score_mine}–${m.score_theirs} ` + |
| 40 | 56 | `<a class="map" href="${mapLink(mapFrom(m.map))}">${escape(m.map)}</a> ` + |
| 41 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` + | |
| 57 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${STATE_SAID[liveness(m, now)]}</span> ` + | |
| 42 | 58 | `<time>${escape(m.updated_at)}</time></li>`, |
| 43 | 59 | ) |
| 44 | 60 | .join(""); |
src/server.test.ts +26
| @@ -5,6 +5,7 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { bundle } from "../scripts/build.ts"; |
| 6 | 6 | import { buildBundles, bundlesDir } from "./bundles.ts"; |
| 7 | 7 | import { bytes, kB } from "./bytes.ts"; |
| 8 | import { IDLE_SECONDS, openDatabase } from "./db.ts"; | |
| 8 | 9 | import { measureExample } from "./cost.ts"; |
| 9 | 10 | import { GAMES } from "./games.ts"; |
| 10 | 11 | import { matchOf, playKey } from "./matchid.ts"; |
| @@ -642,6 +643,31 @@ test("a result is stored and comes back as the leaderboard", async () => { | ||
| 642 | 643 | expect(await (await fetch(base + "/")).text()).toContain("Vesper"); |
| 643 | 644 | }); |
| 644 | 645 | |
| 646 | test("a match that stopped posting reads as abandoned on the page, and keeps its place in the standings", async () => { | |
| 647 | const cookie = "cs_session=" + "c".repeat(32); | |
| 648 | const id = crypto.randomUUID(); | |
| 649 | expect((await post(result({ matchId: id, name: "ghost", finished: false }), cookie)).status).toBe(200); | |
| 650 | const rowOf = (body: string): string => | |
| 651 | /<li><b>ghost \(guest\)<\/b>[\s\S]*?<\/li>/.exec(body)?.[0] ?? "no row for ghost"; | |
| 652 | expect(rowOf(await (await fetch(base + "/")).text())).toContain("· in progress"); | |
| 653 | ||
| 654 | // The tab closes. Nothing posts again, and the row goes stale where it lies: | |
| 655 | // this is the database the server is reading, with `updated_at` pushed back | |
| 656 | // past the idle time and every other column untouched. | |
| 657 | const db = openDatabase(process.env.FORGE_APP_DATA as string); | |
| 658 | expect(db.query("UPDATE match SET updated_at = datetime('now', ?1) WHERE id = ?2") | |
| 659 | .run(`-${IDLE_SECONDS + 60} seconds`, id).changes).toBe(1); | |
| 660 | db.close(); | |
| 661 | ||
| 662 | const page = await (await fetch(base + "/")).text(); | |
| 663 | expect(rowOf(page)).toContain("· abandoned"); | |
| 664 | expect(rowOf(page)).not.toContain("in progress"); | |
| 665 | // The score it did reach is still on the board and still in the standings: | |
| 666 | // reading a row differently moves no number. | |
| 667 | expect(rowOf(page)).toContain("3–2"); | |
| 668 | expect(page).toContain("<td>ghost (guest)</td>"); | |
| 669 | }); | |
| 670 | ||
| 645 | 671 | test("a name is escaped, not run", async () => { |
| 646 | 672 | await post(result({ name: "<img src=x onerror=alert(1)>" })); |
| 647 | 673 | const page = await (await fetch(base + "/")).text(); |
src/terminal/keys.test.ts +2
| @@ -39,6 +39,8 @@ const data: Data = { | ||
| 39 | 39 | matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")], |
| 40 | 40 | total: 4, |
| 41 | 41 | card: CARD, |
| 42 | // The keys read rows and a cursor; the clock is the screen's business. | |
| 43 | now: Date.parse("2026-09-16T10:00:30Z"), | |
| 42 | 44 | }; |
| 43 | 45 | |
| 44 | 46 | const view = (over: Partial<View> = {}): View => |
src/terminal/screen.test.ts +23
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import type { Card } from "../client/weights.ts"; |
| 10 | 10 | import type { MatchRow, Standing } from "../db.ts"; |
| 11 | import { IDLE_SECONDS } from "../db.ts"; | |
| 11 | 12 | import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts"; |
| 12 | 13 | |
| 13 | 14 | const CARD: Card = { |
| @@ -51,11 +52,18 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow | ||
| 51 | 52 | ...over, |
| 52 | 53 | }); |
| 53 | 54 | |
| 55 | // When the fixture's rows were last written, and — half a minute later — when | |
| 56 | // the screen below is drawn, so a match still posting reads as one. The clock | |
| 57 | // is data here like everything else on this screen. | |
| 58 | const ROW_AT = Date.parse("2026-09-16T10:00:00Z"); | |
| 59 | const NOW = ROW_AT + 30_000; | |
| 60 | ||
| 54 | 61 | const data = (over: Partial<Data> = {}): Data => ({ |
| 55 | 62 | standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })], |
| 56 | 63 | matches: [match("m1", "ace"), match("m2", "bo", { finished: 0 })], |
| 57 | 64 | total: 4, |
| 58 | 65 | card: CARD, |
| 66 | now: NOW, | |
| 59 | 67 | ...over, |
| 60 | 68 | }); |
| 61 | 69 | |
| @@ -107,6 +115,21 @@ test("the match list keeps its columns and marks the ones still being played", ( | ||
| 107 | 115 | expect(lines[6]).toBe(" bo 3-2 7/4 950 de_forge 2026-09-16 10:00:00 (live)"); |
| 108 | 116 | }); |
| 109 | 117 | |
| 118 | test("a match that stopped posting is abandoned here too, in the same words as the page", () => { | |
| 119 | // The same row and the same rule (`liveness` in `src/db.ts`), read with a | |
| 120 | // clock that moved on: a match nobody finished stops saying it is live. | |
| 121 | const stale = data({ now: ROW_AT + (IDLE_SECONDS + 60) * 1000 }); | |
| 122 | const lines = text(view({ tab: "matches" }), stale); | |
| 123 | expect(lines[6]).toBe(" bo 3-2 7/4 950 de_forge 2026-09-16 10:00:00 (abandoned)"); | |
| 124 | // The finished row above it says neither thing, then or now. | |
| 125 | expect(lines[5]).toBe("▸ ace 3-2 7/4 950 de_forge 2026-09-16 10:00:00"); | |
| 126 | // And one second before the idle time it is still live: the boundary is the | |
| 127 | // constant, not a feeling. | |
| 128 | const nearly = data({ now: ROW_AT + (IDLE_SECONDS - 1) * 1000 }); | |
| 129 | expect(text(view({ tab: "matches" }), nearly)[6]).toEndWith("(live)"); | |
| 130 | expect(text(view({ tab: "matches" }), data({ now: ROW_AT + IDLE_SECONDS * 1000 }))[6]).toEndWith("(abandoned)"); | |
| 131 | }); | |
| 132 | ||
| 110 | 133 | test("the map a match was played on is a column, generated names and all", () => { |
| 111 | 134 | // What a person can read on the page they can read here: the same fact, in |
| 112 | 135 | // the same words. The longest name there is fits the column it was cut for. |
src/terminal/screen.ts +15 −5
| @@ -14,8 +14,8 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import type { Card } from "../client/weights.ts"; |
| 17 | import type { MatchRow, Standing } from "../db.ts"; | |
| 18 | import { displayName, headshotShare, ratio } from "../db.ts"; | |
| 17 | import type { Liveness, MatchRow, Standing } from "../db.ts"; | |
| 18 | import { displayName, headshotShare, liveness, ratio } from "../db.ts"; | |
| 19 | 19 | import { matchOf } from "../matchid.ts"; |
| 20 | 20 | |
| 21 | 21 | export type Tab = "standings" | "matches" | "policy"; |
| @@ -41,8 +41,18 @@ export type Data = { | ||
| 41 | 41 | /** Every match ever recorded, including the empty ones the lists skip. */ |
| 42 | 42 | total: number; |
| 43 | 43 | card: Card; |
| 44 | /** | |
| 45 | * The moment those rows were read, which is the only clock this file has: | |
| 46 | * it tells a match still posting from one that stopped (`liveness` in | |
| 47 | * `src/db.ts`). Passed in like everything else here — nothing in this file | |
| 48 | * asks the machine what time it is. | |
| 49 | */ | |
| 50 | now: number; | |
| 44 | 51 | }; |
| 45 | 52 | |
| 53 | /** What the `when` column adds about a match that is not finished. */ | |
| 54 | const STATE_SAID: Record<Liveness, string> = { finished: "", live: " (live)", abandoned: " (abandoned)" }; | |
| 55 | ||
| 46 | 56 | const esc = "\x1b["; |
| 47 | 57 | |
| 48 | 58 | const pad = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n)); |
| @@ -113,7 +123,7 @@ function standingsLines(table: Standing[], cursor: number): string[] { | ||
| 113 | 123 | return lines; |
| 114 | 124 | } |
| 115 | 125 | |
| 116 | function matchLines(matches: MatchRow[], cursor: number): string[] { | |
| 126 | function matchLines(matches: MatchRow[], cursor: number, now: number): string[] { | |
| 117 | 127 | const lines = [ |
| 118 | 128 | // The map column is 12 wide: `gen_1z141z3` is the longest name there is |
| 119 | 129 | // (`src/mapname.ts`), and a space after it. It sits before `when` so the |
| @@ -124,7 +134,7 @@ function matchLines(matches: MatchRow[], cursor: number): string[] { | ||
| 124 | 134 | matches.forEach((m, i) => { |
| 125 | 135 | const line = `${pad(printable(displayName(m)), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + |
| 126 | 136 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${pad(printable(m.map), 12)}` + |
| 127 | `${printable(m.updated_at)}${m.finished === 1 ? "" : " (live)"}`; | |
| 137 | `${printable(m.updated_at)}${STATE_SAID[liveness(m, now)]}`; | |
| 128 | 138 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 129 | 139 | }); |
| 130 | 140 | // The match the cursor is on, as the id that plays it again. A row is keyed |
| @@ -160,7 +170,7 @@ export function screen(view: View, data: Data): string[] { | ||
| 160 | 170 | lines.push(""); |
| 161 | 171 | if (view.tab === "policy") lines.push(...policyLines(data.card)); |
| 162 | 172 | else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor)); |
| 163 | else lines.push(...matchLines(data.matches, cursor)); | |
| 173 | else lines.push(...matchLines(data.matches, cursor, data.now)); | |
| 164 | 174 | lines.push(""); |
| 165 | 175 | lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`); |
| 166 | 176 | lines.push(view.ssh === undefined |
src/tty.ts +3
| @@ -81,6 +81,9 @@ export function run(io: Io): void { | ||
| 81 | 81 | matches: recentMatches(io.db, 20), |
| 82 | 82 | total: matchCount(io.db), |
| 83 | 83 | card: CARD, |
| 84 | // Read with the rows, and only ever here: the screen tells a live match | |
| 85 | // from an abandoned one against the moment it was handed. | |
| 86 | now: Date.now(), | |
| 84 | 87 | }); |
| 85 | 88 | |
| 86 | 89 | const draw = (): void => { io.output.write(CLEAR + screen(view, read()).join("\r\n")); }; |
new above
offline