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]>
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", () => { | ||
| 119 | 119 | const state = live(); |
| 120 | 120 | const player = attacker(state); |
| 121 | 121 | 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 }; | |
| 126 | 127 | const openBefore = !sightBlocked(player, behind, state.zones); |
| 127 | 128 | expect(cast(state, player, "e")).toBe(true); |
| 128 | 129 | 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 = [ | ||
| 25 | 25 | "#..####....#......#....####..#", |
| 26 | 26 | "#..####..###......###..####..#", |
| 27 | 27 | "#..####..####....####..####..#", |
| 28 | "#..####..####....####..####..#", | |
| 29 | "#........####....####........#", | |
| 28 | "#..####..####.##.####..####..#", | |
| 29 | "#........####.##.####........#", | |
| 30 | 30 | "#..####..####....####..####..#", |
| 31 | 31 | "#..####..####....####..####..#", |
| 32 | 32 | "#..####..####....####..####..#", |
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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"; | ||
| 6 | 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | const { match, snapshot, advance } = await import("./arena.ts"); | |
| 9 | 10 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | 11 | |
| 11 | test("serves the page", async () => { | |
| 12 | test("serves a live match on the first paint, with no script needed", async () => { | |
| 12 | 13 | const response = await fetch(base + "/"); |
| 13 | 14 | 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); | |
| 15 | 21 | }); |
| 16 | 22 | |
| 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 () => { | |
| 18 | 38 | 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" }); | |
| 21 | 41 | 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); | |
| 23 | 63 | }); |
| 24 | 64 | |
| 25 | 65 | test("stands on the security floor", async () => { |
| 26 | 66 | const response = await fetch(base + "/"); |
| 27 | 67 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 28 | 68 | 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'"); | |
| 29 | 72 | }); |
| 30 | 73 | |
| 31 | 74 | 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", { | |
| 34 | 78 | method: "POST", |
| 35 | 79 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 36 | body: "body=%E0%A4%A", | |
| 80 | body: "name=%E0%A4%A", | |
| 81 | redirect: "manual", | |
| 37 | 82 | }); |
| 38 | expect(response.status).toBe(200); | |
| 83 | expect([303, 500]).toContain(response.status); | |
| 84 | expect(await response.text()).not.toContain("src/server.ts"); | |
| 39 | 85 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 40 | 86 | }); |
| 41 | 87 | |
| 42 | 88 | 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", { | |
| 46 | 90 | method: "POST", |
| 47 | 91 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 48 | body: "body=" + "x".repeat(1024 * 1024), | |
| 92 | body: "name=" + "x".repeat(1024 * 1024), | |
| 49 | 93 | }); |
| 50 | 94 | expect(response.status).toBe(413); |
| 51 | 95 | }); |
| @@ -56,15 +100,48 @@ test("HEAD sees the app up", async () => { | ||
| 56 | 100 | expect(await response.text()).toBe(""); |
| 57 | 101 | }); |
| 58 | 102 | |
| 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); | |
| 70 | 147 | }); |
src/server.ts +145 −71
| @@ -1,29 +1,23 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 3 | 4 | * |
| 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. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | import { Database } from "bun:sqlite"; | |
| 10 | import { mkdirSync } from "node:fs"; | |
| 10 | import { createHash, randomUUID } from "node:crypto"; | |
| 11 | 11 | 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"; | |
| 20 | 16 | |
| 21 | 17 | const env = process.env.FORGE_APP_ENV ?? "local"; |
| 22 | 18 | const pull = process.env.FORGE_APP_PULL; |
| 23 | 19 | const commit = (process.env.FORGE_APP_COMMIT ?? "").slice(0, 7); |
| 24 | ||
| 25 | const escape = (s: string): string => | |
| 26 | s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); | |
| 20 | const where = env === "preview" ? `preview of pull #${pull ?? "?"}` : env; | |
| 27 | 21 | |
| 28 | 22 | // The security floor every response stands on. Scripts and styles come from |
| 29 | 23 | // this origin (inline styles allowed, inline scripts not), nothing frames us, |
| @@ -42,17 +36,20 @@ const SECURITY = { | ||
| 42 | 36 | ? { "strict-transport-security": "max-age=31536000" } |
| 43 | 37 | : {}), |
| 44 | 38 | }; |
| 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 } }); | |
| 47 | 41 | |
| 48 | 42 | // A browser on another site can make a person's browser POST here; it cannot |
| 49 | 43 | // make that request look same-origin. Browsers say where a request came from |
| 50 | 44 | // (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. | |
| 51 | 48 | const sameOrigin = (request: Request): boolean => { |
| 52 | 49 | const site = request.headers.get("sec-fetch-site"); |
| 53 | 50 | if (site !== null) return site === "same-origin" || site === "none"; |
| 54 | 51 | const origin = request.headers.get("origin"); |
| 55 | return origin === null || origin === publicOrigin; | |
| 52 | return origin === null || origin === publicOrigin || origin === new URL(request.url).origin; | |
| 56 | 53 | }; |
| 57 | 54 | |
| 58 | 55 | // 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> { | ||
| 104 | 101 | }).formData(); |
| 105 | 102 | } |
| 106 | 103 | |
| 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 | |
| 143 | 105 | |
| 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>>({ | |
| 145 | 145 | port: Number(process.env.PORT ?? 8080), |
| 146 | 146 | hostname: process.env.HOST ?? "127.0.0.1", |
| 147 | async fetch(request) { | |
| 147 | async fetch(request, self) { | |
| 148 | 148 | const url = new URL(request.url); |
| 149 | const jar = cookies(request); | |
| 150 | const session = jar[SESSION] ?? randomUUID(); | |
| 151 | const fresh = jar[SESSION] === undefined; | |
| 152 | ||
| 149 | 153 | 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") { | |
| 152 | 157 | 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 }); | |
| 158 | 167 | } |
| 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 | ||
| 160 | 179 | 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) } : {}); | |
| 162 | 190 | 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; | |
| 165 | 193 | } |
| 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 | ||
| 166 | 211 | // 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. | |
| 169 | 214 | if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) { |
| 170 | 215 | const file = Bun.file(join(import.meta.dir, "..", url.pathname.slice(1))); |
| 171 | 216 | if (await file.exists()) |
| @@ -175,6 +220,32 @@ export const server = Bun.serve({ | ||
| 175 | 220 | } |
| 176 | 221 | return new Response("not found", { status: 404, headers: SECURITY }); |
| 177 | 222 | }, |
| 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 | ||
| 178 | 249 | // A bug answers with the floor and nothing else: never the runtime's own |
| 179 | 250 | // error page, which carries source and paths, and never without the |
| 180 | 251 | // headers. The detail goes to the log, where it belongs. |
| @@ -184,4 +255,7 @@ export const server = Bun.serve({ | ||
| 184 | 255 | }, |
| 185 | 256 | }); |
| 186 | 257 | |
| 258 | startArena(); | |
| 259 | ||
| 187 | 260 | 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); |