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

forked from benjivers/val

workbenchthis machine

Model the match: map, guns, credits, kits, round loop and bots

The simulation half of val, with no server attached yet: one grid map with
two sites, a mid and a back corridor; the damage table by hit location with
armour; credits with the rising loss bonus; five agent kits built out of
flashes, smokes, mollies, darts, heals, walls and trips; and a round loop
that plants, defuses with the half-way checkpoint, swaps at twelve and
starts a fresh match the moment one ends.

tick(state, dt) is the whole game and takes no clock, so the tests play
whole matches headlessly — six minutes of five bots against five in a
couple of hundred milliseconds.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 30150dd Browse files
11 files changed +2335 −0
src/abilities.test.ts added +141
@@ -0,0 +1,141 @@
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 player.x = 1200;
123 player.y = 1320;
124 player.angle = -Math.PI / 2;
125 const behind = { x: 1200, y: 1320 - 600 };
126 const openBefore = !sightBlocked(player, behind, state.zones);
127 expect(cast(state, player, "e")).toBe(true);
128 expect(openBefore).toBe(true);
129 expect(sightBlocked(player, behind, state.zones)).toBe(true);
130 expect(state.zones.every((z) => z.solid)).toBe(true);
131 });
132
133 test("a round starts with the signature ready and no bought charges", () => {
134 const state = newMatch(3);
135 startRound(state, true);
136 for (const player of state.players) {
137 expect(player.charges.c).toBe(0);
138 expect(player.charges.q).toBe(0);
139 expect(player.charges.e).toBe(1);
140 }
141 });
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/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/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 +320
@@ -0,0 +1,320 @@
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 } 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 blinded player sees nothing", () => {
275 const state = live();
276 const viewer = atk(state)[0]!;
277 const enemy = def(state)[0]!;
278 viewer.x = 1200;
279 viewer.y = 1320;
280 enemy.x = 1200;
281 enemy.y = 1000;
282 viewer.blind = 1.4;
283 expect(visible(state, viewer, enemy)).toBe(false);
284 });
285
286 test("a killed carrier drops the spike where they fell", () => {
287 const state = live();
288 const carrier = onSite(state);
289 const killer = def(state)[0]!;
290 killer.x = carrier.x + 40;
291 killer.y = carrier.y;
292 killer.angle = Math.PI;
293 killer.weapon = "operator";
294 killer.input.fire = true;
295 run(state, 1);
296 if (!carrier.alive) {
297 expect(state.spike.carried).toBe(null);
298 expect(Math.hypot(state.spike.x - carrier.x, state.spike.y - carrier.y)).toBeLessThan(80);
299 }
300 expect(state.shots.length + state.events.length).toBeGreaterThan(0);
301 });
302
303 test("bots play the round: they buy, they take a site and they use their kit", () => {
304 const state = newMatch(42);
305 let casts = 0;
306 let rounds = 0;
307 for (let i = 0; i < 30 * 60 * 6; i++) {
308 runBots(state, 1 / 30);
309 tick(state, 1 / 30);
310 casts += state.zones.length > 0 ? 1 : 0;
311 if (state.round > rounds) rounds = state.round;
312 }
313 expect(rounds).toBeGreaterThan(1);
314 expect(state.score.red + state.score.blue).toBeGreaterThan(1);
315 // Something was on the map: smokes, flashes, mollies, walls.
316 expect(casts).toBeGreaterThan(100);
317 // And somebody bought a real gun with the credits they won.
318 expect(state.players.some((p) => p.weapon !== "classic")).toBe(true);
319 expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0);
320 });
src/match.ts added +646
@@ -0,0 +1,646 @@
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 if (!viewer.alive) return true; // a dead player watches the round out
632 return !sightBlocked(viewer, other, state.zones);
633 }
634
635 export function setInput(player: Player, input: Partial<Input>): void {
636 const target = player.input;
637 if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx));
638 if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my));
639 if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle;
640 if (typeof input.fire === "boolean") target.fire = input.fire;
641 if (typeof input.use === "boolean") target.use = input.use;
642 if (typeof input.reload === "boolean") target.reload = input.reload;
643 if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast;
644 }
645
646 export { castable, agentById };
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/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 } };
↑↓ navigate ↵ open esc close

Keyboard shortcuts