benjivers/val Public Public: anyone can read this; the crew can change it.
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]>
7 files
changed +437
−29
README.md +10 −3
| @@ -21,8 +21,10 @@ 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 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' | |
| 26 | 28 | eyes rather than through walls, so dying is not a wallhack to call out. |
| 27 | 29 | No spawn can see the other spawn: mid is staggered on purpose. A patched |
| 28 | 30 | client learns nothing a plain one does not. |
| @@ -37,7 +39,12 @@ server: | ||
| 37 | 39 | `src/arena.ts` by comparing the players either side of a tick — it carries |
| 38 | 40 | names, the gun and an age, and never a position. |
| 39 | 41 | - **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 | |
| 41 | 48 | shoot through the same spread everyone does, and they aim like something |
| 42 | 49 | with hands: 200–350ms to react to a target they have just seen, an aim |
| 43 | 50 | cone that opens several degrees on the swing and only settles while they |
src/abilities.test.ts +82 −6
| @@ -1,7 +1,23 @@ | ||
| 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 | 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"; | |
| 3 | 16 | import { newMatch, startRound, tick } from "./match.ts"; |
| 4 | 17 | 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"); | |
| 5 | 21 | |
| 6 | 22 | const live = (): MatchState => { |
| 7 | 23 | const state = newMatch(11); |
| @@ -24,22 +40,82 @@ test("every agent has two bought basics, a free signature and an ultimate", () = | ||
| 24 | 40 | |
| 25 | 41 | test("a flash blinds by the angle you were facing", () => { |
| 26 | 42 | const at = { x: 1000, y: 1000 }; |
| 27 | const staring = { x: 1200, y: 1000 }; | |
| 43 | const staring = { x: 1100, y: 1000 }; | |
| 28 | 44 | const facingIt = Math.PI; // looking back down the line at the flash |
| 29 | 45 | 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); | |
| 32 | 48 | // 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); | |
| 34 | 50 | expect(glance).toBeGreaterThan(0); |
| 35 | 51 | expect(glance).toBeLessThan(1.7); |
| 36 | 52 | }); |
| 37 | 53 | |
| 38 | 54 | 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); | |
| 40 | 56 | expect(blind).toBe(0); |
| 41 | 57 | }); |
| 42 | 58 | |
| 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 | ||
| 43 | 119 | test("a smoke blocks the sight line for as long as it lives", () => { |
| 44 | 120 | const smoke: Zone = { |
| 45 | 121 | 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[] = [ | ||
| 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 | }, |
| @@ -138,10 +138,28 @@ export const SLOTS: Slot[] = ["c", "q", "e", "x"]; | ||
| 138 | 138 | /** Bought before the round; the signature and the ultimate are never for sale. */ |
| 139 | 139 | export const buyableSlots: Slot[] = ["c", "q"]; |
| 140 | 140 | |
| 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 | ||
| 141 | 157 | /** |
| 142 | 158 | * How long a flash blinds, in seconds. Facing it is the worst case; looking |
| 143 | 159 | * 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. | |
| 145 | 163 | */ |
| 146 | 164 | export function flashDuration( |
| 147 | 165 | full: number, |
| @@ -149,17 +167,18 @@ export function flashDuration( | ||
| 149 | 167 | at: Point, |
| 150 | 168 | facing: number, |
| 151 | 169 | blocked: boolean, |
| 170 | radius: number, | |
| 152 | 171 | ): number { |
| 153 | 172 | if (blocked) return 0; |
| 173 | const distance = Math.hypot(from.x - at.x, from.y - at.y); | |
| 174 | if (distance > radius) return 0; | |
| 154 | 175 | const toFlash = Math.atan2(at.y - from.y, at.x - from.x); |
| 155 | 176 | let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 156 | 177 | 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); | |
| 163 | 182 | return Math.round(full * facingFactor * near * 100) / 100; |
| 164 | 183 | } |
| 165 | 184 |
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 | }; |