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

forked from benjivers/val

workbenchthis machine

Merge pull request #2 from wren/1-build-val-valorant-honestly-scaled-to-th

Build val — Valorant, honestly scaled to this runtime
benjivers committed bccb5ce merge Browse files
19 files changed +3805 −163
README.md +72 −13
@@ -1,23 +1,82 @@
11 # val
22
3 Five attackers with the spike, five defenders, and abilities on cooldown. First to 13.
3 Five attackers with the spike, five defenders, abilities on cooldown. First to 13.
44
5 This is an app: the forge runs it. Production follows the default branch;
6 every open pull gets a preview at its own address with its own copy of the
7 data. Everything the app keeps lives under `FORGE_APP_DATA`, which is what
8 makes "production data" and "preview data" two different things.
5 A top-down 5v5 played in a browser. One match is always running on the
6 server: open the page and you are given a seat in the round that is
7 happening, taken from a bot where it stands. When a match ends it shows the
8 result for a few seconds and starts a new one, so nobody ever arrives at a
9 frozen board.
10
11 ## What is real
12
13 Everything that decides a round is computed on the server and only the
14 server:
15
16 - **Movement, shooting and hit detection.** The client sends intent — a
17 direction, an angle, a held key — never a position. Damage is by hit
18 location (how close the ray passes the target) with falloff and armour:
19 a Vandal head shot kills through full armour at any range, a Phantom's
20 does not past fifteen metres.
21 - **Line of sight.** A snapshot only carries the enemies that viewer can
22 see. A wall of the map blocks the sight line, a smoke blocks it, a flash
23 blinds by the angle you were facing, and a blinded player is sent no
24 enemies at all. A dead player watches through their living team mates'
25 eyes rather than through walls, so dying is not a wallhack to call out.
26 No spawn can see the other spawn: mid is staggered on purpose. A patched
27 client learns nothing a plain one does not.
28 - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a
29 checkpoint at 3.5s — a defender killed past half way leaves the defuse
30 half done for the next one.
31 - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a
32 plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is
33 refused server-side.
34 - **Bots** fill every empty seat and use their kit: smokes on the way in,
35 flashes before the entry, a recon dart, a molly on the planted spike.
36
37 Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits
38 (charges last the round), a free signature on a cooldown, and an ultimate
39 paid for with ult points from kills, plants and the two orbs on the map.
40
41 Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
42
43 ## What is not
44
45 - One map, one match at a time, ten seats. There is no matchmaking, no
46 ranking and no account: your seat belongs to a session cookie.
47 - No overtime — first to 13 ends it at 12–12 too.
48 - Sight is a full circle rather than a view cone; top-down has no vertical,
49 so the hit location comes from how precisely the shot passes the target.
50 - Assists are counted as a field but never awarded.
51 - The live match lives in memory and is gone if the process restarts; only
52 finished match results are written to SQLite under `FORGE_APP_DATA`.
53
54 ## Playing
55
56 WASD move, the mouse aims, click fires, **R** reloads, **F** plants and
57 defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone
58 the left thumb walks and the right thumb aims and fires.
959
1060 ```sh
1161 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
1262 bun test # the check every pull runs
13 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
63 VAL_URL=http://127.0.0.1:8080 bun run src/tty.ts # the terminal interface
1464 ```
1565
16 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
17 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is
18 the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on
19 a real pty over the same data.
66 The terminal plays the same match over the same socket: it draws the grid,
67 the scoreboard and the round, and takes a seat. The exact command that
68 reaches it is in `FORGE_APP_SSH` when the app runs, and the page prints it.
2069
21 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`
22 (`production` or `preview`), `FORGE_APP_URL` and `FORGE_APP_COMMIT`
23 when it starts the app. `forge.json` says how.
70 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`, `FORGE_APP_URL`,
71 `FORGE_APP_SSH` and `FORGE_APP_COMMIT` when it starts the app; `forge.json`
72 says how.
73
74 ## Shape
75
76 `src/map.ts` the grid and its geometry · `src/weapons.ts` the damage table ·
77 `src/economy.ts` credits · `src/abilities.ts` the agent kits ·
78 `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes
79 no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
80 live match and its sockets · `src/server.ts` the door · `static/val.js` the
81 browser. The rules are pure functions and the tests play whole matches
82 headlessly.
src/abilities.test.ts added +142
@@ -0,0 +1,142 @@
1 import { expect, test } from "bun:test";
2 import { AGENTS, agentById, buyableSlots, cast, castable, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
3 import { newMatch, startRound, tick } from "./match.ts";
4 import type { MatchState, Player, Zone } from "./state.ts";
5
6 const live = (): MatchState => {
7 const state = newMatch(11);
8 state.phase = "live";
9 state.clock = 100;
10 return state;
11 };
12 const attacker = (state: MatchState): Player => state.players.find((p) => p.side === "atk")!;
13
14 test("every agent has two bought basics, a free signature and an ultimate", () => {
15 expect(AGENTS.length).toBe(5);
16 for (const agent of AGENTS) {
17 for (const slot of SLOTS) expect(agent.abilities[slot].name.length).toBeGreaterThan(0);
18 for (const slot of buyableSlots) expect(agent.abilities[slot].cost).toBeGreaterThan(0);
19 expect(agent.abilities.e.cost).toBe(0);
20 expect(agent.abilities.e.cooldown).toBeGreaterThan(0);
21 expect(agent.abilities.x.points).toBeGreaterThanOrEqual(6);
22 }
23 });
24
25 test("a flash blinds by the angle you were facing", () => {
26 const at = { x: 1000, y: 1000 };
27 const staring = { x: 1200, y: 1000 };
28 const facingIt = Math.PI; // looking back down the line at the flash
29 const facingAway = 0;
30 expect(flashDuration(1.7, staring, at, facingIt, false)).toBeGreaterThan(1.6);
31 expect(flashDuration(1.7, staring, at, facingAway, false)).toBe(0);
32 // Half turned away is a shorter blind than staring straight at it.
33 const glance = flashDuration(1.7, staring, at, facingIt - 1.2, false);
34 expect(glance).toBeGreaterThan(0);
35 expect(glance).toBeLessThan(1.7);
36 });
37
38 test("a wall between you and the flash means no blind at all", () => {
39 const blind = flashDuration(1.7, { x: 1200, y: 1000 }, { x: 1000, y: 1000 }, Math.PI, true);
40 expect(blind).toBe(0);
41 });
42
43 test("a smoke blocks the sight line for as long as it lives", () => {
44 const smoke: Zone = {
45 id: 1, kind: "smoke", owner: "x", side: "atk", x: 1200, y: 1000, r: 140,
46 life: 8, fuse: 0, beat: 0, pulses: 0, damage: 0, blocks: true, solid: false,
47 };
48 // Straight down the mid corridor, which is open floor end to end.
49 const from = { x: 1100, y: 1000 };
50 const to = { x: 1300, y: 1000 };
51 expect(sightBlocked(from, to, [])).toBe(false);
52 expect(sightBlocked(from, to, [smoke])).toBe(true);
53 // Standing off the line, the smoke is not in the way.
54 expect(sightBlocked({ x: 1100, y: 700 }, { x: 1300, y: 700 }, [smoke])).toBe(false);
55 });
56
57 test("a charge is spent on a cast and refused when there is none", () => {
58 const state = live();
59 const player = attacker(state);
60 player.agent = "veil";
61 player.charges.c = 1;
62 expect(cast(state, player, "c")).toBe(true);
63 expect(player.charges.c).toBe(0);
64 expect(state.zones.some((z) => z.kind === "smoke")).toBe(true);
65 expect(cast(state, player, "c")).toBe(false);
66 expect(castable(player, "c")).toBe(false);
67 });
68
69 test("the signature goes on cooldown and comes back on its own", () => {
70 const state = live();
71 const player = attacker(state);
72 player.agent = "veil";
73 const cooldown = agentById("veil").abilities.e.cooldown;
74 expect(cast(state, player, "e")).toBe(true);
75 expect(player.charges.e).toBe(0);
76 expect(player.cooldown.e).toBe(cooldown);
77 for (let i = 0; i < Math.ceil(cooldown * 10) + 4; i++) tick(state, 0.1);
78 expect(player.cooldown.e).toBe(0);
79 expect(castable(player, "e")).toBe(true);
80 });
81
82 test("an ultimate costs its points and empties the meter", () => {
83 const state = live();
84 const player = attacker(state);
85 player.agent = "sable";
86 player.ult = 3;
87 expect(cast(state, player, "x")).toBe(false);
88 player.ult = 7;
89 expect(cast(state, player, "x")).toBe(true);
90 expect(player.ult).toBe(0);
91 // Hunt reveals every enemy and leaves the caster's own side alone.
92 for (const other of state.players) expect(other.revealed > 0).toBe(other.side !== player.side);
93 });
94
95 test("a molly burns whoever stands in it and leaves the thrower's team alone", () => {
96 const state = live();
97 const player = attacker(state);
98 player.agent = "ember";
99 player.charges.q = 1;
100 const victim = state.players.find((p) => p.side === "def")!;
101 const mate = state.players.find((p) => p.side === "atk" && p.id !== player.id)!;
102 victim.x = player.x + 120;
103 victim.y = player.y;
104 mate.x = player.x + 120;
105 mate.y = player.y;
106 player.angle = 0;
107 player.x = victim.x - 120;
108 cast(state, player, "q");
109 for (const zone of state.zones) {
110 zone.x = victim.x;
111 zone.y = victim.y;
112 }
113 for (let i = 0; i < 20; i++) tick(state, 0.1);
114 expect(victim.hp).toBeLessThan(100);
115 expect(mate.hp).toBe(100);
116 });
117
118 test("a wall is solid: it blocks the sight line that was open", () => {
119 const state = live();
120 const player = attacker(state);
121 player.agent = "rook";
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 };
127 const openBefore = !sightBlocked(player, behind, state.zones);
128 expect(cast(state, player, "e")).toBe(true);
129 expect(openBefore).toBe(true);
130 expect(sightBlocked(player, behind, state.zones)).toBe(true);
131 expect(state.zones.every((z) => z.solid)).toBe(true);
132 });
133
134 test("a round starts with the signature ready and no bought charges", () => {
135 const state = newMatch(3);
136 startRound(state, true);
137 for (const player of state.players) {
138 expect(player.charges.c).toBe(0);
139 expect(player.charges.q).toBe(0);
140 expect(player.charges.e).toBe(1);
141 }
142 });
src/abilities.ts added +273
@@ -0,0 +1,273 @@
1 /**
2 * Agents and their kits.
3 *
4 * Five agents, each with two basics bought with credits (charges last the
5 * round), one signature that is free and comes back on a cooldown, and one
6 * ultimate paid for in ult points. The effects themselves are a short list of
7 * kinds — flash, smoke, molly, dart, heal, wall, trip — so an agent is data,
8 * not code.
9 *
10 * The two rules worth testing on their own: a flash blinds by the angle you
11 * were facing and only with line of sight, and a smoke blocks that sight.
12 */
13
14 import { rayHitWall, segmentHitsCircle, wallBetween, type Point } from "./map.ts";
15 import type { MatchState, Player, Slot, Zone } from "./state.ts";
16
17 export type Kind =
18 | "flash"
19 | "smoke"
20 | "molly"
21 | "dart"
22 | "heal"
23 | "wall"
24 | "trip"
25 | "haste"
26 | "rage"
27 | "revealall"
28 | "slowall";
29
30 export type Ability = {
31 slot: Slot;
32 name: string;
33 kind: Kind;
34 /** Credits for a basic; 0 for the signature and the ultimate. */
35 cost: number;
36 charges: number;
37 /** Seconds the signature takes to come back. 0 means it does not. */
38 cooldown: number;
39 /** Ult points the ultimate costs. */
40 points: number;
41 range: number;
42 radius: number;
43 duration: number;
44 damage: number;
45 fuse: number;
46 text: string;
47 };
48
49 export type Agent = {
50 id: string;
51 name: string;
52 role: string;
53 blurb: string;
54 abilities: Record<Slot, Ability>;
55 };
56
57 const basic = (a: Partial<Ability> & { slot: Slot; name: string; kind: Kind; text: string }): Ability => ({
58 cost: 200,
59 charges: 1,
60 cooldown: 0,
61 points: 0,
62 range: 700,
63 radius: 110,
64 duration: 10,
65 damage: 0,
66 fuse: 0,
67 ...a,
68 });
69
70 export const AGENTS: Agent[] = [
71 {
72 id: "ember",
73 name: "Ember",
74 role: "Duelist",
75 blurb: "Flash in, burn them off the plant, come back from nothing.",
76 abilities: {
77 c: basic({ slot: "c", name: "Flashfire", kind: "flash", cost: 250, charges: 2, range: 620, radius: 900, fuse: 0.55, duration: 1.7, text: "Throws a flash that pops after half a second and blinds anyone looking near it." }),
78 q: basic({ slot: "q", name: "Blaze", kind: "molly", cost: 200, charges: 1, range: 620, radius: 95, duration: 6, damage: 26, text: "A pool of fire: 26 damage a second to anyone standing in it." }),
79 e: basic({ slot: "e", name: "Second Wind", kind: "haste", cost: 0, charges: 1, cooldown: 35, duration: 4, text: "Signature. Four seconds of speed — take the angle before they re-peek." }),
80 x: basic({ slot: "x", name: "Rebirth", kind: "rage", cost: 0, charges: 0, points: 6, duration: 6, text: "Ultimate. Full health, armour back, every basic charge refilled, six seconds of speed." }),
81 },
82 },
83 {
84 id: "veil",
85 name: "Veil",
86 role: "Controller",
87 blurb: "Takes the sightline away and gives the site to whoever she likes.",
88 abilities: {
89 c: basic({ slot: "c", name: "Cloudburst", kind: "smoke", cost: 100, charges: 2, range: 760, radius: 105, duration: 8, text: "A short smoke. Cheap enough to throw two on a pistol round." }),
90 q: basic({ slot: "q", name: "Blind Spot", kind: "flash", cost: 250, charges: 1, range: 600, radius: 850, fuse: 0.5, duration: 1.6, text: "A flash for the entry you are about to make." }),
91 e: basic({ slot: "e", name: "Curtain", kind: "smoke", cost: 0, charges: 1, cooldown: 35, range: 900, radius: 150, duration: 15, text: "Signature. A wide smoke for fifteen seconds — nobody sees through it, server side." }),
92 x: basic({ slot: "x", name: "Blackout", kind: "smoke", cost: 0, charges: 0, points: 7, range: 1100, radius: 280, duration: 12, damage: -1, text: "Ultimate. A site-sized smoke that slows every enemy inside it." }),
93 },
94 },
95 {
96 id: "sable",
97 name: "Sable",
98 role: "Initiator",
99 blurb: "Finds them before the team walks in, and moves them off the angle.",
100 abilities: {
101 c: basic({ slot: "c", name: "Recon Dart", kind: "dart", cost: 250, charges: 1, range: 900, radius: 340, duration: 6, text: "Sticks and sweeps three times, revealing enemies it can see for two seconds each." }),
102 q: basic({ slot: "q", name: "Shock Bolt", kind: "molly", cost: 150, charges: 2, range: 820, radius: 90, duration: 1.1, damage: 70, text: "A bolt that cracks for 70 a second — clears a corner or finishes a plant." }),
103 e: basic({ slot: "e", name: "Owl Flash", kind: "flash", cost: 0, charges: 1, cooldown: 40, range: 640, radius: 900, fuse: 0.6, duration: 1.6, text: "Signature. A flash on a cooldown, so there is always one for the retake." }),
104 x: basic({ slot: "x", name: "Hunt", kind: "revealall", cost: 0, charges: 0, points: 7, duration: 6, text: "Ultimate. Every enemy on the map is revealed for six seconds." }),
105 },
106 },
107 {
108 id: "mender",
109 name: "Mender",
110 role: "Sentinel",
111 blurb: "Holds the flank shut and keeps the entry alive long enough to trade.",
112 abilities: {
113 c: basic({ slot: "c", name: "Healing Pulse", kind: "heal", cost: 200, charges: 2, range: 260, radius: 260, duration: 3, damage: 60, text: "Heals you or the nearest hurt ally 60 health over three seconds." }),
114 q: basic({ slot: "q", name: "Barrier", kind: "wall", cost: 400, charges: 1, range: 520, radius: 120, duration: 12, text: "A solid wall for twelve seconds: nothing walks through it and nothing sees through it." }),
115 e: basic({ slot: "e", name: "Tripwire", kind: "trip", cost: 0, charges: 1, cooldown: 40, range: 300, radius: 120, duration: 45, text: "Signature. Watches the flank: the enemy who walks in is revealed and slowed." }),
116 x: basic({ slot: "x", name: "Lockdown", kind: "slowall", cost: 0, charges: 0, points: 8, duration: 5, text: "Ultimate. Every enemy is slowed and revealed for five seconds." }),
117 },
118 },
119 {
120 id: "rook",
121 name: "Rook",
122 role: "Initiator",
123 blurb: "Builds the angle he wants and sets the site on fire when it is lost.",
124 abilities: {
125 c: basic({ slot: "c", name: "Smoke Grenade", kind: "smoke", cost: 150, charges: 1, range: 800, radius: 130, duration: 10, text: "One smoke, thrown long." }),
126 q: basic({ slot: "q", name: "Flashbang", kind: "flash", cost: 200, charges: 2, range: 600, radius: 800, fuse: 0.6, duration: 1.5, text: "Two flashes: one for the entry, one for the exit." }),
127 e: basic({ slot: "e", name: "Stone Wall", kind: "wall", cost: 0, charges: 1, cooldown: 45, range: 480, radius: 130, duration: 10, text: "Signature. A free wall, back every forty-five seconds." }),
128 x: basic({ slot: "x", name: "Firestorm", kind: "molly", cost: 0, charges: 0, points: 7, range: 900, radius: 210, duration: 8, damage: 34, text: "Ultimate. A site-wide fire nobody defuses through." }),
129 },
130 },
131 ];
132
133 export const agentById = (id: string): Agent => AGENTS.find((a) => a.id === id) ?? AGENTS[0]!;
134 export const isAgentId = (value: unknown): value is string =>
135 typeof value === "string" && AGENTS.some((a) => a.id === value);
136
137 export const SLOTS: Slot[] = ["c", "q", "e", "x"];
138 /** Bought before the round; the signature and the ultimate are never for sale. */
139 export const buyableSlots: Slot[] = ["c", "q"];
140
141 /**
142 * How long a flash blinds, in seconds. Facing it is the worst case; looking
143 * away is free; a wall or a smoke between you and it means nothing at all.
144 * `blocked` is the caller's line-of-sight answer, so this stays pure.
145 */
146 export function flashDuration(
147 full: number,
148 from: Point,
149 at: Point,
150 facing: number,
151 blocked: boolean,
152 ): number {
153 if (blocked) return 0;
154 const toFlash = Math.atan2(at.y - from.y, at.x - from.x);
155 let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
156 off = Math.min(off, Math.PI);
157 const wide = (100 * Math.PI) / 180;
158 if (off > wide) return 0;
159 const narrow = (45 * Math.PI) / 180;
160 const facingFactor = off <= narrow ? 1 : 1 - ((off - narrow) / (wide - narrow)) * 0.75;
161 const distance = Math.hypot(from.x - at.x, from.y - at.y);
162 const near = distance < 900 ? 1 : distance < 1500 ? 0.6 : 0.35;
163 return Math.round(full * facingFactor * near * 100) / 100;
164 }
165
166 /** Whether a sight line survives the smokes and walls standing on the map. */
167 export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => {
168 if (wallBetween(from, to)) return true;
169 for (const zone of zones)
170 if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true;
171 return false;
172 };
173
174 /** Can this player cast this slot right now, and why not. */
175 export function castable(player: Player, slot: Slot): boolean {
176 if (!player.alive) return false;
177 const ability = agentById(player.agent).abilities[slot];
178 if (slot === "x") return player.ult >= ability.points;
179 if (player.cooldown[slot] > 0) return false;
180 return player.charges[slot] > 0;
181 }
182
183 /** Where an ability lands: down the aim line, stopped by the first wall. */
184 export const castPoint = (player: Player, range: number): Point =>
185 rayHitWall(player, player.angle, range);
186
187 const zone = (state: MatchState, z: Omit<Zone, "id">): Zone => {
188 const made = { ...z, id: state.nextZone++ };
189 state.zones.push(made);
190 return made;
191 };
192
193 /**
194 * Spend the charge and put the effect on the map. Returns false when the
195 * cast was refused — no charge, still on cooldown, not enough ult points —
196 * so the caller never has to guess.
197 */
198 export function cast(state: MatchState, player: Player, slot: Slot): boolean {
199 if (!castable(player, slot)) return false;
200 const agent = agentById(player.agent);
201 const ability = agent.abilities[slot];
202 const spot = castPoint(player, ability.range);
203 const base = { owner: player.id, side: player.side, life: ability.duration, fuse: ability.fuse, beat: 0, pulses: 0, damage: ability.damage, blocks: false, solid: false };
204
205 switch (ability.kind) {
206 case "flash":
207 zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration });
208 break;
209 case "smoke":
210 zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true });
211 break;
212 case "molly":
213 zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 });
214 break;
215 case "dart":
216 zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 });
217 break;
218 case "trip":
219 zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 });
220 break;
221 case "heal": {
222 const hurt = state.players
223 .filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range)
224 .sort((a, b) => a.hp - b.hp)[0];
225 const target = hurt ?? player;
226 zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id });
227 break;
228 }
229 case "wall": {
230 // Three blockers across the aim line: solid to walk through and to see
231 // through, and made of circles so every other test still works.
232 const across = player.angle + Math.PI / 2;
233 for (let i = -1; i <= 1; i++)
234 zone(state, {
235 ...base,
236 kind: "wall",
237 x: spot.x + Math.cos(across) * i * (ability.radius * 0.7),
238 y: spot.y + Math.sin(across) * i * (ability.radius * 0.7),
239 r: ability.radius * 0.45,
240 blocks: true,
241 solid: true,
242 });
243 break;
244 }
245 case "haste":
246 player.haste = Math.max(player.haste, ability.duration);
247 break;
248 case "rage":
249 player.hp = 100;
250 player.armour = Math.max(player.armour, 25);
251 player.haste = Math.max(player.haste, ability.duration);
252 for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges);
253 break;
254 case "revealall":
255 for (const other of state.players)
256 if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration);
257 break;
258 case "slowall":
259 for (const other of state.players)
260 if (other.side !== player.side) {
261 other.revealed = Math.max(other.revealed, ability.duration);
262 other.slow = Math.max(other.slow, ability.duration);
263 }
264 break;
265 }
266
267 if (slot === "x") player.ult = 0;
268 else {
269 player.charges[slot] = Math.max(0, player.charges[slot] - 1);
270 if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown;
271 }
272 return true;
273 }
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/bots.ts added +257
@@ -0,0 +1,257 @@
1 /**
2 * Bots. They fill the seats nobody is sitting in, and they use their kit:
3 * a smoke on the way in, a flash before the entry, a dart to find the hold,
4 * a molly on the planted spike, an ultimate when it is worth something.
5 *
6 * A bot only ever writes to its own `input`, the same field a browser writes
7 * to, so the match loop cannot tell them apart and neither can a cheat.
8 */
9
10 import { agentById, castable } from "./abilities.ts";
11 import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
12 import { buyAbility, buyShield, buyWeapon, random, SPIKE_REACH, visible } from "./match.ts";
13 import type { MatchState, Player, Slot } from "./state.ts";
14 import { WEAPONS, type WeaponId } from "./weapons.ts";
15
16 /**
17 * The site the attacking bots agree on this round. It comes from the match
18 * id and the round number, never the running seed: five bots have to reach
19 * the same answer on every tick or they walk in circles between the sites.
20 */
21 export function attackSite(state: MatchState): Site {
22 let hash = 0;
23 for (const ch of state.id) hash = (hash * 31 + ch.charCodeAt(0)) % 9973;
24 return (hash + state.round) % 2 === 0 ? "A" : "B";
25 }
26
27 const holdSpots = (state: MatchState): Point[] => {
28 const a = siteCentre("A");
29 const b = siteCentre("B");
30 return [
31 { x: a.x + 60, y: a.y + 90 },
32 { x: a.x - 40, y: a.y - 20 },
33 { x: 1200, y: 640 },
34 { x: b.x - 60, y: b.y + 90 },
35 { x: b.x + 40, y: b.y - 20 },
36 ];
37 };
38
39 const ladder: { weapon: WeaponId; shield: "light" | "heavy" | null; floor: number }[] = [
40 { weapon: "operator", shield: "heavy", floor: 6200 },
41 { weapon: "vandal", shield: "heavy", floor: 4400 },
42 { weapon: "phantom", shield: "heavy", floor: 4200 },
43 { weapon: "spectre", shield: "heavy", floor: 2900 },
44 { weapon: "spectre", shield: "light", floor: 2100 },
45 { weapon: "sheriff", shield: "light", floor: 1300 },
46 { weapon: "ghost", shield: "light", floor: 950 },
47 { weapon: "classic", shield: "light", floor: 400 },
48 ];
49
50 /**
51 * What a bot buys. It saves on a broken economy — under 2000 with an empty
52 * gun rack is an eco round, and the credits are worth more next round.
53 */
54 export function botBuy(state: MatchState, player: Player): void {
55 const saving = player.credits < 2000 && state.round > 1 && player.weapon === "classic" && state.lossStreak[player.team] > 0;
56 if (!saving) {
57 const pick = ladder.find((step) => player.credits >= step.floor);
58 if (pick && WEAPONS[pick.weapon].cost > 0) buyWeapon(state, player, pick.weapon);
59 if (pick?.shield) buyShield(state, player, player.credits > 3500 ? "heavy" : pick.shield);
60 }
61 const agent = agentById(player.agent);
62 // Abilities last: a flash is worth more than the last hundred credits.
63 for (const slot of ["c", "q", "c", "q"] as Slot[]) {
64 const ability = agent.abilities[slot];
65 if (player.credits >= ability.cost + (saving ? 400 : 0)) buyAbility(state, player, slot);
66 }
67 }
68
69 const enemiesSeen = (state: MatchState, bot: Player): Player[] =>
70 state.players.filter((p) => p.alive && p.side !== bot.side && visible(state, bot, p));
71
72 const towards = (bot: Player, point: Point): { mx: number; my: number } => {
73 const dx = point.x - bot.x;
74 const dy = point.y - bot.y;
75 const len = Math.hypot(dx, dy) || 1;
76 return { mx: dx / len, my: dy / len };
77 };
78
79 const turn = (bot: Player, target: number, rate: number): void => {
80 let diff = ((target - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
81 bot.input.angle = bot.angle + Math.max(-rate, Math.min(rate, diff));
82 };
83
84 /** One bot's decision for this tick. */
85 export function botThink(state: MatchState, bot: Player, dt: number): void {
86 if (!bot.alive) return;
87 if (state.phase === "buy") {
88 if (bot.think <= 0) {
89 botBuy(state, bot);
90 bot.think = 99;
91 }
92 bot.input.mx = 0;
93 bot.input.my = 0;
94 return;
95 }
96 // The buy left the timer parked; the first live tick starts it thinking.
97 if (bot.think > 5) bot.think = 0;
98 bot.think -= dt;
99 const spike = state.spike;
100 const site = spike.planted && spike.site ? spike.site : attackSite(state);
101 const target = enemiesSeen(state, bot).sort(
102 (a, b) => Math.hypot(a.x - bot.x, a.y - bot.y) - Math.hypot(b.x - bot.x, b.y - bot.y),
103 )[0];
104
105 // --- where to be
106 let goal: Point = siteCentre(site);
107 if (bot.side === "atk") {
108 if (spike.planted) goal = { x: spike.x + (random(state) - 0.5) * 220, y: spike.y + (random(state) - 0.5) * 220 };
109 else if (!spike.carried && !bot.spike) {
110 const loose = { x: spike.x, y: spike.y };
111 if (walkDistance(bot, loose) < 8) goal = loose;
112 }
113 } else {
114 const spots = holdSpots(state);
115 const index = Number(bot.id.slice(-1)) % spots.length;
116 goal = spike.planted ? { x: spike.x, y: spike.y } : spots[index]!;
117 }
118 bot.goal = goal;
119
120 const distToGoal = Math.hypot(goal.x - bot.x, goal.y - bot.y);
121 const step = pathStep(bot, goal);
122 const walk = towards(bot, step);
123 bot.input.mx = walk.mx;
124 bot.input.my = walk.my;
125
126 // --- the kit
127 if (bot.think <= 0) {
128 bot.think = 0.4 + random(state) * 0.5;
129 useKit(state, bot, target ?? null, site, distToGoal);
130 }
131
132 // --- the spike
133 bot.input.use = false;
134 if (bot.side === "atk" && bot.spike && !spike.planted) {
135 const onSite = siteAt(bot.x, bot.y) === site;
136 if (onSite && (!target || bot.planting > 0)) {
137 bot.input.use = true;
138 bot.input.mx = 0;
139 bot.input.my = 0;
140 }
141 }
142 if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < SPIKE_REACH - 6) {
143 const safe = !target || spike.fuse < 8;
144 if (safe) {
145 bot.input.use = true;
146 bot.input.mx = 0;
147 bot.input.my = 0;
148 }
149 }
150 if (bot.blind > 0) {
151 // Blinded: back off and stop shooting, like a person would.
152 bot.input.fire = false;
153 bot.input.mx *= -0.6;
154 bot.input.my *= -0.6;
155 return;
156 }
157
158 // --- the gun
159 bot.input.reload = bot.ammo === 0;
160 if (!target) {
161 bot.input.fire = false;
162 if (Math.hypot(bot.input.mx, bot.input.my) > 0.01)
163 turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt);
164 return;
165 }
166 const range = Math.hypot(target.x - bot.x, target.y - bot.y);
167 const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + (random(state) - 0.5) * 0.06;
168 turn(bot, aim, 7 * dt);
169 const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
170 const weapon = WEAPONS[bot.weapon];
171 bot.input.fire = off < 0.07 && range < weapon.range && bot.ammo > 0;
172 // Stand still to shoot at range, close the gap up front.
173 if (range > 420) {
174 bot.input.mx *= 0.15;
175 bot.input.my *= 0.15;
176 } else if (range < 160) {
177 const away = towards(bot, target);
178 bot.input.mx = -away.mx * 0.5;
179 bot.input.my = -away.my * 0.5;
180 }
181 }
182
183 /** Ability decisions, kept in one place so each is a sentence you can read. */
184 function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void {
185 const agent = agentById(bot.agent);
186 const spike = state.spike;
187 const centre = siteCentre(site);
188 const aimAt = (point: Point): void => {
189 bot.input.angle = Math.atan2(point.y - bot.y, point.x - bot.x);
190 bot.angle = bot.input.angle;
191 };
192 const tryCast = (slot: Slot, point: Point): boolean => {
193 if (!castable(bot, slot)) return false;
194 aimAt(point);
195 bot.input.cast = slot;
196 return true;
197 };
198
199 for (const slot of ["x", "c", "q", "e"] as Slot[]) {
200 const ability = agent.abilities[slot];
201 if (!castable(bot, slot)) continue;
202 const kind = ability.kind;
203
204 if (kind === "rage" || kind === "haste") {
205 if (bot.hp < 70 || (target && distToGoal < 500)) return void tryCast(slot, centre);
206 continue;
207 }
208 if (kind === "revealall" || kind === "slowall") {
209 if (target || distToGoal < 420) return void tryCast(slot, centre);
210 continue;
211 }
212 if (kind === "heal") {
213 const hurt = state.players.find((p) => p.alive && p.side === bot.side && p.hp <= 60 && Math.hypot(p.x - bot.x, p.y - bot.y) < ability.range);
214 if (hurt) return void tryCast(slot, hurt);
215 continue;
216 }
217 if (kind === "molly") {
218 // Fire on the planted spike, or on the choke ahead of us.
219 if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range)
220 return void tryCast(slot, spike);
221 if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && Math.hypot(target.x - bot.x, target.y - bot.y) > 120)
222 return void tryCast(slot, target);
223 continue;
224 }
225 if (kind === "flash") {
226 const enemyClose = target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range;
227 if (enemyClose) return void tryCast(slot, target!);
228 if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 150) return void tryCast(slot, centre);
229 continue;
230 }
231 if (kind === "smoke") {
232 if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 260) return void tryCast(slot, centre);
233 if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range && Math.hypot(bot.x - spike.x, bot.y - spike.y) > 200)
234 return void tryCast(slot, spike);
235 continue;
236 }
237 if (kind === "dart") {
238 if (distToGoal < ability.range && distToGoal > 200) return void tryCast(slot, centre);
239 continue;
240 }
241 if (kind === "wall") {
242 if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && bot.hp < 60)
243 return void tryCast(slot, target);
244 if (bot.side === "def" && !spike.planted && distToGoal < 200 && state.clock > 60) return void tryCast(slot, centre);
245 continue;
246 }
247 if (kind === "trip") {
248 if (bot.side === "def" && distToGoal < 160) return void tryCast(slot, { x: bot.x + (random(state) - 0.5) * 200, y: bot.y + (random(state) - 0.5) * 200 });
249 continue;
250 }
251 }
252 }
253
254 /** Drive every seat that nobody is sitting in. */
255 export function runBots(state: MatchState, dt: number): void {
256 for (const player of state.players) if (player.bot) botThink(state, player, dt);
257 }
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/economy.test.ts added +28
@@ -0,0 +1,28 @@
1 import { expect, test } from "bun:test";
2 import { award, CREDIT_CAP, lossBonus, roundReward, spend, START_CREDITS } from "./economy.ts";
3
4 test("a round starts on 800 and nothing goes past 9000", () => {
5 expect(START_CREDITS).toBe(800);
6 expect(award(8900, 200)).toBe(CREDIT_CAP);
7 expect(award(100, -500)).toBe(0);
8 });
9
10 test("the loss bonus rises 1900 / 2400 / 2900 and stops", () => {
11 expect(lossBonus(1)).toBe(1900);
12 expect(lossBonus(2)).toBe(2400);
13 expect(lossBonus(3)).toBe(2900);
14 expect(lossBonus(7)).toBe(2900);
15 });
16
17 test("a losing attack that planted keeps the plant bonus on top", () => {
18 expect(roundReward(false, 1, false)).toBe(1900);
19 expect(roundReward(false, 1, true)).toBe(1900 + 300);
20 expect(roundReward(true, 3, false)).toBe(3000);
21 });
22
23 test("a buy you cannot afford is refused, never a debt", () => {
24 expect(spend(800, 2900)).toEqual({ ok: false, credits: 800 });
25 expect(spend(2900, 2900)).toEqual({ ok: true, credits: 0 });
26 expect(spend(800, -100).ok).toBe(false);
27 expect(spend(800, Number.NaN).ok).toBe(false);
28 });
src/economy.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * Credits. The whole economy is four numbers and a losing streak, and all of
3 * it is pure: the match calls these and stores what comes back.
4 */
5
6 export const START_CREDITS = 800;
7 export const CREDIT_CAP = 9000;
8 export const KILL_REWARD = 200;
9 export const PLANT_REWARD = 300;
10 export const WIN_REWARD = 3000;
11 /** Rising with the streak, and capped at the third step. */
12 export const LOSS_BONUS = [1900, 2400, 2900];
13
14 export const clampCredits = (credits: number): number =>
15 Math.max(0, Math.min(CREDIT_CAP, Math.floor(credits)));
16
17 export const award = (credits: number, amount: number): number => clampCredits(credits + amount);
18
19 /** The loss bonus for a team that has now lost `streak` rounds in a row. */
20 export const lossBonus = (streak: number): number =>
21 LOSS_BONUS[Math.min(Math.max(streak, 1), LOSS_BONUS.length) - 1]!;
22
23 /** What a round's end pays each member of a team. */
24 export function roundReward(won: boolean, lossStreak: number, planted: boolean): number {
25 if (won) return WIN_REWARD;
26 // A team that lost the round but got the spike down keeps a little more.
27 return lossBonus(lossStreak) + (planted ? PLANT_REWARD : 0);
28 }
29
30 /** A purchase either happens at a price or is refused; never a debt. */
31 export function spend(credits: number, cost: number): { ok: boolean; credits: number } {
32 if (!Number.isFinite(cost) || cost < 0 || cost > credits) return { ok: false, credits };
33 return { ok: true, credits: clampCredits(credits - cost) };
34 }
src/map.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * The map: one grid, and the geometry everything else asks questions of.
3 *
4 * Two sites (A left, B right), a mid corridor between the spawns, and a back
5 * corridor along the bottom that links A main, spawn and B main — the flank.
6 * Tiles are 80 units; a player is a circle of radius 16, so a one-tile
7 * corridor is comfortably wide and a wall is never thin enough for a ray to
8 * step over.
9 *
10 * Everything here is a pure function of the grid: collision, line of sight
11 * and the walk distance a bot uses to path. No state.
12 */
13
14 export const TILE = 80;
15
16 // '#' wall · '.' floor · 'A'/'B' plant site · 'a' attacker spawn · 'd' defender spawn
17 const ROWS_SRC = [
18 "##############################",
19 "#........###.dddd.###........#",
20 "#.AAAAA..###.dddd.###..BBBBB.#",
21 "#.AAAAA..##........##..BBBBB.#",
22 "#.AAAAA..##.#....#.##..BBBBB.#",
23 "#.AAAAA..##........##..BBBBB.#",
24 "#...##......#....#......##...#",
25 "#..####....#......#....####..#",
26 "#..####..###......###..####..#",
27 "#..####..####....####..####..#",
28 "#..####..####.##.####..####..#",
29 "#........####.##.####........#",
30 "#..####..####....####..####..#",
31 "#..####..#####..#####..####..#",
32 "#..####..#####..#####..####..#",
33 "#..####..###......###..####..#",
34 "#........##..aaaa..##........#",
35 "#............aaaa............#",
36 "##############################",
37 ];
38
39 export const COLS = ROWS_SRC[0]!.length;
40 export const ROWS = ROWS_SRC.length;
41 export const WIDTH = COLS * TILE;
42 export const HEIGHT = ROWS * TILE;
43
44 export const grid: string[] = ROWS_SRC;
45 export const gridRows = (): string[] => ROWS_SRC.slice();
46
47 export type Site = "A" | "B";
48 export type Point = { x: number; y: number };
49
50 const at = (col: number, row: number): string =>
51 row < 0 || row >= ROWS || col < 0 || col >= COLS ? "#" : ROWS_SRC[row]![col]!;
52
53 export const solidTile = (col: number, row: number): boolean => at(col, row) === "#";
54
55 export const tileOf = (x: number, y: number): { col: number; row: number } => ({
56 col: Math.floor(x / TILE),
57 row: Math.floor(y / TILE),
58 });
59
60 export const centreOf = (col: number, row: number): Point => ({
61 x: col * TILE + TILE / 2,
62 y: row * TILE + TILE / 2,
63 });
64
65 export const solidAt = (x: number, y: number): boolean => {
66 const t = tileOf(x, y);
67 return solidTile(t.col, t.row);
68 };
69
70 /** The site a point plants on, or null: the spike only arms inside a site. */
71 export const siteAt = (x: number, y: number): Site | null => {
72 const t = tileOf(x, y);
73 const c = at(t.col, t.row);
74 return c === "A" || c === "B" ? c : null;
75 };
76
77 const cellsOf = (mark: string): Point[] => {
78 const out: Point[] = [];
79 for (let row = 0; row < ROWS; row++)
80 for (let col = 0; col < COLS; col++) if (at(col, row) === mark) out.push(centreOf(col, row));
81 return out;
82 };
83
84 export const spawnCells = (side: "atk" | "def"): Point[] => cellsOf(side === "atk" ? "a" : "d");
85 export const siteCells = (site: Site): Point[] => cellsOf(site);
86
87 export const siteCentre = (site: Site): Point => {
88 const cells = siteCells(site);
89 const sum = cells.reduce((a, p) => ({ x: a.x + p.x, y: a.y + p.y }), { x: 0, y: 0 });
90 return { x: sum.x / cells.length, y: sum.y / cells.length };
91 };
92
93 /** Ult orbs: one on each half of mid, free for whoever walks over them. */
94 export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)];
95
96 /**
97 * Slide a circle through the grid: each axis is moved and resolved on its
98 * own, so running along a wall slides instead of sticking.
99 */
100 export function moveCircle(x: number, y: number, dx: number, dy: number, r: number): Point {
101 let nx = x;
102 let ny = y;
103 if (dx !== 0) {
104 const test = nx + dx;
105 if (!blockedCircle(test, ny, r)) nx = test;
106 else {
107 const col = Math.floor(test / TILE);
108 nx = dx > 0 ? col * TILE - r - 0.01 : (col + 1) * TILE + r + 0.01;
109 if (blockedCircle(nx, ny, r)) nx = x;
110 }
111 }
112 if (dy !== 0) {
113 const test = ny + dy;
114 if (!blockedCircle(nx, test, r)) ny = test;
115 else {
116 const row = Math.floor(test / TILE);
117 ny = dy > 0 ? row * TILE - r - 0.01 : (row + 1) * TILE + r + 0.01;
118 if (blockedCircle(nx, ny, r)) ny = y;
119 }
120 }
121 return { x: nx, y: ny };
122 }
123
124 /** True when a circle at (x,y) overlaps any solid tile. */
125 export function blockedCircle(x: number, y: number, r: number): boolean {
126 const c0 = Math.floor((x - r) / TILE);
127 const c1 = Math.floor((x + r) / TILE);
128 const r0 = Math.floor((y - r) / TILE);
129 const r1 = Math.floor((y + r) / TILE);
130 for (let row = r0; row <= r1; row++)
131 for (let col = c0; col <= c1; col++) {
132 if (!solidTile(col, row)) continue;
133 const nearX = Math.max(col * TILE, Math.min(x, col * TILE + TILE));
134 const nearY = Math.max(row * TILE, Math.min(y, row * TILE + TILE));
135 const dx = x - nearX;
136 const dy = y - nearY;
137 if (dx * dx + dy * dy < r * r) return true;
138 }
139 return false;
140 }
141
142 /**
143 * Does a wall stand between two points? Sampled every third of a tile: a
144 * wall is a whole tile thick, so nothing is ever stepped over.
145 */
146 export function wallBetween(a: Point, b: Point): boolean {
147 const dx = b.x - a.x;
148 const dy = b.y - a.y;
149 const dist = Math.hypot(dx, dy);
150 const steps = Math.ceil(dist / (TILE / 3));
151 for (let i = 1; i < steps; i++) {
152 const t = i / steps;
153 if (solidAt(a.x + dx * t, a.y + dy * t)) return true;
154 }
155 return false;
156 }
157
158 /** Where a shot stops: the first wall along the ray, or its full range. */
159 export function rayHitWall(from: Point, angle: number, range: number): Point {
160 const dx = Math.cos(angle);
161 const dy = Math.sin(angle);
162 const step = TILE / 4;
163 for (let d = step; d <= range; d += step) {
164 const x = from.x + dx * d;
165 const y = from.y + dy * d;
166 if (solidAt(x, y)) return { x: from.x + dx * (d - step), y: from.y + dy * (d - step) };
167 }
168 return { x: from.x + dx * range, y: from.y + dy * range };
169 }
170
171 /** Closest approach of a segment to a point, as distance and travel along it. */
172 export function segmentPoint(a: Point, b: Point, p: Point): { dist: number; travel: number } {
173 const dx = b.x - a.x;
174 const dy = b.y - a.y;
175 const len2 = dx * dx + dy * dy;
176 const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
177 const cx = a.x + dx * t;
178 const cy = a.y + dy * t;
179 return { dist: Math.hypot(p.x - cx, p.y - cy), travel: t * Math.sqrt(len2) };
180 }
181
182 export const segmentHitsCircle = (a: Point, b: Point, c: Point, r: number): boolean =>
183 segmentPoint(a, b, c).dist <= r;
184
185 // Walk distance from a goal tile, breadth-first over floor. Bots ask for it
186 // every few ticks; the field for a goal is built once and kept for the round.
187 const fields = new Map<number, Int32Array>();
188
189 export const clearPaths = (): void => void fields.clear();
190
191 function field(goalCol: number, goalRow: number): Int32Array {
192 const key = goalRow * COLS + goalCol;
193 const cached = fields.get(key);
194 if (cached) return cached;
195 const dist = new Int32Array(COLS * ROWS).fill(-1);
196 if (!solidTile(goalCol, goalRow)) {
197 dist[key] = 0;
198 const queue: number[] = [key];
199 for (let head = 0; head < queue.length; head++) {
200 const here = queue[head]!;
201 const col = here % COLS;
202 const row = (here - col) / COLS;
203 const d = dist[here]!;
204 for (const [dc, dr] of [
205 [1, 0],
206 [-1, 0],
207 [0, 1],
208 [0, -1],
209 ] as const) {
210 const nc = col + dc;
211 const nr = row + dr;
212 if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue;
213 const nk = nr * COLS + nc;
214 if (dist[nk] !== -1 || solidTile(nc, nr)) continue;
215 dist[nk] = d + 1;
216 queue.push(nk);
217 }
218 }
219 }
220 if (fields.size > 64) fields.clear();
221 fields.set(key, dist);
222 return dist;
223 }
224
225 /** Walk distance in tiles between two points, or -1 when unreachable. */
226 export function walkDistance(from: Point, to: Point): number {
227 const f = tileOf(from.x, from.y);
228 const g = tileOf(to.x, to.y);
229 return field(g.col, g.row)[f.row * COLS + f.col] ?? -1;
230 }
231
232 /**
233 * The next point to walk toward on the way to a goal: the centre of the
234 * neighbouring tile that is one step closer, so bots round corners instead
235 * of grinding into them.
236 */
237 export function pathStep(from: Point, to: Point): Point {
238 const f = tileOf(from.x, from.y);
239 const g = tileOf(to.x, to.y);
240 if (f.col === g.col && f.row === g.row) return to;
241 const dist = field(g.col, g.row);
242 const here = dist[f.row * COLS + f.col] ?? -1;
243 if (here <= 0) return to;
244 let best: Point | null = null;
245 let bestD = here;
246 for (const [dc, dr] of [
247 [1, 0],
248 [-1, 0],
249 [0, 1],
250 [0, -1],
251 ] as const) {
252 const nc = f.col + dc;
253 const nr = f.row + dr;
254 if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue;
255 const d = dist[nr * COLS + nc] ?? -1;
256 if (d !== -1 && d < bestD) {
257 bestD = d;
258 best = centreOf(nc, nr);
259 }
260 }
261 // Straight to the goal when nothing stands in the way — smoother than
262 // walking tile centre to tile centre down an open corridor.
263 if (best && !wallBetween(from, to) && (walkDistance(from, to) ?? 99) < 6) return to;
264 return best ?? to;
265 }
src/match.test.ts added +373
@@ -0,0 +1,373 @@
1 import { expect, test } from "bun:test";
2 import { runBots } from "./bots.ts";
3 import {
4 aliveOn,
5 buyAbility,
6 buyShield,
7 buyWeapon,
8 DEFUSE_CHECKPOINT,
9 DEFUSE_TIME,
10 FUSE_TIME,
11 newMatch,
12 PLANT_TIME,
13 ROUND_TIME,
14 SWAP_AFTER,
15 tick,
16 visible,
17 WIN_ROUNDS,
18 } from "./match.ts";
19 import { siteCentre, spawnCells, wallBetween } from "./map.ts";
20 import type { MatchState, Player } from "./state.ts";
21
22 const live = (seed = 5): MatchState => {
23 const state = newMatch(seed);
24 state.phase = "live";
25 state.clock = ROUND_TIME;
26 return state;
27 };
28 const atk = (state: MatchState): Player[] => state.players.filter((p) => p.side === "atk");
29 const def = (state: MatchState): Player[] => state.players.filter((p) => p.side === "def");
30 const run = (state: MatchState, seconds: number): void => {
31 for (let i = 0; i < Math.round(seconds * 30); i++) tick(state, 1 / 30);
32 };
33
34 /** Put a carrier on a site with the spike, everyone else out of the way. */
35 function onSite(state: MatchState): Player {
36 const spot = siteCentre("A");
37 const carrier = atk(state)[0]!;
38 for (const player of state.players) {
39 player.spike = false;
40 player.x = 1200;
41 player.y = 1320;
42 }
43 carrier.spike = true;
44 carrier.x = spot.x;
45 carrier.y = spot.y;
46 state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
47 return carrier;
48 }
49
50 test("a match starts in the buy phase with ten seats and a spike carrier", () => {
51 const state = newMatch(1);
52 expect(state.players.length).toBe(10);
53 expect(atk(state).length).toBe(5);
54 expect(def(state).length).toBe(5);
55 expect(state.phase).toBe("buy");
56 expect(state.players.some((p) => p.spike)).toBe(true);
57 expect(state.players.every((p) => p.credits === 800)).toBe(true);
58 });
59
60 test("planting takes four seconds of standing on a site and arms the fuse", () => {
61 const state = live();
62 const carrier = onSite(state);
63 carrier.input.use = true;
64 run(state, PLANT_TIME - 1);
65 expect(state.spike.planted).toBe(false);
66 run(state, 1.2);
67 expect(state.spike.planted).toBe(true);
68 expect(state.spike.site).toBe("A");
69 expect(Math.round(state.spike.fuse)).toBeLessThanOrEqual(FUSE_TIME);
70 // The plant pays the attacking team and charges the planter's ult.
71 expect(atk(state).every((p) => p.credits >= 800 + 300)).toBe(true);
72 expect(carrier.ult).toBeGreaterThan(0);
73 });
74
75 test("a plant off a site does nothing at all", () => {
76 const state = live();
77 const carrier = onSite(state);
78 carrier.x = 1200;
79 carrier.y = 1320; // attacker spawn, not a site
80 carrier.input.use = true;
81 run(state, PLANT_TIME + 2);
82 expect(state.spike.planted).toBe(false);
83 });
84
85 test("a defuse killed at the half way mark leaves the checkpoint for the next defender", () => {
86 const state = live();
87 const carrier = onSite(state);
88 carrier.input.use = true;
89 run(state, PLANT_TIME + 0.2);
90 expect(state.spike.planted).toBe(true);
91 carrier.input.use = false;
92
93 const first = def(state)[0]!;
94 first.x = state.spike.x;
95 first.y = state.spike.y;
96 first.input.use = true;
97 run(state, DEFUSE_CHECKPOINT + 0.2);
98 expect(state.spike.checkpoint).toBe(DEFUSE_CHECKPOINT);
99 expect(state.spike.defuse).toBeGreaterThanOrEqual(DEFUSE_CHECKPOINT);
100
101 // He dies here. The defuse falls back to the checkpoint, not to zero.
102 first.hp = 1;
103 first.alive = false;
104 first.deaths += 1;
105 state.spike.defuse = state.spike.checkpoint;
106 expect(state.spike.defuse).toBe(DEFUSE_CHECKPOINT);
107
108 const second = def(state)[1]!;
109 second.x = state.spike.x;
110 second.y = state.spike.y;
111 second.input.use = true;
112 // Half a defuse is left, so well under the full seven seconds finishes it.
113 run(state, DEFUSE_TIME - DEFUSE_CHECKPOINT + 0.3);
114 expect(state.phase).toBe("end");
115 expect(state.events.some((e) => e.text.includes("defused"))).toBe(true);
116 });
117
118 test("an interrupted defuse before the checkpoint starts again from nothing", () => {
119 const state = live();
120 const carrier = onSite(state);
121 carrier.input.use = true;
122 run(state, PLANT_TIME + 0.2);
123 carrier.input.use = false;
124 const defender = def(state)[0]!;
125 defender.x = state.spike.x;
126 defender.y = state.spike.y;
127 defender.input.use = true;
128 run(state, 2);
129 expect(state.spike.defuse).toBeGreaterThan(1.5);
130 defender.input.use = false;
131 run(state, 0.2);
132 expect(state.spike.defuse).toBe(0);
133 expect(state.spike.checkpoint).toBe(0);
134 });
135
136 test("the spike detonating wins the round for the attackers", () => {
137 const state = live();
138 const carrier = onSite(state);
139 carrier.input.use = true;
140 run(state, PLANT_TIME + 0.2);
141 carrier.input.use = false;
142 state.spike.fuse = 1;
143 run(state, 1.5);
144 expect(state.phase).toBe("end");
145 expect(state.score[state.attackers]).toBe(1);
146 });
147
148 test("time running out with no plant is a defence win, but the clock stops mattering once it is down", () => {
149 const timeout = live();
150 timeout.clock = 0.5;
151 run(timeout, 1);
152 expect(timeout.phase).toBe("end");
153 expect(timeout.score[timeout.attackers]).toBe(0);
154
155 const planted = live();
156 const carrier = onSite(planted);
157 carrier.input.use = true;
158 run(planted, PLANT_TIME + 0.2);
159 carrier.input.use = false;
160 planted.clock = 0.2;
161 run(planted, 1);
162 expect(planted.phase).toBe("live");
163 });
164
165 test("eliminating a team ends the round, and a planted spike still wins it", () => {
166 const wipe = live();
167 for (const player of def(wipe)) player.alive = false;
168 run(wipe, 0.1);
169 expect(wipe.phase).toBe("end");
170 expect(wipe.score[wipe.attackers]).toBe(1);
171
172 const traded = live();
173 const carrier = onSite(traded);
174 carrier.input.use = true;
175 run(traded, PLANT_TIME + 0.2);
176 for (const player of atk(traded)) player.alive = false;
177 run(traded, 0.1);
178 // Everyone is dead but the spike is armed: the round is not over yet.
179 expect(traded.phase).toBe("live");
180 });
181
182 test("a buy is refused server-side when the credits are not there", () => {
183 const state = newMatch(2);
184 const player = state.players[0]!;
185 player.agent = "ember";
186 player.credits = 800;
187 expect(buyWeapon(state, player, "vandal")).toBe(false);
188 expect(player.weapon).toBe("classic");
189 expect(buyWeapon(state, player, "ghost")).toBe(true);
190 expect(player.credits).toBe(300);
191 expect(buyShield(state, player, "heavy")).toBe(false);
192 // 250 for the flash leaves fifty, and fifty buys nothing.
193 expect(buyAbility(state, player, "c")).toBe(true);
194 expect(player.credits).toBe(50);
195 expect(buyAbility(state, player, "c")).toBe(false);
196 expect(buyAbility(state, player, "q")).toBe(false);
197 // And nothing is for sale once the round is live.
198 player.credits = 9000;
199 state.phase = "live";
200 expect(buyWeapon(state, player, "vandal")).toBe(false);
201 });
202
203 test("a basic is bought by the charge and never past its limit", () => {
204 const state = newMatch(2);
205 const player = state.players.find((p) => p.agent === "veil")!;
206 player.credits = 9000;
207 expect(buyAbility(state, player, "c")).toBe(true);
208 expect(buyAbility(state, player, "c")).toBe(true);
209 expect(player.charges.c).toBe(2);
210 expect(buyAbility(state, player, "c")).toBe(false);
211 expect(buyAbility(state, player, "e")).toBe(false);
212 expect(buyAbility(state, player, "x")).toBe(false);
213 });
214
215 test("sides swap after twelve rounds and a thirteenth round wins the match", () => {
216 const state = newMatch(4);
217 const started = state.attackers;
218 state.round = SWAP_AFTER;
219 state.phase = "live";
220 state.score[started] = WIN_ROUNDS - 2;
221 for (const player of def(state)) player.alive = false;
222 run(state, 0.1);
223 expect(state.phase).toBe("end");
224 run(state, 6);
225 expect(state.round).toBe(SWAP_AFTER + 1);
226 expect(state.attackers).not.toBe(started);
227 // One more round, taken by whoever now attacks the old defenders' side.
228 const winners = state.attackers === started ? started : state.attackers;
229 state.phase = "live";
230 state.score[started] = WIN_ROUNDS - 1;
231 for (const player of state.players) if (player.team !== started) player.alive = false;
232 run(state, 0.1);
233 expect(state.score[started]).toBe(WIN_ROUNDS);
234 expect(state.phase).toBe("over");
235 expect(state.result).toContain(`${started} win`);
236 expect(winners.length).toBeGreaterThan(0);
237 });
238
239 test("an ended match never parks: it shows the result and starts a fresh one", () => {
240 const state = newMatch(6);
241 state.phase = "live";
242 state.score[state.attackers] = WIN_ROUNDS - 1;
243 for (const player of def(state)) player.alive = false;
244 run(state, 0.1);
245 expect(state.phase).toBe("over");
246 expect(state.result).not.toBe(null);
247 run(state, 13);
248 expect(state.phase).toBe("buy");
249 expect(state.round).toBe(1);
250 expect(state.score).toEqual({ red: 0, blue: 0 });
251 expect(state.result).toBe(null);
252 expect(state.players.length).toBe(10);
253 });
254
255 test("line of sight is the server's answer: a smoke hides an enemy in the open", () => {
256 const state = live();
257 const viewer = atk(state)[0]!;
258 const enemy = def(state)[0]!;
259 viewer.x = 1200;
260 viewer.y = 1320;
261 enemy.x = 1200;
262 enemy.y = 1000;
263 expect(visible(state, viewer, enemy)).toBe(true);
264 state.zones.push({
265 id: 99, kind: "smoke", owner: "x", side: "def", x: 1200, y: 1160, r: 150,
266 life: 8, fuse: 0, beat: 0, pulses: 0, damage: 0, blocks: true, solid: false,
267 });
268 expect(visible(state, viewer, enemy)).toBe(false);
269 // A revealed enemy is seen through anything; so is a team mate.
270 enemy.revealed = 2;
271 expect(visible(state, viewer, enemy)).toBe(true);
272 });
273
274 test("a wall hides two players from each other, and open floor does not", () => {
275 const state = live();
276 const viewer = atk(state)[0]!;
277 const enemy = def(state)[0]!;
278 viewer.blind = 0;
279 enemy.revealed = 0;
280 // Either side of the block between A site and the middle of the map.
281 viewer.x = 400;
282 viewer.y = 400;
283 enemy.x = 1200;
284 enemy.y = 400;
285 expect(wallBetween(viewer, enemy)).toBe(true);
286 expect(visible(state, viewer, enemy)).toBe(false);
287 // The same two down the open back corridor, with nothing in between.
288 viewer.x = 400;
289 viewer.y = 1400;
290 enemy.x = 1000;
291 enemy.y = 1400;
292 expect(wallBetween(viewer, enemy)).toBe(false);
293 expect(visible(state, viewer, enemy)).toBe(true);
294 });
295
296 test("no spawn can see a spawn: mid is staggered, not a corridor", () => {
297 for (const from of spawnCells("atk"))
298 for (const to of spawnCells("def")) expect(wallBetween(from, to)).toBe(true);
299 });
300
301 test("a dead player watches through the team, never through walls", () => {
302 const state = live();
303 const viewer = atk(state)[0]!;
304 const mate = atk(state)[1]!;
305 const enemy = def(state)[0]!;
306 for (const player of atk(state)) {
307 player.alive = false;
308 player.x = 400;
309 player.y = 400;
310 }
311 enemy.revealed = 0;
312 enemy.x = 1000;
313 enemy.y = 1400;
314 // Every attacker is dead: the enemy across the map is nobody's to see.
315 expect(visible(state, viewer, enemy)).toBe(false);
316 // One team mate alive with a sight line, and the dead player sees it too.
317 mate.alive = true;
318 mate.x = 400;
319 mate.y = 1400;
320 expect(visible(state, viewer, enemy)).toBe(true);
321 // That team mate behind a wall takes the vision away again.
322 mate.x = 400;
323 mate.y = 400;
324 expect(visible(state, viewer, enemy)).toBe(false);
325 });
326
327 test("a blinded player sees nothing", () => {
328 const state = live();
329 const viewer = atk(state)[0]!;
330 const enemy = def(state)[0]!;
331 viewer.x = 1200;
332 viewer.y = 1320;
333 enemy.x = 1200;
334 enemy.y = 1000;
335 viewer.blind = 1.4;
336 expect(visible(state, viewer, enemy)).toBe(false);
337 });
338
339 test("a killed carrier drops the spike where they fell", () => {
340 const state = live();
341 const carrier = onSite(state);
342 const killer = def(state)[0]!;
343 killer.x = carrier.x + 40;
344 killer.y = carrier.y;
345 killer.angle = Math.PI;
346 killer.weapon = "operator";
347 killer.input.fire = true;
348 run(state, 1);
349 if (!carrier.alive) {
350 expect(state.spike.carried).toBe(null);
351 expect(Math.hypot(state.spike.x - carrier.x, state.spike.y - carrier.y)).toBeLessThan(80);
352 }
353 expect(state.shots.length + state.events.length).toBeGreaterThan(0);
354 });
355
356 test("bots play the round: they buy, they take a site and they use their kit", () => {
357 const state = newMatch(42);
358 let casts = 0;
359 let rounds = 0;
360 for (let i = 0; i < 30 * 60 * 6; i++) {
361 runBots(state, 1 / 30);
362 tick(state, 1 / 30);
363 casts += state.zones.length > 0 ? 1 : 0;
364 if (state.round > rounds) rounds = state.round;
365 }
366 expect(rounds).toBeGreaterThan(1);
367 expect(state.score.red + state.score.blue).toBeGreaterThan(1);
368 // Something was on the map: smokes, flashes, mollies, walls.
369 expect(casts).toBeGreaterThan(100);
370 // And somebody bought a real gun with the credits they won.
371 expect(state.players.some((p) => p.weapon !== "classic")).toBe(true);
372 expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0);
373 });
src/match.ts added +652
@@ -0,0 +1,652 @@
1 /**
2 * The match: one round loop, server-authoritative, and the only place a
3 * number changes.
4 *
5 * `tick(state, dt)` is the whole game — call it with a fixed step and it
6 * runs without a clock, a socket or a browser, which is how the tests play
7 * whole rounds in a millisecond. Inputs arrive as intent (a direction, an
8 * angle, a held key); nothing a client says is taken as a position.
9 */
10
11 import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
12 import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts";
13 import {
14 blockedCircle,
15 clearPaths,
16 moveCircle,
17 orbSpots,
18 rayHitWall,
19 segmentPoint,
20 siteAt,
21 spawnCells,
22 type Point,
23 } from "./map.ts";
24 import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts";
25 import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
26
27 export const PLAYER_R = 16;
28 export const RUN_SPEED = 250;
29 export const BUY_TIME = 14;
30 export const ROUND_TIME = 100;
31 export const PLANT_TIME = 4;
32 export const FUSE_TIME = 45;
33 export const DEFUSE_TIME = 7;
34 export const DEFUSE_CHECKPOINT = 3.5;
35 export const END_TIME = 5;
36 export const OVER_TIME = 12;
37 export const SWAP_AFTER = 12;
38 export const WIN_ROUNDS = 13;
39 export const ROSTER = 5;
40 export const SPIKE_REACH = 46;
41
42 const TEAMS: Team[] = ["red", "blue"];
43
44 /** Deterministic randomness: the same seed plays the same round. */
45 export function random(state: MatchState): number {
46 state.seed = (state.seed + 0x6d2b79f5) | 0;
47 let t = state.seed;
48 t = Math.imul(t ^ (t >>> 15), t | 1);
49 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
50 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
51 }
52
53 const say = (state: MatchState, text: string): void => {
54 state.events.unshift({ text, age: 0 });
55 state.events.length = Math.min(state.events.length, 12);
56 };
57
58 export const sideOf = (state: MatchState, team: Team): Side =>
59 team === state.attackers ? "atk" : "def";
60
61 export const teammates = (state: MatchState, side: Side): Player[] =>
62 state.players.filter((p) => p.side === side);
63
64 export const aliveOn = (state: MatchState, side: Side): Player[] =>
65 state.players.filter((p) => p.side === side && p.alive);
66
67 const BOT_NAMES = [
68 "Ash", "Bex", "Cade", "Dune", "Enzo", "Fay", "Gus", "Hale", "Ivo", "Juno",
69 "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove",
70 ];
71
72 export function makePlayer(state: MatchState, team: Team, index: number): Player {
73 const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!;
74 const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!;
75 return {
76 id: `bot-${team}-${index}`,
77 name: `${name}`,
78 bot: true,
79 agent,
80 team,
81 side: sideOf(state, team),
82 x: 0,
83 y: 0,
84 angle: 0,
85 hp: 100,
86 armour: 0,
87 alive: true,
88 credits: START_CREDITS,
89 weapon: "classic",
90 ammo: WEAPONS.classic.magazine,
91 reloadIn: 0,
92 fireIn: 0,
93 charges: { c: 0, q: 0, e: 1, x: 0 },
94 cooldown: { c: 0, q: 0, e: 0, x: 0 },
95 ult: 0,
96 blind: 0,
97 slow: 0,
98 haste: 0,
99 revealed: 0,
100 spike: false,
101 planting: 0,
102 defusing: 0,
103 kills: 0,
104 deaths: 0,
105 assists: 0,
106 damageDone: 0,
107 input: emptyInput(),
108 goal: null,
109 think: 0,
110 spray: 0,
111 joinedAt: 0,
112 };
113 }
114
115 export function newMatch(seed = Date.now() % 100000): MatchState {
116 const state: MatchState = {
117 id: `m${seed.toString(36)}${Math.floor(seed / 7) % 997}`,
118 phase: "buy",
119 clock: BUY_TIME,
120 round: 1,
121 score: { red: 0, blue: 0 },
122 attackers: "red",
123 lossStreak: { red: 0, blue: 0 },
124 players: [],
125 zones: [],
126 nextZone: 1,
127 spike: { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 },
128 orbs: orbSpots().map((p) => ({ ...p, taken: false })),
129 shots: [],
130 events: [],
131 result: null,
132 seed,
133 now: 0,
134 };
135 for (const team of TEAMS)
136 for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i));
137 startRound(state, true);
138 say(state, "Round 1 — buy phase");
139 return state;
140 }
141
142 /** A fresh match on the same seats: an ended match never parks. */
143 export function resetMatch(state: MatchState): void {
144 const fresh = newMatch((state.seed + 7919) % 100000);
145 fresh.players = state.players;
146 fresh.attackers = state.attackers === "red" ? "blue" : "red";
147 for (const player of fresh.players) {
148 player.kills = 0;
149 player.deaths = 0;
150 player.assists = 0;
151 player.damageDone = 0;
152 player.ult = 0;
153 player.credits = START_CREDITS;
154 player.weapon = "classic";
155 }
156 Object.assign(state, fresh);
157 startRound(state, true);
158 say(state, "New match — round 1");
159 }
160
161 function placeAtSpawn(state: MatchState, player: Player, index: number): void {
162 const cells = spawnCells(player.side);
163 const spot = cells[index % cells.length]!;
164 player.x = spot.x + (random(state) - 0.5) * 20;
165 player.y = spot.y + (random(state) - 0.5) * 20;
166 const centre = player.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 };
167 player.angle = Math.atan2(centre.y - player.y, centre.x - player.x);
168 }
169
170 export function startRound(state: MatchState, first = false): void {
171 clearPaths();
172 state.phase = "buy";
173 state.clock = BUY_TIME;
174 state.zones = [];
175 state.shots = [];
176 state.spike = { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
177 state.orbs = orbSpots().map((p) => ({ ...p, taken: false }));
178
179 const byTeam: Record<Team, number> = { red: 0, blue: 0 };
180 for (const player of state.players) {
181 player.side = sideOf(state, player.team);
182 // A gun survives the round its owner does; armour never does.
183 if (!player.alive || first) player.weapon = "classic";
184 player.armour = 0;
185 player.hp = 100;
186 player.alive = true;
187 player.ammo = WEAPONS[player.weapon].magazine;
188 player.reloadIn = 0;
189 player.fireIn = 0;
190 player.blind = 0;
191 player.slow = 0;
192 player.haste = 0;
193 player.revealed = 0;
194 player.spike = false;
195 player.planting = 0;
196 player.defusing = 0;
197 player.spray = 0;
198 player.goal = null;
199 player.think = 0;
200 player.input = emptyInput();
201 // Basics are bought fresh every round; the signature comes back free.
202 player.charges = { c: 0, q: 0, e: 1, x: 0 };
203 player.cooldown = { c: 0, q: 0, e: 0, x: 0 };
204 if (first) player.credits = START_CREDITS;
205 placeAtSpawn(state, player, byTeam[player.team]++);
206 }
207 giveSpike(state);
208 }
209
210 function giveSpike(state: MatchState): void {
211 const attackers = aliveOn(state, "atk");
212 if (attackers.length === 0) return;
213 const carrier = attackers[Math.floor(random(state) * attackers.length)]!;
214 carrier.spike = true;
215 state.spike.carried = carrier.id;
216 state.spike.x = carrier.x;
217 state.spike.y = carrier.y;
218 }
219
220 /** Buying is refused server-side: no credits, no gun, no argument. */
221 export function buyWeapon(state: MatchState, player: Player, id: WeaponId): boolean {
222 if (state.phase !== "buy") return false;
223 const cost = WEAPONS[id].cost;
224 const paid = spend(player.credits, cost);
225 if (!paid.ok) return false;
226 player.credits = paid.credits;
227 player.weapon = id;
228 player.ammo = WEAPONS[id].magazine;
229 return true;
230 }
231
232 export function buyShield(state: MatchState, player: Player, kind: "light" | "heavy"): boolean {
233 if (state.phase !== "buy") return false;
234 const shield = SHIELD[kind];
235 if (player.armour >= shield.armour) return false;
236 const paid = spend(player.credits, shield.cost);
237 if (!paid.ok) return false;
238 player.credits = paid.credits;
239 player.armour = shield.armour;
240 return true;
241 }
242
243 export function buyAbility(state: MatchState, player: Player, slot: Slot): boolean {
244 if (state.phase !== "buy" || !buyableSlots.includes(slot)) return false;
245 const ability = agentById(player.agent).abilities[slot];
246 if (player.charges[slot] >= ability.charges) return false;
247 const paid = spend(player.credits, ability.cost);
248 if (!paid.ok) return false;
249 player.credits = paid.credits;
250 player.charges[slot] += 1;
251 return true;
252 }
253
254 export function pickAgent(state: MatchState, player: Player, agent: string): boolean {
255 if (state.phase !== "buy" || player.charges.c > 0 || player.charges.q > 0) return false;
256 player.agent = agent;
257 return true;
258 }
259
260 // ---------------------------------------------------------------- the tick
261
262 export function tick(state: MatchState, dt: number): void {
263 state.now += dt;
264 for (const shot of state.shots) shot.age += dt;
265 state.shots = state.shots.filter((s) => s.age < 0.12);
266 for (const event of state.events) event.age += dt;
267
268 if (state.phase === "over") {
269 state.clock -= dt;
270 if (state.clock <= 0) resetMatch(state);
271 return;
272 }
273 if (state.phase === "end") {
274 state.clock -= dt;
275 if (state.clock <= 0) nextRound(state);
276 return;
277 }
278
279 state.clock -= dt;
280 if (state.phase === "buy") {
281 for (const player of state.players) movePlayer(state, player, dt, true);
282 if (state.clock <= 0) {
283 state.phase = "live";
284 state.clock = ROUND_TIME;
285 say(state, "Round live");
286 }
287 return;
288 }
289
290 for (const player of state.players) {
291 if (player.blind > 0) player.blind = Math.max(0, player.blind - dt);
292 if (player.slow > 0) player.slow = Math.max(0, player.slow - dt);
293 if (player.haste > 0) player.haste = Math.max(0, player.haste - dt);
294 if (player.revealed > 0) player.revealed = Math.max(0, player.revealed - dt);
295 for (const slot of SLOTS)
296 if (player.cooldown[slot] > 0) {
297 player.cooldown[slot] = Math.max(0, player.cooldown[slot] - dt);
298 // The signature charge comes back on its own when the wait is over.
299 if (player.cooldown[slot] === 0) player.charges[slot] = Math.max(player.charges[slot], 1);
300 }
301 player.spray = Math.max(0, player.spray - dt * 6);
302 if (!player.alive) continue;
303 movePlayer(state, player, dt, false);
304 useAbility(state, player);
305 shoot(state, player, dt);
306 spikeWork(state, player, dt);
307 orbs(state, player);
308 }
309
310 if (state.spike.planted) state.spike.fuse = Math.max(0, state.spike.fuse - dt);
311 zones(state, dt);
312 endConditions(state);
313 }
314
315 function speedOf(player: Player): number {
316 let speed = RUN_SPEED;
317 if (player.haste > 0) speed *= 1.35;
318 if (player.slow > 0) speed *= 0.55;
319 if (player.planting > 0 || player.defusing > 0) speed = 0;
320 if (player.weapon === "operator") speed *= 0.86;
321 return speed;
322 }
323
324 function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void {
325 if (!player.alive) return;
326 const input = player.input;
327 if (Number.isFinite(input.angle)) player.angle = input.angle;
328 let mx = input.mx;
329 let my = input.my;
330 const len = Math.hypot(mx, my);
331 if (len > 1) {
332 mx /= len;
333 my /= len;
334 }
335 const speed = speedOf(player) * dt;
336 if (speed === 0 || (mx === 0 && my === 0)) return;
337 let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R);
338 // In the buy phase nobody leaves their own spawn.
339 if (penned) {
340 const cells = spawnCells(player.side);
341 const near = cells.reduce((best, c) => (Math.hypot(c.x - next.x, c.y - next.y) < Math.hypot(best.x - next.x, best.y - next.y) ? c : best));
342 if (Math.hypot(near.x - next.x, near.y - next.y) > 120) return;
343 }
344 // Walls put down by abilities are as solid as the map's.
345 for (const zone of state.zones) {
346 if (!zone.solid || zone.fuse > 0) continue;
347 const gap = Math.hypot(next.x - zone.x, next.y - zone.y);
348 if (gap < zone.r + PLAYER_R) {
349 const push = zone.r + PLAYER_R - gap;
350 const angle = gap === 0 ? 0 : Math.atan2(next.y - zone.y, next.x - zone.x);
351 const shoved = { x: next.x + Math.cos(angle) * push, y: next.y + Math.sin(angle) * push };
352 if (!blockedCircle(shoved.x, shoved.y, PLAYER_R)) next = shoved;
353 else next = { x: player.x, y: player.y };
354 }
355 }
356 player.x = next.x;
357 player.y = next.y;
358 }
359
360 function useAbility(state: MatchState, player: Player): void {
361 const slot = player.input.cast;
362 if (slot === null) return;
363 player.input.cast = null;
364 if (state.phase !== "live" || player.blind > 0.8) return;
365 if (cast(state, player, slot)) {
366 const ability = agentById(player.agent).abilities[slot];
367 if (slot === "x") say(state, `${player.name} used ${ability.name}`);
368 }
369 }
370
371 function shoot(state: MatchState, player: Player, dt: number): void {
372 player.fireIn = Math.max(0, player.fireIn - dt);
373 if (player.reloadIn > 0) {
374 player.reloadIn -= dt;
375 if (player.reloadIn <= 0) {
376 player.ammo = WEAPONS[player.weapon].magazine;
377 player.reloadIn = 0;
378 }
379 return;
380 }
381 const weapon = WEAPONS[player.weapon];
382 if (player.input.reload && player.ammo < weapon.magazine) {
383 player.input.reload = false;
384 player.reloadIn = weapon.reload;
385 return;
386 }
387 if (!player.input.fire || player.fireIn > 0) return;
388 if (!weapon.auto) player.input.fire = false;
389 if (player.ammo <= 0) {
390 player.reloadIn = weapon.reload;
391 return;
392 }
393 player.ammo -= 1;
394 player.fireIn = 1 / weapon.rate;
395 player.spray = Math.min(6, player.spray + 1);
396
397 const moving = Math.hypot(player.input.mx, player.input.my) > 0.1 ? 1 : 0;
398 // Blind aim is a guess: the shot goes somewhere near where you were looking.
399 const wobble = player.blind > 0 ? 0.5 : 0;
400 const spread = shotSpread(player.weapon, moving) + player.spray * 0.006 + wobble;
401 const angle = player.angle + (random(state) - 0.5) * 2 * spread;
402 const from: Point = { x: player.x, y: player.y };
403 const wall = rayHitWall(from, angle, weapon.range);
404 let stop = wall;
405 // A wall an ability put up stops bullets too; smoke does not.
406 for (const zone of state.zones) {
407 if (!zone.solid || zone.fuse > 0) continue;
408 const near = segmentPoint(from, wall, zone);
409 if (near.dist <= zone.r && near.travel < Math.hypot(stop.x - from.x, stop.y - from.y))
410 stop = { x: from.x + Math.cos(angle) * near.travel, y: from.y + Math.sin(angle) * near.travel };
411 }
412
413 let best: { target: Player; travel: number; miss: number } | null = null;
414 for (const other of state.players) {
415 if (!other.alive || other.side === player.side || other.id === player.id) continue;
416 const near = segmentPoint(from, stop, other);
417 if (near.dist > PLAYER_R || near.travel < 1) continue;
418 if (best === null || near.travel < best.travel) best = { target: other, travel: near.travel, miss: near.dist };
419 }
420
421 const end = best ? { x: from.x + Math.cos(angle) * best.travel, y: from.y + Math.sin(angle) * best.travel } : stop;
422 state.shots.push({ x1: from.x, y1: from.y, x2: end.x, y2: end.y, hit: best !== null, age: 0 });
423 if (!best) return;
424
425 const zone = zoneFromMiss(best.miss, PLAYER_R);
426 const dealt = damage(player.weapon, zone, best.travel);
427 hurt(state, best.target, dealt, player, zone === "head" ? "headshot" : "");
428 }
429
430 function hurt(state: MatchState, target: Player, amount: number, from: Player | null, note: string): void {
431 if (!target.alive) return;
432 const before = target.hp + target.armour;
433 const after = applyDamage(target.hp, target.armour, amount);
434 target.hp = after.hp;
435 target.armour = after.armour;
436 if (from) from.damageDone += Math.max(0, before - (target.hp + target.armour));
437 if (target.hp > 0) return;
438 target.alive = false;
439 target.deaths += 1;
440 target.planting = 0;
441 // A defuse dies with the defender, back to the half-way checkpoint.
442 if (target.defusing > 0) {
443 target.defusing = 0;
444 state.spike.defuse = state.spike.checkpoint;
445 }
446 if (target.spike) {
447 target.spike = false;
448 state.spike.carried = null;
449 state.spike.x = target.x;
450 state.spike.y = target.y;
451 }
452 if (from && from.side !== target.side) {
453 from.kills += 1;
454 from.credits = award(from.credits, 200);
455 from.ult = Math.min(10, from.ult + 1);
456 say(state, `${from.name} ${note === "headshot" ? "headshot" : "killed"} ${target.name}`);
457 } else {
458 say(state, `${target.name} died`);
459 }
460 }
461
462 function spikeWork(state: MatchState, player: Player, dt: number): void {
463 const spike = state.spike;
464 if (player.side === "atk" && !spike.planted) {
465 if (!spike.carried && !player.spike && Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH) {
466 player.spike = true;
467 spike.carried = player.id;
468 say(state, `${player.name} picked up the spike`);
469 }
470 if (player.spike) {
471 spike.x = player.x;
472 spike.y = player.y;
473 const site = siteAt(player.x, player.y);
474 if (player.input.use && site) {
475 player.planting += dt;
476 if (player.planting >= PLANT_TIME) {
477 spike.planted = true;
478 spike.site = site;
479 spike.fuse = FUSE_TIME;
480 spike.carried = null;
481 player.spike = false;
482 player.planting = 0;
483 player.ult = Math.min(10, player.ult + 1);
484 for (const mate of teammates(state, "atk")) mate.credits = award(mate.credits, 300);
485 say(state, `${player.name} planted on ${site}`);
486 }
487 } else player.planting = 0;
488 }
489 return;
490 }
491 if (player.side === "def" && spike.planted) {
492 const close = Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH;
493 if (player.input.use && close) {
494 spike.defuse += dt;
495 player.defusing = spike.defuse;
496 // Half way is a checkpoint: the next defender starts from there.
497 if (spike.defuse >= DEFUSE_CHECKPOINT && spike.checkpoint === 0) {
498 spike.checkpoint = DEFUSE_CHECKPOINT;
499 say(state, `${player.name} is half way through the defuse`);
500 }
501 } else if (player.defusing > 0) {
502 player.defusing = 0;
503 spike.defuse = spike.checkpoint;
504 }
505 }
506 }
507
508 function orbs(state: MatchState, player: Player): void {
509 for (const orb of state.orbs)
510 if (!orb.taken && Math.hypot(player.x - orb.x, player.y - orb.y) < 40) {
511 orb.taken = true;
512 player.ult = Math.min(10, player.ult + 1);
513 say(state, `${player.name} took an ult orb`);
514 }
515 }
516
517 function zones(state: MatchState, dt: number): void {
518 for (const zone of state.zones) {
519 if (zone.fuse > 0) {
520 zone.fuse -= dt;
521 if (zone.fuse > 0) continue;
522 if (zone.kind === "flash") {
523 // Pop: everyone with a sight line to it, blinded by how far their
524 // back was turned. Smoke between them and it saves them.
525 for (const player of state.players) {
526 if (!player.alive) continue;
527 const blocked = sightBlocked(zone, player, state.zones);
528 const within = Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r;
529 const blind = within ? flashDuration(zone.damage, player, zone, player.angle, blocked) : 0;
530 if (blind > 0) player.blind = Math.max(player.blind, blind);
531 }
532 }
533 continue;
534 }
535 zone.life -= dt;
536 zone.beat -= dt;
537 if (zone.kind === "molly" && zone.beat <= 0) {
538 zone.beat = 0.5;
539 const owner = state.players.find((p) => p.id === zone.owner) ?? null;
540 for (const player of state.players)
541 if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r)
542 hurt(state, player, Math.round(zone.damage / 2), owner, "");
543 }
544 if (zone.kind === "dart" && zone.beat <= 0 && zone.pulses > 0) {
545 zone.beat = 1.6;
546 zone.pulses -= 1;
547 for (const player of state.players)
548 if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r && !sightBlocked(zone, player, state.zones))
549 player.revealed = Math.max(player.revealed, 2);
550 }
551 if (zone.kind === "heal" && zone.beat <= 0) {
552 zone.beat = 0.5;
553 const target = state.players.find((p) => p.id === zone.owner);
554 if (target && target.alive) {
555 target.hp = Math.min(100, target.hp + zone.damage / 6);
556 zone.x = target.x;
557 zone.y = target.y;
558 }
559 }
560 if (zone.kind === "trip" && zone.life > 0) {
561 const caught = state.players.find(
562 (p) => p.alive && p.side !== zone.side && Math.hypot(p.x - zone.x, p.y - zone.y) <= zone.r,
563 );
564 if (caught) {
565 caught.revealed = Math.max(caught.revealed, 4);
566 caught.slow = Math.max(caught.slow, 3);
567 zone.life = 0;
568 say(state, `${caught.name} tripped a wire`);
569 }
570 }
571 // Blackout: a smoke that carries a slow (marked with negative damage).
572 if (zone.kind === "smoke" && zone.damage < 0)
573 for (const player of state.players)
574 if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r)
575 player.slow = Math.max(player.slow, 0.5);
576 }
577 state.zones = state.zones.filter((z) => z.life > 0 || z.fuse > 0);
578 }
579
580 function endConditions(state: MatchState): void {
581 const spike = state.spike;
582 if (spike.planted && spike.defuse >= DEFUSE_TIME) return finishRound(state, "def", "Spike defused");
583 if (aliveOn(state, "def").length === 0) return finishRound(state, "atk", "Defenders eliminated");
584 if (aliveOn(state, "atk").length === 0 && !spike.planted)
585 return finishRound(state, "def", "Attackers eliminated");
586 if (spike.planted) {
587 if (spike.fuse <= 0) return finishRound(state, "atk", "Spike detonated");
588 } else if (state.clock <= 0) return finishRound(state, "def", "Time out");
589 }
590
591 function finishRound(state: MatchState, winners: Side, why: string): void {
592 const winningTeam: Team = winners === "atk" ? state.attackers : state.attackers === "red" ? "blue" : "red";
593 const losingTeam: Team = winningTeam === "red" ? "blue" : "red";
594 state.score[winningTeam] += 1;
595 state.lossStreak[losingTeam] += 1;
596 state.lossStreak[winningTeam] = 0;
597 for (const player of state.players) {
598 const won = player.team === winningTeam;
599 player.credits = clampCredits(
600 player.credits + roundReward(won, state.lossStreak[player.team], state.spike.planted && player.side === "atk"),
601 );
602 }
603 state.phase = "end";
604 state.clock = END_TIME;
605 say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`);
606 if (state.score[winningTeam] >= WIN_ROUNDS) {
607 state.phase = "over";
608 state.clock = OVER_TIME;
609 state.result = `${winningTeam} win ${state.score.red}–${state.score.blue}`;
610 say(state, `Match over — ${state.result}. A new one starts in ${OVER_TIME} seconds.`);
611 }
612 }
613
614 function nextRound(state: MatchState): void {
615 state.round += 1;
616 // Sides swap once twelve rounds are behind us.
617 if (state.round === SWAP_AFTER + 1) {
618 state.attackers = state.attackers === "red" ? "blue" : "red";
619 say(state, "Sides swap");
620 }
621 startRound(state);
622 say(state, `Round ${state.round} — buy phase`);
623 }
624
625 /** What one player is allowed to see: their own side, and what light reaches. */
626 export function visible(state: MatchState, viewer: Player, other: Player): boolean {
627 if (other.id === viewer.id || other.side === viewer.side) return true;
628 if (!other.alive) return false;
629 if (other.revealed > 0) return true;
630 if (viewer.blind > 0) return false;
631 // A dead player watches the round out through their team's eyes, not
632 // through the walls: what a living team mate can see, and nothing more.
633 // Otherwise dying and staying connected would be a wallhack to call out.
634 if (!viewer.alive)
635 return state.players.some(
636 (mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones),
637 );
638 return !sightBlocked(viewer, other, state.zones);
639 }
640
641 export function setInput(player: Player, input: Partial<Input>): void {
642 const target = player.input;
643 if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx));
644 if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my));
645 if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle;
646 if (typeof input.fire === "boolean") target.fire = input.fire;
647 if (typeof input.use === "boolean") target.use = input.use;
648 if (typeof input.reload === "boolean") target.reload = input.reload;
649 if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast;
650 }
651
652 export { castable, agentById };
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 +133 −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,79 @@ 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("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => {
142 const { command } = await import("./arena.ts");
143 const { siteCentre } = await import("./map.ts");
144 const state = match();
145 state.phase = "live";
146 state.clock = 100;
147 const spot = siteCentre("B");
148 const carrier = state.players.find((p) => p.side === "atk")!;
149 // Nobody is a bot for these five seconds: no fight can end the round
150 // underneath the plant and make this depend on who shot whom.
151 for (const player of state.players) {
152 player.bot = false;
153 player.alive = true;
154 player.hp = 100;
155 player.input.fire = false;
156 }
157 carrier.x = spot.x;
158 carrier.y = spot.y;
159 carrier.spike = true;
160 state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
161 // Garbage is ignored, and a seat nobody holds cannot be driven.
162 command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
163 command("nobody", { t: "i", u: true });
164 expect(carrier.input.use).toBe(false);
165 command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
166 advance(5);
167 expect(state.spike.planted).toBe(true);
168 expect(state.spike.site).toBe("B");
169 for (const player of state.players) player.bot = true;
170 });
171
172 test("the match keeps running and is never parked", () => {
173 const state = match();
174 const before = state.now;
175 advance(30);
176 expect(state.now).toBeGreaterThan(before);
177 expect(["buy", "live", "end", "over"]).toContain(state.phase);
70178 });
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 };
src/state.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * The shape of a match. Types only, so the ability module and the match loop
3 * can both speak about a player without importing each other.
4 */
5
6 import type { Point, Site } from "./map.ts";
7 import type { WeaponId } from "./weapons.ts";
8
9 export type Side = "atk" | "def";
10 export type Slot = "c" | "q" | "e" | "x";
11 export type Team = "red" | "blue";
12
13 export type Phase = "buy" | "live" | "end" | "over";
14
15 export type ZoneKind = "smoke" | "molly" | "wall" | "trip" | "dart" | "flash" | "heal";
16
17 /** Anything an ability leaves on the map, with a clock on it. */
18 export type Zone = {
19 id: number;
20 kind: ZoneKind;
21 owner: string;
22 side: Side;
23 x: number;
24 y: number;
25 r: number;
26 /** Seconds of life left. */
27 life: number;
28 /** Seconds before it goes off (a flash in the air, a trip arming). */
29 fuse: number;
30 /** Seconds to the next damage tick or recon pulse. */
31 beat: number;
32 pulses: number;
33 damage: number;
34 /** Blocks line of sight (smoke, wall). */
35 blocks: boolean;
36 /** Blocks movement (wall). */
37 solid: boolean;
38 };
39
40 export type Input = {
41 mx: number;
42 my: number;
43 angle: number;
44 fire: boolean;
45 /** Plant, defuse, pick the spike up: one key for all of it. */
46 use: boolean;
47 reload: boolean;
48 cast: Slot | null;
49 };
50
51 export const emptyInput = (): Input => ({
52 mx: 0,
53 my: 0,
54 angle: 0,
55 fire: false,
56 use: false,
57 reload: false,
58 cast: null,
59 });
60
61 export type Player = {
62 id: string;
63 name: string;
64 bot: boolean;
65 agent: string;
66 team: Team;
67 side: Side;
68 x: number;
69 y: number;
70 angle: number;
71 hp: number;
72 armour: number;
73 alive: boolean;
74 credits: number;
75 weapon: WeaponId;
76 /** Kept through a round they survived, reset to the Classic when they die. */
77 ammo: number;
78 reloadIn: number;
79 fireIn: number;
80 /** Basic charges bought this round, and signature/ult readiness. */
81 charges: Record<Slot, number>;
82 cooldown: Record<Slot, number>;
83 ult: number;
84 blind: number;
85 slow: number;
86 haste: number;
87 /** Seconds this player stays visible to the other team however dark it is. */
88 revealed: number;
89 spike: boolean;
90 planting: number;
91 defusing: number;
92 kills: number;
93 deaths: number;
94 assists: number;
95 damageDone: number;
96 input: Input;
97 /** Bot scratch space: where it is walking and when it last thought. */
98 goal: Point | null;
99 think: number;
100 spray: number;
101 joinedAt: number;
102 };
103
104 export type MatchState = {
105 id: string;
106 phase: Phase;
107 /** Seconds left in the phase. */
108 clock: number;
109 round: number;
110 score: Record<Team, number>;
111 /** Which team attacks this round. */
112 attackers: Team;
113 lossStreak: Record<Team, number>;
114 players: Player[];
115 zones: Zone[];
116 nextZone: number;
117 spike: { carried: string | null; x: number; y: number; planted: boolean; fuse: number; site: Site | null; defuse: number; checkpoint: number };
118 orbs: { x: number; y: number; taken: boolean }[];
119 shots: { x1: number; y1: number; x2: number; y2: number; hit: boolean; age: number }[];
120 events: { text: string; age: number }[];
121 result: string | null;
122 seed: number;
123 now: number;
124 };
src/tty.ts +154 −54
@@ -1,74 +1,174 @@
11 /**
2 * val, in a terminal. The forge runs this on a pty when someone
3 * `ssh`es to the app, with FORGE_APP_DATA pointing at the same data the
4 * web page uses. j/k move, a adds a note, d deletes the one under the
5 * cursor, q leaves. Plain ANSI, no dependencies.
2 * val, in a terminal. The forge runs this on a pty when somebody ssh-es to
3 * the app; it takes a seat over the same socket the browser uses, so what a
4 * person can do on the page they can do here: walk, shoot, cast, plant,
5 * defuse and buy.
6 *
7 * The map is the same grid, one character a tile. Plain ANSI, no dependencies.
68 */
79
8 import { Database } from "bun:sqlite";
9 import { mkdirSync } from "node:fs";
10 import { join } from "node:path";
10 const base =
11 process.env.VAL_URL ??
12 (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ??
13 process.env.FORGE_APP_URL ??
14 "http://127.0.0.1:8080";
1115
12 const dataDir = process.env.FORGE_APP_DATA ?? "./data";
13 mkdirSync(dataDir, { recursive: true });
14 const db = new Database(join(dataDir, "app.db"));
15 db.exec("PRAGMA journal_mode = WAL");
16 db.exec(
17 "CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')))",
18 );
19 const where = process.env.FORGE_APP_ENV === "preview" ? `preview of #${process.env.FORGE_APP_PULL ?? "?"}` : (process.env.FORGE_APP_ENV ?? "local");
20
21 type Note = { id: number; body: string; created_at: string };
22 const notes = (): Note[] => db.query<Note, []>("SELECT id, body, created_at FROM note ORDER BY id DESC").all();
23
24 let cursor = 0;
25 let typing: string | undefined;
26 const out = (s: string): void => { process.stdout.write(s); };
2716 const esc = "\x1b[";
17 const out = (s: string): void => void process.stdout.write(s);
18
19 type Snapshot = {
20 phase: string;
21 clock: number;
22 round: number;
23 score: { red: number; blue: number };
24 attackers: string;
25 result: string | null;
26 me: Record<string, any>;
27 players: Record<string, any>[];
28 zones: { k: string; x: number; y: number; r: number }[];
29 spike: { planted: boolean; fuse: number; defuse: number; x: number | null; y: number | null };
30 board: Record<string, any>[];
31 events: string[];
32 };
33
34 let map: { rows: string[]; tile: number } | null = null;
35 let snap: Snapshot | null = null;
36 let socket: WebSocket | null = null;
37 let status = "connecting";
38 let aim = -Math.PI / 2;
39 let move = { mx: 0, my: 0, until: 0 };
40 let firing = 0;
41 let using = 0;
42 let buying = false;
43
44 const send = (message: unknown): void => {
45 if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
46 };
47
48 async function connect(): Promise<void> {
49 try {
50 const page = await fetch(base + "/");
51 const cookie = (page.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
52 map = (await (await fetch(base + "/map.json")).json()) as { rows: string[]; tile: number };
53 const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws", {
54 headers: { cookie, origin: new URL(base).origin },
55 } as unknown as string[]);
56 socket = ws;
57 ws.onopen = () => (status = "live");
58 ws.onclose = () => {
59 status = "reconnecting";
60 socket = null;
61 setTimeout(connect, 2000);
62 };
63 ws.onmessage = (event) => {
64 snap = JSON.parse(String(event.data)) as Snapshot;
65 };
66 } catch (error) {
67 status = `cannot reach ${base}`;
68 setTimeout(connect, 3000);
69 }
70 }
71
72 const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`;
2873
2974 function draw(): void {
30 const rows = notes();
75 if (!map) {
76 out(`${esc}2J${esc}Hval — ${status}\r\n`);
77 return;
78 }
79 const rows = map.rows.map((row) => row.split("").map((c) => (c === "#" ? colour("▓", 90) : c === "A" || c === "B" ? colour(c, 33) : c === "a" || c === "d" ? colour("·", 90) : " ")));
80 const put = (x: number, y: number, ch: string): void => {
81 const col = Math.floor(x / map!.tile);
82 const row = Math.floor(y / map!.tile);
83 if (rows[row] && rows[row]![col] !== undefined) rows[row]![col] = ch;
84 };
85
3186 const lines: string[] = [];
32 lines.push(`${esc}1mval${esc}0m ${esc}2m${where}${esc}0m`);
87 if (snap) {
88 for (const zone of snap.zones) put(zone.x, zone.y, colour(zone.k === "smoke" ? "▒" : zone.k === "molly" ? "≈" : zone.k === "wall" ? "█" : "*", zone.k === "molly" ? 31 : 37));
89 if (snap.spike.x !== null && snap.spike.y !== null)
90 put(snap.spike.x, snap.spike.y, colour(snap.spike.planted ? "◈" : "◇", 33));
91 for (const player of snap.players) {
92 if (!player.alive) continue;
93 const mark = player.id === snap.me.id ? colour("@", 97) : colour(player.name[0] ?? "?", player.mate ? 36 : 31);
94 put(player.x, player.y, mark);
95 }
96 const me = snap.me;
97 const phase = snap.phase === "live" && snap.spike.planted ? `spike ${Math.ceil(snap.spike.fuse)}s` : snap.phase;
98 lines.push(
99 `${colour("val", 1)} ${colour("red " + snap.score.red, 31)} — ${colour(snap.score.blue + " blue", 36)} round ${snap.round} ${phase} ${Math.ceil(snap.clock)}s ${snap.attackers} attacking [${status}]`,
100 );
101 lines.push(
102 `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`,
103 );
104 } else lines.push(`val — ${status}`);
33105 lines.push("");
34 if (rows.length === 0) lines.push(`${esc}2mNo notes yet. Press a to add one.${esc}0m`);
35 rows.forEach((n, i) => {
36 const mark = i === cursor ? "▸" : " ";
37 const body = i === cursor ? `${esc}1m${n.body}${esc}0m` : n.body;
38 lines.push(`${mark} ${body} ${esc}2m${n.created_at}${esc}0m`);
39 });
106 for (const row of rows) lines.push(" " + row.join(""));
40107 lines.push("");
41 lines.push(typing === undefined ? `${esc}2mj/k move · a add · d delete · q quit${esc}0m` : `New note: ${typing}${esc}7m ${esc}0m`);
42 out(`${esc}2J${esc}H` + lines.join("\r\n"));
108 if (snap) {
109 for (const side of ["atk", "def"] as const) {
110 const team = snap.board
111 .filter((p) => p.side === side)
112 .map((p) => `${p.alive ? "" : colour("✕", 90)}${p.name}${p.bot ? "" : "*"} ${p.kills}/${p.deaths}`)
113 .join(" ");
114 lines.push(colour(side === "atk" ? "atk " : "def ", side === "atk" ? 31 : 36) + team);
115 }
116 lines.push("");
117 for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90));
118 }
119 lines.push("");
120 lines.push(
121 buying
122 ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33)
123 : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90),
124 );
125 out(`${esc}H${esc}J` + lines.join("\r\n"));
43126 }
44127
128 const BUYS: Record<string, string> = {
129 "1": "classic", "2": "ghost", "3": "sheriff", "4": "spectre", "5": "phantom", "6": "vandal", "7": "operator",
130 "8": "light", "9": "heavy",
131 };
132
45133 process.stdin.setRawMode?.(true);
46134 process.stdin.resume();
47135 process.stdin.setEncoding("utf8");
48 draw();
49136 process.stdin.on("data", (key: string) => {
50 if (typing !== undefined) {
51 if (key === "\r") {
52 const body = typing.trim();
53 if (body) db.query("INSERT INTO note (body) VALUES (?1)").run(body.slice(0, 500));
54 typing = undefined;
55 cursor = 0;
56 } else if (key === "\x1b") typing = undefined;
57 else if (key === "\x7f") typing = typing.slice(0, -1);
58 else if (key >= " ") typing += key;
59 draw();
137 const now = Date.now();
138 if (key === "\x03" || (key === "q" && !buying)) {
139 out(`${esc}2J${esc}H`);
140 process.exit(0);
141 }
142 if (buying) {
143 if (BUYS[key]) send({ t: "buy", what: BUYS[key] });
144 else if (key === "c" || key === "q") send({ t: "buy", what: key });
145 else if (key === "b" || key === "\x1b") buying = false;
60146 return;
61147 }
62 if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); }
63 const count = notes().length;
64 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
65 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
66 if (key === "a") typing = "";
67 if (key === "d") {
68 const n = notes()[cursor];
69 if (n) db.query("DELETE FROM note WHERE id = ?1").run(n.id);
70 cursor = Math.min(cursor, Math.max(0, notes().length - 1));
71 }
72 draw();
148 if (key === "b") return void (buying = true);
149 if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 };
150 if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 };
151 if (key === "a") move = { mx: -1, my: move.my, until: now + 400 };
152 if (key === "d") move = { mx: 1, my: move.my, until: now + 400 };
153 if (key === "\x1b[D") aim -= Math.PI / 12;
154 if (key === "\x1b[C") aim += Math.PI / 12;
155 if (key === "\x1b[A") move = { mx: move.mx, my: -1, until: now + 400 };
156 if (key === "\x1b[B") move = { mx: move.mx, my: 1, until: now + 400 };
157 if (key === " ") firing = now + 220;
158 if (key === "f") using = now + 700;
159 if (key === "r") send({ t: "i", r: true });
160 if (key === "c" || key === "e" || key === "x") send({ t: "i", c: key });
73161 });
162
163 // Twenty times a second, the same intent the browser sends.
164 setInterval(() => {
165 const now = Date.now();
166 if (now > move.until) move = { mx: 0, my: 0, until: 0 };
167 send({ t: "i", mx: move.mx, my: move.my, a: aim, f: firing > now, u: using > now, r: false });
168 }, 50);
169 setInterval(draw, 120);
74170 process.stdout.on("resize", draw);
171
172 out(`${esc}2J`);
173 draw();
174 void connect();
src/weapons.test.ts added +53
@@ -0,0 +1,53 @@
1 import { expect, test } from "bun:test";
2 import { applyDamage, damage, isLethal, shotSpread, WEAPONS, zoneFromMiss } from "./weapons.ts";
3
4 test("a Vandal head shot kills through full armour at any range, a Phantom's does not", () => {
5 for (const distance of [100, 800, 2000]) expect(isLethal(100, 50, damage("vandal", "head", distance))).toBe(true);
6 // The Phantom only manages it inside fifteen metres; past that the falloff
7 // leaves the target alive, which is the whole argument between the two guns.
8 expect(isLethal(100, 50, damage("phantom", "head", 300))).toBe(true);
9 expect(isLethal(100, 50, damage("phantom", "head", 900))).toBe(false);
10 expect(isLethal(100, 50, damage("phantom", "head", 2000))).toBe(false);
11 // Both still one-tap a head with no armour on.
12 expect(isLethal(100, 0, damage("phantom", "head", 2000))).toBe(true);
13 });
14
15 test("the Phantom falls off with range and the Vandal does not", () => {
16 expect(damage("phantom", "body", 100)).toBe(39);
17 expect(damage("phantom", "body", 1600)).toBe(31);
18 expect(damage("vandal", "body", 100)).toBe(damage("vandal", "body", 2300));
19 });
20
21 test("armour is absorbed point for point but empties first", () => {
22 const hit = applyDamage(100, 50, 40);
23 expect(hit.armour).toBe(50 - 27);
24 expect(hit.hp).toBe(87);
25 // A hundred health behind fifty armour is a hundred and fifty to chew through.
26 let hp = 100;
27 let armour = 50;
28 let shots = 0;
29 while (hp > 0) {
30 ({ hp, armour } = applyDamage(hp, armour, 40));
31 shots++;
32 }
33 expect(shots).toBe(4);
34 });
35
36 test("body shots never kill in one, whatever the gun, except the Operator", () => {
37 expect(isLethal(100, 0, damage("operator", "body", 500))).toBe(true);
38 for (const id of ["classic", "ghost", "sheriff", "spectre", "phantom", "vandal"] as const)
39 expect(isLethal(100, 0, damage(id, "body", 500))).toBe(false);
40 });
41
42 test("where the ray passes decides the hit location", () => {
43 expect(zoneFromMiss(0, 16)).toBe("head");
44 expect(zoneFromMiss(8, 16)).toBe("body");
45 expect(zoneFromMiss(15, 16)).toBe("legs");
46 });
47
48 test("running widens the cone and standing still does not", () => {
49 expect(shotSpread("vandal", 0)).toBe(WEAPONS.vandal.spread);
50 expect(shotSpread("vandal", 1)).toBeGreaterThan(shotSpread("vandal", 0));
51 // The Operator is the accurate one and the Spectre is not.
52 expect(shotSpread("operator", 0)).toBeLessThan(shotSpread("spectre", 0));
53 });
src/weapons.ts added +194
@@ -0,0 +1,194 @@
1 /**
2 * Guns: the damage table and what a shot does with it. Pure functions —
3 * nothing here knows about a match.
4 *
5 * Distance is in world units; fifty units is a metre, so the map is about
6 * forty-eight metres across and the Phantom's fifteen-metre falloff lands
7 * where it should. The rule that matters: a Vandal head shot kills through
8 * full armour at any range, a Phantom head shot does not.
9 */
10
11 export type WeaponId =
12 | "classic"
13 | "ghost"
14 | "sheriff"
15 | "spectre"
16 | "phantom"
17 | "vandal"
18 | "operator";
19
20 export type Zone = "head" | "body" | "legs";
21
22 export type Band = { upTo: number; head: number; body: number; legs: number };
23
24 export type Weapon = {
25 id: WeaponId;
26 name: string;
27 cost: number;
28 /** Shots a second. */
29 rate: number;
30 magazine: number;
31 reload: number;
32 /** Cone half-angle in radians when standing still, and the walking penalty. */
33 spread: number;
34 movePenalty: number;
35 range: number;
36 auto: boolean;
37 bands: Band[];
38 };
39
40 const METRE = 50;
41
42 export const WEAPONS: Record<WeaponId, Weapon> = {
43 classic: {
44 id: "classic",
45 name: "Classic",
46 cost: 0,
47 rate: 6.75,
48 magazine: 12,
49 reload: 1.75,
50 spread: 0.022,
51 movePenalty: 0.05,
52 range: 1600,
53 auto: false,
54 bands: [{ upTo: Infinity, head: 78, body: 26, legs: 22 }],
55 },
56 ghost: {
57 id: "ghost",
58 name: "Ghost",
59 cost: 500,
60 rate: 6.75,
61 magazine: 15,
62 reload: 1.5,
63 spread: 0.016,
64 movePenalty: 0.05,
65 range: 1800,
66 auto: false,
67 bands: [
68 { upTo: 30 * METRE, head: 105, body: 30, legs: 26 },
69 { upTo: Infinity, head: 88, body: 25, legs: 21 },
70 ],
71 },
72 sheriff: {
73 id: "sheriff",
74 name: "Sheriff",
75 cost: 800,
76 rate: 4,
77 magazine: 6,
78 reload: 2.25,
79 spread: 0.02,
80 movePenalty: 0.09,
81 range: 2000,
82 auto: false,
83 bands: [
84 { upTo: 30 * METRE, head: 159, body: 55, legs: 46 },
85 { upTo: Infinity, head: 145, body: 50, legs: 42 },
86 ],
87 },
88 spectre: {
89 id: "spectre",
90 name: "Spectre",
91 cost: 1600,
92 rate: 13.33,
93 magazine: 30,
94 reload: 2.5,
95 spread: 0.03,
96 movePenalty: 0.03,
97 range: 1600,
98 auto: true,
99 bands: [
100 { upTo: 20 * METRE, head: 78, body: 26, legs: 22 },
101 { upTo: Infinity, head: 66, body: 22, legs: 18 },
102 ],
103 },
104 phantom: {
105 id: "phantom",
106 name: "Phantom",
107 cost: 2900,
108 rate: 11,
109 magazine: 30,
110 reload: 2.5,
111 spread: 0.021,
112 movePenalty: 0.06,
113 range: 2400,
114 auto: true,
115 bands: [
116 { upTo: 15 * METRE, head: 156, body: 39, legs: 33 },
117 { upTo: 30 * METRE, head: 140, body: 35, legs: 29 },
118 { upTo: Infinity, head: 124, body: 31, legs: 26 },
119 ],
120 },
121 vandal: {
122 id: "vandal",
123 name: "Vandal",
124 cost: 2900,
125 rate: 9.75,
126 magazine: 25,
127 reload: 2.5,
128 spread: 0.024,
129 movePenalty: 0.07,
130 range: 2400,
131 auto: true,
132 bands: [{ upTo: Infinity, head: 160, body: 40, legs: 34 }],
133 },
134 operator: {
135 id: "operator",
136 name: "Operator",
137 cost: 4700,
138 rate: 0.6,
139 magazine: 5,
140 reload: 3.7,
141 spread: 0.004,
142 movePenalty: 0.2,
143 range: 2800,
144 auto: false,
145 bands: [{ upTo: Infinity, head: 255, body: 150, legs: 120 }],
146 },
147 };
148
149 export const weaponIds = (): WeaponId[] => Object.keys(WEAPONS) as WeaponId[];
150
151 export const isWeaponId = (value: unknown): value is WeaponId =>
152 typeof value === "string" && value in WEAPONS;
153
154 /** Raw damage for a hit: the band the distance falls in, by zone. */
155 export function damage(id: WeaponId, zone: Zone, distance: number): number {
156 const weapon = WEAPONS[id];
157 const band = weapon.bands.find((b) => distance <= b.upTo) ?? weapon.bands[weapon.bands.length - 1]!;
158 return band[zone];
159 }
160
161 /**
162 * Armour absorbs point for point — a hundred health behind fifty armour is a
163 * hundred and fifty to chew through — but it takes two thirds of each hit,
164 * so it runs out before health does.
165 */
166 export function applyDamage(
167 hp: number,
168 armour: number,
169 amount: number,
170 ): { hp: number; armour: number } {
171 const toArmour = Math.min(armour, Math.ceil(amount * (2 / 3)));
172 return { hp: Math.max(0, hp - (amount - toArmour)), armour: armour - toArmour };
173 }
174
175 /** True when this hit kills outright: the test the Vandal exists to pass. */
176 export const isLethal = (hp: number, armour: number, amount: number): boolean =>
177 applyDamage(hp, armour, amount).hp <= 0;
178
179 /**
180 * Where a shot lands, from how close the ray passes the target's centre.
181 * Top-down has no vertical, so precision stands in for it: dead centre is a
182 * head shot, the outer edge is legs.
183 */
184 export function zoneFromMiss(miss: number, radius: number): Zone {
185 if (miss <= radius * 0.33) return "head";
186 if (miss <= radius * 0.75) return "body";
187 return "legs";
188 }
189
190 /** Cone half-angle for a shot: standing still is accurate, running is not. */
191 export const shotSpread = (id: WeaponId, speedFraction: number): number =>
192 WEAPONS[id].spread + WEAPONS[id].movePenalty * Math.min(1, Math.max(0, speedFraction));
193
194 export const SHIELD = { light: { cost: 400, armour: 25 }, heavy: { cost: 1000, armour: 50 } };
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