/** * The bots' flashes, measured. * * A flash used to reach 900 units — 70% of the map — and a bot threw every * charge the moment it was bought, so in a bot round somebody was blind * most of the time. The numbers below are the bug and the fix: a headless * match, the share of live player-seconds spent blind, and how many flashes * go off a round. Before this file: 4.4% blind, 6.5 flashes a round, and a * flash blinded 0.86 players on average — 0.20 of them the thrower, 0.43 a * team mate, 0.23 an enemy. */ import { expect, test } from "bun:test"; import { agentById } from "./abilities.ts"; import { attackSite, FLASH_REPEAT, FLASH_SPACING, flashCall, flashSpot, runBots } from "./bots.ts"; import { siteCentre } from "./map.ts"; import { newMatch, ROUND_TIME, tick } from "./match.ts"; import type { MatchState, Player } from "./state.ts"; const flashZones = (state: MatchState): Set => new Set(state.zones.filter((z) => z.kind === "flash").map((z) => z.id)); /** Held where it stands: a plant in progress is the one thing that sets a body's speed to zero. */ const pin = (p: Player): void => { p.planting = 1; }; /** * One live round in the side corridor of the site the attack is going to: * an Ember bot with two flashes at the bottom of it, 860 from the site — an * entry, as far as its kit is concerned — and an enemy standing still at * the top facing down it, 800 away, so a 620 throw pops 180 in front of * the enemy's face. Everybody else is parked on the far site, not looking. */ function corridor(): { state: MatchState; bot: Player; enemy: Player; mates: Player[]; x: number } { const state = newMatch(21); state.phase = "live"; state.clock = ROUND_TIME; const site = attackSite(state); const x = site === "A" ? 120 : 2280; const far = siteCentre(site === "A" ? "B" : "A"); const atk = state.players.filter((p) => p.side === "atk"); const def = state.players.filter((p) => p.side === "def"); for (const p of state.players) { p.bot = false; p.x = far.x; p.y = far.y; p.angle = Math.PI / 2; p.input.angle = p.angle; p.input.mx = 0; p.input.my = 0; p.spike = false; } state.spike.carried = null; state.spike.x = far.x; state.spike.y = far.y; const bot = atk[0]!; bot.bot = true; bot.agent = "ember"; bot.charges = { c: 2, q: 0, e: 0, x: 0 }; bot.x = x; bot.y = 1150; bot.angle = -Math.PI / 2; bot.input.angle = bot.angle; pin(bot); const enemy = def[0]!; enemy.x = x; enemy.y = 350; enemy.angle = Math.PI / 2; enemy.input.angle = Math.PI / 2; // A sandbag: the bot shoots at it too, and these tests are about the flash. enemy.hp = 1e6; return { state, bot, enemy, mates: atk.slice(1), x }; } const play = (state: MatchState, seconds: number, seen?: Set): void => { for (let i = 0; i < Math.round(seconds * 30); i++) { runBots(state, 1 / 30); tick(state, 1 / 30); if (seen) for (const id of flashZones(state)) seen.add(id); } }; test("a bot throws one flash at the enemy in front of it, not both charges at once", () => { const { state, bot, enemy } = corridor(); const seen = new Set(); play(state, 0.1, seen); expect(seen.size).toBe(1); // The one that went does its job: the enemy facing the thrower is blinded. play(state, 1.5, seen); expect(enemy.blind).toBeGreaterThan(0); // The second charge stays in hand for the repeat, however often it thinks. play(state, FLASH_REPEAT - 2, seen); expect(seen.size).toBe(1); expect(bot.charges.c).toBe(1); // And it is not lost: once the repeat has passed it goes. for (let i = 0; i < 3 * 30 && seen.size < 2; i++) play(state, 1 / 30, seen); expect(seen.size).toBe(2); }); test("a team waits between flashes: one bot's throw holds a team mate's", () => { const { state, bot, mates, x } = corridor(); const mate = mates[0]!; // A second thrower right beside the first, with two flashes of its own. mate.bot = true; mate.agent = "ember"; mate.charges = { c: 2, q: 0, e: 0, x: 0 }; mate.x = x; mate.y = 1190; mate.angle = -Math.PI / 2; mate.input.angle = mate.angle; pin(mate); play(state, FLASH_SPACING - 0.5); expect(4 - bot.charges.c - mate.charges.c).toBe(1); // Past the team's spacing the other thrower's own goes. play(state, 1.5); expect(4 - bot.charges.c - mate.charges.c).toBe(2); expect(bot.charges.c).toBe(1); expect(mate.charges.c).toBe(1); }); test("a bot holds the flash while a team mate stands in it facing the pop", () => { const { state, bot, mates, enemy, x } = corridor(); const mate = mates[0]!; mate.x = x; mate.y = 650; mate.angle = -Math.PI / 2; mate.input.angle = mate.angle; play(state, 2); expect(bot.charges.c).toBe(2); expect(flashZones(state).size).toBe(0); // The same team mate back beside the thrower, looking the other way, is // not in the flash's business: the throw goes, and lands on the enemy. mate.y = 1200; mate.angle = Math.PI / 2; mate.input.angle = mate.angle; play(state, 2); expect(bot.charges.c).toBe(1); expect(mate.blind).toBe(0); expect(enemy.blind).toBeGreaterThan(0); }); test("a bot never throws a flash it could run into, or one that pops behind the enemy's head", () => { const { state, bot, enemy, x } = corridor(); const ability = agentById("ember").abilities.c; // Into the wall right behind it: the pop would land inside its own radius. expect(flashCall(state, bot, ability, { x, y: 1440 }, null)).toBe(false); // Up the corridor at the enemy 800 out: the pop lands in front of it. const spot = flashSpot(bot, ability, enemy); expect(spot.y).toBeGreaterThan(enemy.y); expect(flashCall(state, bot, ability, enemy, enemy)).toBe(true); // An enemy 450 out facing the thrower: the throw flies 620 and pops behind // them, where they are not looking. Not thrown. enemy.y = 700; expect(flashCall(state, bot, ability, enemy, enemy)).toBe(false); // Turned around, that same pop is right in front of them. Thrown. enemy.angle = -Math.PI / 2; expect(flashCall(state, bot, ability, enemy, enemy)).toBe(true); enemy.angle = Math.PI / 2; enemy.input.angle = enemy.angle; play(state, 2); expect(bot.charges.c).toBe(2); }); test("a flash is for taking a site, not for every duel on the way", () => { const { state, bot } = corridor(); // The same bot and the same enemy, but far from the site it is attacking. bot.y = 1400; const enemyY = 600; const enemy = state.players.find((p) => p.side === "def" && p.y === 350)!; enemy.y = enemyY; play(state, 2); expect(bot.charges.c).toBe(2); }); /** A headless match, and the numbers the issue is about. */ function measure(seeds: number[], minutes: number) { let liveSeconds = 0; let blindSeconds = 0; let anyBlindSeconds = 0; let flashes = 0; let rounds = 0; const hit = { self: 0, own: 0, enemy: 0 }; for (const seed of seeds) { const state = newMatch(seed); const seen = new Set(); const before = new Map(); let round = 0; for (let i = 0; i < 30 * 60 * minutes && state.phase !== "over"; i++) { if (state.round !== round) { round = state.round; rounds++; } runBots(state, 1 / 30); const popping = state.phase === "live" ? state.zones.filter((z) => z.kind === "flash" && z.fuse > 0 && z.fuse - 1 / 30 <= 0) : []; for (const p of state.players) before.set(p.id, p.blind); tick(state, 1 / 30); for (const id of flashZones(state)) if (!seen.has(id)) { seen.add(id); flashes++; } const pop = popping[0]; if (pop) for (const p of state.players) if (p.blind > (before.get(p.id) ?? 0)) hit[pop.owner === p.id ? "self" : pop.side === p.side ? "own" : "enemy"]++; if (state.phase !== "live") continue; const alive = state.players.filter((p) => p.alive); liveSeconds += alive.length / 30; const blind = alive.filter((p) => p.blind > 0).length; blindSeconds += blind / 30; if (blind > 0) anyBlindSeconds += 1 / 30; } } const per = (n: number) => n / Math.max(1, flashes); return { rounds, flashesPerRound: flashes / rounds, blindShare: blindSeconds / liveSeconds, anyoneBlindShare: anyBlindSeconds / (liveSeconds / 10), perFlash: { self: per(hit.self), own: per(hit.own), enemy: per(hit.enemy) }, }; } test("a flash is a tool, not weather: the blind share of a bot match, measured", () => { const m = measure([1, 2, 3, 4], 10); const pct = (n: number) => `${(n * 100).toFixed(2)}%`; const f = (n: number) => n.toFixed(2); console.log( `flashes: ${m.rounds} rounds, ${f(m.flashesPerRound)} a round (was 6.48); blind ${pct(m.blindShare)} of live player-seconds (was 4.36%), ` + `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)`, ); expect(m.rounds).toBeGreaterThan(20); expect(m.flashesPerRound).toBeLessThan(2); expect(m.blindShare).toBeLessThan(0.015); expect(m.anyoneBlindShare).toBeLessThan(0.1); // A flash is thrown at the other team: it blinds them, and not its own. expect(m.perFlash.self).toBeLessThan(0.01); expect(m.perFlash.own).toBeLessThan(0.05); expect(m.perFlash.enemy).toBeGreaterThan(0.5); });