rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
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
7 files
changed +515
−36
README.md +12 −3
| @@ -21,8 +21,12 @@ server: | ||
| 21 | 21 | does not past fifteen metres. |
| 22 | 22 | - **Line of sight.** A snapshot only carries the enemies that viewer can |
| 23 | 23 | 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' | |
| 26 | 30 | eyes rather than through walls, so dying is not a wallhack to call out. |
| 27 | 31 | No spawn can see the other spawn: mid is staggered on purpose. A patched |
| 28 | 32 | client learns nothing a plain one does not. |
| @@ -37,7 +41,12 @@ server: | ||
| 37 | 41 | `src/arena.ts` by comparing the players either side of a tick — it carries |
| 38 | 42 | names, the gun and an age, and never a position. |
| 39 | 43 | - **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 | |
| 41 | 50 | shoot through the same spread everyone does, and they aim like something |
| 42 | 51 | with hands: 200–350ms to react to a target they have just seen, an aim |
| 43 | 52 | cone that opens several degrees on the swing and only settles while they |
src/abilities.test.ts +125 −7
| @@ -1,7 +1,26 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 19 | import { newMatch, startRound, tick } from "./match.ts"; |
| 4 | 20 | 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"); | |
| 5 | 24 | |
| 6 | 25 | const live = (): MatchState => { |
| 7 | 26 | const state = newMatch(11); |
| @@ -24,22 +43,121 @@ test("every agent has two bought basics, a free signature and an ultimate", () = | ||
| 24 | 43 | |
| 25 | 44 | test("a flash blinds by the angle you were facing", () => { |
| 26 | 45 | const at = { x: 1000, y: 1000 }; |
| 27 | const staring = { x: 1200, y: 1000 }; | |
| 46 | const staring = { x: 1100, y: 1000 }; | |
| 28 | 47 | const facingIt = Math.PI; // looking back down the line at the flash |
| 29 | 48 | 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); | |
| 34 | 53 | expect(glance).toBeGreaterThan(0); |
| 35 | 54 | expect(glance).toBeLessThan(1.7); |
| 36 | 55 | }); |
| 37 | 56 | |
| 38 | 57 | 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); | |
| 40 | 59 | expect(blind).toBe(0); |
| 41 | 60 | }); |
| 42 | 61 | |
| 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 | ||
| 43 | 161 | test("a smoke blocks the sight line for as long as it lives", () => { |
| 44 | 162 | const smoke: Zone = { |
| 45 | 163 | 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[] = [ | ||
| 74 | 74 | role: "Duelist", |
| 75 | 75 | blurb: "Flash in, burn them off the plant, come back from nothing.", |
| 76 | 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." }), | |
| 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." }), | |
| 78 | 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 | 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 | 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." }), |
| @@ -87,7 +87,7 @@ export const AGENTS: Agent[] = [ | ||
| 87 | 87 | blurb: "Takes the sightline away and gives the site to whoever she likes.", |
| 88 | 88 | abilities: { |
| 89 | 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." }), | |
| 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." }), | |
| 91 | 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 | 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 | 93 | }, |
| @@ -100,7 +100,7 @@ export const AGENTS: Agent[] = [ | ||
| 100 | 100 | abilities: { |
| 101 | 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 | 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." }), | |
| 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." }), | |
| 104 | 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 | 105 | }, |
| 106 | 106 | }, |
| @@ -123,7 +123,7 @@ export const AGENTS: Agent[] = [ | ||
| 123 | 123 | blurb: "Builds the angle he wants and sets the site on fire when it is lost.", |
| 124 | 124 | abilities: { |
| 125 | 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." }), | |
| 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." }), | |
| 127 | 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 | 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 | 129 | }, |
| @@ -139,9 +139,60 @@ export const SLOTS: Slot[] = ["c", "q", "e", "x"]; | ||
| 139 | 139 | export const buyableSlots: Slot[] = ["c", "q"]; |
| 140 | 140 | |
| 141 | 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. | |
| 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. | |
| 145 | 196 | */ |
| 146 | 197 | export function flashDuration( |
| 147 | 198 | full: number, |
| @@ -149,18 +200,13 @@ export function flashDuration( | ||
| 149 | 200 | at: Point, |
| 150 | 201 | facing: number, |
| 151 | 202 | blocked: boolean, |
| 203 | radius: number, | |
| 152 | 204 | ): number { |
| 153 | 205 | 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; | |
| 164 | 210 | } |
| 165 | 211 | |
| 166 | 212 | /** 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 @@ | ||
| 7 | 7 | * to, so the match loop cannot tell them apart and neither can a cheat. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { agentById, castable } from "./abilities.ts"; | |
| 10 | import { agentById, castable, flashDuration, sightBlocked, type Ability } from "./abilities.ts"; | |
| 11 | 11 | 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"; | |
| 13 | 13 | import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts"; |
| 14 | 14 | import type { MatchState, Player, Slot } from "./state.ts"; |
| 15 | 15 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| @@ -129,6 +129,15 @@ export function botThink(state: MatchState, bot: Player, dt: number): void { | ||
| 129 | 129 | bot.think = 0.4 + random(state) * 0.5; |
| 130 | 130 | useKit(state, bot, target ?? null, site, distToGoal); |
| 131 | 131 | } |
| 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 | } | |
| 132 | 141 | |
| 133 | 142 | // --- the spike |
| 134 | 143 | bot.input.use = false; |
| @@ -188,6 +197,47 @@ export function botThink(state: MatchState, bot: Player, dt: number): void { | ||
| 188 | 197 | } |
| 189 | 198 | } |
| 190 | 199 | |
| 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 | ||
| 191 | 241 | /** Ability decisions, kept in one place so each is a sentence you can read. */ |
| 192 | 242 | function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void { |
| 193 | 243 | const agent = agentById(bot.agent); |
| @@ -231,9 +281,19 @@ function useKit(state: MatchState, bot: Player, target: Player | null, site: Sit | ||
| 231 | 281 | continue; |
| 232 | 282 | } |
| 233 | 283 | 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 | } | |
| 237 | 297 | continue; |
| 238 | 298 | } |
| 239 | 299 | if (kind === "smoke") { |
src/match.ts +7 −4
| @@ -142,6 +142,7 @@ export function makePlayer( | ||
| 142 | 142 | goal: null, |
| 143 | 143 | think: 0, |
| 144 | 144 | aim: emptyTrack(), |
| 145 | flashedAt: -99, | |
| 145 | 146 | spray: 0, |
| 146 | 147 | joinedAt: 0, |
| 147 | 148 | }; |
| @@ -235,6 +236,8 @@ export function startRound(state: MatchState, first = false): void { | ||
| 235 | 236 | player.goal = null; |
| 236 | 237 | player.think = 0; |
| 237 | 238 | player.aim = emptyTrack(); |
| 239 | // A new round is a new entry: the team's flash spacing starts clear. | |
| 240 | player.flashedAt = -99; | |
| 238 | 241 | // Basics are bought fresh every round; the signature comes back free. |
| 239 | 242 | player.charges = { c: 0, q: 0, e: 1, x: 0 }; |
| 240 | 243 | player.cooldown = { c: 0, q: 0, e: 0, x: 0 }; |
| @@ -563,13 +566,13 @@ function zones(state: MatchState, dt: number): void { | ||
| 563 | 566 | zone.fuse -= dt; |
| 564 | 567 | if (zone.fuse > 0) continue; |
| 565 | 568 | 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. | |
| 568 | 572 | for (const player of state.players) { |
| 569 | 573 | if (!player.alive) continue; |
| 570 | 574 | 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); | |
| 573 | 576 | if (blind > 0) player.blind = Math.max(player.blind, blind); |
| 574 | 577 | } |
| 575 | 578 | } |
src/state.ts +2
| @@ -100,6 +100,8 @@ export type Player = { | ||
| 100 | 100 | think: number; |
| 101 | 101 | /** Bot scratch space: the target it is holding, and how badly. */ |
| 102 | 102 | aim: Track; |
| 103 | /** Bot scratch space: the match clock when it last threw a flash. */ | |
| 104 | flashedAt: number; | |
| 103 | 105 | spray: number; |
| 104 | 106 | joinedAt: number; |
| 105 | 107 | }; |