/** * val, in a terminal. The forge runs this on a pty when somebody ssh-es to * the app; it takes a seat over the same socket the browser uses, so what a * person can do on the page they can do here: walk, shoot, cast, plant, * defuse and buy. * * The map is the same grid, one character a tile. Plain ANSI, no dependencies. */ import { choosePrompt, REACH } from "../static/prompt.js"; const base = process.env.VAL_URL ?? (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ?? process.env.FORGE_APP_URL ?? "http://127.0.0.1:8080"; const esc = "\x1b["; const out = (s: string): void => void process.stdout.write(s); type Snapshot = { phase: string; clock: number; round: number; score: { red: number; blue: number }; attackers: string; result: string | null; me: Record; players: Record[]; zones: { k: string; x: number; y: number; r: number }[]; spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null }; board: Record[]; events: string[]; }; let map: { rows: string[]; tile: number } | null = null; let snap: Snapshot | null = null; let socket: WebSocket | null = null; let status = "connecting"; let aim = -Math.PI / 2; let move = { mx: 0, my: 0, until: 0 }; let firing = 0; let helping = false; let using = 0; let buying = false; const send = (message: unknown): void => { if (socket && socket.readyState === 1) socket.send(JSON.stringify(message)); }; async function connect(): Promise { try { const page = await fetch(base + "/"); const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number }; const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", { headers: { cookie, origin: new URL(base).origin }, } as unknown as string[]); socket = ws; ws.onopen = () => (status = "live"); ws.onclose = () => { status = "reconnecting"; socket = null; setTimeout(connect, 2000); }; ws.onmessage = (event) => { const next = JSON.parse(String(event.data)) as Snapshot; // The scoreboard comes only when it changes; keep the last one otherwise. if (next.board === undefined) next.board = snap?.board ?? []; snap = next; }; } catch (error) { status = `cannot reach ${base}`; setTimeout(connect, 3000); } } const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`; /** The same line the page shows, from the same function. */ function prompt(s: Snapshot): string { const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : ""; const cell = row ? row[Math.floor(s.me.x / map.tile)] : ""; return choosePrompt(s, { input: "keys", onSite: cell === "A" || cell === "B", nearSpike: s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH, }); } const HOW_TO = [ "How to play — attackers carry the spike to site A or B and plant it (hold f);", "defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.", "· wasd walks, ←/→ turns the aim, space fires, r reloads", "· c q e x cast the kit: c and q are bought in the buy phase, e is free on a cooldown, x is the ultimate", "· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them", "· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see", "h closes this.", ]; function draw(): void { if (!map) { out(`${esc}2J${esc}Hval — ${status}\r\n`); return; } 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) : " "))); const put = (x: number, y: number, ch: string): void => { const col = Math.floor(x / map!.tile); const row = Math.floor(y / map!.tile); if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch; }; const lines: string[] = []; if (snap) { 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)); if (snap.spike.x !== null && snap.spike.y !== null) put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33)); for (const player of snap.players) { if (!player.alive) continue; const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31); put(player.x, player.y, mark); } const me = snap.me; const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase; lines.push( `${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}]`, ); lines.push( `${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)}°`, ); lines.push(colour("› " + prompt(snap), 93)); } else lines.push(`val — ${status}`); lines.push(""); for (const row of rows) lines.push(" " + row.join("")); lines.push(""); if (snap) { for (const side of ["atk", "def"] as const) { const team = snap.board .filter((p) => p.side === side) .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`) .join(" "); lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team); } lines.push(""); for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90)); } lines.push(""); if (helping) for (const line of HOW_TO) lines.push(colour(line, 96)); lines.push( buying ? 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) : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · h how to play · q quit", 90), ); out(`${esc}H${esc}J` + lines.join("\r\n")); } const BUYS: Record = { "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator", "8": "light", "9": "heavy", }; process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); process.stdin.on("data", (key: string) => { const now = Date.now(); if (key === "\x03" || (key === "q" && !buying)) { out(`${esc}2J${esc}H`); process.exit(0); } if (buying) { if (BUYS[key]) send({ t: "buy", what: BUYS[key] }); else if (key === "c" || key === "q") send({ t: "buy", what: key }); else if (key === "b" || key === "\x1b") buying = false; return; } if (key === "b") return void (buying = true); if (key === "h" || key === "?") return void (helping = !helping); if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 }; if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 }; if (key === "a") move = { mx: -1, my: move.my, until: now + 400 }; if (key === "d") move = { mx: 1, my: move.my, until: now + 400 }; if (key === "\x1b[D") aim -= Math.PI / 12; if (key === "\x1b[C") aim += Math.PI / 12; if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 }; if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 }; if (key === " ") firing = now + 220; if (key === "f") using = now + 700; if (key === "r") send({ t: "i", r: true }); if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key }); }); // Twenty times a second, the same intent the browser sends. setInterval(() => { const now = Date.now(); if (now > move.until) move = { mx: 0, my: 0, until: 0 }; send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false }); }, 50); setInterval(draw, 120); process.stdout.on("resize", draw); out(`${esc}2J`); draw(); void connect();