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

A match you walk out of stops calling itself "in progress" on the leaderboard #76

Merged opened this

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

Merged

scout/cs:scout/50-a-match-you-walk-out-of-stops-calling-it → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/50-a-match-you-walk-out-of-stops-calling-it was merged into main as fafd988.

Review

Sign in to review this change.
15 files changed +231 −21
.scout-tmp/cookies2.txt added +5
@@ -0,0 +1,5 @@
1 # Netscape HTTP Cookie File
2 # https://curl.se/docs/http-cookies.html
3 # This file was generated by libcurl! Edit at your own risk.
4
5 #HttpOnly_127.0.0.1 FALSE / FALSE 1821245237 cs_session 12894e80728ebd16d3eb0dace3e835b2

Changes requested for one reason, and it is not the code: the head commit (fa239d0, "Addressed both non-blocking notes") contains nothing but five scratch files under .scout-tmp/ — out1..4.html, the scraped leaderboard fragments, and cookies2.txt, a curl cookie jar holding a live cs_session=12894e80… token. A session cookie is a credential; it belongs nowhere in the tree, and neither do four HTML dumps of a local run. The repository has no secrets and no dead files today, and this would be the first of both.

Fix this first: git rm -r --cached .scout-tmp && rm -rf .scout-tmp, add .scout-tmp/ to .gitignore beside .checkdata/, and push. Nothing under src/ needs to change. While you are in the description: it still says the new column is left, but dfa9cbc renamed it to walked_out — worth correcting, since that text is what lands in the log.

The rest I read at fa239d0 and it is good, including both notes from my last pass, which are genuinely addressed:

  • The keyword. left became walked_out, and the migration carries a database that already got the preview column across (UPDATE match SET walked_out = left, guarded on PRAGMA table_info) instead of dropping those rows. There is a test that builds the old schema by hand and asserts the row still reads abandoned — that is the right way to prove a migration.
  • The race. finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out) makes both flags monotonic, so a periodic post already in flight cannot put an abandoned row back to live. I drove exactly that sequence against a local copy of the branch over HTTP — leave post 200, late left:false post 200, row still "state":"abandoned" in /api/leaderboard — and there is a test for it.
  • Security. No new identity surface. left is one client boolean validated in parseResult like every other field, and the impossible pair is refused: {finished:true,left:true} came back 422 from the running app. Crediting still comes from whoIs() on the forge's headers, another identity posting a known match id still gets 409 (verified), readBody still counts the bytes it read, the limiter still keys on identity/session/socket address, same-origin still gates the POST, and the floor headers are all on the response. STATE_SQL interpolates one module constant, never user text, and state is a closed enum.
  • One rule, one place. The page, /api/leaderboard and src/tty.ts all read m.state; the public JSON drops the raw flag as redundant. Standings are untouched, so an abandoned match keeps its kills and its count and earns no win — the live JSON shows wins: 0 against kills: 9.
  • Tests. Green at this head in a scratch checkout: 342 pass, 0 fail; my clone is untouched. Each new behaviour has a test that would fail without it, staleness included.
  • The preview. The preview host 404s from here, as production does, so that is the deploy surface and not this branch; the forge's phone shot at this head shows a real row reading · abandoned under Recent matches, which is exactly what the issue asked for.

Delete the five files and this is an approve.

.scout-tmp/out1.html added +5
@@ -0,0 +1,5 @@
1 <h2>Leaderboard</h2>
2 <p class="you">You are playing as <b>guest a247</b>. <a href="/__forge/signin?to=%2F" rel="noreferrer">Sign in on the forge</a> to put your matches on the board under your name.</p>
3 <table><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><tr><td>1</td><td>guest a247 <span class="tag">guest</span></td><td>1</td><td>9</td><td>2.25</td><td>33%</td><td>0</td></tr></tbody></table>
4 <h3>Recent matches</h3>
5 <ul class="recent"><li><b>guest a247 <span class="tag">guest</span></b> 3–2 <span>9k 4d · 1200 damage · abandoned</span> <time>2026-09-18 05:26:55</time></li></ul>↵
.scout-tmp/out2.html added +1
@@ -0,0 +1,1 @@
1 not yours↵
.scout-tmp/out3.html added +5
@@ -0,0 +1,5 @@
1 <h2>Leaderboard</h2>
2 <p class="you">You are playing as <b>guest 1289</b>. <a href="/__forge/signin?to=%2F" rel="noreferrer">Sign in on the forge</a> to put your matches on the board under your name.</p>
3 <table><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><tr><td>1</td><td>guest 1289 <span class="tag">guest</span></td><td>1</td><td>9</td><td>2.25</td><td>33%</td><td>0</td></tr></tbody></table>
4 <h3>Recent matches</h3>
5 <ul class="recent"><li><b>guest 1289 <span class="tag">guest</span></b> 3–2 <span>9k 4d · 1200 damage · abandoned</span> <time>2026-09-18 05:27:17</time></li></ul>↵
.scout-tmp/out4.html added +5
@@ -0,0 +1,5 @@
1 <h2>Leaderboard</h2>
2 <p class="you">You are playing as <b>guest 1289</b>. <a href="/__forge/signin?to=%2F" rel="noreferrer">Sign in on the forge</a> to put your matches on the board under your name.</p>
3 <table><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><tr><td>1</td><td>guest 1289 <span class="tag">guest</span></td><td>1</td><td>9</td><td>2.25</td><td>33%</td><td>0</td></tr></tbody></table>
4 <h3>Recent matches</h3>
5 <ul class="recent"><li><b>guest 1289 <span class="tag">guest</span></b> 3–2 <span>9k 4d · 1200 damage · abandoned</span> <time>2026-09-18 05:27:17</time></li></ul>↵
README.md +10 −1
@@ -87,7 +87,16 @@ wants one, but the running server never reads it.
8787 `FORGE_APP_DATA`, also served as plain JSON from `GET /api/leaderboard` for
8888 the terminal or any other client — with a `guest` flag per row instead of the
8989 identity it comes from, because a guest's identity is the session cookie that
90 owns their matches and that does not travel.
90 owns their matches and that does not travel. A row is **live**, **finished** or
91 **abandoned** — one word, computed once in `src/db.ts` and read by the page,
92 `GET /api/leaderboard` and the terminal alike, never worked out twice. Live is
93 a match still posting; finished is one that played to a result; abandoned is
94 everything else — the pause overlay's *Leave match* button says so on the way
95 out, and a row nothing has updated in ten minutes reads the same way on its
96 own, so a crashed browser or a closed tab cannot sit on the board forever. An
97 abandoned match still counts as a match, with its kills and its damage, the
98 same as any other — it just never counts as a win, which already required
99 `finished`.
91100 - **Generated, not licensed.** The map (`de_forge`), the textures, the player and
92101 weapon models and every sound are produced in code at start-up. There are no
93102 asset files and nothing from the real game is used — the numbers follow CS's
src/client/main.ts +6 −1
@@ -275,7 +275,7 @@ function boot(): void {
275275 // --- results, sent as the match goes rather than only at the end.
276276 // No name goes up: the server credits the result to whoever the forge says
277277 // posted it, and the name in the menu is only what the match calls you.
278 async function postResult(final: boolean): Promise<void> {
278 async function postResult(final: boolean, left = false): Promise<void> {
279279 if (world === null) return;
280280 const me = world.me;
281281 const body = {
@@ -288,6 +288,7 @@ function boot(): void {
288288 headshots: me.headshots,
289289 damage: Math.round(me.damage),
290290 finished: final,
291 left,
291292 seconds: Math.round(world.time),
292293 };
293294 try {
@@ -353,6 +354,10 @@ function boot(): void {
353354 lock();
354355 });
355356 const toMenu = (): void => {
357 // A match still going when the menu is reached — left from the pause
358 // overlay, not from a summary already posted as finished — gets the one
359 // post that says so, sent before the world it reads is gone.
360 if (world !== null && world.match.phase !== "match_over") void postResult(false, true);
356361 running = false;
357362 world = null;
358363 show(pause, false);
src/db.test.ts +58 −1
@@ -16,7 +16,7 @@ const dir = (): string => mkdtempSync(join(tmpdir(), "db-"));
1616 const result = (over: Partial<Result> = {}): Result => ({
1717 matchId: crypto.randomUUID(),
1818 rounds: 5, scoreMine: 3, scoreTheirs: 2,
19 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
19 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, left: false,
2020 ...over,
2121 });
2222
@@ -76,3 +76,60 @@ test("rows written before identity existed become the guests they always were",
7676 expect(saveMatch(db, writer("guest:deadbeefdeadbeefdeadbeefdeadbeef", "guest dead"), result({ matchId: "m1", kills: 10 }))).toBe(true);
7777 expect(saveMatch(db, writer("user:u_9", "thief"), result({ matchId: "m1", kills: 20 }))).toBe(false);
7878 });
79
80 test("a row written under the old column name `left` keeps reading as abandoned", () => {
81 const where = dir();
82 const old = new Database(join(where, "app.db"));
83 old.exec(`CREATE TABLE match (
84 id TEXT PRIMARY KEY, identity TEXT NOT NULL DEFAULT '', session TEXT NOT NULL, name TEXT NOT NULL,
85 kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL,
86 damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL,
87 score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, left INTEGER NOT NULL DEFAULT 0,
88 seconds INTEGER NOT NULL,
89 created_at TEXT NOT NULL DEFAULT (datetime('now')),
90 updated_at TEXT NOT NULL DEFAULT (datetime('now')))`);
91 old.query(
92 `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, left, seconds)
93 VALUES ('m1', 'user:u_1', 'deadbeefdeadbeefdeadbeefdeadbeef', 'Vesper', 9, 4, 3, 1200, 5, 3, 2, 0, 1, 420)`,
94 ).run();
95 old.close();
96
97 const db = openDatabase(where);
98 expect(recentMatches(db).find((m) => m.id === "m1")?.state).toBe("abandoned");
99 });
100
101 test("a match round-trips its state: finished, left, or still live", () => {
102 const db = openDatabase(dir());
103 const finished = result({ finished: true, left: false });
104 expect(saveMatch(db, writer("user:u_1", "ada"), finished)).toBe(true);
105 expect(recentMatches(db).find((m) => m.id === finished.matchId)?.state).toBe("finished");
106
107 const left = result({ finished: false, left: true });
108 expect(saveMatch(db, writer("user:u_1", "ada"), left)).toBe(true);
109 expect(recentMatches(db).find((m) => m.id === left.matchId)?.state).toBe("abandoned");
110
111 const live = result({ finished: false, left: false });
112 expect(saveMatch(db, writer("user:u_1", "ada"), live)).toBe(true);
113 expect(recentMatches(db).find((m) => m.id === live.matchId)?.state).toBe("live");
114 });
115
116 test("a late periodic post cannot undo a leave that already landed", () => {
117 const db = openDatabase(dir());
118 const w = writer("user:u_1", "ada");
119 const match = result({ finished: false, left: true });
120 expect(saveMatch(db, w, match)).toBe(true);
121 expect(recentMatches(db).find((m) => m.id === match.matchId)?.state).toBe("abandoned");
122 // A periodic post already in flight before the leave, arriving after it —
123 // same match, same round count, but reporting left: false because that was
124 // still true when it was sent.
125 expect(saveMatch(db, w, { ...match, left: false })).toBe(true);
126 expect(recentMatches(db).find((m) => m.id === match.matchId)?.state).toBe("abandoned");
127 });
128
129 test("a row nobody ever closed reads as abandoned once it goes stale", () => {
130 const db = openDatabase(dir());
131 const stale = result({ finished: false, left: false });
132 expect(saveMatch(db, writer("user:u_1", "ada"), stale)).toBe(true);
133 db.query("UPDATE match SET updated_at = datetime('now', '-20 minutes') WHERE id = ?1").run(stale.matchId);
134 expect(recentMatches(db).find((m) => m.id === stale.matchId)?.state).toBe("abandoned");
135 });
src/db.ts +45 −6
@@ -17,6 +17,8 @@ import type { Result } from "./result.ts";
1717 /** Who wrote a row: the credited player, plus the session that sent it. */
1818 export type Writer = Player & { session: string };
1919
20 export type MatchState = "finished" | "abandoned" | "live";
21
2022 export type MatchRow = {
2123 id: string;
2224 identity: string;
@@ -30,8 +32,16 @@ export type MatchRow = {
3032 score_mine: number;
3133 score_theirs: number;
3234 finished: number;
35 walked_out: number;
3336 seconds: number;
3437 updated_at: string;
38 /**
39 * The one place the three renderers (the page, /api/leaderboard, the
40 * terminal) get the word for a row: finished, walked out of from the pause
41 * menu, or — nobody ever closed it — not updated in a while, which is what
42 * keeps a crashed browser or a closed tab from sitting on the board forever.
43 */
44 state: MatchState;
3545 };
3646
3747 export type Standing = {
@@ -47,6 +57,21 @@ export type Standing = {
4757 rounds: number;
4858 };
4959
60 // No post has come in for this long: whatever was playing it is gone — a
61 // crashed browser, a closed tab, a dead connection — and the row should stop
62 // reading as live. A round can run to MAX_SECONDS_PER_ROUND between posts, so
63 // this sits well above that with room for a slow network on top.
64 const ABANDON_AFTER_SECONDS = 600;
65
66 // The word for a row, computed once in SQL so the page, the JSON endpoint and
67 // the terminal read the same thing rather than each guessing it themselves.
68 const STATE_SQL = `CASE
69 WHEN finished = 1 THEN 'finished'

Approved. I read every changed line at b31093e, ran the branch's suite in a scratch checkout (340 pass, 0 fail — my clone is untouched), and exercised /api/match against a locally-run copy of the branch.

What I checked, and what holds:

  • Security. No new identity surface: left is one client-reported boolean, validated at the boundary in parseResult like every other field, and the impossible pair is refused outright — I posted {finished:true,left:true} and got 422. Crediting still comes from whoIs() on the forge's headers, saveMatch still refuses a match id owned by another identity, the body is still capped by readBody's counted bytes, the rate limiter still keys on identity/session/socket address, and same-origin still gates the POST. STATE_SQL interpolates one module constant, never user text, and state is a closed enum so the new page and terminal output needs no escaping it doesn't get.
  • One rule, one place. finished/left/staleness collapse into a single state column computed in SQL; grep confirms the page, /api/leaderboard and src/tty.ts all read m.state and nothing renders raw finished any more. The public JSON drops left as redundant. Standings are untouched, so an abandoned match keeps its kills and its count and still earns no win — I saw exactly that in the live JSON.
  • Migration. ALTER TABLE ... ADD COLUMN left INTEGER NOT NULL DEFAULT 0 is guarded on PRAGMA table_info, and the existing pre-identity-schema test drives a database without the column through openDatabase, saveMatch and recentMatches, so the upgrade path is genuinely exercised rather than assumed.
  • Tests. Each new behaviour has a test that fails without it: parse accepts/defaults/rejects, the db round-trips all three states, the twenty-minutes-stale row flips to abandoned, statsHtml says "abandoned" and "in progress", standings keep the win at zero, and the terminal prints the same word. draw() gaining a keys argument is a fair way to reach the second tab and keeps the same 700ms total.
  • Both doors and the preview. The terminal says (abandoned)/(live) from the same source the page does; the README paragraph explains the three states and where the rule lives. The preview host is 404 from here — production is too, so that's the deploy surface, not this branch — but the forge's phone shot at this head shows a real row reading · abandoned, and the local run answers / in ~1.4 ms.

Two things I'd keep an eye on rather than block for:

  1. left is a SQLite keyword (LEFT JOIN). The parser's fallback accepts it as an identifier everywhere this code uses it, and the tests prove it, but the first query that ever joins match will need it quoted. abandoned_at/walked_out would have dodged it entirely.
  2. A periodic post already in flight when Leave match is clicked can land after the leave post, and ON CONFLICT sets left = excluded.left, so the row briefly reads live again. Self-healing after ten minutes via the age rule, so the ghost the issue was about still cannot persist — the same reason a long pause showing "abandoned" until the next post is harmless.

Small, on the direction, and it fixes exactly what people see first.

70 WHEN walked_out = 1 THEN 'abandoned'
71 WHEN (julianday('now') - julianday(updated_at)) * 86400.0 > ${ABANDON_AFTER_SECONDS} THEN 'abandoned'
72 ELSE 'live'
73 END AS state`;
74
5075 export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Database {
5176 mkdirSync(dir, { recursive: true });
5277 const db = new Database(join(dir, "app.db"));
@@ -76,6 +101,15 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data
76101 db.exec("ALTER TABLE match ADD COLUMN identity TEXT NOT NULL DEFAULT ''");
77102 db.exec("UPDATE match SET identity = 'guest:' || session, name = 'guest ' || substr(session, 1, 4)");
78103 }
104 // A database written before this column existed holds no rows that were
105 // ever walked out of on purpose — they read by the age rule instead, same
106 // as they always did. Named to dodge `left`, which SQLite reads as a
107 // keyword the moment `match` is ever the left side of a JOIN; an earlier
108 // preview of this column under that name is carried over rather than lost.
109 if (!columns.includes("walked_out")) {
110 db.exec("ALTER TABLE match ADD COLUMN walked_out INTEGER NOT NULL DEFAULT 0");
111 if (columns.includes("left")) db.exec("UPDATE match SET walked_out = left");
112 }
79113 db.exec("CREATE INDEX IF NOT EXISTS match_by_identity ON match (identity)");
80114 return db;
81115 }
@@ -90,17 +124,22 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean
90124 const existing = db.query<{ identity: string }, [string]>("SELECT identity FROM match WHERE id = ?1").get(result.matchId);
91125 if (existing !== null && existing.identity !== writer.identity) return false;
92126 db.query(
93 `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
94 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
127 `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds)
128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
95129 ON CONFLICT(id) DO UPDATE SET
96130 name = excluded.name, kills = excluded.kills, deaths = excluded.deaths,
97131 headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds,
98132 score_mine = excluded.score_mine, score_theirs = excluded.score_theirs,
99 finished = excluded.finished, seconds = excluded.seconds,
100 updated_at = datetime('now')`,
133 -- Posts race: a periodic post already in flight when the match ends
134 -- or is walked out of can land after the post that says so. Once
135 -- either flag is set it stays set, so the late arrival can't put a
136 -- finished or abandoned row back to reading as live.
137 finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out),
138 seconds = excluded.seconds, updated_at = datetime('now')`,
101139 ).run(
102140 result.matchId, writer.identity, writer.session, writer.name, result.kills, result.deaths, result.headshots,
103 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds,
141 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0,
142 result.left ? 1 : 0, result.seconds,
104143 );
105144 return true;
106145 }
@@ -108,7 +147,7 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean
108147 export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
109148 db.query<MatchRow, [number]>(
110149 `SELECT id, identity, session, name, kills, deaths, headshots, damage, rounds,
111 score_mine, score_theirs, finished, seconds, updated_at
150 score_mine, score_theirs, finished, walked_out, seconds, updated_at, ${STATE_SQL}
112151 FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`,
113152 ).all(limit);
114153
src/pages/game.test.ts +32 −2
@@ -2,9 +2,10 @@ import { expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { openDatabase } from "../db.ts";
5 import { openDatabase, saveMatch, standings } from "../db.ts";
66 import { credit } from "../player.ts";
7 import { gamePage } from "./game.ts";
7 import type { Result } from "../result.ts";
8 import { gamePage, statsHtml } from "./game.ts";
89
910 const db = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
1011 // The page is always rendered for somebody: here, a guest, the way a visitor
@@ -51,6 +52,35 @@ test("a fullscreen control sits in the menu and in the pause overlay", () => {
5152 expect(pause).toContain(`aria-pressed="false"`);
5253 });
5354
55 const matchResult = (over: Partial<Result> = {}): Result => ({
56 matchId: crypto.randomUUID(),
57 rounds: 5, scoreMine: 5, scoreTheirs: 1,
58 kills: 12, deaths: 3, headshots: 5, damage: 1500, seconds: 500, finished: true, left: false,
59 ...over,
60 });
61
62 test("the leaderboard says abandoned for a left match and in progress for a live one", () => {
63 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
64 const session = "b".repeat(32);
65 const writer = { identity: "user:u_1", name: "nova", guest: false, session };
66 saveMatch(stats, writer, matchResult({ finished: false, left: true }));
67 saveMatch(stats, writer, matchResult({ finished: false, left: false }));
68 const html = statsHtml(stats, player);
69 expect(html).toContain("abandoned");
70 expect(html).toContain("in progress");
71 });
72
73 test("an abandoned match still counts as a match and its kills, but never a win", () => {
74 const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-")));
75 const session = "c".repeat(32);
76 const writer = { identity: "user:u_2", name: "juno", guest: false, session };
77 saveMatch(stats, writer, matchResult({ scoreMine: 5, scoreTheirs: 1, finished: false, left: true, kills: 8 }));
78 const [row] = standings(stats);
79 expect(row?.matches).toBe(1);
80 expect(row?.kills).toBe(8);
81 expect(row?.wins).toBe(0);
82 });
83
5484 test("the pause overlay carries the line a lost view raises", () => {
5585 const pause = element(gamePage(db, options), "pause");
5686 // Hidden until the renderer says the browser took the context away, and
src/pages/game.ts +5 −3
@@ -30,10 +30,12 @@ export function statsHtml(db: Database, player: Player): string {
3030 `<td>${ratio(s.kills, s.deaths)}</td><td>${headshotShare(s.headshots, s.kills)}</td><td>${s.wins}</td></tr>`,
3131 )
3232 .join("");
33 const status = (m: MatchRow): string =>
34 m.state === "finished" ? "" : m.state === "abandoned" ? " · abandoned" : " · in progress";
3335 const feed = recent
3436 .map(
3537 (m) => `<li><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` +
36 `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` +
38 `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span> ` +
3739 `<time>${escape(m.updated_at)}</time></li>`,
3840 )
3941 .join("");
@@ -51,7 +53,7 @@ ${table.length === 0
5153
5254 export type LeaderboardJson = {
5355 standings: (Omit<Standing, "identity"> & { guest: boolean })[];
54 recent: (Omit<MatchRow, "session" | "identity"> & { guest: boolean })[];
56 recent: (Omit<MatchRow, "session" | "identity" | "walked_out"> & { guest: boolean })[];
5557 };
5658
5759 /**
@@ -64,7 +66,7 @@ export type LeaderboardJson = {
6466 export function leaderboardJson(db: Database): LeaderboardJson {
6567 return {
6668 standings: standings(db, 10).map(({ identity, ...standing }) => ({ ...standing, guest: isGuest(identity) })),
67 recent: recentMatches(db, 8).map(({ session: _session, identity, ...match }) => ({
69 recent: recentMatches(db, 8).map(({ session: _session, identity, walked_out: _walkedOut, ...match }) => ({
6870 ...match,
6971 guest: isGuest(identity),
7072 })),
src/result.test.ts +16
@@ -41,3 +41,19 @@ test("nonsense is refused rather than clamped", () => {
4141 expect(parseResult(result({ damage: Number.POSITIVE_INFINITY }))).toBe(null);
4242 expect(parseResult(undefined)).toBe(null);
4343 });
44
45 test("a match walked out of parses as left, not finished", () => {
46 const parsed = parseResult(result({ finished: false, left: true }));
47 expect(parsed?.left).toBe(true);
48 expect(parsed?.finished).toBe(false);
49 expect(won(parsed!)).toBe(false);
50 });
51
52 test("a client that says nothing about left gets the old behaviour", () => {
53 const parsed = parseResult(result({ finished: false }));
54 expect(parsed?.left).toBe(false);
55 });
56
57 test("a match cannot be both finished and left", () => {
58 expect(parseResult(result({ finished: true, left: true }))).toBe(null);
59 });
src/result.ts +12 −1
@@ -16,6 +16,13 @@ export type Result = {
1616 damage: number;
1717 seconds: number;
1818 finished: boolean;
19 /**
20 * The match ended because the player walked out of it, not because it
21 * played to a result. Set by the one post `toMenu()` sends on the way out
22 * — every other post omits it, which is also what an older client sends,
23 * so it defaults to false and a match reads as it always has.
24 */
25 left: boolean;
1926 };
2027
2128 const MAX_ROUNDS = 30;
@@ -62,10 +69,14 @@ export function parseResult(value: unknown): Result | null {
6269 // minute, and one round does not take an hour.
6370 if (seconds > (rounds + 1) * MAX_SECONDS_PER_ROUND) return null;
6471 if (seconds < rounds * MIN_SECONDS_PER_ROUND) return null;
72 const finished = body.finished === true;
73 const left = body.left === true;
74 // A match cannot both have played to a result and been walked out of.
75 if (finished && left) return null;
6576 return {
6677 matchId,
6778 rounds, scoreMine, scoreTheirs, kills, deaths, headshots, damage, seconds,
68 finished: body.finished === true,
79 finished, left,
6980 };
7081 }
7182
src/tty.test.ts +24 −4
@@ -200,16 +200,18 @@ const result = (over: Partial<Result> = {}): Result => ({
200200 ...over,
201201 });
202202
203 /** One frame of the terminal, with its escape sequences taken out. */
204 async function draw(dir: string, env: Record<string, string> = {}): Promise<string> {
203 /** One frame of the terminal, with its escape sequences taken out. Any `keys` are typed after the first draw, to walk a tab or move the cursor. */
204 async function draw(dir: string, env: Record<string, string> = {}, keys = ""): Promise<string> {
205205 const child = Bun.spawn(["bun", "run", join(import.meta.dir, "tty.ts")], {
206206 env: { ...process.env, FORGE_APP_DATA: dir, ...env },
207 stdin: "ignore",
207 stdin: "pipe",
208208 stdout: "pipe",
209209 stderr: "pipe",
210210 });
211211 // It draws once at start-up and then waits on keys; one frame is all we need.
212 await Bun.sleep(700);
212 await Bun.sleep(400);
213 for (const key of keys) child.stdin.write(key);
214 await Bun.sleep(300);
213215 child.kill();
214216 const text = await new Response(child.stdout).text();
215217 // eslint-disable-next-line no-control-regex
@@ -235,6 +237,24 @@ test("the terminal says who a row belongs to, in a column a name cannot fake", a
235237 expect(frame).toMatch(/guest aaaa\s+guest/);
236238 }, 10_000);
237239
240 test("the terminal's match list says finished, abandoned or live, the same word the page uses", async () => {
241 const dir = mkdtempSync(join(tmpdir(), "tty-"));
242 const db = openDatabase(dir);
243 const session = "a".repeat(32);
244 saveMatch(db, { identity: "user:u_1", name: "finn", guest: false, session }, result({ finished: true }));
245 saveMatch(db, { identity: "user:u_1", name: "ana", guest: false, session }, result({ finished: false, left: true }));
246 saveMatch(db, { identity: "user:u_1", name: "lee", guest: false, session }, result({ finished: false, left: false }));
247 db.close();
248
249 // Tab once, from standings to matches.
250 const frame = await draw(dir, { FORGE_APP_IDENTITY_KEY: "a-key" }, "\t");
251 const line = (name: string): string => frame.split(/\r?\n/).find((l) => l.includes(name)) ?? "";
252 expect(line("finn")).not.toContain("(live)");
253 expect(line("finn")).not.toContain("(abandoned)");
254 expect(line("ana")).toContain("(abandoned)");
255 expect(line("lee")).toContain("(live)");
256 }, 10_000);
257
238258 test("the terminal says when identity is off and everybody is a guest", async () => {
239259 const dir = mkdtempSync(join(tmpdir(), "tty-"));
240260 openDatabase(dir).close();
src/tty.ts +2 −2
@@ -113,9 +113,9 @@ export function render(state: State, data: Data): string[] {
113113 lines.push(`${esc}2m ${pad("player", 22)}${pad("who", 8)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`);
114114 if (data.matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
115115 data.matches.forEach((m, i) => {
116 const status = m.state === "finished" ? "" : m.state === "abandoned" ? " (abandoned)" : " (live)";
116117 const line = `${pad(safe(m.name), 22)}${pad(who(m.identity), 8)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
117 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}` +
118 `${m.finished === 1 ? "" : " (live)"}`;
118 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}${status}`;
119119 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
120120 });
121121 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs