Public: anyone can read this; the crew can change it.
forked from trevato/cs
bots should take cover when reloading. bots are still pretty dumb. they just… #360
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/357-bots-should-take-cover-when-reloading-bo → main 1 commit fork
5 files
changed +325
−23
README.md +14 −1
| @@ -887,7 +887,7 @@ never checked itself. | ||
| 887 | 887 | `world` (the simulation, and the two seconds of what just happened it keeps in |
| 888 | 888 | the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at |
| 889 | 889 | the end of every tick by `EFFECT_LIFE` without allocating), |
| 890 | `bots`, `observe`, `action`, `weights`, `models`, `materials`, | |
| 890 | `bots`, `cover`, `observe`, `action`, `weights`, `models`, `materials`, | |
| 891 | 891 | `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round |
| 892 | 892 | clock, the kill feed, the scoreboard and the red arc and flash that say you |
| 893 | 893 | were hit and from where — every one of them drawn through the |
| @@ -1439,6 +1439,19 @@ already solves it, so that stayed as it was: pathing, buying, planting, | ||
| 1439 | 1439 | defusing, listening for shots through walls. The fight is a reflex, and that is |
| 1440 | 1440 | now a policy learned with PPO. |
| 1441 | 1441 | |
| 1442 | One piece of the fight is scripted on purpose: **reloading**. A bot that runs | |
| 1443 | its magazine dry with an enemy in view does not stand there for the two and a | |
| 1444 | half seconds it cannot shoot. `src/client/cover.ts` searches the cells up to | |
| 1445 | four out (512 units) for the nearest one whose standing eye and both shoulders | |
| 1446 | the enemy has no line to — a wall hides a cell and so does a smoke — and the | |
| 1447 | bot runs there at most six cells of path, watching where the enemy was, and | |
| 1448 | hands back to the policy the moment the magazine is in. The search is cut once | |
| 1449 | per reload: on de_forge it finds cover for 82% of the sampled positions a fight | |
| 1450 | can start from (419 of 509), averages 0.18 ms and was 2.1 ms at worst. With no | |
| 1451 | cover in reach the policy keeps the fight as before. The scripted baseline | |
| 1452 | bot in the arena never does any of this, so what the policy is measured against | |
| 1453 | has not moved. | |
| 1454 | ||
| 1442 | 1455 | - **What it sees** — 33 numbers, all egocentric (`src/client/observe.ts`): the |
| 1443 | 1456 | sine and cosine of its aim error, the range, the enemy's health and whether it |
| 1444 | 1457 | is looking back, its own health, armour, magazine, recoil, current inaccuracy |
src/client/bots.ts +71
| @@ -50,6 +50,7 @@ import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts"; | ||
| 50 | 50 | import { buy } from "./economy.ts"; |
| 51 | 51 | import type { Input, Player, World } from "./world.ts"; |
| 52 | 52 | import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts"; |
| 53 | import { coverFrom } from "./cover.ts"; | |
| 53 | 54 | |
| 54 | 55 | /** |
| 55 | 56 | * The policy every bot in this process thinks with, put here once by whoever |
| @@ -140,6 +141,15 @@ export type BotBrain = { | ||
| 140 | 141 | jitterUntil: number; |
| 141 | 142 | /** False runs the pre-policy fight: the arena's opponent and its baseline. */ |
| 142 | 143 | useLearned: boolean; |
| 144 | /** | |
| 145 | * Where this bot is going to reload out of sight: the path from `coverFrom`, | |
| 146 | * null when there was nowhere to go, cut once per reload — `coverFor` is the | |
| 147 | * `reloadUntil` it was cut for — and `coverLook` is where the threat was. | |
| 148 | */ | |
| 149 | cover: Vec3[] | null; | |
| 150 | coverStep: number; | |
| 151 | coverFor: number; | |
| 152 | coverLook: Vec3 | null; | |
| 143 | 153 | }; |
| 144 | 154 | |
| 145 | 155 | export function makeBrain(skill: number, seed: number, useLearned = true): BotBrain { |
| @@ -170,6 +180,10 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 170 | 180 | jitterApplied: 0, |
| 171 | 181 | jitterUntil: 0, |
| 172 | 182 | useLearned, |
| 183 | cover: null, | |
| 184 | coverStep: 0, | |
| 185 | coverFor: -1, | |
| 186 | coverLook: null, | |
| 173 | 187 | }; |
| 174 | 188 | } |
| 175 | 189 | |
| @@ -647,6 +661,9 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 647 | 661 | brain.intent = { ...STILL }; |
| 648 | 662 | // A fresh round hands the body a fresh view; the pull is not owed back. |
| 649 | 663 | brain.pull = [0, 0]; |
| 664 | brain.cover = null; | |
| 665 | brain.coverFor = -1; | |
| 666 | brain.coverLook = null; | |
| 650 | 667 | input.switchTo = bot.primary ?? bot.secondary; |
| 651 | 668 | } |
| 652 | 669 | const job = jobOf(world, bot, brain); |
| @@ -680,6 +697,13 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 680 | 697 | brain.targetId = null; |
| 681 | 698 | } |
| 682 | 699 | |
| 700 | // --- reloading, which is not a fight. A bot with nothing in the magazine | |
| 701 | // gets out of sight until there is something in it, then the policy has it | |
| 702 | // back. Not the scripted brain: it is the baseline, and it does not move. | |
| 703 | if (brain.useLearned && world.time < bot.reloadUntil && takeCover(world, bot, brain, target, input, dt)) { | |
| 704 | return withBomb(world, bot, input); | |
| 705 | } | |
| 706 | ||
| 683 | 707 | // --- fighting, which is the policy's job |
| 684 | 708 | if (target !== null) { |
| 685 | 709 | // The gun this input is about is the one the swap above is handing it, not |
| @@ -753,6 +777,53 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 753 | 777 | return withBomb(world, bot, input); |
| 754 | 778 | } |
| 755 | 779 | |
| 780 | /** | |
| 781 | * One tick of reloading behind something: walk the path `coverFrom` cut, at a | |
| 782 | * run, with the view on where the threat was, and stand still once there. The | |
| 783 | * path is cut the first tick of a reload that has an enemy in view — a reload | |
| 784 | * started out of sight is already where it should be — and never recut: a bot | |
| 785 | * that changes its mind about cover twice in two seconds reaches none of it. | |
| 786 | * | |
| 787 | * False leaves the tick to the fight below, which is what happens when there | |
| 788 | * is no threat or nowhere within reach to hide from it: the policy strafes, as | |
| 789 | * it always did, and that is better than walking into the open. | |
| 790 | */ | |
| 791 | export function takeCover( | |
| 792 | world: World, bot: Player, brain: BotBrain, target: Player | null, input: Input, dt: number, | |
| 793 | ): boolean { | |
| 794 | if (brain.coverFor !== bot.reloadUntil) { | |
| 795 | brain.coverFor = bot.reloadUntil; | |
| 796 | brain.cover = null; | |
| 797 | brain.coverStep = 0; | |
| 798 | brain.coverLook = null; | |
| 799 | if (target !== null) { | |
| 800 | brain.cover = coverFrom(world, bot, target); | |
| 801 | brain.coverLook = bodyCentre(target); | |
| 802 | } | |
| 803 | } | |
| 804 | if (brain.cover === null) return false; | |
| 805 | if (target !== null) brain.coverLook = bodyCentre(target); | |
| 806 | brain.memory.sinceSeen = target !== null ? 0 : brain.memory.sinceSeen + dt; | |
| 807 | // Whatever the policy decided before the reload, it decides afresh after. | |
| 808 | brain.hold = 0; | |
| 809 | if (brain.coverLook !== null) { | |
| 810 | const look = aimAt(bot, brain.coverLook); | |
| 811 | turnTowards(bot, look, brain.aimSpeed * 0.5, dt); | |
| 812 | } | |
| 813 | let next = brain.cover[brain.coverStep]; | |
| 814 | if (next !== undefined && dist([bot.pos[0], bot.pos[1], 0], [next[0], next[1], 0]) < 24) { | |
| 815 | brain.coverStep += 1; | |
| 816 | next = brain.cover[brain.coverStep]; | |
| 817 | } | |
| 818 | if (next !== undefined) { | |
| 819 | const aim = aimAt(bot, [next[0], next[1], bot.pos[2] + 48]); | |
| 820 | const off = angleDelta(bot.yaw, aim.yaw); | |
| 821 | input.forward = Math.cos((off * Math.PI) / 180); | |
| 822 | input.side = -Math.sin((off * Math.PI) / 180); | |
| 823 | } | |
| 824 | return true; | |
| 825 | } | |
| 826 | ||
| 756 | 827 | /** Planting and defusing: press use when standing where it matters. */ |
| 757 | 828 | function withBomb(world: World, bot: Player, input: Input): Input { |
| 758 | 829 | const bomb = world.bomb; |
src/client/cover.test.ts added +120
| @@ -0,0 +1,120 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { COVER_STEPS, coverFrom, exposed } from "./cover.ts"; | |
| 3 | import { botThink, makeBrain } from "./bots.ts"; | |
| 4 | import { DE_FORGE } from "./map.ts"; | |
| 5 | import { COMPETITIVE } from "./match.ts"; | |
| 6 | import { openCells } from "./level.ts"; | |
| 7 | import { useShipped } from "./shipped.ts"; | |
| 8 | import { WEAPONS } from "./weapons.ts"; | |
| 9 | import { applyInput, canSee, createWorld, eyePos, type Player, type World } from "./world.ts"; | |
| 10 | import type { Vec3 } from "../engine/index.ts"; | |
| 11 | ||
| 12 | useShipped(); | |
| 13 | ||
| 14 | type Scene = { world: World; me: Player; bot: Player }; | |
| 15 | ||
| 16 | const scene = (): Scene => { | |
| 17 | const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 7); | |
| 18 | world.match.phase = "live"; | |
| 19 | const bot = world.players[1] as Player; | |
| 20 | bot.brain = makeBrain(1, 99); | |
| 21 | bot.brain.reset = false; | |
| 22 | bot.brain.seenAt = -10; | |
| 23 | return { world, me: world.me, bot }; | |
| 24 | }; | |
| 25 | ||
| 26 | /** | |
| 27 | * The first pair of de_forge cells, in reading order, where the bot stands in | |
| 28 | * plain view of the player at a fighting distance and has cover within reach. | |
| 29 | * Found rather than typed in, so a map edit moves the scene and not the test. | |
| 30 | */ | |
| 31 | function standoff(s: Scene): Vec3[] { | |
| 32 | const cells = openCells(DE_FORGE, 2); | |
| 33 | for (let i = 0; i < cells.length; i += 5) | |
| 34 | for (let j = 0; j < cells.length; j += 11) { | |
| 35 | s.me.pos = [...(cells[i] as Vec3)]; | |
| 36 | s.bot.pos = [...(cells[j] as Vec3)]; | |
| 37 | const d = Math.hypot(s.me.pos[0] - s.bot.pos[0], s.me.pos[1] - s.bot.pos[1]); | |
| 38 | if (d < 600 || d > 1500 || !canSee(s.world, s.bot, s.me)) continue; | |
| 39 | const path = coverFrom(s.world, s.bot, s.me); | |
| 40 | if (path !== null && path.length > 1) return path; | |
| 41 | } | |
| 42 | throw new Error("de_forge has no standoff with cover in reach"); | |
| 43 | } | |
| 44 | ||
| 45 | test("a body in the open is exposed, and one behind a wall is not", () => { | |
| 46 | const s = scene(); | |
| 47 | const path = standoff(s); | |
| 48 | expect(exposed(s.world, eyePos(s.me), s.bot.pos)).toBe(true); | |
| 49 | const end = path[path.length - 1] as Vec3; | |
| 50 | expect(exposed(s.world, eyePos(s.me), end)).toBe(false); | |
| 51 | }); | |
| 52 | ||
| 53 | test("cover is a short walk, and a bot already out of sight is already there", () => { | |
| 54 | const s = scene(); | |
| 55 | const path = standoff(s); | |
| 56 | expect(path.length).toBeLessThanOrEqual(COVER_STEPS); | |
| 57 | // Stand where the path ends: there is nowhere further to go. | |
| 58 | s.bot.pos = [...(path[path.length - 1] as Vec3)]; | |
| 59 | expect(coverFrom(s.world, s.bot, s.me)).toEqual([]); | |
| 60 | }); | |
| 61 | ||
| 62 | /** Empty the bot's rifle and play its reload out, the bot at the wheel. */ | |
| 63 | function reloadUnderFire(s: Scene, useLearned: boolean): { hiddenAtEnd: boolean; at: Vec3 } { | |
| 64 | const dt = 1 / 64; | |
| 65 | s.bot.brain = makeBrain(1, 99, useLearned); | |
| 66 | s.bot.brain.reset = false; | |
| 67 | s.bot.brain.seenAt = -10; | |
| 68 | s.bot.weapon = "ak47"; | |
| 69 | s.bot.primary = "ak47"; | |
| 70 | s.bot.ammo.ak47 = { mag: 0, reserve: 90 }; | |
| 71 | s.bot.yaw = Math.atan2(s.me.pos[1] - s.bot.pos[1], s.me.pos[0] - s.bot.pos[0]) * (180 / Math.PI); | |
| 72 | let started = false; | |
| 73 | for (let t = 0; t < Math.ceil((WEAPONS.ak47.reloadTime + 0.2) / dt); t++) { | |
| 74 | const input = botThink(s.world, s.bot, dt); | |
| 75 | applyInput(s.world, s.bot, input, dt); | |
| 76 | s.world.time += dt; | |
| 77 | if (s.bot.reloadUntil > 0) started = true; | |
| 78 | // Judge it the last tick the magazine is still going in. | |
| 79 | if (started && s.bot.reloadUntil > 0 && s.world.time + dt >= s.bot.reloadUntil) break; | |
| 80 | } | |
| 81 | expect(started).toBe(true); | |
| 82 | return { | |
| 83 | hiddenAtEnd: !canSee(s.world, s.bot, s.me), | |
| 84 | at: [...s.bot.pos], | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | test("a bot that runs dry in view of an enemy reloads behind cover", () => { | |
| 89 | const s = scene(); | |
| 90 | const path = standoff(s); | |
| 91 | const end = path[path.length - 1] as Vec3; | |
| 92 | const out = reloadUnderFire(s, true); | |
| 93 | // It went where the search said, and not wherever its strafing took it. | |
| 94 | expect(Math.hypot(out.at[0] - end[0], out.at[1] - end[1])).toBeLessThan(48); | |
| 95 | expect(out.hiddenAtEnd).toBe(true); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a bot takes cover once per reload, and the policy has it back when the magazine is in", () => { | |
| 99 | const s = scene(); | |
| 100 | standoff(s); | |
| 101 | reloadUnderFire(s, true); | |
| 102 | const brain = s.bot.brain!; | |
| 103 | const cutFor = brain.coverFor; | |
| 104 | expect(brain.cover).not.toBeNull(); | |
| 105 | // The reload finishes; the next tick is the fight's again, and a cover path | |
| 106 | // cut for a reload that is over is never walked. | |
| 107 | s.world.time = s.bot.reloadUntil + 0.01; | |
| 108 | applyInput(s.world, s.bot, { ...botThink(s.world, s.bot, 1 / 64) }, 1 / 64); | |
| 109 | expect(s.bot.reloadUntil).toBe(0); | |
| 110 | expect(brain.coverFor).toBe(cutFor); | |
| 111 | expect(s.bot.ammo.ak47?.mag).toBe(30); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the scripted baseline does not take cover: it is what the policy is measured against", () => { | |
| 115 | const s = scene(); | |
| 116 | standoff(s); | |
| 117 | reloadUnderFire(s, false); | |
| 118 | expect(s.bot.brain!.cover).toBeNull(); | |
| 119 | expect(s.bot.brain!.coverFor).toBe(-1); | |
| 120 | }); |
src/client/cover.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * Cover: somewhere near a bot that an enemy cannot see into. A bot reloading | |
| 3 | * in the open is two and a half seconds of a body that cannot shoot back, and | |
| 4 | * before this is where the bots spent them — standing on the spot the fight | |
| 5 | * found them, strafing a metre either way. A person ducks behind the nearest | |
| 6 | * crate, and so, now, does a bot. | |
| 7 | * | |
| 8 | * The search is over the level's own cells, because they are what a bot can | |
| 9 | * walk to: rings of cells around the bot, nearest ring first, and the first | |
| 10 | * cell whose standing eye — and both shoulders — the threat's eye has no line | |
| 11 | * to. A wall hides a cell and so does a cloud, through the same `throughSmoke` | |
| 12 | * that `canSee` asks. The cell has to be reachable, too, by a path no longer | |
| 13 | * than a reload is worth walking; a cell behind a wall with the door on the | |
| 14 | * enemy's side is not cover, it is a detour. | |
| 15 | * | |
| 16 | * It is scripted and outside the policy, like the smoke-skirting next door in | |
| 17 | * `bots.ts`: `OBS_SIZE`, the heads and the shipped weights know nothing of it. | |
| 18 | */ | |
| 19 | ||
| 20 | import { visible, type Vec3 } from "../engine/index.ts"; | |
| 21 | import { colOf, findPath, rowOf, walkable, worldOf, type Level } from "./level.ts"; | |
| 22 | import { CS_HULL } from "./movement.ts"; | |
| 23 | import { eyePos, throughSmoke, type Player, type World } from "./world.ts"; | |
| 24 | ||
| 25 | /** How many cells out the search looks: four is 512 units, a reload's walk. */ | |
| 26 | export const COVER_RINGS = 4; | |
| 27 | ||
| 28 | /** The longest path, in steps, worth walking to get out of sight. */ | |
| 29 | export const COVER_STEPS = 7; | |
| 30 | ||
| 31 | /** How many of the nearest hidden cells are tried for a path before giving up. */ | |
| 32 | const COVER_TRIES = 4; | |
| 33 | ||
| 34 | /** | |
| 35 | * Can the eye at `from` see a body standing at `at` (its feet)? The eye of | |
| 36 | * that body and a point a hull's radius to either side of it, across the line | |
| 37 | * of sight: a cell whose centre is hidden behind a corner but whose shoulder | |
| 38 | * pokes past it is not somewhere to reload. | |
| 39 | */ | |
| 40 | export function exposed(world: World, from: Vec3, at: Vec3): boolean { | |
| 41 | const eye: Vec3 = [at[0], at[1], at[2] + CS_HULL.eyeStand]; | |
| 42 | const dx = eye[0] - from[0]; | |
| 43 | const dy = eye[1] - from[1]; | |
| 44 | const len = Math.hypot(dx, dy) || 1; | |
| 45 | const sx = (-dy / len) * CS_HULL.radius; | |
| 46 | const sy = (dx / len) * CS_HULL.radius; | |
| 47 | for (const side of [0, 1, -1]) { | |
| 48 | const point: Vec3 = [eye[0] + sx * side, eye[1] + sy * side, eye[2]]; | |
| 49 | if (visible(from, point, world.level.boxes) && !throughSmoke(world, from, point)) return true; | |
| 50 | } | |
| 51 | return false; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * The path to the nearest cover from `threat`, or null when there is none | |
| 56 | * within reach. Nearest by straight line, ties broken by which is further from | |
| 57 | * the threat; the path is A*'s, ending on the cell's centre. A bot already out | |
| 58 | * of sight gets an empty path — it is where it should be. | |
| 59 | */ | |
| 60 | export function coverFrom(world: World, bot: Player, threat: Player): Vec3[] | null { | |
| 61 | const level: Level = world.level; | |
| 62 | const from = eyePos(threat); | |
| 63 | if (!exposed(world, from, bot.pos)) return []; | |
| 64 | const col = colOf(level, bot.pos[0]); | |
| 65 | const row = rowOf(level, bot.pos[1]); | |
| 66 | const hidden: { at: Vec3; near: number; away: number }[] = []; | |
| 67 | for (let ring = 1; ring <= COVER_RINGS; ring++) { | |
| 68 | for (let dr = -ring; dr <= ring; dr++) | |
| 69 | for (let dc = -ring; dc <= ring; dc++) { | |
| 70 | if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring) continue; | |
| 71 | if (!walkable(level, col + dc, row + dr)) continue; | |
| 72 | const at = worldOf(level, col + dc, row + dr, bot.pos[2]); | |
| 73 | if (exposed(world, from, at)) continue; | |
| 74 | hidden.push({ | |
| 75 | at, | |
| 76 | near: Math.hypot(at[0] - bot.pos[0], at[1] - bot.pos[1]), | |
| 77 | away: Math.hypot(at[0] - threat.pos[0], at[1] - threat.pos[1]), | |
| 78 | }); | |
| 79 | } | |
| 80 | } | |
| 81 | hidden.sort((a, b) => a.near - b.near || b.away - a.away); | |
| 82 | for (const cell of hidden.slice(0, COVER_TRIES)) { | |
| 83 | const path = findPath(level, bot.pos, cell.at); | |
| 84 | if (path.length > 0 && path.length <= COVER_STEPS) return path; | |
| 85 | } | |
| 86 | return null; | |
| 87 | } |
src/client/replay.test.ts +33 −22
| @@ -54,7 +54,18 @@ const SECONDS = 40; | ||
| 54 | 54 | const TICKS = SECONDS * TICKS_PER_SECOND; |
| 55 | 55 | |
| 56 | 56 | /** |
| 57 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #165, | |
| 57 | * Forty seconds of a five-a-side round on de_forge, seed 7. Re-pinned for #357, | |
| 58 | * and the play moved on purpose: a bot that runs its magazine dry with an | |
| 59 | * enemy in view now walks to the nearest cell that enemy cannot see | |
| 60 | * (`src/client/cover.ts`) and reloads there, instead of strafing on the spot. | |
| 61 | * The first twelve seconds are the numbers #165 left, to the character; second | |
| 62 | * 13 is the first reload under fire and every second after it has somebody | |
| 63 | * somewhere else. The smoke round, the switch round and the open generated | |
| 64 | * match moved at the same beat for the same reason. The arena's duel, the | |
| 65 | * tight generated match and `generate.test.ts`'s round did not: nobody in them | |
| 66 | * reloads with an enemy in view, and the scripted baseline never takes cover. | |
| 67 | * | |
| 68 | * Before that it was re-pinned for #165, | |
| 58 | 69 | * which put a smoke grenade in the game, and for the reason #164 and #142 were |
| 59 | 70 | * re-pinned before it: what moved is the *hash*, not the play. A pocket is |
| 60 | 71 | * three counts now rather than two, a live grenade's kind is an index into |
| @@ -106,7 +117,7 @@ const TICKS = SECONDS * TICKS_PER_SECOND; | ||
| 106 | 117 | * anything: the bots buy no grenades and the scripted person never presses |
| 107 | 118 | * either key, so no flash goes off and no `blind` is ever non-zero here. |
| 108 | 119 | */ |
| 109 | const ROUND = "2baf12fbafbff25007a9147511d1307ee9f4d5cf7d0b2473d7dae4fea876490e"; | |
| 120 | const ROUND = "234cf0035b71e3398516909bb9f02c63574799075c4f053fb275a41fce763d7b"; | |
| 110 | 121 | /* |
| 111 | 122 | * Not re-pinned for #143, which gave every weapon a deploy time, and the reason |
| 112 | 123 | * is worth writing down rather than leaving to look like an oversight. Two |
| @@ -121,13 +132,13 @@ const ROUND_SECONDS = [ | ||
| 121 | 132 | "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5", |
| 122 | 133 | "0fab6fec7329", "46cbc192435f", "db863794da65", "0e5d2d2d2685", |
| 123 | 134 | "c693a6f1b5b1", "dd5ba820b82f", "6a9d536dc726", "3e3b6bf68300", |
| 124 | "49e7ea12852b", "088f53f05a7b", "ff5cfd8a23ee", "79b70ab03724", | |
| 125 | "458b377ac8b0", "81c86e729615", "7db95c82ca95", "9ce89515f860", | |
| 126 | "74b44ef7c080", "1c8476381a49", "e4716444e2e0", "4b6a1819fbae", | |
| 127 | "13a62553b5fe", "ca237b750358", "d2bb12aba939", "86eb5bef3ba7", | |
| 128 | "ad7857be9d5b", "db763c785460", "8f761bbb7fd6", "7f701a5bf90f", | |
| 129 | "30e1a75cffa6", "2ed2a01fb154", "aebb3d0af576", "297ffec85d70", | |
| 130 | "357635b90451", "168bd830c746", "c8eaf7365264", "5b20351850e8", | |
| 135 | "5e5e27e0756a", "57e1cfaaff8c", "e501e1790001", "c08a49cfce32", | |
| 136 | "033f01853e26", "a0f0138572dd", "4cd639380d1b", "80cde97c55fa", | |
| 137 | "e92cb0d4c767", "806070dcc75c", "25b49aa6a33b", "5b8919932296", | |
| 138 | "cc925b7292bd", "0627ea739071", "c09215cb1b40", "2fd66aef3aa0", | |
| 139 | "46b2114868ff", "f4e3161b54aa", "f6bb2b9ea7e1", "32114bb8bc90", | |
| 140 | "60e784bb3f7e", "6fb2cbe43109", "3a596cd6ad01", "d17f4534c05a", | |
| 141 | "85d2a2012379", "37305fc17bef", "cbb077b9e9d4", "f3bf87059a4b", | |
| 131 | 142 | ]; |
| 132 | 143 | |
| 133 | 144 | /** |
| @@ -177,16 +188,16 @@ const DUEL_SECONDS = [ | ||
| 177 | 188 | */ |
| 178 | 189 | const SMOKE_SECONDS_LONG = 30; |
| 179 | 190 | const SMOKE_TICKS = SMOKE_SECONDS_LONG * TICKS_PER_SECOND; |
| 180 | const SMOKE_ROUND = "6484de2cd2ba8f6b9beba2620c1432f9701b64eea95b0710674cc9e2cf59b841"; | |
| 191 | const SMOKE_ROUND = "21a08801224e0e51c291bbe73f0d07aadc25eb0eed62603e0135725b107e6e74"; | |
| 181 | 192 | const SMOKE_ROUND_SECONDS: readonly string[] = [ |
| 182 | 193 | "c858433691b7", "ae8816aaf901", "28196227d006", "5dd7f1376bf8", |
| 183 | 194 | "764ca02fa9f0", "5ffb3df0e281", "1bd6a3521cb1", "59fde5add12d", |
| 184 | 195 | "6c43f1cba43b", "78791606a640", "37beb5da7147", "18f2f45184e2", |
| 185 | "c83eb155baee", "c3d81a2d00f8", "961ca06c12b0", "3e98ea17a149", | |
| 186 | "de868179bf35", "6134bc3a91eb", "66705f6c4e85", "41500f852715", | |
| 187 | "82b63aa21f80", "7221bbca2d61", "4bf7ac3920dc", "d9f786de91cf", | |
| 188 | "a6197ae41785", "a6a828c675b7", "bf6377ccc22d", "d8a7a7d93b84", | |
| 189 | "64b65f8f4879", "cb3d1576cec1", | |
| 196 | "6726957db442", "76ed428707f4", "2f5e95ce6a13", "a088efe4594f", | |
| 197 | "6c0dad4ffb8a", "d5bcad4f2329", "77c2b36844f1", "18cb155326f5", | |
| 198 | "6de256a70289", "c5e6bc6a7471", "cd0607ffe9d2", "f3919151a8e8", | |
| 199 | "e72a76f76eee", "dcfa3614b3b1", "c8294c1cdc6a", "58a4c30b521c", | |
| 200 | "e075e6a9f8d6", "c4dbfac761cb", | |
| 190 | 201 | ]; |
| 191 | 202 | |
| 192 | 203 | const SMOKE_PIN: Pin = { |
| @@ -286,13 +297,13 @@ test("the smoked round has a cloud standing in it, and it is gone by the end", ( | ||
| 286 | 297 | */ |
| 287 | 298 | const SWITCH_SECONDS_LONG = 20; |
| 288 | 299 | const SWITCH_TICKS = SWITCH_SECONDS_LONG * TICKS_PER_SECOND; |
| 289 | const SWITCH_ROUND = "899614cb381d4d54c0856641963f1aaa6c9d8abac2c3a8d52b2f8aaef05c016e"; | |
| 300 | const SWITCH_ROUND = "55acf4dfd45187ba0e52b9cd1b9402b17422ba57a28ecbc8a6501f8a03d84cf9"; | |
| 290 | 301 | const SWITCH_ROUND_SECONDS: readonly string[] = [ |
| 291 | 302 | "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5", |
| 292 | 303 | "0fab6fec7329", "46cbc192435f", "db863794da65", "2ee4690ed7ab", |
| 293 | 304 | "9324fbbb0613", "29367a5dec24", "4b1fe24b0cf3", "008c0bc37d56", |
| 294 | "f3a2be3d29aa", "e81f689bb628", "20967b3ec5ef", "164e83be3f18", | |
| 295 | "70db83d7c9d8", "5abbcc14f978", "84afb2c11f16", "8202a14b68d1", | |
| 305 | "09c45f47ec3a", "26c53255e413", "79108feeb42d", "09b5d7abf449", | |
| 306 | "22e6eac231b0", "18ce207a164e", "5ffc3f07d274", "ff2e16356824", | |
| 296 | 307 | ]; |
| 297 | 308 | |
| 298 | 309 | const SWITCH_PIN: Pin = { |
| @@ -422,13 +433,13 @@ const OPEN_MAP = "f5ec0f03c150cdbc92359df63271653c6a2be8c0f485bdf86d028f636eacd9 | ||
| 422 | 433 | const OPEN_MAP_BANDS: readonly string[] = [ |
| 423 | 434 | "f6fa28441414", "aab631e1a5fa", "ebc69b760b9d", "db19d9a43600", |
| 424 | 435 | ]; |
| 425 | const OPEN_ROUND = "0e66a5e70cb3a08e2223ae783e71eadcd2921ab72c24ade58ad273f46949c792"; | |
| 436 | const OPEN_ROUND = "d4f7921067b03297258fb54c6d51f985f986c19f58187df617dcf5a16faa9b53"; | |
| 426 | 437 | const OPEN_ROUND_SECONDS: readonly string[] = [ |
| 427 | 438 | "6d0fbacbd279", "1f2bb687d697", "1e9a0750ec3a", "31e71073342a", |
| 428 | 439 | "eb30f1715453", "95ae3acb2964", "818975909e82", "f1ee5d766f18", |
| 429 | "bb0c65852bf7", "7b9793596110", "29feef4fb8f3", "137e09c5c485", | |
| 430 | "e82aa824dc98", "495ca8c2ef53", "c2b9abc2c229", "ef673bdf0f71", | |
| 431 | "271dff4a23ae", "a7b9a6f6d084", "0437a724cd8e", "ab60865f4146", | |
| 440 | "bb0c65852bf7", "7b9793596110", "29feef4fb8f3", "df0673f1e1da", | |
| 441 | "11e8170769f1", "c21d2250a701", "05dc7925a143", "0cd973d5d526", | |
| 442 | "ffc17133454d", "7e475f96a1a2", "6019a7c2baf5", "7e6adc3b75ea", | |
| 432 | 443 | ]; |
| 433 | 444 | |
| 434 | 445 | const TIGHT_MAP = "4bd818fd2e654f16fb41b6bd718951acbb22ac24572162a2be348e7edfcccc15"; |
new above
offline