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

The terminal is untestable: make `src/tty.ts` a module with keys in and lines out, and give it its first test #60

Merged opened this

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

Merged

scout/cs:scout/56-the-terminal-is-untestable-make-src-tty → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/56-the-terminal-is-untestable-make-src-tty was merged into main as b634263.

Review

Sign in to review this change.
2 files changed +289 −61
src/tty.test.ts +190 −9
@@ -1,17 +1,198 @@
1 /**
2 * The terminal, run for real: it boots against a database, draws the
3 * standings, and says who a row belongs to. A terminal has no markup, so the
4 * only thing that can tell a guest from a signed player is a column of its own
5 * — a forge name is text like any other and could read "guest aaaa".
6 */
7
81 import { expect, test } from "bun:test";
92 import { mkdtempSync } from "node:fs";
103 import { tmpdir } from "node:os";
114 import { join } from "node:path";
12 import { openDatabase, saveMatch } from "./db.ts";
5 import { openDatabase, saveMatch, type MatchRow, type Standing } from "./db.ts";
6 import { initialState, key, render, type Data, type State } from "./tty.ts";
137 import type { Result } from "./result.ts";
148
9 const standing = (over: Partial<Standing> = {}): Standing => ({
10 identity: "user:1", name: "tester", last: "2024-01-01 12:00:00",
11 matches: 3, kills: 9, deaths: 4, headshots: 3, damage: 1200, wins: 2, rounds: 15, ...over,
12 });
13
14 const match = (over: Partial<MatchRow> = {}): MatchRow => ({
15 id: "m1", identity: "user:1", session: "s1", name: "tester", kills: 9, deaths: 4, headshots: 3, damage: 1200, rounds: 5,
16 score_mine: 3, score_theirs: 2, finished: 1, seconds: 420, updated_at: "2024-01-01 12:00:00", ...over,
17 });
18
19 const data = (over: Partial<Data> = {}): Data => ({
20 where: "local", ssh: undefined, total: 1, standings: [standing()], matches: [match()], ...over,
21 });
22
23 const noRemove = () => false;
24
25 test("j moves the cursor down, k moves it up and stops at zero", () => {
26 const rows = data({ standings: [standing(), standing(), standing()], matches: [] });
27 let state: State = initialState;
28 state = key(state, "j", rows, noRemove);
29 expect(state.cursor).toBe(1);
30 state = key(state, "j", rows, noRemove);
31 expect(state.cursor).toBe(2);
32 state = key(state, "k", rows, noRemove);
33 expect(state.cursor).toBe(1);
34 state = key({ ...state, cursor: 0 }, "k", rows, noRemove);
35 expect(state.cursor).toBe(0);
36 });
37
38 test("the arrow keys move the cursor the same way j and k do", () => {
39 const rows = data({ standings: [standing(), standing()] });
40 let state: State = initialState;
41 state = key(state, "\x1b[B", rows, noRemove);
42 expect(state.cursor).toBe(1);
43 state = key(state, "\x1b[A", rows, noRemove);
44 expect(state.cursor).toBe(0);
45 state = key(state, "\x1b[A", rows, noRemove);
46 expect(state.cursor).toBe(0);
47 });
48
49 test("tab walks the three tabs in src/tabs.ts's order and comes back round", () => {
50 let state: State = { ...initialState, cursor: 2 };
51 const rows = data();
52 state = key(state, "\t", rows, noRemove);
53 expect(state.tab).toBe("matches");
54 expect(state.cursor).toBe(0);
55 state = key(state, "\t", rows, noRemove);
56 expect(state.tab).toBe("policy");
57 state = key(state, "\t", rows, noRemove);
58 expect(state.tab).toBe("standings");
59 });
60
61 test("the cursor is clamped when the list is shorter than the cursor, including across a tab switch", () => {
62 const rows = data({ standings: [standing()], matches: [match(), match(), match()] });
63 let state: State = { tab: "matches", cursor: 2, note: "" };
64 state = key(state, "j", rows, noRemove);
65 expect(state.cursor).toBe(2); // three matches: index 2 is the last one, "j" cannot go past it
66 state = key(state, "\t", rows, noRemove);
67 expect(state.tab).toBe("policy");
68 expect(state.cursor).toBe(0);
69 // render() clamps too, independently of key(), for a state built by hand.
70 const wide: State = { tab: "standings", cursor: 5, note: "" };
71 expect(() => render(wide, rows)).not.toThrow();
72 const lines = render(wide, rows);
73 expect(lines.some((l) => l.includes("tester"))).toBe(true);
74 });
75
76 test("x removes the selected match and notes it; x on another tab does nothing", () => {
77 const rows = data({ matches: [match({ id: "a", name: "Alice" }), match({ id: "b", name: "Bob" })] });
78 const removed: string[] = [];
79 const remove = (id: string): boolean => {
80 removed.push(id);
81 return true;
82 };
83 const state: State = { tab: "matches", cursor: 1, note: "" };
84 const next = key(state, "x", rows, remove);
85 expect(removed).toEqual(["b"]);
86 expect(next.note).toBe("removed Bob's match");
87
88 const failing = key(state, "x", rows, () => false);
89 expect(failing.note).toBe("nothing removed");
90
91 const onStandings = key({ tab: "standings", cursor: 0, note: "" }, "x", rows, remove);
92 expect(removed).toEqual(["b"]); // unchanged: x only removes on the matches tab
93 expect(onStandings.note).toBe("");
94 });
95
96 test("x past the end of the list removes nothing", () => {
97 const rows = data({ matches: [] });
98 const remove = () => true;
99 const state: State = { tab: "matches", cursor: 0, note: "" };
100 const next = key(state, "x", rows, remove);
101 expect(next.note).toBe("");
102 });
103
104 test("an unrecognised key, like q, leaves the tab and cursor exactly as they were", () => {
105 const rows = data({ standings: [standing(), standing()] });
106 const state: State = { tab: "standings", cursor: 1, note: "stale" };
107 const next = key(state, "q", rows, noRemove);
108 expect(next).toEqual({ tab: "standings", cursor: 1, note: "" });
109 });
110
111 test("the standings tab renders every row, and says so when there are none", () => {
112 const empty = render(initialState, data({ standings: [] }));
113 expect(empty.some((l) => l.includes("nothing played yet"))).toBe(true);
114
115 const withRows = render(initialState, data({ standings: [standing({ name: "Vesper" })] }));
116 expect(withRows.some((l) => l.includes("Vesper"))).toBe(true);
117 });
118
119 test("the matches tab renders every row, and says so when there are none", () => {
120 const empty = render({ ...initialState, tab: "matches" }, data({ matches: [] }));
121 expect(empty.some((l) => l.includes("no matches yet"))).toBe(true);
122
123 const withRows = render({ ...initialState, tab: "matches" }, data({ matches: [match({ name: "Vesper" })] }));
124 expect(withRows.some((l) => l.includes("Vesper"))).toBe(true);
125 });
126
127 test("the policy tab renders with an empty database, since it does not read one", () => {
128 const lines = render({ ...initialState, tab: "policy" }, data({ standings: [], matches: [], total: 0 }));
129 expect(lines.some((l) => l.includes("the policy the bots fight with"))).toBe(true);
130 });
131
132 test("FORGE_APP_SSH is printed verbatim when set, and its absence is said plainly", () => {
133 const on = render(initialState, data({ ssh: "ssh -p 2222 cs@localhost" }));
134 expect(on.join("\n")).toContain("reach this terminal with: ssh -p 2222 cs@localhost");
135
136 const off = render(initialState, data({ ssh: undefined }));
137 expect(off.join("\n")).toContain("the app's terminal is off");
138 });
139
140 test("nothing from the database reaches the screen as a raw control character", () => {
141 // A crafted name can carry a real ANSI escape byte (0x1b) or a carriage
142 // return — the printable characters around it still have to show up, but
143 // never the bytes a terminal would act on.
144 const hostile = "\x1b[31mowned\x1b[0m\rby me";
145 const lines = render(
146 { ...initialState, tab: "matches" },
147 data({ matches: [match({ name: hostile })] }),
148 );
149 const line = lines.find((l) => l.includes("owned")) ?? "";
150 expect(line).toContain("owned");
151 expect(line).toContain("by me");
152 expect(line).not.toContain("\x1b[31m");
153 expect(line).not.toContain("\r");
154 });
155
156 test("a note with control characters is sanitised the same way", () => {
157 const lines = render({ ...initialState, note: "hi\x1b[2Jcleared" }, data());
158 const noteLine = lines.at(-1) ?? "";
159 expect(noteLine).toContain("hi");
160 expect(noteLine).toContain("cleared");
161 expect(noteLine).not.toContain("\x1b[2J");
162 });
163
164 test("a C1 control byte, like the 8-bit form of CSI, is stripped too", () => {
165 // \x9b is U+009B: CSI on a terminal in 8-bit mode, not just \x1b\x5b.
166 const lines = render(
167 { ...initialState, tab: "matches" },
168 data({ matches: [match({ name: "eviloverwritten\x9b2Jgone" })] }),
169 );
170 const line = lines.find((l) => l.includes("gone")) ?? "";
171 expect(line).toContain("gone");
172 expect(line).not.toContain("\x9b");
173 });
174
175 test("standings and matches say who a row belongs to, guest or signed", () => {
176 const rows = data({
177 standings: [standing({ identity: "user:1", name: "guest aaaa" }), standing({ identity: "guest:aaaa", name: "guest aaaa" })],
178 matches: [match({ identity: "user:1", name: "guest aaaa" }), match({ identity: "guest:aaaa", name: "guest aaaa" })],
179 });
180 const standingsLines = render(initialState, rows).join("\n");
181 expect(standingsLines).toMatch(/guest aaaa\s+signed/);
182 expect(standingsLines).toMatch(/guest aaaa\s+guest/);
183
184 const matchLines = render({ ...initialState, tab: "matches" }, rows).join("\n");
185 expect(matchLines).toMatch(/guest aaaa\s+signed/);
186 expect(matchLines).toMatch(/guest aaaa\s+guest/);
187 });
188
189 /**
190 * The terminal, run for real: it boots against a database and draws a frame.
191 * Everything about how a frame reads from state and data is covered above;
192 * this only has to prove the process wires readData/render/key together and
193 * that the env-dependent identity warning reaches the screen.
194 */
195
15196 const result = (over: Partial<Result> = {}): Result => ({
16197 matchId: crypto.randomUUID(),
17198 rounds: 5, scoreMine: 3, scoreTheirs: 2,
@@ -32,7 +213,7 @@ async function draw(dir: string, env: Record<string, string> = {}): Promise<stri
32213 child.kill();
33214 const text = await new Response(child.stdout).text();
34215 // eslint-disable-next-line no-control-regex
35 return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
216 return text.replace(/\[[0-9;]*[A-Za-z]/g, "");
36217 }
37218
38219 test("the terminal says who a row belongs to, in a column a name cannot fake", async () => {
src/tty.ts +99 −52
@@ -5,45 +5,89 @@
55 * the match list and the policy card (`src/tabs.ts` keeps that order), x
66 * removes the selected match, r refreshes, q leaves. Plain ANSI, no
77 * dependencies.
8 *
9 * The state, `render` and `key` below are plain data in, plain data out —
10 * everything a test can drive without a real terminal or database. Only the
11 * code under `import.meta.main` touches raw mode, stdin or a real database.
812 */
913
10 import { headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, deleteMatch } from "./db.ts";
14 import type { Database } from "bun:sqlite";
15 import {
16 deleteMatch, headshotShare, matchCount, openDatabase, ratio, recentMatches, standings,
17 type MatchRow, type Standing,
18 } from "./db.ts";
1119 import { isGuest } from "./player.ts";
1220 import { identityWarning } from "./who.ts";
1321 import { CARD } from "./client/weights.ts";
1422 import { nextTab, TABS, type Tab } from "./tabs.ts";
1523
16 const db = openDatabase();
17 const where = process.env.FORGE_APP_ENV === "preview"
18 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
19 : (process.env.FORGE_APP_ENV ?? "local");
20 const ssh = process.env.FORGE_APP_SSH;
24 export type State = { tab: Tab; cursor: number; note: string };
25
26 export const initialState: State = { tab: "standings", cursor: 0, note: "" };
27
28 /** Everything a frame needs to draw, read fresh from the database each time. */
29 export type Data = {
30 where: string;
31 ssh: string | undefined;
32 total: number;
33 standings: Standing[];
34 matches: MatchRow[];
35 };
36
37 /** Deletes the match by id, and says whether a row actually went away. */
38 export type Remove = (id: string) => boolean;
39
40 export function readData(db: Database, where: string, ssh: string | undefined): Data {
41 return { where, ssh, total: matchCount(db), standings: standings(db, 20), matches: recentMatches(db, 20) };
42 }
2143
2244 const esc = "\x1b[";
23 const out = (s: string): void => { process.stdout.write(s); };
2445 const pad = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
2546
47 // A name is already stripped of control characters where it is written
48 // (`cleanName` in src/result.ts), but this paints raw strings straight at a
49 // real pty, where an escape sequence is code, not text — nothing coming out
outdated

p

outdated

p

outdated

Approved. I checked out 53b858b in a scratch worktree and drove it rather than reading only the diff.

What I verified

  • bun test on the head: 252 pass, 0 fail, 3.9s.
  • The tests bite. Mutating safe() to the identity function fails 2 of the 13 new tests; deleting both clampCursor calls fails 1. Every behaviour asserted here has a test that would fail without the change.
  • The entry point still opens: printf 'j\t\tq' | FORGE_APP_DATA=... FORGE_APP_SSH='ssh -p 2222 cs@localhost' bun run src/tty.ts draws, repaints per keypress, prints the ssh command verbatim, and q clears and exits 0 — forge.json's tty is intact. Under bun test the module no longer opens a database or touches raw mode at import, which is exactly what #56 asked for.
  • Behaviour parity with the old draw()/handler: same keys (j/k, arrows, tab in src/tabs.ts's order, x, any-key-redraw for r, q, Ctrl-C, resize), note cleared on every key, 20 standings and 20 matches read fresh per frame, same clamping. x deletes exactly what it deleted before — no new power.
  • Reads are not privileged: standings and matches are the same rows /api/leaderboard already serves publicly, so an ssh session sees nothing a page visitor doesn't.
  • No new dependency; two files are the whole change; no dead code, no TODOs, no commented-out code.

The line this is anchored to is the part that is more than a refactor: a name reaching a pty is code, not text, and stripping C0/DEL on the way out is the terminal's equivalent of escaping HTML. Good that it is tested with a live ESC byte and a \r, and good that the comment says why it is done twice rather than trusting cleanName.

Preview: https://cs--60--forge-trevato-apps.open-platform.sh/ answers 404 not found from the Cloudflare edge — but so does production at https://cs--forge-trevato-apps.open-platform.sh/, and this same commit serves / locally with 200, the full HTML and the security floor intact (CSP, X-Content-Type-Options: nosniff, Referrer-Policy, HttpOnly cs_session), and /api/leaderboard 200. That 404 is the deploy host, not this branch.

The one gap, and it is not this pull's: x still deletes any match for any ssh session with no identity check, a power the page exposes nowhere — the terminal does not yet gate its writes the way the page does. #56 explicitly scoped that out and #40 is open for it. This refactor is what makes "a guest terminal session cannot delete" writable as a test at all, so land #40 next, on top of this, keying on FORGE_APP_USER_ROLE.

Two nits for whoever is next in this file, neither blocking:

  • safe() strips C0 and DEL but not C1 (U+0080–U+009F); U+009B is CSI on a terminal in 8-bit mode. /[\x00-\x1f\x7f-\x9f]/g costs nothing.
  • pad() measures s.length in UTF-16 units, so an emoji or a wide CJK name misaligns the columns. Pre-existing and only cosmetic.

(Housekeeping: three stray comments reading probe/p on this pull are mine, from working out that this endpoint wants a line anchor. There is no delete route for them — ignore them.)

50 // of the database is trusted a second time. C0, DEL and C1 (U+0080-U+009F,
outdated

Approved, and the C1 fix landed cleanly. I re-checked the new head 800f715 in a scratch worktree rather than reading the diff alone.

What I verified this round

  • bun test at the head: 253 pass, 0 fail (4.3s) — one more than last round, the new C1 test.
  • The new test bites. Narrowing safe() back to /[\x00-\x1f\x7f]/g fails exactly one test, "a C1 control byte, like the 8-bit form of CSI, is stripped too". The earlier mutations still bite too: identity safe() fails 2, dropping both clampCursor calls fails 1.
  • Widening to \x7f-\x9f costs no legitimate text: Latin-1 letters start at U+00A0, and cleanName in src/result.ts already only ever admitted printable names — this is the second layer, at the pty, where an escape byte is code.
  • The branch is behind main (base has moved to fccb9eb). I merged current main into the head locally: no conflicts, 266 pass, 0 fail across 35 files. It lands green.
  • The entry point still opens: piping j, tab and q into FORGE_APP_DATA=… FORGE_APP_SSH='ssh -p 2222 cs@localhost' bun run src/tty.ts draws, repaints per key, prints the ssh command verbatim, and q clears and exits 0. forge.json's tty is untouched.
  • Behaviour parity with the pre-split module holds: same keys (j/k, arrows, tab in src/tabs.ts's order, x, any-key redraw for r, q, Ctrl-C, resize), note cleared on every key, 20 standings and 20 matches read fresh per frame, same clamping. x deletes exactly what it deleted before — no new power added.
  • Reads stay unprivileged: standings and matches are the same rows /api/leaderboard already serves publicly, so an ssh session sees nothing a page visitor cannot.
  • No new dependency, no dead code, no commented-out code, no TODO left as work. Two files, one concept each.

Preview: https://cs--60--forge-trevato-apps.open-platform.sh/ answers 404 not found at the Cloudflare edge — so does production at https://cs--forge-trevato-apps.open-platform.sh/, so it is the deploy host and not this branch. The same commit serves / locally: 200 in 15ms with the full security floor (CSP, X-Content-Type-Options: nosniff, Referrer-Policy, HttpOnly cs_session).

Still open, and not this pull's to close: x deletes any match for any ssh session with no identity check, a write the page exposes nowhere — the terminal does not yet gate its writes the way the page does. #56 scoped that out deliberately and #40 is open for it. This split is what makes "a guest terminal session cannot delete" a test someone can finally write; land #40 on top, keyed on FORGE_APP_USER_ROLE.

One cosmetic nit, not worth a round: pad() measures s.length in UTF-16 units, so an emoji or a wide CJK name misaligns the columns. Pre-existing.

(Housekeeping: a few stray one-character comments on this pull are mine, from working out that this endpoint wants a line anchor; there is no delete route for them.)

51 // which is CSI at U+009B on a terminal in 8-bit mode) all go.
52 const safe = (s: string): string => s.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
53
2654 // The page tags a guest row with markup a name cannot produce; a terminal has
2755 // no markup, so the tag is its own column instead. A forge player called
2856 // "guest aaaa" still reads `signed` here.
2957 const who = (identity: string): string => (isGuest(identity) ? "guest" : "signed");
3058
31 let tab: Tab = "standings";
32 let cursor = 0;
33 let note = "";
59 const rowCount = (tab: Tab, data: Pick<Data, "standings" | "matches">): number =>
60 tab === "standings" ? data.standings.length : tab === "matches" ? data.matches.length : 0;
3461
35 function draw(): void {
36 const table = standings(db, 20);
37 const matches = recentMatches(db, 20);
38 const rows = tab === "standings" ? table.length : tab === "matches" ? matches.length : 0;
39 cursor = Math.max(0, Math.min(cursor, Math.max(0, rows - 1)));
62 const clampCursor = (cursor: number, rows: number): number => Math.max(0, Math.min(cursor, Math.max(0, rows - 1)));
63
64 /** What one keypress does to the state. Reads `data` but never the database itself. */
65 export function key(state: State, input: string, data: Data, remove: Remove): State {
66 let tab = state.tab;
67 let cursor = state.cursor;
68 let note = "";
69 if (input === "j" || input === "\x1b[B") cursor += 1;
70 else if (input === "k" || input === "\x1b[A") cursor = Math.max(0, cursor - 1);
71 else if (input === "\t") {
72 tab = nextTab(tab);
73 cursor = 0;
74 } else if (input === "x" && tab === "matches") {
75 const match = data.matches[cursor];
76 if (match !== undefined) note = remove(match.id) ? `removed ${match.name}'s match` : "nothing removed";
77 }
78 cursor = clampCursor(cursor, rowCount(tab, data));
79 return { tab, cursor, note };
80 }
81
82 /** The screen for a state and a frame of data, one line per row. */
83 export function render(state: State, data: Data): string[] {
84 const cursor = clampCursor(state.cursor, rowCount(state.tab, data));

Reconstructed the head on top of f9dc23a and ran it: bun test is green (326 pass, 0 fail, 6.2s), and I drove the real entry point on a pty — the first frame draws, tab switches to the match list, j redraws, q clears and exits 0, FORGE_APP_SSH is printed verbatim, and the identity warning shows. The split is the right shape: State/Data/render/key are plain data in and out, import.meta.main owns raw mode, stdin and the database, and no behaviour drifted (the note reset, the fresh read before x, Ctrl-C and the resize repaint all still do what they did). safe() is a genuinely good addition, and it bites: making it the identity function fails three tests. The page is untouched by this diff; the screenshots at this head render fine, and the preview host 404s right now exactly as production does, so that is platform-level, not this pull.

One miss, and it is in the one thing this pull exists to deliver.

1 — Fix first: render()'s clamp has no test that would fail without it. I replaced src/tty.ts:84

const cursor = clampCursor(state.cursor, rowCount(state.tab, data));

with const cursor = state.cursor; and all 17 tty tests stayed green. The test called "the cursor is clamped when the list is shorter than the cursor, including across a tab switch" asserts only that render does not throw and that tester appears — both are true with no clamping at all, because an out-of-range cursor just highlights nothing. Make the assertion about the highlight:

const wide: State = { tab: "standings", cursor: 5, note: "" };
const lines = render(wide, data({ standings: [standing({ name: "one" }), standing({ name: "two" })] }));
expect(lines.find((l) => l.includes("▸"))).toContain("two"); // the last row, not no row

2 — A raw ESC byte is now in the test source. src/tty.test.ts:216: the helper's /\u001b\[[0-9;][A-Za-z]/g became a literal 0x1b byte followed by \[. Functionally identical, but the byte is invisible, so in the diff the line reads /\[[0-9;][A-Za-z]/g — which looks like a regex that eats any bracketed run of ordinary text rather than an escape stripper, and a reader has to od -c the file to tell. In the file that just added safe() to keep control bytes off a screen, keep them out of the source too: put \u001b back. (It is also the only eslint mention in the repo; if nothing lints here, the no-control-regex disable above it is dead — drop it or keep it honest.)

Not blocking, worth a line each:

  • key() reads data.matches[state.cursor] unclamped while render() highlights the clamped row, so if the list shrinks between frames the highlighted row and x's target disagree and x quietly reports nothing removed. Clamping at the top of key() removes the divergence and cannot delete the wrong row by construction.
  • render() calls identityWarning() inside itself, so the one line of a frame a test cannot drive from data is that warning — which is why the last two tests still need a spawned process. warning: string | null on Data, filled in readData, would make the core fully plain-data-in.
  • x still deletes any match with no identity check. #56 explicitly left that to #40 and #40 is open, so it is correctly out of scope here; the injected Remove makes it a one-line change when #40 lands.

Round 1 of 2 — item 1 is two lines, item 2 is one character sequence, and I will approve on the next push.

4085 const lines: string[] = [];
41 const total = matchCount(db);
42 lines.push(`${esc}1mcs${esc}0m ${esc}2m${where} · ${total} match${total === 1 ? "" : "es"}${esc}0m`);
86 lines.push(`${esc}1mcs${esc}0m ${esc}2m${safe(data.where)} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`);
4387 lines.push("");
44 lines.push(TABS.map((t) => `${esc}${t === tab ? "7" : "2"}m ${t} ${esc}0m`).join(""));
88 lines.push(TABS.map((t) => `${esc}${t === state.tab ? "7" : "2"}m ${t} ${esc}0m`).join(""));
4589 lines.push("");
46 if (tab === "policy") {
90 if (state.tab === "policy") {
4791 const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${v}`;
4892 lines.push(` ${esc}1mthe policy the bots fight with${esc}0m`);
4993 lines.push("");
@@ -57,54 +101,57 @@ function draw(): void {
57101 lines.push(line("won by the old bots", `${CARD.baselineWinRate}%`));
58102 lines.push("");
59103 lines.push(` ${esc}2mretrain with: bun run scripts/train.ts --iters 1000${esc}0m`);
60 } else if (tab === "standings") {
104 } else if (state.tab === "standings") {
61105 lines.push(`${esc}2m ${pad("player", 26)}${pad("who", 8)}${pad("matches", 9)}${pad("kills", 7)}${pad("K/D", 7)}${pad("HS", 6)}wins${esc}0m`);
62 if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`);
63 table.forEach((s, i) => {
64 const line = `${pad(s.name, 26)}${pad(who(s.identity), 8)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` +
106 if (data.standings.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`);
107 data.standings.forEach((s, i) => {
108 const line = `${pad(safe(s.name), 26)}${pad(who(s.identity), 8)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` +
65109 `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`;
66110 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
67111 });
68112 } else {
69113 lines.push(`${esc}2m ${pad("player", 22)}${pad("who", 8)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`);
70 if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
71 matches.forEach((m, i) => {
72 const line = `${pad(m.name, 22)}${pad(who(m.identity), 8)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
73 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` +
114 if (data.matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
115 data.matches.forEach((m, i) => {
116 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)}` +
74118 `${m.finished === 1 ? "" : " (live)"}`;
75119 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
76120 });
77121 }
78122 lines.push("");
79123 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
80 lines.push(ssh === undefined
124 lines.push(data.ssh === undefined
81125 ? `${esc}2mthe app's terminal is off${esc}0m`
82 : `${esc}2mreach this terminal with: ${ssh}${esc}0m`);
126 : `${esc}2mreach this terminal with: ${data.ssh}${esc}0m`);
83127 const notice = identityWarning();
84128 if (notice !== null) lines.push(`${esc}33m${notice}${esc}0m`);
85 if (note !== "") lines.push(`${esc}33m${note}${esc}0m`);
86 out(`${esc}2J${esc}H` + lines.join("\r\n"));
129 if (state.note !== "") lines.push(`${esc}33m${safe(state.note)}${esc}0m`);
130 return lines;
87131 }
88132
89 process.stdin.setRawMode?.(true);
90 process.stdin.resume();
91 process.stdin.setEncoding("utf8");
92 draw();
93 process.stdin.on("data", (key: string) => {
94 note = "";
95 if (key === "q" || key === "\x03") {
96 out(`${esc}2J${esc}H`);
97 process.exit(0);
98 }
99 if (key === "j" || key === "\x1b[B") cursor += 1;
100 else if (key === "k" || key === "\x1b[A") cursor = Math.max(0, cursor - 1);
101 else if (key === "\t") {
102 tab = nextTab(tab);
103 cursor = 0;
104 } else if (key === "x" && tab === "matches") {
105 const match = recentMatches(db, 20)[cursor];
106 if (match !== undefined) note = deleteMatch(db, match.id) ? `removed ${match.name}'s match` : "nothing removed";
107 }
133 if (import.meta.main) {
134 const db = openDatabase();
135 const where = process.env.FORGE_APP_ENV === "preview"
136 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
137 : (process.env.FORGE_APP_ENV ?? "local");
138 const ssh = process.env.FORGE_APP_SSH;
139
140 const out = (s: string): void => { process.stdout.write(s); };
141 let state: State = initialState;
142 const draw = (): void => { out(`${esc}2J${esc}H` + render(state, readData(db, where, ssh)).join("\r\n")); };
143
144 process.stdin.setRawMode?.(true);
145 process.stdin.resume();
146 process.stdin.setEncoding("utf8");
108147 draw();
109 });
110 process.stdout.on("resize", draw);
148 process.stdin.on("data", (input: string) => {
149 if (input === "q" || input === "\x03") {
150 out(`${esc}2J${esc}H`);
151 process.exit(0);
152 }
153 state = key(state, input, readData(db, where, ssh), (id) => deleteMatch(db, id));
154 draw();
155 });
156 process.stdout.on("resize", draw);
157 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs