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

forked from benjivers/val

workbenchthis machine

Serve the match: sockets, per-viewer fog of war, and a browser to play it in

The arena runs one match on a 30Hz tick whether anyone is watching or not,
so a visitor lands in a live round rather than a lobby. Taking a seat
replaces a bot where it stands — same credits, same position — and leaving
hands it back, which is what makes joining mid-round work.

Every snapshot is built for one viewer: an enemy without a sight line is
not in the frame at all, so the client cannot draw what the server did not
let it see. Smokes and blindness are part of that answer.

The page paints the live score, round and both team sheets as HTML before
any script runs; the client is one file under static/, named by its own
hash, and speaks the same intent a bot does.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed a07c6a6 Browse files
8 files changed +1160 −102
src/abilities.test.ts +5 −4
@@ -119,10 +119,11 @@ test("a wall is solid: it blocks the sight line that was open", () => {
119119 const state = live();
120120 const player = attacker(state);
121121 player.agent = "rook";
122 player.x = 1200;
123 player.y = 1320;
124 player.angle = -Math.PI / 2;
125 const behind = { x: 1200, y: 1320 - 600 };
122 // Along the back corridor, which is open from one side of the map to the other.
123 player.x = 400;
124 player.y = 1400;
125 player.angle = 0;
126 const behind = { x: 1000, y: 1400 };
126127 const openBefore = !sightBlocked(player, behind, state.zones);
127128 expect(cast(state, player, "e")).toBe(true);
128129 expect(openBefore).toBe(true);
src/arena.ts added +256
@@ -0,0 +1,256 @@
1 /**
2 * The arena: one match, always running, and the seats people sit in.
3 *
4 * The match ticks whether anybody is watching or not, so a visitor never
5 * arrives at a frozen board — they get the round that is happening. Taking a
6 * seat replaces a bot in place: same body, same credits, same position, so
7 * joining mid-round hands you a live player rather than a lobby.
8 *
9 * Every snapshot is built for one viewer: enemies they cannot see are not in
10 * it. The client never learns a position it has no line of sight to.
11 */
12
13 import { agentById, sightBlocked } from "./abilities.ts";
14 import { runBots } from "./bots.ts";
15 import { recordResult } from "./db.ts";
16 import type { Point } from "./map.ts";
17 import {
18 buyAbility,
19 buyShield,
20 buyWeapon,
21 newMatch,
22 pickAgent,
23 setInput,
24 tick,
25 visible,
26 } from "./match.ts";
27 import type { MatchState, Player } from "./state.ts";
28 import { isWeaponId } from "./weapons.ts";
29
30 export const TICK = 1 / 30;
31 const BROADCAST_EVERY = 2;
32
33 export type Seat = { playerId: string; send: (data: string) => void };
34
35 let state: MatchState = newMatch();
36 const seats = new Map<string, Seat>();
37 let ticks = 0;
38 let lastResult: string | null = null;
39
40 export const match = (): MatchState => state;
41
42 export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id);
43
44 /** Sit somebody down in the emptiest team, replacing a bot where it stands. */
45 export function takeSeat(session: string, name: string): Player | null {
46 const existing = seats.get(session);
47 const held = existing ? findPlayer(existing.playerId) : undefined;
48 if (held) {
49 held.name = name;
50 return held;
51 }
52 const humans = { red: 0, blue: 0 };
53 for (const player of state.players) if (!player.bot) humans[player.team] += 1;
54 const wanted = humans.red <= humans.blue ? "red" : "blue";
55 const seat =
56 state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
57 if (!seat) return null;
58 seat.bot = false;
59 seat.name = name;
60 seat.think = 0;
61 seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
62 return seat;
63 }
64
65 export function attach(session: string, playerId: string, send: (data: string) => void): void {
66 seats.set(session, { playerId, send });
67 }
68
69 /** A person leaving gives the seat back to a bot, mid-round and all. */
70 export function release(session: string): void {
71 const seat = seats.get(session);
72 seats.delete(session);
73 if (!seat) return;
74 const player = findPlayer(seat.playerId);
75 if (player && !stillHeld(seat.playerId)) {
76 player.bot = true;
77 player.think = 0;
78 player.input.fire = false;
79 player.input.use = false;
80 }
81 }
82
83 const stillHeld = (playerId: string): boolean =>
84 [...seats.values()].some((s) => s.playerId === playerId);
85
86 export const seatCount = (): number => seats.size;
87
88 // ------------------------------------------------------------- the commands
89
90 export type Command = { t: string; [key: string]: unknown };
91
92 const num = (value: unknown): number | undefined =>
93 typeof value === "number" && Number.isFinite(value) ? value : undefined;
94
95 /** Everything a client may ask for, validated here and nowhere else. */
96 export function command(playerId: string, message: Command): void {
97 const player = findPlayer(playerId);
98 if (!player || player.bot) return;
99 switch (message.t) {
100 case "i":
101 setInput(player, {
102 mx: num(message.mx),
103 my: num(message.my),
104 angle: num(message.a),
105 fire: typeof message.f === "boolean" ? message.f : undefined,
106 use: typeof message.u === "boolean" ? message.u : undefined,
107 reload: typeof message.r === "boolean" ? message.r : undefined,
108 cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined,
109 });
110 break;
111 case "buy": {
112 const what = String(message.what ?? "");
113 if (isWeaponId(what)) buyWeapon(state, player, what);
114 else if (what === "light" || what === "heavy") buyShield(state, player, what);
115 else if (what === "c" || what === "q") buyAbility(state, player, what);
116 break;
117 }
118 case "agent":
119 if (typeof message.id === "string") pickAgent(state, player, message.id);
120 break;
121 }
122 }
123
124 // ------------------------------------------------------------- the snapshot
125
126 const round2 = (n: number): number => Math.round(n * 10) / 10;
127
128 export function snapshot(viewerId: string): string {
129 const me = findPlayer(viewerId) ?? state.players[0]!;
130 const agent = agentById(me.agent);
131 const seen = state.players.filter((p) => visible(state, me, p));
132 const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones);
133
134 return JSON.stringify({
135 t: "s",
136 phase: state.phase,
137 clock: round2(Math.max(0, state.clock)),
138 round: state.round,
139 score: state.score,
140 attackers: state.attackers,
141 result: state.result,
142 me: {
143 id: me.id,
144 side: me.side,
145 team: me.team,
146 agent: me.agent,
147 alive: me.alive,
148 x: round2(me.x),
149 y: round2(me.y),
150 angle: round2(me.angle),
151 hp: Math.round(me.hp),
152 armour: Math.round(me.armour),
153 credits: me.credits,
154 weapon: me.weapon,
155 ammo: me.ammo,
156 reloading: me.reloadIn > 0,
157 charges: me.charges,
158 cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 },
159 ult: me.ult,
160 ultNeeds: agent.abilities.x.points,
161 blind: round2(me.blind),
162 slow: round2(me.slow),
163 haste: round2(me.haste),
164 spike: me.spike,
165 planting: round2(me.planting),
166 bot: me.bot,
167 },
168 players: seen.map((p) => ({
169 id: p.id,
170 name: p.name,
171 agent: p.agent,
172 x: round2(p.x),
173 y: round2(p.y),
174 angle: round2(p.angle),
175 hp: Math.round(p.hp),
176 armour: Math.round(p.armour),
177 alive: p.alive,
178 mate: p.side === me.side,
179 spike: p.spike,
180 blind: p.blind > 0,
181 revealed: p.revealed > 0,
182 })),
183 zones: state.zones
184 .filter((z) => (z.kind === "trip" ? z.side === me.side : true))
185 .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
186 spike: {
187 planted: state.spike.planted,
188 carried: state.spike.carried !== null,
189 fuse: round2(state.spike.fuse),
190 defuse: round2(state.spike.defuse),
191 checkpoint: state.spike.checkpoint,
192 // A loose spike is the attackers' business until it is armed.
193 x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null,
194 y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null,
195 },
196 orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })),
197 shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })),
198 board: state.players.map((p) => ({
199 id: p.id,
200 name: p.name,
201 team: p.team,
202 side: p.side,
203 agent: p.agent,
204 bot: p.bot,
205 alive: p.alive,
206 kills: p.kills,
207 deaths: p.deaths,
208 damage: Math.round(p.damageDone),
209 credits: p.team === me.team ? p.credits : null,
210 ult: p.ult,
211 ultNeeds: agentById(p.agent).abilities.x.points,
212 })),
213 events: state.events.slice(0, 5).map((e) => e.text),
214 });
215 }
216
217 // ----------------------------------------------------------------- the loop
218
219 function step(): void {
220 runBots(state, TICK);
221 tick(state, TICK);
222 if (state.result && state.result !== lastResult) {
223 lastResult = state.result;
224 recordResult(state.result, state.score.red, state.score.blue, state.round);
225 }
226 if (state.result === null) lastResult = null;
227 ticks += 1;
228 if (ticks % BROADCAST_EVERY !== 0) return;
229 for (const seat of seats.values()) {
230 try {
231 seat.send(snapshot(seat.playerId));
232 } catch {
233 // A socket that will not take a frame is closed already; the close
234 // handler gives the seat back.
235 }
236 }
237 }
238
239 let timer: ReturnType<typeof setInterval> | null = null;
240
241 export function startArena(): void {
242 if (timer) return;
243 timer = setInterval(step, TICK * 1000);
244 // The clock must never hold the process open on its own.
245 (timer as unknown as { unref?: () => void }).unref?.();
246 }
247
248 export function stopArena(): void {
249 if (timer) clearInterval(timer);
250 timer = null;
251 }
252
253 /** Used by the tests: run the arena forward without waiting for real time. */
254 export function advance(seconds: number): void {
255 for (let i = 0; i < Math.round(seconds / TICK); i++) step();
256 }
src/db.ts added +43
@@ -0,0 +1,43 @@
1 /**
2 * What survives a restart: finished matches. The live match lives in memory
3 * — it is gone the moment the process is — but its result is written here,
4 * under FORGE_APP_DATA, so the page can say what happened before you arrived.
5 */
6
7 import { Database } from "bun:sqlite";
8 import { mkdirSync } from "node:fs";
9 import { join } from "node:path";
10
11 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
12 mkdirSync(dataDir, { recursive: true });
13
14 export const db = new Database(join(dataDir, "app.db"));
15 db.exec("PRAGMA journal_mode = WAL");
16 db.exec(
17 `CREATE TABLE IF NOT EXISTS result (
18 id INTEGER PRIMARY KEY,
19 summary TEXT NOT NULL,
20 red INTEGER NOT NULL,
21 blue INTEGER NOT NULL,
22 rounds INTEGER NOT NULL,
23 ended_at TEXT NOT NULL DEFAULT (datetime('now'))
24 )`,
25 );
26
27 export type Result = { summary: string; red: number; blue: number; rounds: number; ended_at: string };
28
29 export const recordResult = (summary: string, red: number, blue: number, rounds: number): void => {
30 db.query("INSERT INTO result (summary, red, blue, rounds) VALUES (?1, ?2, ?3, ?4)").run(
31 summary.slice(0, 120),
32 red,
33 blue,
34 rounds,
35 );
36 };
37
38 export const recentResults = (limit = 5): Result[] =>
39 db
40 .query<Result, [number]>(
41 "SELECT summary, red, blue, rounds, ended_at FROM result ORDER BY id DESC LIMIT ?1",
42 )
43 .all(Math.max(1, Math.min(50, limit)));
src/map.ts +2 −2
@@ -25,8 +25,8 @@ const ROWS_SRC = [
2525 "#..####....#......#....####..#",
2626 "#..####..###......###..####..#",
2727 "#..####..####....####..####..#",
28 "#..####..####....####..####..#",
29 "#........####....####........#",
28 "#..####..####.##.####..####..#",
29 "#........####.##.####........#",
3030 "#..####..####....####..####..#",
3131 "#..####..####....####..####..#",
3232 "#..####..####....####..####..#",
src/pages/game.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The page. The first paint is the match as it stands right now — score,
3 * round, phase, scoreboard — server-rendered HTML, so somebody arriving with
4 * a cold cache or no JavaScript still sees a live match. The client script
5 * then takes the same state over a socket and draws it.
6 */
7
8 import { AGENTS } from "../abilities.ts";
9 import type { Result } from "../db.ts";
10 import type { MatchState } from "../state.ts";
11 import { WEAPONS, weaponIds } from "../weapons.ts";
12
13 export const escape = (s: string): string =>
14 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
15
16 const phaseWord = (state: MatchState): string =>
17 state.phase === "buy"
18 ? "buy phase"
19 : state.phase === "live"
20 ? state.spike.planted
21 ? `spike down · ${Math.ceil(state.spike.fuse)}s`
22 : "live"
23 : state.phase === "end"
24 ? "round over"
25 : "match over";
26
27 export function gamePage(opts: {
28 state: MatchState;
29 script: string;
30 name: string;
31 env: string;
32 commit: string;
33 ssh: string | undefined;
34 results: Result[];
35 }): string {
36 const { state } = opts;
37 const board = [...state.players].sort((a, b) => b.kills - a.kills);
38 const row = (side: "atk" | "def"): string =>
39 board
40 .filter((p) => p.side === side)
41 .map(
42 (p) =>
43 `<tr><td>${escape(p.name)}${p.bot ? " <small>bot</small>" : ""}</td><td>${escape(p.agent)}</td><td>${p.kills}</td><td>${p.deaths}</td></tr>`,
44 )
45 .join("");
46
47 return `<!doctype html>
48 <html lang="en">
49 <meta charset="utf-8">
50 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
51 <title>val — attack, defend, plant the spike</title>
52 <link rel="icon" href="data:,">
53 <style>
54 :root { color-scheme: dark; --atk: #e35d5b; --def: #4fb3d9; --ink: #e8e6e3; }
55 * { box-sizing: border-box; }
56 body { margin: 0; background: #0e0f12; color: var(--ink);
57 font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
58 .wrap { max-width: 1100px; margin: 0 auto; padding: .6rem; }
59 h1 { font-size: 1.1rem; margin: 0; letter-spacing: .12em; text-transform: uppercase; }
60 header.top { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
61 .env { font-size: .7rem; padding: .1rem .5rem; border-radius: 99px; background: #2a2d34; }
62 #scoreline { display: flex; align-items: center; justify-content: center; gap: .6rem;
63 font-variant-numeric: tabular-nums; margin: .4rem 0; font-size: 1.05rem; }
64 .red { color: var(--atk); font-weight: 700; } .blue { color: var(--def); font-weight: 700; }
65 #clock { font-size: 1.5rem; font-weight: 700; min-width: 3ch; text-align: center; }
66 #phase { opacity: .75; font-size: .85rem; }
67 #stage { position: relative; width: 100%; aspect-ratio: 30 / 19; background: #14161b;
68 border: 1px solid #262a33; border-radius: .6rem; overflow: hidden; touch-action: none; }
69 #view { width: 100%; height: 100%; display: block; }
70 #flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; }
71 #dead { position: absolute; inset: 0; display: none; place-items: center; background: #0008;
72 font-size: 1.1rem; letter-spacing: .1em; text-transform: uppercase; }
73 #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0;
74 font-variant-numeric: tabular-nums; }
75 .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem;
76 font-size: .85rem; }
77 .kit { display: flex; gap: .4rem; flex-wrap: wrap; }
78 .kit button, .buys button, .agents button { min-height: 44px; min-width: 44px; padding: .4rem .6rem;
79 background: #1b1e25; color: var(--ink); border: 1px solid #333a47; border-radius: .45rem;
80 font: inherit; font-size: .8rem; cursor: pointer; }
81 .kit button[disabled], .buys button[disabled] { opacity: .35; cursor: not-allowed; }
82 .kit b { display: block; font-size: .7rem; opacity: .7; }
83 #buy { display: none; margin: .5rem 0; padding: .6rem; background: #151820; border: 1px solid #2a2f3a;
84 border-radius: .6rem; }
85 #buy.on { display: block; }
86 .buys, .agents { display: flex; gap: .4rem; flex-wrap: wrap; margin: .35rem 0; }
87 #log { margin: .5rem 0; font-size: .82rem; opacity: .8; min-height: 3.2em; }
88 #log div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
89 table { width: 100%; border-collapse: collapse; font-size: .85rem; }
90 th, td { text-align: left; padding: .25rem .4rem; border-bottom: 1px solid #23262e; }
91 td small { opacity: .5; }
92 .boards { display: grid; gap: .8rem; grid-template-columns: 1fr; }
93 @media (min-width: 720px) { .boards { grid-template-columns: 1fr 1fr; } }
94 footer { margin: 1.2rem 0 3rem; font-size: .82rem; opacity: .72; }
95 code { background: #1b1e25; padding: .1rem .35rem; border-radius: .3rem; }
96 form.name { display: flex; gap: .4rem; margin: .5rem 0; }
97 form.name input { flex: 1; min-height: 44px; padding: .4rem .6rem; background: #1b1e25; color: var(--ink);
98 border: 1px solid #333a47; border-radius: .45rem; font: inherit; }
99 form.name button { min-height: 44px; padding: 0 1rem; background: #3a4356; color: var(--ink);
100 border: 0; border-radius: .45rem; font: inherit; cursor: pointer; }
101 #touch { display: none; }
102 @media (pointer: coarse) { #touch { display: block; } }
103 </style>
104 <div class="wrap">
105 <header class="top">
106 <h1>val</h1>
107 <span class="env">${escape(opts.env)}${opts.commit ? " @ " + escape(opts.commit) : ""}</span>
108 <span class="env" id="conn">connecting…</span>
109 </header>
110
111 <div id="scoreline">
112 <span class="red">red ${state.score.red}</span>
113 <span id="clock">${Math.max(0, Math.ceil(state.clock))}</span>
114 <span class="blue">${state.score.blue} blue</span>
115 </div>
116 <div id="phase">round ${state.round} · ${escape(phaseWord(state))} · ${escape(state.attackers)} attacking${state.result ? ` · ${escape(state.result)}` : ""}</div>
117
118 <div id="stage">
119 <canvas id="view" width="900" height="570"></canvas>
120 <div id="flash"></div>
121 <div id="dead">waiting for the next round</div>
122 </div>
123
124 <div id="hud">
125 <span class="chip" id="hp">100 hp</span>
126 <span class="chip" id="gun">Classic 12</span>
127 <span class="chip" id="creds">800 cr</span>
128 <span class="chip" id="ultmeter">ult 0/6</span>
129 <span class="kit" id="kit"></span>
130 <button id="buytoggle" class="chip" type="button">buy (B)</button>
131 </div>
132
133 <div id="buy">
134 <div><strong>Buy phase</strong> — credits are refused server-side if you cannot afford them.</div>
135 <div class="agents" id="agents">${AGENTS.map(
136 (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`,
137 ).join("")}</div>
138 <div class="buys" id="guns">${weaponIds()
139 .map((id) => `<button type="button" data-buy="${id}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`)
140 .join("")}</div>
141 <div class="buys" id="gear">
142 <button type="button" data-buy="light">Light shield <b>400</b></button>
143 <button type="button" data-buy="heavy">Heavy shield <b>1000</b></button>
144 <button type="button" data-buy="c">Ability C</button>
145 <button type="button" data-buy="q">Ability Q</button>
146 </div>
147 </div>
148
149 <div id="log">${state.events
150 .slice(0, 3)
151 .map((e) => `<div>${escape(e.text)}</div>`)
152 .join("")}</div>
153
154 <form class="name" method="post" action="/join">
155 <input name="name" maxlength="16" placeholder="Your name" value="${escape(opts.name)}" aria-label="Your name">
156 <button>Take a seat</button>
157 </form>
158
159 <div class="boards">
160 <div><h2 class="env">attackers</h2><table><thead><tr><th>player</th><th>agent</th><th>k</th><th>d</th></tr></thead><tbody id="atkboard">${row("atk")}</tbody></table></div>
161 <div><h2 class="env">defenders</h2><table><thead><tr><th>player</th><th>agent</th><th>k</th><th>d</th></tr></thead><tbody id="defboard">${row("def")}</tbody></table></div>
162 </div>
163
164 <footer>
165 <p><strong>Controls.</strong> WASD move · mouse aims · click fires · R reloads ·
166 <strong>F</strong> plants and defuses (hold it) · C / Q / E / X cast · B opens the buy.
167 On a phone: left thumb walks, right thumb aims and fires, the buttons under the map do the rest.</p>
168 <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse,
169 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat
170 nobody is in, and they use their kit.</p>
171 <p>Terminal: ${opts.ssh ? `<code>${escape(opts.ssh)}</code>` : "off in this environment."}</p>
172 ${opts.results.length ? `<p>Last matches: ${opts.results.map((r) => `${escape(r.summary)} <small>(${escape(r.ended_at)})</small>`).join(" · ")}</p>` : ""}
173 </footer>
174 </div>
175 <script src="${escape(opts.script)}" defer></script>
176 </html>
177 `;
178 }
src/server.test.ts +102 −25
@@ -6,46 +6,90 @@ import { join } from "node:path";
66 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
9 const { match, snapshot, advance } = await import("./arena.ts");
910 const base = `http://${server.hostname}:${server.port}`;
1011
11 test("serves the page", async () => {
12 test("serves a live match on the first paint, with no script needed", async () => {
1213 const response = await fetch(base + "/");
1314 expect(response.status).toBe(200);
14 expect(await response.text()).toContain("<h1>");
15 const body = await response.text();
16 expect(body).toContain("<h1>val</h1>");
17 // The score, the round and both team sheets are in the HTML itself.
18 expect(body).toContain("red " + match().score.red);
19 expect(body).toContain("round " + match().round);
20 for (const player of match().players) expect(body).toContain(player.name);
1521 });
1622
17 test("keeps a note", async () => {
23 test("the page hands out a session cookie and the script is named by content", async () => {
24 const response = await fetch(base + "/", { redirect: "manual" });
25 const cookie = response.headers.get("set-cookie") ?? "";
26 expect(cookie).toContain("val_session=");
27 expect(cookie).toContain("HttpOnly");
28 const body = await response.text();
29 const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(body);
30 expect(src).not.toBe(null);
31 const script = await fetch(base + src![1]!);
32 expect(script.status).toBe(200);
33 expect(script.headers.get("cache-control")).toContain("immutable");
34 expect(await script.text()).toContain("WebSocket");
35 });
36
37 test("a name is taken from a same-origin form and sanitised", async () => {
1838 const form = new FormData();
19 form.set("body", "hello from a test");
20 const posted = await fetch(base + "/notes", { method: "POST", body: form, redirect: "manual" });
39 form.set("name", ' Wren<script>" ');
40 const posted = await fetch(base + "/join", { method: "POST", body: form, redirect: "manual" });
2141 expect(posted.status).toBe(303);
22 expect(await (await fetch(base + "/")).text()).toContain("hello from a test");
42 const cookie = posted.headers.getSetCookie().join(" ");
43 expect(cookie).toContain("val_name=");
44 // The angle brackets and the quote never reach the cookie or the match.
45 expect(cookie).not.toContain("<");
46 expect(cookie).not.toContain("%3C");
47 expect(match().players.every((p) => !p.name.includes("<"))).toBe(true);
48 // The seat that name took is a real player in the running match.
49 expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true);
50 });
51
52 test("refuses a cross-site post", async () => {
53 const form = new FormData();
54 form.set("name", "Evil");
55 const posted = await fetch(base + "/join", {
56 method: "POST",
57 body: form,
58 redirect: "manual",
59 headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
60 });
61 expect(posted.status).toBe(403);
62 expect(match().players.some((p) => p.name === "Evil")).toBe(false);
2363 });
2464
2565 test("stands on the security floor", async () => {
2666 const response = await fetch(base + "/");
2767 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
2868 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
69 const missing = await fetch(base + "/nope");
70 expect(missing.status).toBe(404);
71 expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'");
2972 });
3073
3174 test("a bug answers with the floor, not the runtime's page", async () => {
32 // A percent-escape that is not one makes the search params throw.
33 const response = await fetch(base + "/notes", {
75 // A percent-escape that is not one: whether the runtime decodes it or
76 // throws, the answer carries the floor and never a stack trace.
77 const response = await fetch(base + "/join", {
3478 method: "POST",
3579 headers: { "content-type": "application/x-www-form-urlencoded" },
36 body: "body=%E0%A4%A",
80 body: "name=%E0%A4%A",
81 redirect: "manual",
3782 });
38 expect(response.status).toBe(200);
83 expect([303, 500]).toContain(response.status);
84 expect(await response.text()).not.toContain("src/server.ts");
3985 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
4086 });
4187
4288 test("a body is capped by the bytes read, whatever it claims", async () => {
43 // The cap counts bytes as they arrive and never reads Content-Length, so
44 // a chunked body with no length is refused the same way this one is.
45 const response = await fetch(base + "/notes", {
89 const response = await fetch(base + "/join", {
4690 method: "POST",
4791 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: "body=" + "x".repeat(1024 * 1024),
92 body: "name=" + "x".repeat(1024 * 1024),
4993 });
5094 expect(response.status).toBe(413);
5195 });
@@ -56,15 +100,48 @@ test("HEAD sees the app up", async () => {
56100 expect(await response.text()).toBe("");
57101 });
58102
59 test("refuses a cross-site post", async () => {
60 const form = new FormData();
61 form.set("body", "from another site");
62 const posted = await fetch(base + "/notes", {
63 method: "POST",
64 body: form,
65 redirect: "manual",
66 headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
67 });
68 expect(posted.status).toBe(403);
69 expect(await (await fetch(base + "/")).text()).not.toContain("from another site");
103 test("the map is served as data, once, for the client to draw", async () => {
104 const response = await fetch(base + "/map.json");
105 const body = (await response.json()) as { rows: string[]; tile: number };
106 expect(body.tile).toBe(80);
107 expect(body.rows.length).toBeGreaterThan(10);
108 expect(body.rows.some((r) => r.includes("A"))).toBe(true);
109 expect(body.rows.some((r) => r.includes("B"))).toBe(true);
110 });
111
112 test("a websocket is refused to another site", async () => {
113 const response = await server.fetch(
114 new Request(base + "/ws", { headers: { "sec-fetch-site": "cross-site", origin: "https://evil.example" } }),
115 );
116 expect(response.status).toBe(403);
117 });
118
119 test("a snapshot only carries the enemies that viewer can see", () => {
120 const state = match();
121 const viewer = state.players.find((p) => p.side === "atk")!;
122 const enemy = state.players.find((p) => p.side === "def")!;
123 viewer.x = 1200;
124 viewer.y = 1320;
125 viewer.blind = 0;
126 enemy.x = 1200;
127 enemy.y = 1240;
128 enemy.revealed = 0;
129 const near = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[] };
130 expect(near.players.some((p) => p.id === enemy.id)).toBe(true);
131 // Behind the wall on the far side of the map, the same enemy is gone.
132 enemy.x = 200;
133 enemy.y = 200;
134 const far = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[]; board: unknown[] };
135 expect(far.players.some((p) => p.id === enemy.id)).toBe(false);
136 // The scoreboard still lists everyone; it carries no position.
137 expect(far.board.length).toBe(10);
138 expect(JSON.stringify(far.board)).not.toContain('"x"');
139 });
140
141 test("the match keeps running and is never parked", () => {
142 const state = match();
143 const before = state.now;
144 advance(30);
145 expect(state.now).toBeGreaterThan(before);
146 expect(["buy", "live", "end", "over"]).toContain(state.phase);
70147 });
src/server.ts +145 −71
@@ -1,29 +1,23 @@
11 /**
2 * val — a small app with real data.
2 * val — the door. Routing, sessions, sockets and the security floor; the
3 * game itself is in src/match.ts and the live one in src/arena.ts.
34 *
4 * Notes live in a SQLite file under FORGE_APP_DATA. Production and each
5 * preview get their own directory, so a preview starts with production's
6 * notes and never writes back to them.
5 * A seat belongs to a session cookie, so the same browser gets the same
6 * player back across a reload, and a socket is only ever given a seat the
7 * server chose.
78 */
89
9 import { Database } from "bun:sqlite";
10 import { mkdirSync } from "node:fs";
10 import { createHash, randomUUID } from "node:crypto";
1111 import { join } from "node:path";
12
13 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
14 mkdirSync(dataDir, { recursive: true });
15 const db = new Database(join(dataDir, "app.db"));
16 db.exec("PRAGMA journal_mode = WAL");
17 db.exec(
18 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
19 );
12 import { attach, command, findPlayer, match, release, snapshot, startArena, takeSeat, type Command } from "./arena.ts";
13 import { recentResults } from "./db.ts";
14 import { gridRows, TILE } from "./map.ts";
15 import { gamePage } from "./pages/game.ts";
2016
2117 const env = process.env.FORGE_APP_ENV ?? "local";
2218 const pull = process.env.FORGE_APP_PULL;
2319 const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7);
24
25 const escape = (s: string): string =>
26 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
20 const where = env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
2721
2822 // The security floor every response stands on. Scripts and styles come from
2923 // this origin (inline styles allowed, inline scripts not), nothing frames us,
@@ -42,17 +36,20 @@ const SECURITY = {
4236 ? { "strict-transport-security": "max-age=31536000" }
4337 : {}),
4438 };
45 const html = (body: string, status = 200): Response =>
46 new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY } });
39 const html = (body: string, status = 200, extra: Record<string, string> = {}): Response =>
40 new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...extra } });
4741
4842 // A browser on another site can make a person's browser POST here; it cannot
4943 // make that request look same-origin. Browsers say where a request came from
5044 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
45 // The address the forge published is the one that counts, but a browser
46 // reaching the app by another name (localhost, an IP, a tunnel) sends that
47 // host in both the URL and the Origin — a cross-site request never can.
5148 const sameOrigin = (request: Request): boolean => {
5249 const site = request.headers.get("sec-fetch-site");
5350 if (site !== null) return site === "same-origin" || site === "none";
5451 const origin = request.headers.get("origin");
55 return origin === null || origin === publicOrigin;
52 return origin === null || origin === publicOrigin || origin === new URL(request.url).origin;
5653 };
5754
5855 // A request body is read up to a cap and no further, counting the bytes as
@@ -104,68 +101,116 @@ async function readForm(request: Request): Promise<FormData | undefined> {
104101 }).formData();
105102 }
106103
107 function page(): string {
108 const notes = db
109 .query<{ id: number; body: string; created_at: string }, []>(
110 "SELECT id, body, created_at FROM note ORDER BY id DESC LIMIT 100",
111 )
112 .all();
113 const where =
114 env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
115 return `<!doctype html>
116 <meta charset="utf-8">
117 <meta name="viewport" content="width=device-width, initial-scale=1">
118 <title>val</title>
119 <style>
120 :root { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; }
121 body { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
122 .env { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8rem;
123 background: ${env === "production" ? "#16a34a" : "#2563eb"}; color: white; }
124 form { display: flex; gap: .5rem; margin: 1.5rem 0; }
125 input { flex: 1; padding: .6rem .8rem; font: inherit; border: 1px solid #8884; border-radius: .5rem; }
126 button { padding: .6rem 1rem; font: inherit; border: 0; border-radius: .5rem; background: #111; color: white; }
127 li { padding: .5rem 0; border-top: 1px solid #8883; list-style: none; }
128 small { opacity: .6; }
129 </style>
130 <h1>val</h1>
131 <p>Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.</p>
132 <p><span class="env">${escape(where)}</span> <small>${commit ? "@ " + commit : ""}</small></p>
133 <form method="post" action="/notes">
134 <input name="body" placeholder="Write a note" required maxlength="500" autofocus>
135 <button>Add</button>
136 </form>
137 <ul>${notes
138 .map((n) => `<li>${escape(n.body)} <small>${n.created_at}</small></li>`)
139 .join("")}</ul>
140 <p><small>${notes.length === 0 ? "No notes yet. Notes written here stay in this environment's data." : `${notes.length} note${notes.length === 1 ? "" : "s"} in ${escape(where)} data.`}</small></p>
141 `;
142 }
104 // ------------------------------------------------------------- the client
143105
144 export const server = Bun.serve({
106 // The client script is named by its content: a change is a new URL, so a
107 // year-long cache is never stale.
108 const scriptPath = join(import.meta.dir, "..", "static", "val.js");
109 const scriptBody = await Bun.file(scriptPath).text();
110 const scriptHash = createHash("sha256").update(scriptBody).digest("hex").slice(0, 10);
111 const scriptUrl = `/static/val.${scriptHash}.js`;
112
113 // ------------------------------------------------------------- sessions
114
115 const cookies = (request: Request): Record<string, string> => {
116 const out: Record<string, string> = {};
117 for (const part of (request.headers.get("cookie") ?? "").split(";")) {
118 const at = part.indexOf("=");
119 if (at < 1) continue;
120 out[part.slice(0, at).trim()] = decodeURIComponent(part.slice(at + 1).trim());
121 }
122 return out;
123 };
124
125 const SESSION = "val_session";
126 const NAME = "val_name";
127
128 /** A name is one line of plain text, sixteen characters, nothing clever. */
129 const cleanName = (raw: unknown): string => {
130 const text = String(raw ?? "")
131 .replace(/[\u0000-\u001f\u007f<>"'\\]/g, "")
132 .trim()
133 .slice(0, 16);
134 return text.length >= 1 ? text : "";
135 };
136
137 const setCookie = (name: string, value: string, httpOnly: boolean): string =>
138 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
139
140 type SocketData = { session: string; playerId: string };
141
142 // ---------------------------------------------------------------- routing
143
144 export const server = Bun.serve<SocketData, Record<string, never>>({
145145 port: Number(process.env.PORT ?? 8080),
146146 hostname: process.env.HOST ?? "127.0.0.1",
147 async fetch(request) {
147 async fetch(request, self) {
148148 const url = new URL(request.url);
149 const jar = cookies(request);
150 const session = jar[SESSION] ?? randomUUID();
151 const fresh = jar[SESSION] === undefined;
152
149153 if (request.method === "POST" && !sameOrigin(request))
150 return new Response("forbidden", { status: 403 });
151 if (request.method === "POST" && url.pathname === "/notes") {
154 return new Response("forbidden", { status: 403, headers: SECURITY });
155
156 if (request.method === "POST" && url.pathname === "/join") {
152157 const form = await readForm(request);
153 if (form === undefined)
154 return new Response("too large", { status: 413, headers: SECURITY });
155 const body = String(form.get("body") ?? "").trim().slice(0, 500);
156 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body);
157 return Response.redirect("/", 303);
158 if (form === undefined) return new Response("too large", { status: 413, headers: SECURITY });
159 const name = cleanName(form.get("name"));
160 const headers = new Headers({ location: "/", ...SECURITY });
161 headers.append("set-cookie", setCookie(SESSION, session, true));
162 if (name) {
163 headers.append("set-cookie", setCookie(NAME, name, false));
164 takeSeat(session, name);
165 }
166 return new Response(null, { status: 303, headers });
158167 }
159 // HEAD is GET without the body: a health check sees the app up.
168
169 // The socket: one seat per session, taken from a bot where it stands.
170 if (url.pathname === "/ws") {
171 if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY });
172 const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`;
173 const player = takeSeat(session, name);
174 if (!player) return new Response("no seats", { status: 503, headers: SECURITY });
175 const upgraded = self.upgrade(request, { data: { session, playerId: player.id } });
176 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
177 }
178
160179 if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
161 const full = html(page());
180 const body = gamePage({
181 state: match(),
182 script: scriptUrl,
183 name: cleanName(jar[NAME]),
184 env: where,
185 commit,
186 ssh: process.env.FORGE_APP_SSH,
187 results: recentResults(3),
188 });
189 const response = html(body, 200, fresh ? { "set-cookie": setCookie(SESSION, session, true) } : {});
162190 return request.method === "HEAD"
163 ? new Response(null, { status: full.status, headers: full.headers })
164 : full;
191 ? new Response(null, { status: response.status, headers: response.headers })
192 : response;
165193 }
194
195 // The map is the same for everybody and changes only when the code does.
196 if (url.pathname === "/map.json" && (request.method === "GET" || request.method === "HEAD"))
197 return Response.json(
198 { rows: gridRows(), tile: TILE },
199 { headers: { "cache-control": "public, max-age=300", ...SECURITY } },
200 );
201
202 if (url.pathname === scriptUrl)
203 return new Response(scriptBody, {
204 headers: {
205 "content-type": "text/javascript; charset=utf-8",
206 "cache-control": "public, max-age=31536000, immutable",
207 ...SECURITY,
208 },
209 });
210
166211 // Files under ./static are served as they are, cached for a year: name
167 // them by content (app.3f2a9c.js) and a change is a new name, never a
168 // stale cache. The path is confined to the directory.
212 // them by content and a change is a new name, never a stale cache. The
213 // path is confined to the directory.
169214 if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
170215 const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1)));
171216 if (await file.exists())
@@ -175,6 +220,32 @@ export const server = Bun.serve({
175220 }
176221 return new Response("not found", { status: 404, headers: SECURITY });
177222 },
223
224 websocket: {
225 maxPayloadLength: 4 * 1024,
226 open(socket) {
227 const { session, playerId } = socket.data;
228 attach(session, playerId, (data) => socket.send(data));
229 socket.send(snapshot(playerId));
230 },
231 message(socket, raw) {
232 // Anything a client sends is a claim: it is parsed, bounded and given
233 // to the arena, which decides what it is allowed to mean.
234 if (typeof raw !== "string" || raw.length > 4096) return;
235 let parsed: unknown;
236 try {
237 parsed = JSON.parse(raw);
238 } catch {
239 return;
240 }
241 if (typeof parsed !== "object" || parsed === null) return;
242 command(socket.data.playerId, parsed as Command);
243 },
244 close(socket) {
245 release(socket.data.session);
246 },
247 },
248
178249 // A bug answers with the floor and nothing else: never the runtime's own
179250 // error page, which carries source and paths, and never without the
180251 // headers. The detail goes to the log, where it belongs.
@@ -184,4 +255,7 @@ export const server = Bun.serve({
184255 },
185256 });
186257
258 startArena();
259
187260 console.log(`val listening on http://${server.hostname}:${server.port} (${env})`);
261 export { findPlayer };
static/val.js added +429
@@ -0,0 +1,429 @@
1 /**
2 * val, in a browser. It draws what the server sends and sends what the
3 * person does — nothing else. There is no position here the server did not
4 * hand over, and no enemy in a snapshot the server decided you cannot see,
5 * so a patched client learns nothing a plain one does not.
6 */
7 "use strict";
8
9 const $ = (id) => document.getElementById(id);
10 const view = $("view");
11 const ctx = view.getContext("2d");
12
13 const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" };
14 const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f" };
15
16 let map = null;
17 let snap = null;
18 let socket = null;
19 const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
20 const keys = new Set();
21 let pointer = null; // aim target in canvas pixels
22 let firing = false;
23 let stick = null; // { id, ox, oy, mx, my }
24 let aimTouch = null;
25
26 // ------------------------------------------------------------------ socket
27
28 function connect() {
29 const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
30 socket = new WebSocket(url);
31 socket.onopen = () => ($("conn").textContent = "live");
32 socket.onclose = () => {
33 $("conn").textContent = "reconnecting…";
34 setTimeout(connect, 1500);
35 };
36 socket.onmessage = (event) => {
37 const data = JSON.parse(event.data);
38 if (data.t === "s") {
39 snap = data;
40 paintHud(data);
41 }
42 };
43 }
44
45 const send = (message) => {
46 if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
47 };
48
49 // ------------------------------------------------------------------- input
50
51 addEventListener("keydown", (event) => {
52 if (event.target instanceof HTMLInputElement) return;
53 if (event.code === "KeyB") {
54 toggleBuy();
55 event.preventDefault();
56 return;
57 }
58 if (KEY_SLOT[event.code]) {
59 send({ t: "i", c: KEY_SLOT[event.code] });
60 event.preventDefault();
61 return;
62 }
63 keys.add(event.code);
64 if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault();
65 });
66 addEventListener("keyup", (event) => keys.delete(event.code));
67 addEventListener("blur", () => keys.clear());
68
69 view.addEventListener("pointermove", (event) => {
70 if (event.pointerType === "touch") return;
71 const box = view.getBoundingClientRect();
72 pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
73 });
74 view.addEventListener("pointerdown", (event) => {
75 view.setPointerCapture(event.pointerId);
76 const box = view.getBoundingClientRect();
77 const x = event.clientX - box.left;
78 if (event.pointerType === "touch") {
79 if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
80 else {
81 aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
82 firing = true;
83 }
84 event.preventDefault();
85 return;
86 }
87 pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
88 firing = true;
89 });
90 view.addEventListener("pointermove", (event) => {
91 if (stick && event.pointerId === stick.id) {
92 const dx = event.clientX - stick.ox;
93 const dy = event.clientY - stick.oy;
94 const len = Math.hypot(dx, dy) || 1;
95 const reach = Math.min(len, 48) / 48;
96 stick.mx = (dx / len) * reach;
97 stick.my = (dy / len) * reach;
98 }
99 if (aimTouch && event.pointerId === aimTouch.id) {
100 const box = view.getBoundingClientRect();
101 aimTouch.x = ((event.clientX - box.left) / box.width) * view.width;
102 aimTouch.y = ((event.clientY - box.top) / box.height) * view.height;
103 }
104 });
105 const endPointer = (event) => {
106 if (stick && event.pointerId === stick.id) stick = null;
107 else if (aimTouch && event.pointerId === aimTouch.id) {
108 aimTouch = null;
109 firing = false;
110 } else firing = false;
111 };
112 view.addEventListener("pointerup", endPointer);
113 view.addEventListener("pointercancel", endPointer);
114 view.addEventListener("contextmenu", (event) => event.preventDefault());
115
116 // The buy opens itself every buy phase; closing it is remembered until the
117 // next one, so nobody has to fight the panel to watch the round start.
118 function toggleBuy() {
119 const open = $("buy").classList.toggle("on");
120 $("buy").dataset.closed = open ? "" : "1";
121 }
122 $("buytoggle").addEventListener("click", toggleBuy);
123 for (const button of document.querySelectorAll("[data-buy]"))
124 button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy }));
125 for (const button of document.querySelectorAll("[data-agent]"))
126 button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent }));
127
128 // Twenty times a second the server hears what this person is doing.
129 setInterval(() => {
130 if (!snap || !map) return;
131 let mx = 0;
132 let my = 0;
133 if (keys.has("KeyW")) my -= 1;
134 if (keys.has("KeyS")) my += 1;
135 if (keys.has("KeyA")) mx -= 1;
136 if (keys.has("KeyD")) mx += 1;
137 if (stick) {
138 mx = stick.mx;
139 my = stick.my;
140 }
141 const aim = aimTouch || pointer;
142 const me = worldToScreen(snap.me);
143 const angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
144 send({
145 t: "i",
146 mx,
147 my,
148 a: angle,
149 f: firing || keys.has("Space"),
150 u: keys.has("KeyF"),
151 r: keys.has("KeyR"),
152 });
153 }, 50);
154
155 // ------------------------------------------------------------------ drawing
156
157 function resize() {
158 const box = view.getBoundingClientRect();
159 const dpr = Math.min(devicePixelRatio || 1, 2);
160 view.width = Math.round(box.width * dpr);
161 view.height = Math.round(box.height * dpr);
162 }
163 addEventListener("resize", resize);
164
165 const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1);
166 const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
167
168 function smooth(id, x, y, angle) {
169 const now = shown.get(id);
170 if (!now) {
171 shown.set(id, { x, y, angle });
172 return { x, y, angle };
173 }
174 now.x += (x - now.x) * 0.35;
175 now.y += (y - now.y) * 0.35;
176 let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
177 now.angle += turn * 0.4;
178 return now;
179 }
180
181 function drawMap(s) {
182 const t = map.tile * s;
183 for (let row = 0; row < map.rows.length; row++)
184 for (let col = 0; col < map.rows[row].length; col++) {
185 const cell = map.rows[row][col];
186 ctx.fillStyle =
187 cell === "#" ? "#20242d" : cell === "A" || cell === "B" ? "#2c2a22" : cell === "a" ? "#2a2024" : cell === "d" ? "#1e2630" : "#171a21";
188 ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6);
189 if (cell === "A" || cell === "B") {
190 ctx.fillStyle = "#6b5f3a";
191 ctx.font = `${Math.round(t * 0.5)}px system-ui`;
192 ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68);
193 }
194 }
195 }
196
197 function drawZone(z, s) {
198 const x = z.x * s;
199 const y = z.y * s;
200 const r = z.r * s;
201 ctx.save();
202 if (z.k === "smoke") {
203 ctx.fillStyle = "rgba(190,195,205,0.72)";
204 ctx.beginPath();
205 ctx.arc(x, y, r, 0, 7);
206 ctx.fill();
207 } else if (z.k === "molly") {
208 ctx.fillStyle = "rgba(230,110,50,0.45)";
209 ctx.strokeStyle = "#f08a4b";
210 ctx.beginPath();
211 ctx.arc(x, y, r, 0, 7);
212 ctx.fill();
213 ctx.stroke();
214 } else if (z.k === "wall") {
215 ctx.fillStyle = "rgba(120,200,255,0.55)";
216 ctx.beginPath();
217 ctx.arc(x, y, r, 0, 7);
218 ctx.fill();
219 } else if (z.k === "flash") {
220 ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)";
221 ctx.beginPath();
222 ctx.arc(x, y, z.f > 0 ? 5 * s : 30 * s, 0, 7);
223 ctx.fill();
224 } else if (z.k === "dart") {
225 ctx.strokeStyle = "rgba(90,220,170,0.7)";
226 ctx.beginPath();
227 ctx.arc(x, y, r, 0, 7);
228 ctx.stroke();
229 } else if (z.k === "trip") {
230 ctx.strokeStyle = "rgba(230,200,90,0.8)";
231 ctx.beginPath();
232 ctx.arc(x, y, r, 0, 7);
233 ctx.stroke();
234 } else if (z.k === "heal") {
235 ctx.fillStyle = "rgba(120,230,140,0.5)";
236 ctx.beginPath();
237 ctx.arc(x, y, 14 * s, 0, 7);
238 ctx.fill();
239 }
240 ctx.restore();
241 }
242
243 function drawPlayer(p, s, me) {
244 const pos = smooth(p.id, p.x, p.y, p.angle);
245 const x = pos.x * s;
246 const y = pos.y * s;
247 const r = 16 * s;
248 const mine = p.id === me.id;
249 ctx.fillStyle = p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b";
250 if (!p.alive) ctx.fillStyle = "#555a63";
251 ctx.beginPath();
252 ctx.arc(x, y, r, 0, 7);
253 ctx.fill();
254 ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888";
255 ctx.lineWidth = Math.max(1, 3 * s);
256 ctx.stroke();
257 if (p.alive) {
258 ctx.strokeStyle = mine ? "#fff" : ctx.fillStyle;
259 ctx.beginPath();
260 ctx.moveTo(x, y);
261 ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2);
262 ctx.stroke();
263 }
264 if (p.spike) {
265 ctx.fillStyle = "#f0d060";
266 ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8);
267 }
268 if (p.blind) {
269 ctx.fillStyle = "rgba(255,255,255,0.8)";
270 ctx.beginPath();
271 ctx.arc(x, y - r * 1.6, r * 0.35, 0, 7);
272 ctx.fill();
273 }
274 if (!mine) {
275 ctx.fillStyle = "rgba(235,235,235,0.75)";
276 ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui`;
277 ctx.textAlign = "center";
278 ctx.fillText(p.name, x, y - r * 1.9);
279 ctx.textAlign = "left";
280 }
281 if (p.alive && p.hp < 100) {
282 ctx.fillStyle = "#0008";
283 ctx.fillRect(x - r, y + r * 1.2, r * 2, 3);
284 ctx.fillStyle = "#6fcf6f";
285 ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3);
286 }
287 }
288
289 function frame() {
290 requestAnimationFrame(frame);
291 if (!map) return;
292 const s = scale();
293 ctx.clearRect(0, 0, view.width, view.height);
294 drawMap(s);
295 if (!snap) return;
296
297 for (const orb of snap.orbs) {
298 ctx.fillStyle = "#c9a0ff";
299 ctx.beginPath();
300 ctx.arc(orb.x * s, orb.y * s, 9 * s, 0, 7);
301 ctx.fill();
302 }
303 for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s);
304
305 if (snap.spike.x !== null) {
306 const x = snap.spike.x * s;
307 const y = snap.spike.y * s;
308 ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c";
309 ctx.fillRect(x - 7 * s, y - 7 * s, 14 * s, 14 * s);
310 if (snap.spike.planted && snap.spike.defuse > 0) {
311 ctx.fillStyle = "#4fd6a0";
312 ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 4);
313 }
314 }
315
316 for (const shot of snap.shots) {
317 ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.55)";
318 ctx.lineWidth = Math.max(1, 2 * s);
319 ctx.beginPath();
320 ctx.moveTo(shot.x1 * s, shot.y1 * s);
321 ctx.lineTo(shot.x2 * s, shot.y2 * s);
322 ctx.stroke();
323 }
324
325 for (const player of snap.players) drawPlayer(player, s, snap.me);
326 // Smoke goes on last: it hides what is behind it on screen too.
327 for (const zone of snap.zones) if (zone.k === "smoke") drawZone(zone, s);
328
329 if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
330 else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") bar(snap.spike.defuse / 7, "#4fd6a0", "defusing");
331 $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
332 $("dead").style.display = snap.me.alive ? "none" : "grid";
333 }
334
335 function bar(fraction, colour, label) {
336 const w = view.width * 0.4;
337 const x = view.width / 2 - w / 2;
338 const y = view.height * 0.86;
339 ctx.fillStyle = "#0009";
340 ctx.fillRect(x, y, w, 16);
341 ctx.fillStyle = colour;
342 ctx.fillRect(x, y, w * Math.min(1, fraction), 16);
343 ctx.fillStyle = "#fff";
344 ctx.font = "12px system-ui";
345 ctx.textAlign = "center";
346 ctx.fillText(label, view.width / 2, y + 12);
347 ctx.textAlign = "left";
348 }
349
350 // --------------------------------------------------------------------- hud
351
352 const KITS = {};
353
354 function paintHud(s) {
355 $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock);
356 document.querySelector("#scoreline .red").textContent = "red " + s.score.red;
357 document.querySelector("#scoreline .blue").textContent = s.score.blue + " blue";
358 $("phase").textContent =
359 `round ${s.round} · ${s.phase === "buy" ? "buy phase" : s.phase === "live" ? (s.spike.planted ? "spike down" : "live") : s.phase === "end" ? "round over" : "match over"} · ${s.attackers} attacking · you are ${s.me.side === "atk" ? "attacking" : "defending"}${s.result ? " · " + s.result : ""}`;
360 $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`;
361 $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`;
362 $("creds").textContent = `${s.me.credits} cr`;
363 $("ultmeter").textContent = `ult ${s.me.ult}/${s.me.ultNeeds}`;
364 $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1");
365 if (s.phase !== "buy") $("buy").dataset.closed = "";
366
367 const kit = $("kit");
368 const shape = ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + s.me.agent + s.me.ult;
369 if (KITS.shape !== shape) {
370 KITS.shape = shape;
371 kit.innerHTML = "";
372 for (const slot of ["c", "q", "e", "x"]) {
373 const button = document.createElement("button");
374 const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0;
375 button.disabled = !ready;
376 const count = slot === "x" ? `${s.me.ult}/${s.me.ultNeeds}` : s.me.cooldown[slot] > 0 ? `${Math.ceil(s.me.cooldown[slot])}s` : s.me.charges[slot];
377 button.innerHTML = `${slot.toUpperCase()} <b>${count}</b>`;
378 button.type = "button";
379 button.addEventListener("click", () => send({ t: "i", c: slot }));
380 kit.append(button);
381 }
382 }
383
384 const log = $("log");
385 const text = s.events.slice(0, 3).join("\n");
386 if (log.dataset.text !== text) {
387 log.dataset.text = text;
388 log.innerHTML = "";
389 for (const line of s.events.slice(0, 3)) {
390 const div = document.createElement("div");
391 div.textContent = line;
392 log.append(div);
393 }
394 }
395
396 for (const side of ["atk", "def"]) {
397 const body = $(side + "board");
398 const rows = s.board.filter((p) => p.side === side).sort((a, b) => b.kills - a.kills);
399 const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join();
400 if (body.dataset.key === key) continue;
401 body.dataset.key = key;
402 body.innerHTML = "";
403 for (const player of rows) {
404 const tr = document.createElement("tr");
405 const cells = [
406 player.name + (player.bot ? " (bot)" : "") + (player.alive ? "" : " ✕"),
407 player.agent + (player.credits !== null ? ` · ${player.credits}cr` : ""),
408 String(player.kills),
409 String(player.deaths),
410 ];
411 for (const value of cells) {
412 const td = document.createElement("td");
413 td.textContent = value;
414 tr.append(td);
415 }
416 if (player.id === s.me.id) tr.style.fontWeight = "700";
417 body.append(tr);
418 }
419 }
420 }
421
422 fetch("/map.json")
423 .then((r) => r.json())
424 .then((data) => {
425 map = data;
426 resize();
427 connect();
428 });
429 requestAnimationFrame(frame);
↑↓ navigate ↵ open esc close

Keyboard shortcuts