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

Merged opened this

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

Merged

torque/cs-engine:torque/162-a-match-nobody-finished-stops-saying-in → main 1 commit fork

  • Merged. torque/162-a-match-nobody-finished-stops-saying-in was merged into main as 90e2b87.

Review

Sign in to review this change.
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**.
267267 the only sign-in this app has: no password, no cookie of its own that names a
268268 person, nothing a page can set.
269269
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
270293 ## Playing
271294
272295 <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`
10851108 on a real pty over the same database the web page uses. Three tabs, and
10861109 <kbd>Tab</kbd> cycles them: **standings** (the leaderboard, one row a name),
10871110 **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
10891112 generated one — to paste back into the menu) and
10901113 **policy** (the card the bots were trained with — trained-at, simulation ticks
10911114 and throughput, PPO updates, duels and both win rates, and the command that
@@ -1097,7 +1120,8 @@ off when it is unset.
10971120
10981121 `src/tty.ts` is the wiring only — the database, raw stdin, stdout, resize and
10991122 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
11011125 `src/terminal/keys.ts` (`press(view, key, data)` returns the next view and one
11021126 command: nothing, quit, or delete this match). Both are plain data in and out,
11031127 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";
99 import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 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";
1315 import type { Result } from "./result.ts";
1416
1517 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_
123125 expect(recentMatches(db).find((m) => m.id === fresh)?.map).toBe("gen_ir1");
124126 });
125127
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
126171 test("opening a database twice migrates once and breaks nothing", () => {
127172 const home = dir();
128173 openDatabase(home).close();
src/db.ts +40
@@ -102,6 +102,46 @@ export type Player = {
102102 export const displayName = (row: { name: string; user_id: string }): string =>
103103 row.user_id === "" ? `${row.name} (guest)` : row.name;
104104
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
105145 /**
106146 * Write a result. A match belongs to whoever started it: a signed-in player's
107147 * 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";
1515 import { mkdtempSync } from "node:fs";
1616 import { tmpdir } from "node:os";
1717 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";
1919 import type { Result } from "../result.ts";
2020 import { CARD, type Card } from "../client/weights.ts";
2121 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";
2323 import { escape } from "./shell.ts";
2424
2525 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
9595 expect(html).toContain(`<a class="map" href="/?map=de_forge">`);
9696 });
9797
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
98134 test("the screen a match ends on is in the page, hidden, with the menu's own sentence on it", () => {
99135 const html = gamePage(db(), options);
100136 // An overlay like the menu and the pause, rendered hidden: the first paint is
src/pages/game.ts +20 −4
@@ -10,7 +10,7 @@
1010 */
1111
1212 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";
1414 import { NOTHING_POSTED } from "../client/pending.ts";
1515 import { CARD, type Card } from "../client/weights.ts";
1616 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
@@ -20,8 +20,24 @@ import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.t
2020
2121 export { escape };
2222
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 {
2541 const table = standings(db, 10);
2642 const recent = recentMatches(db, 8);
2743 const rows = table
@@ -38,7 +54,7 @@ export function statsHtml(db: Database): string {
3854 // is built rather than copied.
3955 (m) => `<li><b>${escape(displayName(m))}</b> ${m.score_mine}–${m.score_theirs} ` +
4056 `<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> ` +
4258 `<time>${escape(m.updated_at)}</time></li>`,
4359 )
4460 .join("");
src/server.test.ts +26
@@ -5,6 +5,7 @@ import { join } from "node:path";
55 import { bundle } from "../scripts/build.ts";
66 import { buildBundles, bundlesDir } from "./bundles.ts";
77 import { bytes, kB } from "./bytes.ts";
8 import { IDLE_SECONDS, openDatabase } from "./db.ts";
89 import { measureExample } from "./cost.ts";
910 import { GAMES } from "./games.ts";
1011 import { matchOf, playKey } from "./matchid.ts";
@@ -642,6 +643,31 @@ test("a result is stored and comes back as the leaderboard", async () => {
642643 expect(await (await fetch(base + "/")).text()).toContain("Vesper");
643644 });
644645
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
645671 test("a name is escaped, not run", async () => {
646672 await post(result({ name: "<img src=x onerror=alert(1)>" }));
647673 const page = await (await fetch(base + "/")).text();
src/terminal/keys.test.ts +2
@@ -39,6 +39,8 @@ const data: Data = {
3939 matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")],
4040 total: 4,
4141 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"),
4244 };
4345
4446 const view = (over: Partial<View> = {}): View =>
src/terminal/screen.test.ts +23
@@ -8,6 +8,7 @@
88 import { expect, test } from "bun:test";
99 import type { Card } from "../client/weights.ts";
1010 import type { MatchRow, Standing } from "../db.ts";
11 import { IDLE_SECONDS } from "../db.ts";
1112 import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts";
1213
1314 const CARD: Card = {
@@ -51,11 +52,18 @@ const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow
5152 ...over,
5253 });
5354
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
5461 const data = (over: Partial<Data> = {}): Data => ({
5562 standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })],
5663 matches: [match("m1", "ace"), match("m2", "bo", { finished: 0 })],
5764 total: 4,
5865 card: CARD,
66 now: NOW,
5967 ...over,
6068 });
6169
@@ -107,6 +115,21 @@ test("the match list keeps its columns and marks the ones still being played", (
107115 expect(lines[6]).toBe(" bo 3-2 7/4 950 de_forge 2026-09-16 10:00:00 (live)");
108116 });
109117
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
110133 test("the map a match was played on is a column, generated names and all", () => {
111134 // What a person can read on the page they can read here: the same fact, in
112135 // 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 @@
1414 */
1515
1616 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";
1919 import { matchOf } from "../matchid.ts";
2020
2121 export type Tab = "standings" | "matches" | "policy";
@@ -41,8 +41,18 @@ export type Data = {
4141 /** Every match ever recorded, including the empty ones the lists skip. */
4242 total: number;
4343 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;
4451 };
4552
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
4656 const esc = "\x1b[";
4757
4858 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[] {
113123 return lines;
114124 }
115125
116 function matchLines(matches: MatchRow[], cursor: number): string[] {
126 function matchLines(matches: MatchRow[], cursor: number, now: number): string[] {
117127 const lines = [
118128 // The map column is 12 wide: `gen_1z141z3` is the longest name there is
119129 // (`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[] {
124134 matches.forEach((m, i) => {
125135 const line = `${pad(printable(displayName(m)), 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
126136 `${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)]}`;
128138 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
129139 });
130140 // 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[] {
160170 lines.push("");
161171 if (view.tab === "policy") lines.push(...policyLines(data.card));
162172 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));
164174 lines.push("");
165175 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
166176 lines.push(view.ssh === undefined
src/tty.ts +3
@@ -81,6 +81,9 @@ export function run(io: Io): void {
8181 matches: recentMatches(io.db, 20),
8282 total: matchCount(io.db),
8383 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(),
8487 });
8588
8689 const draw = (): void => { io.output.write(CLEAR + screen(view, read()).join("\r\n")); };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs