fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Play from the terminal, and say what is real in the README
src/tty.ts takes a seat over the same socket the browser uses and draws the grid, the scoreboard and the round in ANSI: walk, shoot, cast, plant, defuse and buy without leaving the pty. It finds the app at VAL_URL, PORT or FORGE_APP_URL, in that order. Co-Authored-By: Claude Opus 5 <[email protected]>
2 files
changed +223
−67
README.md +69 −13
| @@ -1,23 +1,79 @@ | ||
| 1 | 1 | # val |
| 2 | 2 | |
| 3 | Five attackers with the spike, five defenders, and abilities on cooldown. First to 13. | |
| 3 | Five attackers with the spike, five defenders, abilities on cooldown. First to 13. | |
| 4 | 4 | |
| 5 | This is an app: the forge runs it. Production follows the default branch; | |
| 6 | every open pull gets a preview at its own address with its own copy of the | |
| 7 | data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what | |
| 8 | makes "production data" and "preview data" two different things. | |
| 5 | A top-down 5v5 played in a browser. One match is always running on the | |
| 6 | server: open the page and you are given a seat in the round that is | |
| 7 | happening, taken from a bot where it stands. When a match ends it shows the | |
| 8 | result for a few seconds and starts a new one, so nobody ever arrives at a | |
| 9 | frozen board. | |
| 10 | ||
| 11 | ## What is real | |
| 12 | ||
| 13 | Everything that decides a round is computed on the server and only the | |
| 14 | server: | |
| 15 | ||
| 16 | - **Movement, shooting and hit detection.** The client sends intent — a | |
| 17 | direction, an angle, a held key — never a position. Damage is by hit | |
| 18 | location (how close the ray passes the target) with falloff and armour: | |
| 19 | a Vandal head shot kills through full armour at any range, a Phantom's | |
| 20 | does not past fifteen metres. | |
| 21 | - **Line of sight.** A snapshot only carries the enemies that viewer can | |
| 22 | see. A smoke blocks that sight line, a flash blinds by the angle you were | |
| 23 | facing, and a blinded player is sent no enemies at all. A patched client | |
| 24 | learns nothing a plain one does not. | |
| 25 | - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a | |
| 26 | checkpoint at 3.5s — a defender killed past half way leaves the defuse | |
| 27 | half done for the next one. | |
| 28 | - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a | |
| 29 | plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is | |
| 30 | refused server-side. | |
| 31 | - **Bots** fill every empty seat and use their kit: smokes on the way in, | |
| 32 | flashes before the entry, a recon dart, a molly on the planted spike. | |
| 33 | ||
| 34 | Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits | |
| 35 | (charges last the round), a free signature on a cooldown, and an ultimate | |
| 36 | paid for with ult points from kills, plants and the two orbs on the map. | |
| 37 | ||
| 38 | Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | |
| 39 | ||
| 40 | ## What is not | |
| 41 | ||
| 42 | - One map, one match at a time, ten seats. There is no matchmaking, no | |
| 43 | ranking and no account: your seat belongs to a session cookie. | |
| 44 | - No overtime — first to 13 ends it at 12–12 too. | |
| 45 | - Sight is a full circle rather than a view cone; top-down has no vertical, | |
| 46 | so the hit location comes from how precisely the shot passes the target. | |
| 47 | - Assists are counted as a field but never awarded. | |
| 48 | - The live match lives in memory and is gone if the process restarts; only | |
| 49 | finished match results are written to SQLite under `FORGE_APP_DATA`. | |
| 50 | ||
| 51 | ## Playing | |
| 52 | ||
| 53 | WASD move, the mouse aims, click fires, **R** reloads, **F** plants and | |
| 54 | defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone | |
| 55 | the left thumb walks and the right thumb aims and fires. | |
| 9 | 56 | |
| 10 | 57 | sh |
| 11 | 58 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 12 | 59 | bun test # the check every pull runs |
| 13 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | |
| 60 | VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts # the terminal interface | |
| 14 | 61 | |
| 15 | 62 | |
| 16 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app | |
| 17 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is | |
| 18 | the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on | |
| 19 | a real pty over the same data. | |
| 63 | The terminal plays the same match over the same socket: it draws the grid, | |
| 64 | the scoreboard and the round, and takes a seat. The exact command that | |
| 65 | reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it. | |
| 20 | 66 | |
| 21 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` | |
| 22 | (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT` | |
| 23 | when it starts the app. `forge.json` says how. | |
| 67 | The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`, | |
| 68 | `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json` | |
| 69 | says how. | |
| 70 | ||
| 71 | ## Shape | |
| 72 | ||
| 73 | `src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table · | |
| 74 | `src/economy.ts` credits · `src/abilities.ts` the agent kits · | |
| 75 | `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes | |
| 76 | no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one | |
| 77 | live match and its sockets · `src/server.ts` the door · `static/val.js` the | |
| 78 | browser. The rules are pure functions and the tests play whole matches | |
| 79 | headlessly. |
src/tty.ts +154 −54
| @@ -1,74 +1,174 @@ | ||
| 1 | 1 | /** |
| 2 | * val, in a terminal. The forge runs this on a pty when someone | |
| 3 | * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the | |
| 4 | * web page uses. j/k move, a adds a note, d deletes the one under the | |
| 5 | * cursor, q leaves. Plain ANSI, no dependencies. | |
| 2 | * val, in a terminal. The forge runs this on a pty when somebody ssh-es to | |
| 3 | * the app; it takes a seat over the same socket the browser uses, so what a | |
| 4 | * person can do on the page they can do here: walk, shoot, cast, plant, | |
| 5 | * defuse and buy. | |
| 6 | * | |
| 7 | * The map is the same grid, one character a tile. Plain ANSI, no dependencies. | |
| 6 | 8 | */ |
| 7 | 9 | |
| 8 | import { Database } from "bun:sqlite"; | |
| 9 | import { mkdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 10 | const base = | |
| 11 | process.env.VAL_URL ?? | |
| 12 | (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? | |
| 13 | process.env.FORGE_APP_URL ?? | |
| 14 | "http://127.0.0.1:8080"; | |
| 11 | 15 | |
| 12 | const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 13 | mkdirSync(dataDir, { recursive: true }); | |
| 14 | const db = new Database(join(dataDir, "app.db")); | |
| 15 | db.exec("PRAGMA journal_mode = WAL"); | |
| 16 | db.exec( | |
| 17 | "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))", | |
| 18 | ); | |
| 19 | const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local"); | |
| 20 | ||
| 21 | type Note = { id: number; body: string; created_at: string }; | |
| 22 | const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all(); | |
| 23 | ||
| 24 | let cursor = 0; | |
| 25 | let typing: string | undefined; | |
| 26 | const out = (s: string): void => { process.stdout.write(s); }; | |
| 27 | 16 | const esc = "\x1b["; |
| 17 | const out = (s: string): void => void process.stdout.write(s); | |
| 18 | ||
| 19 | type Snapshot = { | |
| 20 | phase: string; | |
| 21 | clock: number; | |
| 22 | round: number; | |
| 23 | score: { red: number; blue: number }; | |
| 24 | attackers: string; | |
| 25 | result: string | null; | |
| 26 | me: Record<string, any>; | |
| 27 | players: Record<string, any>[]; | |
| 28 | zones: { k: string; x: number; y: number; r: number }[]; | |
| 29 | spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null }; | |
| 30 | board: Record<string, any>[]; | |
| 31 | events: string[]; | |
| 32 | }; | |
| 33 | ||
| 34 | let map: { rows: string[]; tile: number } | null = null; | |
| 35 | let snap: Snapshot | null = null; | |
| 36 | let socket: WebSocket | null = null; | |
| 37 | let status = "connecting"; | |
| 38 | let aim = -Math.PI / 2; | |
| 39 | let move = { mx: 0, my: 0, until: 0 }; | |
| 40 | let firing = 0; | |
| 41 | let using = 0; | |
| 42 | let buying = false; | |
| 43 | ||
| 44 | const send = (message: unknown): void => { | |
| 45 | if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); | |
| 46 | }; | |
| 47 | ||
| 48 | async function connect(): Promise<void> { | |
| 49 | try { | |
| 50 | const page = await fetch(base + "/"); | |
| 51 | const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 52 | map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number }; | |
| 53 | const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", { | |
| 54 | headers: { cookie, origin: new URL(base).origin }, | |
| 55 | } as unknown as string[]); | |
| 56 | socket = ws; | |
| 57 | ws.onopen = () => (status = "live"); | |
| 58 | ws.onclose = () => { | |
| 59 | status = "reconnecting"; | |
| 60 | socket = null; | |
| 61 | setTimeout(connect, 2000); | |
| 62 | }; | |
| 63 | ws.onmessage = (event) => { | |
| 64 | snap = JSON.parse(String(event.data)) as Snapshot; | |
| 65 | }; | |
| 66 | } catch (error) { | |
| 67 | status = `cannot reach ${base}`; | |
| 68 | setTimeout(connect, 3000); | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; | |
| 28 | 73 | |
| 29 | 74 | function draw(): void { |
| 30 | const rows = notes(); | |
| 75 | if (!map) { | |
| 76 | out(`${esc}2J${esc}Hval — ${status}\r\n`); | |
| 77 | return; | |
| 78 | } | |
| 79 | const rows = map.rows.map((row) => row.split("").map((c) => (c === "#" ? colour("▓", 90) : c === "A" || c === "B" ? colour(c, 33) : c === "a" || c === "d" ? colour("·", 90) : " "))); | |
| 80 | const put = (x: number, y: number, ch: string): void => { | |
| 81 | const col = Math.floor(x / map!.tile); | |
| 82 | const row = Math.floor(y / map!.tile); | |
| 83 | if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch; | |
| 84 | }; | |
| 85 | ||
| 31 | 86 | const lines: string[] = []; |
| 32 | lines.push(`${esc}1mval${esc}0m ${esc}2m${where}${esc}0m`); | |
| 87 | if (snap) { | |
| 88 | for (const zone of snap.zones) put(zone.x, zone.y, colour(zone.k === "smoke" ? "▒" : zone.k === "molly" ? "≈" : zone.k === "wall" ? "█" : "*", zone.k === "molly" ? 31 : 37)); | |
| 89 | if (snap.spike.x !== null && snap.spike.y !== null) | |
| 90 | put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33)); | |
| 91 | for (const player of snap.players) { | |
| 92 | if (!player.alive) continue; | |
| 93 | const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31); | |
| 94 | put(player.x, player.y, mark); | |
| 95 | } | |
| 96 | const me = snap.me; | |
| 97 | const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase; | |
| 98 | lines.push( | |
| 99 | `${colour("val", 1)} ${colour("red " + snap.score.red, 31)} — ${colour(snap.score.blue + " blue", 36)} round ${snap.round} ${phase} ${Math.ceil(snap.clock)}s ${snap.attackers} attacking [${status}]`, | |
| 100 | ); | |
| 101 | lines.push( | |
| 102 | `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`, | |
| 103 | ); | |
| 104 | } else lines.push(`val — ${status}`); | |
| 33 | 105 | lines.push(""); |
| 34 | if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`); | |
| 35 | rows.forEach((n, i) => { | |
| 36 | const mark = i === cursor ? "▸" : " "; | |
| 37 | const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body; | |
| 38 | lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`); | |
| 39 | }); | |
| 106 | for (const row of rows) lines.push(" " + row.join("")); | |
| 40 | 107 | lines.push(""); |
| 41 | lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`); | |
| 42 | out(`${esc}2J${esc}H` + lines.join("\r\n")); | |
| 108 | if (snap) { | |
| 109 | for (const side of ["atk", "def"] as const) { | |
| 110 | const team = snap.board | |
| 111 | .filter((p) => p.side === side) | |
| 112 | .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`) | |
| 113 | .join(" "); | |
| 114 | lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team); | |
| 115 | } | |
| 116 | lines.push(""); | |
| 117 | for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); | |
| 118 | } | |
| 119 | lines.push(""); | |
| 120 | lines.push( | |
| 121 | buying | |
| 122 | ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33) | |
| 123 | : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90), | |
| 124 | ); | |
| 125 | out(`${esc}H${esc}J` + lines.join("\r\n")); | |
| 43 | 126 | } |
| 44 | 127 | |
| 128 | const BUYS: Record<string, string> = { | |
| 129 | "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator", | |
| 130 | "8": "light", "9": "heavy", | |
| 131 | }; | |
| 132 | ||
| 45 | 133 | process.stdin.setRawMode?.(true); |
| 46 | 134 | process.stdin.resume(); |
| 47 | 135 | process.stdin.setEncoding("utf8"); |
| 48 | draw(); | |
| 49 | 136 | process.stdin.on("data", (key: string) => { |
| 50 | if (typing !== undefined) { | |
| 51 | if (key === "\r") { | |
| 52 | const body = typing.trim(); | |
| 53 | if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500)); | |
| 54 | typing = undefined; | |
| 55 | cursor = 0; | |
| 56 | } else if (key === "\x1b") typing = undefined; | |
| 57 | else if (key === "\x7f") typing = typing.slice(0, -1); | |
| 58 | else if (key >= " ") typing += key; | |
| 59 | draw(); | |
| 137 | const now = Date.now(); | |
| 138 | if (key === "\x03" || (key === "q" && !buying)) { | |
| 139 | out(`${esc}2J${esc}H`); | |
| 140 | process.exit(0); | |
| 141 | } | |
| 142 | if (buying) { | |
| 143 | if (BUYS[key]) send({ t: "buy", what: BUYS[key] }); | |
| 144 | else if (key === "c" || key === "q") send({ t: "buy", what: key }); | |
| 145 | else if (key === "b" || key === "\x1b") buying = false; | |
| 60 | 146 | return; |
| 61 | 147 | } |
| 62 | if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } | |
| 63 | const count = notes().length; | |
| 64 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); | |
| 65 | if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); | |
| 66 | if (key === "a") typing = ""; | |
| 67 | if (key === "d") { | |
| 68 | const n = notes()[cursor]; | |
| 69 | if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id); | |
| 70 | cursor = Math.min(cursor, Math.max(0, notes().length - 1)); | |
| 71 | } | |
| 72 | draw(); | |
| 148 | if (key === "b") return void (buying = true); | |
| 149 | if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; | |
| 150 | if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; | |
| 151 | if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; | |
| 152 | if (key === "d") move = { mx: 1, my: move.my, until: now + 400 }; | |
| 153 | if (key === "\x1b[D") aim -= Math.PI / 12; | |
| 154 | if (key === "\x1b[C") aim += Math.PI / 12; | |
| 155 | if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 }; | |
| 156 | if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 }; | |
| 157 | if (key === " ") firing = now + 220; | |
| 158 | if (key === "f") using = now + 700; | |
| 159 | if (key === "r") send({ t: "i", r: true }); | |
| 160 | if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key }); | |
| 73 | 161 | }); |
| 162 | ||
| 163 | // Twenty times a second, the same intent the browser sends. | |
| 164 | setInterval(() => { | |
| 165 | const now = Date.now(); | |
| 166 | if (now > move.until) move = { mx: 0, my: 0, until: 0 }; | |
| 167 | send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false }); | |
| 168 | }, 50); | |
| 169 | setInterval(draw, 120); | |
| 74 | 170 | process.stdout.on("resize", draw); |
| 171 | ||
| 172 | out(`${esc}2J`); | |
| 173 | draw(); | |
| 174 | void connect(); |