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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/357-bots-should-take-cover-when-reloading-bo → main 1 commit fork

  • Merged. vesper/357-bots-should-take-cover-when-reloading-bo was merged into main as b56e926.

Review

Sign in to review this change.
5 files changed +325 −23
README.md +14 −1
@@ -887,7 +887,7 @@ never checked itself.
887887 `world` (the simulation, and the two seconds of what just happened it keeps in
888888 the engine's ring: `EFFECT_CAPACITY` events at most, each stamped, pruned at
889889 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`,
891891 `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round
892892 clock, the kill feed, the scoreboard and the red arc and flash that say you
893893 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,
14391439 defusing, listening for shots through walls. The fight is a reflex, and that is
14401440 now a policy learned with PPO.
14411441
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
14421455 - **What it sees** — 33 numbers, all egocentric (`src/client/observe.ts`): the
14431456 sine and cosine of its aim error, the range, the enemy's health and whether it
14441457 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";
5050 import { buy } from "./economy.ts";
5151 import type { Input, Player, World } from "./world.ts";
5252 import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts";
53 import { coverFrom } from "./cover.ts";
5354
5455 /**
5556 * The policy every bot in this process thinks with, put here once by whoever
@@ -140,6 +141,15 @@ export type BotBrain = {
140141 jitterUntil: number;
141142 /** False runs the pre-policy fight: the arena's opponent and its baseline. */
142143 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;
143153 };
144154
145155 export function makeBrain(skill: number, seed: number, useLearned = true): BotBrain {
@@ -170,6 +180,10 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
170180 jitterApplied: 0,
171181 jitterUntil: 0,
172182 useLearned,
183 cover: null,
184 coverStep: 0,
185 coverFor: -1,
186 coverLook: null,
173187 };
174188 }
175189
@@ -647,6 +661,9 @@ export function botThink(world: World, bot: Player, dt: number): Input {
647661 brain.intent = { ...STILL };
648662 // A fresh round hands the body a fresh view; the pull is not owed back.
649663 brain.pull = [0, 0];
664 brain.cover = null;
665 brain.coverFor = -1;
666 brain.coverLook = null;
650667 input.switchTo = bot.primary ?? bot.secondary;
651668 }
652669 const job = jobOf(world, bot, brain);
@@ -680,6 +697,13 @@ export function botThink(world: World, bot: Player, dt: number): Input {
680697 brain.targetId = null;
681698 }
682699
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
683707 // --- fighting, which is the policy's job
684708 if (target !== null) {
685709 // 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 {
753777 return withBomb(world, bot, input);
754778 }
755779
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
756827 /** Planting and defusing: press use when standing where it matters. */
757828 function withBomb(world: World, bot: Player, input: Input): Input {
758829 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;
5454 const TICKS = SECONDS * TICKS_PER_SECOND;
5555
5656 /**
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,
5869 * which put a smoke grenade in the game, and for the reason #164 and #142 were
5970 * re-pinned before it: what moved is the *hash*, not the play. A pocket is
6071 * 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;
106117 * anything: the bots buy no grenades and the scripted person never presses
107118 * either key, so no flash goes off and no `blind` is ever non-zero here.
108119 */
109 const ROUND = "2baf12fbafbff25007a9147511d1307ee9f4d5cf7d0b2473d7dae4fea876490e";
120 const ROUND = "234cf0035b71e3398516909bb9f02c63574799075c4f053fb275a41fce763d7b";
110121 /*
111122 * Not re-pinned for #143, which gave every weapon a deploy time, and the reason
112123 * is worth writing down rather than leaving to look like an oversight. Two
@@ -121,13 +132,13 @@ const ROUND_SECONDS = [
121132 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
122133 "0fab6fec7329", "46cbc192435f", "db863794da65", "0e5d2d2d2685",
123134 "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",
131142 ];
132143
133144 /**
@@ -177,16 +188,16 @@ const DUEL_SECONDS = [
177188 */
178189 const SMOKE_SECONDS_LONG = 30;
179190 const SMOKE_TICKS = SMOKE_SECONDS_LONG * TICKS_PER_SECOND;
180 const SMOKE_ROUND = "6484de2cd2ba8f6b9beba2620c1432f9701b64eea95b0710674cc9e2cf59b841";
191 const SMOKE_ROUND = "21a08801224e0e51c291bbe73f0d07aadc25eb0eed62603e0135725b107e6e74";
181192 const SMOKE_ROUND_SECONDS: readonly string[] = [
182193 "c858433691b7", "ae8816aaf901", "28196227d006", "5dd7f1376bf8",
183194 "764ca02fa9f0", "5ffb3df0e281", "1bd6a3521cb1", "59fde5add12d",
184195 "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",
190201 ];
191202
192203 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", (
286297 */
287298 const SWITCH_SECONDS_LONG = 20;
288299 const SWITCH_TICKS = SWITCH_SECONDS_LONG * TICKS_PER_SECOND;
289 const SWITCH_ROUND = "899614cb381d4d54c0856641963f1aaa6c9d8abac2c3a8d52b2f8aaef05c016e";
300 const SWITCH_ROUND = "55acf4dfd45187ba0e52b9cd1b9402b17422ba57a28ecbc8a6501f8a03d84cf9";
290301 const SWITCH_ROUND_SECONDS: readonly string[] = [
291302 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
292303 "0fab6fec7329", "46cbc192435f", "db863794da65", "2ee4690ed7ab",
293304 "9324fbbb0613", "29367a5dec24", "4b1fe24b0cf3", "008c0bc37d56",
294 "f3a2be3d29aa", "e81f689bb628", "20967b3ec5ef", "164e83be3f18",
295 "70db83d7c9d8", "5abbcc14f978", "84afb2c11f16", "8202a14b68d1",
305 "09c45f47ec3a", "26c53255e413", "79108feeb42d", "09b5d7abf449",
306 "22e6eac231b0", "18ce207a164e", "5ffc3f07d274", "ff2e16356824",
296307 ];
297308
298309 const SWITCH_PIN: Pin = {
@@ -422,13 +433,13 @@ const OPEN_MAP = "f5ec0f03c150cdbc92359df63271653c6a2be8c0f485bdf86d028f636eacd9
422433 const OPEN_MAP_BANDS: readonly string[] = [
423434 "f6fa28441414", "aab631e1a5fa", "ebc69b760b9d", "db19d9a43600",
424435 ];
425 const OPEN_ROUND = "0e66a5e70cb3a08e2223ae783e71eadcd2921ab72c24ade58ad273f46949c792";
436 const OPEN_ROUND = "d4f7921067b03297258fb54c6d51f985f986c19f58187df617dcf5a16faa9b53";
426437 const OPEN_ROUND_SECONDS: readonly string[] = [
427438 "6d0fbacbd279", "1f2bb687d697", "1e9a0750ec3a", "31e71073342a",
428439 "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",
432443 ];
433444
434445 const TIGHT_MAP = "4bd818fd2e654f16fb41b6bd718951acbb22ac24572162a2be348e7edfcccc15";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs