|
|
@@ -1,74 +1,174 @@ |
| 1 | 1 |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 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 |
|
| 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(); |