benjivers/val Public Public: anyone can read this; the crew can change it.

Merge pull request #19 from fable/18-a-flash-blinds-70-of-the-map-so-somebody

A flash blinds 70% of the map, so somebody is always blind
benjivers committed 5de5865 merge Browse files
7 files changed +515 −36
README.md +12 −3
@@ -21,8 +21,12 @@ server:
2121 does not past fifteen metres.
2222 - **Line of sight.** A snapshot only carries the enemies that viewer can
2323 see. A wall of the map blocks the sight line, a smoke blocks it, a flash
24 blinds by the angle you were facing, and a blinded player is sent no
25 enemies at all. A dead player watches through their living team mates'
24 blinds you if you were looking at it — facing decides whether: anywhere
25 on your 75° screen is the full duration, a quick grace past its edge for
26 turning, nothing at all behind your back; distance decides how long,
27 inside a radius the size of a room (320–360 units on a 2400 × 1520 map),
28 full near the pop and a flicker at its edge — and a blinded player is sent
29 no enemies at all. A dead player watches through their living team mates'
2630 eyes rather than through walls, so dying is not a wallhack to call out.
2731 No spawn can see the other spawn: mid is staggered on purpose. A patched
2832 client learns nothing a plain one does not.
@@ -37,7 +41,12 @@ server:
3741 `src/arena.ts` by comparing the players either side of a tick — it carries
3842 names, the gun and an age, and never a position.
3943 - **Bots** fill every empty seat and use their kit: smokes on the way in,
40 flashes before the entry, a recon dart, a molly on the planted spike. They
44 flashes before the entry, a recon dart, a molly on the planted spike. A
45 flash is thrown to take a site — on the way in, or back onto the planted
46 spike — only where the pop would land in front of the enemy's face, never
47 where the thrower or a team mate could be standing in it, and a team
48 spaces its flashes seconds apart (`src/bots.ts`); `src/bots.test.ts`
49 plays a match headless and keeps the share of time spent blind. They
4150 shoot through the same spread everyone does, and they aim like something
4251 with hands: 200–350ms to react to a target they have just seen, an aim
4352 cone that opens several degrees on the swing and only settles while they
src/abilities.test.ts +125 −7
@@ -1,7 +1,26 @@
11 import { expect, test } from "bun:test";
2 import { AGENTS, agentById, buyableSlots, cast, castable, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
2 import {
3 AGENTS,
4 agentById,
5 buyableSlots,
6 cast,
7 castable,
8 facingFlash,
9 FLASH_CONE,
10 FLASH_EDGE,
11 FLASH_GRACE,
12 flashDuration,
13 flashReach,
14 sightBlocked,
15 SLOTS,
16 type Ability,
17 } from "./abilities.ts";
18 import { HEIGHT, WIDTH } from "./map.ts";
319 import { newMatch, startRound, tick } from "./match.ts";
420 import type { MatchState, Player, Zone } from "./state.ts";
21 import { FOV } from "../static/raycast.js";
22
23 const flashes = (): Ability[] => AGENTS.flatMap((a) => SLOTS.map((s) => a.abilities[s])).filter((a) => a.kind === "flash");
524
625 const live = (): MatchState => {
726 const state = newMatch(11);
@@ -24,22 +43,121 @@ test("every agent has two bought basics, a free signature and an ultimate", () =
2443
2544 test("a flash blinds by the angle you were facing", () => {
2645 const at = { x: 1000, y: 1000 };
27 const staring = { x: 1200, y: 1000 };
46 const staring = { x: 1100, y: 1000 };
2847 const facingIt = Math.PI; // looking back down the line at the flash
2948 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);
49 expect(flashDuration(1.7, staring, at, facingIt, false, 360)).toBeGreaterThan(1.6);
50 expect(flashDuration(1.7, staring, at, facingAway, false, 360)).toBe(0);
51 // Turned just past the edge of the screen is a shorter blind than staring.
52 const glance = flashDuration(1.7, staring, at, facingIt - 0.8, false, 360);
3453 expect(glance).toBeGreaterThan(0);
3554 expect(glance).toBeLessThan(1.7);
3655 });
3756
3857 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);
58 const blind = flashDuration(1.7, { x: 1100, y: 1000 }, { x: 1000, y: 1000 }, Math.PI, true, 360);
4059 expect(blind).toBe(0);
4160 });
4261
62 test("a flash reaches a room, not the map", () => {
63 // The map is 2400 by 1520. A 900 radius was 70% of it by area and 75% of
64 // its width across: weather, not a thrown object. A site is a few hundred
65 // units across, and that is the scale every flash is kept to.
66 expect(flashes().length).toBeGreaterThan(0);
67 for (const flash of flashes()) {
68 expect(flash.radius).toBeGreaterThanOrEqual(300);
69 expect(flash.radius).toBeLessThanOrEqual(400);
70 expect((Math.PI * flash.radius * flash.radius) / (WIDTH * HEIGHT)).toBeLessThan(0.12);
71 expect((2 * flash.radius) / WIDTH).toBeLessThanOrEqual(0.3);
72 // And it is a thrown object: it lands past its own radius from the thrower.
73 expect(flash.range).toBeGreaterThan(flash.radius);
74 }
75 });
76
77 test("distance matters inside the radius: the centre is the full duration, the edge a flicker", () => {
78 const at = { x: 1000, y: 1000 };
79 const r = 360;
80 const facingIt = Math.PI;
81 const stand = (d: number) => ({ x: 1000 + d, y: 1000 });
82 expect(flashDuration(1.7, stand(60), at, facingIt, false, r)).toBe(1.7);
83 expect(flashDuration(1.7, stand(0.4 * r), at, facingIt, false, r)).toBe(1.7);
84 const half = flashDuration(1.7, stand(0.7 * r), at, facingIt, false, r);
85 expect(half).toBeGreaterThan(0.3 * 1.7);
86 expect(half).toBeLessThan(0.7 * 1.7);
87 const edge = flashDuration(1.7, stand(r), at, facingIt, false, r);
88 expect(edge).toBeGreaterThan(0);
89 expect(edge).toBeLessThanOrEqual(FLASH_EDGE * 1.7 + 0.01);
90 // Past the radius there is nothing, however hard you were staring.
91 expect(flashDuration(1.7, stand(r + 1), at, facingIt, false, r)).toBe(0);
92 expect(flashDuration(1.7, stand(899), at, facingIt, false, r)).toBe(0);
93 // Closer is never shorter.
94 let last = Infinity;
95 for (let d = 20; d <= r; d += 20) {
96 const now = flashDuration(1.7, stand(d), at, facingIt, false, r);
97 expect(now).toBeLessThanOrEqual(last);
98 last = now;
99 }
100 });
101
102 const deg = (n: number) => (n * Math.PI) / 180;
103
104 test("facing is the gate: on screen is the full duration, behind your back is nothing", () => {
105 // The browser draws FOV wide. Anything inside half of that was on your
106 // screen: you looked at it, you pay all of it. Past the screen's edge a
107 // little grace for turning, gone quickly; beyond that, nothing at all.
108 expect(FLASH_CONE).toBeCloseTo(FOV / 2, 9);
109 expect(FLASH_GRACE).toBeLessThanOrEqual(deg(56));
110 const at = { x: 1000, y: 1000 };
111 const near = { x: 1100, y: 1000 };
112 const straight = Math.PI;
113 for (const off of [0, 15, 30, 37]) {
114 expect(flashDuration(1.7, near, at, straight + deg(off), false, 360)).toBe(1.7);
115 expect(flashDuration(1.7, near, at, straight - deg(off), false, 360)).toBe(1.7);
116 }
117 // The grace falls away quickly: half way through it, well under half is left.
118 const grace = flashDuration(1.7, near, at, straight + deg(46), false, 360);
119 expect(grace).toBeGreaterThan(0);
120 expect(grace).toBeLessThan(0.5 * 1.7);
121 expect(flashDuration(1.7, near, at, straight + deg(53), false, 360)).toBeLessThan(0.1);
122 // Used to blind at full strength out to 45° and taper to 100°.
123 for (const off of [55, 60, 70, 100, 180]) {
124 expect(flashDuration(1.7, near, at, straight + deg(off), false, 360)).toBe(0);
125 expect(facingFlash(near, at, straight + deg(off))).toBe(0);
126 }
127 // Turning further is never a longer blind.
128 let last = Infinity;
129 for (let off = 0; off <= 180; off += 1) {
130 const now = facingFlash(near, at, straight + deg(off));
131 expect(now).toBeLessThanOrEqual(last);
132 last = now;
133 }
134 });
135
136 test("facing decides whether, distance decides how long", () => {
137 const at = { x: 1000, y: 1000 };
138 const r = 360;
139 const stand = (d: number) => ({ x: 1000 + d, y: 1000 });
140 // Facing it exactly is never survivable because of a distance table:
141 // every distance the light carries to blinds, and further is shorter.
142 let last = Infinity;
143 for (let d = 10; d <= r; d += 10) {
144 const blind = flashDuration(1.7, stand(d), at, Math.PI, false, r);
145 expect(blind).toBeGreaterThan(0);
146 expect(blind).toBeLessThanOrEqual(last);
147 last = blind;
148 }
149 expect(flashReach(r, r)).toBeCloseTo(FLASH_EDGE, 9);
150 expect(flashReach(r + 1, r)).toBe(0);
151 // And a close flash never blinds a player who had their back to it.
152 for (let d = 10; d <= r; d += 10) {
153 expect(flashDuration(1.7, stand(d), at, 0, false, r)).toBe(0);
154 expect(flashDuration(1.7, stand(d), at, Math.PI / 2, false, r)).toBe(0);
155 expect(flashDuration(1.7, stand(d), at, Math.PI - deg(60), false, r)).toBe(0);
156 }
157 // Behind a wall or a smoke you were not looking at it, whatever your angle says.
158 expect(flashDuration(1.7, stand(10), at, Math.PI, true, r)).toBe(0);
159 });
160
43161 test("a smoke blocks the sight line for as long as it lives", () => {
44162 const smoke: Zone = {
45163 id: 1, kind: "smoke", owner: "x", side: "atk", x: 1200, y: 1000, r: 140,
src/abilities.ts +63 −17
@@ -74,7 +74,7 @@ export const AGENTS: Agent[] = [
7474 role: "Duelist",
7575 blurb: "Flash in, burn them off the plant, come back from nothing.",
7676 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." }),
77 c: basic({ slot: "c", name: "Flashfire", kind: "flash", cost: 250, charges: 2, range: 620, radius: 360, fuse: 0.55, duration: 1.7, text: "Throws a flash that pops after half a second and blinds anyone looking near it." }),
7878 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." }),
7979 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." }),
8080 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." }),
@@ -87,7 +87,7 @@ export const AGENTS: Agent[] = [
8787 blurb: "Takes the sightline away and gives the site to whoever she likes.",
8888 abilities: {
8989 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." }),
90 q: basic({ slot: "q", name: "Blind Spot", kind: "flash", cost: 250, charges: 1, range: 600, radius: 340, fuse: 0.5, duration: 1.6, text: "A flash for the entry you are about to make." }),
9191 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." }),
9292 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." }),
9393 },
@@ -100,7 +100,7 @@ export const AGENTS: Agent[] = [
100100 abilities: {
101101 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." }),
102102 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." }),
103 e: basic({ slot: "e", name: "Owl Flash", kind: "flash", cost: 0, charges: 1, cooldown: 40, range: 640, radius: 360, fuse: 0.6, duration: 1.6, text: "Signature. A flash on a cooldown, so there is always one for the retake." }),
104104 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." }),
105105 },
106106 },
@@ -123,7 +123,7 @@ export const AGENTS: Agent[] = [
123123 blurb: "Builds the angle he wants and sets the site on fire when it is lost.",
124124 abilities: {
125125 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." }),
126 q: basic({ slot: "q", name: "Flashbang", kind: "flash", cost: 200, charges: 2, range: 600, radius: 320, fuse: 0.6, duration: 1.5, text: "Two flashes: one for the entry, one for the exit." }),
127127 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." }),
128128 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." }),
129129 },
@@ -139,9 +139,60 @@ export const SLOTS: Slot[] = ["c", "q", "e", "x"];
139139 export const buyableSlots: Slot[] = ["c", "q"];
140140
141141 /**
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.
142 * The rule, from the person playing it: if you are looking in the direction
143 * of the flash you get flashed, and if you are not, you do not. Facing is
144 * the gate. Distance only decides how long.
145 *
146 * The browser draws a 75° field of view (`FOV` in `static/raycast.js`), so
147 * anything inside half of that was on your screen: you looked at it, you
148 * pay the whole duration. Past the edge of the screen there is a little
149 * grace for the fact that you were probably turning, falling away quickly
150 * to nothing by `FLASH_GRACE`. Beyond that it is behind your back and
151 * somebody else's problem — not a short blind, not a flicker, nothing.
152 */
153 export const FLASH_CONE = (37.5 * Math.PI) / 180;
154 export const FLASH_GRACE = (55 * Math.PI) / 180;
155 /** Inside this share of the radius a flash is full strength. */
156 export const FLASH_FULL_REACH = 0.4;
157 /**
158 * What is left of the duration at the very edge of the radius. Never zero:
159 * facing a flash exactly is not survivable because of a distance table.
160 */
161 export const FLASH_EDGE = 0.2;
162
163 /**
164 * Whether you were looking at it, as a share of the duration: 1 anywhere on
165 * screen, falling quickly through the grace past its edge, 0 beyond.
166 */
167 export function facingFlash(from: Point, at: Point, facing: number): number {
168 const toFlash = Math.atan2(at.y - from.y, at.x - from.x);
169 let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
170 off = Math.min(off, Math.PI);
171 if (off <= FLASH_CONE) return 1;
172 if (off >= FLASH_GRACE) return 0;
173 const past = (off - FLASH_CONE) / (FLASH_GRACE - FLASH_CONE);
174 return (1 - past) * (1 - past);
175 }
176
177 /**
178 * How far the light carries, as a share of the duration: full near the pop,
179 * a flicker at the edge of the radius, nothing past it. Distance decides
180 * how long, never whether.
181 */
182 export function flashReach(distance: number, radius: number): number {
183 if (distance > radius) return 0;
184 const reach = radius * FLASH_FULL_REACH;
185 if (distance <= reach) return 1;
186 return 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE);
187 }
188
189 /**
190 * How long a flash blinds, in seconds. Facing decides whether: on screen is
191 * the full duration, behind your back is nothing, and a wall or a smoke
192 * between you and the pop means you were not looking at it whatever your
193 * angle says. Distance decides how long, inside the radius the light
194 * carries. `blocked` is the caller's line-of-sight answer, so this stays
195 * pure.
145196 */
146197 export function flashDuration(
147198 full: number,
@@ -149,18 +200,13 @@ export function flashDuration(
149200 at: Point,
150201 facing: number,
151202 blocked: boolean,
203 radius: number,
152204 ): number {
153205 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;
206 const looking = facingFlash(from, at, facing);
207 if (looking === 0) return 0;
208 const near = flashReach(Math.hypot(from.x - at.x, from.y - at.y), radius);
209 return Math.round(full * looking * near * 100) / 100;
164210 }
165211
166212 /** Whether a sight line survives the smokes and walls standing on the map. */
src/bots.test.ts added +241
@@ -0,0 +1,241 @@
1 /**
2 * The bots' flashes, measured.
3 *
4 * A flash used to reach 900 units — 70% of the map — and a bot threw every
5 * charge the moment it was bought, so in a bot round somebody was blind
6 * most of the time. The numbers below are the bug and the fix: a headless
7 * match, the share of live player-seconds spent blind, and how many flashes
8 * go off a round. Before this file: 4.4% blind, 6.5 flashes a round, and a
9 * flash blinded 0.86 players on average — 0.20 of them the thrower, 0.43 a
10 * team mate, 0.23 an enemy.
11 */
12
13 import { expect, test } from "bun:test";
14 import { agentById } from "./abilities.ts";
15 import { attackSite, FLASH_REPEAT, FLASH_SPACING, flashCall, flashSpot, runBots } from "./bots.ts";
16 import { siteCentre } from "./map.ts";
17 import { newMatch, ROUND_TIME, tick } from "./match.ts";
18 import type { MatchState, Player } from "./state.ts";
19
20 const flashZones = (state: MatchState): Set<number> =>
21 new Set(state.zones.filter((z) => z.kind === "flash").map((z) => z.id));
22
23 /** Held where it stands: a plant in progress is the one thing that sets a body's speed to zero. */
24 const pin = (p: Player): void => {
25 p.planting = 1;
26 };
27
28 /**
29 * One live round in the side corridor of the site the attack is going to:
30 * an Ember bot with two flashes at the bottom of it, 860 from the site — an
31 * entry, as far as its kit is concerned — and an enemy standing still at
32 * the top facing down it, 800 away, so a 620 throw pops 180 in front of
33 * the enemy's face. Everybody else is parked on the far site, not looking.
34 */
35 function corridor(): { state: MatchState; bot: Player; enemy: Player; mates: Player[]; x: number } {
36 const state = newMatch(21);
37 state.phase = "live";
38 state.clock = ROUND_TIME;
39 const site = attackSite(state);
40 const x = site === "A" ? 120 : 2280;
41 const far = siteCentre(site === "A" ? "B" : "A");
42 const atk = state.players.filter((p) => p.side === "atk");
43 const def = state.players.filter((p) => p.side === "def");
44 for (const p of state.players) {
45 p.bot = false;
46 p.x = far.x;
47 p.y = far.y;
48 p.angle = Math.PI / 2;
49 p.input.angle = p.angle;
50 p.input.mx = 0;
51 p.input.my = 0;
52 p.spike = false;
53 }
54 state.spike.carried = null;
55 state.spike.x = far.x;
56 state.spike.y = far.y;
57 const bot = atk[0]!;
58 bot.bot = true;
59 bot.agent = "ember";
60 bot.charges = { c: 2, q: 0, e: 0, x: 0 };
61 bot.x = x;
62 bot.y = 1150;
63 bot.angle = -Math.PI / 2;
64 bot.input.angle = bot.angle;
65 pin(bot);
66 const enemy = def[0]!;
67 enemy.x = x;
68 enemy.y = 350;
69 enemy.angle = Math.PI / 2;
70 enemy.input.angle = Math.PI / 2;
71 // A sandbag: the bot shoots at it too, and these tests are about the flash.
72 enemy.hp = 1e6;
73 return { state, bot, enemy, mates: atk.slice(1), x };
74 }
75
76 const play = (state: MatchState, seconds: number, seen?: Set<number>): void => {
77 for (let i = 0; i < Math.round(seconds * 30); i++) {
78 runBots(state, 1 / 30);
79 tick(state, 1 / 30);
80 if (seen) for (const id of flashZones(state)) seen.add(id);
81 }
82 };
83
84 test("a bot throws one flash at the enemy in front of it, not both charges at once", () => {
85 const { state, bot, enemy } = corridor();
86 const seen = new Set<number>();
87 play(state, 0.1, seen);
88 expect(seen.size).toBe(1);
89 // The one that went does its job: the enemy facing the thrower is blinded.
90 play(state, 1.5, seen);
91 expect(enemy.blind).toBeGreaterThan(0);
92 // The second charge stays in hand for the repeat, however often it thinks.
93 play(state, FLASH_REPEAT - 2, seen);
94 expect(seen.size).toBe(1);
95 expect(bot.charges.c).toBe(1);
96 // And it is not lost: once the repeat has passed it goes.
97 for (let i = 0; i < 3 * 30 && seen.size < 2; i++) play(state, 1 / 30, seen);
98 expect(seen.size).toBe(2);
99 });
100
101 test("a team waits between flashes: one bot's throw holds a team mate's", () => {
102 const { state, bot, mates, x } = corridor();
103 const mate = mates[0]!;
104 // A second thrower right beside the first, with two flashes of its own.
105 mate.bot = true;
106 mate.agent = "ember";
107 mate.charges = { c: 2, q: 0, e: 0, x: 0 };
108 mate.x = x;
109 mate.y = 1190;
110 mate.angle = -Math.PI / 2;
111 mate.input.angle = mate.angle;
112 pin(mate);
113 play(state, FLASH_SPACING - 0.5);
114 expect(4 - bot.charges.c - mate.charges.c).toBe(1);
115 // Past the team's spacing the other thrower's own goes.
116 play(state, 1.5);
117 expect(4 - bot.charges.c - mate.charges.c).toBe(2);
118 expect(bot.charges.c).toBe(1);
119 expect(mate.charges.c).toBe(1);
120 });
121
122 test("a bot holds the flash while a team mate stands in it facing the pop", () => {
123 const { state, bot, mates, enemy, x } = corridor();
124 const mate = mates[0]!;
125 mate.x = x;
126 mate.y = 650;
127 mate.angle = -Math.PI / 2;
128 mate.input.angle = mate.angle;
129 play(state, 2);
130 expect(bot.charges.c).toBe(2);
131 expect(flashZones(state).size).toBe(0);
132 // The same team mate back beside the thrower, looking the other way, is
133 // not in the flash's business: the throw goes, and lands on the enemy.
134 mate.y = 1200;
135 mate.angle = Math.PI / 2;
136 mate.input.angle = mate.angle;
137 play(state, 2);
138 expect(bot.charges.c).toBe(1);
139 expect(mate.blind).toBe(0);
140 expect(enemy.blind).toBeGreaterThan(0);
141 });
142
143 test("a bot never throws a flash it could run into, or one that pops behind the enemy's head", () => {
144 const { state, bot, enemy, x } = corridor();
145 const ability = agentById("ember").abilities.c;
146 // Into the wall right behind it: the pop would land inside its own radius.
147 expect(flashCall(state, bot, ability, { x, y: 1440 }, null)).toBe(false);
148 // Up the corridor at the enemy 800 out: the pop lands in front of it.
149 const spot = flashSpot(bot, ability, enemy);
150 expect(spot.y).toBeGreaterThan(enemy.y);
151 expect(flashCall(state, bot, ability, enemy, enemy)).toBe(true);
152 // An enemy 450 out facing the thrower: the throw flies 620 and pops behind
153 // them, where they are not looking. Not thrown.
154 enemy.y = 700;
155 expect(flashCall(state, bot, ability, enemy, enemy)).toBe(false);
156 // Turned around, that same pop is right in front of them. Thrown.
157 enemy.angle = -Math.PI / 2;
158 expect(flashCall(state, bot, ability, enemy, enemy)).toBe(true);
159 enemy.angle = Math.PI / 2;
160 enemy.input.angle = enemy.angle;
161 play(state, 2);
162 expect(bot.charges.c).toBe(2);
163 });
164
165 test("a flash is for taking a site, not for every duel on the way", () => {
166 const { state, bot } = corridor();
167 // The same bot and the same enemy, but far from the site it is attacking.
168 bot.y = 1400;
169 const enemyY = 600;
170 const enemy = state.players.find((p) => p.side === "def" && p.y === 350)!;
171 enemy.y = enemyY;
172 play(state, 2);
173 expect(bot.charges.c).toBe(2);
174 });
175
176 /** A headless match, and the numbers the issue is about. */
177 function measure(seeds: number[], minutes: number) {
178 let liveSeconds = 0;
179 let blindSeconds = 0;
180 let anyBlindSeconds = 0;
181 let flashes = 0;
182 let rounds = 0;
183 const hit = { self: 0, own: 0, enemy: 0 };
184 for (const seed of seeds) {
185 const state = newMatch(seed);
186 const seen = new Set<number>();
187 const before = new Map<string, number>();
188 let round = 0;
189 for (let i = 0; i < 30 * 60 * minutes && state.phase !== "over"; i++) {
190 if (state.round !== round) {
191 round = state.round;
192 rounds++;
193 }
194 runBots(state, 1 / 30);
195 const popping = state.phase === "live" ? state.zones.filter((z) => z.kind === "flash" && z.fuse > 0 && z.fuse - 1 / 30 <= 0) : [];
196 for (const p of state.players) before.set(p.id, p.blind);
197 tick(state, 1 / 30);
198 for (const id of flashZones(state))
199 if (!seen.has(id)) {
200 seen.add(id);
201 flashes++;
202 }
203 const pop = popping[0];
204 if (pop)
205 for (const p of state.players)
206 if (p.blind > (before.get(p.id) ?? 0)) hit[pop.owner === p.id ? "self" : pop.side === p.side ? "own" : "enemy"]++;
207 if (state.phase !== "live") continue;
208 const alive = state.players.filter((p) => p.alive);
209 liveSeconds += alive.length / 30;
210 const blind = alive.filter((p) => p.blind > 0).length;
211 blindSeconds += blind / 30;
212 if (blind > 0) anyBlindSeconds += 1 / 30;
213 }
214 }
215 const per = (n: number) => n / Math.max(1, flashes);
216 return {
217 rounds,
218 flashesPerRound: flashes / rounds,
219 blindShare: blindSeconds / liveSeconds,
220 anyoneBlindShare: anyBlindSeconds / (liveSeconds / 10),
221 perFlash: { self: per(hit.self), own: per(hit.own), enemy: per(hit.enemy) },
222 };
223 }
224
225 test("a flash is a tool, not weather: the blind share of a bot match, measured", () => {
226 const m = measure([1, 2, 3, 4], 10);
227 const pct = (n: number) => `${(n * 100).toFixed(2)}%`;
228 const f = (n: number) => n.toFixed(2);
229 console.log(
230 `flashes: ${m.rounds} rounds, ${f(m.flashesPerRound)} a round (was 6.48); blind ${pct(m.blindShare)} of live player-seconds (was 4.36%), ` +
231 `somebody blind ${pct(m.anyoneBlindShare)} of live seconds (was 15.97%); a flash blinds ${f(m.perFlash.self)} self / ${f(m.perFlash.own)} own / ${f(m.perFlash.enemy)} enemy (was 0.20 / 0.43 / 0.23)`,
232 );
233 expect(m.rounds).toBeGreaterThan(20);
234 expect(m.flashesPerRound).toBeLessThan(2);
235 expect(m.blindShare).toBeLessThan(0.015);
236 expect(m.anyoneBlindShare).toBeLessThan(0.1);
237 // A flash is thrown at the other team: it blinds them, and not its own.
238 expect(m.perFlash.self).toBeLessThan(0.01);
239 expect(m.perFlash.own).toBeLessThan(0.05);
240 expect(m.perFlash.enemy).toBeGreaterThan(0.5);
241 });
src/bots.ts +65 −5
@@ -7,9 +7,9 @@
77 * to, so the match loop cannot tell them apart and neither can a cheat.
88 */
99
10 import { agentById, castable } from "./abilities.ts";
10 import { agentById, castable, flashDuration, sightBlocked, type Ability } from "./abilities.ts";
1111 import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts";
12 import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
12 import { pathStep, rayHitWall, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
1313 import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts";
1414 import type { MatchState, Player, Slot } from "./state.ts";
1515 import { WEAPONS, type WeaponId } from "./weapons.ts";
@@ -129,6 +129,15 @@ export function botThink(state: MatchState, bot: Player, dt: number): void {
129129 bot.think = 0.4 + random(state) * 0.5;
130130 useKit(state, bot, target ?? null, site, distToGoal);
131131 }
132 // A throw is made standing still, down the line it was decided on: the
133 // aim below used to swing the barrel up to 13° the same tick, and a flash
134 // decided for open floor went into the wall beside the thrower's head.
135 if (bot.input.cast !== null) {
136 bot.input.mx = 0;
137 bot.input.my = 0;
138 bot.input.fire = false;
139 return;
140 }
132141
133142 // --- the spike
134143 bot.input.use = false;
@@ -188,6 +197,47 @@ export function botThink(state: MatchState, bot: Player, dt: number): void {
188197 }
189198 }
190199
200 /**
201 * Seconds a team waits between flashes: two pops back to back is a strobe.
202 * Before any of this, bots threw every charge the moment the round went
203 * live — 6.5 flashes a round, 4.4% of live player-seconds spent blind, and
204 * each flash blinding 0.2 throwers and 0.43 team mates against 0.23 enemies.
205 * `src/bots.test.ts` keeps the numbers.
206 */
207 export const FLASH_SPACING = 6;
208 /** Seconds one thrower waits between its own: two charges are an entry and an exit, not a pair. */
209 export const FLASH_REPEAT = 12;
210
211 /** Where this flash pops if thrown at that point: full range down the line, or the first wall. */
212 export const flashSpot = (bot: Point, ability: Ability, point: Point): Point =>
213 rayHitWall(bot, Math.atan2(point.y - bot.y, point.x - bot.x), ability.range);
214
215 /**
216 * Whether a bot should throw this flash at this point right now. A flash is
217 * for taking one angle, so it goes out only when it would do that and
218 * nothing else: not so close that the thrower, or a team mate, could be
219 * inside it by the time it pops — a fuse is long enough to run into one;
220 * not while a team mate is looking at where it will pop; not inside a few
221 * seconds of the team's last; and, thrown at an enemy, only when the pop
222 * would blind them as they stand — a flash that flies past somebody facing
223 * you pops behind their head, and does nothing but light up your own side.
224 */
225 export function flashCall(state: MatchState, bot: Player, ability: Ability, point: Point, target: Player | null): boolean {
226 const spot = flashSpot(bot, ability, point);
227 const stride = RUN_SPEED * 1.35 * ability.fuse;
228 const reach = ability.radius + stride;
229 if (Math.hypot(spot.x - bot.x, spot.y - bot.y) <= reach) return false;
230 if (target && flashDuration(1, target, spot, target.angle, sightBlocked(spot, target, state.zones), ability.radius) <= 0) return false;
231 for (const mate of state.players) {
232 if (mate.side !== bot.side) continue;
233 if (state.now - mate.flashedAt < (mate.id === bot.id ? FLASH_REPEAT : FLASH_SPACING)) return false;
234 if (!mate.alive || mate.id === bot.id) continue;
235 const gap = Math.hypot(spot.x - mate.x, spot.y - mate.y);
236 if (gap <= stride || flashDuration(1, mate, spot, mate.angle, sightBlocked(spot, mate, state.zones), reach) > 0) return false;
237 }
238 return true;
239 }
240
191241 /** Ability decisions, kept in one place so each is a sentence you can read. */
192242 function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void {
193243 const agent = agentById(bot.agent);
@@ -231,9 +281,19 @@ function useKit(state: MatchState, bot: Player, target: Player | null, site: Sit
231281 continue;
232282 }
233283 if (kind === "flash") {
234 const enemyClose = target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range;
235 if (enemyClose) return void tryCast(slot, target!);
236 if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 150) return void tryCast(slot, centre);
284 // A flash is for taking a site: on the way in for the attack, on the
285 // way back to the spike for the retake. Not for every duel on the way.
286 // It goes at an enemy in view, or short of the site so the pop sits
287 // between the team and whoever holds it; and only when it would take
288 // that one angle and blind nobody else (`flashCall`).
289 const taking = bot.side === "atk" ? !spike.planted : spike.planted;
290 const near = distToGoal < ability.range + ability.radius * 0.8;
291 const short = distToGoal > ability.range;
292 const at = taking && near ? target ?? (short ? centre : null) : null;
293 if (at && flashCall(state, bot, ability, at, target)) {
294 bot.flashedAt = state.now;
295 return void tryCast(slot, at);
296 }
237297 continue;
238298 }
239299 if (kind === "smoke") {
src/match.ts +7 −4
@@ -142,6 +142,7 @@ export function makePlayer(
142142 goal: null,
143143 think: 0,
144144 aim: emptyTrack(),
145 flashedAt: -99,
145146 spray: 0,
146147 joinedAt: 0,
147148 };
@@ -235,6 +236,8 @@ export function startRound(state: MatchState, first = false): void {
235236 player.goal = null;
236237 player.think = 0;
237238 player.aim = emptyTrack();
239 // A new round is a new entry: the team's flash spacing starts clear.
240 player.flashedAt = -99;
238241 // Basics are bought fresh every round; the signature comes back free.
239242 player.charges = { c: 0, q: 0, e: 1, x: 0 };
240243 player.cooldown = { c: 0, q: 0, e: 0, x: 0 };
@@ -563,13 +566,13 @@ function zones(state: MatchState, dt: number): void {
563566 zone.fuse -= dt;
564567 if (zone.fuse > 0) continue;
565568 if (zone.kind === "flash") {
566 // Pop: everyone with a sight line to it, blinded by how far their
567 // back was turned. Smoke between them and it saves them.
569 // Pop: everyone inside its radius with a sight line to it, blinded by
570 // how far their back was turned and how close they stood. Smoke
571 // between them and it saves them.
568572 for (const player of state.players) {
569573 if (!player.alive) continue;
570574 const blocked = sightBlocked(zone, player, state.zones);
571 const within = Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r;
572 const blind = within ? flashDuration(zone.damage, player, zone, player.angle, blocked) : 0;
575 const blind = flashDuration(zone.damage, player, zone, player.angle, blocked, zone.r);
573576 if (blind > 0) player.blind = Math.max(player.blind, blind);
574577 }
575578 }
src/state.ts +2
@@ -100,6 +100,8 @@ export type Player = {
100100 think: number;
101101 /** Bot scratch space: the target it is holding, and how badly. */
102102 aim: Track;
103 /** Bot scratch space: the match clock when it last threw a flash. */
104 flashedAt: number;
103105 spray: number;
104106 joinedAt: number;
105107 };
↑↓ navigate ↵ open esc close

Keyboard shortcuts