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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/32-the-terminal-is-held-by-tests-and-the-ta → main 1 commit fork
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. | ||
| 203 | 203 | |
| 204 | 204 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app |
| 205 | 205 | 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. | |
| 208 | 224 | |
| 209 | 225 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 210 | 226 | `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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * cs, in a terminal. The forge runs this on a pty when someone `ssh`es to the |
| 3 | 3 | * 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, | |
| 6 | 6 | * 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. | |
| 7 | 11 | */ |
| 8 | 12 | |
| 9 | import { headshotShare, matchCount, openDatabase, ratio, recentMatches, standings, deleteMatch } from "./db.ts"; | |
| 13 | import { matchCount, openDatabase, recentMatches, standings, deleteMatch } from "./db.ts"; | |
| 10 | 14 | 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"; | |
| 11 | 17 | |
| 12 | 18 | 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; | |
| 17 | 19 | |
| 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 | }; | |
| 21 | 29 | |
| 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 | }); | |
| 26 | 36 | |
| 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")); }; | |
| 81 | 39 | |
| 82 | 40 | process.stdin.setRawMode?.(true); |
| 83 | 41 | process.stdin.resume(); |
| 84 | 42 | process.stdin.setEncoding("utf8"); |
| 85 | 43 | draw(); |
| 86 | 44 | 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); | |
| 90 | 49 | process.exit(0); |
| 91 | 50 | } |
| 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)); | |
| 100 | 53 | } |
| 101 | 54 | draw(); |
| 102 | 55 | }); |
new above
offline