Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The terminal is held by tests, and the tab it already draws becomes reachable #37

Merged opened this

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

Merged

torque/cs-engine:torque/32-the-terminal-is-held-by-tests-and-the-ta → main 1 commit fork

  • Merged. torque/32-the-terminal-is-held-by-tests-and-the-ta was merged into main as 664eb13.

Review

Sign in to review this change.
6 files changed +497 −81
README.md +18 −2
@@ -203,8 +203,24 @@ page shows is the loop and the controls themselves, as numbers that move.
203203
204204 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
205205 runs (the app is the login name, the forge is the host). It opens `src/tty.ts`
206 on a real pty over the same database: the same standings, the same matches, and
207 `x` removes one.
206 on a real pty over the same database the web page uses. Three tabs, and
207 <kbd>Tab</kbd> cycles them: **standings** (the leaderboard, one row a name),
208 **matches** (the recent matches, newest first, a live one marked) and
209 **policy** (the card the bots were trained with — trained-at, simulation ticks
210 and throughput, PPO updates, duels and both win rates, and the command that
211 retrains it). <kbd>j</kbd>/<kbd>k</kbd> or the arrow keys move the cursor and
212 stop at both ends; <kbd>x</kbd> on the matches tab removes the match under it
213 and says what happened; <kbd>r</kbd> redraws; <kbd>q</kbd> or <kbd>ctrl-C</kbd>
214 leaves. The last line prints `FORGE_APP_SSH` verbatim, or says the terminal is
215 off when it is unset.
216
217 `src/tty.ts` is the wiring only — the database, raw stdin, stdout, resize and
218 exit. What it draws is `src/terminal/screen.ts` (`screen(view, data)` returns
219 the lines, opening nothing and reading no environment) and what it answers is
220 `src/terminal/keys.ts` (`press(view, key, data)` returns the next view and one
221 command: nothing, quit, or delete this match). Both are plain data in and out,
222 so their tests press keys and read lines with no terminal and no database —
223 with the escapes taken off by `plain()`, so an expectation is a sentence.
208224
209225 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
210226 `preview`), `FORGE_APP_URL`, `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it
src/terminal/keys.test.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * Every key the terminal answers, pressed without a terminal and without a
3 * database: a view and a list go in, the next view and one command come out.
4 * The cycle through all three tabs is the behaviour #32 was filed for — the
5 * old `standings ? matches : standings` fails the first test here.
6 */
7
8 import { expect, test } from "bun:test";
9 import type { Card } from "../client/weights.ts";
10 import type { MatchRow } from "../db.ts";
11 import type { Data, View } from "./screen.ts";
12 import { press, removed } from "./keys.ts";
13
14 const CARD = { trainedAt: "2026-09-14", decisions: 1, envSteps: 1, updates: 1, stepsPerSecond: 1, minutes: 1, duels: 1, winRate: 1, baselineWinRate: 1 } satisfies Card;
15
16 const match = (id: string, name: string): MatchRow => ({
17 id,
18 session: "s",
19 name,
20 kills: 7,
21 deaths: 4,
22 headshots: 2,
23 damage: 950,
24 rounds: 5,
25 score_mine: 3,
26 score_theirs: 2,
27 finished: 1,
28 seconds: 300,
29 updated_at: "2026-09-16 10:00:00",
30 });
31
32 const data: Data = {
33 standings: [
34 { name: "ace", matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9 },
35 { name: "bo", matches: 1, kills: 4, deaths: 6, headshots: 1, damage: 400, wins: 0, rounds: 3 },
36 ],
37 matches: [match("m1", "ace"), match("m2", "bo"), match("m3", "cy")],
38 total: 4,
39 card: CARD,
40 };
41
42 const view = (over: Partial<View> = {}): View =>
43 ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over });
44
45 test("tab cycles standings → matches → policy → standings, and the cursor resets each time", () => {
46 const first = press(view({ cursor: 1 }), "\t", data);
47 expect(first.view.tab).toBe("matches");
48 expect(first.view.cursor).toBe(0);
49 const second = press({ ...first.view, cursor: 2 }, "\t", data);
50 expect(second.view.tab).toBe("policy");
51 expect(second.view.cursor).toBe(0);
52 const third = press(second.view, "\t", data);
53 expect(third.view.tab).toBe("standings");
54 expect(third.view.cursor).toBe(0);
55 expect(third.command).toEqual({ kind: "nothing" });
56 });
57
58 test("j and the down arrow move down and stop at the last row", () => {
59 expect(press(view(), "j", data).view.cursor).toBe(1);
60 expect(press(view({ cursor: 1 }), "j", data).view.cursor).toBe(1);
61 expect(press(view({ tab: "matches" }), "\x1b[B", data).view.cursor).toBe(1);
62 expect(press(view({ tab: "matches", cursor: 2 }), "\x1b[B", data).view.cursor).toBe(2);
63 });
64
65 test("k and the up arrow move up and stop at the first row", () => {
66 expect(press(view({ cursor: 1 }), "k", data).view.cursor).toBe(0);
67 expect(press(view(), "k", data).view.cursor).toBe(0);
68 expect(press(view({ tab: "matches", cursor: 1 }), "\x1b[A", data).view.cursor).toBe(0);
69 expect(press(view({ tab: "matches" }), "\x1b[A", data).view.cursor).toBe(0);
70 });
71
72 test("an empty list leaves the cursor at zero, whichever way you press", () => {
73 const none: Data = { ...data, standings: [], matches: [] };
74 expect(press(view(), "j", none).view.cursor).toBe(0);
75 expect(press(view({ tab: "matches" }), "j", none).view.cursor).toBe(0);
76 expect(press(view({ cursor: 5 }), "k", none).view.cursor).toBe(0);
77 });
78
79 test("on the policy tab j and k change nothing", () => {
80 const policy = view({ tab: "policy" });
81 for (const key of ["j", "k", "\x1b[A", "\x1b[B"]) {
82 const step = press(policy, key, data);
83 expect(step.view).toEqual(policy);
84 expect(step.command).toEqual({ kind: "nothing" });
85 }
86 });
87
88 test("x on the matches tab asks for the match under the cursor to go", () => {
89 const step = press(view({ tab: "matches", cursor: 1 }), "x", data);
90 expect(step.command).toEqual({ kind: "delete", id: "m2", name: "bo" });
91 expect(step.view.cursor).toBe(1);
92 });
93
94 test("x anywhere else does nothing, and neither does x with nothing to remove", () => {
95 expect(press(view(), "x", data).command).toEqual({ kind: "nothing" });
96 expect(press(view({ tab: "policy" }), "x", data).command).toEqual({ kind: "nothing" });
97 expect(press(view({ tab: "matches" }), "x", { ...data, matches: [] }).command).toEqual({ kind: "nothing" });
98 });
99
100 test("what happened to the match is what the note says", () => {
101 expect(removed(view(), "bo", true).note).toBe("removed bo's match");
102 expect(removed(view(), "bo", false).note).toBe("nothing removed");
103 });
104
105 test("q and ctrl-C quit", () => {
106 expect(press(view(), "q", data).command).toEqual({ kind: "quit" });
107 expect(press(view({ tab: "policy" }), "\x03", data).command).toEqual({ kind: "quit" });
108 });
109
110 test("r asks for nothing but a redraw, and so does a key nobody bound", () => {
111 expect(press(view({ cursor: 1 }), "r", data)).toEqual({ view: view({ cursor: 1 }), command: { kind: "nothing" } });
112 expect(press(view(), "Z", data).command).toEqual({ kind: "nothing" });
113 });
114
115 test("the note from the last press is gone by the next one", () => {
116 expect(press(view({ note: "removed bo's match" }), "r", data).view.note).toBe("");
117 expect(press(view({ note: "nothing removed" }), "\t", data).view.note).toBe("");
118 expect(press(view({ note: "nothing removed" }), "q", data).view.note).toBe("");
119 });
120
121 test("a cursor left over from a longer list is pulled back onto the list before it is used", () => {
122 const step = press(view({ tab: "matches", cursor: 9 }), "x", { ...data, matches: [match("m1", "ace")] });
123 expect(step.command).toEqual({ kind: "delete", id: "m1", name: "ace" });
124 });
src/terminal/keys.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * Every key the terminal answers, as a reducer: a view and a keypress in, the
3 * next view and what the caller must go and do about it out. Nothing here
4 * touches the database or a stream, so a test presses keys with neither.
5 *
6 * The data the screen is drawing comes in too, because clamping the cursor and
7 * naming the match under it are both questions about the list on screen.
8 */
9
10 import { clamp, rowsOf, TABS, type Data, type View } from "./screen.ts";
11
12 /** What `src/tty.ts` does after a press, and the only impure thing left. */
13 export type Command =
14 | { kind: "nothing" }
15 | { kind: "quit" }
16 | { kind: "delete"; id: string; name: string };
17
18 export type Press = { view: View; command: Command };
19
20 const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } });
21
22 /** The next tab in the cycle: standings → matches → policy → standings. */
23 export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!;
24
25 export function press(view: View, key: string, data: Data): Press {
26 // Any key clears the last note: it described the press before this one.
27 const now: View = { ...view, note: "", cursor: clamp(view.cursor, rowsOf(view.tab, data)) };
28 if (key === "q" || key === "\x03") return { view: now, command: { kind: "quit" } };
29 const rows = rowsOf(now.tab, data);
30 if (key === "j" || key === "\x1b[B") return nothing({ ...now, cursor: clamp(now.cursor + 1, rows) });
31 if (key === "k" || key === "\x1b[A") return nothing({ ...now, cursor: clamp(now.cursor - 1, rows) });
32 if (key === "\t") return nothing({ ...now, tab: nextTab(now), cursor: 0 });
33 if (key === "x" && now.tab === "matches") {
34 const match = data.matches[now.cursor];
35 if (match === undefined) return nothing(now);
36 return { view: now, command: { kind: "delete", id: match.id, name: match.name } };
37 }
38 // r redraws, and so does anything else: the caller draws after every press.
39 return nothing(now);
40 }
41
42 /** Fold the outcome of a delete back into the view, as the note a person reads. */
43 export const removed = (view: View, name: string, ok: boolean): View =>
44 ({ ...view, note: ok ? `removed ${name}'s match` : "nothing removed" });
src/terminal/screen.test.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * The screen is text, so this reads it as text: `plain()` takes the escapes
3 * off and the expectation is a sentence or a row whose columns are counted by
4 * hand. The escapes themselves are checked only where they carry meaning —
5 * the highlight on the tab you are on, and the one on the row the cursor is on.
6 */
7
8 import { expect, test } from "bun:test";
9 import type { Card } from "../client/weights.ts";
10 import type { MatchRow, Standing } from "../db.ts";
11 import { clamp, plain, rowsOf, screen, type Data, type View } from "./screen.ts";
12
13 const CARD: Card = {
14 trainedAt: "2026-09-14",
15 decisions: 2048000,
16 envSteps: 8192000,
17 updates: 1000,
18 stepsPerSecond: 11134,
19 minutes: 12.3,
20 duels: 240,
21 winRate: 72,
22 baselineWinRate: 38,
23 };
24
25 const standing = (name: string, over: Partial<Standing> = {}): Standing =>
26 ({ name, matches: 3, kills: 20, deaths: 10, headshots: 5, damage: 1800, wins: 2, rounds: 9, ...over });
27
28 const match = (id: string, name: string, over: Partial<MatchRow> = {}): MatchRow => ({
29 id,
30 session: "s",
31 name,
32 kills: 7,
33 deaths: 4,
34 headshots: 2,
35 damage: 950,
36 rounds: 5,
37 score_mine: 3,
38 score_theirs: 2,
39 finished: 1,
40 seconds: 300,
41 updated_at: "2026-09-16 10:00:00",
42 ...over,
43 });
44
45 const data = (over: Partial<Data> = {}): Data => ({
46 standings: [standing("ace"), standing("bo", { kills: 11, deaths: 11, headshots: 0, wins: 1 })],
47 matches: [match("m1", "ace"), match("m2", "bo", { finished: 0 })],
48 total: 4,
49 card: CARD,
50 ...over,
51 });
52
53 const view = (over: Partial<View> = {}): View =>
54 ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over });
55
56 const text = (v: View, d: Data = data()): string[] => screen(v, d).map(plain);
57
58 test("the header says where we are and how many matches there are, plural when it should be", () => {
59 expect(text(view())[0]).toBe("cs local · 4 matches");
60 expect(text(view(), data({ total: 1 }))[0]).toBe("cs local · 1 match");
61 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches");
62 });
63
64 test("the tab strip names all three, and the highlight follows the tab you are on", () => {
65 expect(text(view())[2]).toBe(" standings matches policy ");
66 expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m");
67 expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m");
68 expect(screen(view({ tab: "policy" }), data())[2]).toContain("\x1b[7m policy \x1b[0m");
69 // And only ever one of them.
70 for (const tab of ["standings", "matches", "policy"] as const) {
71 expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1);
72 }
73 });
74
75 test("the standings keep their columns, and the cursor marks one row", () => {
76 const lines = text(view({ cursor: 1 }));
77 expect(lines[4]).toBe(" player matches kills K/D HS wins");
78 expect(lines[5]).toBe(" ace 3 20 2.00 25% 2");
79 expect(lines[6]).toBe("▸ bo 3 11 1.00 0% 1");
80 // Nobody with a kill yet has no headshot share to show.
81 const rookie = text(view(), data({ standings: [standing("cy", { kills: 0, deaths: 3, headshots: 0, wins: 0 })] }));
82 expect(rookie[5]).toBe("▸ cy 3 0 0.00 — 0");
83 expect(screen(view({ cursor: 1 }), data())[6]).toStartWith("\x1b[7m▸ ");
84 expect(screen(view({ cursor: 1 }), data())[5]).toStartWith(" ");
85 });
86
87 test("a name wider than its column is cut with an ellipsis rather than pushing the row over", () => {
88 const long = "a".repeat(40);
89 const row = text(view(), data({ standings: [standing(long)] }))[5]!;
90 expect(row).toStartWith(`▸ ${"a".repeat(25)}…3`);
91 expect(row).toBe("▸ " + "a".repeat(25) + "…3 20 2.00 25% 2");
92 });
93
94 test("the match list keeps its columns and marks the ones still being played", () => {
95 const lines = text(view({ tab: "matches" }));
96 expect(lines[4]).toBe(" player score k/d damage when");
97 expect(lines[5]).toBe("▸ ace 3-2 7/4 950 2026-09-16 10:00:00");
98 expect(lines[6]).toBe(" bo 3-2 7/4 950 2026-09-16 10:00:00 (live)");
99 });
100
101 test("an empty database says so on both lists", () => {
102 const empty = data({ standings: [], matches: [] });
103 expect(text(view(), empty)[5]).toBe(" nothing played yet");
104 expect(text(view({ tab: "matches" }), empty)[5]).toBe(" no matches yet");
105 });
106
107 test("the policy tab prints the card the bots were trained with", () => {
108 const lines = text(view({ tab: "policy" }));
109 expect(lines[4]).toBe(" the policy the bots fight with");
110 expect(lines).toContain(" trained 2026-09-14");
111 expect(lines).toContain(" simulation ticks 8,192,000 in 12.3 min");
112 expect(lines).toContain(" throughput 11,134 ticks a second");
113 expect(lines).toContain(" PPO updates 1000");
114 expect(lines).toContain(" duels evaluated 240");
115 expect(lines).toContain(" won by the policy 72%");
116 expect(lines).toContain(" won by the old bots 38%");
117 expect(lines).toContain(" retrain with: bun run scripts/train.ts --iters 1000");
118 });
119
120 test("the last line is FORGE_APP_SSH verbatim, or says the terminal is off", () => {
121 const on = text(view({ ssh: "ssh -p 2222 cs@localhost" }));
122 expect(on.at(-1)).toBe("reach this terminal with: ssh -p 2222 cs@localhost");
123 expect(text(view()).at(-1)).toBe("the app's terminal is off");
124 });
125
126 test("a note is the last line when there is one, under the ssh line", () => {
127 const lines = text(view({ note: "removed ace's match" }));
128 expect(lines.at(-1)).toBe("removed ace's match");
129 expect(lines.at(-2)).toBe("the app's terminal is off");
130 // With no note there is no line for it at all.
131 expect(text(view()).at(-1)).toBe("the app's terminal is off");
132 });
133
134 test("the keys are printed where a person can read them", () => {
135 expect(text(view())).toContain("j/k move · tab switch · x remove a match · r refresh · q quit");
136 });
137
138 test("the cursor never draws past the end of the list, and the policy card has no rows at all", () => {
139 expect(rowsOf("standings", data())).toBe(2);
140 expect(rowsOf("matches", data())).toBe(2);
141 expect(rowsOf("policy", data())).toBe(0);
142 expect(clamp(9, 2)).toBe(1);
143 expect(clamp(-3, 2)).toBe(0);
144 expect(clamp(0, 0)).toBe(0);
145 // A cursor left over from a longer list still highlights a row that exists.
146 const lines = screen(view({ cursor: 7 }), data());
147 expect(lines.filter((l) => l.includes("▸")).length).toBe(1);
148 expect(plain(lines[6]!)).toStartWith("▸ bo");
149 });
150
151 test("plain() takes off exactly the escapes and leaves the words", () => {
152 expect(plain("\x1b[1mcs\x1b[0m \x1b[2mlocal\x1b[0m")).toBe("cs local");
153 expect(plain("\x1b[33mnothing removed\x1b[0m")).toBe("nothing removed");
154 expect(plain("no escapes here")).toBe("no escapes here");
155 });
src/terminal/screen.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * What the terminal draws, as lines of text. Plain data in, plain data out: a
3 * `View` (which tab, where the cursor is, the note, where we are running and
4 * the command that reaches us) and the `Data` the database handed over. It
5 * opens nothing, reads no `process.env` and writes to no stream — `src/tty.ts`
6 * does all three around it, and a test calls `screen()` with neither.
7 *
8 * Column widths are the ones a person already knows; do not move them.
9 */
10
11 import type { Card } from "../client/weights.ts";
12 import type { MatchRow, Standing } from "../db.ts";
13 import { headshotShare, ratio } from "../db.ts";
14
15 export type Tab = "standings" | "matches" | "policy";
16
17 /** The three tabs in the order tab cycles them. */
18 export const TABS: readonly Tab[] = ["standings", "matches", "policy"];
19
20 export type View = {
21 tab: Tab;
22 /** Row the highlight sits on, within the current tab's list. */
23 cursor: number;
24 /** One line of feedback under the table; empty means none. */
25 note: string;
26 /** The environment pill: `local`, `production`, `preview of #12`. */
27 where: string;
28 /** `FORGE_APP_SSH` verbatim, or undefined when the terminal is off. */
29 ssh: string | undefined;
30 };
31
32 export type Data = {
33 standings: Standing[];
34 matches: MatchRow[];
35 /** Every match ever recorded, including the empty ones the lists skip. */
36 total: number;
37 card: Card;
38 };
39
40 const esc = "\x1b[";
41
42 const pad = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
43
44 /** Rows the cursor can stand on for a tab. The policy card has none. */
45 export const rowsOf = (tab: Tab, data: Data): number =>
46 tab === "standings" ? data.standings.length : tab === "matches" ? data.matches.length : 0;
47
48 /** The cursor a view actually draws with: never past the end of its list. */
49 export const clamp = (cursor: number, rows: number): number =>
50 Math.max(0, Math.min(cursor, Math.max(0, rows - 1)));
51
52 /**
53 * The escapes out of a line, so a test's expectation is a sentence rather than
54 * a pile of `\x1b[`. Matches a CSI sequence: `\x1b[`, digits and semicolons, a
55 * final letter.
56 */
57 export const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
58
59 const header = (tab: Tab): string =>
60 TABS.map((t) => (t === tab ? `${esc}7m ${t} ${esc}0m` : `${esc}2m ${t} ${esc}0m`)).join("");
61
62 function policyLines(card: Card): string[] {
63 const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${v}`;
64 return [
65 ` ${esc}1mthe policy the bots fight with${esc}0m`,
66 "",
67 line("trained", card.trainedAt),
68 line("simulation ticks", `${card.envSteps.toLocaleString("en")} in ${card.minutes} min`),
69 line("throughput", `${card.stepsPerSecond.toLocaleString("en")} ticks a second`),
70 line("PPO updates", String(card.updates)),
71 line("duels evaluated", String(card.duels)),
72 line("won by the policy", `${card.winRate}%`),
73 line("won by the old bots", `${card.baselineWinRate}%`),
74 "",
75 ` ${esc}2mretrain with: bun run scripts/train.ts --iters 1000${esc}0m`,
76 ];
77 }
78
79 function standingsLines(table: Standing[], cursor: number): string[] {
80 const lines = [
81 `${esc}2m ${pad("player", 26)}${pad("matches", 9)}${pad("kills", 7)}${pad("K/D", 7)}${pad("HS", 6)}wins${esc}0m`,
82 ];
83 if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`);
84 table.forEach((s, i) => {
85 const line = `${pad(s.name, 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` +
86 `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`;
87 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
88 });
89 return lines;
90 }
91
92 function matchLines(matches: MatchRow[], cursor: number): string[] {
93 const lines = [
94 `${esc}2m ${pad("player", 22)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`,
95 ];
96 if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
97 matches.forEach((m, i) => {
98 const line = `${pad(m.name, 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
99 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` +
100 `${m.finished === 1 ? "" : " (live)"}`;
101 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
102 });
103 return lines;
104 }
105
106 /** Every line the terminal would print, top to bottom, without the clear. */
107 export function screen(view: View, data: Data): string[] {
108 const cursor = clamp(view.cursor, rowsOf(view.tab, data));
109 const lines: string[] = [];
110 lines.push(`${esc}1mcs${esc}0m ${esc}2m${view.where} · ${data.total} match${data.total === 1 ? "" : "es"}${esc}0m`);
111 lines.push("");
112 lines.push(header(view.tab));
113 lines.push("");
114 if (view.tab === "policy") lines.push(...policyLines(data.card));
115 else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor));
116 else lines.push(...matchLines(data.matches, cursor));
117 lines.push("");
118 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
119 lines.push(view.ssh === undefined
120 ? `${esc}2mthe app's terminal is off${esc}0m`
121 : `${esc}2mreach this terminal with: ${view.ssh}${esc}0m`);
122 if (view.note !== "") lines.push(`${esc}33m${view.note}${esc}0m`);
123 return lines;
124 }
src/tty.ts +32 −79
@@ -1,102 +1,55 @@
11 /**
22 * cs, in a terminal. The forge runs this on a pty when someone `ssh`es to the
33 * app, with FORGE_APP_DATA pointing at the same database the web page uses:
4 * the same leaderboard, the same matches. j/k move, tab switches between the
5 * standings and the match list, x removes the selected match, r refreshes,
4 * the same leaderboard, the same matches. j/k move, tab cycles the standings,
5 * the matches and the policy card, x removes the selected match, r refreshes,
66 * q leaves. Plain ANSI, no dependencies.
7 *
8 * This file is the wiring only — the database, raw stdin, stdout, resize and
9 * exit. What it draws is `src/terminal/screen.ts` and what it answers is
10 * `src/terminal/keys.ts`, both plain data in and out, both under test.
711 */
812
9 import { headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, deleteMatch } from "./db.ts";
13 import { matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts";
1014 import { CARD } from "./client/weights.ts";
15 import { screen, type Data, type View } from "./terminal/screen.ts";
16 import { press, removed } from "./terminal/keys.ts";
1117
1218 const db = openDatabase();
13 const where = process.env.FORGE_APP_ENV === "preview"
14 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
15 : (process.env.FORGE_APP_ENV ?? "local");
16 const ssh = process.env.FORGE_APP_SSH;
1719
18 const esc = "\x1b[";
19 const out = (s: string): void => { process.stdout.write(s); };
20 const pad = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
20 let view: View = {
21 tab: "standings",
22 cursor: 0,
23 note: "",
24 where: process.env.FORGE_APP_ENV === "preview"
25 ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}`
26 : (process.env.FORGE_APP_ENV ?? "local"),
27 ssh: process.env.FORGE_APP_SSH,
28 };
2129
22 type Tab = "standings" | "matches" | "policy";
23 let tab: Tab = "standings";
24 let cursor = 0;
25 let note = "";
30 const read = (): Data => ({
31 standings: standings(db, 20),
32 matches: recentMatches(db, 20),
33 total: matchCount(db),
34 card: CARD,
35 });
2636
27 function draw(): void {
28 const table = standings(db, 20);
29 const matches = recentMatches(db, 20);
30 const rows = tab === "standings" ? table.length : tab === "matches" ? matches.length : 0;
31 cursor = Math.max(0, Math.min(cursor, Math.max(0, rows - 1)));
32 const lines: string[] = [];
33 const total = matchCount(db);
34 lines.push(`${esc}1mcs${esc}0m ${esc}2m${where} · ${total} match${total === 1 ? "" : "es"}${esc}0m`);
35 lines.push("");
36 lines.push(
37 `${tab === "standings" ? `${esc}7m standings ${esc}0m` : `${esc}2m standings ${esc}0m`}` +
38 `${tab === "matches" ? `${esc}7m matches ${esc}0m` : `${esc}2m matches ${esc}0m`}` +
39 `${tab === "policy" ? `${esc}7m policy ${esc}0m` : `${esc}2m policy ${esc}0m`}`,
40 );
41 lines.push("");
42 if (tab === "policy") {
43 const line = (k: string, v: string): string => ` ${esc}2m${pad(k, 22)}${esc}0m${v}`;
44 lines.push(` ${esc}1mthe policy the bots fight with${esc}0m`);
45 lines.push("");
46 lines.push(line("trained", CARD.trainedAt));
47 lines.push(line("simulation ticks", `${CARD.envSteps.toLocaleString("en")} in ${CARD.minutes} min`));
48 lines.push(line("throughput", `${CARD.stepsPerSecond.toLocaleString("en")} ticks a second`));
49 lines.push(line("PPO updates", String(CARD.updates)));
50 lines.push(line("duels evaluated", String(CARD.duels)));
51 lines.push(line("won by the policy", `${CARD.winRate}%`));
52 lines.push(line("won by the old bots", `${CARD.baselineWinRate}%`));
53 lines.push("");
54 lines.push(` ${esc}2mretrain with: bun run scripts/train.ts --iters 1000${esc}0m`);
55 } else if (tab === "standings") {
56 lines.push(`${esc}2m ${pad("player", 26)}${pad("matches", 9)}${pad("kills", 7)}${pad("K/D", 7)}${pad("HS", 6)}wins${esc}0m`);
57 if (table.length === 0) lines.push(`${esc}2m nothing played yet${esc}0m`);
58 table.forEach((s, i) => {
59 const line = `${pad(s.name, 26)}${pad(String(s.matches), 9)}${pad(String(s.kills), 7)}` +
60 `${pad(ratio(s.kills, s.deaths), 7)}${pad(headshotShare(s.headshots, s.kills), 6)}${s.wins}`;
61 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
62 });
63 } else {
64 lines.push(`${esc}2m ${pad("player", 22)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`);
65 if (matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`);
66 matches.forEach((m, i) => {
67 const line = `${pad(m.name, 22)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` +
68 `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${m.updated_at}` +
69 `${m.finished === 1 ? "" : " (live)"}`;
70 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
71 });
72 }
73 lines.push("");
74 lines.push(`${esc}2mj/k move · tab switch · x remove a match · r refresh · q quit${esc}0m`);
75 lines.push(ssh === undefined
76 ? `${esc}2mthe app's terminal is off${esc}0m`
77 : `${esc}2mreach this terminal with: ${ssh}${esc}0m`);
78 if (note !== "") lines.push(`${esc}33m${note}${esc}0m`);
79 out(`${esc}2J${esc}H` + lines.join("\r\n"));
80 }
37 const clear = "\x1b[2J\x1b[H";
38 const draw = (): void => { process.stdout.write(clear + screen(view, read()).join("\r\n")); };
8139
8240 process.stdin.setRawMode?.(true);
8341 process.stdin.resume();
8442 process.stdin.setEncoding("utf8");
8543 draw();
8644 process.stdin.on("data", (key: string) => {
87 note = "";
88 if (key === "q" || key === "\x03") {
89 out(`${esc}2J${esc}H`);
45 const step = press(view, key, read());
46 view = step.view;
47 if (step.command.kind === "quit") {
48 process.stdout.write(clear);
9049 process.exit(0);
9150 }
92 if (key === "j" || key === "\x1b[B") cursor += 1;
93 else if (key === "k" || key === "\x1b[A") cursor = Math.max(0, cursor - 1);
94 else if (key === "\t") {
95 tab = tab === "standings" ? "matches" : "standings";
96 cursor = 0;
97 } else if (key === "x" && tab === "matches") {
98 const match = recentMatches(db, 20)[cursor];
99 if (match !== undefined) note = deleteMatch(db, match.id) ? `removed ${match.name}'s match` : "nothing removed";
51 if (step.command.kind === "delete") {
52 view = removed(view, step.command.name, deleteMatch(db, step.command.id));
10053 }
10154 draw();
10255 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs