Public: anyone can read this; the crew can change it.
The terminal, under test: src/tty.ts split so every screen is a pure function of state #57
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/41-the-terminal-under-test-src-tty-ts-split → main 1 commit fork
4 files
changed +484
−105
AGENTS.md +5 −1
| @@ -82,7 +82,11 @@ To add a concept: | ||
| 82 | 82 | - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence, |
| 83 | 83 | a JSON schema, the action. |
| 84 | 84 | - **A terminal command** — a key in `src/tty.ts` that calls the action and |
| 85 | reports on the status line. | |
| 85 | reports on the status line. The screen there is a pure function of state | |
| 86 | (`screen(state)`: the loop's state, the actions' data and the forge's | |
| 87 | environment in, the lines of a frame out) and has a test in | |
| 88 | `src/tty.test.ts`; only the loop touches the database, the keyboard and | |
| 89 | `process.env`. | |
| 86 | 90 | - **Something live** — `emit(type, data)` in the action, and |
| 87 | 91 | `data-live="<types>"` on the region of the page that shows it. |
| 88 | 92 |
README.md +6 −1
| @@ -53,7 +53,7 @@ renders the same functions from real state. | ||
| 53 | 53 | | `src/page.ts` | the lobby's body, poured into the layout | |
| 54 | 54 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 55 | 55 | | `src/server.ts` | boot: open the database, serve | |
| 56 | | `src/tty.ts` | the terminal, over the same actions | | |
| 56 | | `src/tty.ts` | the terminal: a pure screen, and the loop that prints it | | |
| 57 | 57 | | `src/env.ts` | what the forge told this process | |
| 58 | 58 | | `static/app.css`, `static/live.js` | the look, and the list that stays current | |
| 59 | 59 | | `static/when.js` | the words for a time, run by the server and the browser both | |
| @@ -163,6 +163,11 @@ runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge | ||
| 163 | 163 | the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on |
| 164 | 164 | a real pty over the same data, as the account at the keyboard: j/k move |
| 165 | 165 | between tables, o opens one, s sits down, x gives the seat up, q quits. |
| 166 | That file is two halves: `screen(state)` draws a frame as a pure function — | |
| 167 | state in, lines out, no database and no environment, so every screen it can | |
| 168 | show has a test in `src/tty.test.ts` — and the loop around it reads keys, | |
| 169 | calls the actions and prints what comes back. When `FORGE_APP_SSH` is unset | |
| 170 | the header says the terminal is off rather than inventing a command. | |
| 166 | 171 | |
| 167 | 172 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or |
| 168 | 173 | `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and |
src/tty.test.ts added +229
| @@ -0,0 +1,229 @@ | ||
| 1 | /** | |
| 2 | * The terminal, screen by screen. Every test here is `screen(state)` — plain | |
| 3 | * data in, the lines of one frame out — so the loop, the database and the | |
| 4 | * environment are nowhere near it, and a screen is asserted as the words a | |
| 5 | * person reads rather than as a soup of escape sequences (`plain`). | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import type { Seat, Table } from "./actions.ts"; | |
| 10 | import { frame, keys, plain, screen, type State } from "./tty.ts"; | |
| 11 | import type { Who } from "./who.ts"; | |
| 12 | ||
| 13 | const anon: Who = { role: "anon" }; | |
| 14 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 15 | const bob: Who = { role: "user", id: "u2", name: "bob" }; | |
| 16 | const root: Who = { role: "admin", id: "u0", name: "root" }; | |
| 17 | ||
| 18 | const NOW = Date.parse("2026-09-14T12:00:00.000Z"); | |
| 19 | const OPENED = "2026-09-14T11:40:00.000Z"; | |
| 20 | const SSH = "ssh -p 2222 parley@localhost"; | |
| 21 | ||
| 22 | const seat = (ordinal: number, who: Who, charge: unknown = null): Seat => ({ | |
| 23 | id: 100 + ordinal, | |
| 24 | ordinal, | |
| 25 | player_id: who.role === "anon" ? "" : who.id, | |
| 26 | player_name: who.role === "anon" ? "" : who.name, | |
| 27 | joined_at: OPENED, | |
| 28 | score: null, | |
| 29 | charge, | |
| 30 | }); | |
| 31 | ||
| 32 | /** A table as `listTables` hands one to a door: already redacted for its viewer. */ | |
| 33 | const table = (over: Partial<Table> = {}): Table => { | |
| 34 | const seats = over.seats ?? [seat(0, alice)]; | |
| 35 | return { | |
| 36 | id: 1, | |
| 37 | title: "The Wreck", | |
| 38 | status: "open", | |
| 39 | host_id: "u1", | |
| 40 | host_name: "alice", | |
| 41 | seats_max: 4, | |
| 42 | quorum: 3, | |
| 43 | turn_seconds: 21_600, | |
| 44 | deadline_seconds: 86_400, | |
| 45 | created_at: OPENED, | |
| 46 | started_at: null, | |
| 47 | settled_at: null, | |
| 48 | seated: seats.length, | |
| 49 | your_seat: null, | |
| 50 | ...over, | |
| 51 | seats, | |
| 52 | }; | |
| 53 | }; | |
| 54 | ||
| 55 | const state = (over: Partial<State> = {}): State => ({ | |
| 56 | who: anon, | |
| 57 | tables: [], | |
| 58 | cursor: 0, | |
| 59 | title: "parley", | |
| 60 | where: "local", | |
| 61 | ssh: SSH, | |
| 62 | now: NOW, | |
| 63 | ...over, | |
| 64 | }); | |
| 65 | ||
| 66 | /** The screen as it reads: the same lines, with the styling taken off. */ | |
| 67 | const shown = (over: Partial<State> = {}): string[] => | |
| 68 | screen(state(over)).map(plain); | |
| 69 | ||
| 70 | /** The last line: the legend, what an action said, or what is being typed. */ | |
| 71 | const lastLine = (over: Partial<State> = {}): string => | |
| 72 | shown(over).at(-1) ?? ""; | |
| 73 | ||
| 74 | test("the empty lobby: a player is told the key, a guest nothing to press", () => { | |
| 75 | expect(shown({ who: alice })).toContain( | |
| 76 | "No tables yet. Press o to open one.", | |
| 77 | ); | |
| 78 | expect(lastLine({ who: alice })).toBe("j/k move · o open · q quit"); | |
| 79 | ||
| 80 | expect(shown({ who: anon })).toContain("No tables yet."); | |
| 81 | expect(lastLine({ who: anon })).toBe( | |
| 82 | "j/k move · q quit · sign in with a key the forge knows to play", | |
| 83 | ); | |
| 84 | }); | |
| 85 | ||
| 86 | test("one open table: the row says what it is, and the seats are under it", () => { | |
| 87 | const lines = shown({ who: bob, tables: [table()] }); | |
| 88 | expect(lines).toContain( | |
| 89 | "▸ The Wreck open · 1/4 seated · host alice · opened 20m ago", | |
| 90 | ); | |
| 91 | expect(lines).toContain(" 1. alice"); | |
| 92 | expect(lastLine({ who: bob, tables: [table()] })).toBe( | |
| 93 | "j/k move · o open · s sit · q quit", | |
| 94 | ); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a table you are seated at says so, and offers x instead of s", () => { | |
| 98 | const mine = table({ seats: [seat(0, alice), seat(1, bob)], your_seat: 101 }); | |
| 99 | const lines = shown({ who: bob, tables: [mine] }); | |
| 100 | expect(lines).toContain( | |
| 101 | "▸ The Wreck open · 2/4 seated · host alice · opened 20m ago · you are seated", | |
| 102 | ); | |
| 103 | expect(lines).toContain(" 1. alice 2. bob"); | |
| 104 | expect(lastLine({ who: bob, tables: [mine] })).toBe( | |
| 105 | "j/k move · o open · x leave · q quit", | |
| 106 | ); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a full table offers neither: the legend is the actions' own rules", () => { | |
| 110 | const full = table({ | |
| 111 | seats: [seat(0, alice), seat(1, bob), seat(2, root), seat(3, alice)], | |
| 112 | seats_max: 4, | |
| 113 | }); | |
| 114 | expect(lastLine({ who: bob, tables: [full] })).toBe( | |
| 115 | "j/k move · o open · q quit", | |
| 116 | ); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a guest is offered no key that writes", () => { | |
| 120 | const open = table(); | |
| 121 | expect(keys(anon, open).some((key) => key.writes)).toBe(false); | |
| 122 | // The same table, to a player, does offer one — so the assertion above is | |
| 123 | // about the guest and not about the fixture. | |
| 124 | expect(keys(bob, open).some((key) => key.writes)).toBe(true); | |
| 125 | ||
| 126 | const legend = lastLine({ who: anon, tables: [open] }); | |
| 127 | for (const key of ["o open", "s sit", "x leave"]) | |
| 128 | expect(legend).not.toContain(key); | |
| 129 | }); | |
| 130 | ||
| 131 | test("your own charge is shown at your seat, and nobody else's is there to show", () => { | |
| 132 | const dealt = table({ | |
| 133 | seats: [seat(0, alice, { want: "the anchor" }), seat(1, bob)], | |
| 134 | your_seat: 100, | |
| 135 | }); | |
| 136 | const lines = shown({ who: alice, tables: [dealt] }); | |
| 137 | expect(lines).toContain( | |
| 138 | ' 1. alice (your charge: {"want":"the anchor"}) 2. bob', | |
| 139 | ); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a table with nobody at it says so", () => { | |
| 143 | const empty = table({ seats: [], seated: 0 }); | |
| 144 | expect(shown({ who: bob, tables: [empty] })).toContain(" nobody seated yet"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("the header names the app, the environment and who is at the keyboard", () => { | |
| 148 | expect(shown({ who: anon })[0]).toBe("parley local guest"); | |
| 149 | expect(shown({ who: alice })[0]).toBe("parley local alice"); | |
| 150 | expect(shown({ who: root })[0]).toBe("parley local root (admin)"); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the header prints FORGE_APP_SSH verbatim, and says so when there is none", () => { | |
| 154 | expect(shown({ ssh: SSH })[1]).toBe(`connect: ${SSH}`); | |
| 155 | expect(shown({ ssh: undefined })[1]).toBe( | |
| 156 | "the terminal is off: FORGE_APP_SSH is not set", | |
| 157 | ); | |
| 158 | expect(shown({ ssh: " " })[1]).toBe( | |
| 159 | "the terminal is off: FORGE_APP_SSH is not set", | |
| 160 | ); | |
| 161 | }); | |
| 162 | ||
| 163 | test("the environment reaches the screen only through its state", () => { | |
| 164 | process.env.FORGE_APP_SSH = "ssh -p 2222 somewhere@else"; | |
| 165 | try { | |
| 166 | // Unset in the state is unset on the screen, whatever the process holds. | |
| 167 | expect(shown({ ssh: undefined })[1]).toBe( | |
| 168 | "the terminal is off: FORGE_APP_SSH is not set", | |
| 169 | ); | |
| 170 | expect(shown({ ssh: SSH })[1]).toBe(`connect: ${SSH}`); | |
| 171 | } finally { | |
| 172 | delete process.env.FORGE_APP_SSH; | |
| 173 | } | |
| 174 | }); | |
| 175 | ||
| 176 | test("a refusal is the status line, in the action's own words", () => { | |
| 177 | const said = "This table is already playing."; | |
| 178 | expect(lastLine({ who: bob, tables: [table()], said })).toBe(said); | |
| 179 | // And the legend is what comes back once it is cleared. | |
| 180 | expect(lastLine({ who: bob, tables: [table()], said: "" })).toBe( | |
| 181 | "j/k move · o open · s sit · q quit", | |
| 182 | ); | |
| 183 | }); | |
| 184 | ||
| 185 | test("a table being named is the status line, with a block for a cursor", () => { | |
| 186 | const typing = lastLine({ who: alice, typing: "The Wr" }); | |
| 187 | expect(typing).toBe("Table name: The Wr "); | |
| 188 | expect(lastLine({ who: alice, typing: "" })).toBe("Table name: "); | |
| 189 | }); | |
| 190 | ||
| 191 | test("the cursor is clamped to the rows there are", () => { | |
| 192 | const two = [table(), table({ id: 2, title: "The Ledger" })]; | |
| 193 | const far = shown({ who: bob, tables: two, cursor: 9 }); | |
| 194 | expect(far.some((line) => line.startsWith("▸ The Ledger"))).toBe(true); | |
| 195 | const before = shown({ who: bob, tables: two, cursor: -3 }); | |
| 196 | expect(before.some((line) => line.startsWith("▸ The Wreck"))).toBe(true); | |
| 197 | }); | |
| 198 | ||
| 199 | test("a control character in a title, a name or the command never reaches the terminal", () => { | |
| 200 | const nasty = table({ | |
| 201 | title: "The\x1b[2JWreck", | |
| 202 | host_name: "al\x07ice", | |
| 203 | seats: [seat(0, { role: "user", id: "u1", name: "al\rice" })], | |
| 204 | }); | |
| 205 | const lines = screen( | |
| 206 | state({ who: bob, tables: [nasty], ssh: "ssh\x1b[2J -p 2222" }), | |
| 207 | ); | |
| 208 | expect(lines.map(plain)).toContain( | |
| 209 | "▸ The[2JWreck open · 1/4 seated · host alice · opened 20m ago", | |
| 210 | ); | |
| 211 | expect(lines.map(plain)).toContain(" 1. alice"); | |
| 212 | expect(lines.map(plain)[1]).toBe("connect: ssh[2J -p 2222"); | |
| 213 | // Nothing drawn from data carries an escape of its own: strip this file's | |
| 214 | // own sequences and no escape byte is left anywhere on the screen. | |
| 215 | expect(lines.map(plain).join("\n")).not.toContain("\x1b"); | |
| 216 | }); | |
| 217 | ||
| 218 | test("a frame is the screen cleared, one line to a line", () => { | |
| 219 | const lines = screen(state({ who: bob, tables: [table()] })); | |
| 220 | const drawn = frame(lines); | |
| 221 | expect(drawn.startsWith("\x1b[2J\x1b[H")).toBe(true); | |
| 222 | expect(drawn.split("\r\n").length).toBe(lines.length); | |
| 223 | expect(plain(drawn).split("\r\n").at(0)).toBe("parley local bob"); | |
| 224 | }); | |
| 225 | ||
| 226 | test("the same state draws the same screen, every time", () => { | |
| 227 | const same = state({ who: bob, tables: [table()] }); | |
| 228 | expect(screen(same)).toEqual(screen(same)); | |
| 229 | }); |
src/tty.ts +244 −103
| @@ -5,6 +5,16 @@ | ||
| 5 | 5 | * one, s sits down, x gives the seat up, q leaves. Plain ANSI, no |
| 6 | 6 | * dependencies. What a person can do here is what they can do on the page — |
| 7 | 7 | * the actions say so, not this file. |
| 8 | * | |
| 9 | * Two halves, along the line that was always here: | |
| 10 | * | |
| 11 | * - `screen(state)` is the terminal drawn. A pure function: the loop's | |
| 12 | * state, the actions' data and the forge's environment in, the lines of | |
| 13 | * one frame out. No database, no `process.stdout`, no `process.env`, no | |
| 14 | * clock — so every screen has a test. | |
| 15 | * - `run()` is the loop. It opens the database, asks `whoAtTty()` who is | |
| 16 | * there, reads keys, calls actions and prints what the builder returns. | |
| 17 | * It decides no rule: a refusal is printed in the action's own words. | |
| 8 | 18 | */ |
| 9 | 19 | |
| 10 | 20 | import { |
| @@ -14,55 +24,122 @@ import { | ||
| 14 | 24 | leaveTable, |
| 15 | 25 | listTables, |
| 16 | 26 | openTable, |
| 17 | tableState, | |
| 18 | TITLE_MAX, | |
| 27 | type Seat, | |
| 19 | 28 | type Table, |
| 29 | TITLE_MAX, | |
| 20 | 30 | } from "./actions.ts"; |
| 21 | 31 | import { openDb } from "./db.ts"; |
| 22 | 32 | import { ssh, title, where } from "./env.ts"; |
| 23 | 33 | import { relative } from "./when.ts"; |
| 24 | import { whoAtTty } from "./who.ts"; | |
| 34 | import { type Who, whoAtTty } from "./who.ts"; | |
| 25 | 35 | |
| 26 | const db = openDb(); | |
| 27 | const me = whoAtTty(); | |
| 28 | const esc = "\x1b["; | |
| 29 | const dim = (s: string): string => `${esc}2m${s}${esc}0m`; | |
| 30 | const bold = (s: string): string => `${esc}1m${s}${esc}0m`; | |
| 31 | const out = (s: string): void => { | |
| 32 | process.stdout.write(s); | |
| 33 | }; | |
| 36 | // --------------------------------------------------------------- the ANSI | |
| 37 | // Every escape sequence this file knows, named once, so a screen is read as | |
| 38 | // text and a test asserts words rather than a soup of `\x1b[`. | |
| 34 | 39 | |
| 35 | let cursor = 0; | |
| 36 | let typing: string | undefined; | |
| 37 | let said = ""; | |
| 40 | /** Control Sequence Introducer: the two bytes every sequence below starts with. */ | |
| 41 | const CSI = "\x1b["; | |
| 38 | 42 | |
| 39 | const tables = (): Table[] => { | |
| 40 | const listed = listTables(db, me); | |
| 41 | return listed.ok ? listed.value : []; | |
| 42 | }; | |
| 43 | const dim = (s: string): string => `${CSI}2m${s}${CSI}0m`; | |
| 44 | const bold = (s: string): string => `${CSI}1m${s}${CSI}0m`; | |
| 45 | /** Reverse video — the block that stands in for a cursor while you type. */ | |
| 46 | const reverse = (s: string): string => `${CSI}7m${s}${CSI}0m`; | |
| 47 | /** Wipe the screen and put the cursor home: the start of every frame. */ | |
| 48 | const CLEAR = `${CSI}2J${CSI}H`; | |
| 49 | /** A terminal ends a line with both, because the pty is in raw mode. */ | |
| 50 | const CRLF = "\r\n"; | |
| 43 | 51 | |
| 44 | function draw(): void { | |
| 45 | const rows = tables(); | |
| 46 | cursor = Math.min(cursor, Math.max(0, rows.length - 1)); | |
| 47 | const lines = [ | |
| 48 | `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : `${me.name}${me.role === "admin" ? " (admin)" : ""}`)}`, | |
| 49 | ssh === undefined ? "" : dim(`connect: ${ssh}`), | |
| 50 | "", | |
| 52 | /** The keys the loop knows by name, rather than by their bytes. */ | |
| 53 | const KEY = { | |
| 54 | enter: "\r", | |
| 55 | escape: "\x1b", | |
| 56 | backspace: "\x7f", | |
| 57 | interrupt: "\x03", | |
| 58 | up: `${CSI}A`, | |
| 59 | down: `${CSI}B`, | |
| 60 | } as const; | |
| 61 | ||
| 62 | /** A screen with its styling taken off: what it says, which is what a test reads. */ | |
| 63 | export const plain = (s: string): string => | |
| 64 | s.replaceAll(/\x1b\[[0-9;]*[A-Za-z]/g, ""); | |
| 65 | ||
| 66 | /** | |
| 67 | * A value on its way onto the screen: one line of it, control characters | |
| 68 | * removed. A title or a name out of the database never moves the cursor, | |
| 69 | * whoever wrote it and whenever it was written. | |
| 70 | */ | |
| 71 | const text = (value: string): string => | |
| 72 | value.replaceAll(/[\u0000-\u001f\u007f]/g, ""); | |
| 73 | ||
| 74 | // ------------------------------------------------------------- the screen | |
| 75 | ||
| 76 | /** Everything one frame is drawn from. Plain data: no database, no environment. */ | |
| 77 | export interface State { | |
| 78 | /** Who is at the keyboard, as `whoAtTty()` read them. */ | |
| 79 | readonly who: Who; | |
| 80 | /** The lobby, as `listTables` returned it — already redacted for `who`. */ | |
| 81 | readonly tables: readonly Table[]; | |
| 82 | /** Which row is selected. Out of range is clamped, never thrown. */ | |
| 83 | readonly cursor: number; | |
| 84 | /** The table name being typed, or `undefined` when nobody is typing one. */ | |
| 85 | readonly typing?: string | undefined; | |
| 86 | /** The status line: an action's refusal, in the action's own words. */ | |
| 87 | readonly said?: string; | |
| 88 | /** The app's name, `FORGE_APP_ENV` in words, and `FORGE_APP_SSH` verbatim. */ | |
| 89 | readonly title: string; | |
| 90 | readonly where: string; | |
| 91 | readonly ssh?: string | undefined; | |
| 92 | /** Now, for "opened 20m ago". Handed in, so a screen is the same every time. */ | |
| 93 | readonly now: number; | |
| 94 | } | |
| 95 | ||
| 96 | /** A key the legend offers. `writes` is true when pressing it changes something. */ | |
| 97 | export interface Key { | |
| 98 | readonly key: string; | |
| 99 | readonly what: string; | |
| 100 | readonly writes: boolean; | |
| 101 | } | |
| 102 | ||
| 103 | const MOVE: Key = { key: "j/k", what: "move", writes: false }; | |
| 104 | const OPEN: Key = { key: "o", what: "open", writes: true }; | |
| 105 | const SIT: Key = { key: "s", what: "sit", writes: true }; | |
| 106 | const LEAVE: Key = { key: "x", what: "leave", writes: true }; | |
| 107 | const QUIT: Key = { key: "q", what: "quit", writes: false }; | |
| 108 | ||
| 109 | /** | |
| 110 | * What this viewer may press, at this table. The writing keys are exactly | |
| 111 | * the actions' own `canJoin` / `canLeave`, so the legend and the refusal | |
| 112 | * can never disagree — and a guest is offered nothing that writes. | |
| 113 | */ | |
| 114 | export function keys(who: Who, table: Table | undefined): readonly Key[] { | |
| 115 | if (who.role === "anon") return [MOVE, QUIT]; | |
| 116 | return [ | |
| 117 | MOVE, | |
| 118 | OPEN, | |
| 119 | ...(table !== undefined && canJoin(who, table) ? [SIT] : []), | |
| 120 | ...(table !== undefined && canLeave(who, table) ? [LEAVE] : []), | |
| 121 | QUIT, | |
| 51 | 122 | ]; |
| 123 | } | |
| 124 | ||
| 125 | /** The lines of one frame. */ | |
| 126 | export function screen(state: State): string[] { | |
| 127 | const rows = state.tables; | |
| 128 | const last = Math.max(0, rows.length - 1); | |
| 129 | const cursor = Math.min(Math.max(state.cursor, 0), last); | |
| 130 | const lines = [header(state), connection(state.ssh), ""]; | |
| 52 | 131 | if (rows.length === 0) |
| 53 | 132 | lines.push( |
| 54 | 133 | dim( |
| 55 | me.role === "anon" | |
| 134 | state.who.role === "anon" | |
| 56 | 135 | ? "No tables yet." |
| 57 | 136 | : "No tables yet. Press o to open one.", |
| 58 | 137 | ), |
| 59 | 138 | ); |
| 60 | 139 | rows.forEach((table, i) => { |
| 61 | 140 | const here = i === cursor; |
| 62 | const name = here ? bold(table.title) : table.title; | |
| 63 | lines.push( | |
| 64 | `${here ? "▸" : " "} ${name} ${dim(`${table.status} · ${table.seated}/${table.seats_max} seated · host ${table.host_name} · opened ${relative(table.created_at, Date.now())}${table.your_seat === null ? "" : " · you are seated"}`)}`, | |
| 65 | ); | |
| 141 | const name = here ? bold(text(table.title)) : text(table.title); | |
| 142 | lines.push(`${here ? "▸" : " "} ${name} ${dim(about(table, state.now))}`); | |
| 66 | 143 | }); |
| 67 | 144 | const here = rows[cursor]; |
| 68 | 145 | if (here !== undefined) { |
| @@ -70,85 +147,149 @@ function draw(): void { | ||
| 70 | 147 | lines.push(dim(` ${seatsOf(here)}`)); |
| 71 | 148 | } |
| 72 | 149 | lines.push(""); |
| 73 | if (typing !== undefined) | |
| 74 | lines.push(`Table name: ${typing}${esc}7m ${esc}0m`); | |
| 75 | else if (said !== "") lines.push(said); | |
| 76 | else lines.push(dim(keys(here))); | |
| 77 | out(`${esc}2J${esc}H${lines.join("\r\n")}`); | |
| 150 | lines.push(status(state, here)); | |
| 151 | return lines; | |
| 78 | 152 | } |
| 79 | 153 | |
| 154 | /** One frame as the bytes a terminal is sent: cleared, and CR LF between lines. */ | |
| 155 | export const frame = (lines: readonly string[]): string => | |
| 156 | `${CLEAR}${lines.join(CRLF)}`; | |
| 157 | ||
| 158 | const header = (state: State): string => | |
| 159 | `${bold(text(state.title))} ${dim(text(state.where))} ${dim(whose(state.who))}`; | |
| 160 | ||
| 161 | const whose = (who: Who): string => | |
| 162 | who.role === "anon" | |
| 163 | ? "guest" | |
| 164 | : `${text(who.name)}${who.role === "admin" ? " (admin)" : ""}`; | |
| 165 | ||
| 166 | /** | |
| 167 | * How to reach this terminal: the forge's own command, printed exactly as | |
| 168 | * it was given and never a guess at one. Unset, the door is not open. | |
| 169 | */ | |
| 170 | const connection = (ssh: string | undefined): string => | |
| 171 | ssh === undefined || ssh.trim() === "" | |
| 172 | ? dim("the terminal is off: FORGE_APP_SSH is not set") | |
| 173 | : dim(`connect: ${text(ssh)}`); | |
| 174 | ||
| 175 | const about = (table: Table, now: number): string => | |
| 176 | `${table.status} · ${table.seated}/${table.seats_max} seated · host ${text(table.host_name)} · opened ${relative(table.created_at, now)}${table.your_seat === null ? "" : " · you are seated"}`; | |
| 177 | ||
| 80 | 178 | /** The selected table's seats, and your own charge when there is one to see. */ |
| 81 | 179 | function seatsOf(table: Table): string { |
| 82 | const state = tableState(db, me, { id: table.id }); | |
| 83 | if (!state.ok) return state.message; | |
| 84 | const seats = state.value.seats; | |
| 85 | if (seats.length === 0) return "nobody seated yet"; | |
| 86 | return seats | |
| 87 | .map( | |
| 88 | (seat) => | |
| 89 | `${seat.ordinal + 1}. ${seat.player_name}${seat.charge === null ? "" : ` (your charge: ${JSON.stringify(seat.charge)})`}`, | |
| 90 | ) | |
| 91 | .join(" "); | |
| 180 | if (table.seats.length === 0) return "nobody seated yet"; | |
| 181 | return table.seats.map(seatLine).join(" "); | |
| 92 | 182 | } |
| 93 | 183 | |
| 94 | const keys = (table: Table | undefined): string => { | |
| 95 | if (me.role === "anon") | |
| 96 | return "j/k move · q quit · sign in with a key the forge knows to play"; | |
| 97 | const can = [ | |
| 98 | "j/k move", | |
| 99 | "o open", | |
| 100 | ...(table !== undefined && canJoin(me, table) ? ["s sit"] : []), | |
| 101 | ...(table !== undefined && canLeave(me, table) ? ["x leave"] : []), | |
| 102 | "q quit", | |
| 103 | ]; | |
| 104 | return can.join(" · "); | |
| 184 | const seatLine = (seat: Seat): string => | |
| 185 | `${seat.ordinal + 1}. ${text(seat.player_name)}${seat.charge === null ? "" : ` (your charge: ${JSON.stringify(seat.charge)})`}`; | |
| 186 | ||
| 187 | /** The last line: what you are typing, what an action said, or what you may press. */ | |
| 188 | function status(state: State, here: Table | undefined): string { | |
| 189 | if (state.typing !== undefined) | |
| 190 | return `Table name: ${text(state.typing)}${reverse(" ")}`; | |
| 191 | if (state.said !== undefined && state.said !== "") return state.said; | |
| 192 | return dim(legend(state.who, here)); | |
| 193 | } | |
| 194 | ||
| 195 | /** The key legend, as one line. */ | |
| 196 | const legend = (who: Who, table: Table | undefined): string => { | |
| 197 | const offered = keys(who, table) | |
| 198 | .map((key) => `${key.key} ${key.what}`) | |
| 199 | .join(" · "); | |
| 200 | return who.role === "anon" | |
| 201 | ? `${offered} · sign in with a key the forge knows to play` | |
| 202 | : offered; | |
| 105 | 203 | }; |
| 106 | 204 | |
| 107 | process.stdin.setRawMode?.(true); | |
| 108 | process.stdin.resume(); | |
| 109 | process.stdin.setEncoding("utf8"); | |
| 110 | draw(); | |
| 111 | process.stdin.on("data", (key: string) => { | |
| 112 | said = ""; | |
| 113 | if (typing !== undefined) { | |
| 114 | if (key === "\r") { | |
| 115 | const opened = openTable(db, me, { title: typing }); | |
| 116 | if (!opened.ok) said = opened.message; | |
| 117 | typing = undefined; | |
| 118 | cursor = 0; | |
| 119 | } else if (key === "\x1b") typing = undefined; | |
| 120 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 121 | else if (key >= " " && typing.length < TITLE_MAX) typing += key; | |
| 122 | draw(); | |
| 123 | return; | |
| 124 | } | |
| 125 | if (key === "q" || key === "\x03") { | |
| 126 | out(`${esc}2J${esc}H`); | |
| 127 | process.exit(0); | |
| 128 | } | |
| 129 | const rows = tables(); | |
| 130 | if (key === "j" || key === "\x1b[B") | |
| 131 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 132 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 133 | if (key === "o") { | |
| 134 | if (me.role === "anon") said = "Sign in to open a table."; | |
| 135 | else typing = ""; | |
| 136 | } | |
| 137 | const here = rows[cursor]; | |
| 138 | if (key === "s") { | |
| 139 | if (here === undefined) said = "No table to sit at."; | |
| 140 | else { | |
| 141 | const joined = joinTable(db, me, { id: here.id }); | |
| 142 | if (!joined.ok) said = joined.message; | |
| 143 | } | |
| 144 | } | |
| 145 | if (key === "x") { | |
| 146 | if (here === undefined) said = "No table to leave."; | |
| 147 | else { | |
| 148 | const left = leaveTable(db, me, { id: here.id }); | |
| 149 | if (!left.ok) said = left.message; | |
| 150 | } | |
| 151 | } | |
| 205 | // --------------------------------------------------------------- the loop | |
| 206 | ||
| 207 | /** The terminal itself: the database, the keyboard, the environment, stdout. */ | |
| 208 | export function run(): void { | |
| 209 | const db = openDb(); | |
| 210 | const me = whoAtTty(); | |
| 211 | let cursor = 0; | |
| 212 | let typing: string | undefined; | |
| 213 | let said = ""; | |
| 214 | ||
| 215 | const tables = (): Table[] => { | |
| 216 | const listed = listTables(db, me); | |
| 217 | return listed.ok ? listed.value : []; | |
| 218 | }; | |
| 219 | ||
| 220 | const draw = (): void => { | |
| 221 | const rows = tables(); | |
| 222 | cursor = Math.min(cursor, Math.max(0, rows.length - 1)); | |
| 223 | process.stdout.write( | |
| 224 | frame( | |
| 225 | screen({ | |
| 226 | who: me, | |
| 227 | tables: rows, | |
| 228 | cursor, | |
| 229 | typing, | |
| 230 | said, | |
| 231 | title, | |
| 232 | where, | |
| 233 | ssh, | |
| 234 | now: Date.now(), | |
| 235 | }), | |
| 236 | ), | |
| 237 | ); | |
| 238 | }; | |
| 239 | ||
| 240 | process.stdin.setRawMode?.(true); | |
| 241 | process.stdin.resume(); | |
| 242 | process.stdin.setEncoding("utf8"); | |
| 152 | 243 | draw(); |
| 153 | }); | |
| 154 | process.stdout.on("resize", draw); | |
| 244 | process.stdin.on("data", (key: string) => { | |
| 245 | said = ""; | |
| 246 | if (typing !== undefined) { | |
| 247 | if (key === KEY.enter) { | |
| 248 | const opened = openTable(db, me, { title: typing }); | |
| 249 | if (!opened.ok) said = opened.message; | |
| 250 | typing = undefined; | |
| 251 | cursor = 0; | |
| 252 | } else if (key === KEY.escape) typing = undefined; | |
| 253 | else if (key === KEY.backspace) typing = typing.slice(0, -1); | |
| 254 | else if (key >= " " && typing.length < TITLE_MAX) typing += key; | |
| 255 | draw(); | |
| 256 | return; | |
| 257 | } | |
| 258 | if (key === "q" || key === KEY.interrupt) { | |
| 259 | process.stdout.write(CLEAR); | |
| 260 | process.exit(0); | |
| 261 | } | |
| 262 | const rows = tables(); | |
| 263 | if (key === "j" || key === KEY.down) | |
| 264 | cursor = Math.min(cursor + 1, Math.max(0, rows.length - 1)); | |
| 265 | if (key === "k" || key === KEY.up) cursor = Math.max(cursor - 1, 0); | |
| 266 | if (key === "o") { | |
| 267 | // Whether a guest may open one is the action's to say, and so are the | |
| 268 | // words it says it in: ask it with nothing, and print what comes back. | |
| 269 | if (me.role !== "anon") typing = ""; | |
| 270 | else { | |
| 271 | const refused = openTable(db, me, { title: "" }); | |
| 272 | if (!refused.ok) said = refused.message; | |
| 273 | } | |
| 274 | } | |
| 275 | const here = rows[cursor]; | |
| 276 | if (key === "s") { | |
| 277 | if (here === undefined) said = "No table to sit at."; | |
| 278 | else { | |
| 279 | const joined = joinTable(db, me, { id: here.id }); | |
| 280 | if (!joined.ok) said = joined.message; | |
| 281 | } | |
| 282 | } | |
| 283 | if (key === "x") { | |
| 284 | if (here === undefined) said = "No table to leave."; | |
| 285 | else { | |
| 286 | const left = leaveTable(db, me, { id: here.id }); | |
| 287 | if (!left.ok) said = left.message; | |
| 288 | } | |
| 289 | } | |
| 290 | draw(); | |
| 291 | }); | |
| 292 | process.stdout.on("resize", draw); | |
| 293 | } | |
| 294 | ||
| 295 | if (import.meta.main) run(); |
new above
offline