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

forked from benjivers/val

workbenchthis machine

A flash is the size of a room, blinds only what was on screen, and bots throw it to take a site

The map is 2400 x 1520 and a flash reached 900: 70% of the map by area,
full strength everywhere inside it, and a cone of 100 degrees either side
of your facing when the screen shows 37.5. Bots threw every charge the
moment the round went live. Measured headless over eight seeds: 6.5
flashes a round, 4.4% of live player-seconds spent blind, and each flash
blinding 0.20 throwers and 0.43 team mates against 0.23 enemies.

abilities.ts: every flash radius is 320-360 (11% of the map by area, 30%
of its width). flashDuration takes the radius: full inside 40% of it,
fading to a flicker of 20% at the edge, nothing past it. The cone is half
the client's 75 degree field of view, full inside 20 degrees.

bots.ts: flashCall decides a throw. It goes only where the pop would land
in front of the enemy's face as they stand, never within a fuse's run of
the thrower or a team mate, never while a team mate is looking at the
spot, six seconds after the team's last and twelve after the thrower's
own, and only to take a site: on the way in, or back onto the planted
spike. A bot plants its feet on the tick it throws, because the aim code
used to swing the barrel up to 13 degrees the same tick and the flash went
into the wall beside its head.

After, same seeds: 1.26 flashes a round, 0.84% of live player-seconds
blind, somebody blind 6.5% of live seconds (was 16%), each flash blinding
0.99 enemies, 0 throwers and 0.01 team mates. src/bots.test.ts plays the
match and keeps those numbers; src/abilities.test.ts holds the radius,
the falloff and the cone against the renderer's FOV.

Closes #18

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Fable committed a9ddab6 Browse files
7 files changed +437 −29
README.md +10 −3
@@ -21,8 +21,10 @@ 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 by the angle you were facing — only a pop that was on your screen,
25 only inside a radius the size of a room (320–360 units on a 2400 × 1520
26 map), full strength at its centre and a flicker at its edge — and a
27 blinded player is sent no enemies at all. A dead player watches through their living team mates'
2628 eyes rather than through walls, so dying is not a wallhack to call out.
2729 No spawn can see the other spawn: mid is staggered on purpose. A patched
2830 client learns nothing a plain one does not.
@@ -37,7 +39,12 @@ server:
3739 `src/arena.ts` by comparing the players either side of a tick — it carries
3840 names, the gun and an age, and never a position.
3941 - **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
42 flashes before the entry, a recon dart, a molly on the planted spike. A
43 flash is thrown to take a site — on the way in, or back onto the planted
44 spike — only where the pop would land in front of the enemy's face, never
45 where the thrower or a team mate could be standing in it, and a team
46 spaces its flashes seconds apart (`src/bots.ts`); `src/bots.test.ts`
47 plays a match headless and keeps the share of time spent blind. They
4148 shoot through the same spread everyone does, and they aim like something
4249 with hands: 200–350ms to react to a target they have just seen, an aim
4350 cone that opens several degrees on the swing and only settles while they
src/abilities.test.ts +82 −6
@@ -1,7 +1,23 @@
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 FLASH_CONE,
9 FLASH_EDGE,
10 flashDuration,
11 sightBlocked,
12 SLOTS,
13 type Ability,
14 } from "./abilities.ts";
15 import { HEIGHT, WIDTH } from "./map.ts";
316 import { newMatch, startRound, tick } from "./match.ts";
417 import type { MatchState, Player, Zone } from "./state.ts";
18 import { FOV } from "../static/raycast.js";
19
20 const flashes = (): Ability[] => AGENTS.flatMap((a) => SLOTS.map((s) => a.abilities[s])).filter((a) => a.kind === "flash");
521
622 const live = (): MatchState => {
723 const state = newMatch(11);
@@ -24,22 +40,82 @@ test("every agent has two bought basics, a free signature and an ultimate", () =
2440
2541 test("a flash blinds by the angle you were facing", () => {
2642 const at = { x: 1000, y: 1000 };
27 const staring = { x: 1200, y: 1000 };
43 const staring = { x: 1100, y: 1000 };
2844 const facingIt = Math.PI; // looking back down the line at the flash
2945 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);
46 expect(flashDuration(1.7, staring, at, facingIt, false, 360)).toBeGreaterThan(1.6);
47 expect(flashDuration(1.7, staring, at, facingAway, false, 360)).toBe(0);
3248 // 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 const glance = flashDuration(1.7, staring, at, facingIt - 0.5, false, 360);
3450 expect(glance).toBeGreaterThan(0);
3551 expect(glance).toBeLessThan(1.7);
3652 });
3753
3854 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);
55 const blind = flashDuration(1.7, { x: 1100, y: 1000 }, { x: 1000, y: 1000 }, Math.PI, true, 360);
4056 expect(blind).toBe(0);
4157 });
4258
59 test("a flash reaches a room, not the map", () => {
60 // The map is 2400 by 1520. A 900 radius was 70% of it by area and 75% of
61 // its width across: weather, not a thrown object. A site is a few hundred
62 // units across, and that is the scale every flash is kept to.
63 expect(flashes().length).toBeGreaterThan(0);
64 for (const flash of flashes()) {
65 expect(flash.radius).toBeGreaterThanOrEqual(300);
66 expect(flash.radius).toBeLessThanOrEqual(400);
67 expect((Math.PI * flash.radius * flash.radius) / (WIDTH * HEIGHT)).toBeLessThan(0.12);
68 expect((2 * flash.radius) / WIDTH).toBeLessThanOrEqual(0.3);
69 // And it is a thrown object: it lands past its own radius from the thrower.
70 expect(flash.range).toBeGreaterThan(flash.radius);
71 }
72 });
73
74 test("distance matters inside the radius: the centre is the full duration, the edge a flicker", () => {
75 const at = { x: 1000, y: 1000 };
76 const r = 360;
77 const facingIt = Math.PI;
78 const stand = (d: number) => ({ x: 1000 + d, y: 1000 });
79 expect(flashDuration(1.7, stand(60), at, facingIt, false, r)).toBe(1.7);
80 expect(flashDuration(1.7, stand(0.4 * r), at, facingIt, false, r)).toBe(1.7);
81 const half = flashDuration(1.7, stand(0.7 * r), at, facingIt, false, r);
82 expect(half).toBeGreaterThan(0.3 * 1.7);
83 expect(half).toBeLessThan(0.7 * 1.7);
84 const edge = flashDuration(1.7, stand(r), at, facingIt, false, r);
85 expect(edge).toBeGreaterThan(0);
86 expect(edge).toBeLessThanOrEqual(FLASH_EDGE * 1.7 + 0.01);
87 // Past the radius there is nothing, however hard you were staring.
88 expect(flashDuration(1.7, stand(r + 1), at, facingIt, false, r)).toBe(0);
89 expect(flashDuration(1.7, stand(899), at, facingIt, false, r)).toBe(0);
90 // Closer is never shorter.
91 let last = Infinity;
92 for (let d = 20; d <= r; d += 20) {
93 const now = flashDuration(1.7, stand(d), at, facingIt, false, r);
94 expect(now).toBeLessThanOrEqual(last);
95 last = now;
96 }
97 });
98
99 test("the flash cone is the screen: nothing off screen blinds you", () => {
100 // The browser draws FOV wide, so a pop more than half of that off your
101 // facing was never on your screen, and it does nothing.
102 expect(FLASH_CONE).toBeLessThanOrEqual(FOV / 2 + 1e-9);
103 const at = { x: 1000, y: 1000 };
104 const near = { x: 1100, y: 1000 };
105 const straight = Math.PI;
106 const deg = (n: number) => (n * Math.PI) / 180;
107 expect(flashDuration(1.7, near, at, straight + deg(15), false, 360)).toBe(1.7);
108 expect(flashDuration(1.7, near, at, straight - deg(15), false, 360)).toBe(1.7);
109 // Used to blind at full strength out to 45° and taper to 100°.
110 expect(flashDuration(1.7, near, at, straight + deg(45), false, 360)).toBe(0);
111 expect(flashDuration(1.7, near, at, straight + deg(70), false, 360)).toBe(0);
112 expect(flashDuration(1.7, near, at, straight + deg(100), false, 360)).toBe(0);
113 // Just inside the screen's edge it is a flicker, not the full pop.
114 const edge = flashDuration(1.7, near, at, straight + FLASH_CONE - deg(2), false, 360);
115 expect(edge).toBeGreaterThan(0);
116 expect(edge).toBeLessThan(0.3);
117 });
118
43119 test("a smoke blocks the sight line for as long as it lives", () => {
44120 const smoke: Zone = {
45121 id: 1, kind: "smoke", owner: "x", side: "atk", x: 1200, y: 1000, r: 140,
src/abilities.ts +30 −11
@@ -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 },
@@ -138,10 +138,28 @@ export const SLOTS: Slot[] = ["c", "q", "e", "x"];
138138 /** Bought before the round; the signature and the ultimate are never for sale. */
139139 export const buyableSlots: Slot[] = ["c", "q"];
140140
141 /**
142 * The cone a flash reaches into, either side of where you are facing.
143 *
144 * The browser draws a 75° field of view (`FOV` in `static/raycast.js`), so
145 * the far edge is half of that: a pop you could not have had on screen does
146 * not blind you, because being blinded by something off screen reads as
147 * unfair — it is. Inside `FLASH_FULL_CONE` it is the whole duration; from
148 * there to the edge of the screen it fades to nothing.
149 */
150 export const FLASH_CONE = (37.5 * Math.PI) / 180;
151 export const FLASH_FULL_CONE = (20 * Math.PI) / 180;
152 /** Inside this share of the radius a flash is full strength. */
153 export const FLASH_FULL_REACH = 0.4;
154 /** What is left of the duration at the very edge of the radius: a flicker. */
155 export const FLASH_EDGE = 0.2;
156
141157 /**
142158 * How long a flash blinds, in seconds. Facing it is the worst case; looking
143159 * 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.
160 * Distance matters inside the radius too: the centre is the full duration,
161 * the edge is a flicker, and past the radius there is nothing. `blocked` is
162 * the caller's line-of-sight answer, so this stays pure.
145163 */
146164 export function flashDuration(
147165 full: number,
@@ -149,17 +167,18 @@ export function flashDuration(
149167 at: Point,
150168 facing: number,
151169 blocked: boolean,
170 radius: number,
152171 ): number {
153172 if (blocked) return 0;
173 const distance = Math.hypot(from.x - at.x, from.y - at.y);
174 if (distance > radius) return 0;
154175 const toFlash = Math.atan2(at.y - from.y, at.x - from.x);
155176 let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
156177 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;
178 if (off > FLASH_CONE) return 0;
179 const facingFactor = off <= FLASH_FULL_CONE ? 1 : 1 - (off - FLASH_FULL_CONE) / (FLASH_CONE - FLASH_FULL_CONE);
180 const reach = radius * FLASH_FULL_REACH;
181 const near = distance <= reach ? 1 : 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE);
163182 return Math.round(full * facingFactor * near * 100) / 100;
164183 }
165184
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