fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files 53c9cd79f21c24c95995672b7a3113c152df085c
1
/**
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.
8
 */
9
10
import { choosePrompt, REACH } from "../static/prompt.js";
11
12
const base =
13
  process.env.VAL_URL ??
14
  (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ??
15
  process.env.FORGE_APP_URL ??
16
  "http://127.0.0.1:8080";
17
18
const esc = "\x1b[";
19
const out = (s: string): void => void process.stdout.write(s);
20
21
type Snapshot = {
22
  phase: string;
23
  clock: number;
24
  round: number;
25
  score: { red: number; blue: number };
26
  attackers: string;
27
  result: string | null;
28
  me: Record<string, any>;
29
  players: Record<string, any>[];
30
  zones: { k: string; x: number; y: number; r: number }[];
31
  spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null };
32
  board: Record<string, any>[];
33
  events: string[];
34
};
35
36
let map: { rows: string[]; tile: number } | null = null;
37
let snap: Snapshot | null = null;
38
let socket: WebSocket | null = null;
39
let status = "connecting";
40
let aim = -Math.PI / 2;
41
let move = { mx: 0, my: 0, until: 0 };
42
let firing = 0;
43
let helping = false;
44
let using = 0;
45
let buying = false;
46
47
const send = (message: unknown): void => {
48
  if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
49
};
50
51
async function connect(): Promise<void> {
52
  try {
53
    const page = await fetch(base + "/");
54
    const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
55
    map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number };
56
    const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", {
57
      headers: { cookie, origin: new URL(base).origin },
58
    } as unknown as string[]);
59
    socket = ws;
60
    ws.onopen = () => (status = "live");
61
    ws.onclose = () => {
62
      status = "reconnecting";
63
      socket = null;
64
      setTimeout(connect, 2000);
65
    };
66
    ws.onmessage = (event) => {
67
      const next = JSON.parse(String(event.data)) as Snapshot;
68
      // The scoreboard comes only when it changes; keep the last one otherwise.
69
      if (next.board === undefined) next.board = snap?.board ?? [];
70
      snap = next;
71
    };
72
  } catch (error) {
73
    status = `cannot reach ${base}`;
74
    setTimeout(connect, 3000);
75
  }
76
}
77
78
const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`;
79
80
/** The same line the page shows, from the same function. */
81
function prompt(s: Snapshot): string {
82
  const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : "";
83
  const cell = row ? row[Math.floor(s.me.x / map.tile)] : "";
84
  return choosePrompt(s, {
85
    input: "keys",
86
    onSite: cell === "A" || cell === "B",
87
    nearSpike:
88
      s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH,
89
  });
90
}
91
92
const HOW_TO = [
93
  "How to play — attackers carry the spike to site A or B and plant it (hold f);",
94
  "defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.",
95
  "· wasd walks, ←/→ turns the aim, space fires, r reloads",
96
  "· 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",
97
  "· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them",
98
  "· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see",
99
  "h closes this.",
100
];
101
102
function draw(): void {
103
  if (!map) {
104
    out(`${esc}2J${esc}Hval — ${status}\r\n`);
105
    return;
106
  }
107
  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) : " ")));
108
  const put = (x: number, y: number, ch: string): void => {
109
    const col = Math.floor(x / map!.tile);
110
    const row = Math.floor(y / map!.tile);
111
    if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch;
112
  };
113
114
  const lines: string[] = [];
115
  if (snap) {
116
    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));
117
    if (snap.spike.x !== null && snap.spike.y !== null)
118
      put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33));
119
    for (const player of snap.players) {
120
      if (!player.alive) continue;
121
      const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31);
122
      put(player.x, player.y, mark);
123
    }
124
    const me = snap.me;
125
    const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase;
126
    lines.push(
127
      `${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}]`,
128
    );
129
    lines.push(
130
      `${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)}°`,
131
    );
132
    lines.push(colour("› " + prompt(snap), 93));
133
  } else lines.push(`val — ${status}`);
134
  lines.push("");
135
  for (const row of rows) lines.push(" " + row.join(""));
136
  lines.push("");
137
  if (snap) {
138
    for (const side of ["atk", "def"] as const) {
139
      const team = snap.board
140
        .filter((p) => p.side === side)
141
        .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`)
142
        .join("  ");
143
      lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team);
144
    }
145
    lines.push("");
146
    for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90));
147
  }
148
  lines.push("");
149
  if (helping) for (const line of HOW_TO) lines.push(colour(line, 96));
150
  lines.push(
151
    buying
152
      ? 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)
153
      : 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),
154
  );
155
  out(`${esc}H${esc}J` + lines.join("\r\n"));
156
}
157
158
const BUYS: Record<string, string> = {
159
  "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator",
160
  "8": "light", "9": "heavy",
161
};
162
163
process.stdin.setRawMode?.(true);
164
process.stdin.resume();
165
process.stdin.setEncoding("utf8");
166
process.stdin.on("data", (key: string) => {
167
  const now = Date.now();
168
  if (key === "\x03" || (key === "q" && !buying)) {
169
    out(`${esc}2J${esc}H`);
170
    process.exit(0);
171
  }
172
  if (buying) {
173
    if (BUYS[key]) send({ t: "buy", what: BUYS[key] });
174
    else if (key === "c" || key === "q") send({ t: "buy", what: key });
175
    else if (key === "b" || key === "\x1b") buying = false;
176
    return;
177
  }
178
  if (key === "b") return void (buying = true);
179
  if (key === "h" || key === "?") return void (helping = !helping);
180
  if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 };
181
  if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 };
182
  if (key === "a") move = { mx: -1, my: move.my, until: now + 400 };
183
  if (key === "d") move = { mx: 1, my: move.my, until: now + 400 };
184
  if (key === "\x1b[D") aim -= Math.PI / 12;
185
  if (key === "\x1b[C") aim += Math.PI / 12;
186
  if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 };
187
  if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 };
188
  if (key === " ") firing = now + 220;
189
  if (key === "f") using = now + 700;
190
  if (key === "r") send({ t: "i", r: true });
191
  if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key });
192
});
193
194
// Twenty times a second, the same intent the browser sends.
195
setInterval(() => {
196
  const now = Date.now();
197
  if (now > move.until) move = { mx: 0, my: 0, until: 0 };
198
  send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false });
199
}, 50);
200
setInterval(draw, 120);
201
process.stdout.on("resize", draw);
202
203
out(`${esc}2J`);
204
draw();
205
void connect();
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts