Public: anyone can read this; the crew can change it.

forked from trevato/cs

Take updates from trevato/cs #375

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/372-take-updates-from-trevato-cs → main 2 commits fork

  • Merged. vesper/372-take-updates-from-trevato-cs was merged into main as 3d80733.

Review

Sign in to review this change.
355 files changed +60631 −22801
.gitignore +2
@@ -1,4 +1,6 @@
11 node_modules/
2 dist/
3 static/
24 *.db
35 *.db-wal
46 *.db-shm
AGENTS.md +3 −4
@@ -9,9 +9,8 @@ preview at its own address, deployed from your branch as you push.
99 - Work on the branch you were given. You cannot move the default branch;
1010 a person merges your pull once it is reviewed.
1111 - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime.
12 - Run `bun run check` before you push — types, then tests. It is the check
13 the pull shows, and every behaviour you add gets a test that would fail
14 without it. `bun test` alone is the fast loop while you work.
12 - Run `bun test` before you push. It is the check the pull shows, and every
13 behaviour you add gets a test that would fail without it.
1514 - `src/tty.ts` is the app's terminal interface. The exact command that
1615 reaches it is `FORGE_APP_SSH` at runtime; print that verbatim wherever the
1716 app tells people how to connect. Keep it working and keep it useful: what
@@ -34,7 +33,7 @@ preview at its own address, deployed from your branch as you push.
3433 come from the environment, never the repository; no `eval`, no shell
3534 built from user text; a private thing needs a session in an HttpOnly cookie.
3635 Every request body is capped by the bytes actually read (`readBody` in
37 `src/server.ts`) — never by a Content-Length, which a chunked request does
36 `src/gate.ts`) — never by a Content-Length, which a chunked request does
3837 not have and a lie does not honour. `HEAD` answers like `GET`.
3938 An app with an owner or admin is never claimed by whoever visits first:
4039 the first sign-in needs a secret only the operator holds — a password from
README.md +1634 −878

Diff not shown — 179,911 bytes of diff (limit 100,000). +1634 −878

View file
bun.lock +6 −1
@@ -3,7 +3,10 @@
33 "configVersion": 1,
44 "workspaces": {
55 "": {
6 "name": "cs",
6 "name": "cs-engine",
7 "dependencies": {
8 "@typesafe-ai/sdk": "^0.6.0",
9 },
710 "devDependencies": {
811 "@types/bun": "^1.4.2",
912 "typescript": "^7.0.2",
@@ -15,6 +18,8 @@
1518
1619 "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
1720
21 "@typesafe-ai/sdk": ["@typesafe-ai/[email protected]", "", {}, "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw=="],
22
1823 "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
1924
2025 "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
forge.json +1 −1
@@ -1,7 +1,7 @@
11 {
22 "app": {
33 "run": "bun run src/server.ts",
4 "test": "bun run check",
4 "test": "bun run test",
55 "tty": "bun run src/tty.ts"
66 }
77 }
package.json +5 −3
@@ -1,16 +1,18 @@
11 {
2 "name": "cs",
2 "name": "cs-engine",
33 "private": true,
44 "type": "module",
55 "scripts": {
66 "start": "bun run src/server.ts",
77 "build": "bun run scripts/build.ts",
8 "test": "bun test --timeout 20000",
98 "typecheck": "tsc --noEmit",
10 "check": "tsc --noEmit && bun test --timeout 20000"
9 "test": "bun run typecheck && bun test"
1110 },
1211 "devDependencies": {
1312 "@types/bun": "^1.4.2",
1413 "typescript": "^7.0.2"
14 },
15 "dependencies": {
16 "@typesafe-ai/sdk": "^0.6.0"
1517 }
1618 }
scripts/args.ts deleted −5
@@ -1,5 +0,0 @@
1 /** `--name value` off argv, or null if the flag is missing or has nothing after it. */
2 export const flagValue = (argv: string[], name: string): string | null => {
3 const at = argv.indexOf(`--${name}`);
4 return at < 0 ? null : (argv[at + 1] ?? null);
5 };
scripts/build.test.ts added +430
@@ -0,0 +1,430 @@
1 import { expect, test } from "bun:test";
2 import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import {
6 ASSETS,
7 assetOf,
8 bundle,
9 collect,
10 differences,
11 ENTRIES,
12 isNamed,
13 GZIP_LEVEL,
14 isBundleOf,
15 NOTE,
16 overshoot,
17 row,
18 sizes,
19 table,
20 weigh,
21 write,
22 type Built,
23 type Ceiling,
24 type Entry,
25 } from "./build.ts";
26 import { GAMES } from "../src/games.ts";
27
28 /** What a built file looks like whatever it is: a plain name, its content hash, an extension. */
29 const STAMPED = /^[a-z][a-z0-9-]*\.[0-9a-f]{10}\.[a-z0-9]+$/;
30
31 /** Room enough that the fixture's two-line bundles are never the thing failing. */
32 const ROOMY = { raw: 100_000, gzip: 100_000 };
33
34 /** A throwaway pair of entries: two sources and a directory to write them to. */
35 function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: string) => void } {
36 const dir = mkdtempSync(join(tmpdir(), "build-"));
37 const source = (name: string, text: string): void => writeFileSync(join(dir, `${name}.ts`), text);
38 source("app", `export const game = "app";\nconsole.log(game);\n`);
39 source("sandbox", `export const game = "sandbox";\nconsole.log(game);\n`);
40 return {
41 dir,
42 entries: [
43 { name: "app", entry: join(dir, "app.ts"), ceiling: ROOMY },
44 { name: "sandbox", entry: join(dir, "sandbox.ts"), ceiling: ROOMY },
45 ],
46 source,
47 };
48 }
49
50 test("the entry table is what the app ships today, each with the size it may not pass", () => {
51 expect(ENTRIES).toEqual([
52 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 132_000, gzip: 51_500 } },
53 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 44_000, gzip: 18_200 } },
54 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 26_800, gzip: 11_400 } },
55 ]);
56 // And it is the registry projected, not a second list beside it: a game's
57 // source and its ceiling are written down once, in `src/games.ts`.
58 expect(ENTRIES).toEqual(GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling })));
59 // Nothing else of a game comes along: this file has no idea what path any of
60 // them answers on.
61 for (const entry of ENTRIES) expect(Object.keys(entry).sort()).toEqual(["ceiling", "entry", "name"]);
62 });
63
64 test("every entry is bundled to its own name and its own content hash", async () => {
65 const { dir, entries } = fixture();
66 const built = await write(entries, dir, []);
67 expect(built.map((b) => b.name)).toEqual(["app", "sandbox"]);
68 for (const { name, file, code } of built) {
69 expect(file).toMatch(new RegExp(`^${name}\\.[0-9a-f]{10}\\.js$`));
70 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
71 expect(file).toBe(`${name}.${hash}.js`);
72 expect(existsSync(join(dir, file))).toBe(true);
73 }
74 expect(built[0]?.file).not.toBe(built[1]?.file);
75 });
76
77 test("rebuilding one entry sweeps up its own stale bundle and leaves the other game alone", async () => {
78 const { dir, entries, source } = fixture();
79 const first = await write(entries, dir, []);
80 const [staleApp, sandbox] = first;
81 writeFileSync(join(dir, "keep.txt"), "not a bundle");
82
83 source("app", `export const game = "app";\nconsole.log(game, "again");\n`);
84 const second = await write(entries, dir, []);
85 const [freshApp, sandboxAgain] = second;
86
87 expect(freshApp?.file).not.toBe(staleApp?.file);
88 expect(existsSync(join(dir, staleApp?.file as string))).toBe(false);
89 expect(existsSync(join(dir, freshApp?.file as string))).toBe(true);
90 // The trap: the other game's bundle did not change and must still be there.
91 expect(sandboxAgain?.file).toBe(sandbox?.file as string);
92 expect(existsSync(join(dir, sandbox?.file as string))).toBe(true);
93 expect(readdirSync(dir)).toContain("keep.txt");
94 });
95
96 test("a bundle belongs to one entry by exact name", () => {
97 expect(isBundleOf("app", "app.6864d577a4.js")).toBe(true);
98 expect(isBundleOf("app", "sandbox.6864d577a4.js")).toBe(false);
99 expect(isBundleOf("sandbox", "app.6864d577a4.js")).toBe(false);
100 expect(isBundleOf("app", "app.js")).toBe(false);
101 expect(isBundleOf("app", "app.6864d577a4.js.map")).toBe(false);
102 expect(isBundleOf("app", "appx6864d577a4.js")).toBe(false);
103 // A name that is not a plain name never becomes a pattern.
104 expect(isBundleOf("a.p", "axp.6864d577a4.js")).toBe(false);
105 expect(isBundleOf(".*", "app.6864d577a4.js")).toBe(false);
106 });
107
108 test("two entries cannot share a name, and a name is plain", async () => {
109 const { dir } = fixture();
110 const entry = join(dir, "app.ts");
111 const of = (name: string): Entry => ({ name, entry, ceiling: ROOMY });
112 expect(bundle([of("app"), of("app")])).rejects.toThrow(/share a name/);
113 expect(bundle([of("../app")])).rejects.toThrow(/plain name/);
114 });
115
116 test("a size is bytes on disk and bytes gzipped at one fixed level", () => {
117 const code = `console.log(${JSON.stringify("é".repeat(1000))});\n`;
118 const bytes = new TextEncoder().encode(code);
119 // Raw counts bytes, not characters: "é" is two of them.
120 expect(sizes(code).raw).toBe(bytes.byteLength);
121 expect(sizes(code).raw).toBeGreaterThan(code.length);
122 expect(sizes(code)).toEqual(sizes(bytes));
123 // The level is pinned, so the number is the same on every machine that runs
124 // this. A different level is a different number, which is why it is pinned.
125 expect(GZIP_LEVEL).toBe(9);
126 expect(sizes(code).gzip).toBe(Bun.gzipSync(bytes, { level: GZIP_LEVEL }).byteLength);
127 expect(sizes(code).gzip).not.toBe(Bun.gzipSync(bytes, { level: 1 }).byteLength);
128 expect(sizes(code)).toEqual(sizes(code));
129 });
130
131 test("a bundle that fits says nothing, and one that does not names the overshoot", () => {
132 const ceiling = { raw: 1000, gzip: 500 };
133 expect(overshoot("app", { raw: 1000, gzip: 500 }, ceiling)).toBeNull();
134 expect(overshoot("app", { raw: 999, gzip: 499 }, ceiling)).toBeNull();
135
136 const raw = overshoot("app", { raw: 1100, gzip: 400 }, ceiling) as string;
137 expect(raw).toContain("app is too big");
138 expect(raw).toContain("raw 1100 bytes over the 1000 byte ceiling by 100 bytes (10.0%)");
139 expect(raw).not.toContain("gzip");
140
141 const gzip = overshoot("range", { raw: 900, gzip: 750 }, ceiling) as string;
142 expect(gzip).toContain("range is too big");
143 expect(gzip).toContain("gzip 750 bytes over the 500 byte ceiling by 250 bytes (50.0%)");
144 expect(gzip).not.toContain("raw");
145
146 // Both at once is one sentence, not two failures to chase.
147 const both = overshoot("app", { raw: 1001, gzip: 501 }, ceiling) as string;
148 expect(both).toContain("raw 1001 bytes over");
149 expect(both).toContain("gzip 501 bytes over");
150 });
151
152 test("a build in another process is the same names and the same bytes as this one", async () => {
153 // Nothing is committed any more, so this is what makes a deploy reproducible:
154 // every process that boots this tree serves files under the same names, and a
155 // browser's year-long cache of `app.<hash>.js` stays correct across deploys
156 // that changed nothing. Two builds in one process would share whatever Bun
157 // caches between them, so the second one is a child process with its own
158 // start-up — the same thing a deploy does — and the whole `code` is compared,
159 // not just the hash of it. If Bun's output ever stops being byte-stable this
160 // fails here, with the file and the two sizes in hand, rather than in a
161 // preview that serves a name nobody else builds.
162 const dir = mkdtempSync(join(tmpdir(), "elsewhere-"));
163 try {
164 const run = Bun.spawnSync(["bun", "run", join(import.meta.dir, "build.ts"), dir], {
165 stdout: "pipe",
166 stderr: "pipe",
167 });
168 expect(run.stderr.toString().trim()).toBe("");
169 expect(run.exitCode).toBe(0);
170
171 const built = [...(await bundle()), ...collect()];
172 // Every difference this build has from that one, as sentences: none.
173 expect(differences(built, dir)).toEqual([]);
174 // And that directory holds one file per game and per asset and nothing
175 // else — a second shooter bundle beside the first is the outage this is
176 // about, and it would be both an extra name here and a sentence above.
177 expect(readdirSync(dir).sort()).toEqual(built.map((b) => b.file).sort());
178 expect(built.map((b) => b.name)).toEqual([...ENTRIES.map((e) => e.name), ...ASSETS.map((a) => a.name)]);
179 } finally {
180 rmSync(dir, { recursive: true, force: true });
181 }
182 }, 60_000);
183
184 test("a directory that is not what the sources build says which file, and what to run", async () => {
185 const { dir, entries } = fixture();
186 try {
187 const built = await write(entries, dir, []);
188 const [app, sandbox] = built as [Built, Built];
189 // What a build just wrote is what the build builds: nothing to say. The
190 // fixture's own `.ts` sources sit in this directory too, and are not the
191 // build's business — neither is the database beside the bundles in
192 // FORGE_APP_DATA.
193 expect(differences(built, dir)).toEqual([]);
194 writeFileSync(join(dir, "keep.txt"), "not a bundle");
195 expect(differences(built, dir)).toEqual([]);
196
197 // The loud half of the outage: two bundles of one game, and nothing says
198 // which is the tree.
199 writeFileSync(join(dir, "app.0123456789.js"), app.code);
200 const two = differences(built, dir);
201 expect(two.length).toBe(1);
202 expect(two[0]).toContain("1 stale file");
203 expect(two[0]).toContain("app.0123456789.js");
204 expect(two[0]).toContain(`beside ${app.file}`);
205 expect(two[0]).toContain("bun run scripts/build.ts");
206
207 // The quiet half, which used to pass without a word: one valid-looking
208 // file, and it is not this tree's.
209 rmSync(join(dir, app.file));
210 const stale = differences(built, dir);
211 expect(stale.length).toBe(1);
212 expect(stale[0]).toContain(`has no ${app.file}`);
213 expect(stale[0]).toContain("app.0123456789.js");
214 expect(stale[0]).toContain("bun run scripts/build.ts");
215
216 // Nothing at all under that name says so in its own words.
217 rmSync(join(dir, "app.0123456789.js"));
218 expect(differences(built, dir)[0]).toContain("nothing there is named app.<hash>.js");
219 expect(differences(built, dir).length).toBe(1);
220
221 // And a file edited in place keeps its name and is caught on its bytes,
222 // which is the one thing a content hash cannot notice by itself.
223 writeFileSync(join(dir, sandbox.file), `${sandbox.code}// and more\n`);
224 const edited = differences([sandbox], dir);
225 expect(edited.length).toBe(1);
226 expect(edited[0]).toContain(`${join(dir, sandbox.file)} is not what this tree builds`);
227 expect(edited[0]).toContain(`${sizes(`${sandbox.code}// and more\n`).raw} bytes there`);
228 expect(edited[0]).toContain(`${sizes(sandbox.code).raw} bytes built`);
229 } finally {
230 rmSync(dir, { recursive: true, force: true });
231 }
232 });
233
234 test("no generated file is in git: the repository tracks nothing named by its own hash", () => {
235 const root = join(import.meta.dir, "..");
236 // A source export with no `.git` beside it cannot be asked; a checkout can,
237 // and that is where this class of outage was born, so it is asked there.
238 if (!existsSync(join(root, ".git"))) return;
239 const ls = Bun.spawnSync(["git", "ls-files"], { cwd: root, stdout: "pipe", stderr: "pipe" });
240 expect(ls.exitCode).toBe(0);
241 const said = ls.stdout
242 .toString()
243 .split("\n")
244 .filter((path) => path !== "")
245 .filter((path) => path.startsWith("static/") || STAMPED.test(path.split("/").pop() as string))
246 .map(
247 (path) =>
248 `${path} is a generated file and it is committed: run \`git rm --cached ${path}\` — ` +
249 `the bundles are built at boot into FORGE_APP_DATA/static, and an artifact in no branch cannot collide in a merge`,
250 );
251 expect(said).toEqual([]);
252 // The reason nobody adds one by accident: a build into the repository's own
253 // static/ is ignored where it lands.
254 expect(readFileSync(join(root, ".gitignore"), "utf8").split("\n")).toContain("static/");
255 });
256
257 test("a fresh build of every entry is under its ceiling", async () => {
258 // The sources as they are now: a fat import fails here on the first build,
259 // before anyone has deployed the bundle it produced.
260 for (const { name, code, ceiling } of await bundle()) {
261 expect(overshoot(`${name} (fresh build)`, sizes(code), ceiling)).toBeNull();
262 }
263 }, 60_000);
264
265 test("the bundle every entry is written as is under its ceiling, weighed on disk", async () => {
266 // The bytes a browser downloads: the file, not the string the bundler handed
267 // back. Nothing is committed any more, so this writes the build to a
268 // directory of its own — exactly as a boot writes it to FORGE_APP_DATA — and
269 // weighs what landed there.
270 const dir = mkdtempSync(join(tmpdir(), "ceilings-"));
271 try {
272 await write(ENTRIES, dir, []);
273 for (const { name, ceiling } of ENTRIES) {
274 const files = readdirSync(dir).filter((file) => isBundleOf(name, file));
275 expect(files.length).toBe(1);
276 const bytes = readFileSync(join(dir, files[0] as string));
277 expect(overshoot(`${files[0]} (on disk)`, sizes(bytes), ceiling)).toBeNull();
278 }
279 } finally {
280 rmSync(dir, { recursive: true, force: true });
281 }
282 }, 60_000);
283
284 test("weighing a committed bundle reads the file and builds nothing", async () => {
285 const { dir, entries } = fixture();
286 const [built] = await write(entries, dir, []);
287 const file = readFileSync(join(dir, built?.file as string));
288 // The same ruler `sizes` is: on disk, and gzipped at the pinned level.
289 expect(weigh("app", dir)).toEqual(sizes(file));
290 expect(weigh("app", dir).raw).toBe(file.byteLength);
291 // And it is the artifact, not a fresh build: a file edited in place weighs
292 // what it now is, which is the point of quoting it on a page.
293 writeFileSync(join(dir, built?.file as string), `${built?.code as string}// and more\n`);
294 expect(weigh("app", dir).raw).toBeGreaterThan(file.byteLength);
295 // A game with nothing in static/ is the sentence `bundleOf` writes, not a zero.
296 expect(() => weigh("nowhere", dir)).toThrow(/has no bundle/);
297 });
298
299 test("the asset table is what the app downloads beside its bundles", () => {
300 // The shooter's weights, and nothing else: the registry projected again.
301 expect(ASSETS).toEqual([
302 { name: "weights", source: "src/client/weights.json", ceiling: { raw: 12_300, gzip: 9_100 } },
303 ]);
304 expect(ASSETS).toEqual(GAMES.flatMap((game) => game.assets.map((asset) => ({ ...asset }))));
305 });
306
307 test("an asset is copied verbatim, stamped by its own contents, and swept when it changes", () => {
308 const dir = mkdtempSync(join(tmpdir(), "assets-"));
309 writeFileSync(join(dir, "w.json"), `{"a":1}`);
310 const asset = { name: "weights", source: join(dir, "w.json"), ceiling: ROOMY };
311 const [first] = collect([asset], dir);
312 // The bytes in static/ are the bytes the file holds: an asset is a copy.
313 expect(first?.code).toBe(`{"a":1}`);
314 expect(first?.file).toMatch(/^weights\.[0-9a-f]{10}\.json$/);
315 expect(isNamed("weights", "json", first?.file as string)).toBe(true);
316 expect(isBundleOf("weights", first?.file as string)).toBe(false);
317 // The hash is the contents', so a retrain is a new name and a sweep.
318 writeFileSync(join(dir, "w.json"), `{"a":2}`);
319 const [second] = collect([asset], dir);
320 expect(second?.file).not.toBe(first?.file);
321 // And the finder reads one back by name, or says what to run.
322 expect(assetOf(asset, [second?.file as string], dir)).toBe(second?.file as string);
323 expect(() => assetOf(asset, [], dir)).toThrow(/has no asset.*run `bun run scripts\/build\.ts`/);
324 expect(() => assetOf(asset, [first?.file as string, second?.file as string], dir)).toThrow(/has 2 assets/);
325 });
326
327 test("the asset every game declares is under its ceiling, and is what its source holds", async () => {
328 const dir = mkdtempSync(join(tmpdir(), "assets-ceilings-"));
329 try {
330 await write([], dir);
331 const files = readdirSync(dir);
332 for (const asset of ASSETS) {
333 const file = assetOf(asset, files, dir);
334 const bytes = readFileSync(join(dir, file));
335 expect(overshoot(`${file} (on disk)`, sizes(bytes), asset.ceiling)).toBeNull();
336 // Byte for byte the file the trainer wrote: a copy that is not the source
337 // is a policy nobody trained, served to every player.
338 expect(bytes.toString("utf8")).toBe(collect([asset])[0]?.code as string);
339 }
340 } finally {
341 rmSync(dir, { recursive: true, force: true });
342 }
343 });
344
345 /** A built file of known contents, for the table below: sizes nobody has to guess at. */
346 const builtFile = (name: string, code: string, ceiling: Ceiling): Built => ({
347 name,
348 ext: "js",
349 file: `${name}.6864d577a4.js`,
350 code,
351 ceiling,
352 });
353
354 test("a row is one file weighed once: what it measured, what it may not pass, what is left", () => {
355 const code = `console.log("hi");\n`;
356 const { raw, gzip } = sizes(code);
357 const measured = row(builtFile("app", code, { raw: 400, gzip: 300 }));
358 expect(measured.measured).toEqual({ raw, gzip });
359 expect(measured.headroom).toEqual({ raw: 400 - raw, gzip: 300 - gzip });
360 // Over the ceiling the headroom is negative, in the same column a reader is
361 // already scanning — not a row that quietly looks like the others.
362 const over = row(builtFile("app", code, { raw: 4, gzip: 3 }));
363 expect(over.headroom).toEqual({ raw: 4 - raw, gzip: 3 - gzip });
364 expect(over.headroom.raw).toBeLessThan(0);
365 });
366
367 test("the build prints a table of what it built: the sizes and the headroom under each ceiling", () => {
368 const code = `console.log("hi");\n`;
369 const long = `console.log(${JSON.stringify("x".repeat(500))});\n`;
370 const { raw, gzip } = sizes(code);
371 const big = sizes(long);
372 const printed = table([
373 builtFile("app", code, { raw: 400, gzip: 300 }),
374 builtFile("range", long, { raw: 900, gzip: 400 }),
375 ]);
376
377 const [head, rule, app, range] = printed as [string, string, string, string];
378 expect(head.split(/\s+/)).toEqual(["file", "raw", "ceiling", "left", `gzip-${GZIP_LEVEL}`, "ceiling", "left"]);
379 expect(rule).toMatch(/^-+( +-+)+$/);
380 // Each row is its file and the six numbers, in the order the heading names
381 // them: measured, ceiling and what is left, on both rulers.
382 expect(app.split(/\s+/)).toEqual([
383 "static/app.6864d577a4.js",
384 String(raw),
385 "400",
386 String(400 - raw),
387 String(gzip),
388 "300",
389 String(300 - gzip),
390 ]);
391 expect(range.split(/\s+/)).toEqual([
392 "static/range.6864d577a4.js",
393 String(big.raw),
394 "900",
395 String(900 - big.raw),
396 String(big.gzip),
397 "400",
398 String(400 - big.gzip),
399 ]);
400 // The columns line up, so two rows can be compared by eye.
401 expect(rule.length).toBe(head.length);
402 expect(app.length).toBe(rule.length);
403 expect(range.length).toBe(rule.length);
404 // And the directory it wrote into is the one named in the rows.
405 expect(table([builtFile("app", code, { raw: 400, gzip: 300 })], "/tmp/out")[2]).toContain(
406 "/tmp/out/app.6864d577a4.js",
407 );
408 // The note under it carries the units and the ruler, so a pasted table means
409 // something away from this file.
410 expect(NOTE).toContain("Bytes");
411 expect(NOTE).toContain(`gzip-${GZIP_LEVEL}`);
412 expect(NOTE).toContain("src/games.ts");
413 });
414
415 test("a file over its ceiling prints a negative headroom, and is still the failure it is", async () => {
416 const { dir, entries } = fixture();
417 const tight = entries.map((entry) => ({ ...entry, ceiling: { raw: 10, gzip: 10 } }));
418 const built = await bundle(tight);
419 const printed = table(built);
420 expect(printed[0]).toContain("left");
421 const app = (printed[2] as string).split(/\s+/);
422 expect(Number(app[3])).toBeLessThan(0);
423 expect(Number(app[6])).toBeLessThan(0);
424 // Printing it is not forgiving it: the same numbers are what the ceiling
425 // tests above fail on, with the sentence naming the overshoot.
426 for (const { name, code, ceiling } of built) {
427 expect(overshoot(name, sizes(code), ceiling)).toContain("is too big");
428 }
429 rmSync(dir, { recursive: true, force: true });
430 });
scripts/build.ts +344 −17
@@ -1,26 +1,353 @@
11 /**
2 * `bun run build` writes the client bundle to static/, for anyone who wants
3 * a file on disk to inspect. The server does not read it: it builds its own
4 * copy into memory at start-up (see src/bundle.ts), so this file is never
5 * committed and never served.
2 * The build: every game in the registry, bundled to `<name>.<hash>.js` in a
3 * directory, and every asset a game declares beside it, copied to
4 * `<name>.<hash>.<ext>` the same way.
5 *
6 * Nothing it writes is committed. `src/server.ts` calls `write` at start-up,
7 * into `FORGE_APP_DATA/static` (`src/bundles.ts` says where), and serves what
8 * came out; `bun run scripts/build.ts` runs the same build into the repository's
9 * own `static/` — ignored by git — for a person who wants the files on disk to
10 * read, weigh or serve another way. Each entry carries the size it may not pass,
11 * and `build.test.ts` builds and weighs it.
12 *
13 * Run as a program it prints a table of what it built — every file, raw and
14 * gzipped, against its ceiling and the headroom left under it — so the sizes a
15 * pull states are copied from a run rather than remembered.
16 *
17 * A bundle and an asset are the same thing here — bytes filed under a name and
18 * their own content hash, swept when they change and held to a ceiling — and
19 * they differ only in where the bytes come from: a bundler, or a file. That is
20 * why `write` returns one list of both.
621 */
722
8 import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9 import { join } from "node:path";
10 import { bundle } from "../src/bundle.ts";
23 import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24 import { extname, join, resolve } from "node:path";
25 import { type Asset, type Ceiling, GAMES } from "../src/games.ts";
1126
12 export const STATIC_DIR = join(import.meta.dir, "..", "static");
27 const ROOT = join(import.meta.dir, "..");
28 /**
29 * Where `bun run scripts/build.ts` writes when nobody says otherwise: the
30 * repository's own `static/`, which git ignores. The server never uses it — it
31 * passes the directory it wants, under `FORGE_APP_DATA` — and every function
32 * here that takes a `dir` or a `where` takes it for the same reason.
33 */
34 export const STATIC_DIR = join(ROOT, "static");
1335
14 export async function write(): Promise<string> {
15 const { name, code } = await bundle();
16 mkdirSync(STATIC_DIR, { recursive: true });
17 for (const file of readdirSync(STATIC_DIR))
18 if (/^app\.[0-9a-f]+\.js$/.test(file) && file !== name) rmSync(join(STATIC_DIR, file));
19 writeFileSync(join(STATIC_DIR, name), code);
20 return name;
36 export type { Asset, Ceiling };
37
38 /**
39 * One bundle: the name it is filed under, the source it starts from, and the
40 * size it may not pass. It is the part of a game this file cares about —
41 * nothing here knows what path a game answers on or what the header calls it.
42 */
43 export type Entry = { name: string; entry: string; ceiling: Ceiling };
44
45 /**
46 * What is built, projected from the registry in `src/games.ts`: a game is
47 * bundled because it is a game, not because it is also written down here. The
48 * ceilings live beside the entries there, with the rule for moving one.
49 */
50 export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling }));
51
52 /**
53 * And what is copied, projected the same way: every game's assets, flattened,
54 * because static/ is one directory and a name in it belongs to one file
55 * whichever game declared it.
56 */
57 export const ASSETS: Asset[] = GAMES.flatMap((game) => game.assets.map((asset) => ({ ...asset })));
58
59 /** The extension an asset is filed under, taken from the file it is copied from. */
60 export const extOf = (asset: Asset): string => extname(asset.source).replace(/^\./, "");
61
62 /**
63 * The one level gzip is ever measured at here. Compression level is the knob
64 * that moves the number between machines, so it is pinned: `bun test` on a
65 * laptop and on the forge weigh the same bundle the same way. It is not a claim
66 * about what a CDN sends — it is a stable ruler.
67 */
68 export const GZIP_LEVEL = 9;
69
70 /** What a bundle weighs: bytes on disk, and bytes after gzip at `GZIP_LEVEL`. */
71 // `Uint8Array<ArrayBuffer>` and not a plain `Uint8Array`: the gzip below will
72 // not read a view onto a `SharedArrayBuffer`, and saying so here is how a
73 // caller that holds one finds out at the call rather than at the call inside.
74 export function sizes(code: string | Uint8Array<ArrayBuffer>): Ceiling {
75 const bytes = typeof code === "string" ? new TextEncoder().encode(code) : code;
76 return { raw: bytes.byteLength, gzip: Bun.gzipSync(bytes, { level: GZIP_LEVEL }).byteLength };
2177 }
2278
79 /**
80 * A sentence naming the bundle, what it measured, what it may not pass and by
81 * how much it passed it — in bytes and in percent — or `null` when it fits.
82 */
83 export function overshoot(name: string, measured: Ceiling, ceiling: Ceiling): string | null {
84 const over = (["raw", "gzip"] as const)
85 .filter((kind) => measured[kind] > ceiling[kind])
86 .map((kind) => {
87 const by = measured[kind] - ceiling[kind];
88 const percent = ((by / ceiling[kind]) * 100).toFixed(1);
89 return `${kind} ${measured[kind]} bytes over the ${ceiling[kind]} byte ceiling by ${by} bytes (${percent}%)`;
90 });
91 return over.length === 0 ? null : `${name} is too big: ${over.join("; ")}`;
92 }
93
94 // A name becomes part of a file name and part of a regular expression: keep it
95 // plain, so neither can be surprised by it.
96 const NAME = /^[a-z][a-z0-9-]*$/;
97
98 /** Is this file filed under that name and extension — `app.3f2a9c1b04.js` of `app`? */
99 export function isNamed(name: string, ext: string, file: string): boolean {
100 return NAME.test(name) && NAME.test(ext) && new RegExp(`^${name}\\.[0-9a-f]+\\.${ext}$`).test(file);
101 }
102
103 /** Is this file a bundle of that entry — `app.3f2a9c1b04.js` of `app`? */
104 export const isBundleOf = (name: string, file: string): boolean => isNamed(name, "js", file);
105
106 /**
107 * The one `<name>.<hash>.<ext>` among a directory's `files`, or a sentence
108 * saying what to do about it: none means nothing built it, and two means a
109 * stale one was left behind and whoever picks between them picks at random.
110 * `what` is the word the message uses for it — a bundle, an asset — and `where`
111 * is the directory those names came from, read out in the message and nowhere
112 * else.
113 *
114 * It is here rather than beside either caller because both — the server, which
115 * refuses to boot without every game's files, and the tests, which boot the
116 * built ones — are asking the same question of the same directory, and one
117 * answer cannot drift from itself.
118 */
119 export function oneNamed(
120 name: string,
121 ext: string,
122 files: readonly string[],
123 where = STATIC_DIR,
124 what = "bundle",
125 ): string {
126 const mine = files.filter((file) => isNamed(name, ext, file));
127 const first = mine[0];
128 if (mine.length === 1 && first !== undefined) return first;
129 const build = "run `bun run scripts/build.ts` to write them there";
130 throw new Error(
131 mine.length === 0
132 ? `the game ${JSON.stringify(name)} has no ${what}: nothing in ${where} is named ${name}.<hash>.${ext} — ${build}`
133 : `the game ${JSON.stringify(name)} has ${mine.length} ${what}s in ${where}: ${mine.join(", ")} — ` +
134 `all but one are stale, so ${build}`,
135 );
136 }
137
138 /** The one `<name>.<hash>.js` in a directory, or the sentence saying what to do. */
139 export const bundleOf = (name: string, files: readonly string[], where = STATIC_DIR): string =>
140 oneNamed(name, "js", files, where);
141
142 /** The one `<name>.<hash>.<ext>` of a declared asset, or the same sentence. */
143 export const assetOf = (asset: Asset, files: readonly string[], where = STATIC_DIR): string =>
144 oneNamed(asset.name, extOf(asset), files, where, "asset");
145
146 /**
147 * What the built bundle of `name` weighs right now: the one
148 * `<name>.<hash>.js` in `dir`, on disk and gzipped at `GZIP_LEVEL`. It builds
149 * nothing itself — this is the file a browser downloads, already written,
150 * weighed by the same ruler the ceilings are held to, so a page that quotes a
151 * size and a test that checks a ceiling are reading one number.
152 *
153 * A missing or duplicated bundle throws here, through `bundleOf`, with the
154 * sentence that says what to run about it.
155 */
156 export function weigh(name: string, dir = STATIC_DIR): Ceiling {
157 return sizes(readFileSync(join(dir, bundleOf(name, readdirSync(dir), dir))));
158 }
159
160 export type Built = { name: string; ext: string; file: string; code: string; ceiling: Ceiling };
161
162 /** `app` and its contents into `app.3f2a9c1b04.js`: the one naming rule here. */
163 function stamp(name: string, ext: string, code: string, ceiling: Ceiling): Built {
164 if (!NAME.test(name)) throw new Error(`entry name ${JSON.stringify(name)} is not a plain name`);
165 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
166 return { name, ext, file: `${name}.${hash}.${ext}`, code, ceiling };
167 }
168
169 /** Bundle every entry and give each the file name its own contents deserve. */
170 export async function bundle(entries: Entry[] = ENTRIES): Promise<Built[]> {
171 if (new Set(entries.map((e) => e.name)).size !== entries.length)
172 throw new Error("two entries share a name; each bundle needs its own");
173 const built: Built[] = [];
174 for (const { name, entry, ceiling } of entries) {
175 if (!NAME.test(name)) throw new Error(`entry name ${JSON.stringify(name)} is not a plain name`);
176 const out = await Bun.build({
177 entrypoints: [resolve(ROOT, entry)],
178 target: "browser",
179 minify: true,
180 format: "esm",
181 });
182 if (!out.success) throw new Error(out.logs.map(String).join("\n"));
183 const output = out.outputs[0];
184 if (output === undefined) throw new Error(`the bundle for ${name} produced no output`);
185 built.push(stamp(name, "js", await output.text(), ceiling));
186 }
187 return built;
188 }
189
190 /**
191 * Every declared asset, read off disk and stamped with its own content hash —
192 * verbatim, because the bytes served are the bytes the file holds. A
193 * source that is not there throws here, naming the file, rather than deploying
194 * a page that links to nothing.
195 */
196 export function collect(assets: Asset[] = ASSETS, root = ROOT): Built[] {
197 if (new Set(assets.map((a) => a.name)).size !== assets.length)
198 throw new Error("two assets share a name; each one needs its own");
199 return assets.map((asset) => {
200 const ext = extOf(asset);
201 if (ext === "") throw new Error(`the asset ${JSON.stringify(asset.name)} has no extension: ${asset.source}`);
202 return stamp(asset.name, ext, readFileSync(resolve(root, asset.source), "utf8"), asset.ceiling);
203 });
204 }
205
206 /**
207 * Write every bundle and every asset, and sweep up after each name and
208 * extension only: a stale `app.*.js` goes, another game's bundle beside it
209 * stays, and so does `weights.*.json`. Anything else in the directory is left
210 * where it is.
211 */
212 export async function write(
213 entries: Entry[] = ENTRIES,
214 dir = STATIC_DIR,
215 assets: Asset[] = ASSETS,
216 root = ROOT,
217 ): Promise<Built[]> {
218 const built = [...(await bundle(entries)), ...collect(assets, root)];
219 mkdirSync(dir, { recursive: true });
220 const present = readdirSync(dir);
221 for (const { name, ext, file, code } of built) {
222 for (const old of present) if (old !== file && isNamed(name, ext, old)) rmSync(join(dir, old));
223 writeFileSync(join(dir, file), code);
224 }
225 return built;
226 }
227
228 /**
229 * Every way a directory is not what these sources build, as sentences a person
230 * can act on: a file this tree builds that is not there, a file filed under a
231 * built name whose bytes are not the built bytes, and a second file under a
232 * name that belongs to one — the shape of three outages, where two branches
233 * each rebuilt the shooter and git kept both.
234 *
235 * It ignores everything it was not asked about: the database beside the
236 * bundles in `FORGE_APP_DATA`, a note somebody left in `static/`. `write`
237 * sweeps by name and extension and nothing else, so this reads the same
238 * directory the same way.
239 *
240 * Empty is the only good answer, and it is what `build.test.ts` asserts of a
241 * build made in another process: the same names and the same bytes, which is
242 * what a year-long `immutable` cache of `app.<hash>.js` is a promise about.
243 */
244 export function differences(built: readonly Built[], dir: string): string[] {
245 const present = readdirSync(dir);
246 const said: string[] = [];
247 const rebuild = `run \`bun run scripts/build.ts ${dir}\` and compare again`;
248 for (const { name, ext, file, code } of built) {
249 const mine = present.filter((old) => isNamed(name, ext, old));
250 const stale = mine.filter((old) => old !== file);
251 if (!mine.includes(file)) {
252 const instead =
253 mine.length === 0 ? `nothing there is named ${name}.<hash>.${ext}` : `it holds ${mine.join(", ")}`;
254 said.push(
255 `${dir} has no ${file}, which is what this tree builds for ${JSON.stringify(name)}: ` +
256 `${instead} — ${rebuild}`,
257 );
258 } else {
259 const there = readFileSync(join(dir, file), "utf8");
260 if (there !== code)
261 said.push(
262 `${join(dir, file)} is not what this tree builds under that name: ` +
263 `${sizes(there).raw} bytes there, ${sizes(code).raw} bytes built — ${rebuild}`,
264 );
265 }
266 // Only beside the built file: when that one is missing the sentence above
267 // already read out what is there instead, and one name deserves one
268 // sentence.
269 if (mine.includes(file) && stale.length > 0)
270 said.push(
271 `${dir} holds ${stale.length} stale file${stale.length === 1 ? "" : "s"} for ${JSON.stringify(name)} ` +
272 `beside ${file}: ${stale.join(", ")} — whoever picks between them picks at random, so ${rebuild}`,
273 );
274 }
275 return said;
276 }
277
278 /**
279 * One row of the build's table: what was built, what it weighs on both rulers,
280 * what it may not pass and what is left under that — negative when it passed it,
281 * because a row that is over says so in the same column the others are read in.
282 */
283 export type Row = { name: string; file: string; measured: Ceiling; ceiling: Ceiling; headroom: Ceiling };
284
285 /** What one built file measured, weighed once and reported as a row. */
286 export function row({ name, file, code, ceiling }: Built): Row {
287 const measured = sizes(code);
288 return {
289 name,
290 file,
291 measured,
292 ceiling,
293 headroom: { raw: ceiling.raw - measured.raw, gzip: ceiling.gzip - measured.gzip },
294 };
295 }
296
297 /** What the table is a table of, printed under it so a pasted table carries its units. */
298 export const NOTE = [
299 "",
300 `Bytes. Every row is one file in static/: a game's bundle, or an asset it`,
301 `downloads beside one. "raw" is the file on disk and "gzip-${GZIP_LEVEL}" is it compressed`,
302 `at the one level the ceilings are measured at; "left" is the headroom under`,
303 `that ceiling, and a negative one is a bundle over it, which fails`,
304 `scripts/build.test.ts. The ceilings are in src/games.ts, with the rule for`,
305 `moving one. Run: bun run scripts/build.ts`,
306 ].join("\n");
307
308 /**
309 * The whole table, as lines a person reads and a pull quotes: one built file a
310 * row, in the order they were built. It is the reason no pull has to remember a
311 * size — the numbers a reviewer is given are copied from a run of this.
312 */
313 export function table(built: readonly Built[], where = "static"): string[] {
314 const rows = built.map(row);
315 const width = Math.max(4, ...rows.map((r) => `${where}/${r.file}`.length));
316 const cell = (text: string, size = 8): string => text.padStart(size);
317 const head =
318 `${"file".padEnd(width)} ${cell("raw")} ${cell("ceiling")} ${cell("left")} ` +
319 `${cell(`gzip-${GZIP_LEVEL}`)} ${cell("ceiling")} ${cell("left")}`;
320 return [
321 head,
322 `${"-".repeat(width)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)}`,
323 ...rows.map(
324 (r) =>
325 `${`${where}/${r.file}`.padEnd(width)} ${cell(String(r.measured.raw))} ${cell(String(r.ceiling.raw))} ` +
326 `${cell(String(r.headroom.raw))} ${cell(String(r.measured.gzip))} ${cell(String(r.ceiling.gzip))} ` +
327 `${cell(String(r.headroom.gzip))}`,
328 ),
329 ];
330 }
331
332 /**
333 * Run as a program: build into the directory named on the command line, or into
334 * the repository's `static/` when none is. Nothing needs this to deploy — the
335 * server builds its own — and it is here for a person who wants the files where
336 * they can read them, and for `src/engine/bundles.test.ts`, which asks a child
337 * process for the bundles because the engine may import nothing outside itself.
338 *
339 * The sizes are printed, not enforced: the ceiling is a test, so a build that
340 * overshoots still writes the file a reviewer can then weigh — and says so
341 * under the table, in the sentence the test would fail with.
342 */
23343 if (import.meta.main) {
24 const name = await write();
25 console.log(`static/${name}`);
344 const asked = process.argv[2];
345 const dir = asked === undefined ? STATIC_DIR : resolve(asked);
346 const built = await write(ENTRIES, dir);
347 for (const printed of table(built, asked === undefined ? "static" : dir)) console.log(printed);
348 console.log(NOTE);
349 for (const { name, measured, ceiling } of built.map(row)) {
350 const over = overshoot(name, measured, ceiling);
351 if (over !== null) console.log(over);
352 }
26353 }
scripts/bundle.test-helper.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * This tree's bundle, built and imported. All three games boot their minified
3 * file in a test — `src/client/bundle.test.ts`, `src/range/bundle.test.ts` and
4 * `src/example/bundle.test.ts` — and this is the step they share: where this
5 * game's bundle is, and how to import it more than once when the entry boots
6 * itself on the way in.
7 *
8 * It builds, once, into a directory of its own, because there is no committed
9 * bundle to read any more: `src/server.ts` builds into `FORGE_APP_DATA` at
10 * start-up and nothing generated is in the repository. So the artifact under
11 * test is the one this tree produces, which is what a browser downloads from a
12 * deploy of this tree — the question `scripts/build.test.ts` used to ask of a
13 * committed file, answered by not having one.
14 *
15 * The build happens at import, once per test process (about 70 ms for all three
16 * games), and every game's bundle comes out of that one build: three test files
17 * sharing this module share the directory it wrote.
18 *
19 * It ships in no bundle: only `.test.ts` files import it, which is why
20 * `src/independence.test.ts` lets a range test reach for it and a range module
21 * not.
22 */
23
24 import { copyFileSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
25 import { tmpdir } from "node:os";
26 import { join } from "node:path";
27 import { pathToFileURL } from "node:url";
28 import { bundleOf, write } from "./build.ts";
29
30 /** Where this process's build went: a throwaway directory, never the repository. */
31 export const BUILT_DIR = mkdtempSync(join(tmpdir(), "built-"));
32
33 // Top level, so `bundlePath` below can stay the synchronous name lookup its
34 // callers read a file with: by the time any of them runs, the files are there.
35 await write(undefined, BUILT_DIR);
36
37 /**
38 * Where the one `<name>.<hash>.js` in `dir` is. Which file that is — and what is
39 * said when there is no such file, or two of them — is `bundleOf` in
40 * `./build.ts`: the test boots the file the server would refuse to boot without.
41 */
42 export function bundlePath(name: string, dir = BUILT_DIR): string {
43 return join(dir, bundleOf(name, readdirSync(dir), dir));
44 }
45
46 // An import is cached per specifier, so an entry would boot itself once and
47 // every later boot would come out of the module cache. Two defences: a
48 // cache-busting query, and — because Bun keys that cache by path and ignores
49 // the query — a copy of the built bytes at a path of its own each time.
50 // The copies go to a throwaway directory, never into the repository.
51 let copies: string | null = null;
52 let loads = 0;
53
54 /**
55 * Import the bundle of `name` as if for the first time: the entry runs, which
56 * for every game here means it looks for a page and boots on it. Install the
57 * stub page *before* awaiting this.
58 */
59 export async function importBundle(name: string): Promise<unknown> {
60 if (copies === null) copies = mkdtempSync(join(tmpdir(), "bundle-"));
61 const load = (loads += 1);
62 const copy = join(copies, `${name}.${load}.js`);
63 copyFileSync(bundlePath(name), copy);
64 return import(`${pathToFileURL(copy).href}?load=${load}`);
65 }
66
67 /** Throw the copies away. A test file calls this in `afterAll`; the next one remakes them. */
68 export function discardBundleCopies(): void {
69 if (copies !== null) rmSync(copies, { recursive: true, force: true });
70 copies = null;
71 }
scripts/cost.test.ts +269 −76
@@ -1,88 +1,281 @@
1 /**
2 * The cost table, held against the guide. `scripts/cost.ts` measures what each
3 * group behind the door adds to a bundle; `src/engine/GUIDE.md` prints those
4 * numbers beside the paragraph a game author is already reading. A number on a
5 * page is a number that rots, so this file measures again and fails when the
6 * page and the bundler disagree.
7 *
8 * Three rules, and they are the whole of the honesty here:
9 *
10 * 1. Every group the door exports carries a `**Weight**` line under its
11 * heading, so a group that lands behind the door arrives with a number or it
12 * fails here.
13 * 2. Every `**Weight**` line in the guide is under a heading that is a group, so
14 * a renamed module cannot leave a stray measurement on the page.
15 * 3. Every line's numbers are within `TOLERANCE` of what the script measures
16 * today, and the modules it says come along are exactly the ones that do.
17 *
18 * **The tolerance**, written here rather than left for a reader to guess: a
19 * number passes when it is within 10% of what was measured, or within
20 * `GRAIN` — 100 bytes — whichever is wider. The floor is the page's own
21 * resolution: the guide writes tenths of a kilobyte, so rounding alone moves a
22 * number by up to 50 bytes and must never fail a run. The band above it is for
23 * the minifier: a bun release that renames a few identifiers differently, or
24 * shortens a helper, moves a bundle by a few percent and is nobody's bug. A
25 * group that doubled is far outside both — the smallest measurement on the
26 * table is under half a kilobyte, so doubling it moves it by more than 300
27 * bytes — which is the drift this test exists to catch.
28 *
29 * It measures for real: the script's own builds, in a temporary directory of
30 * its own that it removes. That is forty-odd `Bun.build` calls and takes well
31 * under a second, which is cheaper than a stale page.
32 */
33
134 import { expect, test } from "bun:test";
2 import { COMPETITIVE, SHORT } from "../src/client/match.ts";
35 import { readFileSync } from "node:fs";
36 import { join } from "node:path";
337 import {
4 TICK_BUDGET_MS, parseArgs, reportLines, rulesFor, sampleTicks, summarize,
38 type Cost,
39 DOOR,
40 FLOOR,
41 SLACK,
42 WHOLE_DOOR,
43 costs,
44 entryFor,
45 groupsOf,
46 guideLine,
47 headingOf,
48 kb,
49 listed,
50 moduleOf,
51 pullsOf,
52 sharesOf,
53 table,
54 vlq,
555 } from "./cost.ts";
656
7 test("no flags fall back to a short, quick, seeded default", () => {
8 expect(parseArgs([])).toEqual({ ticks: 7680, seed: 20260917, rulesName: "short", difficulty: "normal" });
9 });
57 const GUIDE = join(import.meta.dir, "..", "src", "engine", "GUIDE.md");
58 const guide = (): string => readFileSync(GUIDE, "utf8");
1059
11 test("ticks, seed, rules and difficulty are read off the command line", () => {
12 expect(parseArgs(["--ticks", "30000", "--seed", "7", "--rules", "long", "--difficulty", "hard"])).toEqual({
13 ticks: 30000, seed: 7, rulesName: "long", difficulty: "hard",
14 });
15 });
60 /** How far a written number may sit from a measured one before it is drift. */
61 export const TOLERANCE = 0.1;
1662
17 test("a malformed or missing value falls back to the default rather than NaN", () => {
18 expect(parseArgs(["--ticks", "nope"]).ticks).toBe(7680);
19 expect(parseArgs(["--ticks", "-3"]).ticks).toBe(7680);
20 expect(parseArgs(["--ticks"]).ticks).toBe(7680);
21 expect(parseArgs(["--rules", "medium"]).rulesName).toBe("short");
22 expect(parseArgs(["--seed"]).seed).toBe(20260917);
23 expect(parseArgs(["--seed", "-3"]).seed).toBe(20260917);
24 expect(parseArgs(["--seed", "1.5"]).seed).toBe(20260917);
25 expect(parseArgs(["--seed", "0"]).seed).toBe(0); // zero is a fine seed, not a falsy one
26 expect(parseArgs(["--difficulty", "extreme"]).difficulty).toBe("normal");
27 expect(parseArgs(["--difficulty"]).difficulty).toBe("normal");
28 });
63 /** The page's own resolution in bytes: a tenth of a kilobyte, either way. */
64 export const GRAIN = 100;
2965
30 test("rulesFor maps the two names the same way measure.ts does", () => {
31 expect(rulesFor("short")).toBe(SHORT);
32 expect(rulesFor("long")).toBe(COMPETITIVE);
33 });
66 /** Is this written number still the measured one? The rule in the docstring, once. */
67 export const near = (written: number, measured: number): boolean =>
68 Math.abs(written - measured) <= Math.max(GRAIN, measured * TOLERANCE);
3469
35 test("an empty sample summarizes to zeros, not NaN or a thrown error", () => {
36 expect(summarize([])).toEqual({
37 ticks: 0, meanMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0, ticksPerSecond: 0, budgetShare: 0,
38 });
39 });
40
41 test("percentiles of a known list are nearest-rank, and order does not matter", () => {
42 // 1..100 shuffled by a fixed stride, so the list arrives out of order.
43 const values = Array.from({ length: 100 }, (_, i) => ((i * 37) % 100) + 1);
44 const cost = summarize(values);
45 expect(cost.ticks).toBe(100);
46 expect(cost.meanMs).toBeCloseTo(50.5, 10);
47 expect(cost.p50Ms).toBe(50);
48 expect(cost.p95Ms).toBe(95);
49 expect(cost.p99Ms).toBe(99);
50 expect(cost.maxMs).toBe(100);
51 });
52
53 test("a single tick is its own mean, median and max", () => {
54 const cost = summarize([4]);
55 expect(cost).toMatchObject({ ticks: 1, meanMs: 4, p50Ms: 4, p95Ms: 4, p99Ms: 4, maxMs: 4 });
56 });
57
58 test("ticks a second and the budget share come off the mean", () => {
59 const cost = summarize([1, 3]); // mean 2 ms
60 expect(cost.ticksPerSecond).toBe(500);
61 expect(cost.budgetShare).toBeCloseTo(2 / TICK_BUDGET_MS, 10);
62 expect(TICK_BUDGET_MS).toBeCloseTo(15.625, 10);
63 });
64
65 test("a Float64Array summarizes the same as the plain list of the same numbers", () => {
66 expect(summarize(new Float64Array([3, 1, 2]))).toEqual(summarize([3, 1, 2]));
67 });
68
69 test("the report says the ticks, the mean, the spread and the share of the budget", () => {
70 const lines = reportLines(summarize([1, 3]));
71 expect(lines[0]).toBe("ticks sampled 2");
72 expect(lines[1]).toContain("2.000 ms a tick (500 ticks/s)");
73 expect(lines[2]).toContain("p50 1.000 ms · p95 3.000 ms");
74 expect(lines[3]).toContain("12.8% of the 15.6 ms");
75 });
70 /** A weight the guide carries: the heading it sits under, and the line itself. */
71 export type Written = { heading: string; line: string };
7672
7773 /**
78 * The one wall-clock assertion in the repository. 2 ms is roughly fourteen
79 * times what a tick costs today (about 0.13 ms on the box that runs the
80 * check), so this is a regression alarm rather than a target: it fails when
81 * a change to `bots.ts`, `trace.ts` or `world.ts` makes a tick an order of
82 * magnitude dearer, and survives a CI box under load.
74 * Every `**Weight**` line in the guide, under the heading it belongs to — the
75 * whole heading, hashes and all, because the whole door's line sits under a
76 * `##` and a group's under a `###`. A line before any heading comes back under
77 * the empty name, which the tests below turn into a sentence rather than
78 * passing quietly.
8379 */
84 test("a tick of the shipped simulation costs well under its 64 Hz budget", () => {
85 const cost = summarize(sampleTicks({ ticks: 600, seed: 20260917, rulesName: "short", difficulty: "normal" }));
86 expect(cost.ticks).toBe(600);
87 expect(cost.meanMs).toBeLessThan(2);
80 export function weightsOf(markdown: string): Written[] {
81 const out: Written[] = [];
82 let heading = "";
83 for (const line of markdown.split("\n")) {
84 const found = /^#{2,3} .+$/.exec(line.trim());
85 if (found !== null) heading = line.trim();
86 else if (line.startsWith("**Weight**")) out.push({ heading, line: line.trim() });
87 }
88 return out;
89 }
90
91 /** The numbers and the tail of one written line, or null when it is not one. */
92 export function readWeight(line: string): { raw: number; gzip: number; tail: string } | null {
93 const m = /^\*\*Weight\*\*: ([0-9.]+) kB raw, ([0-9.]+) kB gzipped — (.+)$/.exec(line);
94 if (m === null) return null;
95 return {
96 raw: Number(m[1]) * 1000,
97 gzip: Number(m[2]) * 1000,
98 tail: (m[3] as string).trim(),
99 };
100 }
101
102 /** The measurement, made once and shared by every test below it. */
103 const measured: Cost[] = await costs();
104 const groups = measured.filter((cost) => cost.name !== FLOOR && cost.name !== WHOLE_DOOR);
105 /** What the page prints a number for: every group, and all of them together. */
106 const priced = measured.filter((cost) => cost.name !== FLOOR);
107 const byName = new Map(measured.map((cost) => [cost.name, cost]));
108
109 test("the door's groups are the modules behind it, values only, in the door's order", () => {
110 const F = "fr" + "om";
111 const source = [
112 `export { a, b as c } ${F} "./one.ts";`,
113 `export type { D } ${F} "./one.ts";`,
114 `export { e, type G } ${F} "./two.ts";`,
115 `export { f } ${F} "./one.ts";`,
116 ].join("\n");
117 expect(groupsOf(source)).toEqual([
118 { name: "one", names: ["a", "c", "f"] },
119 { name: "two", names: ["e"] },
120 ]);
121 // And the real door: one group a module, every one of them measured.
122 const real = groupsOf(readFileSync(DOOR, "utf8"));
123 expect(real.length).toBeGreaterThan(15);
124 expect(real.map((group) => group.name)).toEqual(groups.map((cost) => cost.name));
125 expect(new Set(real.map((group) => group.name)).size).toBe(real.length);
126 });
127
128 test("the generated entry imports exactly its group and holds on to every name", () => {
129 const entry = entryFor({ name: "mesh", names: ["builder", "finish"] }, "/engine/index.ts", "/tmp/x");
130 expect(entry).toContain('import { builder, finish } from "../../engine/index.ts";');
131 expect(entry).toContain("globalThis.__cost = [builder, finish];");
132 // A group with no names is still an import, because the floor is a measurement.
133 expect(entryFor({ name: FLOOR, names: [] }, "/engine/index.ts", "/tmp/x")).toContain(
134 'import "../../engine/index.ts";',
135 );
136 });
137
138 test("a base64 VLQ segment is the signed numbers it packs", () => {
139 expect(vlq("A")).toEqual([0]);
140 expect(vlq("C")).toEqual([1]);
141 expect(vlq("D")).toEqual([-1]);
142 expect(vlq("AAAA")).toEqual([0, 0, 0, 0]);
143 expect(vlq("gB")).toEqual([16]);
144 expect(vlq("hB")).toEqual([-16]);
145 expect(() => vlq("!")).toThrow(/base64 VLQ/);
146 });
147
148 test("a source is an engine module, or it is not one", () => {
149 expect(moduleOf("src/engine/math.ts", "/repo", "/repo/src/engine")).toBe("math");
150 expect(moduleOf("../tmp/cost-1/math.entry.ts", "/repo", "/repo/src/engine")).toBeNull();
151 // The door itself holds no code, so nothing is ever charged to it.
152 expect(moduleOf("src/engine/index.ts", "/repo", "/repo/src/engine")).toBeNull();
153 });
154
155 test("the bytes of a bundle are charged to the module the mappings name", () => {
156 // Three segments on one generated line of ten bytes: the first owns columns
157 // 0..3 and is the first source, the second owns 4..7 and steps on to the
158 // second source, and the third is one field — generated code that belongs to
159 // no source, so the last two bytes are charged to nobody.
160 const map = JSON.stringify({
161 sources: ["src/engine/math.ts", "src/engine/mesh.ts", "entry.ts"],
162 mappings: "AAAA,ICAA,I",
163 });
164 const shares = sharesOf(map, "0123456789", "/repo", "/repo/src/engine");
165 expect(shares.get("math")).toBe(4);
166 expect(shares.get("mesh")).toBe(4);
167 expect(shares.has("entry")).toBe(false);
168 expect(() => sharesOf("{}", "x")).toThrow(/sources and mappings/);
169 });
170
171 test("a module counts as brought along when it beats the floor, not when it jitters", () => {
172 const floor = new Map([["net", 31]]);
173 const shares = new Map([
174 ["mesh", 800],
175 ["net", 32],
176 ["math", 120],
177 ]);
178 // `net` is a byte heavier than the floor's copy of it: that is the minifier,
179 // not a dependency. `math` is not in the floor at all, so any of it counts.
180 expect(pullsOf("mesh", shares, floor)).toEqual([{ module: "math", raw: 120 }]);
181 expect(pullsOf("mesh", new Map([...shares, ["net", 31 + SLACK + 1]]), floor)).toEqual([
182 { module: "net", raw: 48 },
183 { module: "math", raw: 120 },
184 ].sort((a, b) => b.raw - a.raw));
185 // The floor keeps everything it holds, because that is what it is for.
186 expect(pullsOf(FLOOR, floor, floor)).toEqual([{ module: "net", raw: 31 }]);
187 });
188
189 test("the units and the prose are the units and the prose", () => {
190 expect(kb(1814)).toBe("1.8 kB");
191 expect(kb(53)).toBe("0.1 kB");
192 expect(listed([])).toBe("");
193 expect(listed(["math"])).toBe("math");
194 expect(listed(["math", "mesh"])).toBe("math and mesh");
195 expect(listed(["math", "mesh", "scene"])).toBe("math, mesh and scene");
196 expect(guideLine({ name: "trace", raw: 1008, gzip: 599, pulls: [{ module: "math", raw: 83 }] })).toBe(
197 "**Weight**: 1.0 kB raw, 0.6 kB gzipped — including math, which comes with it.",
198 );
199 expect(guideLine({ name: "mesh", raw: 916, gzip: 496, pulls: [] })).toBe(
200 "**Weight**: 0.9 kB raw, 0.5 kB gzipped — this module alone.",
201 );
202 expect(readWeight("**Weight**: 1.0 kB raw, 0.6 kB gzipped — this module alone.")).toEqual({
203 raw: 1000,
204 gzip: 600,
205 tail: "this module alone.",
206 });
207 expect(readWeight("a paragraph")).toBeNull();
208 });
209
210 test("the measurement is a bundle per group, the floor and the whole door", () => {
211 expect(byName.get(FLOOR)?.raw).toBeGreaterThan(0);
212 expect(byName.get(FLOOR)?.raw).toBeLessThan(500);
213 const whole = byName.get(WHOLE_DOOR);
214 expect(whole?.raw).toBeGreaterThan(20_000);
215 // Every group is smaller than all of them together, and none is empty.
216 for (const cost of groups) {
217 expect(cost.raw).toBeGreaterThan(200);
218 expect(cost.raw).toBeLessThan(whole?.raw ?? 0);
219 expect(cost.gzip).toBeLessThan(cost.raw);
220 expect(cost.pulls.map((pull) => pull.module)).not.toContain(cost.name);
221 }
222 // Tree-shaking is the point: a game that wants the arithmetic does not get
223 // the WebGL, and one that wants the renderer does get the arithmetic.
224 expect(byName.get("math")?.pulls).toEqual([]);
225 expect(byName.get("renderer")?.pulls.map((pull) => pull.module)).toContain("math");
226 expect((byName.get("math")?.raw ?? 0) * 3).toBeLessThan(byName.get("renderer")?.raw ?? 0);
227 });
228
229 test("the table a person reads has a row for every group and its units under it", () => {
230 const lines = table(measured);
231 expect(lines[0]).toContain("raw");
232 expect(lines[0]).toContain("gzip-9");
233 expect(lines.length).toBe(measured.length + 2);
234 for (const cost of measured) expect(lines.some((line) => line.startsWith(cost.name))).toBe(true);
235 });
236
237 test("every group the door offers carries its weight in the guide, and nothing else does", () => {
238 const written = weightsOf(guide());
239 const under = new Set(written.map((weight) => weight.heading));
240 const offences: string[] = [];
241 for (const cost of priced)
242 if (!under.has(headingOf(cost.name)))
243 offences.push(
244 `GUIDE.md has no "**Weight**" line under "${headingOf(cost.name)}": ` +
245 `run \`bun run scripts/cost.ts --guide\` and put the line it prints there`,
246 );
247 const headings = new Set(priced.map((cost) => headingOf(cost.name)));
248 for (const weight of written)
249 if (!headings.has(weight.heading))
250 offences.push(
251 `GUIDE.md writes a weight under "${weight.heading}", which is not a group the door exports`,
252 );
253 expect(offences).toEqual([]);
254 expect(written.length).toBe(priced.length);
255 });
256
257 test("every weight the guide writes is what the bundler measures today", () => {
258 const written = new Map(weightsOf(guide()).map((weight) => [weight.heading, weight.line]));
259 const offences: string[] = [];
260 for (const cost of priced) {
261 const line = written.get(headingOf(cost.name));
262 if (line === undefined) continue; // the test above is the one that says so
263 const read = readWeight(line);
264 if (read === null) {
265 offences.push(`GUIDE.md's weight for ${cost.name} is not in the shape "${guideLine(cost)}"`);
266 continue;
267 }
268 const fresh = readWeight(guideLine(cost));
269 if (fresh === null) throw new Error("the generated line no longer reads as one");
270 if (!near(read.raw, cost.raw) || !near(read.gzip, cost.gzip))
271 offences.push(
272 `GUIDE.md says ${cost.name} is ${line} — it measures ${cost.raw} B raw and ` +
273 `${cost.gzip} B gzipped today, so the line is \`${guideLine(cost)}\``,
274 );
275 if (read.tail !== fresh.tail)
276 offences.push(
277 `GUIDE.md says ${cost.name} brings "${read.tail}" and it brings "${fresh.tail}"`,
278 );
279 }
280 expect(offences).toEqual([]);
88281 });
scripts/cost.ts +369 −139
@@ -1,159 +1,389 @@
11 /**
2 * Measures what one tick of the simulation costs: `world.update` stepped at
3 * 64 Hz on a seeded ten-player world, the person idle (`NO_INPUT`) the whole
4 * way, with every tick's wall clock recorded as a number. Prints one table —
5 * mean, p50, p95, p99 and max milliseconds a tick, the ticks a second that
6 * implies, and the share of the 15.6 ms a 64 Hz tick is allowed.
2 * What each group behind the engine's door costs a bundle, measured:
73 *
8 * bun run scripts/cost.ts # 7,680 ticks of a SHORT world
9 * bun run scripts/cost.ts --ticks 30000 --seed 7 # longer sample, a different seed
10 * bun run scripts/cost.ts --rules long # COMPETITIVE rules instead
11 * bun run scripts/cost.ts --difficulty hard # harder bots instead of normal
4 * bun run scripts/cost.ts # the table
5 * bun run scripts/cost.ts --guide # the lines GUIDE.md should carry
126 *
13 * Same shape as `scripts/measure.ts`: nothing here is imported by the game or
14 * the server, the flags parse the same way, and the arithmetic is a pure
15 * function (`summarize`) tested on a known list without touching a clock. One
16 * world exists at a time and no tick is kept as anything but a number, so a
17 * hundred thousand ticks cost one Float64Array rather than a hundred thousand
18 * objects.
7 * The ceilings in `src/games.ts` weigh whole games, which answers "is the
8 * shooter still small" and not the question a game author actually asks —
9 * *what does it cost me to import this?* This script answers that one the only
10 * honest way: it generates a tiny entry that imports exactly one group from
11 * `src/engine/index.ts` and uses every name in it, bundles that entry with the
12 * settings `scripts/build.ts` uses for a real game — browser, minified, esm —
13 * and weighs the output raw and gzipped at `GZIP_LEVEL`, the same ruler the
14 * ceilings are held to.
15 *
16 * **A group is not independent, and the table says so rather than pretending.**
17 * The renderer is drawn with the arithmetic; a bundle that has the renderer in
18 * it has that too, and there is no number for "the renderer alone" because no
19 * game can buy one. So every measurement is *including what it brings with it*,
20 * and what it brought is not asserted from the import lines but read off the
21 * build: the source map says which module every byte of the output came from,
22 * so a module the bundler loaded and then shook away is charged nothing. That
23 * is why the range ships none of the WebGL, and it is the number that says so.
24 *
25 * Nothing is subtracted from anything. A group's number is what a bundle that
26 * imports that group and nothing else actually weighs — the entry's own
27 * `globalThis` assignment, which is what keeps the names from being shaken
28 * away, included — so the rows can be compared with each other and with the
29 * whole-door row at the bottom without a reader holding an offset in their
30 * head. The entry with no group in it is a row of its own for the same reason.
31 *
32 * It ships in no bundle and no game imports it: this is a script a person runs
33 * and `cost.test.ts` runs. It writes only inside a directory it makes under the
34 * OS temp directory and removes afterwards, like `pack.test.ts` does; nothing
35 * lands in the repository and nothing touches `FORGE_APP_DATA`.
1936 */
2037
21 import { COMPETITIVE, SHORT, type Rules } from "../src/client/match.ts";
22 import {
23 DEFAULT_DIFFICULTY, DIFFICULTIES, NO_INPUT, createWorld, difficultyLabel, update, type Difficulty,
24 } from "../src/client/world.ts";
38 import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
39 import { tmpdir } from "node:os";
40 import { join, relative, resolve } from "node:path";
41 import { type Ceiling, GZIP_LEVEL, sizes } from "./build.ts";
2542
26 export type RulesName = "short" | "long";
27 export type Options = { ticks: number; seed: number; rulesName: RulesName; difficulty: Difficulty };
43 const ROOT = join(import.meta.dir, "..");
2844
29 /** 7,680 ticks is two minutes of a 64 Hz match: long enough to cross a plant. */
30 const DEFAULTS: Options = { ticks: 7680, seed: 20260917, rulesName: "short", difficulty: DEFAULT_DIFFICULTY };
31
32 /** `--ticks 30000 --seed 7 --rules long --difficulty hard`, missing or malformed flags keep the default. */
33 export function parseArgs(argv: string[]): Options {
34 const value = (name: string): string | null => {
35 const at = argv.indexOf(`--${name}`);
36 return at < 0 ? null : (argv[at + 1] ?? null);
37 };
38 const ticks = value("ticks") === null ? NaN : Number(value("ticks"));
39 const seed = value("seed") === null ? NaN : Number(value("seed"));
40 const rulesArg = value("rules");
41 const difficultyArg = value("difficulty");
42 return {
43 ticks: Number.isFinite(ticks) && ticks > 0 ? Math.round(ticks) : DEFAULTS.ticks,
44 seed: Number.isInteger(seed) && seed >= 0 ? seed : DEFAULTS.seed,
45 rulesName: rulesArg === "long" ? "long" : rulesArg === "short" ? "short" : DEFAULTS.rulesName,
46 difficulty: isDifficulty(difficultyArg) ? difficultyArg : DEFAULTS.difficulty,
47 };
48 }
49
50 export const rulesFor = (name: RulesName): Rules => (name === "long" ? COMPETITIVE : SHORT);
51
52 const isDifficulty = (value: string | null): value is Difficulty =>
53 DIFFICULTIES.includes(value as Difficulty);
54
55 const TICK = 1 / 64;
56
57 /** The milliseconds a 64 Hz tick is allowed end to end — simulation and frame both. */
58 export const TICK_BUDGET_MS = 1000 * TICK;
59
60 /** Ticks stepped and thrown away before the sample, so the JIT is warm when it counts. */
61 const WARMUP_TICKS = 256;
62
63 export type Cost = {
64 ticks: number;
65 meanMs: number;
66 p50Ms: number;
67 p95Ms: number;
68 p99Ms: number;
69 maxMs: number;
70 ticksPerSecond: number; // what the mean implies if nothing else ran
71 budgetShare: number; // fraction of TICK_BUDGET_MS the mean uses
72 };
45 /** The engine, and the one file outside it that anybody may name. */
46 export const ENGINE = join(ROOT, "src", "engine");
47 export const DOOR = join(ENGINE, "index.ts");
7348
7449 /**
75 * Nearest-rank percentile: the value at the smallest rank covering the given
76 * share of the sorted sample, so p50 of four values is the second and the
77 * top share is the max. No interpolation — every number printed is a tick
78 * that actually happened.
50 * One group the door offers: the module it comes from — which is the name the
51 * guide gives its section — and the runtime names re-exported from it. Types
52 * are not in here: `export type { … }` weighs nothing in a bundle, so a group
53 * of nothing but types would be measuring the harness.
7954 */
80 const percentile = (sorted: number[], share: number): number =>
81 sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(share * sorted.length) - 1))] as number;
55 export type Group = { name: string; names: string[] };
8256
83 /** Pure arithmetic on a list of per-tick milliseconds. No clock is read here. */
84 export function summarize(millis: ArrayLike<number>): Cost {
85 const values = Array.from(millis);
86 if (values.length === 0) {
87 return { ticks: 0, meanMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0, ticksPerSecond: 0, budgetShare: 0 };
57 /**
58 * The groups of a barrel, in the order it writes them, which is the order the
59 * guide's sections are in. Only value re-exports count — `export type { … }` is
60 * skipped whole, and a `type X` written inside a value clause is skipped with
61 * it — and two clauses from one module are one group, because a game importing
62 * that module's names gets one module either way.
63 */
64 export function groupsOf(source: string): Group[] {
65 const groups = new Map<string, string[]>();
66 const pattern = /\bexport\s+(type\s+)?\{([^}]*)\}\s*from\s*["']\.\/([A-Za-z0-9_-]+)\.ts["']/g;
67 for (let m = pattern.exec(source); m !== null; m = pattern.exec(source)) {
68 if (m[1] !== undefined) continue;
69 const module = m[3] as string;
70 const names = (m[2] as string)
71 .split(",")
72 .map((clause) => clause.trim())
73 .filter((clause) => clause !== "" && !/^type\s/.test(clause))
74 .map((clause) => clause.split(/\s+as\s+/).pop()?.trim() ?? "");
75 groups.set(module, [...(groups.get(module) ?? []), ...names.filter((name) => name !== "")]);
8876 }
89 const sorted = [...values].sort((a, b) => a - b);
90 let total = 0;
91 for (const ms of values) total += ms;
92 const meanMs = total / values.length;
93 return {
94 ticks: values.length,
95 meanMs,
96 p50Ms: percentile(sorted, 0.5),
97 p95Ms: percentile(sorted, 0.95),
98 p99Ms: percentile(sorted, 0.99),
99 maxMs: sorted[sorted.length - 1] as number,
100 ticksPerSecond: meanMs === 0 ? Infinity : 1000 / meanMs,
101 budgetShare: meanMs / TICK_BUDGET_MS,
102 };
77 return [...groups].map(([name, names]) => ({ name, names }));
10378 }
10479
105 /** The cost as one line a stat, the table this script prints. */
106 export function reportLines(cost: Cost): string[] {
107 const ms = (value: number): string => `${value.toFixed(3)} ms`;
80 /**
81 * The entry that measures a group: import those names through the door, and
82 * park them on `globalThis` so the bundler cannot shake away what it was asked
83 * to weigh. An assignment to a property of the global object is a side effect,
84 * so minification keeps it and everything it reaches.
85 */
86 export function entryFor(group: Group, door = DOOR, dir = ""): string {
87 const from = dir === "" ? door : relative(dir, door);
88 const path = from.startsWith(".") || from.startsWith("/") ? from : `./${from}`;
89 const names = group.names.join(", ");
10890 return [
109 `ticks sampled ${cost.ticks}`,
110 `mean ${ms(cost.meanMs)} a tick (${Math.round(cost.ticksPerSecond).toLocaleString("en-US")} ticks/s)`,
111 `spread p50 ${ms(cost.p50Ms)} · p95 ${ms(cost.p95Ms)} · p99 ${ms(cost.p99Ms)} · max ${ms(cost.maxMs)}`,
112 `budget ${(cost.budgetShare * 100).toFixed(1)}% of the ${TICK_BUDGET_MS.toFixed(1)} ms a 64 Hz tick is allowed`,
91 `// generated by scripts/cost.ts: what ${group.name} costs, and nothing else`,
92 names === "" ? `import "${path}";` : `import { ${names} } from "${path}";`,
93 `globalThis.__cost = [${names}];`,
94 "",
95 ].join("\n");
96 }
97
98 /** A module a group brought with it, and the bytes of the bundle that are its. */
99 export type Pull = { module: string; raw: number };
100
101 /** What one group weighs, and what came with it — its own module never among them. */
102 export type Cost = Ceiling & { name: string; pulls: Pull[] };
103
104 /** Every group at once: what a game that used all of it would carry. */
105 export const WHOLE_DOOR = "the whole door";
106
107 /**
108 * The floor: the door imported and none of it used. It is not zero — `net`
109 * allocates a scratch array at module scope and a bundler may not drop an
110 * allocation it cannot prove nobody observes — and every group's number has
111 * that floor inside it. So the table prints the floor as its own row, and a
112 * module counts as brought along only when it weighs *more* in a group's bundle
113 * than it does in this one: `mesh` is not dragging `net` in, and `learn` is.
114 */
115 export const FLOOR = "the door, unused";
116
117 /** What one bundle of a generated entry came to: its bytes, and whose bytes they are. */
118 export type Weighed = Ceiling & { shares: Map<string, number> };
119
120 /** One build, with the settings `scripts/build.ts` gives a game. */
121 const build = (file: string, sourcemap: boolean): Promise<Bun.BuildOutput> =>
122 Bun.build({
123 entrypoints: [file],
124 target: "browser",
125 minify: true,
126 format: "esm",
127 ...(sourcemap ? { sourcemap: "external" as const } : {}),
128 });
129
130 /** The one output of a build, or the build's own complaint about why there is none. */
131 async function only(out: Bun.BuildOutput, kind: string, what: string): Promise<string> {
132 if (!out.success) throw new Error(out.logs.map(String).join("\n"));
133 const output = out.outputs.find((file) => file.kind === kind);
134 if (output === undefined) throw new Error(`the entry for ${what} produced no ${kind}`);
135 return await output.text();
136 }
137
138 /**
139 * The engine module a source map's source path names, or null when the path is
140 * not one: the generated entry, and the door itself, which holds no code.
141 *
142 * A build with no file to write puts its sources relative to the working
143 * directory, so `base` is where they are resolved from and the caller's own
144 * directory is the only sensible default.
145 */
146 export function moduleOf(source: string, base = process.cwd(), engine = ENGINE): string | null {
147 const inside = relative(engine, resolve(base, source));
148 if (inside.startsWith("..") || !inside.endsWith(".ts")) return null;
149 const module = inside.replace(/\.ts$/, "");
150 return module === "index" ? null : module;
151 }
152
153 /** The digits of a base64 VLQ, in the order the format numbers them. */
154 const DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
155
156 /**
157 * One segment of a source map's `mappings`, as the signed numbers it packs:
158 * base64 VLQ, continuation bit 0x20, sign in the low bit of the first digit.
159 * A field it does not carry is simply absent — a one-field segment is generated
160 * code that belongs to no source, which is exactly what this file must not
161 * charge to a module.
162 */
163 export function vlq(segment: string): number[] {
164 const out: number[] = [];
165 let value = 0;
166 let shift = 0;
167 for (const char of segment) {
168 const digit = DIGITS.indexOf(char);
169 if (digit < 0) throw new Error(`"${segment}" is not a base64 VLQ segment`);
170 value += (digit & 0x1f) << shift;
171 if ((digit & 0x20) !== 0) {
172 shift += 5;
173 continue;
174 }
175 out.push((value & 1) === 1 ? -(value >> 1) : value >> 1);
176 value = 0;
177 shift = 0;
178 }
179 return out;
180 }
181
182 /**
183 * Whose bytes the bundle is made of: every engine module in it, against the
184 * number of generated bytes the source map attributes to it. This is how "what
185 * came with it" is measured rather than argued — a module the bundler loaded
186 * and then shook away is charged nothing, which is the whole reason the range
187 * ships none of the WebGL — and it is per module rather than a bare list
188 * because two bundles can hold the same module and one line of it.
189 *
190 * A span runs from a segment's generated column to the next segment's, or to
191 * the end of that line. The count is of the *mapped* output, which is the
192 * source-mapped build and so a few bytes longer than the shipped one; the
193 * shares are a breakdown of a bundle, not a second measurement of it.
194 */
195 export function sharesOf(
196 map: string,
197 code: string,
198 base = process.cwd(),
199 engine = ENGINE,
200 ): Map<string, number> {
201 const { sources, mappings } = JSON.parse(map) as { sources?: unknown; mappings?: unknown };
202 if (!Array.isArray(sources) || typeof mappings !== "string")
203 throw new Error("the source map has no sources and mappings to read");
204 const owners = (sources as string[]).map((source) => moduleOf(source, base, engine));
205 const lines = code.split("\n");
206 const out = new Map<string, number>();
207 let source = 0;
208 const rows = mappings.split(";");
209 for (let row = 0; row < rows.length; row++) {
210 let column = 0;
211 const at: number[] = [];
212 const whose: (string | null)[] = [];
213 for (const segment of (rows[row] as string).split(",")) {
214 if (segment === "") continue;
215 const fields = vlq(segment);
216 column += fields[0] ?? 0;
217 if (fields.length >= 4) source += fields[1] ?? 0;
218 at.push(column);
219 whose.push(fields.length >= 4 ? (owners[source] ?? null) : null);
220 }
221 const end = (lines[row] ?? "").length;
222 for (let i = 0; i < at.length; i++) {
223 const module = whose[i];
224 const span = (i + 1 < at.length ? (at[i + 1] as number) : end) - (at[i] as number);
225 if (module === null || module === undefined || span <= 0) continue;
226 out.set(module, (out.get(module) ?? 0) + span);
227 }
228 }
229 return out;
230 }
231
232 /**
233 * Bundle one generated entry twice: once for the bytes, and once with a source
234 * map for the breakdown. Twice, because an external source map appends a
235 * `debugId` comment to the JavaScript, and the number this script prints has to
236 * be the bytes a game would actually ship rather than those plus a comment.
237 */
238 export async function weighEntry(name: string, file: string): Promise<Weighed> {
239 const code = await only(await build(file, false), "entry-point", name);
240 const mapped = await build(file, true);
241 const shares = sharesOf(
242 await only(mapped, "sourcemap", name),
243 await only(mapped, "entry-point", name),
244 );
245 return { ...sizes(code), shares };
246 }
247
248 /**
249 * Every group the door offers, measured, with `FLOOR` first and `WHOLE_DOOR`
250 * last. Each entry is written into `dir`, which the caller owns. A group's
251 * `pulls` is what its bundle holds beyond its own module and beyond the floor
252 * every import of the door pays.
253 */
254 export async function measure(groups: Group[], dir: string): Promise<Cost[]> {
255 const all: Group[] = [
256 { name: FLOOR, names: [] },
257 ...groups,
258 { name: WHOLE_DOOR, names: groups.flatMap((group) => group.names) },
259 ];
260 const out: Cost[] = [];
261 let floor = new Map<string, number>();
262 for (const group of all) {
263 const file = join(dir, `${group.name.replace(/[^a-z]+/g, "-")}.entry.ts`);
264 writeFileSync(file, entryFor(group, DOOR, dir));
265 const { raw, gzip, shares } = await weighEntry(group.name, file);
266 if (group.name === FLOOR) floor = shares;
267 out.push({ name: group.name, raw, gzip, pulls: pullsOf(group.name, shares, floor) });
268 }
269 return out;
270 }
271
272 /**
273 * The bytes by which a module already in the floor has to beat the floor before
274 * a group is said to have dragged it in. A minifier names identifiers by how
275 * often they are used, so the same two lines of `net` come out a byte or two
276 * longer in one bundle than in another; that jitter is a few bytes and this is
277 * wider than it. A module the floor does not charge at all is counted at any
278 * size, because one line of it is still a line a game did not ask for.
279 */
280 export const SLACK = 16;
281
282 /**
283 * What a bundle brought along: every engine module in it but the group's own,
284 * charged more bytes here than the floor charges it. The floor's own row keeps
285 * everything, because what it brings along is the whole of what it is.
286 */
287 export function pullsOf(
288 name: string,
289 shares: Map<string, number>,
290 floor: Map<string, number>,
291 ): Pull[] {
292 const beyondTheFloor = (module: string, raw: number): boolean => {
293 const paid = floor.get(module) ?? 0;
294 return raw > paid + (paid === 0 ? 0 : SLACK);
295 };
296 return [...shares]
297 .filter(([module, raw]) => module !== name && (name === FLOOR || beyondTheFloor(module, raw)))
298 .map(([module, raw]) => ({ module, raw }))
299 .sort((a, b) => b.raw - a.raw || a.module.localeCompare(b.module));
300 }
301
302 /** Measure in a temporary directory of this run's own, removed however it ends. */
303 export async function costs(source = readFileSync(DOOR, "utf8")): Promise<Cost[]> {
304 const dir = mkdtempSync(join(tmpdir(), "cost-"));
305 try {
306 return await measure(groupsOf(source), dir);
307 } finally {
308 rmSync(dir, { recursive: true, force: true });
309 }
310 }
311
312 /**
313 * Bytes as the guide writes them: kilobytes of a thousand bytes, one decimal.
314 * A tenth of a kilobyte is the resolution of the page, and `TOLERANCE` below is
315 * wide enough to cover the rounding this does.
316 */
317 export const kb = (bytes: number): string => `${(bytes / 1000).toFixed(1)} kB`;
318
319 /** `and` as a person writes it: `a`, `a and b`, `a, b and c`. */
320 export function listed(names: readonly string[]): string {
321 if (names.length <= 1) return names[0] ?? "";
322 return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
323 }
324
325 /**
326 * The sentence the guide carries under a group's heading. It is generated here
327 * rather than written by hand so that the page and the measurement cannot drift
328 * into two different claims; `cost.test.ts` holds today's guide against it.
329 */
330 export function guideLine({ name, raw, gzip, pulls }: Cost): string {
331 const tail =
332 name === WHOLE_DOOR
333 ? "every group at once, which is the most any game can pay"
334 : pulls.length === 0
335 ? "this module alone"
336 : `including ${listed(pulls.map((pull) => pull.module))}, which come${
337 pulls.length === 1 ? "s" : ""
338 } with it`;
339 return `**Weight**: ${kb(raw)} raw, ${kb(gzip)} gzipped — ${tail}.`;
340 }
341
342 /**
343 * The guide's heading a measurement belongs under: a group's own section, and
344 * for the whole door the section all of them are in. `cost.test.ts` reads the
345 * page by these, so the script that prints the lines and the test that checks
346 * them cannot disagree about where a line goes.
347 */
348 export const SECTION = "What is behind the door";
349 export const headingOf = (name: string): string =>
350 name === WHOLE_DOOR ? `## ${SECTION}` : `### ${name}`;
351
352 /** What the table is a table of, printed under it so a pasted table carries its units. */
353 export const NOTE = [
354 "",
355 `Bytes. Every row is one bundle: an entry that imports exactly that group through`,
356 `src/engine/index.ts and uses every name in it, bundled the way scripts/build.ts`,
357 `bundles a game — browser, minified, esm — and gzipped at level ${GZIP_LEVEL}.`,
358 `So a row includes what the group brings with it, and the floor every import of`,
359 `the door pays. Run: bun run scripts/cost.ts`,
360 ].join("\n");
361
362 /** The whole table, as lines a person reads: one group a row, widest name first. */
363 export function table(measured: readonly Cost[]): string[] {
364 const width = Math.max(...measured.map((cost) => cost.name.length));
365 const cell = (text: string, size: number): string => text.padStart(size);
366 const rows = measured.map(
367 (cost) =>
368 `${cost.name.padEnd(width)} ${cell(String(cost.raw), 7)} ${cell(String(cost.gzip), 7)} ` +
369 (cost.pulls.length === 0
370 ? "—"
371 : cost.pulls.map((pull) => `${pull.module} ${pull.raw}`).join(", ")),
372 );
373 return [
374 `${"group".padEnd(width)} ${cell("raw", 7)} ${cell(`gzip-${GZIP_LEVEL}`, 7)} brings with it`,
375 `${"-".repeat(width)} ${"-".repeat(7)} ${"-".repeat(7)} ${"-".repeat(14)}`,
376 ...rows,
113377 ];
114378 }
115379
116 /**
117 * Step a seeded world `ticks` times and return each tick's milliseconds. The
118 * world is replaced, untimed, whenever a match finishes, so a long sample
119 * measures play rather than a `match_over` screen.
120 */
121 export function sampleTicks(opts: Options): Float64Array {
122 const rules = rulesFor(opts.rulesName);
123 const newWorld = (restart: number) =>
124 // The same prime stride scripts/measure.ts uses, so a restart is not a
125 // neighbour of the seed it followed.
126 createWorld(rules, "cost", "ct", 5, opts.seed + restart * 7919, opts.difficulty);
127
128 let world = newWorld(0);
129 let restarts = 0;
130 for (let i = 0; i < WARMUP_TICKS; i++) update(world, TICK, NO_INPUT);
131
132 const millis = new Float64Array(opts.ticks);
133 for (let i = 0; i < opts.ticks; i++) {
134 const before = Bun.nanoseconds();
135 update(world, TICK, NO_INPUT);
136 millis[i] = (Bun.nanoseconds() - before) / 1e6;
137 if (world.match.phase === "match_over") world = newWorld(++restarts);
380 if (import.meta.main) {
381 const measured = await costs();
382 if (process.argv.includes("--guide")) {
383 for (const cost of measured)
384 if (cost.name !== FLOOR) console.log(`${headingOf(cost.name)}\n${guideLine(cost)}\n`);
385 } else {
386 for (const line of table(measured)) console.log(line);
387 console.log(NOTE);
138388 }
139 return millis;
140389 }
141
142 function main(): void {
143 const opts = parseArgs(process.argv.slice(2));
144 console.log(
145 `${opts.ticks} ticks of a 10-player ${opts.rulesName.toUpperCase()} world, seed ${opts.seed}, ` +
146 `${difficultyLabel(opts.difficulty)} bots, the person idle`,
147 );
148
149 const started = Bun.nanoseconds();
150 const cost = summarize(sampleTicks(opts));
151 const totalSeconds = (Bun.nanoseconds() - started) / 1e9;
152
153 console.log("");
154 for (const line of reportLines(cost)) console.log(line);
155 console.log("");
156 console.log(`${totalSeconds.toFixed(1)}s wall clock, warm-up and sampling both`);
157 }
158
159 if (import.meta.main) main();
scripts/duel.test.ts deleted −37
@@ -1,37 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { parseArgs } from "./duel.ts";
3
4 const CARD_SEED = 20260914 + 555;
5
6 test("no flags fall back to de_forge, 240 duels, and train.ts's own evaluation seed", () => {
7 expect(parseArgs([])).toEqual({ map: "de_forge", duels: 240, seed: CARD_SEED });
8 });
9
10 test("map, duels and seed are read off the command line", () => {
11 expect(parseArgs(["--map", "de_cinder", "--duels", "500", "--seed", "7"])).toEqual({
12 map: "de_cinder", duels: 500, seed: 7,
13 });
14 });
15
16 // The whole point of the flag is comparing two maps on one seed; a typo that
17 // silently measured the default map again would read as the new map's numbers.
18 test("a map that is not in the catalogue falls back to the default rather than a name nothing holds", () => {
19 expect(parseArgs(["--map", "de_nothing"]).map).toBe("de_forge");
20 expect(parseArgs(["--map"]).map).toBe("de_forge");
21 expect(parseArgs(["--map", "DE_CINDER"]).map).toBe("de_forge");
22 expect(parseArgs(["--map", "../../etc/passwd"]).map).toBe("de_forge");
23 });
24
25 test("a malformed or missing value falls back to the default rather than NaN", () => {
26 expect(parseArgs(["--duels", "nope"]).duels).toBe(240);
27 expect(parseArgs(["--duels", "-5"]).duels).toBe(240);
28 expect(parseArgs(["--duels"]).duels).toBe(240);
29 expect(parseArgs(["--seed", "abc"]).seed).toBe(CARD_SEED);
30 expect(parseArgs(["--seed"]).seed).toBe(CARD_SEED);
31 });
32
33 test("a negative or non-integer seed falls back the same way a non-positive duel count does", () => {
34 expect(parseArgs(["--seed", "-3"]).seed).toBe(CARD_SEED);
35 expect(parseArgs(["--seed", "1.5"]).seed).toBe(CARD_SEED);
36 expect(parseArgs(["--seed", "0"]).seed).toBe(0); // zero is a fine seed, not a falsy one
37 });
scripts/duel.ts deleted −52
@@ -1,52 +0,0 @@
1 /**
2 * Measures the shipped policy — and the scripted baseline it is measured
3 * against — over the same fixed duels `scripts/train.ts` plays to write
4 * `CARD`, on a map of your choosing. It does not retrain: the weights come
5 * from `weights.ts` exactly as the game ships them, and `--map` only changes
6 * which arena they are dropped into.
7 *
8 * bun run scripts/duel.ts # de_forge, what CARD was measured on
9 * bun run scripts/duel.ts --map de_cinder # the other map, same seeds, same duels
10 * bun run scripts/duel.ts --duels 500 --seed 7
11 */
12
13 import { policy } from "../src/client/bots.ts";
14 import { DEFAULT_MAP, MAP_NAMES, mapFor, type MapName } from "../src/client/map.ts";
15 import { flagValue } from "./args.ts";
16 import { evaluate, greedyDriver, type Outcome } from "./train.ts";
17
18 // train.ts's own default seed (20260914) plus the offset its evaluations are
19 // always called with, so a duel here lines up with the number on the card.
20 const CARD_SEED = 20260914 + 555;
21
22 type Options = { map: MapName; duels: number; seed: number };
23
24 const isMapName = (value: string | null): value is MapName =>
25 (MAP_NAMES as readonly string[]).includes(value as string);
26
27 /** A missing or malformed value keeps the default rather than guessing at it. */
28 export function parseArgs(argv: string[]): Options {
29 const value = (name: string): string | null => flagValue(argv, name);
30 const mapArg = value("map");
31 const duels = value("duels") === null ? NaN : Number(value("duels"));
32 const seed = value("seed") === null ? NaN : Number(value("seed"));
33 return {
34 map: isMapName(mapArg) ? mapArg : DEFAULT_MAP,
35 duels: Number.isFinite(duels) && duels > 0 ? Math.round(duels) : 240,
36 seed: Number.isInteger(seed) && seed >= 0 ? seed : CARD_SEED,
37 };
38 }
39
40 const rate = (o: Outcome): number => Math.round((o.won / (o.won + o.lost + o.draw)) * 100);
41
42 function main(): void {
43 const opts = parseArgs(process.argv.slice(2));
44 const map = mapFor(opts.map);
45 console.log(`${opts.duels} seeded duels on ${opts.map}, seed ${opts.seed}`);
46 const shipped = evaluate(greedyDriver(policy()), opts.duels, opts.seed, map);
47 const baseline = evaluate("scripted", opts.duels, opts.seed, map);
48 console.log(`shipped ${shipped.won}/${shipped.lost}/${shipped.draw} (${rate(shipped)}% won)`);
49 console.log(`scripted ${baseline.won}/${baseline.lost}/${baseline.draw} (${rate(baseline)}% won)`);
50 }
51
52 if (import.meta.main) main();
scripts/measure.test.ts deleted −46
@@ -1,46 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { parseArgs, rulesFor } from "./measure.ts";
3 import { COMPETITIVE, SHORT } from "../src/client/match.ts";
4
5 test("no flags fall back to a short, quick, seeded default", () => {
6 expect(parseArgs([])).toEqual({
7 matches: 12, seed: 20260917, rulesName: "short", difficulty: "normal", map: "de_forge",
8 });
9 });
10
11 test("matches, seed, rules, difficulty and map are read off the command line", () => {
12 expect(parseArgs(
13 ["--matches", "40", "--seed", "7", "--rules", "long", "--difficulty", "hard", "--map", "de_cinder"],
14 )).toEqual({
15 matches: 40, seed: 7, rulesName: "long", difficulty: "hard", map: "de_cinder",
16 });
17 });
18
19 // The whole point of the flag is comparing two maps on one seed; a typo that
20 // silently measured the default map again would read as the new map's numbers.
21 test("a map that is not in the catalogue falls back to the default rather than a name nothing holds", () => {
22 expect(parseArgs(["--map", "de_nothing"]).map).toBe("de_forge");
23 expect(parseArgs(["--map"]).map).toBe("de_forge");
24 expect(parseArgs(["--map", "DE_CINDER"]).map).toBe("de_forge");
25 });
26
27 test("a malformed or missing value falls back to the default rather than NaN", () => {
28 expect(parseArgs(["--matches", "nope"]).matches).toBe(12);
29 expect(parseArgs(["--matches", "-3"]).matches).toBe(12);
30 expect(parseArgs(["--rules", "medium"]).rulesName).toBe("short");
31 expect(parseArgs(["--seed"]).seed).toBe(20260917);
32 expect(parseArgs(["--difficulty", "extreme"]).difficulty).toBe("normal");
33 expect(parseArgs(["--difficulty"]).difficulty).toBe("normal");
34 });
35
36 test("a negative or non-integer seed falls back the same way a negative matches count does", () => {
37 expect(parseArgs(["--seed", "-3"]).seed).toBe(20260917);
38 expect(parseArgs(["--seed", "1.5"]).seed).toBe(20260917);
39 expect(parseArgs(["--seed", "nope"]).seed).toBe(20260917);
40 expect(parseArgs(["--seed", "0"]).seed).toBe(0); // zero is a fine seed, not a falsy one
41 });
42
43 test("rulesFor maps the two names to the rule sets main.ts already uses", () => {
44 expect(rulesFor("short")).toBe(SHORT);
45 expect(rulesFor("long")).toBe(COMPETITIVE);
46 });
scripts/measure.ts deleted −130
@@ -1,130 +0,0 @@
1 /**
2 * Measures what the bots' rounds are actually like: how often the bomb goes
3 * down, how rounds end, how long they take. Plays N seeded matches headless
4 * through `world.update`, the person idle (`NO_INPUT`) the whole way, and
5 * prints one table — the arithmetic itself lives in `src/client/rounds.ts`
6 * and is what the tests check.
7 *
8 * bun run scripts/measure.ts # 12 seeded SHORT matches
9 * bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed
10 * bun run scripts/measure.ts --rules long # COMPETITIVE rules instead
11 * bun run scripts/measure.ts --difficulty hard # harder bots instead of normal
12 * bun run scripts/measure.ts --map de_cinder # a map from the catalogue, not the default one
13 *
14 * Nothing here is imported by the game or the server. A round is recorded as
15 * plain numbers as it finishes — never a whole `World` held onto for later —
16 * so a hundred rounds cost a hundred small objects, not a hundred worlds.
17 */
18
19 import { COMPETITIVE, SHORT, type Rules } from "../src/client/match.ts";
20 import {
21 DEFAULT_DIFFICULTY, DIFFICULTIES, NO_INPUT, createWorld, difficultyLabel, update, type Difficulty,
22 } from "../src/client/world.ts";
23 import { DEFAULT_MAP, MAP_NAMES, mapFor, type MapName } from "../src/client/map.ts";
24 import { reportLines, summarize, type RoundRecord } from "../src/client/rounds.ts";
25 import { flagValue } from "./args.ts";
26
27 export type RulesName = "short" | "long";
28 export type Options = {
29 matches: number; seed: number; rulesName: RulesName; difficulty: Difficulty; map: MapName;
30 };
31
32 const DEFAULTS: Options = {
33 matches: 12, seed: 20260917, rulesName: "short", difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP,
34 };
35
36 /**
37 * `--matches 40 --seed 7 --rules long --difficulty hard --map de_cinder`; a
38 * missing or malformed value keeps the default rather than guessing at it.
39 */
40 export function parseArgs(argv: string[]): Options {
41 const value = (name: string): string | null => flagValue(argv, name);
42 const matches = value("matches") === null ? NaN : Number(value("matches"));
43 const seed = value("seed") === null ? NaN : Number(value("seed"));
44 const rulesArg = value("rules");
45 const difficultyArg = value("difficulty");
46 const mapArg = value("map");
47 return {
48 matches: Number.isFinite(matches) && matches > 0 ? Math.round(matches) : DEFAULTS.matches,
49 seed: Number.isInteger(seed) && seed >= 0 ? seed : DEFAULTS.seed,
50 rulesName: rulesArg === "long" ? "long" : rulesArg === "short" ? "short" : DEFAULTS.rulesName,
51 difficulty: isDifficulty(difficultyArg) ? difficultyArg : DEFAULTS.difficulty,
52 map: isMapName(mapArg) ? mapArg : DEFAULTS.map,
53 };
54 }
55
56 export const rulesFor = (name: RulesName): Rules => (name === "long" ? COMPETITIVE : SHORT);
57
58 const isDifficulty = (value: string | null): value is Difficulty =>
59 DIFFICULTIES.includes(value as Difficulty);
60
61 const isMapName = (value: string | null): value is MapName =>
62 (MAP_NAMES as readonly string[]).includes(value as string);
63
64 const TICK = 1 / 64;
65
66 /**
67 * The longest a match can honestly take, with room to spare: every round at
68 * its worst (freeze, the full round clock, the bomb's full fuse, the delay
69 * after), times the most rounds a best-of-`2 * winScore - 1` can ask for.
70 * A guard against a bug turning this into an infinite loop rather than a bad
71 * table, not a budget anyone is expected to spend.
72 */
73 function maxTicksFor(rules: Rules): number {
74 const worstRound = rules.freezeTime + rules.roundTime + rules.bombTime + rules.endDelay;
75 const worstRounds = 2 * rules.winScore - 1;
76 return Math.ceil((worstRound * worstRounds * 1.5) / TICK);
77 }
78
79 /** Play one seeded match to its end, the person idle, and return its rounds. */
80 function playMatch(rules: Rules, seed: number, difficulty: Difficulty, map: MapName): RoundRecord[] {
81 const world = createWorld(rules, "measure", "ct", 5, seed, difficulty, mapFor(map));
82 const rounds: RoundRecord[] = [];
83 let roundStart = 0; // world.time at the freeze that started the round in play
84 let prevPhase = world.match.phase;
85 const maxTicks = maxTicksFor(rules);
86 for (let tick = 0; tick < maxTicks && world.match.phase !== "match_over"; tick++) {
87 update(world, TICK, NO_INPUT);
88 const phase = world.match.phase;
89 if (phase === "over" && prevPhase !== "over") {
90 const { reason, winner } = world.match;
91 if (reason === null || winner === null) throw new Error("a round went over with no winner or reason set");
92 rounds.push({ winner, reason, planted: world.bombPlanted, seconds: world.time - roundStart });
93 }
94 if (phase === "freeze" && prevPhase === "over") roundStart = world.time;
95 prevPhase = phase;
96 }
97 if (world.match.phase !== "match_over") {
98 throw new Error(`match seeded ${seed} did not finish inside ${maxTicks} ticks`);
99 }
100 return rounds;
101 }
102
103 function main(): void {
104 const opts = parseArgs(process.argv.slice(2));
105 const rules = rulesFor(opts.rulesName);
106 console.log(
107 `${opts.matches} seeded ${opts.rulesName.toUpperCase()} matches on ${opts.map}, seed ${opts.seed}, ` +
108 `${difficultyLabel(opts.difficulty)} bots, the person idle`,
109 );
110
111 const rounds: RoundRecord[] = [];
112 const started = Bun.nanoseconds();
113 for (let i = 0; i < opts.matches; i++) {
114 const before = Bun.nanoseconds();
115 // Spread out the same way scripts/train.ts spreads its duels: a prime
116 // stride so nearby matches do not share a seed's low bits.
117 const matchRounds = playMatch(rules, opts.seed + i * 7919, opts.difficulty, opts.map);
118 rounds.push(...matchRounds);
119 const seconds = (Bun.nanoseconds() - before) / 1e9;
120 console.log(` match ${String(i + 1).padStart(3)}/${opts.matches} ${matchRounds.length} rounds ${seconds.toFixed(1)}s`);
121 }
122 const totalSeconds = (Bun.nanoseconds() - started) / 1e9;
123
124 console.log("");
125 for (const line of reportLines(summarize(rounds))) console.log(line);
126 console.log("");
127 console.log(`${totalSeconds.toFixed(1)}s wall clock for ${opts.matches} matches (${(totalSeconds / opts.matches).toFixed(1)}s a match)`);
128 }
129
130 if (import.meta.main) main();
scripts/mutate-result.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * A test that stays green when the rule it tests is deleted is not a test.
3 * This runs `src/result.test.ts` against a copy of the gate a match comes in
4 * through, with one rule broken at a time, and reports which test caught it.
5 * Every mutant must die; a survivor is a rule nothing pins.
6 *
7 * bun run scripts/mutate-result.ts
8 *
9 * The gate is two files, not one: `src/result.ts` says what a match is, and
10 * `src/gate.ts` holds the two primitives every posted score is checked with —
11 * a counted number and a printable name — because the range posts scores too.
12 * A mutant names the file it breaks, defaulting to `src/result.ts`; both are
13 * put back afterwards whatever happens.
14 *
15 * It is not part of `bun test`: it spawns one test run per mutant, which takes
16 * seconds rather than milliseconds. Run it when you change either file.
17 */
18
19 import { copyFileSync, unlinkSync, writeFileSync } from "node:fs";
20
21 const RESULT = "src/result.ts";
22 const GATE = "src/gate.ts";
23 const backupOf = (file: string): string => `${file}.original`;
24
25 /**
26 * Each mutant: a name, the text to find, and what to put in its place — in
27 * `file`, or in `src/result.ts` when it does not say. `redundant` marks a line
28 * no test can kill because another rule already refuses everything it would —
29 * defence in depth rather than behaviour. Those are expected to survive, and a
30 * mutant that stops matching its note (dies when it should survive, or the
31 * reverse) fails this run.
32 */
33 const MUTANTS: { rule: string; find: string; replace: string; file?: string; redundant?: string }[] = [
34 { rule: "a body must be an object", find: `if (typeof value !== "object" || value === null) return null;`, replace: "" },
35 // The id's own shape lives in `src/matchid.ts` now; what this file can still
36 // break is asking it at all.
37 { rule: "matchId goes through the one door", find: "if (!isMatchId(matchId)) return null;", replace: "" },
38 { rule: "a map that is not a map is refused", find: "const map = body.map === undefined ? DEFAULT_MAP : playedOn(body.map);", replace: "const map = playedOn(body.map) ?? DEFAULT_MAP;" },
39 { rule: "a refused map refuses the result", find: "if (map === null) return null;", replace: "" },
40 { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "", file: GATE },
41 { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false", file: GATE },
42 { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;", file: GATE },
43 { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "", file: GATE },
44 { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max", file: GATE },
45 { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0", file: GATE },
46 { rule: "the rounds ceiling", find: "const rounds = integer(body.rounds, MAX_ROUNDS);", replace: "const rounds = integer(body.rounds, Infinity);" },
47 {
48 rule: "the score ceilings", find: "const scoreMine = integer(body.scoreMine, MAX_ROUNDS);",
49 replace: "const scoreMine = integer(body.scoreMine, Infinity);",
50 redundant: "both scores are non-negative and sum to rounds, so neither can pass 30 anyway",
51 },
52 {
53 rule: "the kills ceiling", find: "const kills = integer(body.kills, MAX_ROUNDS * KILLS_PER_ROUND);",
54 replace: "const kills = integer(body.kills, Infinity);",
55 redundant: "kills > rounds * 5 refuses everything over 150 already",
56 },
57 {
58 rule: "the deaths ceiling", find: "const deaths = integer(body.deaths, MAX_ROUNDS);",
59 replace: "const deaths = integer(body.deaths, Infinity);",
60 redundant: "deaths > rounds refuses everything over 30 already",
61 },
62 {
63 rule: "the headshots ceiling", find: "const headshots = integer(body.headshots, MAX_ROUNDS * KILLS_PER_ROUND);",
64 replace: "const headshots = integer(body.headshots, Infinity);",
65 redundant: "headshots > kills refuses everything over 150 already",
66 },
67 { rule: "the damage ceiling", find: "const damage = integer(body.damage, MAX_ROUNDS * KILLS_PER_ROUND * 200);", replace: "const damage = integer(body.damage, Infinity);" },
68 { rule: "the seconds ceiling", find: "const seconds = integer(body.seconds, MAX_ROUNDS * SECONDS_PER_ROUND);", replace: "const seconds = integer(body.seconds, Infinity);" },
69 { rule: "a missing number is not a zero", find: "deaths === null || headshots === null || damage === null || seconds === null", replace: "false" },
70 { rule: "the scores are the rounds", find: "if (scoreMine + scoreTheirs !== rounds) return null;", replace: "" },
71 { rule: "headshots are kills", find: "if (headshots > kills) return null;", replace: "" },
72 { rule: "deaths are bounded by rounds", find: "if (deaths > rounds) return null;", replace: "" },
73 { rule: "kills are bounded by the enemies there were", find: "if (kills > rounds * KILLS_PER_ROUND) return null;", replace: "" },
74 { rule: "the clock is bounded by the rounds", find: "if (seconds > (rounds + 1) * SECONDS_PER_ROUND) return null;", replace: "" },
75 { rule: "finished is exactly true", find: "finished: body.finished === true,", replace: "finished: !!body.finished," },
76 { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);`, file: GATE },
77 { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "", file: GATE },
78 { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)", file: GATE },
79 { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "", file: GATE },
80 { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;", file: GATE },
81 { rule: "a win is finished", find: "result.finished && result.scoreMine > result.scoreTheirs", replace: "result.scoreMine > result.scoreTheirs" },
82 { rule: "a draw is not a win", find: "result.scoreMine > result.scoreTheirs", replace: "result.scoreMine >= result.scoreTheirs" },
83 ];
84
85 const originals = new Map<string, string>();
86 for (const file of [RESULT, GATE]) {
87 originals.set(file, await Bun.file(file).text());
88 copyFileSync(file, backupOf(file));
89 }
90 let wrong = 0;
91
92 try {
93 for (const { rule, find, replace, file = RESULT, redundant } of MUTANTS) {
94 const original = originals.get(file) as string;
95 if (!original.includes(find)) {
96 console.log(`? ${rule}: no longer in ${file} — the mutant needs rewriting`);
97 wrong++;
98 continue;
99 }
100 writeFileSync(file, original.replace(find, replace));
101 const run = Bun.spawnSync(["bun", "test", "src/result.test.ts"], { stderr: "pipe", stdout: "pipe" });
102 const log = run.stderr.toString() + run.stdout.toString();
103 const killers = [...log.matchAll(/\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/gm)].map((m) => m[1]?.trim());
104 const died = run.exitCode !== 0;
105 if (died) console.log(`killed ${rule}\n by: ${killers.join("\n by: ")}`);
106 else console.log(`survived ${rule}${redundant === undefined ? "" : `\n expected: ${redundant}`}`);
107 if (died === (redundant !== undefined)) {
108 console.log(died
109 ? ` UNEXPECTED: a line called redundant is not: a test can tell the difference`
110 : ` UNEXPECTED: nothing catches this line being deleted`);
111 wrong++;
112 }
113 // Put this mutant's file back before the next one is broken, so only ever
114 // one rule is missing at a time.
115 writeFileSync(file, original);
116 }
117 } finally {
118 for (const file of [RESULT, GATE]) {
119 copyFileSync(backupOf(file), file);
120 unlinkSync(backupOf(file));
121 }
122 }
123
124 const expected = MUTANTS.filter((m) => m.redundant !== undefined).length;
125 console.log(wrong === 0
126 ? `\nall ${MUTANTS.length - expected} mutants died; the ${expected} noted as redundant survived, as noted`
127 : `\n${wrong} of ${MUTANTS.length} mutants did not do what the list says`);
128 process.exit(wrong === 0 ? 0 : 1);
scripts/pack.test.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * The pack, measured. `scripts/pack.ts` claims the engine is a package: one
3 * door, a name, no dependencies, and complete enough that a game builds on it
4 * with nothing else on disk. A claim like that is only worth what proves it, so
5 * this file takes the package out of the repository for real — into a
6 * directory it makes under the OS temp and removes afterwards, so a failure
7 * never leaves bytes behind for the next run to read — and then builds the
8 * sample against the copy.
9 *
10 * The proof is where the modules came from. The build runs with a plugin that
11 * reads every `.ts` the bundler asks for and records the path it read it from;
12 * if one of them resolved back into this repository, the path says so. A build
13 * that merely *succeeded* would prove nothing, because this repository is
14 * sitting right there.
15 *
16 * The second proof is the surface: the packed door is imported from the temp
17 * copy and its keys are held against the door here, and the names it re-exports
18 * against `SURFACE` in `src/engine/boundary.test.ts` — the one written-down
19 * copy of the engine's API. A pack that dropped a file fails on this line
20 * rather than in somebody else's repository.
21 */
22
23 import { expect, test } from "bun:test";
24 import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
25 import { tmpdir } from "node:os";
26 import { join, relative, resolve } from "node:path";
27 import { pathToFileURL } from "node:url";
28 import { DEFAULT_OUT, GUIDE, NAME, SAMPLE, VERSION, isModule, line, manifest, pack, target } from "./pack.ts";
29 import { SURFACE, reExportedNames } from "../src/engine/boundary.test.ts";
30
31 /**
32 * A directory of this test's own, removed however the body ends. Everything
33 * that writes in this file writes in one of these and nowhere else.
34 */
35 async function inTemp<T>(body: (dir: string) => T | Promise<T>): Promise<T> {
36 const dir = mkdtempSync(join(tmpdir(), "pack-"));
37 try {
38 return await body(dir);
39 } finally {
40 rmSync(dir, { recursive: true, force: true });
41 }
42 }
43
44 /** Is `path` inside `dir` — the whole question this file exists to ask? */
45 export function inside(dir: string, path: string): boolean {
46 const within = relative(resolve(dir), resolve(path));
47 return within !== "" && !within.startsWith("..");
48 }
49
50 test("a path is inside the copy or it is not, and `..` is not a way in", () => {
51 expect(inside("/tmp/pack-1", "/tmp/pack-1/math.ts")).toBe(true);
52 expect(inside("/tmp/pack-1", "/tmp/pack-1/sub/deep.ts")).toBe(true);
53 expect(inside("/tmp/pack-1", "/tmp/pack-1/../repo/src/engine/math.ts")).toBe(false);
54 expect(inside("/tmp/pack-1", "/tmp/pack-10/math.ts")).toBe(false);
55 expect(inside("/tmp/pack-1", "/tmp/pack-1")).toBe(false);
56 });
57
58 test("the manifest is a name, a version, an ES module, one door and no dependencies", () => {
59 expect(manifest()).toEqual({
60 name: NAME,
61 version: VERSION,
62 type: "module",
63 exports: { ".": "./index.ts" },
64 });
65 // Not "dependencies": {} — there is no such key, because there is nothing to
66 // install and nothing that could quietly grow a first entry.
67 expect(Object.keys(manifest()).filter((key) => /dependencies/i.test(key))).toEqual([]);
68 expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
69 });
70
71 test("a module of the package is a module; a test, a probe and the sample are not", () => {
72 expect(isModule("index.ts")).toBe(true);
73 expect(isModule("renderer.ts")).toBe(true);
74 expect(isModule("sub/deep.ts")).toBe(true);
75 expect(isModule("boundary.test.ts")).toBe(false);
76 expect(isModule("purity.probe.ts")).toBe(false);
77 expect(isModule(SAMPLE)).toBe(false);
78 expect(isModule(GUIDE)).toBe(false);
79 });
80
81 test("the directory is the one argument, resolved, and the default is under dist/", () => {
82 expect(target(undefined)).toBe(DEFAULT_OUT);
83 expect(DEFAULT_OUT.endsWith(join("dist", "engine"))).toBe(true);
84 expect(target("dist/elsewhere")).toBe(resolve(import.meta.dir, "..", "dist", "elsewhere"));
85 expect(target("/tmp/pack-fixed")).toBe("/tmp/pack-fixed");
86 expect(() => target("")).toThrow(/cannot be empty/);
87 expect(() => target(" ")).toThrow(/cannot be empty/);
88 });
89
90 test("the pack is every shipping module, the guide, the sample and a manifest — and nothing else", async () => {
91 await inTemp(async (tmp) => {
92 const dir = join(tmp, "made", "here");
93 const packed = pack(dir);
94
95 // It makes the directory it was given, however deep, and writes only there.
96 expect(readdirSync(tmp)).toEqual(["made"]);
97 expect(packed.dir).toBe(dir);
98 expect(readdirSync(dir).sort()).toEqual([...packed.files].sort());
99
100 for (const file of packed.files) expect(existsSync(join(dir, file))).toBe(true);
101 expect(packed.files).toContain("index.ts");
102 expect(packed.files).toContain("renderer.ts");
103 expect(packed.files).toContain(GUIDE);
104 expect(packed.files).toContain(SAMPLE);
105 expect(packed.files).toContain("package.json");
106
107 // Nothing that is not the package came along.
108 for (const file of packed.files) {
109 expect(file.endsWith(".test.ts")).toBe(false);
110 expect(file.endsWith(".probe.ts")).toBe(false);
111 }
112 expect(packed.modules).not.toContain(SAMPLE);
113 expect(packed.modules.length).toBeGreaterThan(15);
114
115 // The manifest on disk is the manifest, parsed rather than trusted.
116 expect(JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))).toEqual(manifest());
117 // The guide is the guide, not a stub.
118 expect(readFileSync(join(dir, GUIDE), "utf8")).toBe(
119 readFileSync(join(import.meta.dir, "..", "src", "engine", GUIDE), "utf8"),
120 );
121 });
122 });
123
124 test("packing into a directory that exists leaves what is already there alone", async () => {
125 await inTemp(async (tmp) => {
126 mkdirSync(tmp, { recursive: true });
127 writeFileSync(join(tmp, "notes.txt"), "mine");
128 const packed = pack(tmp);
129 expect(readdirSync(tmp)).toContain("notes.txt");
130 expect(readFileSync(join(tmp, "notes.txt"), "utf8")).toBe("mine");
131 // And packing twice over itself is the same bytes, not a second copy.
132 const again = pack(tmp);
133 expect(again.files).toEqual(packed.files);
134 });
135 });
136
137 test("the sample builds against the packed door, and every module came out of the copy", async () => {
138 await inTemp(async (dir) => {
139 const packed = pack(dir);
140 const loaded: string[] = [];
141 const out = await Bun.build({
142 entrypoints: [join(dir, SAMPLE)],
143 target: "browser",
144 format: "esm",
145 plugins: [
146 {
147 name: "where-it-came-from",
148 setup(build): void {
149 build.onLoad({ filter: /\.ts$/ }, async (args) => {
150 loaded.push(args.path);
151 return { contents: await Bun.file(args.path).text(), loader: "ts" };
152 });
153 },
154 },
155 ],
156 });
157 expect(out.logs.map(String)).toEqual([]);
158 expect(out.success).toBe(true);
159
160 // The whole point: no path leads back into this repository.
161 const astray = loaded.filter((path) => !inside(dir, path));
162 expect(astray).toEqual([]);
163
164 // And it really pulled the package: the sample, the door, and the modules
165 // behind it — the renderer included, which is the fattest thing in there
166 // and is exactly what a game importing the door gets.
167 const seen = new Set(loaded.map((path) => relative(dir, path)));
168 expect(seen).toContain(SAMPLE);
169 expect(seen).toContain("index.ts");
170 expect(seen).toContain("renderer.ts");
171 expect([...seen].sort()).toEqual([...packed.modules, SAMPLE].sort());
172
173 const code = await (out.outputs[0] as (typeof out.outputs)[number]).text();
174 expect(code.length).toBeGreaterThan(1000);
175 });
176 }, 60_000);
177
178 test("the packed door offers exactly the names this repository's door offers", async () => {
179 await inTemp(async (dir) => {
180 pack(dir);
181 const packedDoor = (await import(pathToFileURL(join(dir, "index.ts")).href)) as Record<string, unknown>;
182 const door = (await import("../src/engine/index.ts")) as Record<string, unknown>;
183
184 // Every value the door here hands out, the copy hands out too, under the
185 // same name: a module that failed to come along is a missing key.
186 expect(Object.keys(packedDoor).sort()).toEqual(Object.keys(door).sort());
187 // A runtime key is a name that is written down; the types are the rest.
188 for (const name of Object.keys(packedDoor)) expect(SURFACE).toContain(name);
189
190 // The written surface, types and all, read off the packed door itself.
191 expect(reExportedNames(readFileSync(join(dir, "index.ts"), "utf8")).sort()).toEqual([...SURFACE].sort());
192
193 // And the copy is a second engine, not a re-export of this one: the same
194 // names, its own function objects, loaded from its own files.
195 expect(typeof packedDoor.clamp).toBe("function");
196 expect(packedDoor.clamp).not.toBe(door.clamp);
197 expect((packedDoor.clamp as (x: number, lo: number, hi: number) => number)(5, 0, 1)).toBe(1);
198 });
199 }, 30_000);
200
201 test("the run prints where the package went and what went into it", () => {
202 const printed = line({ dir: DEFAULT_OUT, modules: ["index.ts", "math.ts"], files: [] });
203 expect(printed).toContain(`${NAME} ${VERSION}`);
204 expect(printed).toContain(join("dist", "engine"));
205 expect(printed).toContain("2 modules");
206 });
scripts/pack.ts added +157
@@ -0,0 +1,157 @@
1 /**
2 * The pack: `src/engine/` written out as a package somebody else could install,
3 * into a directory that holds nothing of this repository but the engine.
4 *
5 * bun run scripts/pack.ts [directory] # default dist/engine
6 *
7 * `boundary.test.ts` proves the engine imports nothing outside itself and
8 * `purity.test.ts` proves it needs no browser to load. This script is the third
9 * claim a package has to make — that it is *complete on its own*, behind one
10 * door, with a name and nothing to install — and `pack.test.ts` beside it is
11 * the measurement: it packs into a directory of its own, builds the sample
12 * against the packed door and asserts every module the bundler loaded came out
13 * of that directory. A path that led back here would fail there rather than in
14 * somebody else's repository.
15 *
16 * What is written, and nothing else:
17 *
18 * - every shipping module under `src/engine/`, with the directory shape it has
19 * here — not a `.test.ts`, which is in no bundle, and not a `.probe.ts`,
20 * which is tooling a test spawns;
21 * - `GUIDE.md`, because a package whose how is in another repository is not one;
22 * - `package.json`, generated below: a name, `VERSION`, an ES module, one entry
23 * point and no dependencies, because the engine has none;
24 * - `example.ts`, the guide's game, beside the modules rather than as one of
25 * them. It goes through the door on purpose — it is the sample a reader
26 * builds first, not a module of the package, which is why `index.ts` does not
27 * offer it and why this script does not count it as one.
28 *
29 * It is a script and not the app: it runs when a person runs it, it never runs
30 * at request time, and it writes only under the directory it was given — which
31 * it creates if it is missing and otherwise leaves alone. Nothing here reads or
32 * writes `FORGE_APP_DATA`. Anything already in the target that this pack does
33 * not write is left where it is; the copy is the files below, not the
34 * directory's whole contents.
35 */
36
37 import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
38 import { dirname, join, relative, resolve } from "node:path";
39 import { VERSION } from "./surface.ts";
40
41 const ROOT = join(import.meta.dir, "..");
42
43 /** Where the package is taken from: the engine, and only the engine. */
44 export const ENGINE = join(ROOT, "src", "engine");
45
46 /** Where it lands when nobody says: under `dist/`, which `.gitignore` covers. */
47 export const DEFAULT_OUT = join(ROOT, "dist", "engine");
48
49 /** The package's name. It is the repository's, because it is its point. */
50 export const NAME = "cs-engine";
51
52 /**
53 * The version written into the generated `package.json`. It means the engine's
54 * surface — the names `index.ts` offers — and it moves when one of them
55 * arrives, leaves or is renamed. This repository keeps no other version and
56 * does not otherwise number its releases: the commit is the real identity of a
57 * pack, and this is what a reader of the package can compare against the guide.
58 *
59 * It is not a constant here, because a number that means a surface and sits
60 * nowhere near one goes stale in the first pull that widens the door. It lives
61 * in `src/engine/surface.json`, committed *with* the sorted list of names it
62 * belongs to; `scripts/surface.ts` reads that record, `scripts/surface.test.ts`
63 * holds today's door against it and says which part of this number to move, and
64 * `src/engine/GUIDE.md` says what a move of it promises a game that packed an
65 * older copy.
66 */
67 export { VERSION };
68
69 /** The guide, which ships: a package whose how lives elsewhere is not one. */
70 export const GUIDE = "GUIDE.md";
71
72 /** The sample, which ships beside the package rather than as a module of it. */
73 export const SAMPLE = "example.ts";
74
75 /** Every `.ts` under `dir`, recursively, as paths relative to it, sorted. */
76 export function sources(dir: string, from = dir): string[] {
77 const out: string[] = [];
78 for (const entry of readdirSync(dir, { withFileTypes: true })) {
79 const path = join(dir, entry.name);
80 if (entry.isDirectory()) out.push(...sources(path, from));
81 else if (entry.name.endsWith(".ts")) out.push(relative(from, path));
82 }
83 return out.sort();
84 }
85
86 /**
87 * Is this file a module of the package? The same three exceptions
88 * `src/engine/boundary.test.ts` knows about, read from this side: a test and a
89 * probe are not code that ships, and the sample is a game written *on* the
90 * package rather than a part of it.
91 */
92 export function isModule(file: string): boolean {
93 return (
94 file.endsWith(".ts") &&
95 !file.endsWith(".test.ts") &&
96 !file.endsWith(".probe.ts") &&
97 file !== SAMPLE
98 );
99 }
100
101 /** The generated `package.json`, as plain data: no dependencies key, none to name. */
102 export function manifest(): Record<string, unknown> {
103 return {
104 name: NAME,
105 version: VERSION,
106 type: "module",
107 exports: { ".": "./index.ts" },
108 };
109 }
110
111 /** Where a pack goes: the one argument, resolved against the repository root. */
112 export function target(arg: string | undefined): string {
113 if (arg === undefined) return DEFAULT_OUT;
114 if (arg.trim() === "") throw new Error("the directory to pack into cannot be empty");
115 return resolve(ROOT, arg);
116 }
117
118 /** What one pack wrote: the directory, the modules in it, and every file in it. */
119 export type Packed = { dir: string; modules: string[]; files: string[] };
120
121 /**
122 * Write the package into `dir`, making it if it is missing, and hand back what
123 * was written. Every path is joined onto `dir`; nothing above it is touched.
124 */
125 export function pack(dir: string = DEFAULT_OUT, from: string = ENGINE): Packed {
126 const modules = sources(from).filter(isModule);
127 if (!modules.includes("index.ts")) throw new Error(`${from} has no index.ts; there is no door to pack`);
128
129 const files: string[] = [];
130 const put = (name: string, text: string): void => {
131 const path = join(dir, name);
132 mkdirSync(dirname(path), { recursive: true });
133 writeFileSync(path, text);
134 files.push(name);
135 };
136
137 mkdirSync(dir, { recursive: true });
138 for (const module of modules) put(module, readFileSync(join(from, module), "utf8"));
139 put(GUIDE, readFileSync(join(from, GUIDE), "utf8"));
140 put(SAMPLE, readFileSync(join(from, SAMPLE), "utf8"));
141 put("package.json", `${JSON.stringify(manifest(), null, 2)}\n`);
142 return { dir, modules, files };
143 }
144
145 /** `cs-engine 0.1.0 → dist/engine (21 modules, the guide, the sample)`. */
146 export function line({ dir, modules }: Packed): string {
147 const where = relative(ROOT, dir) === "" ? dir : relative(ROOT, dir);
148 return `${NAME} ${VERSION} → ${where} (${modules.length} modules, the guide, the sample)`;
149 }
150
151 if (import.meta.main) {
152 const args = process.argv.slice(2);
153 if (args.length > 1) throw new Error("usage: bun run scripts/pack.ts [directory]");
154 const packed = pack(target(args[0]));
155 console.log(line(packed));
156 console.log(` bun build ${join(relative(ROOT, packed.dir), SAMPLE)} # the sample, against the packed door`);
157 }
scripts/room.test.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * The room bench, held to the two things a bench can be wrong about that a
3 * reader would never see: its **arithmetic** and its **subject**.
4 *
5 * The timings themselves are not asserted here and must not be — a number from
6 * a shared box is not a number from production, which is why `scripts/room.ts`
7 * prints the machine it ran on and why the suite's only assertion about speed
8 * is the ceiling in `src/budget.test.ts`. What is asserted is everything
9 * around them:
10 *
11 * - The flags are read or refused with a sentence, so nobody measures a window
12 * of `NaN` seconds and writes the answer in a pull.
13 * - Rooms per core is the division it claims to be, floored, and a margin
14 * narrows it.
15 * - The JSON candidate carries the **same round** the packed one does — ten
16 * bodies with their health and their guns, the grenades, the smoke, the bomb
17 * and the clock — because a comparison between a packed snapshot and a
18 * smaller idea of one would flatter the winner and settle #304 on a lie.
19 * - And the three encodings are measured over a real stepped room, packed
20 * smallest, JSON largest.
21 */
22
23 import { expect, test } from "bun:test";
24 import { GZIP_LEVEL } from "../src/compress.ts";
25 import { useShipped } from "../src/client/shipped.ts";
26 import { createRoom, stepRoom, SNAPSHOT_HZ } from "../src/rooms/room.ts";
27 import { SNAPSHOT_MAX } from "../src/rooms/wire.ts";
28 import {
29 DEFAULTS, RATES, encodings, jsonSnapshot, machine, parseOptions, perMemberPerSecond, roomsPerCore,
30 } from "./room.ts";
31 import type { Player } from "../src/client/world.ts";
32
33 // --- the flags ------------------------------------------------------------
34
35 test("the flags are read, and anything that is not a run is a sentence", () => {
36 expect(parseOptions([])).toEqual(DEFAULTS);
37 expect(parseOptions(["--seconds", "2.5"]).seconds).toBe(2.5);
38 expect(parseOptions(["--members", "4"]).members).toBe(4);
39 expect(parseOptions(["--margin", "0.25"]).margin).toBe(0.25);
40
41 expect(() => parseOptions(["--fast"])).toThrow(/not a flag this script knows/);
42 expect(() => parseOptions(["seconds", "2"])).toThrow(/is not a flag/);
43 expect(() => parseOptions(["--seconds"])).toThrow(/needs a value/);
44 expect(() => parseOptions(["--seconds", "--members"])).toThrow(/needs a value/);
45 expect(() => parseOptions(["--seconds", "soon"])).toThrow(/takes a number/);
46 expect(() => parseOptions(["--seconds", "0"])).toThrow(/above zero/);
47 expect(() => parseOptions(["--members", "11"])).toThrow(/1…10/);
48 expect(() => parseOptions(["--members", "2.5"])).toThrow(/1…10/);
49 expect(() => parseOptions(["--margin", "2"])).toThrow(/share of one core/);
50 expect(() => parseOptions(["--margin", "0"])).toThrow(/share of one core/);
51 });
52
53 // --- the arithmetic -------------------------------------------------------
54
55 test("rooms per core is the division it says it is, floored, and the margin narrows it", () => {
56 // A second of one core is 1000 ms of CPU: a room costing 10 ms of it a second
57 // is a hundredth of a core, so a hundred of them is the core and fifty is
58 // half of it.
59 expect(roomsPerCore(10, 1)).toBe(100);
60 expect(roomsPerCore(10, 0.5)).toBe(50);
61 // Half a room is no room.
62 expect(roomsPerCore(30, 1)).toBe(33);
63 // And a cost nobody measured is not an infinite number of rooms.
64 expect(roomsPerCore(0, 1)).toBe(0);
65 expect(roomsPerCore(-1, 1)).toBe(0);
66 });
67
68 test("bytes a member a second is the frame at the rate, and the rates include the shipped one", () => {
69 expect(perMemberPerSecond(544, 16)).toBe(8704);
70 expect(perMemberPerSecond(544, 32)).toBe(17408);
71 expect(RATES).toEqual([8, SNAPSHOT_HZ, 32]);
72 expect(RATES).toContain(SNAPSHOT_HZ);
73 });
74
75 test("the machine is named, because a number from a laptop is not a number from production", () => {
76 expect(machine()).toContain(`bun ${Bun.version}`);
77 expect(machine()).toContain(process.platform);
78 });
79
80 // --- the subject ----------------------------------------------------------
81
82 /** A room with a few seconds of a live round played into it. */
83 function played(seconds: number) {
84 useShipped();
85 const room = createRoom("TESTRM", { map: "de_forge", seed: 9, length: "short", now: 0 });
86 room.world.match.phase = "live";
87 room.world.match.timer = room.rules.roundTime;
88 for (let i = 0; i < 64 * seconds; i++) stepRoom(room);
89 return room;
90 }
91
92 test("the JSON candidate carries the same round the packed one does", () => {
93 const room = played(3);
94 room.world.bomb.state = "planted";
95 room.world.bomb.site = "A";
96 room.world.clouds.push({ pos: [500, 500, 40], born: room.world.time - 1, owner: 3 });
97 const snapshot = jsonSnapshot(room.world, room.tick, 0) as Record<string, unknown>;
98
99 expect(snapshot.t).toBe(room.tick);
100 expect(snapshot.phase).toBe("live");
101 const bomb = snapshot.bomb as Record<string, unknown>;
102 expect(bomb.state).toBe("planted");
103 expect(bomb.site).toBe("A");
104 expect((snapshot.clouds as unknown[]).length).toBe(1);
105
106 const bodies = snapshot.players as Record<string, unknown>[];
107 expect(bodies.length).toBe(room.world.players.length);
108 bodies.forEach((body, i) => {
109 const p = room.world.players[i] as Player;
110 // Every body, to the hundredth of a unit a JSON wire would round to.
111 expect((body.pos as number[])[0]).toBeCloseTo(p.pos[0], 1);
112 expect(body.health).toBe(Math.round(p.health));
113 expect(body.weapon).toBe(p.weapon);
114 expect(body.alive).toBe(p.alive);
115 expect(body.money).toBe(Math.round(p.money));
116 });
117 });
118
119 test("the three encodings are measured over a real round, and the packed one is the smallest", () => {
120 const room = played(3);
121 const frame = encodings(room.world, room.tick, 0, new Uint8Array(SNAPSHOT_MAX));
122
123 expect(frame.packed).toBeGreaterThan(0);
124 expect(frame.packed).toBeLessThan(frame.gzip);
125 expect(frame.gzip).toBeLessThan(frame.json);
126 // The packed frame is the one the wire sends, so it is the one pinned by
127 // `src/rooms/wire.test.ts`; this only holds the ordering the pull reports.
128 expect(frame.json).toBeGreaterThan(2000);
129
130 // And the gzip is really gzip at the level this server compresses at: a
131 // different level would be a different number in the table.
132 const text = JSON.stringify(jsonSnapshot(room.world, room.tick, 0));
133 expect(frame.gzip).toBe(Bun.gzipSync(Buffer.from(text, "utf8"), { level: GZIP_LEVEL }).byteLength);
134 expect(frame.json).toBe(Buffer.byteLength(text, "utf8"));
135 });
scripts/room.ts added +506
@@ -0,0 +1,506 @@
1 /**
2 * What one room costs, measured:
3 *
4 * bun run scripts/room.ts # the table
5 * bun run scripts/room.ts --seconds 30 # a longer window of play
6 * bun run scripts/room.ts --members 4 # snapshots for four people, not ten
7 * bun run scripts/room.ts --margin 0.25 # a quarter of a core for rooms
8 *
9 * `src/rooms/hub.ts` holds `ROOM_CAP` rooms in this process and the argument for
10 * where that cap is is arithmetic on two numbers: what a world-second of the
11 * simulation costs this process, and what a snapshot of that world weighs on a
12 * wire. This script measures both rather than asserting them, because every
13 * question multiplayer asks after it — how many rooms a deployment holds, how
14 * often a snapshot can go out, whether this process can be the truth at all —
15 * is those two numbers and nothing else.
16 *
17 * It measures a **real room**: `createRoom` from `src/rooms/room.ts`, ten
18 * bodies on de_forge, `stepRoom` at the game's own 64 Hz with the shipped
19 * policy thinking, and `writeSnapshot` on the round that leaves. Nothing here
20 * is a stub and nothing is a model of the game; the only thing it does that a
21 * deployment does not is skip the freeze at the top of the round, because eight
22 * seconds of ten bodies standing still is eight seconds of a tick that is not
23 * the tick anybody is asking about.
24 *
25 * **What it excludes**, said plainly because a bench that hides its edges is
26 * worse than none: the socket. No WebSocket is opened, nothing is written to a
27 * file descriptor, and the kernel's share of sending 8.7 kB a second to ten
28 * members is not in any number below. What is in them is everything this
29 * process computes — the simulation, the input frames off the wire, and the
30 * encoding of every snapshot.
31 *
32 * **It is not a ceiling.** `src/budget.test.ts` holds the ceiling and fails
33 * when a room-second gets dearer; this prints numbers for a person reading
34 * them, and the two are kept apart for the reason that file's header gives.
35 *
36 * It ships in no bundle and the server never imports it: this is a script a
37 * person runs and `room.test.ts` runs the pure parts of. It writes nothing,
38 * anywhere.
39 */
40
41 import { cpus } from "node:os";
42 import { GZIP_LEVEL } from "../src/compress.ts";
43 import { useShipped } from "../src/client/shipped.ts";
44 import { createRoom, drive, join, stepRoom, TICK, SNAPSHOT_HZ, type Room } from "../src/rooms/room.ts";
45 import { writeInput, writeSnapshot, SNAPSHOT_MAX } from "../src/rooms/wire.ts";
46 import { NO_INPUT, type Effect, type Player, type World } from "../src/client/world.ts";
47
48 // --- the options ----------------------------------------------------------
49
50 export type Options = {
51 /** How many simulated seconds one batch of the step is. */
52 seconds: number;
53 /** How many members' snapshots a full room is costed for. */
54 members: number;
55 /** What share of one core rooms are allowed, for the "how many" arithmetic. */
56 margin: number;
57 };
58
59 export const DEFAULTS: Options = { seconds: 10, members: 10, margin: 0.5 };
60
61 const KNOWN = ["--seconds", "--members", "--margin"];
62
63 export const USAGE = `usage: bun run scripts/room.ts [${KNOWN.join("] [")}]`;
64
65 /**
66 * argv (what comes after the script name) in, options out. Anything that is not
67 * a run — an unknown flag, a value that is not a number, a window of zero
68 * seconds, a margin outside 0…1 — throws a sentence naming the flag and what it
69 * got, because a table of numbers measured over `NaN` seconds is worse than an
70 * error.
71 */
72 export function parseOptions(argv: readonly string[]): Options {
73 const options: Options = { ...DEFAULTS };
74 for (let i = 0; i < argv.length; i++) {
75 const flag = argv[i] as string;
76 if (!flag.startsWith("--")) throw new Error(`${JSON.stringify(flag)} is not a flag; ${USAGE}`);
77 if (!KNOWN.includes(flag)) throw new Error(`${flag} is not a flag this script knows; ${USAGE}`);
78 const text = argv[++i];
79 if (text === undefined || text.startsWith("--")) throw new Error(`${flag} needs a value`);
80 const n = Number(text);
81 if (!Number.isFinite(n)) throw new Error(`${flag} takes a number, and got ${JSON.stringify(text)}`);
82 if (flag === "--seconds") {
83 if (n <= 0) throw new Error(`--seconds is a window of play, so it is above zero, and got ${n}`);
84 options.seconds = n;
85 } else if (flag === "--members") {
86 if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error(`--members is 1…10, and got ${JSON.stringify(text)}`);
87 options.members = n;
88 } else {
89 if (n <= 0 || n > 1) throw new Error(`--margin is a share of one core, above 0 and at most 1, and got ${n}`);
90 options.margin = n;
91 }
92 }
93 return options;
94 }
95
96 // --- the arithmetic the numbers are read through --------------------------
97
98 /**
99 * How many rooms one core holds, given what a room-second of CPU costs and the
100 * share of that core rooms may have. A thousand milliseconds of CPU is a second
101 * of one core; a room that costs `each` milliseconds a simulated second takes
102 * that much of it, and the floor is taken because half a room is no room.
103 *
104 * The margin is not conservatism for its own sake: this process also serves
105 * pages, encodes snapshots for everybody, and runs a garbage collector, and a
106 * room that is late does not drop a frame — it judders for ten people.
107 */
108 export const roomsPerCore = (each: number, margin: number): number =>
109 each <= 0 ? 0 : Math.floor((1000 * margin) / each);
110
111 /** What one member is sent a second, in bytes, at a rate of `hz` snapshots. */
112 export const perMemberPerSecond = (bytes: number, hz: number): number => bytes * hz;
113
114 /** The rates this script costs every encoding at. */
115 export const RATES = [8, SNAPSHOT_HZ, 32] as const;
116
117 // --- the two clocks -------------------------------------------------------
118
119 /**
120 * Milliseconds of CPU this process has burned, user and system together.
121 *
122 * Every batch below is timed by this **and** by the wall clock, and the
123 * measurement is the smaller of the two: interference only ever pushes one of
124 * them up — a descheduled process reads a long wall clock, a GC thread on
125 * another core reads a long CPU clock — so the smaller is the tightest bound
126 * the pair honestly supports. It is the same argument `src/budget.test.ts`
127 * makes at length, and the fifteen lines are here rather than imported from
128 * there because a script that imports a test file to run is a worse trade than
129 * fifteen lines that say the same thing.
130 */
131 const cpuMs = (): number => {
132 const used = process.cpuUsage();
133 return (used.user + used.system) / 1000;
134 };
135
136 const median = (xs: number[]): number =>
137 [...xs].sort((a, b) => a - b)[(xs.length - 1) >> 1] as number;
138
139 /** Batches thrown away before any are kept, so nothing measured is cold. */
140 const WARM = 2;
141 /** Batches kept, of which the median is the answer. Odd, so there is one. */
142 const BATCHES = 7;
143
144 export type Measured = { label: string; each: number; wall: number; cpu: number };
145
146 /** One batch's worth of world-seconds, timed one at a time. */
147 const secondsOf = (ticks: number, step: () => void): number[] => {
148 const out: number[] = [];
149 for (let i = 0; i < ticks; i += 64) {
150 const wall = performance.now();
151 const cpu = cpuMs();
152 for (let j = 0; j < Math.min(64, ticks - i); j++) step();
153 out.push(Math.min(performance.now() - wall, cpuMs() - cpu));
154 }
155 return out;
156 };
157
158 /**
159 * The cost of a world-second across a round rather than averaged over one: a
160 * room's first second is ten bots walking out of spawn and its fifth is a
161 * firefight, and a deployment is sized by the dear second and not the cheap
162 * one. `make` hands back a fresh room per batch; `step` takes one tick of it.
163 */
164 export function profile(ticks: number, make: () => () => void): { mean: number; peak: number; at: number } {
165 const means: number[] = [];
166 const peaks: number[] = [];
167 const ats: number[] = [];
168 for (let b = 0; b < WARM + BATCHES; b++) {
169 const each = secondsOf(ticks, make());
170 if (b < WARM) continue;
171 means.push(each.reduce((a, c) => a + c, 0) / each.length);
172 const peak = Math.max(...each);
173 peaks.push(peak);
174 ats.push(each.indexOf(peak) + 1);
175 }
176 return { mean: median(means), peak: median(peaks), at: median(ats) };
177 }
178
179 /**
180 * `runs` of something, warmed and repeated, in milliseconds each. `batch` does
181 * `runs` operations and is called once per batch; the answer is the median of
182 * `BATCHES` kept batches on the tighter of the two clocks.
183 */
184 function measure(label: string, runs: number, batch: () => void): Measured {
185 const walls: number[] = [];
186 const cpus_: number[] = [];
187 for (let i = 0; i < WARM + BATCHES; i++) {
188 const wall = performance.now();
189 const cpu = cpuMs();
190 batch();
191 if (i < WARM) continue;
192 walls.push((performance.now() - wall) / runs);
193 cpus_.push((cpuMs() - cpu) / runs);
194 }
195 const wall = median(walls);
196 const cpu = median(cpus_);
197 return { label, each: Math.min(wall, cpu), wall, cpu };
198 }
199
200 // --- the room being measured ----------------------------------------------
201
202 /** How many bodies a room has. Ten, like the game. */
203 const BODIES = 10;
204
205 /**
206 * A room with its round already under way: the same fixed map and seed every
207 * time, and the freeze set aside for the reason in the header. Fresh per batch,
208 * because a world played for thousands of ticks is a world where people have
209 * died and a dead body does not think — the measurement would drift downwards
210 * into a number about nothing.
211 */
212 function liveRoom(seed = 9): Room {
213 const room = createRoom("BENCH1", { map: "de_forge", seed, length: "short", now: 0 });
214 room.world.match.phase = "live";
215 room.world.match.timer = room.rules.roundTime;
216 return room;
217 }
218
219 /** The same room with every seat held by a member, so no body is left to think. */
220 function heldRoom(seed = 9): { room: Room; ids: string[] } {
221 const room = liveRoom(seed);
222 const ids: string[] = [];
223 for (let i = 0; i < BODIES; i++) {
224 const id = `member-${i}`;
225 if (join(room, id, id, 0) !== null) ids.push(id);
226 }
227 return { room, ids };
228 }
229
230 /** One input frame per member per tick, the way a page sends them: keys down. */
231 function driveAll(room: Room, ids: readonly string[], frames: Uint8Array<ArrayBuffer>[]): void {
232 ids.forEach((id, i) => {
233 writeInput(
234 { tick: room.tick, input: { ...NO_INPUT, forward: 1 }, yaw: 90, pitch: 0, switchCode: 0, buy: null },
235 frames[i] as Uint8Array<ArrayBuffer>,
236 );
237 drive(room, id, frames[i] as Uint8Array);
238 });
239 }
240
241 // --- the three encodings a wire could use ---------------------------------
242
243 /**
244 * The same snapshot as plain data: every field `writeSnapshot` packs, in the
245 * shape a JSON wire would send it. It exists so the comparison below is between
246 * two encodings of *one* frame rather than between a packed snapshot and a
247 * smaller idea of one — the ten bodies, the grenades in the air, the smoke
248 * standing, the bomb, the clock, and the events since the member's last beat.
249 *
250 * Nothing chooses an encoding here. This measures three candidates; the choice
251 * and its reasons are `src/rooms/wire.ts`'s, and a reader comparing the numbers
252 * should read that file's header for what the packed one buys.
253 *
254 * Numbers are rounded the way an honest JSON wire would round them — positions
255 * to a hundredth of a unit, angles to a tenth of a degree — because
256 * `JSON.stringify` of an f32 writes seventeen significant digits and charging
257 * JSON for those would be measuring a strawman.
258 */
259 export function jsonSnapshot(world: World, tick: number, since: number): unknown {
260 const r2 = (v: number): number => Math.round(v * 100) / 100;
261 const r1 = (v: number): number => Math.round(v * 10) / 10;
262 const m = world.match;
263 return {
264 t: tick,
265 time: r2(world.time),
266 phase: m.phase,
267 round: m.round,
268 score: [m.score.t, m.score.ct],
269 timer: r2(m.timer),
270 winner: m.matchWinner,
271 bomb: {
272 state: world.bomb.state,
273 pos: world.bomb.pos.map(r2),
274 plant: r2(world.bomb.plantProgress),
275 defuse: r2(world.bomb.defuseProgress),
276 site: world.bomb.site,
277 },
278 players: world.players.map((p: Player) => ({
279 pos: p.pos.map(r2),
280 vel: p.vel.map(r2),
281 yaw: r1(p.yaw),
282 pitch: r1(p.pitch),
283 health: Math.round(p.health),
284 armor: Math.round(p.armor),
285 alive: p.alive,
286 team: p.team,
287 onGround: p.onGround,
288 ducking: p.ducking,
289 duck: r2(p.duckAmount),
290 bomb: p.hasBomb,
291 helmet: p.helmet,
292 kit: p.defuseKit,
293 scoped: p.scoped,
294 weapon: p.weapon,
295 primary: p.primary,
296 secondary: p.secondary,
297 mag: p.ammo[p.weapon]?.mag ?? 0,
298 reserve: p.ammo[p.weapon]?.reserve ?? 0,
299 money: Math.round(p.money),
300 blind: r2(p.blind),
301 pocket: [p.pocket.he, p.pocket.flash, p.pocket.smoke],
302 kills: p.kills,
303 deaths: p.deaths,
304 damage: Math.round(p.damage),
305 })),
306 grenades: world.grenades.map((g) => ({
307 pos: g.pos.map(r2), weapon: g.weapon, owner: g.owner, team: g.team, fuse: r2(g.fuse),
308 })),
309 clouds: world.clouds.map((s) => ({ pos: s.pos.map(r2), age: r2(world.time - s.born), owner: s.owner })),
310 events: eventsSince(world, since).map((e) => ({ ...e, at: r2(e.at) })),
311 };
312 }
313
314 /** Everything in the world's effect ring younger than `since`, oldest first. */
315 function eventsSince(world: World, since: number): Effect[] {
316 const out: Effect[] = [];
317 const ring = world.effects;
318 for (let i = 0; i < ring.count; i++) {
319 const e = ring.events[i];
320 if (e !== undefined && e.at > since) out.push(e);
321 }
322 return out;
323 }
324
325 export type Frame = { packed: number; json: number; gzip: number };
326
327 /** What one beat weighs in each of the three encodings, in bytes. */
328 export function encodings(world: World, tick: number, since: number, into: Uint8Array): Frame {
329 const packed = writeSnapshot(world, tick, since, into).byteLength;
330 const text = JSON.stringify(jsonSnapshot(world, tick, since));
331 const bytes = Buffer.byteLength(text, "utf8");
332 return { packed, json: bytes, gzip: Bun.gzipSync(Buffer.from(text, "utf8"), { level: GZIP_LEVEL }).byteLength };
333 }
334
335 // --- printing -------------------------------------------------------------
336
337 const ms = (v: number): string => `${v.toFixed(v < 1 ? 3 : 2)} ms`;
338 const kb = (v: number): string => (v < 1000 ? `${Math.round(v)} B` : `${(v / 1000).toFixed(1)} kB`);
339 const pad = (text: string, width: number): string => text.padEnd(width);
340 const rpad = (text: string, width: number): string => text.padStart(width);
341
342 /** Which machine this ran on, because a number from a laptop is not production. */
343 export const machine = (): string =>
344 `${cpus()[0]?.model.trim() ?? "unknown CPU"} × ${cpus().length}` +
345 `, bun ${Bun.version}, ${process.platform}/${process.arch}`;
346
347 async function main(options: Options): Promise<void> {
348 useShipped(); // a bot with no policy does not think, and would not be measured
349 const ticks = Math.round(options.seconds * 64);
350 const seconds = ticks * TICK;
351 const buffer = new Uint8Array(SNAPSHOT_MAX);
352
353 console.log(`what a room costs — ten bodies on de_forge at 64 Hz, ${seconds.toFixed(2)} simulated seconds a batch`);
354 console.log(` ${machine()}`);
355 console.log(` median of ${BATCHES} batches after ${WARM}, on the tighter of the wall and CPU clocks\n`);
356
357 // --- the step ---
358 // The rooms of every batch are kept so the round each measurement really
359 // played can be asked afterwards how many bodies were still standing in it.
360 const played: Room[] = [];
361 const thinking = profile(ticks, () => {
362 const room = liveRoom();
363 played.push(room);
364 return () => stepRoom(room);
365 });
366 const aliveAfter = (played[played.length - 1] as Room).world.players.filter((p) => p.alive).length;
367
368 // The other half of the pair, and the only way this game has of stepping a
369 // world with no policy in the loop: a bot without one does not think, it
370 // throws (`policy()` in `src/client/bots.ts`), so "the policy unloaded" is a
371 // room whose every seat is held by a member sending keys.
372 const frames = Array.from({ length: BODIES }, () => new Uint8Array(14));
373 const idle = profile(ticks, () => {
374 const { room, ids } = heldRoom();
375 return () => {
376 driveAll(room, ids, frames);
377 stepRoom(room);
378 };
379 });
380
381 // What the wire itself costs inside that number: ten input frames written and
382 // read every tick, which the thinking room above does not pay at all.
383 const wire = measure(" of which the input frames off the wire", seconds, () => {
384 const { room, ids } = heldRoom();
385 for (let i = 0; i < ticks; i++) driveAll(room, ids, frames);
386 });
387
388 console.log("CPU per simulated world-second");
389 console.log(` ${pad("", 40)}${rpad("average", 10)}${rpad("dearest second", 18)}`);
390 const step = (label: string, m: { mean: number; peak: number; at: number }): void =>
391 console.log(` ${pad(label, 40)}${rpad(ms(m.mean), 10)}${rpad(`${ms(m.peak)} (no. ${m.at})`, 18)}`);
392 step("ten bots thinking, the shipped policy", thinking);
393 step("ten seats driven, nothing thinking", idle);
394 console.log(` ${pad(wire.label, 40)}${rpad(ms(wire.each), 10)}`);
395 // Ten bodies are stepped either way; what the first row has and the second
396 // does not is ten policies thinking, and what the second has and the first
397 // does not is the wire. So the policy's share is the difference, with the
398 // wire handed back — which is an estimate and is called one: bots that think
399 // also shoot, throw and die, and a body walking forward into a wall does not.
400 const share = thinking.mean - (idle.mean - wire.each);
401 console.log(
402 ` ${pad("so the ten policies cost about", 40)}${rpad(ms(share), 10)}` +
403 ` (${((share / thinking.mean) * 100).toFixed(0)}% of a thinking room)`,
404 );
405 console.log(
406 ` ${aliveAfter} of ${BODIES} bodies were still alive after ${seconds.toFixed(1)}s` +
407 ` of the thinking round, which is what the numbers above are seconds of\n`,
408 );
409
410 // --- the snapshot, over a real round ---
411 const beats: Frame[] = [];
412 {
413 const room = liveRoom();
414 const every = Math.max(1, Math.round(64 / SNAPSHOT_HZ));
415 let since = 0;
416 for (let i = 0; i < ticks; i++) {
417 stepRoom(room);
418 if (i % every !== 0) continue;
419 beats.push(encodings(room.world, room.tick, since, buffer));
420 since = room.world.time;
421 }
422 }
423 const pick = (of: (f: Frame) => number): { med: number; max: number } => ({
424 med: median(beats.map(of)),
425 max: Math.max(...beats.map(of)),
426 });
427 const sizes = {
428 "packed binary": pick((f) => f.packed),
429 "JSON": pick((f) => f.json),
430 [`JSON gzipped (level ${GZIP_LEVEL})`]: pick((f) => f.gzip),
431 };
432
433 console.log(`a snapshot of this round, over ${beats.length} beats at ${SNAPSHOT_HZ} Hz`);
434 for (const [name, size] of Object.entries(sizes))
435 console.log(` ${pad(name, 30)} median ${rpad(kb(size.med), 8)} widest ${rpad(kb(size.max), 8)}`);
436 console.log();
437
438 console.log(`bytes a member a second, at the median beat`);
439 console.log(` ${pad("", 30)}${RATES.map((hz) => rpad(`${hz} Hz`, 12)).join("")}`);
440 for (const [name, size] of Object.entries(sizes))
441 console.log(
442 ` ${pad(name, 30)}` +
443 RATES.map((hz) => rpad(`${kb(perMemberPerSecond(size.med, hz))}/s`, 12)).join(""),
444 );
445 console.log();
446
447 // --- what encoding a beat costs this process ---
448 const busy = liveRoom();
449 for (let i = 0; i < ticks; i++) stepRoom(busy);
450 // One beat's worth of "since", not the whole ring: what a member is sent
451 // sixteen times a second is the round plus what happened in the last
452 // sixteenth of it, and costing them the whole ring would be costing a frame
453 // nobody is ever sent.
454 const since = busy.world.time - 1 / SNAPSHOT_HZ;
455 const runs = 200;
456 const encode = [
457 measure("packed binary", runs, () => {
458 for (let i = 0; i < runs; i++) writeSnapshot(busy.world, busy.tick, since, buffer);
459 }),
460 measure("JSON", runs, () => {
461 for (let i = 0; i < runs; i++) JSON.stringify(jsonSnapshot(busy.world, busy.tick, since));
462 }),
463 measure(`JSON gzipped (level ${GZIP_LEVEL})`, runs, () => {
464 for (let i = 0; i < runs; i++)
465 Bun.gzipSync(Buffer.from(JSON.stringify(jsonSnapshot(busy.world, busy.tick, since)), "utf8"), { level: GZIP_LEVEL });
466 }),
467 ];
468 console.log("CPU to encode one beat, and what that is a world-second for a full room");
469 for (const m of encode)
470 console.log(
471 ` ${pad(m.label, 30)} ${rpad(ms(m.each), 9)} a beat` +
472 ` ${rpad(ms(m.each * SNAPSHOT_HZ * options.members), 9)} a world-second` +
473 ` (${options.members} members at ${SNAPSHOT_HZ} Hz)`,
474 );
475 console.log();
476
477 // --- the answer ---
478 const packedCost = (encode[0] as Measured).each * SNAPSHOT_HZ * options.members;
479 const whole = thinking.mean + packedCost;
480 const dearest = thinking.peak + packedCost;
481 const row = (label: string, each: number): void =>
482 console.log(
483 ` ${pad(label, 46)} ${rpad(String(roomsPerCore(each, options.margin)), 4)} rooms` +
484 ` (${roomsPerCore(each, 1)} flat out, at ${ms(each)} a room-second)`,
485 );
486 console.log(`rooms one core holds at 64 Hz, with ${((1 - options.margin) * 100).toFixed(0)}% of it left for everything else`);
487 row("the step alone, bots thinking", thinking.mean);
488 row("the step alone, nobody thinking", idle.mean);
489 row(`a whole room: step and ${options.members} packed snapshots`, whole);
490 row("the same, priced at the round's dearest second", dearest);
491 console.log(
492 `\none room costs ${ms(whole)} of CPU a second — the step ${ms(thinking.mean)}` +
493 ` and ${options.members} snapshots ${ms(packedCost)} — so one core holds ${roomsPerCore(whole, options.margin)}` +
494 ` of them at ${SNAPSHOT_HZ} Hz with ${((1 - options.margin) * 100).toFixed(0)}% of the core to spare,` +
495 ` or ${roomsPerCore(dearest, options.margin)} if every one of them is having its dearest second at once.`,
496 );
497 }
498
499 if (import.meta.main) {
500 try {
501 await main(parseOptions(process.argv.slice(2)));
502 } catch (error) {
503 console.error(error instanceof Error ? error.message : String(error));
504 process.exit(1);
505 }
506 }
scripts/surface.test.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * The version, enforced. `src/engine/surface.json` says which names the door
3 * offered when the version in it was set, and `scripts/pack.ts` stamps that
4 * version into every pack. This file is what makes the number true: it reads
5 * today's door, holds it against the record, and fails when the surface moved
6 * and the number did not — naming the names that arrived or left, saying which
7 * part of the version that moves, and printing the one step that records it.
8 *
9 * The rest of this file is that message and that arithmetic, tested on made-up
10 * surfaces rather than on the real one, so the promises hold for a change that
11 * has not happened yet.
12 */
13
14 import { expect, test } from "bun:test";
15 import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16 import { tmpdir } from "node:os";
17 import { join } from "node:path";
18 import {
19 COMMAND,
20 DOOR,
21 RECORD,
22 VERSION,
23 bump,
24 changed,
25 doorNames,
26 isVersion,
27 line,
28 moved,
29 parse,
30 read,
31 render,
32 today,
33 verdict,
34 write,
35 } from "./surface.ts";
36 import { manifest } from "./pack.ts";
37 import { SURFACE, reExportedNames } from "../src/engine/boundary.test.ts";
38
39 /** A record of this test's own, removed however the body ends. */
40 function inTemp<T>(body: (path: string) => T): T {
41 const dir = mkdtempSync(join(tmpdir(), "surface-"));
42 try {
43 return body(join(dir, "surface.json"));
44 } finally {
45 rmSync(dir, { recursive: true, force: true });
46 }
47 }
48
49 const seed = (path: string, version: string, names: string[]): string => {
50 writeFileSync(path, render({ version, names }));
51 return path;
52 };
53
54 // --- the record on disk ---------------------------------------------------
55
56 test("today's door is the surface the recorded version belongs to", () => {
57 const record = read();
58 const why = verdict(today(), record);
59 // Thrown rather than asserted, the way a pinned run's complaint is thrown in
60 // `src/range/pin.test.ts`: the message is the whole point of this file, so a
61 // failure here prints it entire rather than as a diff of two hundred names.
62 if (why !== null) throw new Error(why);
63 expect(today()).toEqual(record.names);
64 expect(record.names.length).toBeGreaterThan(50);
65 });
66
67 test("the version a pack is stamped with is the version the record pins", () => {
68 expect(VERSION).toBe(read().version);
69 expect(manifest().version).toBe(read().version);
70 expect(isVersion(VERSION)).toBe(true);
71 });
72
73 test("the record is exactly the bytes a write produces, so a diff is the names", () => {
74 const text = readFileSync(RECORD, "utf8");
75 expect(text).toBe(render(read()));
76 // Data, not prose: two keys, and a name to a line.
77 expect(Object.keys(JSON.parse(text) as object).sort()).toEqual(["names", "version"]);
78 // One name to a line: the two keys, the brackets, the braces and the names.
79 expect(text.trimEnd().split("\n").length).toBe(read().names.length + 5);
80 expect(text.endsWith("]\n}\n")).toBe(true);
81 });
82
83 test("the surface the record holds is the surface the engine's own test pins", () => {
84 // `boundary.test.ts` reads this record for `SURFACE` — one written-down copy
85 // of the API — and reads the door with its own regular expression, which may
86 // not import this one. Both readings, on the real door, agree here or never.
87 expect(SURFACE).toEqual(read().names);
88 expect(doorNames(readFileSync(DOOR, "utf8"))).toEqual(
89 reExportedNames(readFileSync(DOOR, "utf8")).sort(),
90 );
91 });
92
93 test("a type-only export is in the record, because a game imports types too", () => {
94 const record = read().names;
95 // `Body` and `Hull` are types; `move` is a function. All three are promises.
96 expect(record).toContain("Body");
97 expect(record).toContain("Hull");
98 expect(record).toContain("move");
99 });
100
101 // --- reading the record ---------------------------------------------------
102
103 test("the record is checked rather than trusted", () => {
104 expect(parse(`{"version":"1.2.3","names":["a","b"]}`)).toEqual({ version: "1.2.3", names: ["a", "b"] });
105 expect(() => parse("{")).toThrow(/is not JSON/);
106 expect(() => parse("[]")).toThrow(/is not an object/);
107 expect(() => parse(`{"names":["a"]}`)).toThrow(/a version and a list of names/);
108 expect(() => parse(`{"version":"1.0.0","names":["a"],"note":"why"}`)).toThrow(/a version and a list of names/);
109 expect(() => parse(`{"version":"1.0","names":["a"]}`)).toThrow(/three numbers/);
110 expect(() => parse(`{"version":"1.0.0","names":[]}`)).toThrow(/no names/);
111 expect(() => parse(`{"version":"1.0.0","names":[3]}`)).toThrow(/not a name/);
112 expect(() => parse(`{"version":"1.0.0","names":["a","a"]}`)).toThrow(/twice/);
113 expect(() => parse(`{"version":"1.0.0","names":["b","a"]}`)).toThrow(/not sorted/);
114 });
115
116 test("a version is three numbers and two dots", () => {
117 expect(isVersion("0.1.0")).toBe(true);
118 expect(isVersion("10.20.30")).toBe(true);
119 expect(isVersion("1.0")).toBe(false);
120 expect(isVersion("v1.0.0")).toBe(false);
121 expect(isVersion("1.0.0-beta")).toBe(false);
122 });
123
124 // --- what moved, and what that asks of the number -------------------------
125
126 test("what arrived and what left, and a rename is both", () => {
127 expect(changed(["a", "b"], ["a", "b"])).toEqual({ added: [], removed: [] });
128 expect(changed(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] });
129 expect(changed(["a", "b"], ["a"])).toEqual({ added: [], removed: ["b"] });
130 expect(changed(["move"], ["walk"])).toEqual({ added: ["walk"], removed: ["move"] });
131 expect(moved(changed(["a"], ["a"]))).toBe(false);
132 expect(moved(changed(["a"], ["a", "b"]))).toBe(true);
133 });
134
135 test("a name that arrived moves the middle number; one that left moves the first", () => {
136 expect(bump("0.1.0", { added: ["b"], removed: [] })).toBe("0.2.0");
137 expect(bump("1.4.2", { added: ["b"], removed: [] })).toBe("1.5.0");
138 expect(bump("0.1.0", { added: [], removed: ["a"] })).toBe("1.0.0");
139 // A rename is a removal wearing an arrival, and the removal decides.
140 expect(bump("0.1.0", { added: ["walk"], removed: ["move"] })).toBe("1.0.0");
141 expect(bump("0.1.0", { added: [], removed: [] })).toBe("0.1.0");
142 expect(() => bump("0.1", { added: [], removed: [] })).toThrow(/not a version/);
143 });
144
145 // --- the message ----------------------------------------------------------
146
147 test("a surface that did not move says nothing at all", () => {
148 expect(verdict(["a", "b"], { version: "0.1.0", names: ["a", "b"] })).toBeNull();
149 });
150
151 test("a name that arrived is named, with the middle number and the step to take", () => {
152 const why = verdict(["a", "b", "sway"], { version: "0.1.0", names: ["a", "b"] }) as string;
153 expect(why).toContain("1 name arrived: `sway`.");
154 expect(why).not.toContain("left:");
155 expect(why).toContain("still builds");
156 expect(why).toContain("the middle number moves: 0.1.0 → 0.2.0");
157 expect(why).toContain(`${COMMAND} 0.2.0`);
158 expect(why).toContain("src/engine/surface.json");
159 expect(why).toContain("src/engine/GUIDE.md");
160 });
161
162 test("a name that left is named, with the first number and the step to take", () => {
163 const why = verdict(["a"], { version: "0.1.0", names: ["a", "b"] }) as string;
164 expect(why).toContain("1 name left: `b`.");
165 expect(why).toContain("stop a game that packed an older copy from building");
166 expect(why).toContain("the first number moves: 0.1.0 → 1.0.0");
167 expect(why).toContain(`${COMMAND} 1.0.0`);
168 });
169
170 test("a rename says both halves, and the removal decides the number", () => {
171 const why = verdict(["walk"], { version: "2.3.4", names: ["move"] }) as string;
172 expect(why).toContain("1 name arrived: `walk`.");
173 expect(why).toContain("1 name left: `move`.");
174 expect(why).toContain("2.3.4 → 3.0.0");
175 expect(why).toContain(`${COMMAND} 3.0.0`);
176 });
177
178 test("several names read as a sentence, not as a diff", () => {
179 const why = verdict(["a", "b", "c", "d"], { version: "0.1.0", names: ["a"] }) as string;
180 expect(why).toContain("3 names arrived: `b`, `c` and `d`.");
181 });
182
183 // --- the one step ---------------------------------------------------------
184
185 test("the write records today's names, sorted, against the version it was given", () => {
186 inTemp((path) => {
187 seed(path, "0.1.0", ["a", "b"]);
188 const written = write(["sway", "a", "b"], "0.2.0", path);
189 expect(written).toEqual({ version: "0.2.0", names: ["a", "b", "sway"] });
190 expect(read(path)).toEqual(written);
191 expect(readFileSync(path, "utf8")).toBe(render(written));
192 expect(verdict(["a", "b", "sway"], read(path))).toBeNull();
193 });
194 });
195
196 test("a write that leaves the number where it was when the surface moved is refused", () => {
197 inTemp((path) => {
198 seed(path, "0.1.0", ["a", "b"]);
199 expect(() => write(["a", "b", "sway"], "0.1.0", path)).toThrow(/asks for 0\.2\.0/);
200 expect(() => write(["a"], "0.1.0", path)).toThrow(/asks for 1\.0\.0/);
201 expect(() => write(["a", "b"], "0.1", path)).toThrow(/not a version/);
202 // The refusals left the record alone.
203 expect(read(path)).toEqual({ version: "0.1.0", names: ["a", "b"] });
204 // A surface that did not move may be rewritten at the same version.
205 expect(write(["a", "b"], "0.1.0", path).version).toBe("0.1.0");
206 });
207 });
208
209 test("the run prints the version and how many names it covers", () => {
210 expect(line({ version: "0.1.0", names: ["a", "b"] }, "src/engine/surface.json")).toBe(
211 "0.1.0 — 2 names behind the door, recorded in src/engine/surface.json",
212 );
213 });
scripts/surface.ts added +241
@@ -0,0 +1,241 @@
1 /**
2 * The engine's surface, recorded against the version it belongs to.
3 *
4 * bun run scripts/surface.ts # is today's door the recorded one?
5 * bun run scripts/surface.ts --write 0.2.0 # record today's door as that version
6 *
7 * `scripts/pack.ts` writes a version into the `package.json` it generates, and
8 * that number means one thing: the names `src/engine/index.ts` offers. A number
9 * like that is a promise to somebody who packed an older copy, and a promise
10 * nothing checks is a lie waiting for the first pull that widens the door. So
11 * the surface and the version are committed *together*, as one record —
12 * `src/engine/surface.json`, the sorted list of names and the version they
13 * belong to, and nothing else — and `scripts/surface.test.ts` holds today's
14 * door against it on every run of the tests.
15 *
16 * The rule the record enforces, in one line each, and written out for a reader
17 * of the package in `src/engine/GUIDE.md`:
18 *
19 * - a name arrives and none leaves → the middle number moves. A game that
20 * packed an older copy still builds: every name it imported is still here;
21 * - a name leaves or is renamed → the first number moves. A game that packed an
22 * older copy may stop building, and the number is the warning;
23 * - the surface stands still → so does the version, however much behaviour
24 * moved underneath it. The commit is the real identity of a pack.
25 *
26 * Type-only exports are in the record, because a game that imports a type from
27 * this door is a game the surface is a promise to — the same list, types and
28 * all, that `src/engine/boundary.test.ts` has always pinned the door against.
29 * That file reads this record for its `SURFACE`, so there is one written-down
30 * copy of the engine's API in this repository rather than two that drift.
31 *
32 * It is a script and not the app: it runs when a person runs it, it never runs
33 * at request time, it reads two files in the repository and writes exactly one
34 * — the record — and only when told to. Nothing here reads or writes
35 * `FORGE_APP_DATA`.
36 */
37
38 import { readFileSync, writeFileSync } from "node:fs";
39 import { join, relative } from "node:path";
40
41 const ROOT = join(import.meta.dir, "..");
42
43 /** The door the surface is read off: the engine's one entry point. */
44 export const DOOR = join(ROOT, "src", "engine", "index.ts");
45
46 /** The committed record. Data, beside the door whose names it holds. */
47 export const RECORD = join(ROOT, "src", "engine", "surface.json");
48
49 /** The step a builder takes when the surface moves, as it is typed. */
50 export const COMMAND = "bun run scripts/surface.ts --write";
51
52 /** A surface, pinned: the names, sorted, and the version they belong to. */
53 export type Surface = { version: string; names: string[] };
54
55 /** Three numbers and two dots. This repository numbers nothing else. */
56 export const isVersion = (version: string): boolean => /^\d+\.\d+\.\d+$/.test(version);
57
58 /**
59 * The record, parsed and checked rather than trusted: a version of the right
60 * shape, a non-empty list of distinct names in sorted order, and no third key,
61 * because a record that grows prose stops being readable in a diff.
62 */
63 export function parse(text: string, where = RECORD): Surface {
64 let data: unknown;
65 try {
66 data = JSON.parse(text);
67 } catch (error) {
68 throw new Error(`${where} is not JSON: ${(error as Error).message}`);
69 }
70 if (data === null || typeof data !== "object" || Array.isArray(data))
71 throw new Error(`${where} is not an object`);
72 const record = data as Record<string, unknown>;
73 const keys = Object.keys(record).sort();
74 if (keys.join(",") !== "names,version")
75 throw new Error(`${where} holds ${keys.join(", ") || "nothing"}; a record is a version and a list of names`);
76 const { version, names } = record;
77 if (typeof version !== "string" || !isVersion(version))
78 throw new Error(`${where} has version ${JSON.stringify(version)}; a version is three numbers, like 0.1.0`);
79 if (!Array.isArray(names) || names.length === 0)
80 throw new Error(`${where} has no names; the door offers some`);
81 for (const name of names)
82 if (typeof name !== "string" || name === "")
83 throw new Error(`${where} holds ${JSON.stringify(name)}, which is not a name`);
84 const list = names as string[];
85 if (new Set(list).size !== list.length) throw new Error(`${where} names something twice`);
86 if ([...list].sort().join(",") !== list.join(","))
87 throw new Error(`${where} is not sorted; the record is a sorted list so a diff reads as one`);
88 return { version, names: list };
89 }
90
91 /** The record as it is committed. Read once: a stale one helps nobody. */
92 export function read(path = RECORD): Surface {
93 return parse(readFileSync(path, "utf8"), path);
94 }
95
96 /**
97 * The version a pack is stamped with, read from the record rather than typed
98 * beside it. `scripts/pack.ts` re-exports this as its own.
99 */
100 export const VERSION: string = read().version;
101
102 /**
103 * The names a door offers: everything inside the braces of an `export { … }
104 * from` or `export type { … } from`, under the name it leaves by, sorted.
105 *
106 * `src/engine/boundary.test.ts` reads the door the same way and may not import
107 * this file — a module in the engine imports nothing outside the engine, and
108 * that boundary is the point of it. So the reading is written twice on purpose,
109 * and `scripts/surface.test.ts` holds the two readings against each other on
110 * the real door, where a difference between them would matter.
111 */
112 export function doorNames(source: string): string[] {
113 const out: string[] = [];
114 const pattern = /\bexport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']+["']/g;
115 for (let m = pattern.exec(source); m !== null; m = pattern.exec(source))
116 for (const clause of (m[1] as string).split(",")) {
117 const name = clause.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop()?.trim();
118 if (name !== undefined && name !== "") out.push(name);
119 }
120 return out.sort();
121 }
122
123 /** Today's surface, read off the door. */
124 export function today(path = DOOR): string[] {
125 return doorNames(readFileSync(path, "utf8"));
126 }
127
128 /** What moved between a recorded surface and today's: names, not counts. */
129 export type Change = { added: string[]; removed: string[] };
130
131 /** Which names arrived and which left, sorted, between `was` and `now`. */
132 export function changed(was: readonly string[], now: readonly string[]): Change {
133 const before = new Set(was);
134 const after = new Set(now);
135 return {
136 added: now.filter((name) => !before.has(name)).sort(),
137 removed: was.filter((name) => !after.has(name)).sort(),
138 };
139 }
140
141 /** Did anything move at all? A rename is both halves of one. */
142 export const moved = (change: Change): boolean => change.added.length + change.removed.length > 0;
143
144 /**
145 * The version that change asks for. A name that left — which is half of every
146 * rename — can break a game that packed an older copy, so the first number
147 * moves; a name that only arrived cannot, so the middle one does. Nothing moved
148 * means nothing moves.
149 */
150 export function bump(version: string, change: Change): string {
151 if (!isVersion(version)) throw new Error(`${version} is not a version`);
152 const [major = 0, minor = 0] = version.split(".").map(Number);
153 if (change.removed.length > 0) return `${major + 1}.0.0`;
154 if (change.added.length > 0) return `${major}.${minor + 1}.0`;
155 return version;
156 }
157
158 /** `` `a`, `b` and `c` `` — names in a sentence, quoted the way a message is. */
159 const names = (list: readonly string[]): string => {
160 const quoted = list.map((name) => `\`${name}\``);
161 if (quoted.length <= 1) return quoted.join("");
162 return `${quoted.slice(0, -1).join(", ")} and ${quoted[quoted.length - 1] as string}`;
163 };
164
165 /**
166 * Why today's door and the record disagree, or null if they do not. A test that
167 * only says "no" sends a reader to a diff of two hundred names: this one names
168 * what arrived and what left, says which part of the version that moves and
169 * why, and prints the one step that records today's surface against it.
170 */
171 export function verdict(now: readonly string[], record: Surface, where = relative(ROOT, RECORD)): string | null {
172 const change = changed(record.names, now);
173 if (!moved(change)) return null;
174 const next = bump(record.version, change);
175 const lines = [
176 `The engine's door no longer offers the surface recorded in ${where}, which version ${record.version} belongs to.`,
177 ];
178 if (change.added.length > 0)
179 lines.push(`${change.added.length} name${change.added.length === 1 ? "" : "s"} arrived: ${names(change.added)}.`);
180 if (change.removed.length > 0)
181 lines.push(`${change.removed.length} name${change.removed.length === 1 ? "" : "s"} left: ${names(change.removed)}.`);
182 lines.push(
183 change.removed.length > 0
184 ? `A name that left or was renamed can stop a game that packed an older copy from building, ` +
185 `so the first number moves: ${record.version} → ${next}.`
186 : `Nothing left, so a game that packed an older copy still builds, ` +
187 `and the middle number moves: ${record.version} → ${next}.`,
188 `If that is what this pull means to do, record today's surface against that version in one step:`,
189 ``,
190 ` ${COMMAND} ${next}`,
191 ``,
192 `That rewrites ${where}. ${relative(ROOT, join(ROOT, "src", "engine", "GUIDE.md"))} says what the move promises.`,
193 );
194 return lines.join("\n");
195 }
196
197 /** The record as bytes: sorted names, one to a line, and a trailing newline. */
198 export function render(surface: Surface): string {
199 return `${JSON.stringify({ version: surface.version, names: [...surface.names].sort() }, null, 2)}\n`;
200 }
201
202 /**
203 * Write the record: today's names against the version the builder named. The
204 * version has to move when the surface did — that is the whole promise — so a
205 * write that leaves it where it was is refused with the number it should be.
206 */
207 export function write(now: readonly string[], version: string, path = RECORD): Surface {
208 if (!isVersion(version)) throw new Error(`${version} is not a version: three numbers, like 0.2.0`);
209 const was = read(path);
210 const change = changed(was.names, now);
211 if (moved(change) && version === was.version)
212 throw new Error(
213 `the surface moved and ${version} is where it already was; ` +
214 `this change asks for ${bump(was.version, change)}`,
215 );
216 const surface: Surface = { version, names: [...now].sort() };
217 writeFileSync(path, render(surface));
218 return surface;
219 }
220
221 /** `cs-engine 0.1.0 — 184 names behind the door, recorded in src/engine/surface.json`. */
222 export function line(surface: Surface, where = relative(ROOT, RECORD)): string {
223 return `${surface.version} — ${surface.names.length} names behind the door, recorded in ${where}`;
224 }
225
226 if (import.meta.main) {
227 const args = process.argv.slice(2);
228 const now = today();
229 if (args.length === 0) {
230 const why = verdict(now, read());
231 if (why !== null) {
232 console.error(why);
233 process.exit(1);
234 }
235 console.log(line(read()));
236 } else if (args[0] === "--write" && args.length === 2) {
237 console.log(line(write(now, args[1] as string)));
238 } else {
239 throw new Error(`usage: bun run scripts/surface.ts [--write <version>]`);
240 }
241 }
scripts/train.test.ts added +162
@@ -0,0 +1,162 @@
1 /**
2 * The trainer's two ends, checked as data in and data out: the flags a run is
3 * asked for, and the module a run writes. Nothing here trains — the twelve
4 * minutes between the two is not a thing a test suite should spend, and the
5 * duels that grade the shipped weights are `src/rl/learned.test.ts`'s.
6 *
7 * The test that matters is the last one: `cardModule` handed what
8 * `src/client/weights.ts` exports today gives that file back character for
9 * character. It says at once that the generator's formatting is what is in the
10 * tree, that the file in the tree was written by this generator and not by
11 * hand, and that a retrain writes a file shaped exactly like the one it
12 * replaces. `src/client/weights.test.ts` is the other half: it holds the file
13 * that is actually in the tree to the numbers it claims.
14 *
15 * `../src/client/packed.ts` is imported before `./train.ts` on purpose, and
16 * the order is load-bearing until somebody breaks the cycle it works around:
17 * `packed.ts` reads `OBS_SIZE` out of `observe.ts` while it is initialising,
18 * and `observe.ts` → `world.ts` → `bots.ts` → `packed.ts` closes the ring, so
19 * a graph entered at the `observe.ts` end — which is what `./train.ts` is, one
20 * import further on — runs `packed.ts`'s body before that constant exists and
21 * throws `Cannot access 'OBS_SIZE' before initialization`. Entered here, this
22 * file loads on its own as well as in a suite. Not this pull's to fix.
23 */
24
25 import { expect, test } from "bun:test";
26 import { mkdtempSync, writeFileSync } from "node:fs";
27 import { tmpdir } from "node:os";
28 import { join, resolve } from "node:path";
29 import { parsePacked } from "../src/client/packed.ts";
30 import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts";
31 import { PACKED } from "../src/client/shipped.ts";
32 import { CARD } from "../src/client/weights.ts";
33
34 const SOURCE = join(import.meta.dir, "..", "src", "client", "weights.ts");
35
36 /** What `parseOptions` gives back for these flags, as a difference from the defaults. */
37 const options = (...argv: string[]): Options => parseOptions(argv);
38
39 test("no flags is the run the README documents, writing the file the game ships", () => {
40 expect(options()).toEqual(DEFAULTS);
41 expect(DEFAULTS.out).toBe(WEIGHTS);
42 expect(WEIGHTS).toBe(SOURCE);
43 // The defaults are a real run, not placeholders.
44 expect(DEFAULTS.iters).toBeGreaterThan(0);
45 expect(DEFAULTS.envs).toBeGreaterThan(0);
46 expect(DEFAULTS.maps).toBeGreaterThan(1);
47 expect(DEFAULTS.holdout).toBeGreaterThan(0);
48 expect(DEFAULTS.foe).toBe("mixed");
49 expect(DEFAULTS.bench).toBe(false);
50 expect(DEFAULTS.dry).toBe(false);
51 });
52
53 test("every flag the trainer takes lands on the option it names", () => {
54 expect(options("--bench")).toEqual({ ...DEFAULTS, bench: true });
55 expect(options("--dry")).toEqual({ ...DEFAULTS, dry: true });
56 expect(options("--iters", "300")).toEqual({ ...DEFAULTS, iters: 300 });
57 expect(options("--envs", "8", "--steps", "4")).toEqual({ ...DEFAULTS, envs: 8, steps: 4 });
58 expect(options("--maps", "1")).toEqual({ ...DEFAULTS, maps: 1 });
59 expect(options("--holdout", "3")).toEqual({ ...DEFAULTS, holdout: 3 });
60 expect(options("--foe", "scripted")).toEqual({ ...DEFAULTS, foe: "scripted" });
61 expect(options("--foe", "still")).toEqual({ ...DEFAULTS, foe: "still" });
62 // A seed is a label, not a size, so zero is one.
63 expect(options("--seed", "0")).toEqual({ ...DEFAULTS, seed: 0 });
64 // The order they are written in is not a meaning, and the last one wins.
65 expect(options("--dry", "--iters", "7")).toEqual(options("--iters", "7", "--dry"));
66 expect(options("--iters", "7", "--iters", "9").iters).toBe(9);
67 });
68
69 test("a count that is not a count is a sentence and not a twelve-minute run on NaN", () => {
70 expect(() => options("--iters", "abc")).toThrow(`--iters wants a whole number greater than 0, and got "abc"`);
71 expect(() => options("--iters", "NaN")).toThrow("--iters wants a whole number");
72 expect(() => options("--envs", "-4")).toThrow("--envs wants a whole number");
73 expect(() => options("--envs", "0")).toThrow("--envs wants a whole number");
74 expect(() => options("--steps", "2.5")).toThrow("--steps wants a whole number");
75 expect(() => options("--maps", "")).toThrow("--maps wants a whole number");
76 expect(() => options("--maps", "1e400")).toThrow("--maps wants a whole number");
77 // A seed of 0 is fine; a seed below it is not a seed.
78 expect(() => options("--seed", "-1")).toThrow("--seed wants a whole number of 0 or more");
79 });
80
81 test("a flag with nothing after it is refused rather than swallowing the next flag", () => {
82 expect(() => options("--iters")).toThrow("--iters needs a value");
83 expect(() => options("--iters", "--dry")).toThrow("--iters needs a value");
84 expect(() => options("--foe")).toThrow("--foe needs a value");
85 expect(() => options("--out")).toThrow("--out needs a value");
86 expect(() => options("--out", " ")).toThrow("--out cannot be empty");
87 });
88
89 test("a flag nobody knows, and a word that is not a flag, stop the run and say so", () => {
90 const unknown = (): Options => options("--iter", "300");
91 expect(unknown).toThrow("unknown flag --iter");
92 // The complaint lists what there is, so the typo is one line from its fix.
93 expect(unknown).toThrow("--iters");
94 expect(() => options("300")).toThrow(`"300" is not a flag`);
95 expect(() => options("--foe", "friendly")).toThrow(`--foe is one of mixed, still, scripted, and got "friendly"`);
96 expect(USAGE).toContain("bun run scripts/train.ts");
97 for (const flag of ["--bench", "--dry", "--envs", "--foe", "--holdout", "--iters", "--maps", "--out", "--seed", "--steps"])
98 expect(USAGE).toContain(flag);
99 });
100
101 test("--out and --policy are the two paths a run writes, resolved against where it was run", () => {
102 expect(options("--out", "/tmp/weights.ts").out).toBe("/tmp/weights.ts");
103 expect(options("--out", "elsewhere/w.ts").out).toBe(resolve("elsewhere/w.ts"));
104 expect(options("--out", "/tmp/w.ts").out).not.toBe(WEIGHTS);
105 // Since #214 the weights are their own file: the card is one write and the
106 // packed policy the build copies into static/ is the other.
107 expect(DEFAULTS.policy).toBe(POLICY);
108 expect(POLICY.endsWith("src/client/weights.json")).toBe(true);
109 expect(options("--policy", "/tmp/w.json").policy).toBe("/tmp/w.json");
110 expect(options("--policy", "elsewhere/w.json").policy).toBe(resolve("elsewhere/w.json"));
111 expect(options("--policy", "/tmp/w.json").out).toBe(WEIGHTS);
112 });
113
114 test("the policy document it writes is what the page's reader accepts, and is the file in the tree", async () => {
115 const text = policyDocument(PACKED);
116 expect(text.endsWith("\n")).toBe(true);
117 // One line of JSON: these bytes are copied into static/ verbatim and are
118 // what a browser downloads, so `parsePacked` is the other end of this.
119 expect(parsePacked(text)).toEqual(PACKED);
120 expect(policyDocument(PACKED)).toBe(await Bun.file(POLICY).text());
121 });
122
123 test("the generated module says not to edit it, and says it first", () => {
124 const text = cardModule(CARD);
125 expect(text.startsWith("/**")).toBe(true);
126 expect(text).toContain("scripts/train.ts. Do not edit by hand.");
127 expect(text.indexOf("Do not edit by hand")).toBeLessThan(text.indexOf("export const CARD"));
128 expect(text.endsWith("\n")).toBe(true);
129 });
130
131 test("the same card and the same weights write the same text, every time", () => {
132 expect(cardModule(CARD)).toBe(cardModule(CARD));
133 // And a different card is a different file in exactly one place: its numbers.
134 const other = cardModule({ ...CARD, winRate: 3 });
135 expect(other).not.toBe(cardModule(CARD));
136 expect(other).toContain(`"winRate": 3`);
137 expect(other.replace(`"winRate": 3`, `"winRate": ${CARD.winRate}`)).toBe(cardModule(CARD));
138 });
139
140 test("what it emits parses, and exports what src/client/weights.ts exports today", async () => {
141 // Transpiled rather than imported as TypeScript: the generated module has no
142 // imports at all, so what is left is a module with nothing of this repository
143 // in it.
144 const js = new Bun.Transpiler({ loader: "ts" }).transformSync(cardModule(CARD));
145 const file = join(mkdtempSync(join(tmpdir(), "train-")), "weights.js");
146 writeFileSync(file, js);
147 const generated = (await import(file)) as { CARD: typeof CARD };
148 const shipped = await import("../src/client/weights.ts");
149
150 expect(Object.keys(generated).sort()).toEqual(Object.keys(shipped).sort());
151 expect(generated.CARD).toEqual(CARD);
152 // And the weights are not in it any more: they are the JSON beside it.
153 expect(Object.keys(generated)).not.toContain("PACKED");
154 });
155
156 test("the file in the tree is this generator's output, character for character", async () => {
157 // If this fails, either somebody edited `src/client/weights.ts` by hand or
158 // the generator's formatting moved under it. Both are worth a failure: the
159 // page reads the file in the tree, and every other test here reads the
160 // generator.
161 expect(cardModule(CARD)).toBe(await Bun.file(SOURCE).text());
162 });
scripts/train.ts +325 −180
@@ -1,109 +1,169 @@
11 /**
22 * Training. Runs a few hundred PPO updates over a batch of duels and writes
3 * the result to src/client/weights.ts, which is what the game ships.
3 * two files the game ships: src/client/weights.json, which is the packed
4 * policy and is copied into static/ under its own content hash by
5 * `bun run scripts/build.ts`, and src/client/weights.ts, which is the card the
6 * page boasts about it. A run that writes them is followed by a build and a
7 * commit of both, and of what static/ then holds.
48 *
59 * bun run scripts/train.ts --bench # how fast the arena steps
610 * bun run scripts/train.ts --iters 300 # train and write the weights
711 * bun run scripts/train.ts --iters 300 --dry # train without writing
8 * bun run scripts/train.ts --workers 0 # on this thread, for debugging
12 * bun run scripts/train.ts --maps 1 # one map, the way it used to train
13 * bun run scripts/train.ts --out /tmp/w.ts # write the card somewhere else
14 * bun run scripts/train.ts --policy /tmp/w.json # and the weights somewhere else
915 *
1016 * Nothing here is imported by the server or the page: this file runs on a
1117 * machine with a few minutes to spare, and its only output is a table of
1218 * numbers and about eight kilobytes of int8.
19 *
20 * The PPO is the engine's, reached through the door like everything else it
21 * offers; what is the shooter's is the arena it collects from, the observation
22 * it fills, the heads it decodes and the curriculum below.
23 *
24 * The two ends of the run are plain functions, so `train.test.ts` can check
25 * both in milliseconds without training for twelve minutes: `parseOptions`
26 * (argv in, options out, a sentence for anything that is not one) and
27 * `weightsModule` (a card and packed weights in, the source text out). What is
28 * between them is the twelve minutes, and no test runs it.
1329 */
1430
1531 import { writeFileSync } from "node:fs";
16 import { join } from "node:path";
32 import { join, resolve } from "node:path";
1733 import { HEADS, decode } from "../src/client/action.ts";
18 import { botThink, makeBrain } from "../src/client/bots.ts";
19 import { rng } from "../src/client/math.ts";
20 import { DE_FORGE, type MapDef } from "../src/client/map.ts";
34 import {
35 computeAdvantages, copyPolicy, createAdam, createPolicy, createRollout, forward,
36 newActivations, pack, rng, sample, unpack, update, DEFAULT_HYPER,
37 type Hyper, type Packed,
38 } from "../src/engine/index.ts";
2139 import { OBS_SIZE } from "../src/client/observe.ts";
40 import { SHAPE } from "../src/client/packed.ts";
2241 import {
23 createPolicy, forward, greedy, newActivations, pack, policyBytes, policyOver, sample,
24 tensorsOf, unpack, type Policy, type Shape,
25 } from "../src/client/policy.ts";
26 import {
27 createArena, EPISODE_TICKS, observeLearner, reset, scriptedFoe, SKIP,
28 step, stepInput, type Arena,
42 createArena, observeFoe, observeLearner, policyFoe, relevel, reset, scriptedFoe, SKIP,
43 step, stillFoe, type Arena,
2944 } from "../src/rl/arena.ts";
30 import { cores } from "../src/rl/cores.ts";
31 import { addTotals, createPool, noTotals, updateStep } from "../src/rl/pool.ts";
32 import { LEAGUE, type Curriculum } from "../src/rl/shard.ts";
33 import { createAdam, rolloutOver, DEFAULT_HYPER, type Hyper } from "../src/rl/ppo.ts";
34
35 export const SHAPE: Shape = { obs: OBS_SIZE, hidden: 64, heads: HEADS };
36
37 const arg = (name: string, fallback: number): number => {
38 const at = process.argv.indexOf(`--${name}`);
39 if (at < 0) return fallback;
40 const value = Number(process.argv[at + 1]);
41 return Number.isFinite(value) ? value : fallback;
42 };
43 const flag = (name: string): boolean => process.argv.includes(`--${name}`);
44
45 const ENVS = arg("envs", 64);
46 const STEPS = arg("steps", 32);
47 const ITERS = arg("iters", 240);
48 const SEED = arg("seed", 20260914);
49 // One worker a shard, defaulting to the cores this process is allowed. Zero
50 // runs the whole thing on this thread, which is the reference the parallel path
51 // is tested against and the way to debug it.
52 const WORKERS = arg("workers", cores());
53
54 // --- evaluation -----------------------------------------------------------
55
56 export type Outcome = { won: number; lost: number; draw: number; damage: number };
45 import { drawPercent, evaluate, greedyDriver, wonPercent } from "../src/rl/evaluate.ts";
46 import { holdoutLevels, trainingLevels } from "../src/rl/maps.ts";
47 import type { Level } from "../src/client/level.ts";
48 import type { Card } from "../src/client/weights.ts";
5749
5850 /**
59 * Play `duels` fixed duels against the scripted bots. Both drivers get the
60 * same seeds, so the same spawns, the same distances and the same opponent
61 * skills: the difference in the result is the difference in the driver.
62 * `map` defaults to de_forge — the arena the shipped weights were trained
63 * and are still measured in — and takes any other map to measure the same
64 * fixed duels on it instead, without retraining anything.
51 * The shape trained here, which is the shape the game asks for: it is
52 * `src/client/packed.ts`'s constant and not a second copy, because the browser
53 * refuses a downloaded policy of any other shape and a trainer that disagreed
54 * with it would write eight kilobytes nobody can run.
6555 */
66 export function evaluate(
67 drive: ((arena: Arena) => void) | "scripted",
68 duels: number,
69 seed: number,
70 map: MapDef = DE_FORGE,
71 ): Outcome {
72 const out: Outcome = { won: 0, lost: 0, draw: 0, damage: 0 };
73 for (let i = 0; i < duels; i++) {
74 const arena = createArena(seed + i * 7919, scriptedFoe, map);
75 if (drive === "scripted") arena.learner.brain = makeBrain(0.65, seed + i, false);
76 // Both drivers get the same six seconds; the scripted brain turns its own
77 // view every tick, the policy decides once every SKIP ticks.
78 while (arena.ticks < EPISODE_TICKS && arena.learner.alive && arena.foe.alive) {
79 if (drive === "scripted") stepInput(arena, botThink(arena.world, arena.learner, 1 / 64));
80 else drive(arena);
81 }
82 out.damage += arena.learner.damage;
83 if (!arena.foe.alive) out.won += 1;
84 else if (!arena.learner.alive) out.lost += 1;
85 else out.draw += 1;
86 }
87 return out;
88 }
56 export { SHAPE };
8957
90 /** A driver that plays the policy greedily — no sampling, so it is repeatable. */
91 export function greedyDriver(policy: Policy): (arena: Arena) => void {
92 const obs = new Float32Array(OBS_SIZE);
93 const act = newActivations(policy.shape);
94 const actions = new Int32Array(HEADS.length);
95 return (arena) => {
96 observeLearner(arena, obs);
97 forward(policy, obs, act);
98 greedy(policy.shape, act.logits, actions);
99 step(arena, decode(actions));
58 // --- the boundary ---------------------------------------------------------
59
60 /** Where the card lands when nobody says otherwise: the file the page reads. */
61 export const WEIGHTS = join(import.meta.dir, "..", "src", "client", "weights.ts");
62
63 /** And where the weights themselves land: the document the build copies to static/. */
64 export const POLICY = join(import.meta.dir, "..", "src", "client", "weights.json");
65
66 /** Who a run practises against: the curriculum, or one opponent all the way. */
67 export type Foe = "mixed" | "still" | "scripted";
68
69 /** One run, as plain data. Everything the loop below reads comes from here. */
70 export type Options = {
71 /** Step the arena and print a throughput, instead of training. */
72 bench: boolean;
73 /** Train and print the table, but write no module. */
74 dry: boolean;
75 envs: number;
76 steps: number;
77 iters: number;
78 seed: number;
79 /** How many layouts a run learns on: de_forge and the rest carved from seeds. */
80 maps: number;
81 /** How many layouts it is graded on, none of which it trained on. */
82 holdout: number;
83 foe: Foe;
84 /** Where the card is written. Absolute, resolved against the cwd. */
85 out: string;
86 /** Where the packed weights are written. Absolute, resolved against the cwd. */
87 policy: string;
88 };
89
90 export const DEFAULTS: Options = {
91 bench: false,
92 dry: false,
93 envs: 64,
94 steps: 32,
95 iters: 240,
96 seed: 20260914,
97 maps: 96,
98 holdout: 24,
99 foe: "mixed",
100 out: WEIGHTS,
101 policy: POLICY,
102 };
103
104 /** The flags that take a whole number, and the smallest one each will accept. */
105 const NUMBERS = {
106 envs: 1, steps: 1, iters: 1, maps: 1, holdout: 1,
107 // A seed is a label, not a size: zero is a seed like any other.
108 seed: 0,
109 } as const;
110
111 const FOES: readonly Foe[] = ["mixed", "still", "scripted"];
112
113 const KNOWN = ["--bench", "--dry", "--foe", "--out", "--policy", ...Object.keys(NUMBERS).map((k) => `--${k}`)].sort();
114
115 /** What the usage line says, printed under any complaint below. */
116 export const USAGE = `usage: bun run scripts/train.ts [${KNOWN.join("] [")}]`;
117
118 /**
119 * argv (what comes after the script name) in, options out. Anything that is
120 * not a run — a flag nobody knows, a value that is not a number, a count of
121 * zero, a missing value, a bare word — throws a sentence saying which flag and
122 * what it got, because training for twelve minutes on `NaN` and writing the
123 * result over the shipped weights is the failure this stands in front of.
124 */
125 export function parseOptions(argv: readonly string[]): Options {
126 const options: Options = { ...DEFAULTS };
127 const value = (flag: string, next: string | undefined): string => {
128 if (next === undefined || next.startsWith("--")) throw new Error(`${flag} needs a value`);
129 return next;
100130 };
131 for (let i = 0; i < argv.length; i++) {
132 const flag = argv[i] as string;
133 if (!flag.startsWith("--")) throw new Error(`${JSON.stringify(flag)} is not a flag; ${USAGE}`);
134 const name = flag.slice(2);
135 if (flag === "--bench") options.bench = true;
136 else if (flag === "--dry") options.dry = true;
137 else if (flag === "--foe") {
138 const foe = value(flag, argv[++i]);
139 if (!FOES.includes(foe as Foe)) throw new Error(`--foe is one of ${FOES.join(", ")}, and got ${JSON.stringify(foe)}`);
140 options.foe = foe as Foe;
141 } else if (flag === "--out" || flag === "--policy") {
142 const where = value(flag, argv[++i]);
143 if (where.trim() === "") throw new Error(`${flag} cannot be empty`);
144 if (flag === "--out") options.out = resolve(where);
145 else options.policy = resolve(where);
146 } else if (Object.hasOwn(NUMBERS, name)) {
147 const key = name as keyof typeof NUMBERS;
148 const text = value(flag, argv[++i]);
149 const n = Number(text);
150 if (!Number.isInteger(n) || n < NUMBERS[key])
151 throw new Error(
152 `${flag} wants a whole number ${NUMBERS[key] === 0 ? "of 0 or more" : "greater than 0"}, ` +
153 `and got ${JSON.stringify(text)}`,
154 );
155 options[key] = n;
156 } else throw new Error(`unknown flag ${flag}; known flags are ${KNOWN.join(" ")}`);
157 }
158 return options;
101159 }
102160
103161 // --- the loop -------------------------------------------------------------
104162
105 function benchmark(): void {
106 const arenas = Array.from({ length: 64 }, (_, i) => createArena(SEED + i));
163 function benchmark({ maps, seed }: Options): void {
164 const levels = trainingLevels(maps);
165 const arenas = Array.from({ length: 64 }, (_, i) =>
166 createArena(seed + i, scriptedFoe, levels[i % levels.length] as Level));
107167 const obs = new Float32Array(OBS_SIZE);
108168 const policy = createPolicy(SHAPE, rng(1));
109169 const act = newActivations(SHAPE);
@@ -124,138 +184,213 @@ function benchmark(): void {
124184 console.log(`${steps} steps in ${seconds.toFixed(2)}s = ${(steps / seconds / 1000).toFixed(0)}k steps/s (observe + policy + arena)`);
125185 }
126186
127 async function train(): Promise<void> {
187 async function train(options: Options): Promise<void> {
188 const { envs: ENVS, steps: STEPS, iters: ITERS, seed: SEED, maps: MAPS, holdout: HOLDOUT } = options;
128189 const hyper: Hyper = { ...DEFAULT_HYPER, minibatch: Math.max(256, (ENVS * STEPS) / 4) };
129190 const random = rng(SEED);
130 const pool = createPool({
131 shape: SHAPE, envs: ENVS, steps: STEPS, seed: SEED, hyper,
132 shards: Math.max(1, WORKERS), threaded: WORKERS > 0,
133 });
134 // The weights live in the shared buffer every shard reads, so there is no
135 // broadcast: the Adam step below is visible to the workers the moment it lands.
136 const policy = policyOver(SHAPE, pool.weights);
137 const initial = createPolicy(SHAPE, random);
138 tensorsOf(policy).forEach((t, i) => t.set(tensorsOf(initial)[i] as Float32Array));
191 const policy = createPolicy(SHAPE, random);
139192 const adam = createAdam(policy, hyper);
140 const rollout = rolloutOver(SHAPE, ENVS, STEPS, pool.rollout);
141 const league = Array.from({ length: LEAGUE }, (_, k) => policyOver(SHAPE, pool.league, k * policyBytes(SHAPE)));
142 const freezeEvery = Math.max(1, Math.floor(ITERS / (LEAGUE * 6)));
143 let filled = 0;
144 let slot = 0;
193 const rollout = createRollout(SHAPE, ENVS, STEPS);
194 const act = newActivations(SHAPE);
195 const foeObs = new Float32Array(OBS_SIZE);
196 const foeAct = newActivations(SHAPE);
197 const foeActions = new Int32Array(HEADS.length);
198 const actions = new Int32Array(HEADS.length);
199 const tail = new Float32Array(OBS_SIZE);
200
201 let snapshot = copyPolicy(policy);
202 const selfPlay = policyFoe((arena) => {
203 observeFoe(arena, foeObs);
204 forward(snapshot, foeObs, foeAct);
205 sample(SHAPE, foeAct.logits, random, foeActions);
206 return decode(foeActions);
207 });
208 // Who a bot practises against, and how hard, both move with the run: it
209 // starts mostly against targets that do not shoot back, and ends mostly
210 // against the scripted bots at full skill and against itself. Handed a hard
211 // opponent from the first step, the policy learns to hide instead of to
212 // shoot, and never comes back from it.
213 const opponentFor = (e: number, progress: number): Arena["opponent"] => {
214 if (options.foe === "still") return stillFoe;
215 if (options.foe === "scripted") return scriptedFoe;
216 const still = Math.max(0.1, 0.6 - progress * 0.5);
217 const scripted = 0.3 + progress * 0.3;
218 const u = (e % 10) / 10;
219 return u < still ? stillFoe : u < still + scripted ? scriptedFoe : selfPlay;
220 };
221
222 // The maps. Every arena starts on one of them and rolls onto another every
223 // time its duel ends, so a run of this length fights on all of them many
224 // times over and none of them long enough to learn its corners. There is no
225 // curriculum on the map on purpose: what the policy sees is egocentric, so a
226 // corridor is a corridor, and mixing the layouts from the first step is what
227 // stops "the enemy went left" from meaning a particular left.
228 const levels = trainingLevels(MAPS);
229 const holdout = holdoutLevels(HOLDOUT);
230 let rolled = 0;
231 const nextLevel = (): Level => levels[rolled++ % levels.length] as Level;
232 const arenas = Array.from({ length: ENVS }, (_, e) =>
233 createArena(SEED + e * 104729, opponentFor(e, 0), nextLevel()));
145234
146235 const baseline = evaluate("scripted", 120, SEED + 555);
236 const baselineUnseen = evaluate("scripted", 120, SEED + 777, holdout);
237 console.log(`${levels.length} maps to learn on (de_forge and ${levels.length - 1} carved from seeds), ` +
238 `${holdout.length} held back to be graded on`);
147239 console.log(
148 `${pool.shards} shard${pool.shards === 1 ? "" : "s"}, ${ENVS} environments, ` +
149 `${ENVS * STEPS} decisions an update, minibatch ${hyper.minibatch}`,
150 );
151 console.log(
152 `baseline (scripted brain in the same duels): ${baseline.won} won / ${baseline.lost} lost / ${baseline.draw} drawn`,
240 `baseline (scripted brain in the same duels): ${baseline.won} won / ${baseline.lost} lost / ${baseline.draw} drawn` +
241 `, and on maps it has never seen ${baselineUnseen.won}/${baselineUnseen.lost}/${baselineUnseen.draw}`,
153242 );
154243
155 const n = ENVS * STEPS;
244 const episodeReturn = new Float64Array(ENVS);
245 let returns: number[] = [];
246 let wins = 0, losses = 0, finished = 0;
247 let aimSum = 0, aimN = 0, fireSum = 0, fireN = 0;
156248 const started = Bun.nanoseconds();
157249 let envSteps = 0;
158 let sinceLog = noTotals();
159250
160251 for (let iter = 1; iter <= ITERS; iter++) {
161 // Two curricula, both stretched over the first half of the run: how far
162 // apart a duel starts, and who it is against. Learning to shoot across the
163 // map before learning to shoot at all is the sort of thing that eats a run,
164 // and handed a good opponent from the first step the policy learns to hide.
252 // A curriculum on distance: near the start every duel is close enough that
253 // the enemy is a wide target, and it widens to the full range by halfway.
254 // Learning to shoot across the map before learning to shoot at all is the
255 // sort of thing that eats a training run.
165256 const progress = Math.min(1, iter / (ITERS * 0.5));
166 const curriculum: Curriculum = {
167 maxRange: 600 + progress * 900,
168 maxSkill: 0.45 + progress * 0.55,
169 still: Math.max(0.1, 0.6 - progress * 0.5),
170 scripted: 0.3 + progress * 0.3,
171 filled,
172 };
173
174 const { totals: t, gradNorm } = await updateStep(pool, policy, adam, rollout, curriculum, random);
175 envSteps += n;
176
177 // The league: a copy of the policy is frozen into the next slot every so
178 // often, and self-play duels draw from the ones that are filled. Practising
179 // only against your newest self is how a policy learns to beat one opponent
180 // and forgets the rest; a handful of older selves keeps it honest.
181 if (iter % freezeEvery === 0) {
182 const into = league[slot % LEAGUE] as Policy;
183 tensorsOf(into).forEach((tensor, i) => tensor.set(tensorsOf(policy)[i] as Float32Array));
184 slot += 1;
185 filled = Math.min(LEAGUE, filled + 1);
257 for (const arena of arenas) {
258 arena.maxRange = 600 + progress * 900;
259 arena.maxSkill = 0.45 + progress * 0.55;
186260 }
261 for (let t = 0; t < STEPS; t++) {
262 for (let e = 0; e < ENVS; e++) {
263 const arena = arenas[e] as Arena;
264 const i = t * ENVS + e;
265 observeLearner(arena, rollout.obs, i * OBS_SIZE);
266 forward(policy, rollout.obs, act, i * OBS_SIZE);
267 rollout.values[i] = act.value;
268 rollout.logProbs[i] = sample(SHAPE, act.logits, random, actions);
269 for (let h = 0; h < HEADS.length; h++) rollout.actions[i * HEADS.length + h] = actions[h] as number;
270 if ((rollout.obs[i * OBS_SIZE + 2] as number) > 0.5) {
271 aimSum += rollout.obs[i * OBS_SIZE + 4] as number; // cos of the aim error
272 aimN += 1;
273 fireSum += actions[4] === 1 ? 1 : 0;
274 fireN += 1;
275 }
276 const result = step(arena, decode(actions));
277 rollout.rewards[i] = result.reward;
278 rollout.dones[i] = result.done ? 1 : 0;
279 episodeReturn[e] = (episodeReturn[e] as number) + result.reward;
280 envSteps += 1;
281 if (result.done) {
282 returns.push(episodeReturn[e] as number);
283 episodeReturn[e] = 0;
284 finished += 1;
285 if (result.won) wins += 1;
286 if (result.lost) losses += 1;
287 relevel(arena, nextLevel());
288 arena.opponent = opponentFor(e, progress);
289 }
290 }
291 }
292 // Bootstrap: what the policy thinks the unfinished episodes are still worth.
293 for (let e = 0; e < ENVS; e++) {
294 observeLearner(arenas[e] as Arena, tail);
295 forward(policy, tail, act);
296 rollout.lastValues[e] = act.value;
297 }
298 computeAdvantages(rollout, hyper.gamma, hyper.lambda);
299 const stats = update(policy, rollout, adam, random);
300 if (iter % 10 === 0) snapshot = copyPolicy(policy);
187301
188 sinceLog = addTotals(sinceLog, t);
189302 if (iter % 10 === 0 || iter === 1) {
303 const mean = returns.length === 0 ? 0 : returns.reduce((a, b) => a + b, 0) / returns.length;
190304 const seconds = (Bun.nanoseconds() - started) / 1e9;
191 const s = sinceLog;
192 const rate = (x: number, of: number): string => (of === 0 ? "0" : Math.round((x / of) * 100).toString());
193305 console.log(
194 `${String(iter).padStart(4)} ${((envSteps * SKIP) / 1000).toFixed(0).padStart(7)}k ticks ` +
195 `${((envSteps * SKIP) / seconds / 1000).toFixed(0).padStart(4)}k/s ` +
196 `return ${(s.episodes === 0 ? 0 : s.returns / s.episodes).toFixed(2).padStart(6)} ` +
197 `won ${rate(s.wins, s.episodes)}% lost ${rate(s.losses, s.episodes)}% ` +
198 `aim ${(s.aimN === 0 ? 0 : s.aim / s.aimN).toFixed(2)} fire ${(s.fireN === 0 ? 0 : s.fire / s.fireN).toFixed(2)} ` +
199 `ent ${(s.entropy / Math.max(1, s.samples)).toFixed(2)} ` +
200 `vloss ${(s.valueLoss / Math.max(1, s.samples)).toFixed(3)} ` +
201 `clip ${(s.clipped / Math.max(1, s.samples)).toFixed(2)} gn ${gradNorm.toFixed(2)}`,
306 `${String(iter).padStart(4)} ${((envSteps * SKIP) / 1000).toFixed(0).padStart(6)}k ticks ` +
307 `${((envSteps * SKIP) / seconds / 1000).toFixed(0).padStart(4)}k/s return ${mean.toFixed(2).padStart(6)} ` +
308 `won ${finished === 0 ? 0 : Math.round((wins / finished) * 100)}% lost ${finished === 0 ? 0 : Math.round((losses / finished) * 100)}% ` +
309 `aim ${(aimN === 0 ? 0 : aimSum / aimN).toFixed(2)} fire ${(fireN === 0 ? 0 : fireSum / fireN).toFixed(2)} ` +
310 `ent ${stats.entropy.toFixed(2)} vloss ${stats.valueLoss.toFixed(3)} clip ${stats.clipFraction.toFixed(2)}`,
202311 );
203 sinceLog = noTotals();
312 returns = [];
313 wins = 0; losses = 0; finished = 0;
314 aimSum = 0; aimN = 0; fireSum = 0; fireN = 0;
204315 }
205316 }
206317
207318 const seconds = (Bun.nanoseconds() - started) / 1e9;
208 pool.close();
209319 const packed = pack(policy);
210320 const quantised = unpack(packed);
211321 const trained = evaluate(greedyDriver(policy), 240, SEED + 555);
212322 const shipped = evaluate(greedyDriver(quantised), 240, SEED + 555);
213323 const baselineAgain = evaluate("scripted", 240, SEED + 555);
214 const rate = (o: typeof trained): number => Math.round((o.won / (o.won + o.lost + o.draw)) * 100);
215 console.log(`\ntrained ${trained.won}/${trained.lost}/${trained.draw} (${rate(trained)}% won)`);
216 console.log(`shipped ${shipped.won}/${shipped.lost}/${shipped.draw} (${rate(shipped)}% won) after int8 packing`);
217 console.log(`scripted ${baselineAgain.won}/${baselineAgain.lost}/${baselineAgain.draw} (${rate(baselineAgain)}% won)`);
324 // The same duels again on the maps no arena in this run was ever put on:
325 // what the policy does on a layout it has never seen is the number the whole
326 // map curriculum is for, and a draw there is the bot that got confused.
327 const unseen = evaluate(greedyDriver(quantised), 240, SEED + 777, holdout);
328 const unseenBaseline = evaluate("scripted", 240, SEED + 777, holdout);
329 console.log(`\nde_forge, the map it has always been measured on:`);
330 console.log(` trained ${trained.won}/${trained.lost}/${trained.draw} (${wonPercent(trained)}% won)`);
331 console.log(` shipped ${shipped.won}/${shipped.lost}/${shipped.draw} (${wonPercent(shipped)}% won) after int8 packing`);
332 console.log(` scripted ${baselineAgain.won}/${baselineAgain.lost}/${baselineAgain.draw} (${wonPercent(baselineAgain)}% won)`);
333 console.log(`${holdout.length} maps it has never seen:`);
334 console.log(` shipped ${unseen.won}/${unseen.lost}/${unseen.draw} (${wonPercent(unseen)}% won, ${drawPercent(unseen)}% drawn)`);
335 console.log(` scripted ${unseenBaseline.won}/${unseenBaseline.lost}/${unseenBaseline.draw} ` +
336 `(${wonPercent(unseenBaseline)}% won, ${drawPercent(unseenBaseline)}% drawn)`);
218337
219 if (flag("dry")) return;
220 const out = join(import.meta.dir, "..", "src", "client", "weights.ts");
221 writeFileSync(out, weightsModule(packed, {
338 if (options.dry) return;
339 // The two writes this script does, each to the path the options name: the
340 // weights themselves, and the card that says what they measured.
341 const out = options.out;
342 writeFileSync(options.policy, policyDocument(packed));
343 writeFileSync(out, cardModule({
222344 trainedAt: new Date().toISOString().slice(0, 10),
223345 decisions: envSteps,
224346 envSteps: envSteps * SKIP,
225347 updates: ITERS,
226348 stepsPerSecond: Math.round((envSteps * SKIP) / seconds),
227349 minutes: Math.round(seconds / 6) / 10,
228 workers: pool.shards,
229350 duels: 240,
230 winRate: rate(shipped),
231 baselineWinRate: rate(baselineAgain),
351 winRate: wonPercent(shipped),
352 baselineWinRate: wonPercent(baselineAgain),
353 maps: levels.length,
354 unseenMaps: holdout.length,
355 unseenWinRate: wonPercent(unseen),
356 unseenBaselineWinRate: wonPercent(unseenBaseline),
357 unseenDrawRate: drawPercent(unseen),
232358 }));
233 console.log(`\nwrote ${out} (${packed.bytes.length} base64 chars)`);
359 console.log(`\nwrote ${options.policy} (${packed.bytes.length} base64 chars) and ${out}`);
360 console.log("now run `bun run scripts/build.ts` and commit both files with what it writes into static/");
234361 }
235362
236 type Card = {
237 trainedAt: string;
238 decisions: number;
239 envSteps: number;
240 updates: number;
241 stepsPerSecond: number;
242 minutes: number;
243 workers: number;
244 duels: number;
245 winRate: number;
246 baselineWinRate: number;
247 };
248
249 function weightsModule(packed: ReturnType<typeof pack>, card: Card): string {
250 return `/**
251 * The trained policy, written by scripts/train.ts. Do not edit by hand.
363 /**
364 * The packed weights as they are written to disk and as they travel: one
365 * compact JSON document, newline-ended like any other file here. Compact
366 * because these bytes are what a browser downloads — this file is copied into
367 * static/ verbatim — and nobody reads eight kilobytes of base64 by eye.
252368 *
253 * Weights are int8 with one scale per tensor — about eight kilobytes instead of
254 * thirty — and are dequantised once when the page loads. CARD is what the run
255 * that produced them measured, and it is what the page and the terminal show.
369 * `src/client/packed.ts` is the other end of it and the only reader: what this
370 * writes, that accepts, and `train.test.ts` holds the pair to it.
256371 */
372 export const policyDocument = (packed: Packed): string => `${JSON.stringify(packed)}\n`;
257373
258 import type { Packed } from "./policy.ts";
374 /**
375 * The generated module, as text: the header that says not to edit it, the type
376 * the card is, and the card this run measured. It is a pure function of its one
377 * argument and formats the same way every time, so `train.test.ts` can hand it
378 * what `src/client/weights.ts` exports today and get that file back character
379 * for character.
380 */
381 export function cardModule(card: Card): string {
382 return `/**
383 * The card for the weights in \`weights.json\` beside this file, written by
384 * scripts/train.ts. Do not edit by hand.
385 *
386 * The weights themselves are that JSON document and not a literal in here:
387 * int8 with one scale per tensor, about eight kilobytes, shipped as this
388 * game's own asset (\`src/games.ts\`) and fetched at boot rather than bundled
389 * into the entry a player downloads first. CARD is what the run that produced
390 * them measured, and it is what the page and the terminal show — a hundred
391 * bytes of prose numbers, which is why it is still a module the first paint
392 * can read.
393 */
259394
260395 export type Card = {
261396 trainedAt: string;
@@ -264,24 +399,34 @@ export type Card = {
264399 updates: number;
265400 stepsPerSecond: number;
266401 minutes: number;
267 workers: number;
402 /** Duels evaluated on de_forge, for \`winRate\` and \`baselineWinRate\`. */
268403 duels: number;
269404 winRate: number;
270405 baselineWinRate: number;
406 /** Layouts the run learned on: de_forge and the rest carved from seeds. */
407 maps: number;
408 /** Layouts it was graded on and never trained on, from the other seed stream. */
409 unseenMaps: number;
410 unseenWinRate: number;
411 unseenBaselineWinRate: number;
412 /** Duels on those maps that ran the six seconds out without a kill. */
413 unseenDrawRate: number;
271414 };
272415
273416 export const CARD: Card = ${JSON.stringify(card, null, 2)};
274
275 export const PACKED: Packed = {
276 shape: ${JSON.stringify(packed.shape)},
277 scales: ${JSON.stringify(packed.scales)},
278 bytes:
279 "${packed.bytes}",
280 };
281417 `;
282418 }
283419
284420 if (import.meta.main) {
285 if (flag("bench")) benchmark();
286 else await train();
421 let options: Options;
422 try {
423 options = parseOptions(process.argv.slice(2));
424 } catch (error) {
425 // A sentence and a non-zero exit, rather than twelve minutes on nonsense.
426 console.error(error instanceof Error ? error.message : String(error));
427 console.error(USAGE);
428 process.exit(2);
429 }
430 if (options.bench) benchmark(options);
431 else await train(options);
287432 }
src/bind.test.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * What the server binds, given an environment. The case that matters is a
3 * shell's ambient `HOST`: a machine name is not an address, and the app starts
4 * on the loopback rather than failing to resolve it.
5 */
6
7 import { expect, test } from "bun:test";
8 import { bind, isAddress } from "./bind.ts";
9
10 test("the forge's HOST and PORT are taken as they are", () => {
11 expect(bind({ HOST: "0.0.0.0", PORT: "8080" })).toEqual({ hostname: "0.0.0.0", port: 8080 });
12 expect(bind({ HOST: "::", PORT: "3000" })).toEqual({ hostname: "::", port: 3000 });
13 expect(bind({ HOST: "127.0.0.1", PORT: "0" })).toEqual({ hostname: "127.0.0.1", port: 0 });
14 expect(bind({ HOST: "localhost", PORT: "65535" })).toEqual({ hostname: "localhost", port: 65535 });
15 });
16
17 test("an ambient HOST is not an address, and is ignored rather than bound", () => {
18 const listen = bind({ HOST: "vespers-laptop.local", PORT: "8080" });
19 expect(listen.hostname).toBe("127.0.0.1");
20 expect(listen.port).toBe(8080);
21 expect(listen.unusable).toBe("vespers-laptop.local");
22 for (const host of ["example.com", "999.1.1.1", "0.0.0.0 ", "", "-", "10.0.0"])
23 expect(bind({ HOST: host }).hostname).toBe("127.0.0.1");
24 expect(isAddress("vespers-laptop.local")).toBe(false);
25 expect(isAddress("fe80::1")).toBe(true);
26 });
27
28 test("no HOST is the loopback, quietly: nothing was ignored", () => {
29 expect(bind({})).toEqual({ hostname: "127.0.0.1", port: 8080 });
30 expect(bind({ PORT: "9000" }).unusable).toBeUndefined();
31 });
32
33 test("a port that is not a port falls back rather than binding something else", () => {
34 for (const port of ["", " ", "eighty", "-1", "70000", "80.5"]) expect(bind({ PORT: port }).port).toBe(8080);
35 });
src/bind.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * The socket the server listens on. `PORT` and `HOST` are the forge's, but
3 * `HOST` is also an ambient variable a shell hands out — zsh and csh set it to
4 * the machine's name — and binding to a name that does not resolve here is a
5 * start-up that fails for no reason a person can see. So only an address is
6 * taken: an IPv4 or IPv6 literal, or `localhost`. Anything else is not
7 * something the forge set, and the loopback default stands.
8 */
9
10 export const DEFAULT_HOSTNAME = "127.0.0.1";
11 export const DEFAULT_PORT = 8080;
12
13 const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
14 const IPV6 = /^[0-9a-fA-F:]{2,45}$/; // "::", "::1", a full address — colons and hex digits only
15
16 /** True when a string is an address to bind, rather than a name to resolve. */
17 export const isAddress = (host: string): boolean =>
18 host === "localhost" ||
19 (IPV4.test(host) && host.split(".").every((part) => Number(part) <= 255)) ||
20 (IPV6.test(host) && host.includes(":"));
21
22 /** What to bind, given the environment. `unusable` is what was ignored, for the log. */
23 export function bind(env: Record<string, string | undefined>): {
24 hostname: string;
25 port: number;
26 unusable?: string;
27 } {
28 const host = env.HOST;
29 const port = env.PORT ?? "";
30 const listen = {
31 hostname: host !== undefined && isAddress(host) ? host : DEFAULT_HOSTNAME,
32 port: /^\d{1,5}$/.test(port) && Number(port) <= 65535 ? Number(port) : DEFAULT_PORT,
33 };
34 return host === undefined || isAddress(host) ? listen : { ...listen, unusable: host };
35 }
src/budget.test.ts added +433
@@ -0,0 +1,433 @@
1 /**
2 * A frame has a budget, and this is where the suite holds it. Sixty-four frames
3 * a second is **15.625 ms** a frame for everything — the simulation, the scene
4 * and the drawing — so the things measured here, the 64 Hz tick and the scene
5 * built from it, have to stay a small fraction of it on a laptop and stay one
6 * on a phone. `src/frames.test.ts` proves a frame allocates nothing after
7 * the first; this proves a frame is *cheap*, and the two sit beside each other
8 * for the same reason: both claims are about the two games at once, and neither
9 * game may import the other.
10 *
11 * **This is not a bench.** #20 is a script a person runs to read numbers, and
12 * `bun run scripts/room.ts` is the one for the room below. This is a ceiling
13 * the suite enforces on every push, and nothing here is meant to be read as
14 * "how fast is it": for that, run the bench. Merging the two would lose the
15 * only thing this file is for — failing.
16 *
17 * ## What is measured
18 *
19 * - One tick of the shooter at 64 Hz on a live round: ten bodies on de_forge,
20 * nine of them bots running the shipped policy, plus the grenades, the bomb
21 * and the rules clock. This is the whole simulation a playing frame steps.
22 * - **A world-second of a room**, which is the same simulation stepped by this
23 * process instead of by a browser: `createRoom` and sixty-four `stepRoom`s,
24 * ten bots thinking, which is what a deployment pays per room per second of
25 * play. A frame's budget is a person's patience; a room-second's budget is a
26 * core, and `ROOM_CAP` in `src/rooms/hub.ts` is one divided by the other, so
27 * this is the number that decides how many rooms one box holds.
28 * - `buildScene` on the world that tick left, with a firefight's worth of
29 * effects in the ring — bodies, the bomb, tracers, sparks and blood — which
30 * is a hungry frame rather than a quiet one.
31 * - `buildRangeScene` on a live run: the room, the plates standing in it and a
32 * tracer still fading. The range's frame is a thirtieth of the shooter's and
33 * is measured anyway, because a game with no ceiling is where the cost goes.
34 * - And a frame deliberately made dearer, which must fail the same ceiling: a
35 * guard nothing can trip is not a guard, and the last test here proves this
36 * one trips.
37 *
38 * ## Why the ceilings are where they are
39 *
40 * Measured on the machine that wrote this, idle: a tick **0.078 ms**, the
41 * shooter's hungry frame **0.0096 ms**, the range's **0.0003 ms**. The ceilings
42 * below are not those numbers — a test pinned to today's number fails the first
43 * time CI is busy — they are **1.0 ms**, **0.15 ms** and **0.02 ms**: thirteen,
44 * sixteen and sixty-six times today's cost, and each still a small slice of the
45 * 15.625 ms a frame has. A radar, a gait, a grenade trail, footsteps or a
46 * damage indicator that made one of these an order of magnitude dearer trips
47 * the ceiling. A box a few times slower than this one does not: with all eighty
48 * cores of this machine saturated by other work the three measured 0.27 ms,
49 * 0.022 ms and 0.0010 ms — still under their ceilings by four, seven and twenty
50 * times over.
51 *
52 * The room-second is the same argument at a different scale, and it is worth
53 * saying exactly which second it is: the **first** of a live round, which is
54 * ten bots walking out of spawn, and which reads about **4.6 ms** here. A round
55 * gets dearer as it meets itself — `bun run scripts/room.ts` measures an
56 * eight-second window on the forge's box, eighty cores at a load average of
57 * twenty, and reads 5.4–8.1 ms an average second with 10–24 ms in the dearest
58 * one — so the ceiling is set against the bench's numbers and not against this
59 * test's own. It is **40 ms**: roughly five times an average second of play,
60 * four per cent of the second a room has to be stepped inside, and still above
61 * the dearest second this box has produced. What it catches is a change that
62 * makes the simulation *several* times dearer, not one that makes it a fifth
63 * dearer — a tighter ceiling on a shared box is a red build about somebody
64 * else's job, which nobody would read twice.
65 *
66 * The multiple is deliberately larger where the number is smaller. Clock
67 * resolution and scheduler jitter are absolute, not proportional: ten times
68 * 0.3 µs is inside the noise of any machine, so the range's ceiling is sixty
69 * times its cost and still a seven-hundredth of a frame, which is what makes it
70 * worth asserting at all rather than not asserting it.
71 *
72 * ## Why it does not flake
73 *
74 * Three things, and the file is worth little without all of them.
75 *
76 * Every measurement **warms up first** — two batches thrown away, so the JIT
77 * has compiled and the shapes have settled — and then takes the **median of
78 * seven batches** rather than the mean of one, so a batch that lands on a GC
79 * pause is one of seven and the median steps over it.
80 *
81 * And every batch is timed by **two clocks**: the wall clock, and this
82 * process's own CPU time (`process.cpuUsage`). Both are upper bounds on the
83 * work a frame does, and interference only ever pushes one of them up — a
84 * descheduled process reads a long wall clock, and Bun's GC and JIT threads
85 * burning a second core read a long CPU clock — so the measurement is the
86 * **smaller of the two medians**, which is the tightest bound the pair can
87 * honestly support. It matters: measured against 160 spinning processes on 80
88 * cores, the wall clock put a tick at 7.8 ms — a hundred times its real cost,
89 * and past any ceiling worth having — while the CPU clock put it at 0.43 ms and
90 * every ceiling here held. That is the difference between a test that fails
91 * when the code got slower and one that fails when CI got busy.
92 *
93 * The whole file costs about six tenths of a second — a thirty-fifth of the
94 * twenty-second suite it joined — which is the price of batches big enough for
95 * a clock to resolve.
96 */
97
98 import { expect, test } from "bun:test";
99 import { pushEffect, resetEffects, type Camera } from "./engine/index.ts";
100 import { DE_FORGE } from "./client/map.ts";
101 import { SHORT } from "./client/match.ts";
102 import { buildScene, createScene } from "./client/scene.ts";
103 import { useShipped } from "./client/shipped.ts";
104 import { NO_INPUT, createWorld, update, type Effect, type World } from "./client/world.ts";
105 import { buildRangeScene, createRangeScene } from "./range/scene.ts";
106 import { EYE, createRun, start, step } from "./range/targets.ts";
107 import { createRoom, stepRoom, type Room } from "./rooms/room.ts";
108
109 /** The fixed simulation step, and the wall clock a frame of it has to fit in. */
110 const TICK = 1 / 64;
111 const FRAME_BUDGET = 1000 * TICK;
112
113 /** One tick of ten bodies, nine of them thinking. Today: 0.078 ms. */
114 const TICK_CEILING = 1.0;
115 /** The shooter's scene, a hungry frame of it. Today: 0.0096 ms. */
116 const SCENE_CEILING = 0.15;
117 /** The range's scene. Today: 0.0003 ms — see the note on small numbers above. */
118 const RANGE_SCENE_CEILING = 0.02;
119 /**
120 * One second of play in one room, stepped here. Today: 4.6 ms for the second
121 * this test takes, 5.4–8.1 ms for an average one on the forge's box under load
122 * — see the note above, and `bun run scripts/room.ts` for where the numbers
123 * come from and what a deployment does with them.
124 */
125 const ROOM_SECOND_CEILING = 40;
126
127 /** Batches thrown away before any are kept, so nothing measured is cold. */
128 const WARM = 2;
129 /** Batches kept, of which the median is the answer. Odd, so there is one. */
130 const BATCHES = 7;
131
132 /**
133 * Milliseconds of CPU this process has burned, user and system together. The
134 * clock every batch below is timed by, for the reason in the header: it counts
135 * work done and not time spent waiting for a core.
136 */
137 const cpuMs = (): number => {
138 const used = process.cpuUsage();
139 return (used.user + used.system) / 1000;
140 };
141
142 const median = (xs: number[]): number =>
143 [...xs].sort((a, b) => a - b)[(xs.length - 1) >> 1] as number;
144
145 /** One batch of operations, in milliseconds each, read off both clocks. */
146 type Batch = { wall: number; cpu: number };
147
148 /** `runs` operations of `work`, timed by both clocks and divided by `runs`. */
149 function timed(runs: number, work: () => void): Batch {
150 const wall = performance.now();
151 const cpu = cpuMs();
152 work();
153 return { wall: (performance.now() - wall) / runs, cpu: (cpuMs() - cpu) / runs };
154 }
155
156 /**
157 * Long enough that a machine under real contention finishes rather than fails:
158 * the measurement itself is immune to load, but the wall clock the runner holds
159 * a test to is not, and a timeout is a flake by another name.
160 */
161 const PATIENCE = 60_000;
162
163 /**
164 * What a ceiling here is a slice of. A frame has 15.625 ms of a person's
165 * patience; a room-second has a second of a core, which is the same sentence
166 * about a different clock, and the printed line says which one it meant.
167 */
168 const FRAME = { ms: FRAME_BUDGET, name: "frame" };
169 const ROOM_SECOND = { ms: 1000, name: "second a room has" };
170
171 /**
172 * Run `batch` — which times some number of operations and hands back the
173 * milliseconds one of them cost — until it is warm, then seven more times, and
174 * return the median. The number is printed, because a green run should still
175 * tell a person what a frame costs today.
176 */
177 function measure(label: string, ceiling: number, batch: () => Batch, within = FRAME): number {
178 for (let i = 0; i < WARM; i++) batch();
179 const kept: Batch[] = [];
180 for (let i = 0; i < BATCHES; i++) kept.push(batch());
181 const wall = median(kept.map((b) => b.wall));
182 const cpu = median(kept.map((b) => b.cpu));
183 const cost = Math.min(wall, cpu);
184 console.log(
185 ` ${label}: ${cost.toFixed(4)} ms` +
186 ` (median of ${BATCHES}: wall ${wall.toFixed(4)}, cpu ${cpu.toFixed(4)};` +
187 ` ceiling ${ceiling} ms of a ${within.ms.toFixed(3)} ms ${within.name})`,
188 );
189 return cost;
190 }
191
192 /**
193 * A round of the shooter already under way: ten bodies on de_forge, the freeze
194 * over. The phase is set rather than played through, because eight seconds of
195 * freeze is eight seconds of the same tick and this file has a second to spend.
196 */
197 function liveRound(seed: number): World {
198 const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seed);
199 world.match.phase = "live";
200 world.match.timer = SHORT.roundTime;
201 return world;
202 }
203
204 /** How many ticks one batch of the simulation is: a second of play. */
205 const TICKS = 64;
206
207 test("a 64 Hz tick with ten bodies stays well inside the frame budget", () => {
208 useShipped(); // a bot with no policy does not think, and would not be measured
209 let alive = 0;
210 const each = measure("a 64 Hz tick, ten bodies, nine of them thinking", TICK_CEILING, () => {
211 // A fresh round per batch: a world played for thousands of ticks is a world
212 // where people have died, and a dead bot does not think — the measurement
213 // would drift downwards into meaninglessness.
214 const world = liveRound(7);
215 const batch = timed(TICKS, () => {
216 for (let i = 0; i < TICKS; i++) update(world, TICK, NO_INPUT);
217 });
218 alive = world.players.filter((p) => p.alive).length;
219 return batch;
220 });
221
222 expect(each).toBeLessThan(TICK_CEILING);
223 // And the ticks that were measured really did have ten bodies in them: if the
224 // round had emptied out, the number above would be a number about nothing.
225 expect(alive).toBe(10);
226 }, PATIENCE);
227
228 /**
229 * The same tick with smoke standing in the round: ten clouds, one per body,
230 * which is the most this game can have at once and far more than a round ever
231 * does. It is measured against the *same* ceiling as the plain tick, because
232 * what #165 promised is that a cloud does not buy a sight test a second price:
233 * `canSee` traces the level's fifty-odd boxes first, as it always has, and then
234 * runs `segmentSphere` over the clouds — a dot product and at most one square
235 * root each, with no allocation — and in a round nobody smoked the loop is a
236 * length check on an empty array and costs nothing measurable at all.
237 *
238 * Measured on the machine that wrote this, idle: a plain tick 0.102 ms, this
239 * one 0.107 ms. Ten clouds cost about **0.005 ms a tick** — three hundredths of
240 * one per cent of the 15.625 ms a frame has, and a twentieth of what a tick
241 * already cost — and both are nearly ten times under the 1.0 ms ceiling, which
242 * did not move for any of this.
243 */
244 test("a tick with ten clouds of smoke standing is still well inside the budget", () => {
245 useShipped();
246 let clouds = 0;
247 const each = measure("a 64 Hz tick with ten clouds standing", TICK_CEILING, () => {
248 const world = liveRound(7);
249 // One cloud on each body, opened a moment ago so every one of them is at
250 // its full radius for the whole batch rather than growing out of nothing.
251 for (const p of world.players)
252 world.clouds.push({ pos: [p.pos[0], p.pos[1], p.pos[2] + 58], born: world.time - 2, owner: p.id });
253 const batch = timed(TICKS, () => {
254 for (let i = 0; i < TICKS; i++) update(world, TICK, NO_INPUT);
255 });
256 clouds = world.clouds.length;
257 return batch;
258 });
259
260 expect(each).toBeLessThan(TICK_CEILING);
261 // The ticks that were measured really did have ten clouds standing in them.
262 expect(clouds).toBe(10);
263 }, PATIENCE);
264
265 /**
266 * A room's own round, whose freeze is set aside the way `liveRound` sets it
267 * aside and for the same reason. Every one of the ten bodies is a bot here —
268 * `createRoom` makes `world.me` one too, because a room has seats and not a
269 * person — so this is ten policies thinking and not nine.
270 */
271 function liveRoom(seed: number): Room {
272 const room = createRoom("BUDGET", { map: "de_forge", seed, length: "short", now: 0 });
273 room.world.match.phase = "live";
274 room.world.match.timer = room.rules.roundTime;
275 return room;
276 }
277
278 /**
279 * What this process pays to *be* one room for one second of play. It is the
280 * number `ROOM_CAP` is divided out of, so the day the simulation gets several
281 * times dearer this is where it is read — before twenty rooms full of people
282 * find out for themselves.
283 *
284 * The snapshots are not in here: what they weigh is pinned in
285 * `src/rooms/wire.test.ts` and what they cost is printed by
286 * `bun run scripts/room.ts`, and this holds the one number that is the room
287 * whether anybody is watching it or not.
288 */
289 test("a second of play in one room costs a small fraction of the second it had", () => {
290 useShipped(); // a bot with no policy does not think, and would not be measured
291 let alive = 0;
292 const each = measure(
293 "a room-second: 64 steps of ten bodies, all ten thinking",
294 ROOM_SECOND_CEILING,
295 () => {
296 // A fresh round per batch, for the reason the tick above takes one.
297 const room = liveRoom(7);
298 const batch = timed(1, () => {
299 for (let i = 0; i < TICKS; i++) stepRoom(room);
300 });
301 alive = room.world.players.filter((p) => p.alive).length;
302 return batch;
303 },
304 ROOM_SECOND,
305 );
306
307 expect(each).toBeLessThan(ROOM_SECOND_CEILING);
308 // And the second that was measured really was ten bodies thinking: a room
309 // that had emptied out would be a cheap number about nothing.
310 expect(alive).toBe(10);
311 }, PATIENCE);
312
313 /**
314 * And the room's ceiling bites too, for the reason the frame's does. Sixteen
315 * seconds of play charged as one is a simulation sixteen times dearer — nothing
316 * about the room changed, it is simply asked for sixteen times the work inside
317 * the second it had — and the measurement notices and the ceiling refuses it.
318 * Sixteen and not two, for the same reason the frame's proof is written at
319 * sixty-four substeps and not at ten: the margin between the ceiling and
320 * today's cost is what keeps a busy box from turning this file red, and a proof
321 * has to clear that margin on a machine slower than this one as well.
322 */
323 test("a room-second that got several times dearer is past the ceiling", () => {
324 useShipped();
325 const dearer = 16;
326 const each = measure(
327 `a room-second asked to step ${dearer} of them, to prove the ceiling bites`,
328 ROOM_SECOND_CEILING,
329 () => {
330 const room = liveRoom(7);
331 return timed(1, () => {
332 for (let i = 0; i < TICKS * dearer; i++) stepRoom(room);
333 });
334 },
335 ROOM_SECOND,
336 );
337
338 expect(each).toBeGreaterThan(ROOM_SECOND_CEILING);
339 }, PATIENCE);
340
341 /**
342 * The guard's own guard. A ceiling nothing can reach is a green light wired to
343 * nothing: a loop the JIT lifted out, a clock read twice in a row, a batch that
344 * timed an empty function would all pass every assertion above and say so
345 * cheerfully forever. So here is a frame that really did get dearer — the same
346 * 1/64 s of play, stepped in sixty-four substeps instead of one — and the
347 * measurement notices it and the ceiling refuses it.
348 *
349 * Sixty-four substeps is not sixty-four times the cost: some of a tick is
350 * per-call work that does not multiply, and it measures about 35× a plain one,
351 * 2.6–3.8 ms against a 1.0 ms ceiling. That margin is the point of the number.
352 * A tenfold regression would land just under the ceiling on *this* machine and
353 * over it on any machine slower — which is the price of a ceiling that never
354 * flakes, and the reason this proof is not written at ten.
355 */
356 test("a frame that got an order of magnitude dearer is past the ceiling", () => {
357 useShipped();
358 const dearer = 64;
359 const ops = 4;
360 const each = measure(`a 64 Hz tick cut into ${dearer} substeps, to prove the ceiling bites`, TICK_CEILING, () => {
361 const world = liveRound(7);
362 return timed(ops, () => {
363 for (let i = 0; i < ops; i++)
364 for (let j = 0; j < dearer; j++) update(world, TICK / dearer, NO_INPUT);
365 });
366 });
367
368 expect(each).toBeGreaterThan(TICK_CEILING);
369 }, PATIENCE);
370
371 test("the shooter's scene is built in a fraction of what a tick costs", () => {
372 useShipped();
373 const world = liveRound(11);
374 for (let i = 0; i < 64; i++) update(world, TICK, NO_INPUT);
375
376 // A hungry frame: the bomb down and a firefight's worth of effects still in
377 // the ring, so the line buffer is being filled and not just the instances.
378 resetEffects(world.effects);
379 world.bomb.state = "planted";
380 world.bomb.pos = [500, 600, 20];
381 for (let i = 0; i < 64; i++) {
382 const at = world.time - 0.02;
383 const effect: Effect =
384 i % 3 === 0
385 ? { kind: "tracer", from: [i, 0, 40], to: [i, 200, 45], at }
386 : i % 3 === 1
387 ? { kind: "impact", at, pos: [i, 2, 3], normal: [0, 0, 1] }
388 : { kind: "blood", at, pos: [i, 2, 3] };
389 pushEffect(world.effects, effect, effect.at);
390 }
391
392 const camera: Camera = {
393 pos: [world.me.pos[0], world.me.pos[1], world.me.pos[2] + 64],
394 yaw: world.me.yaw, pitch: world.me.pitch, fov: 90,
395 };
396 const scene = createScene();
397 const runs = 1000;
398 const each = measure("buildScene, a firefight frame", SCENE_CEILING, () =>
399 timed(runs, () => {
400 for (let i = 0; i < runs; i++) buildScene(world, camera, scene);
401 }),
402 );
403
404 expect(each).toBeLessThan(SCENE_CEILING);
405 // The frame that was timed was the hungry one it claims to be: bodies drawn
406 // and a few hundred line vertices, not an empty scene measured very fast.
407 expect(scene.instanceCount).toBeGreaterThan(5);
408 expect(scene.lineCount).toBeGreaterThan(100);
409 }, PATIENCE);
410
411 test("the range's scene is built in a fraction of what a tick costs", () => {
412 const run = createRun(1);
413 start(run);
414 // Five seconds in, so the lane is as full as this drill ever makes it.
415 for (let i = 0; i < 64 * 5; i++) step(run, TICK);
416
417 const scene = createRangeScene();
418 const view = { eye: EYE, yaw: 3, pitch: -1 };
419 const trail = { from: EYE, to: [900, 0, 60] as [number, number, number], at: 0 };
420 const runs = 10000;
421 const each = measure("buildRangeScene, plates and a tracer", RANGE_SCENE_CEILING, () =>
422 timed(runs, () => {
423 for (let i = 0; i < runs; i++) buildRangeScene(run, view, trail, 0.02, scene);
424 }),
425 );
426
427 expect(each).toBeLessThan(RANGE_SCENE_CEILING);
428 // The room, the plates standing in it, and the tracer still fading — which is
429 // one line, and a line is two vertices, which is what `lineCount` counts.
430 expect(run.targets.length).toBeGreaterThan(0);
431 expect(scene.instanceCount).toBe(1 + run.targets.length);
432 expect(scene.lineCount).toBe(2);
433 }, PATIENCE);
src/bundle.ts deleted −23
@@ -1,23 +0,0 @@
1 /**
2 * The client bundle: one file, named by the hash of its own bytes, so a
3 * source change is a new URL and a stale cache is impossible.
4 */
5
6 import { join } from "node:path";
7
8 const ENTRY = join(import.meta.dir, "client", "main.ts");
9
10 export async function bundle(): Promise<{ name: string; code: string }> {
11 const built = await Bun.build({
12 entrypoints: [ENTRY],
13 target: "browser",
14 minify: true,
15 format: "esm",
16 });
17 if (!built.success) throw new Error(built.logs.map(String).join("\n"));
18 const output = built.outputs[0];
19 if (output === undefined) throw new Error("the bundle produced no output");
20 const code = await output.text();
21 const hash = new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 10);
22 return { name: `app.${hash}.js`, code };
23 }
src/bundles.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Where this deployment's bundles are, and the build that puts them there.
3 *
4 * They are generated files, so they are generated: `src/server.ts` calls
5 * `buildBundles()` before it binds, into `FORGE_APP_DATA/static`, and serves
6 * what came out. Nothing built is committed — three outages in three days were
7 * one cause, two branches each rebuilding `static/app.<hash>.js` and git
8 * keeping both, and an artifact that is never in git cannot collide in it.
9 *
10 * It is also why a preview can never be stale: the process that answers a
11 * request built the files it answers with, from the tree it was deployed from,
12 * this boot.
13 *
14 * The build itself is `scripts/build.ts` — one bundler call per game and one
15 * copy per asset, with the naming, the sweeping and the ceilings — and this
16 * module is only the two things the server adds to it: which directory, and
17 * what a failure says. A build that fails throws here, naming the directory,
18 * and the process stops on it exactly as a missing bundle stopped it before:
19 * a deploy that cannot build is a failed deploy, not a server listening with
20 * nothing to serve.
21 */
22
23 import { join } from "node:path";
24 import { type Built, ENTRIES, sizes, write } from "../scripts/build.ts";
25 import { kB } from "./bytes.ts";
26
27 /**
28 * The directory the bundles are built into and served from: `static/` under
29 * this deployment's data directory, which production and every preview each
30 * have their own of. The default is `./data` like the database's, so a checkout
31 * with no forge around it still runs.
32 *
33 * It is a subdirectory rather than the data directory itself because the
34 * database lives there too, and the server holds every file in this one in
35 * memory at start-up.
36 */
37 export const bundlesDir = (data = process.env.FORGE_APP_DATA ?? "./data"): string => join(data, "static");
38
39 /**
40 * Build every game's bundle and every declared asset into `dir`, and hand back
41 * what was written. A failure — an unwritable directory, a source that does not
42 * compile — is re-thrown with the directory named, because the message a
43 * deploy's log shows should say where it was trying to write.
44 */
45 export async function buildBundles(dir = bundlesDir()): Promise<Built[]> {
46 try {
47 return await write(ENTRIES, dir);
48 } catch (cause) {
49 const why = cause instanceof Error ? cause.message : String(cause);
50 throw new Error(`the bundles could not be built into ${dir}: ${why}`, { cause });
51 }
52 }
53
54 /**
55 * What the build cost this boot, for the log: how many files, what they weigh
56 * on disk, and the milliseconds it took. The gzip of them is weighed separately
57 * when they are held, and `src/server.ts` prints that line beside this one.
58 */
59 export const buildSummary = (built: readonly Built[], ms: number, dir = bundlesDir()): string =>
60 `${built.length} files built into ${dir}, ` +
61 `${kB(built.reduce((total, { code }) => total + sizes(code).raw, 0))} raw, in ${ms.toFixed(1)} ms`;
src/bytes.test.ts added +31
@@ -0,0 +1,31 @@
1 import { expect, test } from "bun:test";
2 import { bytes, kB } from "./bytes.ts";
3
4 test("an exact count is grouped in threes, from either side of a thousand", () => {
5 expect(bytes(0)).toBe("0");
6 expect(bytes(7)).toBe("7");
7 expect(bytes(999)).toBe("999");
8 expect(bytes(1000)).toBe("1,000");
9 expect(bytes(9735)).toBe("9,735");
10 expect(bytes(93_714)).toBe("93,714");
11 expect(bytes(1_234_567)).toBe("1,234,567");
12 });
13
14 test("a kilobyte is 1,024 bytes, to a tenth, with the unit on it", () => {
15 // The number the issue this came from wrote by hand: 4,229 over the wire is
16 // 4.1 kB. It is 1,024 and not 1,000, and that is the whole difference.
17 expect(kB(4229)).toBe("4.1 kB");
18 expect(kB(4619)).toBe("4.5 kB");
19 expect(kB(1024)).toBe("1.0 kB");
20 expect(kB(512)).toBe("0.5 kB");
21 expect(kB(0)).toBe("0.0 kB");
22 expect(kB(42_539)).toBe("41.5 kB");
23 });
24
25 test("a number that is not a count of bytes is a throw, not a sentence with NaN in it", () => {
26 expect(() => bytes(-1)).toThrow(/whole number of bytes/);
27 expect(() => bytes(1.5)).toThrow(/whole number of bytes/);
28 expect(() => bytes(Number.NaN)).toThrow(/whole number of bytes/);
29 expect(() => kB(-1)).toThrow(/whole number of bytes/);
30 expect(() => kB(Number.POSITIVE_INFINITY)).toThrow(/whole number of bytes/);
31 });
src/bytes.ts added +27
@@ -0,0 +1,27 @@
1 /**
2 * A byte count, written the way a sentence says one. Two shapes and nothing
3 * else: `9735` is `9,735` when the exact figure is the claim, and `4619` is
4 * `4.5 kB` when what a reader wants is the feel of the download.
5 *
6 * It is a module of its own because the alternative is arithmetic inside a
7 * template, where no test can reach it: a page interpolates `bytes(n)` and the
8 * rounding, the grouping and the unit are decided here, once, with a test
9 * beside them.
10 *
11 * The unit is stated rather than assumed: a kB here is 1,024 bytes, which is
12 * what makes 4,619 read as 4.5 kB. That is the ruler `scripts/build.ts`
13 * measures with too — a gzip figure at level 9 — and the pages that print these
14 * numbers say so next to them.
15 */
16
17 /** A count this module will print: a whole number of bytes, and not a negative one. */
18 const whole = (n: number, kind: string): number => {
19 if (!Number.isInteger(n) || n < 0) throw new Error(`${kind} wants a whole number of bytes, not ${n}`);
20 return n;
21 };
22
23 /** `9735` → `9,735`: the exact count, in groups of three. */
24 export const bytes = (n: number): string => String(whole(n, "bytes")).replace(/\B(?=(\d{3})+$)/g, ",");
25
26 /** `4619` → `4.5 kB`: the same count rounded to a tenth of a kibibyte, with its unit. */
27 export const kB = (n: number): string => `${(whole(n, "kB") / 1024).toFixed(1)} kB`;
src/client/action.test.ts +201 −72
@@ -1,94 +1,223 @@
11 /**
2 * `action.ts` decodes the bins the shipped policy was trained against — a
3 * bin renumbered or reordered here does not fail to compile, it just makes
4 * the weights mean something else. So the bin tables are pinned by value,
5 * and `decode`/`toInput` are pinned end to end: an index in, the exact
6 * `Intent`/`Input` it is supposed to mean out.
2 * What a sample from the policy means, which is this game's and not the
3 * engine's: the engine hands back one integer per head and knows nothing about
4 * what any of them says. These tests moved out of `policy.test.ts` when the
5 * network itself went behind the door in #106 — the arithmetic went with it as
6 * `src/engine/net.test.ts`, and the bins stayed here, where the bins are.
7 *
8 * The bins are a contract with weights that were trained against them, so the
9 * expectations below are written as literals rather than as `decode`'s own
10 * arithmetic said twice: a table of what each index means, walked over the
11 * whole 9·7·3·3·2·3. A test that recomputed the index would agree with any
12 * future mistake made the same way, and the first sign of one is a bot standing
13 * still on the map.
714 */
815
916 import { expect, test } from "bun:test";
10 import { AXIS_BINS, decode, HEADS, PITCH_BINS, STILL, toInput, YAW_BINS } from "./action.ts";
17 import {
18 ACTION_SIZE, AXIS_BINS, decode, HEADS, PITCH_BINS, STILL, toInput, YAW_BINS, type Intent,
19 } from "./action.ts";
20 import { forward, greedy, newActivations, rng, sample, unpack } from "../engine/index.ts";
21 import { OBS_SIZE } from "./observe.ts";
22 import { PACKED } from "./shipped.ts";
23 import { NO_INPUT } from "./world.ts";
1124
12 test("the bin tables are odd-length, sorted, and centred on zero", () => {
13 for (const bins of [YAW_BINS, PITCH_BINS, AXIS_BINS]) {
14 expect(bins.length % 2).toBe(1); // an odd count so there is a middle bin
15 const middle = (bins.length - 1) / 2;
16 expect(bins[middle]).toBe(0);
17 const sorted = [...bins].sort((a, b) => a - b);
18 expect(bins).toEqual(sorted); // strictly increasing, so index order is bin order
25 /** The bins, written out again by hand. If these two lists ever disagree, one of them moved. */
26 const YAW = [-720, -240, -80, -25, 0, 25, 80, 240, 720];
27 const PITCH = [-240, -80, -25, 0, 25, 80, 240];
28 const AXIS = [-1, 0, 1];
29 /** The posture head: index 0 is neither, 1 is walking, 2 is ducking. */
30 const POSTURE = [
31 { walk: false, duck: false },
32 { walk: true, duck: false },
33 { walk: false, duck: true },
34 ];
35
36 test("the bins are the ones the shipped policy was trained on", () => {
37 // Spread because the bins are a readonly tuple and these lists are plain.
38 expect([...YAW_BINS]).toEqual(YAW);
39 expect([...PITCH_BINS]).toEqual(PITCH);
40 expect([...AXIS_BINS]).toEqual(AXIS);
41 expect(HEADS).toEqual([9, 7, 3, 3, 2, 3]);
42 expect(ACTION_SIZE).toBe(6);
43 expect(ACTION_SIZE).toBe(HEADS.length);
44 });
45
46 /**
47 * The one failure that could pass every other test in this repository: weights
48 * quantised against a different action or observation space than the one the
49 * game hands them. `weights.test.ts` asks the same of the unpacked policy; this
50 * asks it of the shape the file declares, which is what `unpack` reads.
51 */
52 test("the shipped weights and the spaces they were trained on agree", () => {
53 expect(PACKED.shape.heads).toEqual(HEADS);
54 expect(PACKED.shape.obs).toBe(OBS_SIZE);
55 expect(unpack(PACKED).shape.heads).toEqual(HEADS);
56 });
57
58 test("every index of every head decodes to the bin it names", () => {
59 const actions = new Int32Array(ACTION_SIZE);
60 let seen = 0;
61 for (let y = 0; y < YAW.length; y++) {
62 for (let p = 0; p < PITCH.length; p++) {
63 for (let f = 0; f < AXIS.length; f++) {
64 for (let s = 0; s < AXIS.length; s++) {
65 for (let b = 0; b < 2; b++) {
66 for (let t = 0; t < POSTURE.length; t++) {
67 actions.set([y, p, f, s, b, t]);
68 expect(decode(actions)).toEqual({
69 yawRate: YAW[y]!,
70 pitchRate: PITCH[p]!,
71 forward: AXIS[f]!,
72 side: AXIS[s]!,
73 fire: b === 1,
74 walk: POSTURE[t]!.walk,
75 duck: POSTURE[t]!.duck,
76 });
77 seen += 1;
78 }
79 }
80 }
81 }
82 }
1983 }
20 expect(YAW_BINS[0]).toBe(-720);
21 expect(YAW_BINS[YAW_BINS.length - 1]).toBe(720);
22 expect(PITCH_BINS[0]).toBe(-240);
23 expect(PITCH_BINS[PITCH_BINS.length - 1]).toBe(240);
24 expect(AXIS_BINS).toEqual([-1, 0, 1]);
84 expect(seen).toBe(9 * 7 * 3 * 3 * 2 * 3);
85 expect(seen).toBe(3402);
2586 });
2687
27 test("HEADS lists each head's bin count, in the order decode reads them", () => {
28 expect(HEADS).toEqual([YAW_BINS.length, PITCH_BINS.length, AXIS_BINS.length, AXIS_BINS.length, 2, 3]);
88 test("a head is read from its own index and not its neighbour's", () => {
89 // Six heads whose indices are all different, so reading one along shows.
90 const actions = new Int32Array([1, 2, 0, 2, 1, 2]);
91 expect(decode(actions)).toEqual({
92 yawRate: -240, pitchRate: -25, forward: -1, side: 1, fire: true, walk: false, duck: true,
93 });
2994 });
3095
31 test("decoding the middle of every bin head is the still intent", () => {
32 const yawMid = (YAW_BINS.length - 1) / 2;
33 const pitchMid = (PITCH_BINS.length - 1) / 2;
34 const axisMid = (AXIS_BINS.length - 1) / 2;
35 const intent = decode(Int32Array.from([yawMid, pitchMid, axisMid, axisMid, 0, 0]));
36 expect(intent).toEqual(STILL);
96 /**
97 * An index a head does not have is a sample from somewhere else — short, or
98 * from a net trained against different heads. `decode` clamps it to the nearest
99 * bin, because the nearest bin is a bot that turns too far while `undefined` is
100 * a view angle of NaN that never comes back. The expectations are the ends of
101 * the tables above, written out.
102 */
103 test("a bin the head does not have is clamped to the nearest one it does", () => {
104 const past = decode(new Int32Array([YAW.length + 3, -2, 99, -1, 0, 0]));
105 expect(past).toEqual({
106 yawRate: 720, // the last yaw bin, not undefined
107 pitchRate: -240, // the first pitch bin: a negative index clamps to 0
108 forward: 1,
109 side: -1,
110 fire: false,
111 walk: false,
112 duck: false,
113 });
114 for (const v of [past.yawRate, past.pitchRate, past.forward, past.side])
115 expect(Number.isNaN(v)).toBe(false);
116
117 // A sample that stops short of the six heads asks the same question of a
118 // missing index, and answers with the first bin rather than undefined.
119 expect(decode(new Int32Array([1]))).toEqual({
120 yawRate: -240, pitchRate: -240, forward: -1, side: -1, fire: false, walk: false, duck: false,
121 });
37122 });
38123
39 test("the extreme yaw bins turn at 720 deg/s, one way and the other", () => {
40 const actions = Int32Array.from([0, 3, 1, 1, 0, 0]);
41 expect(decode(actions).yawRate).toBe(-720);
42 actions[0] = YAW_BINS.length - 1;
43 expect(decode(actions).yawRate).toBe(720);
124 test("`at` reads one sample out of a buffer of many", () => {
125 const buffer = new Int32Array([
126 8, 0, 2, 0, 0, 1, // a sample at 0: hard right, nose down, forward, walking
127 0, 6, 0, 2, 1, 2, // a sample at 6: hard left, nose up, back, right, firing, ducking
128 ]);
129 expect(decode(buffer, 0)).toEqual({
130 yawRate: 720, pitchRate: -240, forward: 1, side: -1, fire: false, walk: true, duck: false,
131 });
132 expect(decode(buffer, 6)).toEqual({
133 yawRate: -720, pitchRate: 240, forward: -1, side: 1, fire: true, walk: false, duck: true,
134 });
44135 });
45136
46 test("decode reads fire and posture as the flags they are, at a given offset", () => {
47 const walking = decode(Int32Array.from([4, 3, 1, 1, 1, 1]));
48 expect(walking.fire).toBe(true);
49 expect(walking.walk).toBe(true);
50 expect(walking.duck).toBe(false);
51
52 const ducking = decode(Int32Array.from([4, 3, 1, 1, 0, 2]));
53 expect(ducking.fire).toBe(false);
54 expect(ducking.walk).toBe(false);
55 expect(ducking.duck).toBe(true);
56
57 // and at a non-zero offset, into a batch of several samples
58 const batch = Int32Array.from([0, 0, 0, 0, 0, 0, 4, 3, 2, 0, 1, 1]);
59 const second = decode(batch, 6);
60 expect(second.forward).toBe(1);
61 expect(second.side).toBe(-1);
62 expect(second.fire).toBe(true);
63 expect(second.walk).toBe(true);
137 test("the middle bin of every axis is standing still, and that is STILL", () => {
138 const middle = new Int32Array([4, 3, 1, 1, 0, 0]);
139 expect(decode(middle)).toEqual({
140 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
141 });
142 expect(STILL).toEqual({
143 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
144 });
145 expect(decode(middle)).toEqual(STILL);
64146 });
65147
66 test("an out-of-range index is clamped, never undefined in an Input", () => {
67 const tooHigh = decode(Int32Array.from([YAW_BINS.length + 5, PITCH_BINS.length + 5, 9, 9, 9, 9]));
68 for (const value of Object.values(tooHigh)) expect(value).not.toBeUndefined();
69 expect(Number.isFinite(tooHigh.yawRate)).toBe(true);
70 expect(Number.isFinite(tooHigh.pitchRate)).toBe(true);
148 const INTENT: Intent = {
149 yawRate: 240, pitchRate: -80, forward: 1, side: -1, fire: true, walk: true, duck: false,
150 };
71151
72 const negative = decode(Int32Array.from([-1, -1, -1, -1, -1, -1]));
73 for (const value of Object.values(negative)) expect(value).not.toBeUndefined();
152 test("an intent becomes an input the game can apply, field by field", () => {
153 expect(toInput(INTENT, false)).toEqual({
154 forward: 1,
155 side: -1,
156 jump: false,
157 duck: false,
158 walk: true,
159 fire: true,
160 reload: false,
161 use: false,
162 scope: false,
163 throwing: null,
164 switchTo: null,
165 });
166 // Turning is not an Input: the caller moves the view, the way a mouse does.
167 // `toEqual` above already refuses an extra field; this names the one meant.
168 expect(toInput(INTENT, false)).not.toHaveProperty("yawRate");
169 expect(toInput(INTENT, false)).not.toHaveProperty("pitchRate");
74170 });
75171
76 test("toInput carries the intent's own fields and takes turning from nobody", () => {
77 const intent = decode(Int32Array.from([0, 0, 2, 0, 1, 1]));
78 const input = toInput(intent, true, "ak47");
79 expect(input.forward).toBe(intent.forward);
80 expect(input.side).toBe(intent.side);
81 expect(input.fire).toBe(intent.fire);
82 expect(input.walk).toBe(intent.walk);
83 expect(input.duck).toBe(intent.duck);
84 expect(input.reload).toBe(true);
85 expect(input.switchTo).toBe("ak47");
86 expect(input.jump).toBe(false); // a bot never jumps through this path
87 expect(input.use).toBe(false);
88 expect(input.scope).toBe(false);
89 expect("yawRate" in input).toBe(false); // turning is the caller's mouse, not Input
172 test("reload and a weapon switch are carried, and the other posture too", () => {
173 const ducking: Intent = { ...INTENT, walk: false, duck: true, fire: false, forward: -1, side: 1 };
174 expect(toInput(ducking, true, "ak47")).toEqual({
175 forward: -1,
176 side: 1,
177 jump: false,
178 duck: true,
179 walk: false,
180 fire: false,
181 reload: true,
182 use: false,
183 scope: false,
184 throwing: null,
185 switchTo: "ak47",
186 });
90187 });
91188
92 test("toInput defaults to no weapon switch", () => {
93 expect(toInput(STILL, false).switchTo).toBeNull();
189 test("a still intent is the world's own no-input", () => {
190 expect(toInput(STILL, false)).toEqual(NO_INPUT);
191 });
192
193 /**
194 * And the end to end: whatever the shipped weights say about an observation,
195 * greedily or sampled, is an intent the game can act on.
196 */
197 test("the shipped policy decodes to a valid intent on a seeded sweep", () => {
198 const net = unpack(PACKED);
199 const act = newActivations(net.shape);
200 const actions = new Int32Array(ACTION_SIZE);
201 const obs = new Float32Array(OBS_SIZE);
202 const random = rng(20108);
203 const check = (intent: Intent): void => {
204 expect(YAW).toContain(intent.yawRate);
205 expect(PITCH).toContain(intent.pitchRate);
206 expect(AXIS).toContain(intent.forward);
207 expect(AXIS).toContain(intent.side);
208 expect(typeof intent.fire).toBe("boolean");
209 // Walking and ducking come from one head, so a bot never asks for both.
210 expect(intent.walk && intent.duck).toBe(false);
211 for (const v of [intent.yawRate, intent.pitchRate, intent.forward, intent.side])
212 expect(Number.isNaN(v)).toBe(false);
213 };
214 for (let i = 0; i < 200; i++) {
215 for (let j = 0; j < OBS_SIZE; j++) obs[j] = random() * 2 - 1; // the trained range
216 forward(net, obs, act);
217 for (const v of act.logits) expect(Number.isFinite(v)).toBe(true);
218 greedy(net.shape, act.logits, actions);
219 check(decode(actions));
220 sample(net.shape, act.logits, random, actions);
221 check(decode(actions));
222 }
94223 });
src/client/action.ts +23 −11
@@ -13,9 +13,12 @@
1313 import type { WeaponId } from "./weapons.ts";
1414 import type { Input } from "./world.ts";
1515
16 export const YAW_BINS = [-720, -240, -80, -25, 0, 25, 80, 240, 720];
17 export const PITCH_BINS = [-240, -80, -25, 0, 25, 80, 240];
18 export const AXIS_BINS = [-1, 0, 1];
16 /** A head's bins: at least one, so there is always a bin to fall back on. */
17 export type Bins = readonly [number, ...number[]];
18
19 export const YAW_BINS: Bins = [-720, -240, -80, -25, 0, 25, 80, 240, 720];
20 export const PITCH_BINS: Bins = [-240, -80, -25, 0, 25, 80, 240];
21 export const AXIS_BINS: Bins = [-1, 0, 1];
1922
2023 /** The heads, in the order the policy writes them. */
2124 export const HEADS = [YAW_BINS.length, PITCH_BINS.length, AXIS_BINS.length, AXIS_BINS.length, 2, 3];
@@ -36,18 +39,24 @@ export const STILL: Intent = {
3639 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
3740 };
3841
39 /** An index into a bin table, clamped so a stray sample never reads past it. */
40 const clampBin = (i: number, bins: readonly number[]): number =>
41 bins[Math.max(0, Math.min(bins.length - 1, i))] as number;
42 /**
43 * The bin an index names, clamped to the head it belongs to. A policy of the
44 * right shape only ever names one of its own bins; a sample that is short, or
45 * that came from a net trained against different heads, names one that is not
46 * there — and the nearest bin is a bot that turns too far, while `undefined`
47 * is a yaw of NaN that never comes back.
48 */
49 const bin = (bins: Bins, index: number | undefined): number =>
50 bins[Math.min(Math.max(index ?? 0, 0), bins.length - 1)] ?? bins[0];
4251
4352 /** Turn one sample from the policy into an intent. */
4453 export function decode(actions: Int32Array, at = 0): Intent {
45 const posture = actions[at + 5] as number;
54 const posture = actions[at + 5];
4655 return {
47 yawRate: clampBin(actions[at] as number, YAW_BINS),
48 pitchRate: clampBin(actions[at + 1] as number, PITCH_BINS),
49 forward: clampBin(actions[at + 2] as number, AXIS_BINS),
50 side: clampBin(actions[at + 3] as number, AXIS_BINS),
56 yawRate: bin(YAW_BINS, actions[at]),
57 pitchRate: bin(PITCH_BINS, actions[at + 1]),
58 forward: bin(AXIS_BINS, actions[at + 2]),
59 side: bin(AXIS_BINS, actions[at + 3]),
5160 fire: actions[at + 4] === 1,
5261 walk: posture === 1,
5362 duck: posture === 2,
@@ -69,6 +78,9 @@ export function toInput(intent: Intent, reload: boolean, switchTo: WeaponId | nu
6978 reload,
7079 use: false,
7180 scope: false,
81 // A bot throws nothing: it buys neither grenade, and `null` here is the
82 // field's own "neither", which `applyInput` reads as no press at all.
83 throwing: null,
7284 switchTo,
7385 };
7486 }
src/client/audio.test.ts +254 −328
@@ -1,361 +1,287 @@
1 /**
2 * `audio.ts` against a recording `AudioContext`: no browser, no speaker, just
3 * the graph it would have built — the same trick `renderer.test.ts` plays on
4 * WebGL2, one level down. `createAudio` takes its context constructor as an
5 * argument, so the fake below stands in for `window.AudioContext`.
6 */
7
81 import { expect, test } from "bun:test";
9 import { createAudio, type WorldSoundName } from "./audio.ts";
10 import { ears } from "./ears.ts";
11 import type { Vec3 } from "./math.ts";
2 import { readFileSync } from "node:fs";
3 import { join } from "node:path";
4 import { LOUDEST_INSTANT, createAudio, loudness, shotTone, type Audio } from "./audio.ts";
5 import type { Burst, SoundDevice, Tone, Vec3 } from "../engine/index.ts";
6 import type { Sound } from "./world.ts";
127
13 type Event = { method: string; value: number; time: number };
8 /**
9 * Every noise a round can make, which is the whole of `Sound`: the five a
10 * grenade makes and the five the round does. Written once, because three tests
11 * here walk all of them and a name missing from one of those lists is a recipe
12 * nobody checks — `smoke` was exactly that until the budget below wanted it.
13 */
14 const SOUNDS: Sound[] = [
15 "step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash", "smoke",
16 ];
1417
15 /** A minimal AudioParam: `.value` sets and the two ramps `audio.ts` schedules
16 * are both logged, in order, so a test can tell a sound's starting loudness
17 * (its first event) from where the ramp leaves it (its `.value`). */
18 class FakeParam {
19 #value: number;
20 readonly events: Event[] = [];
21 constructor(initial: number) {
22 this.#value = initial;
23 }
24 get value(): number {
25 return this.#value;
26 }
27 set value(v: number) {
28 this.#value = v;
29 this.events.push({ method: "value", value: v, time: NaN });
30 }
31 setValueAtTime(value: number, time: number): FakeParam {
32 this.#value = value;
33 this.events.push({ method: "setValueAtTime", value, time });
34 return this;
35 }
36 exponentialRampToValueAtTime(value: number, time: number): FakeParam {
37 this.#value = value;
38 this.events.push({ method: "exponentialRampToValueAtTime", value, time });
39 return this;
40 }
18 /** A device that plays nothing and writes down everything. */
19 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } {
20 const device = {
21 muted: false,
22 bursts: [] as Burst[],
23 tones: [] as Tone[],
24 resumes: 0,
25 resume() { device.resumes += 1; },
26 noiseBurst(burst: Burst) { if (!device.muted) device.bursts.push(burst); },
27 tone(tone: Tone) { if (!device.muted) device.tones.push(tone); },
28 };
29 return device;
4130 }
4231
43 type Edge = { from: number; to: number };
32 test("a shot is a burst plus a thump, coloured by the weapon", () => {
33 const device = recorder();
34 const audio = createAudio(device);
35 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
36 expect(device.bursts.length).toBe(1);
37 expect(device.tones.length).toBe(1);
38 const burst = device.bursts[0] as Burst;
39 const thump = device.tones[0] as Tone;
40 expect(burst.frequency).toBe(shotTone("ak47").cut);
41 expect(burst.length).toBe(shotTone("ak47").length);
42 expect(thump.slideTo).toBeLessThan(thump.frequency);
4443
45 class FakeNode {
46 readonly id: number;
47 readonly kind: string;
48 #onConnect: (edge: Edge) => void;
49 constructor(id: number, kind: string, onConnect: (edge: Edge) => void) {
50 this.id = id;
51 this.kind = kind;
52 this.#onConnect = onConnect;
53 }
54 connect(dest: FakeNode): FakeNode {
55 this.#onConnect({ from: this.id, to: dest.id });
56 return dest;
44 audio.shot("awp", [0, 0, 0], [0, 0, 0]);
45 const sniper = device.bursts[1] as Burst;
46 // The sniper is lower and rings longer than the rifle.
47 expect(sniper.frequency).toBeLessThan(burst.frequency);
48 expect(sniper.length).toBeGreaterThan(burst.length);
49 });
50
51 test("a shot across the map is quieter than one at your feet", () => {
52 const device = recorder();
53 const audio = createAudio(device);
54 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
55 audio.shot("ak47", [4000, 0, 0], [0, 0, 0]);
56 const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst;
57 expect(far.gain).toBeLessThan(near.gain / 2);
58 expect(near.gain).toBeCloseTo(0.35, 6);
59 expect(loudness([100, 0, 0], [0, 0, 0])).toBe(1);
60 expect(loudness([4000, 0, 0], [0, 0, 0])).toBeLessThan(0.3);
61 });
62
63 /**
64 * The gain the recipe asks for at a distance, by hand: `attenuate` is
65 * `min(1, earshot / distance)`, so a footstep's 256-unit earshot is full gain
66 * at 256 units and a quarter of it at 1,024 — four times the distance, a
67 * quarter the volume. The numbers on the right are written out rather than
68 * computed from the constants, because a test that recomputes the code proves
69 * nothing about what a player hears.
70 */
71 test("a footstep is full gain at 256 Source units and a quarter of that at 1,024", () => {
72 const device = recorder();
73 const audio = createAudio(device);
74 audio.play("step", [0, 0, 0], [256, 0, 0]);
75 audio.play("step", [1024, 0, 0], [0, 0, 0]);
76 const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst;
77 expect(near.gain).toBeCloseTo(0.3, 10); // 0.3 × min(1, 256/256)
78 expect(far.gain).toBeCloseTo(0.075, 10); // 0.3 × 256/1024
79 // Past a site and a half it is under a tenth of what it was: an enemy across
80 // the map is not information, and this is the distance that says so.
81 audio.play("step", [2560, 0, 0], [0, 0, 0]);
82 expect((device.bursts[2] as Burst).gain).toBeCloseTo(0.03, 10); // 0.3 × 256/2560
83 });
84
85 test("your own boots are quieter than somebody else's taken in the same place", () => {
86 const device = recorder();
87 const audio = createAudio(device);
88 audio.play("step", [0, 0, 0], [0, 0, 0], true);
89 audio.play("step", [0, 0, 0], [0, 0, 0], false);
90 const mine = device.bursts[0] as Burst, theirs = device.bursts[1] as Burst;
91 expect(mine.gain).toBeCloseTo(0.105, 10); // 0.3 × 0.35, and no distance at all
92 expect(theirs.gain).toBeCloseTo(0.3, 10);
93 expect(mine.gain).toBeLessThan(theirs.gain / 2);
94 });
95
96 test("every noise a round makes has a recipe, and a far one is quieter than a near one", () => {
97 for (const name of SOUNDS) {
98 const device = recorder();
99 const audio = createAudio(device);
100 audio.play(name, [0, 0, 0], [0, 0, 0]);
101 const bursts = [...device.bursts], tones = [...device.tones];
102 expect(bursts.length + tones.length).toBeGreaterThan(0);
103 expect([...bursts, ...tones].every((node) => node.gain > 0)).toBe(true);
104
105 // The same noise three thousand units away, node for node: every one of
106 // them is quieter, and none of them went missing on the way.
107 audio.play(name, [3000, 0, 0], [0, 0, 0]);
108 const far = [...device.bursts.slice(bursts.length), ...device.tones.slice(tones.length)];
109 const near = [...bursts, ...tones];
110 expect(far.length).toBe(near.length);
111 for (let i = 0; i < far.length; i++)
112 expect((far[i] as Burst).gain).toBeLessThan((near[i] as Burst).gain);
57113 }
114 });
115
116 // --- the budget -----------------------------------------------------------
117
118 /** What one recipe asks for at zero distance: the peak gain of every voice in it. */
119 function peak(play: (audio: Audio) => void): number {
120 const device = recorder();
121 play(createAudio(device));
122 return [...device.bursts, ...device.tones].reduce((sum, voice) => sum + voice.gain, 0);
58123 }
59124
60 class FakeGain extends FakeNode {
61 readonly gain = new FakeParam(1);
62 constructor(id: number, onConnect: (edge: Edge) => void) {
63 super(id, "gain", onConnect);
64 }
65 }
125 /**
126 * The headroom, measured rather than asserted. This adds the recipes up from
127 * the recipes — not from a table of numbers copied beside them — and holds the
128 * total against `LOUDEST_INSTANT`, which is the number `src/engine/sound.ts`
129 * sized the master bus and its limiter from. A recipe that grows, or a new loud
130 * name, fails here and is read there.
131 *
132 * The instant is the issue's own: five guns in one room, and the three loudest
133 * other noises a tick can carry in with them. Which three is not written down —
134 * it is whichever three are loudest today — so a new noise louder than a body
135 * falling joins the sum on its own rather than waiting for somebody to notice.
136 */
137 test("the recipes fit the headroom the engine's bus was sized for", () => {
138 const here: Vec3 = [0, 0, 0];
139 const shot = peak((audio) => audio.shot("ak47", here, here));
140 expect(shot).toBeCloseTo(0.65, 9); // 0.35 of noise, 0.30 of thump
66141
67 class FakeStereoPanner extends FakeNode {
68 readonly pan = new FakeParam(0);
69 constructor(id: number, onConnect: (edge: Edge) => void) {
70 super(id, "panner", onConnect);
71 }
72 }
142 const others = SOUNDS
143 .map((name) => ({ name, gain: peak((audio) => audio.play(name, here, here)) }))
144 .sort((a, b) => b.gain - a.gain);
145 expect(others.slice(0, 3)).toEqual([
146 { name: "blast", gain: expect.closeTo(1.25, 9) },
147 { name: "flash", gain: expect.closeTo(0.85, 9) },
148 { name: "death", gain: expect.closeTo(0.58, 9) },
149 ]);
73150
74 class FakeBiquadFilter extends FakeNode {
75 type = "lowpass";
76 readonly frequency = new FakeParam(350);
77 readonly Q = new FakeParam(1);
78 constructor(id: number, onConnect: (edge: Edge) => void) {
79 super(id, "filter", onConnect);
80 }
81 }
151 const worst = 5 * shot + others.slice(0, 3).reduce((sum, one) => sum + one.gain, 0);
152 expect(worst).toBeCloseTo(5.93, 9);
153 // A nanogain of slack, because this is a sum of floats and the budget is a
154 // statement about a firefight rather than about the last bit of a double.
155 expect(worst).toBeLessThanOrEqual(LOUDEST_INSTANT + 1e-9);
82156
83 class FakeBufferSource extends FakeNode {
84 buffer: AudioBuffer | null = null;
85 readonly playbackRate = new FakeParam(1);
86 #onStart: (id: number) => void;
87 #onStop: (id: number) => void;
88 constructor(id: number, onConnect: (edge: Edge) => void, onStart: (id: number) => void, onStop: (id: number) => void) {
89 super(id, "bufferSource", onConnect);
90 this.#onStart = onStart;
91 this.#onStop = onStop;
92 }
93 start(_time: number): void {
94 this.#onStart(this.id);
95 }
96 stop(_time: number): void {
97 this.#onStop(this.id);
98 }
99 }
100
101 class FakeOscillator extends FakeNode {
102 type = "sine";
103 readonly frequency = new FakeParam(440);
104 #onStart: (id: number) => void;
105 #onStop: (id: number) => void;
106 constructor(id: number, onConnect: (edge: Edge) => void, onStart: (id: number) => void, onStop: (id: number) => void) {
107 super(id, "oscillator", onConnect);
108 this.#onStart = onStart;
109 this.#onStop = onStop;
110 }
111 start(_time: number): void {
112 this.#onStart(this.id);
113 }
114 stop(_time: number): void {
115 this.#onStop(this.id);
116 }
117 }
118
119 class FakeAudioContext {
120 readonly sampleRate = 44100;
121 currentTime = 0;
122 state: "running" | "suspended" = "suspended";
123 readonly destination: FakeNode;
124 readonly nodes: FakeNode[] = [];
125 readonly edges: Edge[] = [];
126 readonly started: number[] = [];
127 readonly stopped: number[] = [];
128 #seq = 0;
129
130 constructor() {
131 this.destination = new FakeNode(this.#next(), "destination", (e) => this.edges.push(e));
132 }
133 #next(): number {
134 this.#seq += 1;
135 return this.#seq;
136 }
137 #register<T extends FakeNode>(node: T): T {
138 this.nodes.push(node);
139 return node;
140 }
141 createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer {
142 const data = new Float32Array(length);
143 return { getChannelData: () => data, length, sampleRate, numberOfChannels: channels } as unknown as AudioBuffer;
144 }
145 createGain(): FakeGain {
146 return this.#register(new FakeGain(this.#next(), (e) => this.edges.push(e)));
147 }
148 createStereoPanner(): FakeStereoPanner {
149 return this.#register(new FakeStereoPanner(this.#next(), (e) => this.edges.push(e)));
150 }
151 createBiquadFilter(): FakeBiquadFilter {
152 return this.#register(new FakeBiquadFilter(this.#next(), (e) => this.edges.push(e)));
153 }
154 createBufferSource(): FakeBufferSource {
155 return this.#register(
156 new FakeBufferSource(this.#next(), (e) => this.edges.push(e), (id) => this.started.push(id), (id) => this.stopped.push(id)),
157 );
158 }
159 createOscillator(): FakeOscillator {
160 return this.#register(
161 new FakeOscillator(this.#next(), (e) => this.edges.push(e), (id) => this.started.push(id), (id) => this.stopped.push(id)),
162 );
163 }
164 resume(): Promise<void> {
165 this.state = "running";
166 return Promise.resolve();
167 }
168 }
169
170 /** A constructor `createAudio` can call with `new`, plus every instance it built. */
171 function recorder(): { Ctor: typeof AudioContext; instances: FakeAudioContext[] } {
172 const instances: FakeAudioContext[] = [];
173 class Ctor extends FakeAudioContext {
174 constructor() {
175 super();
176 instances.push(this);
177 }
178 }
179 return { Ctor: Ctor as unknown as typeof AudioContext, instances };
180 }
181
182 const last = (ctx: FakeAudioContext, kind: string): FakeNode =>
183 ctx.nodes.filter((n) => n.kind === kind).at(-1) as FakeNode;
184
185 const LISTENER: Vec3 = [0, 0, 0];
186
187 test("nothing is created before the first sound or resume()", () => {
188 const { Ctor, instances } = recorder();
189 createAudio(Ctor);
190 expect(instances.length).toBe(0);
157 // And the same line of addition is the reason the bus multiplies by what it
158 // multiplies by, so the docstring that does the arithmetic has to hold this
159 // number: read out of the file rather than trusted to stay true.
160 const device = readFileSync(join(import.meta.dir, "..", "engine", "sound.ts"), "utf8");
161 expect(device).toContain(`= ${LOUDEST_INSTANT}`);
191162 });
192163
193 test("resume() is what creates a context built at page load would otherwise need", () => {
194 const { Ctor, instances } = recorder();
195 const audio = createAudio(Ctor);
196 audio.resume();
197 expect(instances.length).toBe(1);
198 expect(instances[0]?.state).toBe("running");
164 test("the bomb counting is a square tone, and the plant climbs where the defuse falls", () => {
165 const device = recorder();
166 const audio = createAudio(device);
167 audio.play("beep", [0, 0, 0], [0, 0, 0]);
168 const beep = device.tones[0] as Tone;
169 expect(beep.type).toBe("square");
170 expect(device.bursts.length).toBe(0); // nothing under it: it has to cut through
171
172 audio.play("plant", [0, 0, 0], [0, 0, 0]);
173 audio.play("defuse", [0, 0, 0], [0, 0, 0]);
174 const plant = device.tones[1] as Tone, defuse = device.tones[2] as Tone;
175 expect(plant.slideTo as number).toBeGreaterThan(plant.frequency);
176 expect(defuse.slideTo as number).toBeLessThan(defuse.frequency);
199177 });
200178
201 test("the first sound creates the context lazily, exactly once", () => {
202 const { Ctor, instances } = recorder();
203 const audio = createAudio(Ctor);
204 audio.beep(900);
205 expect(instances.length).toBe(1);
206 audio.beep(900);
207 audio.shot("ak47", [10, 0, 0], LISTENER, 0);
208 expect(instances.length).toBe(1);
179 test("a headshot beeps higher than a body hit", () => {
180 const device = recorder();
181 const audio = createAudio(device);
182 audio.hit(true);
183 audio.hit(false);
184 const head = device.tones[0] as Tone, body = device.tones[1] as Tone;
185 expect(head.frequency).toBeGreaterThan(body.frequency);
186 expect(head.type).toBe("square");
209187 });
210188
211 test("muted creates no nodes at all, and setMuted(false) brings the sound back", () => {
212 const { Ctor, instances } = recorder();
213 const audio = createAudio(Ctor);
214 audio.setMuted(true);
215 audio.shot("ak47", [10, 0, 0], LISTENER, 0);
216 audio.step([10, 0, 0], LISTENER, 0);
217 audio.beep(900);
218 audio.pain(20);
219 audio.world("beep", [10, 0, 0], LISTENER, 0);
220 expect(instances.length).toBe(0);
221
222 audio.setMuted(false);
223 audio.beep(900);
224 expect(instances.length).toBe(1);
225 expect(instances[0]?.nodes.length).toBeGreaterThan(0);
189 test("muting the voice mutes the device under it", () => {
190 const device = recorder();
191 const audio = createAudio(device);
192 audio.muted = true;
193 expect(device.muted).toBe(true);
194 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
195 audio.play("step", [0, 0, 0], [0, 0, 0]);
196 audio.beep(700);
197 expect(device.bursts).toEqual([]);
198 expect(device.tones).toEqual([]);
199 audio.muted = false;
200 audio.beep(700);
201 expect(device.tones.length).toBe(1);
226202 });
227203
228 test("a volume set before the first sound is applied once master exists", () => {
229 const { Ctor, instances } = recorder();
230 const audio = createAudio(Ctor);
231 audio.setVolume(0.4);
232 audio.beep(900);
233 const master = instances[0]?.nodes[0] as FakeGain;
234 expect(master.kind).toBe("gain");
235 expect(master.gain.value).toBeCloseTo(0.4, 6);
204 test("resuming the voice resumes the device", () => {
205 const device = recorder();
206 createAudio(device).resume();
207 expect(device.resumes).toBe(1);
236208 });
237209
238 test("setVolume(NaN) keeps the previous volume", () => {
239 const { Ctor, instances } = recorder();
240 const audio = createAudio(Ctor);
241 audio.setVolume(0.7);
242 audio.setVolume(NaN);
243 audio.beep(900);
244 const master = instances[0]?.nodes[0] as FakeGain;
245 expect(master.gain.value).toBeCloseTo(0.7, 6);
210 // --- which side it came from ----------------------------------------------
211
212 /**
213 * North is +Y, so an ear facing north is at yaw 90, and from there west (−X) is
214 * its left and east (+X) is its right. 300 units is well past the engine's
215 * softening radius, so these are the full swing: a step that far to one side
216 * comes out of that speaker and nowhere near the other.
217 */
218 const NORTH = 90;
219
220 test("a footstep to the left of a facing ear is negative, to the right positive, ahead zero", () => {
221 const device = recorder();
222 const audio = createAudio(device);
223 audio.play("step", [-300, 0, 0], [0, 0, 0], false, NORTH);
224 audio.play("step", [300, 0, 0], [0, 0, 0], false, NORTH);
225 audio.play("step", [0, 300, 0], [0, 0, 0], false, NORTH);
226 const [left, right, ahead] = device.bursts as [Burst, Burst, Burst];
227 expect(left.pan).toBeLessThan(0);
228 expect(right.pan).toBeGreaterThan(0);
229 expect(ahead.pan).toBeCloseTo(0, 12);
230 // Hard over, both of them, and mirrored: 300 units is a long way past a head.
231 expect(left.pan).toBeCloseTo(-1, 12);
232 expect(right.pan).toBeCloseTo(1, 12);
233 // The thump under the step is placed with it, or the two halves of one
234 // footstep come out of different speakers.
235 expect((device.tones[0] as Tone).pan).toBe(left.pan);
246236 });
247237
248 test("volume is clamped to 0..1", () => {
249 const above = recorder();
250 const audioAbove = createAudio(above.Ctor);
251 audioAbove.setVolume(5);
252 audioAbove.beep(900);
253 expect((above.instances[0]?.nodes[0] as FakeGain).gain.value).toBe(1);
254
255 const below = recorder();
256 const audioBelow = createAudio(below.Ctor);
257 audioBelow.setVolume(-3);
258 audioBelow.beep(900);
259 expect((below.instances[0]?.nodes[0] as FakeGain).gain.value).toBe(0);
238 test("turning to face the sound brings it to the middle, and turning past it swaps sides", () => {
239 const device = recorder();
240 const audio = createAudio(device);
241 // The same step due east of the ear, heard while facing east, north and south.
242 for (const yaw of [0, 90, -90]) audio.play("step", [300, 0, 0], [0, 0, 0], false, yaw);
243 const [facing, backTo, turnedAway] = device.bursts as [Burst, Burst, Burst];
244 expect(facing.pan).toBeCloseTo(0, 12); // straight at it
245 expect(backTo.pan).toBeCloseTo(1, 12); // right shoulder
246 expect(turnedAway.pan).toBeCloseTo(-1, 12); // left shoulder
260247 });
261248
262 test("a shot to the left pans negative and to the right positive, agreeing with ears()", () => {
263 const { Ctor, instances } = recorder();
264 const audio = createAudio(Ctor);
265 const yaw = 0;
266 const leftPos: Vec3 = [100, 100, 0];
267 const rightPos: Vec3 = [100, -100, 0];
249 test("a shot is placed the same way, and every node of a noise carries one pan", () => {
250 const device = recorder();
251 const audio = createAudio(device);
252 audio.shot("ak47", [-800, 0, 0], [0, 0, 0], NORTH);
253 expect((device.bursts[0] as Burst).pan).toBeCloseTo(-1, 12);
254 expect((device.tones[0] as Tone).pan).toBeCloseTo(-1, 12);
268255
269 audio.shot("ak47", leftPos, LISTENER, yaw);
270 const leftPan = (last(instances[0] as FakeAudioContext, "panner") as FakeStereoPanner).pan.value;
271 expect(Math.sign(leftPan)).toBe(Math.sign(ears(leftPos, LISTENER, yaw).pan));
272
273 audio.shot("ak47", rightPos, LISTENER, yaw);
274 const rightPan = (last(instances[0] as FakeAudioContext, "panner") as FakeStereoPanner).pan.value;
275 expect(Math.sign(rightPan)).toBe(Math.sign(ears(rightPos, LISTENER, yaw).pan));
276 expect(Math.sign(leftPan)).not.toBe(Math.sign(rightPan));
277 });
278
279 test("a distant shot's gain is below a near one's", () => {
280 const { Ctor, instances } = recorder();
281 const audio = createAudio(Ctor);
282 const ctx = () => instances[0] as FakeAudioContext;
283 const yaw = 0;
284
285 audio.shot("ak47", [50, 0, 0], LISTENER, yaw);
286 const nearGain = (last(ctx(), "gain") as FakeGain).gain.events[0]?.value as number;
287
288 audio.shot("ak47", [3000, 0, 0], LISTENER, yaw);
289 const farGain = (last(ctx(), "gain") as FakeGain).gain.events[0]?.value as number;
290
291 expect(farGain).toBeLessThan(nearGain);
292 });
293
294 test("pain twice inside 140ms plays once, and after the window it plays again", () => {
295 const { Ctor, instances } = recorder();
296 const audio = createAudio(Ctor);
297 audio.pain(20);
298 const ctx = instances[0] as FakeAudioContext;
299 const afterFirst = ctx.nodes.length;
300 expect(afterFirst).toBeGreaterThan(0);
301
302 ctx.currentTime = 0.1; // inside the 140ms window
303 audio.pain(20);
304 expect(ctx.nodes.length).toBe(afterFirst); // nothing new
305
306 ctx.currentTime = 0.3; // outside it
307 audio.pain(20);
308 expect(ctx.nodes.length).toBeGreaterThan(afterFirst);
309 });
310
311 test('world("beep", …, pitch) uses the pitch it is given, and the table\'s frequency when it is not', () => {
312 const { Ctor, instances } = recorder();
313 const audio = createAudio(Ctor);
314 const ctx = () => instances[0] as FakeAudioContext;
315
316 audio.world("beep", [10, 0, 0], LISTENER, 0, 1200);
317 expect((last(ctx(), "oscillator") as FakeOscillator).frequency.events[0]?.value).toBe(1200);
318
319 audio.world("beep", [10, 0, 0], LISTENER, 0);
320 expect((last(ctx(), "oscillator") as FakeOscillator).frequency.events[0]?.value).toBe(900); // WORLD_TONE.beep.freq
321 });
322
323 test("explode and blast take the same path, and ignore the pitch a beep would use", () => {
324 const { Ctor, instances } = recorder();
325 const audio = createAudio(Ctor);
326 const ctx = () => instances[0] as FakeAudioContext;
327
328 for (const name of ["explode", "blast"] as const) {
329 audio.world(name, [10, 0, 0], LISTENER, 0, 5000);
330 const boom = last(ctx(), "oscillator") as FakeOscillator;
331 expect(boom.frequency.events[0]?.value).toBe(140);
332 const tail = last(ctx(), "bufferSource") as FakeBufferSource;
333 expect(tail.buffer).not.toBeNull();
256 // Every recipe places every voice it builds: a name that forgot would leave
257 // half a noise in the middle while the other half is off to one side.
258 for (const name of SOUNDS) {
259 const one = recorder();
260 createAudio(one).play(name, [900, 0, 0], [0, 0, 0], false, NORTH);
261 const voices = [...one.bursts, ...one.tones];
262 expect(voices.length).toBeGreaterThan(0);
263 for (const voice of voices) expect([name, voice.pan]).toEqual([name, expect.closeTo(1, 12)]);
334264 }
335265 });
336266
337 test("a name with no tone and no special case creates nothing but the panner", () => {
338 const { Ctor, instances } = recorder();
339 const audio = createAudio(Ctor);
340 audio.beep(900); // gets the context and its master gain out of the way
341 const ctx = instances[0] as FakeAudioContext;
342 const before = ctx.nodes.length;
343
344 audio.world("nope" as unknown as WorldSoundName, [10, 0, 0], LISTENER, 0);
345 expect(ctx.nodes.length).toBe(before + 1);
346 expect(ctx.nodes[ctx.nodes.length - 1]?.kind).toBe("panner");
267 test("your own boots and your own gun stay in the middle", () => {
268 const device = recorder();
269 const audio = createAudio(device);
270 // A noise you made is passed no pan at all: there is no side for it.
271 audio.play("step", [0, 0, 0], [0, 0, 0], true, NORTH);
272 expect((device.bursts[0] as Burst).pan).toBeUndefined();
273 expect((device.tones[0] as Tone).pan).toBeUndefined();
274 // Your own gun fires from the ear it is heard with, so it works out centred
275 // without a special case.
276 audio.shot("ak47", [0, 0, 0], [0, 0, 0], NORTH);
277 expect((device.bursts[1] as Burst).pan).toBe(0);
347278 });
348279
349 test("a hundred shots cost a fixed number of nodes, and every source and oscillator that starts is stopped", () => {
350 const { Ctor, instances } = recorder();
351 const audio = createAudio(Ctor);
352 for (let i = 0; i < 100; i += 1) audio.shot("ak47", [i, 0, 0], LISTENER, 0);
353
354 const ctx = instances[0] as FakeAudioContext;
355 // One master gain, plus a panner, a source, a filter, a gain, an
356 // oscillator and its own gain per shot: 1 + 100 * 6, not one node more.
357 expect(ctx.nodes.length).toBe(1 + 100 * 6);
358 expect(ctx.started.length).toBe(200); // the noise burst and the thump, per shot
359 expect(ctx.stopped.length).toBe(ctx.started.length);
360 expect(new Set(ctx.stopped)).toEqual(new Set(ctx.started));
280 test("a caller that names no heading is the game as it was: every sound ahead-and-centre of due east", () => {
281 const device = recorder();
282 const audio = createAudio(device);
283 // No yaw given is yaw 0 — facing +X — which is the old behaviour for anything
284 // directly in front, and still a real pan for anything off to a side.
285 audio.play("blast", [1000, 0, 0], [0, 0, 0]);
286 expect((device.bursts[0] as Burst).pan).toBeCloseTo(0, 12);
361287 });
src/client/audio.ts +221 −278
@@ -1,36 +1,53 @@
11 /**
2 * Sound, synthesised. A gunshot is a noise burst through a resonant filter
3 * plus a low thump; the rest are short tones. Nothing is downloaded, and the
4 * context is only created once the person has clicked something.
2 * The game's voice. A gunshot is a noise burst through a resonant filter plus a
3 * low thump, and each weapon kind gets its own colour, length and thump; a hit
4 * and a buy are short tones. Nothing is downloaded, and the engine's device
5 * only opens a context once the person has clicked something.
6 *
7 * Everything the round itself makes — a foot landing, the bomb's beep, the
8 * plant, the cut wire, a body going down — is one recipe per name under `play`,
9 * positioned through the engine's `attenuate` and `panOf` from the ear the
10 * frame is listening with: how far away it is sets the gain, and which side of
11 * the ear's heading it is on sets the pan. Front and back are not distinguished
12 * and cannot be with two speakers — a step ahead and a step behind are both
13 * centre, and that is the whole of what this device claims.
14 *
15 * The distances are in Source units (1 unit ≈ 1.9 cm) and are
16 * the numbers below: a sound is at its recipe's full gain inside its earshot
17 * and falls as `earshot / distance` past it, so a footstep at 256 units is as
18 * loud as a footstep can be and the same one at 1,024 is a quarter of that.
519 */
620
7 import { ears } from "./ears.ts";
8 import type { Vec3 } from "./math.ts";
21 import { attenuate, createSoundDevice, dist, panOf, type SoundDevice, type Vec3 } from "../engine/index.ts";
922 import { WEAPONS, type WeaponId } from "./weapons.ts";
10 import type { SoundName } from "./world.ts";
11
12 /** Every named world sound `world()` below knows how to play — everything but a footstep, which keeps its own dedicated `step`. */
13 export type WorldSoundName = Exclude<SoundName, "step">;
23 import type { Sound } from "./world.ts";
1424
1525 export type Audio = {
16 shot(weapon: WeaponId, at: Vec3, listener: Vec3, listenerYaw: number): void;
17 step(at: Vec3, listener: Vec3, listenerYaw: number): void;
18 hit(headshot: boolean): void;
19 pain(amount: number): void;
20 beep(pitch: number, gain?: number): void;
2126 /**
22 * One named world sound — the bomb's own beep, a plant, a defuse, the
23 * blast that ends it, a grenade's bounce and its own blast, a dry trigger
24 * — panned and attenuated through `ears` exactly like `shot` and `step`.
25 * Each name has its own timbre; `pitch` overrides the default for
26 * `"beep"`, which climbs as the fuse runs down.
27 * A weapon going off at `at`, heard from `listener` facing `yaw` degrees.
28 * Your own gun needs no special case: it is fired from the ear it is heard
29 * with, so `panOf` softens it to dead centre on its own.
2730 */
28 world(name: WorldSoundName, at: Vec3, listener: Vec3, listenerYaw: number, pitch?: number): void;
31 shot(weapon: WeaponId, at: Vec3, listener: Vec3, yaw?: number): void;
32 /**
33 * One noise of the round, by name, placed where it happened and heard from
34 * `listener`. `own` is true when the body that made it is the body these ears
35 * belong to, which is the whole of why your own boots are quieter than
36 * somebody else's standing where you are.
37 *
38 * One path widened by a name rather than a voice per noise: a grenade leaving
39 * the hand and a foot landing are the same two nodes with different numbers
40 * in them, and the day a sixth round noise is wanted it is a `case` here.
41 *
42 * `yaw` is the ear's heading in degrees; a noise you made yourself is passed
43 * no pan at all, because your own boots come from where you are standing and
44 * a panner on them is a node that does nothing.
45 */
46 play(sound: Sound, at: Vec3, listener: Vec3, own?: boolean, yaw?: number): void;
47 hit(headshot: boolean): void;
48 beep(pitch: number, gain?: number): void;
2949 resume(): void;
30 /** Mute is silence at the one node everything goes through, not a per-sound skip. */
31 setMuted(muted: boolean): void;
32 setVolume(volume: number): void;
33 readonly muted: boolean;
50 muted: boolean;
3451 };
3552
3653 const TONE: Record<string, { cut: number; thump: number; length: number }> = {
@@ -39,278 +56,204 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = {
3956 rifle: { cut: 1500, thump: 110, length: 0.24 },
4057 sniper: { cut: 1000, thump: 80, length: 0.5 },
4158 knife: { cut: 4200, thump: 400, length: 0.08 },
59 // Never heard: a grenade is thrown rather than fired, and its three noises
60 // are below. It is here so the table covers every kind a weapon can be.
61 grenade: { cut: 2600, thump: 300, length: 0.1 },
4262 };
4363
44 /** One tone per named world sound: a square blip for the bomb's own beep,
45 * two-note chirps for the plant and the defuse, a click for a dry trigger.
46 * `explode`, `blast` and `bounce` are noise, not a tone, and have no entry
47 * here — `world()` below synthesises those directly. */
48 const WORLD_TONE: Partial<Record<WorldSoundName, { freq: number; type: OscillatorType; length: number }>> = {
49 beep: { freq: 900, type: "square", length: 0.08 },
50 plant: { freq: 420, type: "sine", length: 0.22 },
51 defuse: { freq: 900, type: "sine", length: 0.3 },
52 empty: { freq: 220, type: "square", length: 0.04 },
53 };
54
55 type Live = { ctx: AudioContext; noise: AudioBuffer; master: GainNode };
56
57 /** The browser default: `webkitAudioContext` is Safari's old name for it. */
58 const defaultContext = (): typeof AudioContext | undefined =>
59 window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
64 /** Full volume within 120 units, and audible across the map after that. */
65 const EARSHOT = 900;
66 const CLOSE = 120;
6067
6168 /**
62 * `Ctor` is the seam a test supplies a recording `AudioContext` through — the
63 * same trick `renderer.test.ts` plays on `getContext`. Nothing calls it
64 * before the first sound or `resume()`, since a context built at page load is
65 * one the browser suspends anyway.
69 * A blast carries much further than a gunshot and is loud over a much wider
70 * circle: full volume out to 300 units and still worth hearing at 2,400.
6671 */
67 export function createAudio(Ctor: typeof AudioContext | undefined = defaultContext()): Audio {
68 let live: Live | null = null;
69 let lastPain = -1;
70 let volume = 1;
71 let muted = false;
72 const BLAST_EARSHOT = 2400;
73 const BLAST_CLOSE = 300;
7274
73 const applyGain = (): void => {
74 if (live !== null) live.master.gain.value = muted ? 0 : volume;
75 };
75 /**
76 * A footstep, in Source units: full gain inside 256 (about five metres, a room)
77 * and `256 / distance` past it, so a body at 1,024 units — the far side of a
78 * site — is heard at a quarter of that, and one at 2,560 at a tenth, which is
79 * under the noise of a round being fought. That is the distance the game
80 * intends: an enemy crossing a site is information, an enemy across the map is
81 * not.
82 */
83 const STEP_EARSHOT = 256;
84 const STEP_CLOSE = 48;
85 const STEP_GAIN = 0.3;
7686
77 // The one node the whole of this file plays into: a volume set before the
78 // first click just waits here for `master` to exist to apply itself to.
79 const ensure = (): Live | null => {
80 if (live === null) {
81 if (Ctor === undefined) return null;
82 const ctx = new Ctor();
83 const noise = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
84 const data = noise.getChannelData(0);
85 for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
86 const master = ctx.createGain();
87 master.connect(ctx.destination);
88 live = { ctx, noise, master };
89 applyGain();
90 }
91 if (live.ctx.state === "suspended") void live.ctx.resume();
92 return live;
93 };
87 /**
88 * What your own boots are worth beside somebody else's: a little over a third.
89 * They are the only footsteps in the game that are always at zero distance, so
90 * without this they would be the loudest thing in every round — and the whole
91 * point of the sound is hearing the other one.
92 */
93 const OWN_STEP = 0.35;
9494
95 /**
96 * The bomb: the beep, the plant and the cut wire. 600 units of full gain is the
97 * site and the ways on to it — a defuse behind you is meant to be heard from
98 * where you are holding — and it falls off from there like everything else.
99 */
100 const BOMB_EARSHOT = 600;
101 const BOMB_CLOSE = 64;
102
103 /** A body going down: heard across the room it fell in, not across the map. */
104 const DEATH_EARSHOT = 400;
105 const DEATH_CLOSE = 64;
106
107 /**
108 * The loudest instant these recipes can ask a device for, as peak gains summed:
109 * a grenade landing in a five-man crossfire. Five gunshots (0.35 of noise and
110 * 0.30 of thump each), the blast (0.70 + 0.55), the flash that came with it
111 * (0.55 + 0.30) and a body hitting the floor (0.34 + 0.24) — every one of them
112 * inside its own full-volume radius, and every voice of it starting at the same
113 * instant, because a frame plays everything that happened since the last frame
114 * at one `currentTime`:
115 *
116 * 5 × 0.65 + 1.25 + 0.85 + 0.58 = 5.93
117 *
118 * Nearly six times the 1.0 a sample can hold, which is what a firefight sounded
119 * like before #211: not a firefight, a crackle. The number lives here because it
120 * is a fact about these recipes and about nothing else, and it is what the
121 * engine's master bus was sized against — `MASTER_GAIN` in `src/engine/sound.ts`
122 * multiplies it down and the limiter on the end of that bus catches the rest.
123 * `audio.test.ts` adds the recipes up from the recipes themselves, fails when a
124 * new one grows past this, and holds this number against the arithmetic written
125 * in `sound.ts` so that the two cannot drift apart.
126 */
127 export const LOUDEST_INSTANT = 5.93;
128
129 /** The shot a weapon makes: its filter colour, its thump and how long it rings. */
130 export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } =>
131 TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number });
132
133 /** How loud a shot fired at `at` is to someone standing at `listener`. */
134 export const loudness = (at: Vec3, listener: Vec3): number =>
135 attenuate(dist(at, listener), EARSHOT, CLOSE);
136
137 export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
95138 const audio: Audio = {
96139 get muted() {
97 return muted;
140 return device.muted;
98141 },
99 setMuted(next) {
100 muted = next;
101 applyGain();
142 set muted(value: boolean) {
143 device.muted = value;
102144 },
103 setVolume(next) {
104 volume = Number.isFinite(next) ? Math.min(1, Math.max(0, next)) : volume;
105 applyGain();
145 resume: () => device.resume(),
146 shot(weapon, at, listener, yaw = 0) {
147 const tone = shotTone(weapon);
148 const volume = loudness(at, listener);
149 const pan = panOf(at, listener, yaw);
150 device.noiseBurst({
151 frequency: tone.cut,
152 gain: 0.35 * volume,
153 length: tone.length,
154 q: 0.9,
155 rate: 1 + Math.random() * 0.15,
156 pan,
157 });
158 device.tone({
159 frequency: tone.thump,
160 slideTo: tone.thump * 0.4,
161 gain: 0.3 * volume,
162 length: tone.length,
163 pan,
164 });
106165 },
107 resume: () => void ensure(),
108 shot(weapon, at, listener, listenerYaw) {
109 if (muted) return;
110 const live = ensure();
111 if (live === null) return;
112 const { ctx: context, noise, master } = live;
113 const kind = WEAPONS[weapon]?.kind ?? "rifle";
114 const tone = TONE[kind] ?? TONE.rifle;
115 const { pan, volume } = ears(at, listener, listenerYaw);
116 const now = context.currentTime;
117 const panner = context.createStereoPanner();
118 panner.pan.value = pan;
119 panner.connect(master);
120
121 const source = context.createBufferSource();
122 source.buffer = noise;
123 source.playbackRate.value = 1 + Math.random() * 0.15;
124 const filter = context.createBiquadFilter();
125 filter.type = "bandpass";
126 filter.frequency.value = tone.cut;
127 filter.Q.value = 0.9;
128 const gain = context.createGain();
129 gain.gain.setValueAtTime(0.35 * volume, now);
130 gain.gain.exponentialRampToValueAtTime(0.0008, now + tone.length);
131 source.connect(filter).connect(gain).connect(panner);
132 source.start(now);
133 source.stop(now + tone.length);
134
135 const thump = context.createOscillator();
136 thump.frequency.setValueAtTime(tone.thump, now);
137 thump.frequency.exponentialRampToValueAtTime(tone.thump * 0.4, now + tone.length);
138 const thumpGain = context.createGain();
139 thumpGain.gain.setValueAtTime(0.3 * volume, now);
140 thumpGain.gain.exponentialRampToValueAtTime(0.0008, now + tone.length);
141 thump.connect(thumpGain).connect(panner);
142 thump.start(now);
143 thump.stop(now + tone.length);
144 },
145 /** A footstep: a short low tap, panned and faded the same way a shot is. */
146 step(at, listener, listenerYaw) {
147 if (muted) return;
148 const live = ensure();
149 if (live === null) return;
150 const { ctx: context, noise, master } = live;
151 const { pan, volume } = ears(at, listener, listenerYaw);
152 const now = context.currentTime;
153 const length = 0.09;
154
155 const source = context.createBufferSource();
156 source.buffer = noise;
157 source.playbackRate.value = 0.5 + Math.random() * 0.15;
158 const filter = context.createBiquadFilter();
159 filter.type = "lowpass";
160 filter.frequency.value = 320;
161 const gain = context.createGain();
162 gain.gain.setValueAtTime(0.14 * volume, now);
163 gain.gain.exponentialRampToValueAtTime(0.0008, now + length);
164 const panner = context.createStereoPanner();
165 panner.pan.value = pan;
166 source.connect(filter).connect(gain).connect(panner).connect(master);
167 source.start(now);
168 source.stop(now + length);
166 play(sound, at, listener, own = false, yaw = 0) {
167 const far = dist(at, listener);
168 // Your own noise is not placed: it happens at the ear, so there is no
169 // side for it to be on and no panner worth building for it.
170 const pan = own ? undefined : panOf(at, listener, yaw);
171 if (sound === "step") {
172 // A foot landing: a scuff of grit through a wide filter with a soft
173 // thump under it. The rate is jittered so a body at a dead run is a
174 // walk and not one sample played twelve times.
175 const volume = attenuate(far, STEP_EARSHOT, STEP_CLOSE) * (own ? OWN_STEP : 1);
176 device.noiseBurst({ frequency: 520, gain: STEP_GAIN * volume, length: 0.1, q: 1.1, rate: 0.85 + Math.random() * 0.3, pan });
177 device.tone({ frequency: 95, slideTo: 55, gain: 0.16 * volume, length: 0.09, pan });
178 return;
179 }
180 if (sound === "beep") {
181 // The bomb, counting. A square tone and nothing under it: it has to cut
182 // through a firefight, and it is the one sound here that repeats.
183 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
184 device.tone({ frequency: 2200, gain: 0.2 * volume, length: 0.07, type: "square", floor: 0.0005, tail: 0.01, pan });
185 return;
186 }
187 if (sound === "plant") {
188 // The bomb going down: the clunk of it landing, and a tone that climbs
189 // — the round just turned over, and it turned the terrorists' way.
190 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
191 device.noiseBurst({ frequency: 1400, gain: 0.3 * volume, length: 0.09, q: 5, rate: 1, pan });
192 device.tone({ frequency: 300, slideTo: 900, gain: 0.26 * volume, length: 0.3, pan });
193 return;
194 }
195 if (sound === "defuse") {
196 // The wire, cut: a bright snip and a tone that falls away under it. It
197 // is the plant's noise backwards on purpose — the same event, undone.
198 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
199 device.noiseBurst({ frequency: 2600, gain: 0.26 * volume, length: 0.07, q: 6, rate: 1, pan });
200 device.tone({ frequency: 900, slideTo: 300, gain: 0.26 * volume, length: 0.3, pan });
201 return;
202 }
203 if (sound === "death") {
204 // A body hitting the floor: low, wide, short, with a thump that falls
205 // away. No cry — the game has no voices, and a fall carries further.
206 const volume = attenuate(far, DEATH_EARSHOT, DEATH_CLOSE);
207 device.noiseBurst({ frequency: 240, gain: 0.34 * volume, length: 0.35, q: 0.8, rate: 0.8, pan });
208 device.tone({ frequency: 110, slideTo: 50, gain: 0.24 * volume, length: 0.3, pan });
209 return;
210 }
211 if (sound === "throw") {
212 // A short upward whoosh of air: quiet, and only near you.
213 device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4, pan });
214 return;
215 }
216 if (sound === "bounce") {
217 // A hard tap: the shell against concrete, tight and short.
218 const volume = loudness(at, listener);
219 device.noiseBurst({ frequency: 1200, gain: 0.22 * volume, length: 0.07, q: 4, rate: 1, pan });
220 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08, pan });
221 return;
222 }
223 if (sound === "smoke") {
224 // The canister opening: no bang at all. A long, soft, wide hiss that
225 // fades away rather than cracks — it is the one grenade noise in this
226 // game that is a *process* and not an event, and it is quiet, because
227 // what a smoke costs you is what you can see and never what you hear.
228 // It carries like a footstep does and not like a blast: a cloud across
229 // the map is not information anybody should get for free.
230 const soft = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
231 device.noiseBurst({ frequency: 3200, gain: 0.2 * soft, length: 1.4, q: 0.5, rate: 0.9, pan });
232 device.tone({ frequency: 420, slideTo: 210, gain: 0.1 * soft, length: 0.9, pan });
233 return;
234 }
235 if (sound === "flash") {
236 // The flash: the opposite noise to the blast in every way. A bright,
237 // tight crack with no body under it and a ring above it that climbs
238 // rather than falls — what a very loud, very short bang leaves in your
239 // ears. It carries as far as the blast does, and it matters over a
240 // wider circle than one, because so does the light.
241 const bright = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
242 device.noiseBurst({ frequency: 5200, gain: 0.55 * bright, length: 0.28, q: 1.2, rate: 1.5, pan });
243 device.tone({ frequency: 2600, slideTo: 4200, gain: 0.3 * bright, length: 0.5, pan });
244 return;
245 }
246 // The blast: a wide, low body of noise with a thump under it that falls
247 // an octave and a half as it goes.
248 const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
249 device.noiseBurst({ frequency: 360, gain: 0.7 * volume, length: 1.1, q: 0.35, rate: 0.6, pan });
250 device.tone({ frequency: 130, slideTo: 42, gain: 0.55 * volume, length: 0.85, pan });
169251 },
170252 hit(headshot) {
171253 audio.beep(headshot ? 1400 : 900, 0.08);
172254 },
173 /**
174 * Taking damage: a low grunt sliding down, nothing like the square-wave
175 * beep the shooter hears. A rifle burst lands four of these inside a
176 * quarter of a second, so one every 140ms is all that is let through —
177 * the rest would be noise on top of a sound already playing.
178 */
179 pain(amount) {
180 if (muted) return;
181 const live = ensure();
182 if (live === null) return;
183 const { ctx: context, noise, master } = live;
184 const now = context.currentTime;
185 if (now - lastPain < 0.14) return;
186 lastPain = now;
187 const hurt = Math.min(1, Math.max(0.15, amount / 50));
188 const length = 0.18 + hurt * 0.12;
189
190 const osc = context.createOscillator();
191 osc.type = "sawtooth";
192 osc.frequency.setValueAtTime(240 + hurt * 90, now);
193 osc.frequency.exponentialRampToValueAtTime(110, now + length);
194 const shape = context.createBiquadFilter();
195 shape.type = "lowpass";
196 shape.frequency.value = 900;
197 const gain = context.createGain();
198 gain.gain.setValueAtTime(0.0001, now);
199 gain.gain.exponentialRampToValueAtTime(0.16 * hurt, now + 0.02);
200 gain.gain.exponentialRampToValueAtTime(0.0005, now + length);
201 osc.connect(shape).connect(gain).connect(master);
202 osc.start(now);
203 osc.stop(now + length + 0.02);
204
205 // A breath of noise under it, so it reads as a body and not a synth.
206 const breath = context.createBufferSource();
207 breath.buffer = noise;
208 breath.playbackRate.value = 0.6;
209 const band = context.createBiquadFilter();
210 band.type = "bandpass";
211 band.frequency.value = 500;
212 band.Q.value = 1.2;
213 const breathGain = context.createGain();
214 breathGain.gain.setValueAtTime(0.07 * hurt, now);
215 breathGain.gain.exponentialRampToValueAtTime(0.0005, now + length * 0.8);
216 breath.connect(band).connect(breathGain).connect(master);
217 breath.start(now);
218 breath.stop(now + length);
219 },
220255 beep(pitch, gain = 0.06) {
221 if (muted) return;
222 const live = ensure();
223 if (live === null) return;
224 const { ctx: context, master } = live;
225 const now = context.currentTime;
226 const osc = context.createOscillator();
227 osc.type = "square";
228 osc.frequency.value = pitch;
229 const gainNode = context.createGain();
230 gainNode.gain.setValueAtTime(gain, now);
231 gainNode.gain.exponentialRampToValueAtTime(0.0005, now + 0.09);
232 osc.connect(gainNode).connect(master);
233 osc.start(now);
234 osc.stop(now + 0.1);
235 },
236 world(name, at, listener, listenerYaw, pitch) {
237 if (muted) return;
238 const live = ensure();
239 if (live === null) return;
240 const { ctx: context, noise, master } = live;
241 const { pan, volume } = ears(at, listener, listenerYaw);
242 const now = context.currentTime;
243 const panner = context.createStereoPanner();
244 panner.pan.value = pan;
245 panner.connect(master);
246
247 // An explosion, whichever set it off: a sine falling through octaves
248 // for the punch in the chest, and a long low-passed tail of noise over
249 // it for the debris. The bomb and a thrown HE are the same event to an
250 // ear a hundred units away, so they are the same code.
251 if (name === "explode" || name === "blast") {
252 const length = 0.9;
253 const tail = context.createBufferSource();
254 tail.buffer = noise;
255 tail.playbackRate.value = 0.35;
256 const filter = context.createBiquadFilter();
257 filter.type = "lowpass";
258 filter.frequency.setValueAtTime(3200, now);
259 filter.frequency.exponentialRampToValueAtTime(220, now + length);
260 const tailGain = context.createGain();
261 tailGain.gain.setValueAtTime(0.45 * volume, now);
262 tailGain.gain.exponentialRampToValueAtTime(0.0008, now + length);
263 tail.connect(filter).connect(tailGain).connect(panner);
264 tail.start(now);
265 tail.stop(now + length);
266
267 const boom = context.createOscillator();
268 boom.type = "sine";
269 boom.frequency.setValueAtTime(140, now);
270 boom.frequency.exponentialRampToValueAtTime(30, now + length * 0.7);
271 const boomGain = context.createGain();
272 boomGain.gain.setValueAtTime(0.5 * volume, now);
273 boomGain.gain.exponentialRampToValueAtTime(0.0008, now + length);
274 boom.connect(boomGain).connect(panner);
275 boom.start(now);
276 boom.stop(now + length);
277 return;
278 }
279
280 // A grenade off a wall: a tick of filtered noise, shorter than a footstep.
281 if (name === "bounce") {
282 const length = 0.07;
283 const source = context.createBufferSource();
284 source.buffer = noise;
285 source.playbackRate.value = 1.4 + Math.random() * 0.3;
286 const filter = context.createBiquadFilter();
287 filter.type = "bandpass";
288 filter.frequency.value = 1700;
289 filter.Q.value = 2.4;
290 const gain = context.createGain();
291 gain.gain.setValueAtTime(0.11 * volume, now);
292 gain.gain.exponentialRampToValueAtTime(0.0008, now + length);
293 source.connect(filter).connect(gain).connect(panner);
294 source.start(now);
295 source.stop(now + length);
296 return;
297 }
298
299 const tone = WORLD_TONE[name];
300 if (tone === undefined) return;
301 const osc = context.createOscillator();
302 osc.type = tone.type;
303 const start = pitch ?? tone.freq;
304 osc.frequency.setValueAtTime(start, now);
305 // A plant climbs like it is arming; a defuse settles like it is done.
306 if (name === "plant") osc.frequency.exponentialRampToValueAtTime(start * 1.7, now + tone.length);
307 else if (name === "defuse") osc.frequency.exponentialRampToValueAtTime(start * 0.7, now + tone.length);
308 const gain = context.createGain();
309 gain.gain.setValueAtTime((name === "empty" ? 0.1 : 0.16) * volume, now);
310 gain.gain.exponentialRampToValueAtTime(0.0005, now + tone.length);
311 osc.connect(gain).connect(panner);
312 osc.start(now);
313 osc.stop(now + tone.length + 0.02);
256 device.tone({ frequency: pitch, gain, length: 0.09, type: "square", floor: 0.0005, tail: 0.01 });
314257 },
315258 };
316259 return audio;
src/client/bind.test.ts deleted −62
@@ -1,62 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { ACTION_LABELS, decideBind, labelForCode } from "./bind.ts";
3 import { ACTIONS, DEFAULT_BINDINGS } from "./input.ts";
4
5 test("a fresh code nothing else holds is accepted, and only that action moves", () => {
6 const outcome = decideBind("forward", "KeyG", DEFAULT_BINDINGS);
7 expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, forward: "KeyG" } });
8 });
9
10 test("a reserved code — the movement and crouch fallback — is refused, and says why", () => {
11 for (const code of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]) {
12 const outcome = decideBind("jump", code, DEFAULT_BINDINGS);
13 expect(outcome.ok).toBe(false);
14 expect(outcome).toMatchObject({ reason: expect.stringContaining("reserved") });
15 }
16 });
17
18 test("a code another action already holds is refused, naming that action", () => {
19 const outcome = decideBind("jump", DEFAULT_BINDINGS.forward, DEFAULT_BINDINGS);
20 expect(outcome.ok).toBe(false);
21 expect(outcome).toMatchObject({ reason: expect.stringContaining("Move forward") });
22 });
23
24 test("rebinding an action to the code it already has is a no-op success, not a self-conflict", () => {
25 const outcome = decideBind("forward", DEFAULT_BINDINGS.forward, DEFAULT_BINDINGS);
26 expect(outcome).toEqual({ ok: true, bindings: DEFAULT_BINDINGS });
27 });
28
29 test("a refusal never changes the map: the caller still holds the bindings it started with", () => {
30 const outcome = decideBind("jump", "KeyC", DEFAULT_BINDINGS);
31 expect(outcome.ok).toBe(false);
32 // decideBind returns no bindings at all on refusal — there is nothing for
33 // a caller to accidentally apply.
34 expect((outcome as { bindings?: unknown }).bindings).toBeUndefined();
35 });
36
37 test("labelForCode reads a code the way a player would say it", () => {
38 expect(labelForCode("KeyW")).toBe("W");
39 expect(labelForCode("Digit1")).toBe("1");
40 expect(labelForCode("Space")).toBe("Space");
41 expect(labelForCode("ControlLeft")).toBe("Left Ctrl");
42 expect(labelForCode("ArrowUp")).toBe("Arrow Up");
43 expect(labelForCode("Numpad5")).toBe("Numpad 5");
44 });
45
46 test("labelForCode never throws on a code it does not recognise — it just shows the code", () => {
47 expect(labelForCode("IntlBackslash")).toBe("IntlBackslash");
48 });
49
50 test("the radio is one of the bindable actions: a default key, a label, and rebindable", () => {
51 // It reaches the Controls panel the same way every other action does — the
52 // panel is built from ACTIONS — so being on that list is the whole wiring.
53 expect(ACTIONS).toContain("radio");
54 expect(DEFAULT_BINDINGS.radio).toBe("KeyZ");
55 expect(ACTION_LABELS.radio).toBe("Radio");
56 const outcome = decideBind("radio", "KeyG", DEFAULT_BINDINGS);
57 expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, radio: "KeyG" } });
58 // And it takes part in the collision rule rather than sitting outside it.
59 expect(decideBind("buy", "KeyZ", DEFAULT_BINDINGS)).toEqual({
60 ok: false, reason: "Z is already bound to Radio.",
61 });
62 });
src/client/bind.ts deleted −55
@@ -1,55 +0,0 @@
1 /**
2 * The rebind panel's rules: what a captured key does to a binding map, and
3 * what a key or an action is called on the screen. Pure data in, plain data
4 * out — nothing here touches the DOM, so main.ts is free to paint it however
5 * a click or a screen reader needs, the way buy.ts already keeps the buy
6 * panel's rules out of main.ts.
7 */
8
9 import { ACTIONS, RESERVED_CODES, type Action } from "./input.ts";
10
11 export const ACTION_LABELS: Record<Action, string> = {
12 forward: "Move forward", back: "Move back", left: "Strafe left", right: "Strafe right",
13 jump: "Jump", duck: "Crouch", walk: "Walk",
14 reload: "Reload", use: "Use / plant / defuse", buy: "Buy menu", radio: "Radio",
15 scoreboard: "Scoreboard", scope: "Scope",
16 slot1: "Slot 1 — rifle", slot2: "Slot 2 — pistol", slot3: "Slot 3 — knife", slot4: "Slot 4 — grenade",
17 };
18
19 const CODE_LABELS: Record<string, string> = {
20 Space: "Space", Tab: "Tab", Escape: "Escape", CapsLock: "Caps Lock", Enter: "Enter", Backspace: "Backspace",
21 ControlLeft: "Left Ctrl", ControlRight: "Right Ctrl", ShiftLeft: "Left Shift", ShiftRight: "Right Shift",
22 AltLeft: "Left Alt", AltRight: "Right Alt", Backquote: "`", Minus: "-", Equal: "=",
23 BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'", Comma: ",", Period: ".",
24 Slash: "/", Backslash: "\\",
25 };
26
27 /** A key code, as a player reads it — not as the DOM names it. */
28 export function labelForCode(code: string): string {
29 if (code.startsWith("Key")) return code.slice(3);
30 if (code.startsWith("Digit")) return code.slice(5);
31 if (code.startsWith("Numpad")) return `Numpad ${code.slice(6)}`;
32 if (code.startsWith("Arrow")) return `Arrow ${code.slice(5)}`;
33 return CODE_LABELS[code] ?? code;
34 }
35
36 export type BindOutcome =
37 | { ok: true; bindings: Record<Action, string> }
38 | { ok: false; reason: string };
39
40 /**
41 * What a captured key does to `action`'s binding: reserved codes (the
42 * movement/crouch fallback) are refused outright, a code another action
43 * already holds is refused with which one, and otherwise the map gets the
44 * one change asked for and nothing else moves.
45 */
46 export function decideBind(action: Action, code: string, bindings: Record<Action, string>): BindOutcome {
47 if (RESERVED_CODES.has(code)) {
48 return { ok: false, reason: `${labelForCode(code)} is reserved for movement and can't be bound.` };
49 }
50 const takenBy = ACTIONS.find((other) => other !== action && bindings[other] === code);
51 if (takenBy !== undefined) {
52 return { ok: false, reason: `${labelForCode(code)} is already bound to ${ACTION_LABELS[takenBy]}.` };
53 }
54 return { ok: true, bindings: { ...bindings, [action]: code } };
55 }
src/client/binding.test.ts added +139
@@ -0,0 +1,139 @@
1 import { expect, test } from "bun:test";
2 import { CS_KEYS, CS_MOUSE, CS_TAP, CS_WHEEL, NUMBER_ROW, readIntent, slotAction, slotIndex } from "./binding.ts";
3
4 const NONE = { x: 0, y: 0 };
5 const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick);
6
7 test("the bindings are the ones the keyboard has always had", () => {
8 expect(CS_KEYS.KeyW).toBe("forward");
9 expect(CS_KEYS.KeyS).toBe("back");
10 expect(CS_KEYS.KeyA).toBe("left");
11 expect(CS_KEYS.KeyD).toBe("right");
12 expect(CS_KEYS.ArrowUp).toBe("forward");
13 expect(CS_KEYS.Space).toBe("jump");
14 expect(CS_KEYS.ControlLeft).toBe("duck");
15 expect(CS_KEYS.KeyC).toBe("duck");
16 expect(CS_KEYS.ShiftLeft).toBe("walk");
17 expect(CS_KEYS.KeyR).toBe("reload");
18 expect(CS_KEYS.KeyE).toBe("use");
19 expect(CS_KEYS.KeyB).toBe("buy");
20 expect(CS_KEYS.KeyG).toBe("grenade");
21 expect(CS_KEYS.KeyT).toBe("flash");
22 expect(CS_KEYS.KeyV).toBe("smoke");
23 expect(CS_KEYS.Tab).toBe("scoreboard");
24 expect(CS_KEYS.Digit1).toBe("slot1");
25 expect(CS_KEYS.Digit3).toBe("slot3");
26 expect(CS_MOUSE[0]).toBe("fire");
27 expect(CS_MOUSE[2]).toBe("scope");
28 // The whole mouse, because an unbound button is one the browser keeps: 3 and
29 // 4 are Back and Forward on Windows, and the wheel pressed is the quick knife.
30 expect(CS_MOUSE[1]).toBe("knife");
31 expect(CS_MOUSE[3]).toBe("use");
32 expect(CS_MOUSE[4]).toBe("grenade");
33 // The wheel jumps whichever way it is turned, which is how half of this
34 // game's players jump at all.
35 expect(CS_WHEEL.up).toBe("jump");
36 expect(CS_WHEEL.down).toBe("jump");
37 expect(intent(["jump"]).input.jump).toBe(true);
38 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke"]);
39 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
40 // the browser for the screen, and nothing about an `Input` changes for it.
41 expect(CS_KEYS.KeyF).toBe("fullscreen");
42 expect(intent(["fullscreen"])).toEqual(intent([]));
43 // P is the page's in the same way: it pauses, which is what Escape means too,
44 // and no browser has a meaning of its own for it to take the window back with.
45 expect(CS_KEYS.KeyP).toBe("pause");
46 expect(intent(["pause"])).toEqual(intent([]));
47 // X is the page's too: it buys last round's loadout again, which is money and
48 // not movement, so nothing about an `Input` changes for it either.
49 expect(CS_KEYS.KeyX).toBe("rebuy");
50 expect(intent(["rebuy"])).toEqual(intent([]));
51 });
52
53 test("the number row is one row of keys, bound in the order it is printed", () => {
54 // Eleven keys, left to right, and the code of each is what a browser calls
55 // that key: the buy panel prints them on its tiles in this order.
56 expect([...NUMBER_ROW]).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"]);
57 const codes = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus"];
58 expect(codes.map((code) => CS_KEYS[code])).toEqual(NUMBER_ROW.map((_, i) => slotAction(i)));
59 // And the way back, which is how `main.ts` turns a press into the tile under
60 // it: every one of them, and nothing else.
61 expect(codes.map((code) => slotIndex(CS_KEYS[code] ?? ""))).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
62 expect(slotIndex("forward")).toBeNull();
63 expect(slotIndex("slot12")).toBeNull();
64 expect(slotIndex("slot")).toBeNull();
65 });
66
67 test("only the first three of the row are pockets: the rest are the panel's and the world has never heard of them", () => {
68 expect(intent(["slot1"]).slotWanted).toBe(1);
69 expect(intent(["slot2"]).slotWanted).toBe(2);
70 expect(intent(["slot3"]).slotWanted).toBe(3);
71 for (const action of ["slot4", "slot9", "slot10", "slot11"]) {
72 expect(intent([action]).slotWanted).toBeNull();
73 expect(intent([action])).toEqual(intent([]));
74 }
75 });
76
77 test("the wheel pressed is the knife pocket, and it is not a tile of the buy panel", () => {
78 // The same pocket `3` is, by a name no tile is printed with: with the shop
79 // open a key of the number row buys, and a thumb on the wheel means the knife
80 // rather than whatever the third tile happens to be.
81 expect(intent(["knife"]).slotWanted).toBe(3);
82 expect(slotIndex("knife")).toBeNull();
83 expect(intent(["knife"]).input).toEqual(intent([]).input);
84 });
85
86 test("nothing held is nothing asked for", () => {
87 const { input, scoreboard, slotWanted } = intent([]);
88 expect(input.forward).toBe(0);
89 expect(input.side).toBe(0);
90 expect(input.jump).toBe(false);
91 expect(input.fire).toBe(false);
92 expect(input.switchTo).toBeNull();
93 expect(scoreboard).toBe(false);
94 expect(slotWanted).toBeNull();
95 });
96
97 test("the movement keys sum and opposites cancel", () => {
98 expect(intent(["forward"]).input.forward).toBe(1);
99 expect(intent(["back"]).input.forward).toBe(-1);
100 expect(intent(["forward", "back"]).input.forward).toBe(0);
101 expect(intent(["right"]).input.side).toBe(1);
102 expect(intent(["left"]).input.side).toBe(-1);
103 expect(intent(["left", "right"]).input.side).toBe(0);
104 });
105
106 test("the stick adds to the keys and the sum clamps to ±1", () => {
107 expect(intent([], { x: -0.5, y: 0.25 }).input.side).toBeCloseTo(-0.5, 10);
108 expect(intent([], { x: -0.5, y: 0.25 }).input.forward).toBeCloseTo(0.25, 10);
109 expect(intent(["forward"], { x: 0, y: 0.9 }).input.forward).toBe(1);
110 expect(intent(["left"], { x: -0.9, y: 0 }).input.side).toBe(-1);
111 });
112
113 test("every held action lands on its field", () => {
114 const { input } = intent(["jump", "duck", "walk", "reload", "use", "fire", "scope"]);
115 expect(input.jump).toBe(true);
116 expect(input.duck).toBe(true);
117 expect(input.walk).toBe(true);
118 expect(input.reload).toBe(true);
119 expect(input.use).toBe(true);
120 expect(input.fire).toBe(true);
121 expect(input.scope).toBe(true);
122 });
123
124 test("the scoreboard is not an input to the world", () => {
125 expect(intent(["scoreboard"]).scoreboard).toBe(true);
126 expect(intent(["scoreboard"]).input).toEqual(intent([]).input);
127 });
128
129 test("three beats two beats one", () => {
130 expect(intent(["slot1"]).slotWanted).toBe(1);
131 expect(intent(["slot2"]).slotWanted).toBe(2);
132 expect(intent(["slot3"]).slotWanted).toBe(3);
133 expect(intent(["slot1", "slot2"]).slotWanted).toBe(2);
134 expect(intent(["slot1", "slot2", "slot3"]).slotWanted).toBe(3);
135 });
136
137 test("an action the game does not know is not an input", () => {
138 expect(intent(["buy", "nonsense"]).input).toEqual(intent([]).input);
139 });
src/client/binding.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The game's half of input: which keys mean what, and what a held action does
3 * to an `Input`. The engine's controls collect names — "forward", "slot2" —
4 * and know nothing else; this is where a name becomes the same struct a bot
5 * hands the simulation, so a person and a bot are the same kind of player.
6 */
7
8 import { clamp } from "../engine/index.ts";
9 import { NO_INPUT, type Input } from "./world.ts";
10
11 /**
12 * The number row, left to right, printed as the key prints itself. It is one
13 * list because it is one row of keys with two meanings, and which one is meant
14 * is the frame's question rather than the keyboard's: with the buy panel open
15 * every key of it presses the tile it is printed on, and with the panel closed
16 * the first three are the weapon pockets and the rest do nothing.
17 *
18 * Eleven, because a CT's shelf is eleven tiles — five guns, three grenades, the
19 * vest, the vest and helmet, and the kit — and a tile with no key on it is a
20 * tile you need the mouse for, which is the whole of what #153 was about.
21 * `src/client/buy.ts` prints these on the tiles and `src/client/main.ts` reads
22 * one back to an index in the same list, so the number on the screen and the
23 * key that buys it cannot drift.
24 */
25 export const NUMBER_ROW = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"] as const;
26
27 /** The `KeyboardEvent.code` of each of those keys, in the same order. */
28 const NUMBER_CODES = [
29 "Digit1", "Digit2", "Digit3", "Digit4", "Digit5",
30 "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus",
31 ] as const;
32
33 /** What the nth key of the number row presses: `slot1` is the first of them. */
34 export const slotAction = (index: number): string => `slot${index + 1}`;
35
36 /** Which key of the number row an action is, or null for anything else. */
37 export function slotIndex(action: string): number | null {
38 const at = NUMBER_ROW.findIndex((_, index) => slotAction(index) === action);
39 return at === -1 ? null : at;
40 }
41
42 /** `KeyboardEvent.code` to action. Today's bindings, moved rather than tuned. */
43 export const CS_KEYS: Record<string, string> = {
44 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
45 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
46 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
47 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyV: "smoke",
48 KeyB: "buy", Tab: "scoreboard",
49 ...Object.fromEntries(NUMBER_CODES.map((code, index) => [code, slotAction(index)])),
50 // Neither of these is a thing the world knows about: `main.ts` hears them on
51 // `onAction`, and `readIntent` below never sees either. F asks the browser for
52 // the screen; P pauses, which is what Escape means too — and P is a key no
53 // browser has its own meaning for, so it pauses without also handing the
54 // window back, which is what Escape does on a Mac.
55 KeyF: "fullscreen",
56 KeyP: "pause",
57 // Nor is this one: X buys last round's loadout again, which is money and not
58 // movement, so `main.ts` hears it and `readIntent` never does.
59 KeyX: "rebuy",
60 };
61
62 /**
63 * Left fires, right scopes, and the three buttons a five-button mouse has as
64 * well — all of them, because a bound button is a button the engine takes the
65 * browser's meaning away from and an unbound one is left alone.
66 *
67 * That matters most for the two under the thumb: to a browser on Windows they
68 * are Back and Forward, so a thumb that finds one mid-round in Edge leaves the
69 * page and takes the match with it. Bound here, they are the game's. Which
70 * actions they get is the pair a thumb reaches for: plant-or-defuse, and the HE.
71 *
72 * The wheel pressed is the quick knife every other shooter puts there — it is
73 * `knife` rather than `slot3` on purpose, because the number row is also the
74 * buy panel's and a middle click with the shop open would otherwise spend the
75 * money on whatever tile is printed with a 3.
76 */
77 export const CS_MOUSE: Record<number, string> = {
78 0: "fire", 1: "knife", 2: "scope", 3: "use", 4: "grenade",
79 };
80
81 /** The wheel jumps, both ways, which is the bind half this game plays with. */
82 export const CS_WHEEL: Record<string, string> = { up: "jump", down: "jump" };
83
84 /** On-screen buttons that are a tap, not a hold. */
85 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke"] as const;
86
87 export type Intent = {
88 input: Input;
89 scoreboard: boolean;
90 /** 1 primary, 2 pistol, 3 knife — `main` knows which weapon each is. */
91 slotWanted: 1 | 2 | 3 | null;
92 };
93
94 /**
95 * What the held actions and the thumb stick add up to this frame. `switchTo`
96 * stays null: only the world knows what is in slot 1, so `main` fills it from
97 * `slotWanted`.
98 */
99 export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: number }): Intent {
100 const down = (action: string): boolean => held.has(action);
101 const input: Input = {
102 ...NO_INPUT,
103 forward: clamp((down("forward") ? 1 : 0) - (down("back") ? 1 : 0) + stick.y, -1, 1),
104 side: clamp((down("right") ? 1 : 0) - (down("left") ? 1 : 0) + stick.x, -1, 1),
105 jump: down("jump"),
106 duck: down("duck"),
107 walk: down("walk"),
108 reload: down("reload"),
109 use: down("use"),
110 fire: down("fire"),
111 scope: down("scope"),
112 // Three pockets, three keys, read newest-reach-first: the one nearest the
113 // front of this list wins while more than one is held, because the one you
114 // just reached for is the one you meant, and releasing it throws whichever
115 // of the others is still down.
116 throwing: down("smoke") ? "smoke" : down("flash") ? "flash" : down("grenade") ? "he" : null,
117 switchTo: null,
118 };
119 return {
120 input,
121 scoreboard: down("scoreboard"),
122 // Three pockets, and only three: the rest of the number row belongs to the
123 // buy panel, and a world that has never heard of `slot4` cannot be steered
124 // by the key that buys the fourth tile. `knife` is the third pocket by
125 // another name — the wheel pressed, which no tile is printed with.
126 slotWanted:
127 down("slot3") || down("knife") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null,
128 };
129 }
src/client/bodies.test.ts deleted −114
@@ -1,114 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { addBody, MAX_BODIES, type Corpse } from "./bodies.ts";
3 import { traceBullet } from "./combat.ts";
4 import { WEAPONS } from "./weapons.ts";
5 import { SHORT } from "./match.ts";
6 import { move, MOVE, type Body, type Wish } from "./physics.ts";
7 import {
8 aimAt, bodyCentre, createWorld, shoot, startRound, type Player, type World,
9 } from "./world.ts";
10
11 /** Put a shooter in front of the person on an empty map, rifle up and aimed. */
12 function faceOff(world: World, shooter: Player, apart = 200): void {
13 world.boxes = [];
14 world.match.phase = "live";
15 for (const p of world.players) if (p !== shooter && p !== world.me) p.alive = false;
16 world.bodies.length = 0;
17 world.me.pos = [0, 0, 0];
18 world.me.vel = [0, 0, 0];
19 world.me.yaw = 180;
20 shooter.pos = [apart, 0, 0];
21 shooter.vel = [0, 0, 0];
22 shooter.onGround = true;
23 shooter.primary = "ak47";
24 shooter.weapon = "ak47";
25 shooter.ammo.ak47 = { mag: 30, reserve: 90 };
26 shooter.nextShotAt = 0;
27 shooter.reloadUntil = 0;
28 shooter.shotIndex = 0;
29 const aim = aimAt(shooter, bodyCentre(world.me));
30 shooter.yaw = aim.yaw;
31 shooter.pitch = aim.pitch;
32 world.effects.length = 0;
33 }
34
35 /** Kill the person where they stand, and hand back the shooter. */
36 function killMe(world: World): Player {
37 const shooter = world.players.find((p) => p.team === "t") as Player;
38 faceOff(world, shooter);
39 world.me.health = 1;
40 world.me.armor = 0;
41 expect(shoot(world, shooter)).toBe(true);
42 expect(world.me.alive).toBe(false);
43 return shooter;
44 }
45
46 test("a kill leaves one body where and how the player fell", () => {
47 const world = createWorld(SHORT, "me", "ct", 5, 211);
48 killMe(world);
49 expect(world.bodies.length).toBe(1);
50 const body = world.bodies[0] as Corpse;
51 expect(body.id).toBe(world.me.id);
52 expect(body.team).toBe("ct");
53 expect(body.yaw).toBe(world.me.yaw);
54 expect(body.at).toBe(world.time);
55 expect(body.pos).toEqual([0, 0, 0]);
56 // A copy, not the player: the corpse does not follow them to the next round.
57 expect(body.pos).not.toBe(world.me.pos);
58 });
59
60 test("the next round clears the bodies", () => {
61 const world = createWorld(SHORT, "me", "ct", 5, 223);
62 killMe(world);
63 expect(world.bodies.length).toBe(1);
64 startRound(world);
65 expect(world.bodies.length).toBe(0);
66 expect(world.me.alive).toBe(true);
67 });
68
69 test("the list of bodies is bounded however long the world is stepped", () => {
70 const world = createWorld(SHORT, "me", "ct", 5, 227);
71 const shooter = world.players.find((p) => p.team === "t") as Player;
72 faceOff(world, shooter);
73 for (let i = 0; i < 400; i++) {
74 world.me.alive = true;
75 world.me.health = 1;
76 world.me.armor = 0;
77 shooter.nextShotAt = 0;
78 shooter.shotIndex = 0; // the first bullet of a spray, every time: it lands
79 shooter.ammo.ak47 = { mag: 30, reserve: 90 };
80 world.time += 1;
81 shoot(world, shooter);
82 }
83 expect(world.bodies.length).toBe(MAX_BODIES);
84 // The oldest went first: what is left is the last MAX_BODIES kills.
85 expect((world.bodies[0] as Corpse).at).toBeLessThan((world.bodies[MAX_BODIES - 1] as Corpse).at);
86 });
87
88 test("someone killed in the air is put down on the floor under them", () => {
89 const bodies: Corpse[] = [];
90 const boxes = [{ min: [-200, -200, -64] as [number, number, number], max: [200, 200, 0] as [number, number, number], mat: 0 }];
91 addBody(bodies, { id: 3, team: "t", pos: [0, 0, 180], yaw: 45 }, boxes, 9);
92 expect((bodies[0] as Corpse).pos).toEqual([0, 0, 0]);
93 // Nothing under them at all — a hole in the world — leaves them where they died.
94 addBody(bodies, { id: 4, team: "t", pos: [0, 0, 180], yaw: 0 }, [], 9);
95 expect((bodies[1] as Corpse).pos[2]).toBe(180);
96 });
97
98 test("a body is decoration: nobody collides with it and no bullet stops on it", () => {
99 const world = createWorld(SHORT, "me", "ct", 5, 229);
100 const shooter = killMe(world);
101 expect(world.bodies.length).toBe(1);
102 // `move` only ever collides with `world.boxes`, and a body is never one: the
103 // only box in this world is the floor the body is lying on.
104 world.boxes = [{ min: [-400, -400, -64], max: [400, 400, 0], mat: 0 }];
105 expect(world.boxes.length).toBe(1);
106 const walker: Body = { pos: [0, -100, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0, landSpeed: 0 };
107 const wish: Wish = { forward: 1, side: 0, yaw: 90, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed };
108 for (let i = 0; i < 128; i++) move(walker, wish, 1 / 64, world.boxes);
109 expect(walker.pos[1]).toBeGreaterThan(0); // straight over where the body lies
110
111 // A bullet along the same line finds nothing: only living players are actors.
112 const hit = traceBullet([0, -300, 20], [0, 300, 20], world.boxes, world.players, shooter.id, WEAPONS.ak47);
113 expect(hit.kind).toBe("none");
114 });
src/client/bodies.ts deleted −53
@@ -1,53 +0,0 @@
1 /**
2 * The dead, left where they fell. A body is a copy of four numbers taken at the
3 * moment of the kill — never a reference to the player, who stands up again at
4 * the next `startRound` — and it is decoration and nothing else: bullets go
5 * through it, `move` never collides with it, and no bot has ever seen one.
6 *
7 * It lives in `world.bodies` rather than `world.effects` because the effects
8 * list is trimmed to the last two seconds and a body lasts the round.
9 */
10
11 import type { Box } from "./map.ts";
12 import type { Team } from "./match.ts";
13 import type { Vec3 } from "./math.ts";
14 import { HULL, groundUnder } from "./physics.ts";
15
16 export type Corpse = {
17 id: number; // the player who fell, so the HUD or a camera could find them
18 team: Team; // the tint, kept here because half time swaps the player's side
19 pos: Vec3; // the feet, on the floor under where they died
20 yaw: number; // the way they were facing: the body lies along it
21 at: number; // world time of the kill
22 };
23
24 /**
25 * Ten a side is the ceiling for a round, but a world can be stepped for as long
26 * as anyone likes, so the list has a hard end and the oldest body goes first.
27 */
28 export const MAX_BODIES = 20;
29
30 /** How far below a player killed in the air the floor is looked for. */
31 export const FALL_REACH = 512;
32
33 /** What a body needs from the player it was; every field is copied out. */
34 export type Fallen = { id: number; team: Team; pos: Vec3; yaw: number };
35
36 /**
37 * Add the body of `p` to `bodies`. Someone shot mid-jump is put down on the
38 * floor under them — the only work here, and it happens once per kill, never
39 * in a tick where nobody dies.
40 */
41 export function addBody(bodies: Corpse[], p: Fallen, boxes: Box[], time: number): Corpse {
42 const floor = groundUnder(p.pos, HULL.stand, boxes, FALL_REACH);
43 const body: Corpse = {
44 id: p.id,
45 team: p.team,
46 pos: [p.pos[0], p.pos[1], floor ?? p.pos[2]],
47 yaw: p.yaw,
48 at: time,
49 };
50 bodies.push(body);
51 if (bodies.length > MAX_BODIES) bodies.shift();
52 return body;
53 }
src/client/bomb.test.ts added +214
@@ -0,0 +1,214 @@
1 /**
2 * The objective's own tests, on a round made of three plain objects: the bomb,
3 * the match clock and a level. No world, no players, no bots — `updateBomb`
4 * takes a carrier and gives back at most one thing for the world to do, which
5 * is exactly what can be checked here without any of that.
6 *
7 * `world.test.ts` keeps the two that are the world's rather than the bomb's: a
8 * plant that keeps the round alive past the clock, and a defuse that wins it and
9 * pays ten people.
10 */
11
12 import { expect, test } from "bun:test";
13 import {
14 BEEP_FAST, BEEP_SLOW, beepDue, beepInterval, defuseTime, dropBomb, giveBomb, newBomb, updateBomb,
15 useProgress, type BombState, type Carrier, type Round,
16 } from "./bomb.ts";
17 import { DE_FORGE } from "./map.ts";
18 import { newMatch, PLANT_REWARD, SHORT, type Phase } from "./match.ts";
19
20 const TICK = 1 / 64;
21
22 const at = (site: "A" | "B"): [number, number, number] =>
23 [DE_FORGE.sites[site][0], DE_FORGE.sites[site][1], 2];
24
25 /** A round mid-play, with the bomb wherever this test wants it. */
26 const round = (phase: Phase = "live"): Round => {
27 const match = newMatch(SHORT, "t");
28 match.phase = phase;
29 return { bomb: newBomb(), match, level: DE_FORGE };
30 };
31
32 const carrier = (over: Partial<Carrier> = {}): Carrier => ({
33 team: "t", pos: at("A"), onGround: true, money: 1000, defuseKit: false, hasBomb: false, ...over,
34 });
35
36 /** Hold use (or not) for `seconds`, and hand back whatever the bomb said. */
37 function hold(r: Round, p: Carrier, seconds: number, use = true): ReturnType<typeof updateBomb> {
38 let last: ReturnType<typeof updateBomb> = null;
39 for (let t = 0; t < seconds; t += TICK) last = updateBomb(r, p, use, TICK) ?? last;
40 return last;
41 }
42
43 test("a plant needs the site and not just the bomb", () => {
44 const r = round();
45 const p = carrier({ hasBomb: true, pos: [0, 0, 2] }); // holding it, standing nowhere
46 expect(hold(r, p, SHORT.plantTime + 0.2)).toBe(null);
47 expect(r.bomb.state).toBe("carried");
48 expect(r.bomb.plantProgress).toBe(0);
49
50 // The same key, the same seconds, on the site.
51 p.pos = at("A");
52 const event = hold(r, p, SHORT.plantTime + 0.2);
53 expect(event).toEqual({ type: "planted", site: "A" });
54 expect(r.bomb.state).toBe("planted");
55 expect(r.bomb.site).toBe("A");
56 });
57
58 test("a plant needs the bomb and not just the site, and needs the round live", () => {
59 const empty = round();
60 expect(hold(empty, carrier({ hasBomb: false }), SHORT.plantTime + 0.2)).toBe(null);
61 expect(empty.bomb.state).toBe("carried");
62
63 const frozen = round("freeze");
64 expect(hold(frozen, carrier({ hasBomb: true }), SHORT.plantTime + 0.2)).toBe(null);
65 expect(frozen.bomb.plantProgress).toBe(0);
66 });
67
68 test("a plant pays the planter, empties their hands and puts the clock on the fuse", () => {
69 const r = round();
70 const p = carrier({ hasBomb: true, pos: at("B") });
71 expect(hold(r, p, SHORT.plantTime + 0.2)?.type).toBe("planted");
72 expect(p.money).toBe(1000 + PLANT_REWARD);
73 expect(p.hasBomb).toBe(false);
74 expect(r.match.phase).toBe("planted");
75 expect(r.match.timer).toBe(SHORT.bombTime);
76 expect(r.bomb.pos[2]).toBe(4); // on the floor at their feet, not inside them
77 });
78
79 test("a plant interrupted by letting go starts again from nothing", () => {
80 const r = round();
81 const p = carrier({ hasBomb: true });
82 hold(r, p, SHORT.plantTime / 2);
83 expect(r.bomb.plantProgress).toBeGreaterThan(0);
84 expect(useProgress(r, p)).toEqual({ label: "Planting", fraction: expect.any(Number) });
85 hold(r, p, TICK, false);
86 expect(r.bomb.plantProgress).toBe(0);
87 expect(useProgress(r, p)).toBe(null);
88 });
89
90 test("the bomb the carrier dropped is picked up by the next terrorist past it", () => {
91 const r = round();
92 const dead = carrier({ hasBomb: true, pos: [100, 100, 2] });
93 dropBomb(r.bomb, dead);
94 expect(r.bomb.state).toBe("dropped");
95 expect(dead.hasBomb).toBe(false);
96
97 const ct = carrier({ team: "ct", pos: [100, 100, 2] });
98 updateBomb(r, ct, false, TICK);
99 expect(r.bomb.state).toBe("dropped"); // not theirs to carry
100 const far = carrier({ pos: [1000, 1000, 2] });
101 updateBomb(r, far, false, TICK);
102 expect(r.bomb.state).toBe("dropped");
103
104 const near = carrier({ pos: [120, 100, 2] });
105 updateBomb(r, near, false, TICK);
106 expect(r.bomb.state).toBe("carried");
107 expect(near.hasBomb).toBe(true);
108 });
109
110 test("a defuse interrupted goes back to nothing, and resuming starts the wire again", () => {
111 const r = round("planted");
112 r.bomb.state = "planted";
113 r.bomb.pos = at("B");
114 const ct = carrier({ team: "ct", pos: at("B") });
115
116 hold(r, ct, SHORT.defuseTime * 0.75);
117 expect(r.bomb.defuser).toBe(ct);
118 expect(useProgress(r, ct)?.label).toBe("Defusing");
119
120 hold(r, ct, TICK, false); // the key came up
121 expect(r.bomb.defuseProgress).toBe(0);
122 expect(r.bomb.defuser).toBe(null);
123 expect(useProgress(r, ct)).toBe(null);
124
125 // Three quarters of the way was worth nothing: it takes the whole of it again.
126 expect(hold(r, ct, SHORT.defuseTime * 0.9)).toBe(null);
127 expect(r.bomb.state).toBe("planted");
128 expect(hold(r, ct, SHORT.defuseTime * 0.2)).toEqual({ type: "defused" });
129 // Asked as a `BombState`: the checker last saw this field assigned, and
130 // does not know that `hold` moved it on.
131 expect<BombState>(r.bomb.state).toBe("defused");
132 });
133
134 test("a defuse needs to be stood next to, and is the CT's alone", () => {
135 const r = round("planted");
136 r.bomb.state = "planted";
137 r.bomb.pos = at("A");
138 const far = carrier({ team: "ct", pos: [DE_FORGE.sites.A[0] + 200, DE_FORGE.sites.A[1], 2] });
139 expect(hold(r, far, SHORT.defuseTime + 0.2)).toBe(null);
140 expect(r.bomb.defuseProgress).toBe(0);
141
142 const t = carrier({ team: "t", pos: at("A") });
143 expect(hold(r, t, SHORT.defuseTime + 0.2)).toBe(null);
144 expect(r.bomb.state).toBe("planted");
145 });
146
147 test("a kit halves the defuse, and the readout says which one it is", () => {
148 const withKit = round("planted");
149 withKit.bomb.state = "planted";
150 withKit.bomb.pos = at("A");
151 const kitted = carrier({ team: "ct", pos: at("A"), defuseKit: true });
152 expect(defuseTime(withKit, kitted)).toBe(SHORT.defuseTimeWithKit);
153 expect(SHORT.defuseTimeWithKit).toBeLessThan(SHORT.defuseTime);
154
155 // The seconds a bare-handed CT would still be cutting at.
156 expect(hold(withKit, kitted, SHORT.defuseTimeWithKit - 0.1)).toBe(null);
157 expect(useProgress(withKit, kitted)?.label).toBe("Defusing (kit)");
158 expect(hold(withKit, kitted, 0.2)).toEqual({ type: "defused" });
159
160 const bare = round("planted");
161 bare.bomb.state = "planted";
162 bare.bomb.pos = at("A");
163 const empty = carrier({ team: "ct", pos: at("A") });
164 expect(defuseTime(bare, empty)).toBe(SHORT.defuseTime);
165 expect(hold(bare, empty, SHORT.defuseTimeWithKit + 0.1)).toBe(null);
166 expect(bare.bomb.state).toBe("planted");
167 });
168
169 test("a defuse pays the hands that did it", () => {
170 const r = round("planted");
171 r.bomb.state = "planted";
172 r.bomb.pos = at("A");
173 const ct = carrier({ team: "ct", pos: at("A"), defuseKit: true });
174 hold(r, ct, SHORT.defuseTimeWithKit + 0.1);
175 expect(ct.money).toBe(1300);
176 });
177
178 test("the bomb starts in the hands of whoever was given it", () => {
179 const p = carrier({ pos: [12, 34, 2] });
180 const bomb = giveBomb(p);
181 expect(p.hasBomb).toBe(true);
182 expect(bomb.state).toBe("carried");
183 expect(bomb.pos).toEqual([12, 34, 2]);
184 p.pos[0] = 999; // a copy, not the player's own array
185 expect(bomb.pos[0]).toBe(12);
186 });
187
188 test("the fuse beeps slower at the top than at the bottom, by a straight line", () => {
189 // A forty-second fuse: 0.9 s between beeps at the plant, 0.12 + 0.78/2 at
190 // half, and 0.12 at the moment it goes off.
191 expect(beepInterval(40, 40)).toBeCloseTo(BEEP_SLOW, 10);
192 expect(beepInterval(20, 40)).toBeCloseTo(0.51, 10);
193 expect(beepInterval(0, 40)).toBeCloseTo(BEEP_FAST, 10);
194 // A clock that has run past its own end, or a rule set with no fuse at all,
195 // is the fastest beep rather than a division by zero.
196 expect(beepInterval(-5, 40)).toBeCloseTo(BEEP_FAST, 10);
197 expect(beepInterval(1, 0)).toBeCloseTo(BEEP_FAST, 10);
198 });
199
200 test("a bomb beeps on the tick it is planted, and then on its own clock", () => {
201 const bomb = newBomb();
202 // The first call is due at once: a bomb that has just gone down says so.
203 expect(beepDue(bomb, 40, 40, TICK)).toBe(true);
204 let beeps = 0;
205 for (let t = 0; t < 0.9 - TICK; t += TICK) if (beepDue(bomb, 40, 40, TICK)) beeps += 1;
206 expect(beeps).toBe(0);
207 expect(beepDue(bomb, 40, 40, TICK)).toBe(true);
208 // With a second of fuse left it beeps seven times a second instead of once:
209 // the first of the two below is still the 0.9 s that was set when the fuse
210 // was full, and everything after it is 0.12 + 0.78/40 apart.
211 let fast = 0;
212 for (let t = 0; t < 2; t += TICK) if (beepDue(bomb, 1, 40, TICK)) fast += 1;
213 expect(fast).toBeGreaterThan(5);
214 });
src/client/bomb.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The objective: who is carrying it, where it landed when they died, what
3 * pressing use on a site does, and what pressing use on a planted one undoes.
4 * The kit lives here too, because the only thing a defuse kit is is half a
5 * defuse, and so does the fraction a readout draws a bar from.
6 *
7 * It knows nothing about the world it is called from. `updateBomb` reads the
8 * round's clock and the level's sites, writes the bomb and the one player in
9 * front of it, and hands back what the *world* has to do about it — a line on
10 * the screen, a round that just ended — because paying ten people for a round
11 * needs all ten and this file has one. So it takes a slice of the world named
12 * by the members it touches, the way `combat.ts` names an `Actor`, and cannot
13 * reach back for `world.ts`.
14 */
15
16 import { siteAt, type Level } from "./level.ts";
17 import { clamp, dist, type Vec3 } from "../engine/index.ts";
18 import { capMoney, plantBomb, DEFUSE_REWARD, PLANT_REWARD, type MatchState, type Team } from "./match.ts";
19
20 /** Everything the bomb reads or writes on the person handling it. */
21 export type Carrier = {
22 team: Team;
23 pos: Vec3;
24 onGround: boolean;
25 money: number;
26 defuseKit: boolean;
27 hasBomb: boolean;
28 };
29
30 /** Where the bomb is in its life, from a T's hands to the crater. */
31 export type BombState = "carried" | "dropped" | "planted" | "defused" | "detonated";
32
33 export type Bomb = {
34 state: BombState;
35 pos: Vec3;
36 site: "A" | "B" | null;
37 plantProgress: number;
38 defuseProgress: number;
39 defuser: Carrier | null;
40 /**
41 * Seconds until the next beep. It is the bomb's own clock and nothing reads
42 * it back — a beep is information, not a rule — so it is here with the fuse
43 * rather than in the frame, which would beep at whatever rate the screen runs.
44 */
45 beepIn: number;
46 };
47
48 /** The round as the bomb sees it: itself, the clock, and the ground it is on. */
49 export type Round = { bomb: Bomb; match: MatchState; level: Level };
50
51 /**
52 * What the world must do about a tick of the bomb that it could not do here:
53 * say a line about the plant, and end the round on the defuse. The state the
54 * bomb owns — the fuse, the site, the money for the hands that did it — is
55 * already written by the time one of these comes back.
56 */
57 export type BombEvent = { type: "planted"; site: "A" | "B" } | { type: "defused" };
58
59 /** How close a defuser has to stand. Counter-Strike's is generous; so is this. */
60 export const DEFUSE_RANGE = 64;
61
62 /** How close you walk past a dropped bomb before it is in your hands. */
63 export const PICKUP_RANGE = 48;
64
65 /** A fresh bomb, in somebody's hands, at `pos`. */
66 export const newBomb = (pos: Vec3 = [0, 0, 0]): Bomb => ({
67 state: "carried", pos: [pos[0], pos[1], pos[2]],
68 site: null, plantProgress: 0, defuseProgress: 0, defuser: null, beepIn: 0,
69 });
70
71 /**
72 * How long between two beeps, in seconds, with `left` of a `whole`-second fuse
73 * still to run. It is a straight line from `BEEP_SLOW` at the moment of the
74 * plant to `BEEP_FAST` at zero, because the beep is how long you have: a
75 * defuser who can hear it does not need to see the clock. A forty-second fuse
76 * beeps every 0.9 s at the top, every 0.51 s at half, and eight times a second
77 * in the last moment of it.
78 */
79 export const BEEP_SLOW = 0.9;
80 export const BEEP_FAST = 0.12;
81
82 export const beepInterval = (left: number, whole: number): number =>
83 BEEP_FAST + (BEEP_SLOW - BEEP_FAST) * clamp(whole <= 0 ? 0 : left / whole, 0, 1);
84
85 /**
86 * One tick of the fuse's clock: true on the tick a beep is due, and the next
87 * one is set from how much fuse is left at that moment. Called once a tick for
88 * the one bomb — never once per player — and it makes no sound itself: the
89 * world turns a true into an effect, the way it does with everything else that
90 * can be heard.
91 */
92 export function beepDue(bomb: Bomb, left: number, whole: number, dt: number): boolean {
93 bomb.beepIn -= dt;
94 if (bomb.beepIn > 0) return false;
95 bomb.beepIn = beepInterval(left, whole);
96 return true;
97 }
98
99 /** Hand the bomb to this player at the top of a round: it starts where they do. */
100 export function giveBomb(carrier: Carrier): Bomb {
101 carrier.hasBomb = true;
102 return newBomb(carrier.pos);
103 }
104
105 /** The carrier died: the bomb is on the floor where the body is, for anyone. */
106 export function dropBomb(bomb: Bomb, carrier: Carrier): void {
107 carrier.hasBomb = false;
108 bomb.state = "dropped";
109 bomb.pos = [carrier.pos[0], carrier.pos[1], carrier.pos[2] + 4];
110 }
111
112 /** How long this player's defuse takes: a kit is the whole of the difference. */
113 export const defuseTime = (round: Round, p: Carrier): number =>
114 p.defuseKit ? round.match.rules.defuseTimeWithKit : round.match.rules.defuseTime;
115
116 /**
117 * One tick of the bomb for one player: pick it up, carry it, plant it on a
118 * site, or cut the wire on one that is down. Everything a plant or a defuse is
119 * — the progress that resets the moment the key comes up or you step off, the
120 * reward, the clock going onto the fuse — happens here; what the round does
121 * about it comes back to the caller.
122 */
123 export function updateBomb(round: Round, p: Carrier, use: boolean, dt: number): BombEvent | null {
124 const { bomb, match } = round;
125 let event: BombEvent | null = null;
126 if (bomb.state === "carried" && p.hasBomb && match.phase === "live") {
127 bomb.pos = [p.pos[0], p.pos[1], p.pos[2]];
128 const site = siteAt(round.level, p.pos);
129 if (use && site !== null && p.onGround) {
130 bomb.plantProgress += dt;
131 if (bomb.plantProgress >= match.rules.plantTime) {
132 bomb.state = "planted";
133 bomb.site = site;
134 bomb.pos = [p.pos[0], p.pos[1], p.pos[2] + 2];
135 p.hasBomb = false;
136 p.money = capMoney(p.money + PLANT_REWARD, match.rules);
137 plantBomb(match);
138 event = { type: "planted", site };
139 }
140 } else {
141 bomb.plantProgress = 0;
142 }
143 }
144 if (bomb.state === "dropped" && p.team === "t" && dist(p.pos, bomb.pos) < PICKUP_RANGE) {
145 bomb.state = "carried";
146 p.hasBomb = true;
147 }
148 if (bomb.state === "planted" && p.team === "ct") {
149 const near = dist(p.pos, bomb.pos) < DEFUSE_RANGE;
150 if (use && near && p.onGround) {
151 bomb.defuser = p;
152 bomb.defuseProgress += dt;
153 if (bomb.defuseProgress >= defuseTime(round, p)) {
154 bomb.state = "defused";
155 p.money = capMoney(p.money + DEFUSE_REWARD, match.rules);
156 event = { type: "defused" };
157 }
158 } else if (bomb.defuser === p) {
159 bomb.defuser = null;
160 bomb.defuseProgress = 0;
161 }
162 }
163 return event;
164 }
165
166 /** How far along the thing this player is doing is, 0..1, for the HUD bar. */
167 export function useProgress(round: Round, p: Carrier): { label: string; fraction: number } | null {
168 const bomb = round.bomb;
169 if (p.team === "t" && p.hasBomb && bomb.plantProgress > 0)
170 return { label: "Planting", fraction: clamp(bomb.plantProgress / round.match.rules.plantTime, 0, 1) };
171 if (p.team === "ct" && bomb.defuser === p && bomb.defuseProgress > 0) {
172 return {
173 label: p.defuseKit ? "Defusing (kit)" : "Defusing",
174 fraction: clamp(bomb.defuseProgress / defuseTime(round, p), 0, 1),
175 };
176 }
177 return null;
178 }
src/client/bots.test.ts +359 −632
@@ -1,18 +1,41 @@
11 import { expect, test } from "bun:test";
2 import { PACKED, useShipped } from "./shipped.ts";
3 import { unpack, type Policy } from "../engine/index.ts";
24 import {
3 applyIntent, botThink, CARD, DIFFICULTIES, gunGoal, makeBrain, policy, restyleBrain, skillBand, think,
5 applyIntent, blindness, botBuy, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy,
6 ride, roundPlan, think, usePlan, usePolicy,
47 } from "./bots.ts";
5 import { DE_FORGE, placeAt, worldOf } from "./map.ts";
6 import { dist2d } from "./math.ts";
7 import { COMPETITIVE, SHORT } from "./match.ts";
8 import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts";
9 import {
10 aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
11 type Player, type World,
12 } from "./world.ts";
8 import type { RoundPlan, TeamPlan } from "../plan/plan.ts";
9 import { grip } from "./spray.ts";
10 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
11 import { dist, type Vec3 } from "../engine/index.ts";
12 import { OBS_SIZE } from "./observe.ts";
13 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
14 import { DE_FORGE } from "./map.ts";
15 import { COMPETITIVE } from "./match.ts";
16 import { aimAt, applyInput, bodyCentre, createWorld, deployTicks, type Player } from "./world.ts";
17
18 // The bots in here think with the policy this repository ships, handed in the
19 // way a browser hands in the one it downloaded.
20 useShipped();
21
22 test("a bot with no policy handed to it says so, and one handed a policy uses that one", () => {
23 // The seam #214 opened: the weights are data now, so `usePolicy` is the only
24 // way a bot gets any — a browser hands it what it downloaded, Bun hands it
25 // what `shipped.ts` read. Nobody having handed one in is loud, not a bot
26 // quietly aiming at nothing.
27 usePolicy(null as unknown as Policy);
28 expect(() => policy()).toThrow(NO_POLICY);
29 const net = useShipped();
30 expect(policy()).toBe(net);
31 const other = unpack(PACKED);
32 usePolicy(other);
33 expect(policy()).toBe(other);
34 useShipped();
35 });
1336
1437 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
15 const world = createWorld(COMPETITIVE, "you", "ct", 1, seed);
38 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, seed);
1639 world.match.phase = "live";
1740 const me = world.me;
1841 const bot = world.players[1] as Player;
@@ -61,12 +84,47 @@ test("a bot turns towards a target it can see, and shoots at it", () => {
6184 expect(fired).toBeGreaterThan(0);
6285 });
6386
87 test("the hands hold the gun down through a spray and give the whole pull back", () => {
88 const { world, bot } = duel();
89 const brain = bot.brain!;
90 bot.weapon = "ak47";
91 bot.pitch = 0;
92 bot.yaw = 180;
93 // Ten rounds into a spray: the pattern has climbed and the hands are on it.
94 bot.shotIndex = 10;
95 for (let t = 0; t < 32; t++) ride(bot, brain, brain.grip, 1 / 64);
96 expect(bot.pitch).toBeGreaterThan(1); // pointing below where it was aiming
97 expect(brain.pull[1]).toBeCloseTo(bot.pitch, 9);
98 expect(brain.pull[0]).toBeCloseTo(bot.yaw - 180, 9);
99 // The world puts shotIndex back a third of a second after the trigger comes
100 // up, and the pull unwinds with it: nothing of it outlives the burst.
101 bot.shotIndex = 0;
102 for (let t = 0; t < 64; t++) ride(bot, brain, brain.grip, 1 / 64);
103 expect(bot.pitch).toBeCloseTo(0, 2);
104 expect(bot.yaw).toBeCloseTo(180, 2);
105 expect(Math.hypot(brain.pull[0], brain.pull[1])).toBeLessThan(0.01);
106
107 // And the scripted brain never rides at all: it is the baseline the policy is
108 // measured against, and a baseline that moves measures nothing.
109 const scripted = duel();
110 scripted.bot.brain = makeBrain(1, 99, false);
111 scripted.bot.weapon = "ak47";
112 scripted.bot.shotIndex = 10;
113 for (let t = 0; t < 32; t++) botThink(scripted.world, scripted.bot, 1 / 64);
114 expect(scripted.bot.brain.pull).toEqual([0, 0]);
115 });
116
64117 test("skill is a handicap on the hands, not on the policy", () => {
65118 const fast = makeBrain(1, 7);
66119 const slow = makeBrain(0, 7);
67120 expect(slow.aimSpeed).toBeLessThan(fast.aimSpeed);
68121 expect(slow.aimError).toBeGreaterThan(fast.aimError);
69122 expect(slow.reaction).toBeGreaterThan(fast.reaction);
123 // Spray control is on the same ladder: fodder holds a fifth of the pattern
124 // and chases it slowly, the best hands in the game hold nine tenths.
125 expect(slow.grip).toBeLessThan(fast.grip);
126 expect(fast.grip).toBeCloseTo(grip(1), 12);
127 expect(slow.gripRate).toBeLessThan(fast.gripRate);
70128
71129 // The same intent, asked of both: the slow one cannot turn as far.
72130 const { world, bot } = duel();
@@ -88,643 +146,312 @@ test("a bot that has only just seen you does not shoot yet", () => {
88146 expect(immediate.fire).toBe(false);
89147 });
90148
91 // --- hearing --------------------------------------------------------------
149 test("a goal nothing can walk to is nudged to a cell something can", () => {
150 // A crate is solid to the search and standing on it is not: a bomb dropped
151 // up there is a point A* answers nothing about.
152 const crate = worldOf(DE_FORGE, 21, 18, 0); // one of de_forge's jumpable crates
153 expect(walkable(DE_FORGE, colOf(DE_FORGE, crate[0]), rowOf(DE_FORGE, crate[1]))).toBe(false);
154 const from = DE_FORGE.spawns.t[0] as Vec3;
155 expect(findPath(DE_FORGE, from, crate)).toHaveLength(0);
92156
93 /**
94 * A bot standing in ct mid with its back to b link, and the person 512 units
95 * away through the wall between the two. The person moves for `seconds` —
96 * running, walking or ducking — and what comes back is what the bot made of
97 * the noise. Nothing here is pushed by hand: the footsteps are the ones
98 * `world.ts` pushes for a person who is actually moving.
99 */
100 function behindTheWall(seconds: number, how: Partial<typeof NO_INPUT>): Player {
101 const world = createWorld(COMPETITIVE, "you", "ct", 1, 4242);
157 const path = pathTo(DE_FORGE, from, crate);
158 expect(path.length).toBeGreaterThan(0);
159 const end = path[path.length - 1] as Vec3;
160 expect(Math.hypot(end[0] - crate[0], end[1] - crate[1])).toBeLessThan(3 * 128 * 1.5);
161 });
162
163 test("a bot whose objective sits on a crate walks to it rather than standing still", () => {
164 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 4242);
102165 world.match.phase = "live";
103166 const bot = world.players[1] as Player;
104 world.me.pos = [-448, 1856, 2]; // b link
105 world.me.yaw = 90; // running up the room, not into the wall
106 bot.pos = [64, 1856, 2]; // ct mid, one wall away
107 bot.yaw = 0; // looking the other way entirely
108 bot.brain = makeBrain(1, 99);
109 bot.brain.reset = false;
110 bot.brain.heardAt = world.time;
111 for (let t = 0; t < 64 * seconds; t++) update(world, 1 / 64, { ...NO_INPUT, forward: 1, ...how });
112 return bot;
113 }
114
115 test("a bot turns towards a running enemy it cannot see", () => {
116 const bot = behindTheWall(1, {});
117 const suspect = bot.brain!.suspect;
118 expect(suspect).not.toBeNull();
119 // It believes the noise is roughly where it was made: west of it, in b link.
120 expect(suspect![0]).toBeLessThan(bot.pos[0]);
121 const towards = aimAt(bot, suspect!).yaw;
122 expect(Math.abs(((towards - bot.yaw + 540) % 360) - 180)).toBeLessThan(45);
123 expect(Math.abs(bot.yaw)).toBeGreaterThan(45); // it started out looking the other way
124 });
125
126 test("the same enemy walking or ducking is not heard at all", () => {
127 expect(behindTheWall(1, { walk: true }).brain!.suspect).toBeNull();
128 expect(behindTheWall(1, { duck: true }).brain!.suspect).toBeNull();
129 });
130
131 test("a bot never hears itself walking about", () => {
132 const world = createWorld(RULES, "you", "ct", 1, 606);
133 world.match.phase = "live";
134 const bot = world.players[1] as Player;
135 // The person stands still and silent at the far end of the map; the only
136 // footsteps in this round are the bot's own.
137 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
138 let steps = 0;
139 for (let t = 0; t < 64 * 3; t++) {
140 update(world, 1 / 64, NO_INPUT);
141 steps += world.effects.filter((e) => e.kind === "sound" && e.name === "step" && e.by === bot.id).length;
142 expect(bot.brain?.suspect ?? null).toBeNull();
143 }
144 expect(steps).toBeGreaterThan(0); // it really was making a noise
145 });
146
147 // --- difficulty -----------------------------------------------------------
148
149 test("easy hands are strictly slower and shakier than hard's", () => {
150 const easy = createWorld(SHORT, "you", "ct", 5, 999, "easy");
151 const hard = createWorld(SHORT, "you", "ct", 5, 999, "hard");
152 for (const easyBot of easy.players) {
153 if (!easyBot.bot) continue;
154 const hardBot = hard.players.find((p) => p.id === easyBot.id)!;
155 const easyBrain = easyBot.brain!;
156 const hardBrain = hardBot.brain!;
157 expect(easyBrain.aimSpeed).toBeLessThan(hardBrain.aimSpeed);
158 expect(easyBrain.aimError).toBeGreaterThan(hardBrain.aimError);
159 expect(easyBrain.reaction).toBeGreaterThan(hardBrain.reaction);
160 }
161 });
162
163 test("createWorld gives every bot a skill inside its difficulty's band", () => {
164 for (const difficulty of DIFFICULTIES) {
165 const { lo, hi } = skillBand(difficulty);
166 const world = createWorld(SHORT, "you", "ct", 5, 555, difficulty);
167 const bots = world.players.filter((p) => p.bot);
168 expect(bots.length).toBeGreaterThan(0);
169 for (const bot of bots) {
170 expect(bot.brain).toBeDefined();
171 expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo);
172 expect(bot.brain!.skill).toBeLessThanOrEqual(hi);
173 }
174 }
175 });
176
177 test("a difficulty change waits for the next startRound", () => {
178 const world = createWorld(SHORT, "you", "ct", 5, 777, "normal");
179 const bot = world.players.find((p) => p.bot)!;
180 const before = bot.brain!.skill;
181 setDifficulty(world, "hard");
182 // Queued, not applied: the round already running keeps the hands it started with.
183 expect(world.difficulty).toBe("normal");
184 expect(bot.brain!.skill).toBe(before);
185 startRound(world);
186 expect(world.difficulty).toBe("hard");
187 const { lo, hi } = skillBand("hard");
188 expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo);
189 expect(bot.brain!.skill).toBeLessThanOrEqual(hi);
190 });
191
192 test("restyleBrain changes the hands but keeps the plan it already made", () => {
193 const brain = makeBrain(0.9, 5);
194 brain.path = [[100, 200, 0]];
195 brain.step = 1;
196 brain.site = "B";
197 brain.targetId = 4;
198 restyleBrain(brain, 3, "easy");
199 const { lo, hi } = skillBand("easy");
200 expect(brain.skill).toBeGreaterThanOrEqual(lo);
201 expect(brain.skill).toBeLessThanOrEqual(hi);
202 expect(brain.path).toEqual([[100, 200, 0]]);
203 expect(brain.step).toBe(1);
204 expect(brain.site).toBe("B");
205 expect(brain.targetId).toBe(4);
206 });
207
208 test("the card describes a real training run", () => {
209 expect(CARD.envSteps).toBeGreaterThan(1_000_000);
210 expect(CARD.updates).toBeGreaterThan(0);
211 expect(CARD.duels).toBeGreaterThan(0);
212 expect(CARD.winRate).toBeGreaterThanOrEqual(0);
213 expect(CARD.winRate).toBeLessThanOrEqual(100);
214 expect(CARD.trainedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
215 });
216
217 // --- the round's plan -----------------------------------------------------
218
219 /**
220 * A round played out headlessly: every player, the person's slot included, is
221 * driven by the same bot code, so this is a real round with nobody watching.
222 * Three a side and a half-minute clock: the whole suite is a few seconds, and
223 * a full 1:55 round with ten players in it is most of one on its own.
224 */
225 const RULES = { ...SHORT, freezeTime: 0.5, roundTime: 30 };
226
227 function playRound(seed: number, teamSize = 3): { planted: boolean; left: number } {
228 const world = createWorld(RULES, "you", "ct", teamSize, seed);
229 for (let t = 0; t < 64 * 70; t++) {
230 update(world, 1 / 64, botThink(world, world.me, 1 / 64));
231 if (world.bomb.state === "planted") return { planted: true, left: world.match.timer };
232 if (world.match.phase === "over" || world.match.phase === "match_over") break;
233 }
234 return { planted: false, left: 0 };
235 }
236
237 test("a side walks in together: every terrorist reads the round's own site", () => {
238 const world = createWorld(SHORT, "you", "ct", 5, 20240);
239 for (let t = 0; t < 4; t++) for (const p of world.players) if (p.bot) botThink(world, p, 1 / 64);
240 const ts = world.players.filter((p) => p.team === "t" && p.bot);
241 expect(ts.length).toBe(5);
242 for (const t of ts) expect(t.brain?.site).toBe(world.plan.push);
243 // And the defence covers both sites rather than all standing on one.
244 const cts = world.players.filter((p) => p.team === "ct" && p.bot).map((p) => p.brain?.site);
245 expect(new Set(cts).size).toBe(2);
246 });
247
248 test("the carrier gets the bomb down inside the round clock", () => {
249 const seeds = [11, 23, 37, 51, 67, 83, 97, 113, 131, 149];
250 const planted = seeds.filter((seed) => playRound(seed).planted);
251 // Not every round: a carrier that loses the fight on the way is a round the
252 // terrorists lost, and that is allowed to happen. Half of them, though —
253 // where the code this replaced planted in none of these, because a bot that
254 // rolled its own site never arrived anywhere with anybody. Ten seeds rather
255 // than five because the rate this is asking about is only about 54% (that is
256 // `scripts/measure.ts` over 177 rounds, before and after the bots could
257 // hear), and five rounds cannot tell 54% from a bad afternoon.
258 expect(planted.length).toBeGreaterThanOrEqual(5);
259 }, 10_000); // ten rounds played out headlessly, twice the default timeout for a slower box
260
261 test("a bomb on the floor is still picked up by a living terrorist", () => {
262 const world = createWorld(RULES, "you", "ct", 1, 4242);
263 world.match.phase = "live";
264 const bot = world.players.find((p) => p.team === "t") as Player;
265 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2]; // out of the way, and out of sight
167 bot.team = "t";
266168 bot.hasBomb = false;
267 world.bomb = { ...world.bomb, state: "dropped", pos: [bot.pos[0] + 240, bot.pos[1], bot.pos[2] + 4] };
268 for (let t = 0; t < 64 * 6 && world.bomb.state === "dropped"; t++) update(world, 1 / 64, NO_INPUT);
269 expect(world.bomb.state).toBe("carried");
270 expect(bot.hasBomb).toBe(true);
271 });
272
273 // --- picking a gun up off the floor ----------------------------------------
274
275 test("a bot with an empty primary slot detours to a rifle a few steps off its path", () => {
276 const world = createWorld(RULES, "you", "t", 1, 4242);
277 world.match.phase = "live";
278 const bot = world.players.find((p) => p.team === "ct") as Player;
279 world.me.alive = false; // nothing for it to fight, nothing to flee
280 expect(bot.primary).toBeNull(); // an eco round: too little money to have bought one
281 world.pickups.push({
282 weapon: "m4a4", pos: [bot.pos[0] + 80, bot.pos[1], bot.pos[2] + 4], ammo: { mag: 30, reserve: 90 },
283 });
284 for (let t = 0; t < 64 * 6 && bot.primary === null; t++) update(world, 1 / 64, NO_INPUT);
285 expect(bot.primary).toBe("m4a4");
286 });
287
288 test("a bot already holding a primary never diverts for one on the floor", () => {
289 const world = createWorld(SHORT, "you", "ct", 1, 1);
290 const bot = world.players[1] as Player;
291 bot.primary = "ak47";
292 world.pickups.push({
293 weapon: "m4a4", pos: [bot.pos[0] + 10, bot.pos[1], bot.pos[2]], ammo: { mag: 30, reserve: 90 },
294 });
295 const goal: [number, number, number] = [123, 456, 0];
296 expect(gunGoal(world, bot, goal)).toBe(goal);
297 });
298
299 test("a rifle across the map is not worth crossing for", () => {
300 const world = createWorld(SHORT, "you", "ct", 1, 1);
301 const bot = world.players[1] as Player;
302 bot.primary = null;
303 bot.pos = [0, 0, 2];
304 world.pickups.push({ weapon: "ak47", pos: [4000, 4000, 2], ammo: { mag: 30, reserve: 90 } });
305 const goal: [number, number, number] = [100, 0, 2];
306 expect(gunGoal(world, bot, goal)).toBe(goal);
307 });
308
309 test("an empty pickups list leaves the goal it was given untouched", () => {
310 const world = createWorld(SHORT, "you", "ct", 1, 1);
311 const bot = world.players[1] as Player;
312 bot.primary = null;
313 const goal: [number, number, number] = [123, 456, 0];
314 expect(world.pickups.length).toBe(0);
315 expect(gunGoal(world, bot, goal)).toBe(goal); // same reference: nothing was computed
316 });
317
318 // --- the radio ------------------------------------------------------------
319
320 /**
321 * A side thinking with nobody to shoot at: the enemy is taken out of the world
322 * so every bot takes the walking branch, which is the one that reads the plan.
323 * The person is a terrorist here, so the terrorists are the side that hears.
324 */
325 function quietSide(seed: number): { world: ReturnType<typeof createWorld>; carrier: Player } {
326 const world = createWorld(SHORT, "you", "t", 5, seed);
327 world.match.phase = "live";
328 for (const p of world.players) {
329 if (p.team === "ct") p.alive = false;
330 p.hasBomb = false;
331 }
332 const carrier = world.players.find((p) => p.team === "t" && p.bot) as Player;
333 carrier.hasBomb = true;
334 return { world, carrier };
335 }
336
337 const thinkAll = (world: ReturnType<typeof createWorld>): void => {
338 for (const p of world.players) if (p.bot && p.alive) botThink(world, p, 1 / 64);
339 };
340
341 test("a bot carrying the bomb obeys a call exactly as it obeys the plan", () => {
342 const { world, carrier } = quietSide(20240);
343 thinkAll(world);
344 expect(carrier.brain?.site).toBe(world.plan.push);
345 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[world.plan.push]);
346
347 const called = otherSite(world.plan.push);
348 world.time += 1;
349 radioCall(world, called);
350 thinkAll(world); // the next tick, and the bomb is already walking the other way
351 expect(carrier.brain?.site).toBe(called);
352 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[called]);
353 });
354
355 test("the four teammates turn round on the call, and the other side never hears it", () => {
356 const { world } = quietSide(4242);
357 thinkAll(world);
358 const called = otherSite(world.plan.push);
359 world.time += 1;
360 radioCall(world, called);
361 thinkAll(world);
362 const ts = world.players.filter((p) => p.team === "t" && p.bot);
363 expect(ts.length).toBe(4);
364 for (const t of ts) expect(t.brain?.site).toBe(called);
365 // The counter-terrorists were never told anything: their brains still read
366 // the roll, split across the two sites exactly as they were.
367 for (const ct of world.players.filter((p) => p.team === "ct")) ct.alive = true;
368 thinkAll(world);
369 const cts = world.players.filter((p) => p.team === "ct").map((p) => p.brain?.site);
370 expect(new Set(cts).size).toBe(2);
371 });
372
373 test("a call expires and the side goes back to the plan it rolled", () => {
374 const { world, carrier } = quietSide(1234);
375 const called = otherSite(world.plan.push);
376 radioCall(world, called);
377 thinkAll(world);
378 expect(carrier.brain?.site).toBe(called);
379 world.time += CALL_LIFE + 1;
380 thinkAll(world);
381 expect(carrier.brain?.site).toBe(world.plan.push);
382 });
383
384 test("hold stops the push and leaves nobody standing still in the open", () => {
385 const { world, carrier } = quietSide(777);
386 thinkAll(world);
387 const site = DE_FORGE.sites[world.plan.push];
388 expect(dist2d(carrier.pos, carrier.brain?.goal ?? [0, 0, 0])).toBeGreaterThan(600);
389
390 world.time += 1;
391 radioCall(world, "hold");
392 thinkAll(world);
393 for (const t of world.players.filter((p) => p.team === "t" && p.bot)) {
394 const goal = t.brain?.goal as [number, number, number];
395 // Where it stands, not where the plan was taking it — and a goal it can
396 // still walk to, so a held bot repositions rather than freezing.
397 expect(t.brain?.site).toBe(world.plan.push); // hold does not move the site
398 expect(dist2d(t.pos, goal)).toBeLessThanOrEqual(360);
399 expect(dist2d(goal, site)).toBeGreaterThan(600);
400 expect(t.brain?.path.length).toBeGreaterThan(0);
401 }
402 });
403
404 test("a call outranks the rotation a counter-terrorist would have made on its own", () => {
405 // The person is a counter-terrorist — the side the menu picks by default —
406 // and one of their teammates has just died at B. That is the contact the
407 // defence rotates to on its own, and it is exactly what a person who has
408 // seen something at A is calling against.
409 const world = createWorld(SHORT, "you", "ct", 5, 31);
410 world.match.phase = "live";
411 for (const p of world.players) if (p.team === "t") p.alive = false;
412 const cts = world.players.filter((p) => p.team === "ct" && p.bot);
413 const fallen = cts[0] as Player;
414 fallen.alive = false;
415 fallen.pos = [...DE_FORGE.sites.B] as [number, number, number];
416 world.feed.push({
417 at: world.time, killer: "them", killerId: 9, victim: fallen.name, victimId: fallen.id,
418 weapon: "ak47", headshot: false, killerTeam: "t",
419 });
420
421 const standing = cts.filter((p) => p.alive);
422 const near = (p: Player, site: "A" | "B"): number =>
423 dist2d(p.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[site]);
424 thinkAll(world);
425 // Without a word said, every one of them is on its way to the contact.
426 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
427
428 world.time += 1;
429 radioCall(world, "A");
430 thinkAll(world);
431 for (const ct of standing) {
432 expect(ct.brain?.site).toBe("A");
433 expect(near(ct, "A")).toBeLessThan(400);
434 }
435
436 // And when the call runs out, the side reads the round for itself again.
437 world.time += CALL_LIFE + 1;
438 thinkAll(world);
439 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
440 });
441
442 // --- the clock ------------------------------------------------------------
443
444 /**
445 * The terrorists walking in with the bomb and the clock wherever the test puts
446 * it, with one counter-terrorist standing in front of the carrier so that the
447 * branch every terrorist is in is the fight — the one that trades ground for a
448 * duel. Nothing is planted, and the person is a terrorist so that their side
449 * is the one the radio reaches.
450 */
451 function underFire(secondsLeft: number, seed = 20240): {
452 world: ReturnType<typeof createWorld>; carrier: Player; enemy: Player;
453 } {
454 const world = createWorld(SHORT, "you", "t", 3, seed);
455 world.match.phase = "live";
456 world.match.timer = secondsLeft;
457 const carrier = world.players.find((p) => p.team === "t" && p.bot && p.hasBomb) as Player;
458 const enemy = world.players.find((p) => p.team === "ct") as Player;
459 for (const p of world.players) if (p.team === "ct" && p.id !== enemy.id) p.alive = false;
460 // Close enough to be unmissable, and the carrier already looking at it.
461 enemy.pos = [carrier.pos[0] + 160, carrier.pos[1], carrier.pos[2]];
462 carrier.yaw = aimAt(carrier, bodyCentre(enemy)).yaw;
463 return { world, carrier, enemy };
464 }
465
466 test("with the clock low and no plant, a terrorist's feet go to the site", () => {
467 const { world, carrier } = underFire(25);
468 botThink(world, carrier, 1 / 64);
469 expect(carrier.brain?.targetId).not.toBeNull(); // it really is in a fight
470 // The policy still has the gun; the plan has taken the feet, and they are
471 // walking to the site the round rolled.
472 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[carrier.brain!.site]);
473 expect(carrier.brain?.path.length).toBeGreaterThan(0);
474 });
475
476 test("with the clock high the same fight is the policy's to move", () => {
477 const { world, carrier } = underFire(110);
478 botThink(world, carrier, 1 / 64);
479 expect(carrier.brain?.targetId).not.toBeNull(); // the same fight, one number apart
480 expect(carrier.brain?.goal).toBeNull(); // nothing asked the plan for a path
481 });
482
483 test("the escort feels the clock too, not just the bot holding the bomb", () => {
484 const { world, carrier } = underFire(25);
485 const escort = world.players.find((p) => p.team === "t" && p.bot && p.id !== carrier.id) as Player;
486 escort.pos = [...carrier.pos] as [number, number, number];
487 escort.yaw = carrier.yaw;
488 botThink(world, escort, 1 / 64);
489 expect(escort.brain?.targetId).not.toBeNull();
490 expect(dist2d(escort.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[escort.brain!.site])).toBeLessThan(400);
491 });
492
493 test("a call is answered on a hard push: the push goes where the person said", () => {
494 const { world, carrier } = underFire(25);
495 const called = otherSite(world.plan.push);
496 world.time += 1;
497 radioCall(world, called);
498 botThink(world, carrier, 1 / 64);
499 expect(carrier.brain?.site).toBe(called);
500 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[called]);
501 });
502
503 test("hold outranks the clock: a bot told to stand still is not dragged off", () => {
504 const { world, carrier } = underFire(25);
505 world.time += 1;
506 radioCall(world, "hold");
507 botThink(world, carrier, 1 / 64);
508 expect(carrier.brain?.holding).toBe(true);
509 // It fights where it was told to stand: nothing asked the plan for a path
510 // to the site, which is what the same clock does to a bot nobody called.
511 expect(carrier.brain?.goal).toBeNull();
512 });
513
514 test("once the bomb is down the clock stops moving the terrorists' feet", () => {
515 const { world, carrier } = underFire(25);
516 // The same round one moment later: the bomb is on the floor at the site and
517 // the carrier is in the fight it was already in.
518 world.bomb = {
519 ...world.bomb,
520 state: "planted",
521 pos: [...DE_FORGE.sites[carrier.brain?.site ?? "A"]] as [number, number, number],
522 };
523 carrier.hasBomb = false;
524 botThink(world, carrier, 1 / 64);
525 expect(carrier.brain?.targetId).not.toBeNull();
526 // There is nothing left to be late for. Time pressure is off, so nothing
527 // walks a terrorist at the bomb in the middle of a duel it is having —
528 // holding near a planted bomb is not the same thing as standing on it.
529 expect(carrier.brain?.goal).toBeNull();
530 });
531
532 /** The defence with nothing to shoot at and nothing on the floor to defend. */
533 function quietDefence(secondsLeft: number, seed = 31): ReturnType<typeof createWorld> {
534 const world = createWorld(SHORT, "you", "ct", 3, seed);
535 world.match.phase = "live";
536 world.match.timer = secondsLeft;
537 for (const p of world.players) if (p.team === "t") p.alive = false;
538 return world;
539 }
540
541 const defenders = (world: ReturnType<typeof createWorld>): Player[] =>
542 world.players.filter((p) => p.team === "ct" && p.bot);
543
544 test("with the clock low and no plant, the defence stops holding and goes looking", () => {
545 const late = quietDefence(HUNT_AT - 1);
546 thinkAll(late);
547 for (const ct of defenders(late)) {
548 const goal = ct.brain?.goal ?? [0, 0, 0];
549 // Nobody has come to the corner it was sitting in, so it walks at the half
550 // of the map the terrorists came from — and it walks, rather than teleports.
551 expect(dist2d(goal, DE_FORGE.muster.t)).toBeLessThan(1000);
552 expect(dist2d(goal, DE_FORGE.sites[ct.brain!.site])).toBeGreaterThan(2000);
553 expect(ct.brain?.path.length).toBeGreaterThan(0);
554 }
555
556 const early = quietDefence(HUNT_AT + 1);
557 thinkAll(early);
558 for (const ct of defenders(early)) {
559 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[ct.brain!.site])).toBeLessThan(400);
560 }
561 });
562
563 test("the defence leaves its angle the tick the clock says so, not at the next repath", () => {
564 const world = quietDefence(HUNT_AT + 10);
565 thinkAll(world);
566 const holding = defenders(world).map((ct) => {
567 const brain = ct.brain as NonNullable<Player["brain"]>;
568 // A path it is part-way through and a repath it is nowhere near due for:
569 // nothing about this bot is stale, which is the whole point of the test.
570 expect(dist2d(brain.goal ?? [0, 0, 0], DE_FORGE.sites[brain.site])).toBeLessThan(400);
571 expect(brain.path.length).toBeGreaterThan(0);
572 expect(brain.step).toBeLessThan(brain.path.length);
573 expect(world.time).toBeLessThan(brain.repathAt);
574 return { ct, brain, plannedFor: brain.plannedFor };
575 });
576 expect(holding.length).toBeGreaterThan(0);
577
578 // Eleven seconds of clock and not a tick of anything else. The objective it
579 // planned for has to say it is hunting, or the defence sits on its angle for
580 // the rest of its repath window with the round running out.
581 world.match.timer = HUNT_AT - 1;
582 thinkAll(world);
583 for (const { brain, plannedFor } of holding) {
584 expect(brain.plannedFor).not.toBe(plannedFor);
585 expect(dist2d(brain.goal ?? [0, 0, 0], DE_FORGE.muster.t)).toBeLessThan(1000);
586 }
587 });
588
589 test("a call turns a rotating defender round inside its repath window", () => {
590 // The person is a counter-terrorist, a teammate has just died at B, and the
591 // side is rotating there on its own. The person calls A — which is the site
592 // this bot was rolled onto anyway, so nothing about its orders changes
593 // except that a person said them.
594 const world = quietDefence(SHORT.roundTime); // nothing hunting at this clock
595 const fallen = defenders(world)[0] as Player;
596 fallen.alive = false;
597 fallen.pos = [...DE_FORGE.sites.B] as [number, number, number];
598 world.feed.push({
599 at: world.time, killer: "them", killerId: 9, victim: fallen.name, victimId: fallen.id,
600 weapon: "ak47", headshot: false, killerTeam: "t",
601 });
602 thinkAll(world);
603 const rotated = defenders(world).filter((p) => p.alive && p.brain?.site === "A");
604 expect(rotated.length).toBeGreaterThan(0);
605 for (const ct of rotated) {
606 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.B)).toBeLessThan(400);
607 expect(world.time).toBeLessThan(ct.brain!.repathAt); // the path is fresh
608 }
609
610 radioCall(world, "A");
611 thinkAll(world);
612 for (const ct of rotated) {
613 expect(ct.brain?.site).toBe("A"); // the call named the site it already had
614 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.A)).toBeLessThan(400);
615 }
616 });
617
618 test("a bomb on the floor is what the defence is for: no hunt once it is planted", () => {
619 const world = quietDefence(HUNT_AT - 1);
620 world.bomb = { ...world.bomb, state: "planted", pos: [...DE_FORGE.sites.A] as [number, number, number] };
621 thinkAll(world);
622 for (const ct of defenders(world)) {
623 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.A)).toBeLessThan(200);
624 }
625 });
626
627 test("a defender told to hold keeps holding however low the clock is", () => {
628 const world = quietDefence(HUNT_AT - 1);
629 world.time += 1;
630 radioCall(world, "hold");
631 thinkAll(world);
632 for (const ct of defenders(world)) {
633 expect(ct.brain?.holding).toBe(true);
634 expect(dist2d(ct.pos, ct.brain?.goal ?? [0, 0, 0])).toBeLessThanOrEqual(360);
635 }
636 });
637
638 // --- callouts -------------------------------------------------------------
639
640 /**
641 * One teammate and one enemy, six cells apart down mid with nothing between
642 * them, and two more players parked out of the way at the far end of the map.
643 * The teammate is looking straight at the enemy, so `pickTarget`'s own trace
644 * is what decides whether it can see anything — nothing here is handed to it.
645 */
646 function squad(seed = 4114): { world: World; mate: Player; foe: Player } {
647 const world = createWorld(COMPETITIVE, "you", "ct", 2, seed);
648 world.match.phase = "live";
649 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
650 const foe = world.players.find((p) => p.team === "t") as Player;
651 for (const p of world.players) {
652 if (p === mate || p === foe) continue;
653 p.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
654 }
655 mate.pos = [...worldOf(DE_FORGE, 22, 14, 2)];
656 foe.pos = [...worldOf(DE_FORGE, 22, 20, 2)];
657 mate.brain = makeBrain(1, 99);
658 mate.brain.reset = false;
659 mate.yaw = aimAt(mate, bodyCentre(foe)).yaw;
660 return { world, mate, foe };
661 }
662
663 const callouts = (world: World): string[] =>
664 world.messages.filter((m) => m.by !== undefined).map((m) => m.text);
665
666 /** Let `bot` think for `seconds` of ticks, the way a round would. */
667 const thinkFor = (world: World, bot: Player, seconds: number): void => {
668 for (let t = 0; t < 64 * seconds; t++) {
669 botThink(world, bot, 1 / 64);
169 world.me.alive = false; // nothing to fight: this is about the walking
170 // The bomb, dropped on top of a crate in mid.
171 world.bomb.state = "dropped";
172 world.bomb.pos = worldOf(DE_FORGE, 21, 18, 0);
173 const before = dist(bot.pos, world.bomb.pos);
174 for (let tick = 0; tick < 64 * 3; tick++) {
670175 world.time += 1 / 64;
176 applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64);
671177 }
178 expect(dist(bot.pos, world.bomb.pos)).toBeLessThan(before - 200);
179 });
180
181
182 test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, blinder", () => {
183 const { world, me, bot } = duel();
184 const brain = bot.brain!;
185 const clear = flashed(bot, brain);
186 // Unflashed it is exactly the skill it always was — the same numbers, to the
187 // bit, which is why every pinned run in this repository is untouched by this.
188 expect(blindness(bot)).toBe(0);
189 expect(clear.aimSpeed).toBe(brain.aimSpeed);
190 expect(clear.aimError).toBe(brain.aimError);
191 expect(clear.reaction).toBe(brain.reaction);
192 expect(clear.grip).toBe(brain.grip);
193
194 bot.blind = FLASH.flash.maxBlind;
195 const hit = flashed(bot, brain);
196 expect(blindness(bot)).toBe(1);
197 expect(hit.aimSpeed).toBeCloseTo(brain.aimSpeed * 0.2, 6);
198 expect(hit.aimError).toBeCloseTo(brain.aimError * 7, 6);
199 expect(hit.reaction).toBeCloseTo(brain.reaction * 4, 6);
200 expect(hit.sight).toBeLessThan(clear.sight);
201 // And it holds none of its spray: a pair of hands that cannot see the gun
202 // cannot ride the pattern either.
203 expect(hit.grip).toBe(0);
204 // Half way through the flash it is half of each: the number in the world is
205 // the only thing that decides, and it is the same one the HUD paints.
206 bot.blind = FLASH.flash.maxBlind / 2;
207 expect(flashed(bot, brain).aimSpeed).toBeCloseTo(brain.aimSpeed * 0.6, 6);
208
209 // Its head is untouched: the observation, the heads and the weights know
210 // nothing about any of this.
211 expect(OBS_SIZE).toBe(33);
212 expect(brain.obs.length).toBe(OBS_SIZE);
213 });
214
215 test("a flashed bot turns slower and shoots later than the same bot unflashed", () => {
216 const turn = (blind: number): number => {
217 const { world, me, bot } = duel();
218 bot.blind = blind;
219 bot.yaw = 90; // ninety degrees off the target
220 let fired = 0;
221 for (let t = 0; t < 64; t++) {
222 const input = botThink(world, bot, 1 / 64);
223 world.time += 1 / 64;
224 if (input.fire) fired += 1;
225 }
226 return Math.abs(bot.yaw - 90) - fired * 1e-6;
227 };
228 // A second of the same duel, from the same seed, with and without a flash on
229 // it: the blinded one has come nowhere near as far round towards the shot.
230 expect(turn(FLASH.flash.maxBlind)).toBeLessThan(turn(0));
231
232 // And a bot blinded at the far end of the map does not pick a fight it cannot
233 // see: its sight shrinks with the blindness.
234 const blinded = duel();
235 blinded.bot.blind = FLASH.flash.maxBlind;
236 botThink(blinded.world, blinded.bot, 1 / 64);
237 expect(flashed(blinded.bot, blinded.bot.brain!).sight).toBeLessThan(500);
238 expect(blinded.bot.brain!.targetId).toBe(null);
239 // The same bot, the same room, the same enemy five hundred units away, with
240 // its eyes working: it picks the fight it just could not see.
241 const seeing = duel();
242 botThink(seeing.world, seeing.bot, 1 / 64);
243 expect(seeing.bot.brain!.targetId).toBe(seeing.me.id);
244 });
245
246 /**
247 * A duel where both of them are standing somewhere de_forge actually has a
248 * floor, five hundred units apart down a clear line. The tests below step the
249 * movement as well as the thinking, so where they stand has to be real.
250 */
251 const standoff = (): ReturnType<typeof duel> => {
252 const d = duel();
253 d.me.pos = [-448, 2496, 2];
254 d.bot.pos = [64, 2496, 2];
255 d.bot.yaw = aimAt(d.bot, bodyCentre(d.me)).yaw;
256 return d;
672257 };
673258
674 test("a teammate that can see an enemy says where it is", () => {
675 const { world, mate, foe } = squad();
676 botThink(world, mate, 1 / 64);
677 expect(callouts(world)).toEqual([`${mate.name}: enemy — ${placeAt(DE_FORGE, foe.pos)}`]);
678 expect(world.messages[world.messages.length - 1]?.by).toBe(mate.id);
259 /** Step a duel for `ticks`, counting the shots the bot actually got out. */
260 const fight = (d: ReturnType<typeof duel>, ticks: number): number => {
261 let shots = 0;
262 for (let t = 0; t < ticks; t++) {
263 const input = botThink(d.world, d.bot, 1 / 64);
264 const before = d.bot.lastShotAt;
265 applyInput(d.world, d.bot, input, 1 / 64);
266 if (d.bot.lastShotAt !== before) shots += 1;
267 d.world.time += 1 / 64;
268 }
269 return shots;
270 };
271
272 test("a gun is dry only with nothing behind it, and a knife never is", () => {
273 const { bot } = duel();
274 bot.primary = "ak47";
275 bot.weapon = "ak47";
276 bot.ammo.ak47 = { mag: 30, reserve: 90 };
277 expect(dry(bot, "ak47")).toBe(false);
278 expect(nextWeapon(bot)).toBe(null);
279 // An empty magazine with a spare behind it is a reload, not a swap.
280 bot.ammo.ak47 = { mag: 0, reserve: 90 };
281 expect(dry(bot, "ak47")).toBe(false);
282 expect(nextWeapon(bot)).toBe(null);
283 // Nothing anywhere: the pistol, then the knife, and then nothing more.
284 bot.ammo.ak47 = { mag: 0, reserve: 0 };
285 expect(dry(bot, "ak47")).toBe(true);
286 expect(nextWeapon(bot)).toBe(bot.secondary);
287 bot.ammo[bot.secondary] = { mag: 0, reserve: 0 };
288 expect(nextWeapon(bot)).toBe("knife");
289 bot.weapon = "knife";
290 expect(dry(bot, "knife")).toBe(false);
291 expect(nextWeapon(bot)).toBe(null);
679292 });
680293
681 test("a teammate with nothing in view says nothing", () => {
682 const { world, mate, foe } = squad();
683 // The same two players, with the whole map between them: b site cannot be
684 // seen from mid, and `pickTarget` is the thing that finds that out.
685 foe.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
686 thinkFor(world, mate, 3);
687 expect(callouts(world)).toEqual([]);
294 test("a bot that has fired its rifle dry draws its pistol and keeps fighting", () => {
295 const d = standoff();
296 d.bot.primary = "ak47";
297 d.bot.weapon = "ak47";
298 d.bot.ammo.ak47 = { mag: 0, reserve: 0 }; // it spent the round's ammunition
299 const shots = fight(d, 192);
300 // Before this it held the trigger down on an empty rifle for the rest of the
301 // round: `shoot` refuses without a round in the magazine and `reload` refuses
302 // with nothing to reload from, so nobody was ever hit.
303 expect<WeaponId>(d.bot.weapon).toBe(d.bot.secondary);
304 expect(shots).toBeGreaterThan(0);
305 expect(d.me.health).toBeLessThan(100);
688306 });
689307
690 test("a teammate does not say the same thing twice in a row", () => {
691 const { world, mate } = squad();
692 botThink(world, mate, 1 / 64);
693 expect(callouts(world).length).toBe(1);
694 // Half a minute of staring down the same corridor at the same enemy: the
695 // sighting was news once, and the rate limit and the contact edge between
696 // them keep it at once.
697 thinkFor(world, mate, 30);
698 expect(callouts(world).length).toBe(1);
308 test("a bot with nothing left to fire closes the distance and knifes", () => {
309 const d = standoff();
310 d.bot.primary = "ak47";
311 d.bot.weapon = "ak47";
312 d.bot.ammo.ak47 = { mag: 0, reserve: 0 };
313 d.bot.ammo[d.bot.secondary] = { mag: 0, reserve: 0 };
314 const started = dist(d.bot.pos, d.me.pos);
315 const shots = fight(d, 384);
316 expect<WeaponId>(d.bot.weapon).toBe("knife");
317 expect(dist(d.bot.pos, d.me.pos)).toBeLessThan(started); // it walked in
318 expect(shots).toBeGreaterThan(0);
319 expect(d.me.health).toBeLessThan(100);
699320 });
700321
701 test("nothing the other side's bots know ever reaches the person's feed", () => {
702 const { world, mate, foe } = squad();
703 // The enemy looking straight back down the same corridor, for as long as
704 // the teammate looked down it.
705 foe.brain = makeBrain(1, 77);
706 foe.brain.reset = false;
707 foe.yaw = aimAt(foe, bodyCentre(mate)).yaw;
708 mate.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2]; // and the teammate out of it
709 thinkFor(world, foe, 30);
710 expect(callouts(world)).toEqual([]);
322 test("a bot pays the deploy time too: no bot bypasses the timer", () => {
323 const d = standoff();
324 d.bot.primary = "ak47";
325 d.bot.weapon = "ak47";
326 d.bot.ammo.ak47 = { mag: 0, reserve: 0 };
327 // A bot switches the way a person does — `input.switchTo` into `applyInput`
328 // — so it goes through exactly the same clock. Its pistol is a Glock (it is
329 // a T here) and CS's 0.7 s is 45 whole ticks at 64 Hz.
330 let swappedAt = -1;
331 let firstShot = -1;
332 for (let t = 0; t < 192; t++) {
333 const input = botThink(d.world, d.bot, 1 / 64);
334 const holding: WeaponId = d.bot.weapon;
335 const before = d.bot.lastShotAt;
336 applyInput(d.world, d.bot, input, 1 / 64);
337 if (swappedAt < 0 && d.bot.weapon !== holding) swappedAt = t;
338 if (firstShot < 0 && d.bot.lastShotAt !== before) firstShot = t;
339 d.world.time += 1 / 64;
340 }
341 expect<WeaponId>(d.bot.weapon).toBe(d.bot.secondary);
342 expect(swappedAt).toBeGreaterThanOrEqual(0);
343 expect(firstShot).toBeGreaterThan(swappedAt);
344 expect(deployTicks(WEAPONS[d.bot.secondary], 1 / 64)).toBe(45);
345 // The empty rifle's trigger sent nothing before the swap either, so the first
346 // shot of the round is the pistol's, and it is not one tick earlier than 45.
347 expect(firstShot - swappedAt).toBeGreaterThanOrEqual(45);
711348 });
712349
713 test("a round restart clears the callouts and leaves the round's own line", () => {
714 const { world, mate, foe } = squad();
715 botThink(world, mate, 1 / 64);
716 expect(callouts(world).length).toBe(1);
717 world.messages.push({ at: world.time, text: "Terrorists win — detonated the bomb" });
718 startRound(world);
719 expect(callouts(world)).toEqual([]);
720 expect(world.messages.map((m) => m.text)).toEqual(["Terrorists win — detonated the bomb"]);
721 // And the side starts the round with nothing already said, so the first
722 // sighting of it is news again.
350 test("a bot with bullets left plays exactly as it did: no swap, no knife", () => {
351 const d = standoff();
352 d.bot.primary = "ak47";
353 d.bot.weapon = "ak47";
354 d.bot.ammo.ak47 = { mag: 30, reserve: 90 };
355 const shots = fight(d, 192);
356 expect(d.bot.weapon).toBe("ak47");
357 expect(shots).toBeGreaterThan(0);
358 });
359
360 // --- the round's plan -------------------------------------------------------
361
362 const planned = (over: Partial<TeamPlan> = {}): RoundPlan => ({
363 round: 1,
364 source: "typesafe",
365 model: "jev-1",
366 t: { site: "A", shape: "stack", tempo: 2, save: false, ...over },
367 ct: { site: "A", shape: "stack", tempo: 2, save: false, ...over },
368 });
369
370 /** A full ten-bot round, frozen on the first tick, with every brain made. */
371 const round = (seed = 77): ReturnType<typeof createWorld> => {
372 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 5, seed);
373 world.match.phase = "freeze";
374 return world;
375 };
376
377 const bots = (world: ReturnType<typeof createWorld>, team: "t" | "ct"): Player[] =>
378 world.players.filter((p) => p.team === team && p !== world.me);
379
380 test("with no plan a bot is what it always was: its own coin flip, no wait", () => {
381 const world = round();
382 const bot = bots(world, "t")[0] as Player;
383 botThink(world, bot, 1 / 64);
384 expect(roundPlan(world)).toBeNull();
385 expect(bot.brain?.job.role).toBe("support");
386 expect(bot.brain?.job.delay).toBe(0);
387 expect(["A", "B"]).toContain(bot.brain?.job.site ?? "");
388 });
389
390 test("a plan makes a team a team: one site between them, and one of them first", () => {
391 const world = round();
392 usePlan(world, planned({ site: "B" }));
393 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
394 const attackers = bots(world, "t");
395 expect(attackers.every((bot) => bot.brain?.job.site === "B")).toBe(true);
396 expect(attackers.filter((bot) => bot.brain?.job.role === "entry")).toHaveLength(1);
397 // The entry goes now and the rest give it its beat: that is the coordination.
398 const entry = attackers.find((bot) => bot.brain?.job.role === "entry") as Player;
399 expect(entry.brain?.job.delay).toBe(0);
400 expect(attackers.some((bot) => (bot.brain?.job.delay ?? 0) > 0)).toBe(true);
401 });
402
403 test("a split sends the attackers to both sites at once", () => {
404 const world = round();
405 usePlan(world, planned({ site: "A", shape: "split" }));
406 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
407 const sites = bots(world, "t").map((bot) => bot.brain?.job.site);
408 expect(sites.filter((site) => site === "A").length).toBeGreaterThan(0);
409 expect(sites.filter((site) => site === "B").length).toBeGreaterThan(0);
410 });
411
412 test("a bot waiting for its beat stands still, and goes when the beat comes", () => {
413 const world = round();
414 usePlan(world, planned());
415 world.match.phase = "freeze";
416 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
417 const waiting = bots(world, "t").find((bot) => (bot.brain?.job.delay ?? 0) > 0) as Player;
418 waiting.hasBomb = false;
723419 world.match.phase = "live";
724 mate.pos = [...worldOf(DE_FORGE, 22, 14, 2)];
725 foe.pos = [...worldOf(DE_FORGE, 22, 20, 2)];
726 mate.yaw = aimAt(mate, bodyCentre(foe)).yaw;
727 botThink(world, mate, 1 / 64); // `reset` and all: this is the round's first tick
728
729 expect(callouts(world).length).toBe(1);
420 world.time += 0.5;
421 const held = botThink(world, waiting, 1 / 64);
422 expect(held.forward).toBe(0);
423 expect(held.side).toBe(0);
424 // Past the wait it moves off towards the site like any other bot.
425 world.time = (waiting.brain?.pushAt ?? 0) + 0.1;
426 const moving = botThink(world, waiting, 1 / 64);
427 expect(Math.abs(moving.forward) + Math.abs(moving.side)).toBeGreaterThan(0);
428 });
429
430 test("a plan that says save is a round nothing is spent on a rifle", () => {
431 const world = round();
432 const bot = bots(world, "t")[0] as Player;
433 bot.money = 2400;
434 usePlan(world, planned({ save: true }));
435 botBuy(world, bot);
436 expect(bot.primary).toBeNull();
437 expect(bot.money).toBe(2400);
438
439 // The same money with the same plan, on a side that is not saving: it buys.
440 const spending = round(78);
441 const other = bots(spending, "t")[0] as Player;
442 other.money = 2400;
443 usePlan(spending, planned({ save: false }));
444 botBuy(spending, other);
445 expect(other.money).toBeLessThan(2400);
446 });
447
448 test("a plan that lands mid-round re-tasks the team it was cut for", () => {
449 const world = round();
450 usePlan(world, planned({ site: "A" }));
451 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
452 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "A")).toBe(true);
453 usePlan(world, planned({ site: "B" }));
454 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
455 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "B")).toBe(true);
456 expect(roundPlan(world)?.t.site).toBe("B");
730457 });
src/client/bots.ts +547 −396
@@ -7,68 +7,107 @@
77 * The split is deliberate. The round's objective is a plan (go to a site, plant
88 * the bomb, defuse it) and A* already solves it. The fight is a reflex, and a
99 * reflex is what reinforcement learning is good at: src/rl trains this policy
10 * on millions of duels in the same simulation you are playing, and the weights
11 * it produces are in weights.ts.
10 * on millions of duels in the same simulation you are playing.
11 *
12 * Between the two there is now a third thing, and it is the round: `usePlan`
13 * hands the bots a `RoundPlan` — a site, a shape, a tempo and a save for each
14 * side — and `src/plan/plan.ts` cuts it into one job per bot, so five attackers
15 * arrive at one site together, or at two sites in two groups, instead of each
16 * flipping its own coin. Where that plan came from is not this file's business:
17 * the page asks its own server, and the server asks TypeSafe AI when it has a
18 * key and answers with the local plan when it has not. Bots nobody planned for
19 * — the arena's, the trainer's — behave exactly as they did before, down to the
20 * coin flip, because `usePlan` was never called on them.
21 *
22 * Smoke is handled in the same layer, and deliberately not in the policy. A bot
23 * cannot see through a cloud because `canSee` says so, which is one change in
24 * one function and covers the shooting; what it cannot answer is the walking,
25 * so `skirt` below bends the next step of a path around a cloud rather than
26 * into it. Both are scripted, both are outside the network, and the shipped
27 * weights are the same eight kilobytes they were.
28 *
29 * The weights are not in here and are not imported: they arrive as data. In a
30 * browser `src/client/main.ts` downloads `weights.json` as this game's own
31 * asset and calls `usePolicy` before Play is pressable; in Bun
32 * `src/client/shipped.ts` reads the committed document and calls the same
33 * function. So a second game with its own policy — or with none — is a
34 * different caller of `usePolicy` and not a different copy of this file.
1235 */
1336
1437 import { decode, HEADS, STILL, type Intent } from "./action.ts";
15 import { fromOther, loudness, reported, type HeardName, type Maker } from "./hearing.ts";
16 import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts";
17 import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts";
18 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
19 import { mustHunt, mustPush, ordersFor, worthTheDetour, type Site } from "./plan.ts";
2038 import {
21 forward, newActivations, sample, unpack, type Activations, type Policy,
22 } from "./policy.ts";
23 import { CARD, PACKED } from "./weights.ts";
24 import { WEAPONS, type WeaponId } from "./weapons.ts";
39 angleDelta, clamp, dist, forward, newActivations, rng, sample,
40 type Activations, type Policy, type Vec3,
41 } from "../engine/index.ts";
42 import { assign, DEFAULT_JOB, type Job, type RoundPlan } from "../plan/plan.ts";
43 import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
44 import { CS_HULL } from "./movement.ts";
45 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
46 import { SHAPE } from "./packed.ts";
47 import { ease, grip, gripRate, sprayPull } from "./spray.ts";
48 import { cloudRadius } from "./grenade.ts";
49 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
50 import { buy } from "./economy.ts";
2551 import type { Input, Player, World } from "./world.ts";
26 import { aimAt, bodyCentre, buy, callout, canSee, NO_INPUT, pickupSlot } from "./world.ts";
52 import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts";
53 import { coverFrom } from "./cover.ts";
2754
28 export { CARD };
29
30 /** The weights are unpacked once, the first time a bot needs to think. */
55 /**
56 * The policy every bot in this process thinks with, put here once by whoever
57 * got hold of one. There is one because there is one game: a bot's own skill
58 * is what makes it easy or hard, not a network of its own.
59 */
3160 let learned: Policy | null = null;
61
62 /** What `policy()` says when nobody handed one in. */
63 export const NO_POLICY = "no policy: nothing has called usePolicy, so a bot has nothing to think with";
64
65 /** Hand the bots the policy they think with. `main.ts` at boot, `shipped.ts` in Bun. */
66 export function usePolicy(net: Policy): void {
67 learned = net;
68 }
69
70 /**
71 * The policy the bots think with. It throws rather than falling back to the
72 * scripted fight: a round played against bots that quietly stopped being the
73 * trained ones is the failure nobody would notice, and the browser never
74 * reaches here anyway — Play is not pressable until the weights have arrived.
75 */
3276 export function policy(): Policy {
33 if (learned === null) learned = unpack(PACKED);
77 if (learned === null) throw new Error(NO_POLICY);
3478 return learned;
3579 }
3680
37 /** The policy decides at 16 Hz and holds it, exactly as it was trained. */
38 const SKIP = 4;
39
4081 /**
41 * How hard the opposition is. It is a band of skills, not one number — a side
42 * still runs from fodder to uncomfortable at every setting, just a different
43 * stretch of the same 0..1 scale `makeBrain` turns into hands.
82 * Hand one world's bots the round they are playing. `main.ts` does this every
83 * round: the local plan the moment the round starts, and the planned one if a
84 * better one arrives while it is still that round.
85 *
86 * It is the world's and not this module's, unlike the policy above, because a
87 * plan is one match's round and a policy is the whole game's: a plan in a
88 * module would be one match's orders reaching the next match's bots, and the
89 * arena's duels and the trainer's rounds — worlds nobody plans for — would be
90 * playing whatever the last page happened to ask for.
4491 */
45 export type Difficulty = "easy" | "normal" | "hard";
46 export const DIFFICULTIES: readonly Difficulty[] = ["easy", "normal", "hard"];
47 export const DEFAULT_DIFFICULTY: Difficulty = "normal";
48 const DIFFICULTY_LABELS: Record<Difficulty, string> = { easy: "Easy", normal: "Normal", hard: "Hard" };
49 export const difficultyLabel = (difficulty: Difficulty): string => DIFFICULTY_LABELS[difficulty];
50
51 const BANDS: Record<Difficulty, { lo: number; hi: number }> = {
52 easy: { lo: 0, hi: 0.35 },
53 normal: { lo: 0.25, hi: 1 },
54 hard: { lo: 0.55, hi: 1 },
55 };
56
57 /** A bot's place in its band, from its id — the same spread `brainOf` always used. */
58 function skillFor(id: number, difficulty: Difficulty): number {
59 const { lo, hi } = BANDS[difficulty];
60 const frac = ((id * 37) % 100) / 100;
61 return clamp(lo + frac * (hi - lo), 0, 1);
92 export function usePlan(world: World, plan: RoundPlan | null): void {
93 world.plan = plan;
6294 }
6395
64 /** The band itself, for anything — a test, a hint on the page — that needs its edges. */
65 export const skillBand = (difficulty: Difficulty): { lo: number; hi: number } => BANDS[difficulty];
96 /** The round this world's bots are playing, or null when nobody planned one. */
97 export const roundPlan = (world: World): RoundPlan | null => world.plan;
98
99 /** The policy decides at 16 Hz and holds it, exactly as it was trained. */
100 const SKIP = 4;
66101
67102 export type BotBrain = {
68103 skill: number; // 0 = fodder, 1 = uncomfortable
69104 reaction: number; // seconds between seeing and shooting
70105 aimSpeed: number; // degrees a second
71106 aimError: number; // degrees of standing error
107 grip: number; // 0..1, the share of the spray pattern these hands pull back
108 gripRate: number; // how fast that pull catches up, e-foldings a second
109 /** Where the pull is holding the view right now: [yaw, pitch] degrees. */
110 pull: [number, number];
72111 goal: Vec3 | null;
73112 path: Vec3[];
74113 step: number;
@@ -80,13 +119,14 @@ export type BotBrain = {
80119 strafe: number;
81120 strafeUntil: number;
82121 suspect: Vec3 | null;
83 /** The world clock the last time this bot listened: what came after it is news. */
84 heardAt: number;
85122 plannedFor: string;
86 site: Site; // where the side is going: the round's plan, or what was called
87 slot: number; // this bot's place in its side's list, counted when the round starts
88 holding: boolean; // told to stop and defend where it stands rather than push
89 ordered: boolean; // the site came from a person on the radio, not from the roll
123 /** This bot's share of the round: a site, a role, a beat to wait, a spread. */
124 job: Job;
125 /** The plan the job was cut from, and the round it was cut for. */
126 jobPlan: RoundPlan | null;
127 jobRound: number;
128 /** The clock the job's wait is up at. Set while the round is still frozen. */
129 pushAt: number;
90130 reset: boolean;
91131 random: () => number;
92132 // What the policy needs to run, one set per bot so nothing is shared.
@@ -101,6 +141,15 @@ export type BotBrain = {
101141 jitterUntil: number;
102142 /** False runs the pre-policy fight: the arena's opponent and its baseline. */
103143 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;
104153 };
105154
106155 export function makeBrain(skill: number, seed: number, useLearned = true): BotBrain {
@@ -109,13 +158,20 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
109158 reaction: 0.5 - skill * 0.33,
110159 aimSpeed: 180 + skill * 620,
111160 aimError: 4.5 - skill * 3.6,
161 grip: grip(skill),
162 gripRate: gripRate(skill),
163 pull: [0, 0],
112164 goal: null, path: [], step: 0, repathAt: 0,
113165 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
114 strafe: 0, strafeUntil: 0, suspect: null, heardAt: 0, plannedFor: "",
115 site: "A", slot: 0, holding: false, ordered: false, reset: true,
166 strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "",
167 job: DEFAULT_JOB, jobPlan: null, jobRound: -1, pushAt: 0,
168 reset: true,
116169 random: rng(seed),
117170 obs: new Float32Array(OBS_SIZE),
118 activations: newActivations(PACKED.shape),
171 // Sized from the shape the game asks for rather than from a policy: a
172 // brain is made before any weights have arrived, and the two agree because
173 // `parsePacked` refuses a document of any other shape.
174 activations: newActivations(SHAPE),
119175 actions: new Int32Array(HEADS.length),
120176 memory: { sinceSeen: 9 },
121177 intent: { ...STILL },
@@ -124,12 +180,95 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
124180 jitterApplied: 0,
125181 jitterUntil: 0,
126182 useLearned,
183 cover: null,
184 coverStep: 0,
185 coverFor: -1,
186 coverLook: null,
127187 };
128188 }
129189
190 // --- blinded --------------------------------------------------------------
191
192 /**
193 * How badly this bot is flashed, 0 to 1: the seconds of blindness it has left
194 * over the ceiling a flash can hand out. A bot does not see a white screen —
195 * there is no screen — so what a flash costs it is written here, in the same
196 * skill layer its reaction and its shaky hands already live in.
197 *
198 * Not flashed, this is exactly 0 and every number below is multiplied by
199 * exactly 1, which is why an unflashed bot plays the same game to the bit and
200 * the training arena's digest does not move for any of this.
201 */
202 export const blindness = (bot: Player): number => clamp(bot.blind / FLASH.flash.maxBlind, 0, 1);
203
204 /**
205 * What a flash leaves a bot with. Fully blind it turns at a fifth of its usual
206 * speed, its hands wander seven times as far off centre, it takes four times as
207 * long to decide to shoot at all, it holds none of its spray pattern, and it
208 * only notices an enemy inside a twelfth of its usual sight — which is roughly
209 * what a person flashed in the face does, and is the whole of the disadvantage.
210 * Its policy is untouched: `OBS_SIZE`, the heads and the shipped weights know
211 * nothing about this.
212 */
213 export function flashed(
214 bot: Player,
215 brain: BotBrain,
216 ): { aimSpeed: number; aimError: number; reaction: number; sight: number; grip: number } {
217 const b = blindness(bot);
218 return {
219 aimSpeed: brain.aimSpeed * (1 - 0.8 * b),
220 aimError: brain.aimError * (1 + 6 * b),
221 reaction: brain.reaction * (1 + 3 * b),
222 sight: SIGHT * (1 - 0.92 * b),
223 grip: brain.grip * (1 - b),
224 };
225 }
226
227 /**
228 * One tick of the bot riding its own recoil: the pull eases towards what would
229 * cancel the shot it is about to take, and the *change* in it is what moves the
230 * view — exactly the way the wobble below is applied, and for the same reason.
231 * The view carries the pull, nothing else does, so the bullets that leave the
232 * barrel are the ones a person pulling down would have sent.
233 *
234 * `bot.shotIndex` is where the next round lands in the pattern, and the world
235 * puts it back to zero a third of a second after the trigger comes up — so a
236 * bot that stops spraying eases back up to where it was aiming, with the whole
237 * of the pull given back. Nothing here is a net offset that outlives a burst.
238 *
239 * The policy sees the pulled view, because the arena it learned in pulls the
240 * same way (`src/rl/arena.ts`) and its own grip is one of the 33 numbers it is
241 * handed. A policy told nothing about its hands would aim for a recoil nobody
242 * was about to make.
243 */
244 export function ride(bot: Player, brain: BotBrain, grip: number, dt: number): void {
245 const want = sprayPull(bot.weapon, bot.shotIndex, grip);
246 const next = ease(brain.pull, want, brain.gripRate, dt);
247 bot.yaw += next[0] - brain.pull[0];
248 bot.pitch = clamp(bot.pitch + next[1] - brain.pull[1], -89, 89);
249 brain.pull = next;
250 }
251
252 /** How far a bot notices an enemy at all, in units, with its eyes working. */
253 const SIGHT = 4000;
254
255 /**
256 * A bot rich enough that a save costs it nothing buys anyway, which is what a
257 * person does with a round's worth of money already in the bank.
258 */
259 const FORCE = 5000;
260
130261 /** What a bot spends its money on, roughly in the order a person would. */
131262 export function botBuy(world: World, bot: Player): void {
132263 const brain = brainOf(world, bot);
264 const job = jobOf(world, bot, brain);
265 // The economy half of a plan: a round the side is saving is a round nothing
266 // leaves the bank, so the next one is a full buy for five rather than a rifle
267 // for two. Armour is still worth it to whoever can spare the change.
268 if (job.save && bot.money < FORCE) {
269 if (bot.armor < 100 && bot.money >= 2000) buy(world, bot, "armor");
270 return;
271 }
133272 const rich = bot.money >= 4000;
134273 const wants: WeaponId[] = [];
135274 if (rich && brain.random() < 0.15 && bot.money >= WEAPONS.awp.price + 1000) wants.push("awp");
@@ -141,243 +280,168 @@ export function botBuy(world: World, bot: Player): void {
141280 if (bot.team === "ct" && !bot.defuseKit && bot.money >= 400 && brain.random() < 0.6) buy(world, bot, "kit");
142281 }
143282
283 // --- what is left to shoot with -------------------------------------------
284
285 /**
286 * Is this weapon out of bullets — none in the magazine and none behind it?
287 * A weapon with no magazine at all is the knife, and a knife is never dry:
288 * that is what makes it the last answer below and why a bot always has one.
289 */
290 export function dry(bot: Player, id: WeaponId): boolean {
291 const weapon = WEAPONS[id];
292 if (weapon === undefined) return true;
293 if (weapon.magazine === 0) return weapon.kind !== "knife";
294 const ammo = bot.ammo[id];
295 return ammo === undefined || (ammo.mag <= 0 && ammo.reserve <= 0);
296 }
297
298 /**
299 * What this bot should have in its hands instead of what it is holding, or
300 * null to keep holding it. A gun it can still fire it keeps; an empty one it
301 * puts away for the rifle, then the pistol, then the knife.
302 *
303 * A bot cannot buy mid-round and there is nothing on the floor to pick up, so
304 * an empty gun stays empty until the round ends. Before this it kept pulling
305 * the trigger on it: `shoot` refuses with no round in the magazine and `reload`
306 * refuses with nothing to reload from, so the bot stood in the open holding
307 * fire at an enemy it could see and never hurt it — which is what a weird spot
308 * where two bots will not shoot each other looks like from the inside.
309 */
310 export function nextWeapon(bot: Player): WeaponId | null {
311 if (!dry(bot, bot.weapon)) return null;
312 for (const id of [bot.primary, bot.secondary, "knife" as WeaponId]) {
313 if (id !== null && id !== bot.weapon && !dry(bot, id)) return id;
314 }
315 return null;
316 }
317
318 /**
319 * How close a knife has to be, centre to centre, to land: `world.ts` traces a
320 * knife 64 units from the eye, and the body it is swung at is a hull's radius
321 * wide, so the two overlap from here in.
322 */
323 const KNIFE_RANGE = 64 + CS_HULL.radius;
324
144325 function brainOf(world: World, bot: Player): BotBrain {
145326 if (bot.brain === undefined) {
146 bot.brain = makeBrain(skillFor(bot.id, world.difficulty), 0x1234 + bot.id * 2654435761);
327 const skill = clamp(0.25 + ((bot.id * 37) % 100) / 130, 0, 1);
328 bot.brain = makeBrain(skill, 0x1234 + bot.id * 2654435761);
147329 }
148330 return bot.brain;
149331 }
150332
151333 /**
152 * Re-skill a brain for a new difficulty in place: the hands change, but the
153 * path, the plan and the memory it already built up this match do not. Called
154 * once a round, from `startRound`, so a change queued mid-fight cannot hand a
155 * bot new hands between one shot and the next.
334 * This bot's job, cut from the round's plan the first time anyone asks for it
335 * after that plan arrived. A job belongs to a team and not to a bot — an entry
336 * only means anything if somebody else is the support — so asking for one bot's
337 * re-tasks its whole side at once, in one `assign`, and the other four find
338 * theirs already waiting.
339 *
340 * With no plan handed in this is whatever `botThink` put there at the start of
341 * the round, which is the coin flip these bots have always made. Nothing about
342 * the arena or the trainer changes until somebody calls `usePlan`.
156343 */
157 export function restyleBrain(brain: BotBrain, id: number, difficulty: Difficulty): void {
158 const skill = skillFor(id, difficulty);
159 brain.skill = skill;
160 brain.reaction = 0.5 - skill * 0.33;
161 brain.aimSpeed = 180 + skill * 620;
162 brain.aimError = 4.5 - skill * 3.6;
163 }
164
165 /** This bot's place in its side's list, so the plan can hand out two sites. */
166 function slotOf(world: World, bot: Player): number {
167 let slot = 0;
168 for (const p of world.players) if (p.team === bot.team && p.id < bot.id) slot += 1;
169 return slot;
170 }
171
172 /**
173 * Read this side's orders onto the brain: where to be, and whether to push
174 * onto it. `plan.ts` answers with the round's roll unless somebody on this
175 * side has called something else — which is all this file knows about it. A
176 * site that changed under a bot already walking is a new objective, and
177 * `repath` notices that on the next tick by itself.
178 */
179 function readOrders(world: World, bot: Player, brain: BotBrain): void {
180 const orders = ordersFor(world.plan, world.radio, bot.team, brain.slot, world.time);
181 brain.site = orders.site;
182 brain.holding = orders.hold;
183 brain.ordered = orders.called;
184 }
185
186 /** What is left of the walk this bot has already planned, in map units. */
187 function pathLeft(brain: BotBrain, from: Vec3): number {
188 let total = 0;
189 let prev = from;
190 for (let i = brain.step; i < brain.path.length; i++) {
191 const at = brain.path[i] as Vec3;
192 total += dist2d(prev, at);
193 prev = at;
194 }
195 return total;
196 }
197
198 /** A crow flight underestimates a walk around a wall by about this much. */
199 const DETOUR = 1.3;
200
201 /**
202 * How far this bot still has to walk to where the round wants it, in seconds
203 * of clock: the longer of the crow flight around a wall and what is left of
204 * the path it has already planned.
205 */
206 function walkLeft(world: World, bot: Player, brain: BotBrain): number {
207 const crow = dist2d(bot.pos, world.map.sites[brain.site]) * DETOUR;
208 return Math.max(crow, pathLeft(brain, bot.pos));
209 }
210
211 /**
212 * Has a person told this bot to stand still? "Hold" is the one call the clock
213 * does not overrule: a teammate that walked off mid-sentence because the timer
214 * said so is a teammate you cannot use. The other two calls need no exception
215 * — a bot under time pressure walks to `brain.site`, and a call is what
216 * `brain.site` says — so somebody who calls A at 0:20 gets the push, at A.
217 */
218 const heldInPlace = (brain: BotBrain): boolean => brain.holding;
219
220 /**
221 * Time pressure on the terrorists. What is left of the round is no more than
222 * the walk to the site, the plant, and `PUSH_WINDOW` seconds of fighting — so
223 * a duel on the way is a round lost on the timer. The whole side reads it, not
224 * just the carrier: an escort still trading angles in mid is the carrier
225 * walking in alone. A bot that reads true keeps its gun — the policy still
226 * points it and still fires it — but its feet stop negotiating and take it to
227 * the site.
228 */
229 function pushing(world: World, bot: Player, brain: BotBrain): boolean {
230 if (bot.team !== "t" || world.match.phase !== "live" || heldInPlace(brain)) return false;
231 if (world.bomb.state === "planted") return false;
232 return mustPush(world.match.timer, walkLeft(world, bot, brain), world.match.rules.plantTime);
233 }
234
235 /**
236 * Time pressure on the counter-terrorists, which is the mirror of it: with the
237 * clock this low and no bomb on the floor, the terrorists are not coming to
238 * the corner this bot is sitting in — they are somewhere else, running it out.
239 * So it stops holding and goes to look, and `chooseGoal` says where.
240 */
241 function hunting(world: World, bot: Player, brain: BotBrain): boolean {
242 if (bot.team !== "ct" || world.match.phase !== "live") return false;
243 // Both calls outrank this one, and for the same reason: a hunt is a guess
244 // about which room the terrorists are in, and a person who has just said
245 // "go A" or "hold" has made that guess already, out loud.
246 if (heldInPlace(brain) || brain.ordered) return false;
247 if (world.bomb.state === "planted") return false;
248 return mustHunt(world.match.timer);
249 }
250
251 /**
252 * How wide a hunt fans out around the terrorists' own half. Enough that five
253 * counter-terrorists sweep it rather than queue down one corridor into the
254 * same angle, and not so much that they stop covering each other.
255 */
256 const HUNT_SPREAD = 700;
257
258 /** A death this far from a site centre is a fight over that site. */
259 const CONTACT_RANGE = 1600;
260
261 /** Which site a fight at `pos` is a fight over, if it is near enough to be one. */
262 function contactSite(world: World, pos: Vec3): Site | null {
263 const a = dist2d(pos, world.map.sites.A);
264 const b = dist2d(pos, world.map.sites.B);
265 if (Math.min(a, b) > CONTACT_RANGE) return null;
266 return a <= b ? "A" : "B";
267 }
268
269 /**
270 * Where the round is being fought, as far as this side can tell: the site the
271 * last of them to die fell at. It is only ever this side's own information —
272 * who is dead and where they were, which is the kill feed and the radar — and
273 * it is the answer to a side that commits to one site together. The first CT
274 * down at A is what turns the B players around; without it, five together
275 * beats three and two nearly every round.
276 */
277 function rotateTo(world: World, bot: Player): Site | null {
278 let site: Site | null = null;
279 let freshest = -Infinity;
280 for (const entry of world.feed) {
281 if (entry.killerTeam === bot.team || entry.at < freshest) continue;
282 for (const p of world.players) {
283 if (p.id !== entry.victimId || p.team !== bot.team) continue;
284 const where = contactSite(world, p.pos);
285 if (where === null) continue;
286 freshest = entry.at;
287 site = where;
288 }
289 }
290 return site;
291 }
292
293 /**
294 * `goal`, or a dropped primary if this bot has an empty primary slot and one
295 * is close enough to be worth the walk. `worthTheDetour` (plan.ts) is the
296 * whole arithmetic; this only finds the candidates and picks the cheapest.
297 * The empty floor — every tick of the training arena — costs one comparison.
298 */
299 export function gunGoal(world: World, bot: Player, goal: Vec3): Vec3 {
300 if (world.pickups.length === 0 || bot.primary !== null) return goal;
301 const toGoal = dist2d(bot.pos, goal);
302 let best: Vec3 | null = null;
303 let bestDetour = Infinity;
304 for (const pickup of world.pickups) {
305 if (pickupSlot(pickup.weapon) !== "primary") continue;
306 const toGun = dist2d(bot.pos, pickup.pos);
307 const gunToGoal = dist2d(pickup.pos, goal);
308 if (!worthTheDetour(toGun, gunToGoal, toGoal)) continue;
309 const detour = toGun + gunToGoal - toGoal;
310 if (detour < bestDetour) {
311 bestDetour = detour;
312 best = pickup.pos;
313 }
314 }
315 return best ?? goal;
316 }
317
318 /**
319 * How many dropped primaries are on the floor right now — the whole gun
320 * signature `repath` needs in its objective string: a rifle appearing or
321 * getting taken is a floor that changed, whatever else this bot is doing.
322 */
323 function primaryPickupCount(world: World): number {
324 let count = 0;
325 for (const pickup of world.pickups) if (pickupSlot(pickup.weapon) === "primary") count += 1;
326 return count;
344 function jobOf(world: World, bot: Player, brain: BotBrain): Job {
345 const plan = world.plan;
346 if (plan === null) return brain.job;
347 // The plan itself is the stamp: a new one is a new object, and a bot holding
348 // a job cut from another one is a bot with yesterday's orders.
349 if (brain.jobPlan === plan && brain.jobRound === world.match.round) return brain.job;
350 const mates = world.players.filter((p) => p.team === bot.team);
351 const brains = mates.map((mate) => brainOf(world, mate));
352 const jobs = assign(plan[bot.team], bot.team, mates.map((mate, at) => ({
353 id: mate.id,
354 skill: brains[at]?.skill ?? 0,
355 })));
356 brains.forEach((mate, at) => {
357 mate.job = jobs[at] ?? DEFAULT_JOB;
358 mate.jobPlan = plan;
359 mate.jobRound = world.match.round;
360 // The wait runs from the moment the round goes live, and the freeze time is
361 // still running: `botThink` pushes this forward on every frozen tick.
362 mate.pushAt = world.time + mate.job.delay;
363 // A goal cut for the site this bot is no longer going to is not a goal.
364 mate.plannedFor = "";
365 });
366 return brain.job;
327367 }
328368
329369 /** Where this bot is trying to be, given what the round is doing. */
330370 function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
331371 const bomb = world.bomb;
332 // Told to hold: this is where the round is being fought from now on. The
333 // goal is a walkable spot a stride away rather than the bot's own feet —
334 // a bot with no path stands still wherever it happened to be, and standing
335 // still in the open is how a hold gets a side killed. Once the bomb is on
336 // the floor or in the ground it outranks anything anyone said.
337 if (brain.holding && bomb.state === "carried") return nearby(world, brain, bot.pos, 250);
372 const level = world.level;
373 const job = brain.job;
374 const site = level.sites[job.site];
338375 if (bot.team === "t") {
339376 if (bomb.state === "planted") {
340377 // Hold near the bomb rather than walk onto it.
341 return nearby(world, brain, bomb.pos, 300);
378 return nearby(level, brain, bomb.pos, 300);
342379 }
343 // The bomb carrier does not stop for a gun: walking in alone with a rifle
344 // costs the round more than walking in with a pistol on time.
345 if (bot.hasBomb) return world.map.sites[brain.site];
380 if (bot.hasBomb) return site;
346381 if (bomb.state === "dropped") return bomb.pos;
347 return gunGoal(world, bot, nearby(world, brain, world.map.sites[brain.site], 200));
382 // An entry walks onto the site; a lurker is happy five hundred units off
383 // it, watching the way somebody would come. That is what a role is worth.
384 return nearby(level, brain, site, job.spread * 0.8);
348385 }
349386 if (bomb.state === "planted") return bomb.pos;
350 // The contact rotation is this side reading the round for itself — and it
351 // gives way to a person who has just said where to be. Somebody dying at B
352 // is why you called A; it is not an argument against the call.
353 const contact = brain.ordered ? null : rotateTo(world, bot);
354 // Nothing has happened and the clock is nearly out: whatever the terrorists
355 // are doing they are not doing it here, or this bot would have seen them by
356 // now. So it walks at the half of the map they came from and takes what it
357 // meets on the way. A death somewhere is better information than a guess,
358 // so a contact still wins.
359 if (contact === null && hunting(world, bot, brain)) {
360 return gunGoal(world, bot, nearby(world, brain, world.map.muster.t, HUNT_SPREAD));
361 }
362 return gunGoal(world, bot, nearby(world, brain, world.map.sites[contact ?? brain.site], 250));
387 return nearby(level, brain, site, job.spread);
363388 }
364389
365 const nearby = (world: World, brain: BotBrain, at: Vec3, spread: number): Vec3 => {
390 const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 => {
366391 for (let tries = 0; tries < 8; tries++) {
367392 const x = at[0] + (brain.random() * 2 - 1) * spread;
368393 const y = at[1] + (brain.random() * 2 - 1) * spread;
369 if (walkable(world.map, colOf(world.map, x), rowOf(world.map, y))) return [x, y, at[2]];
394 if (walkable(level, colOf(level, x), rowOf(level, y))) return [x, y, at[2]];
370395 }
371396 return at;
372397 };
373398
399 /** How many cells out a goal nothing walks to may be nudged, and how many tries. */
400 const NUDGE_RINGS = 3;
401 const NUDGE_TRIES = 8;
402
403 /**
404 * A path to `goal`, or to the nearest cell something can walk to.
405 *
406 * A* aims at a cell, and not every point a round hands a bot is one: a bomb
407 * dropped on top of a jumpable crate stands on a cell the search calls solid,
408 * and so does a body that fell into a corner of a map carved from a seed. The
409 * search then comes back with nothing, and a bot with no path stands exactly
410 * where it is until it asks again three seconds later — the standing about
411 * that looks, from the other end of a corridor, like a bot that has forgotten
412 * what it was doing. So a goal nothing walks to is nudged outwards in rings
413 * until a cell answers, at most `NUDGE_TRIES` searches deep, and only a goal
414 * walled off entirely leaves the bot without a plan.
415 */
416 export function pathTo(level: Level, from: Vec3, goal: Vec3): Vec3[] {
417 const direct = findPath(level, from, goal);
418 if (direct.length > 0) return direct;
419 const col = colOf(level, goal[0]);
420 const row = rowOf(level, goal[1]);
421 let tries = 0;
422 for (let ring = 1; ring <= NUDGE_RINGS; ring++) {
423 for (let dr = -ring; dr <= ring; dr++)
424 for (let dc = -ring; dc <= ring; dc++) {
425 if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring) continue;
426 if (!walkable(level, col + dc, row + dr)) continue;
427 if (tries++ >= NUDGE_TRIES) return [];
428 const path = findPath(level, from, worldOf(level, col + dc, row + dr, goal[2]));
429 if (path.length > 0) return path;
430 }
431 }
432 return [];
433 }
434
374435 function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
375436 let best: Player | null = null;
376437 let bestScore = Infinity;
438 // Flashed, a bot picks its fights out of a much smaller room: this is the
439 // "choice" half of what a flash costs it, and the aim below is the other.
440 const sight = flashed(bot, brain).sight;
377441 for (const other of world.players) {
378442 if (!other.alive || other.team === bot.team) continue;
379443 const d = dist(bot.pos, other.pos);
380 if (d > 4000) continue;
444 if (d > sight) continue;
381445 if (!canSee(world, bot, other)) continue;
382446 const aim = aimAt(bot, bodyCentre(other));
383447 const off = Math.abs(angleDelta(bot.yaw, aim.yaw));
@@ -388,60 +452,82 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
388452 return best;
389453 }
390454
391 /** The player an effect's `by` names, if it names one. Ids are the slot they sit in. */
392 function makerOf(world: World, by: number | undefined): Maker {
393 if (by === undefined) return undefined;
394 const at = world.players[by];
395 return at !== undefined && at.id === by ? at : world.players.find((p) => p.id === by);
396 }
455 // --- smoke ----------------------------------------------------------------
397456
398457 /**
399 * Hearing. Every noise the world made since this bot last listened is looked
400 * at once — no list built and nothing allocated until something is actually
401 * heard — and the loudest of them, if it arrives above `AUDIBLE`, becomes the
402 * place the bot thinks someone is.
403 *
404 * It is the sounds themselves, not the players making them: a gunshot through
405 * two walls is quieter than one down the corridor, a sprint carries and a walk
406 * makes no sound at all, and where the bot ends up looking is off by more the
407 * further away it was. `hearing.ts` owns all of that arithmetic.
408 *
409 * The bomb's own beep is skipped on purpose. It repeats every half-second from
410 * a place both sides already know, so acting on it would drown out every
411 * footstep near a planted site — what a CT should do about it is #46's retake.
458 * How far outside a cloud a bot aims to walk, in units. A hull's width, so it
459 * skirts the fringe of one rather than clipping through it — and so that a
460 * diversion is a visible step around something and not a shuffle along its
461 * edge.
412462 */
413 function listen(world: World, bot: Player, brain: BotBrain): void {
414 const since = brain.heardAt;
415 brain.heardAt = world.time;
416 let loudest = 0;
417 let from: Vec3 | null = null;
418 // Backwards, and out at the first stale one: `world.effects` is only ever
419 // appended to and only ever filtered, so what is new is the tail of it. The
420 // list is two seconds deep and a firefight fills it with tracers, blood and
421 // impacts; what this bot has not heard yet is the handful on the end.
422 for (let i = world.effects.length - 1; i >= 0; i--) {
423 const effect = world.effects[i];
424 if (effect === undefined || effect.at < since) break;
425 let name: HeardName;
426 let pos: Vec3;
427 let by: number | undefined;
428 if (effect.kind === "shot") {
429 name = effect.weapon;
430 pos = effect.pos;
431 by = effect.shooter;
432 } else if (effect.kind === "sound" && effect.name !== "beep") {
433 name = effect.name;
434 pos = effect.pos;
435 by = effect.by;
436 } else continue;
437 if (!fromOther(bot, makerOf(world, by))) continue;
438 const heard = loudness(bot.pos, pos, name, world.map);
439 if (heard > loudest) {
440 loudest = heard;
441 from = pos;
463 const SMOKE_CLEARANCE = CS_HULL.radius * 2;
464
465 /**
466 * Where this bot should walk instead of straight at `step`, given the clouds
467 * standing in the round.
468 *
469 * A cloud is not a wall — a body walks through one exactly as it walks through
470 * air, and it should, because walking through smoke is half of what smoke is
471 * for — but it *is* a wall to this bot's eyes: `canSee` refuses a line through
472 * one, so a bot that strolls into a cloud arrives blind at whatever is waiting
473 * on the other side and has given away the fight it was walking to. So the
474 * scripted layer treats it as something to go around, and the policy is told
475 * nothing: the shipped weights, `OBS_SIZE` and every head are untouched by all
476 * of this.
477 *
478 * Two cases and no third. Standing in one, the shortest way out is straight
479 * away from the middle, and that is where the bot walks. Heading into one, the
480 * step is pushed sideways to whichever edge is nearer — the tangent, plus the
481 * clearance — so the path bends around the cloud and rejoins itself. A cloud
482 * behind the bot, beside the walk, or past the end of this step is left alone,
483 * which is every cloud on most ticks; a round nobody smoked never reaches here
484 * at all, because the caller checks the list is empty first.
485 *
486 * The step comes back unchanged when there is nothing to do, so the common case
487 * allocates nothing.
488 */
489 export function skirt(world: World, bot: Player, step: Vec3): Vec3 {
490 for (const cloud of world.clouds) {
491 const radius = cloudRadius(cloud, world.time);
492 if (radius <= 0) continue;
493 const keep = radius + SMOKE_CLEARANCE;
494 const cx = cloud.pos[0] - bot.pos[0];
495 const cy = cloud.pos[1] - bot.pos[1];
496 const here = Math.hypot(cx, cy);
497 if (here < keep) {
498 // Inside it, or in its fringe: out, by the shortest line there is. Dead
499 // in the middle there is no such line, so the bot keeps walking rather
500 // than picking a direction out of nothing.
501 if (here < 1e-6) continue;
502 return [bot.pos[0] - (cx / here) * keep, bot.pos[1] - (cy / here) * keep, step[2]];
442503 }
504 const dx = step[0] - bot.pos[0];
505 const dy = step[1] - bot.pos[1];
506 const len = Math.hypot(dx, dy);
507 if (len < 1e-6) continue;
508 const ux = dx / len, uy = dy / len;
509 // How far along the walk the cloud sits, and how far off to one side.
510 const along = cx * ux + cy * uy;
511 if (along <= 0 || along > len + keep) continue;
512 const off = cx * -uy + cy * ux;
513 if (Math.abs(off) >= keep) continue;
514 // Around the near edge: the perpendicular, away from the side the cloud is
515 // on, by just enough to put the walk on the tangent.
516 const side = off >= 0 ? -1 : 1;
517 const push = (keep - Math.abs(off)) * side;
518 return [step[0] + -uy * push, step[1] + ux * push, step[2]];
519 }
520 return step;
521 }
522
523 /** An enemy shooting nearby is a direction to look, even through a wall. */
524 function listen(world: World, bot: Player, brain: BotBrain): void {
525 for (const other of world.players) {
526 if (!other.alive || other.team === bot.team) continue;
527 if (world.time - other.lastShotAt > 0.25) continue;
528 if (dist(bot.pos, other.pos) > 2200) continue;
529 brain.suspect = [other.pos[0], other.pos[1], other.pos[2] + 48];
443530 }
444 if (from !== null) brain.suspect = reported(bot.pos, from, world.map, brain.random);
445531 }
446532
447533 /**
@@ -454,12 +540,13 @@ function listen(world: World, bot: Player, brain: BotBrain): void {
454540 export function scriptedFight(
455541 world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number,
456542 ): Input {
543 const hands = flashed(bot, brain);
457544 const spraying = bot.shotIndex > 0 && world.time - bot.lastShotAt < 0.3;
458 const wobble = brain.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
545 const wobble = hands.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
459546 const aim = aimAt(bot, bodyCentre(target));
460 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, brain.aimSpeed, dt);
461 const ready = world.time - brain.seenAt > brain.reaction;
462 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + brain.aimError;
547 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, hands.aimSpeed, dt);
548 const ready = world.time - brain.seenAt > hands.reaction;
549 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + hands.aimError;
463550 const ammo = bot.ammo[bot.weapon];
464551 if (ammo !== undefined && ammo.mag === 0) input.reload = true;
465552 if (ready && onTarget && world.time > brain.restUntil) {
@@ -483,6 +570,24 @@ export function scriptedFight(
483570 return input;
484571 }
485572
573 /**
574 * A bot with nothing left to fire, which on this map means a bot down to its
575 * knife. The policy is not asked: it learned a rifle duel and is content to
576 * hold a distance a knife cannot cross, and the two things a knife needs — be
577 * pointed at them, be next to them — are not a reflex worth a network. So the
578 * bot turns onto the enemy with its own hands, at its own skill's turn rate,
579 * walks straight in and swings once the swing can land.
580 */
581 export function knifeRush(
582 bot: Player, brain: BotBrain, target: Player, input: Input, dt: number,
583 ): Input {
584 const hands = flashed(bot, brain);
585 turnTowards(bot, aimAt(bot, bodyCentre(target)), hands.aimSpeed, dt);
586 input.forward = 1;
587 input.fire = dist(bot.pos, target.pos) < KNIFE_RANGE;
588 return input;
589 }
590
486591 /**
487592 * Ask the policy what to do about `target`. It decides once every SKIP ticks
488593 * and the answer is held in between, which is how it was trained and what a
@@ -511,11 +616,12 @@ export function think(world: World, bot: Player, brain: BotBrain, target: Player
511616 * is a bot you can beat; the same policy with steady hands is not.
512617 */
513618 export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void {
619 const hands = flashed(bot, brain);
514620 if (world.time > brain.jitterUntil) {
515 brain.jitter = (brain.random() * 2 - 1) * brain.aimError;
621 brain.jitter = (brain.random() * 2 - 1) * hands.aimError;
516622 brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35;
517623 }
518 const limit = brain.aimSpeed;
624 const limit = hands.aimSpeed;
519625 bot.yaw += clamp(intent.yawRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied);
520626 bot.pitch = clamp(
521627 bot.pitch + clamp(intent.pitchRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied) * 0.4,
@@ -533,96 +639,57 @@ function turnTowards(bot: Player, target: { yaw: number; pitch: number }, speed:
533639 bot.pitch = clamp(bot.pitch + clamp(dp, -maxTurn, maxTurn), -89, 89);
534640 }
535641
536 /**
537 * Ask for a path when there is none, when it runs out, when it goes stale, or
538 * when the objective itself changed under the bot's feet. A* is only ever run
539 * here, which is a few times a round and never per tick.
540 */
541 function repath(world: World, bot: Player, brain: BotBrain): void {
542 // `ordered` is part of the objective and not just a tie-break inside
543 // `chooseGoal`: a counter-terrorist that has rotated off its own site is
544 // walking to a goal its site no longer names, so a call that happens to
545 // agree with the roll still has to be a new objective to turn it round.
546 // The hunt is part of it too: a counter-terrorist that has just run out of
547 // clock is walking to a goal its site does not name, and nothing else in
548 // this string would have changed to send it there. The gun count only
549 // matters to a bot with an empty primary slot — one that already has a
550 // rifle never diverts, so the floor changing under it is not its news.
551 const objective =
552 `${world.bomb.state}:${brain.site}${brain.holding ? ":hold" : ""}` +
553 `${brain.ordered ? ":called" : ""}${hunting(world, bot, brain) ? ":hunt" : ""}` +
554 `${bot.primary === null ? `:guns${primaryPickupCount(world)}` : ""}`;
555 const stale =
556 brain.path.length === 0 || brain.step >= brain.path.length ||
557 world.time > brain.repathAt || objective !== brain.plannedFor;
558 if (!stale) return;
559 brain.goal = chooseGoal(world, bot, brain);
560 brain.path = findPath(world.map, bot.pos, brain.goal);
561 brain.step = 0;
562 brain.plannedFor = objective;
563 brain.repathAt = world.time + 3 + brain.random() * 3;
564 }
565
566 /**
567 * Point the feet at the next waypoint and return how far off the view is from
568 * it, in degrees, or null when the path has run out. The movement is in the
569 * bot's own frame, so this works whether the view is following the path or
570 * looking down the barrel of something else.
571 */
572 function follow(bot: Player, brain: BotBrain, input: Input): number | null {
573 const next = brain.path[brain.step];
574 if (next === undefined) {
575 brain.goal = null;
576 return null;
577 }
578 if (dist2d(bot.pos, next) < 48) brain.step += 1;
579 const off = angleDelta(bot.yaw, aimAt(bot, [next[0], next[1], bot.pos[2] + 48]).yaw);
580 input.forward = Math.cos((off * Math.PI) / 180);
581 input.side = -Math.sin((off * Math.PI) / 180);
582 return off;
583 }
584
585642 /** One bot's decision for this tick, expressed as the same input a person gives. */
586643 export function botThink(world: World, bot: Player, dt: number): Input {
587644 const brain = brainOf(world, bot);
588645 const input: Input = { ...NO_INPUT };
589646 if (brain.reset) {
590647 brain.reset = false;
591 // The round's plan, not a coin of this bot's own: the side arrives together.
592 brain.slot = slotOf(world, bot);
593 readOrders(world, bot, brain);
648 // The job a bot has when no round was planned for it: its own coin flip for
649 // a site, no wait and the spread these bots have always used. A plan, if one
650 // arrived, replaces this on the next line and for the rest of the round.
651 brain.job = { ...DEFAULT_JOB, site: brain.random() < 0.5 ? "A" : "B" };
652 brain.jobPlan = null;
653 brain.pushAt = 0;
594654 brain.goal = null;
595655 brain.path = [];
596656 brain.targetId = null;
597657 brain.suspect = null;
598 brain.heardAt = world.time;
599658 brain.plannedFor = "";
600659 brain.memory.sinceSeen = 9;
601660 brain.hold = 0;
602661 brain.intent = { ...STILL };
662 // A fresh round hands the body a fresh view; the pull is not owed back.
663 brain.pull = [0, 0];
664 brain.cover = null;
665 brain.coverFor = -1;
666 brain.coverLook = null;
603667 input.switchTo = bot.primary ?? bot.secondary;
604668 }
669 const job = jobOf(world, bot, brain);
605670 if (world.match.phase === "freeze") {
606 // Nothing that happens behind the barrier is worth turning round for.
607 brain.heardAt = world.time;
671 // Nobody moves in the freeze time, so a job's wait cannot start running
672 // inside it: it is pushed along in front of the clock and starts the moment
673 // the round goes live, which is the beat an entry is ahead by.
674 brain.pushAt = world.time + job.delay;
608675 return input;
609676 }
610 // Orders can change under a bot mid-round — but only ever because somebody
611 // said something, so a world with a silent radio does not pay for the ones
612 // that do not: the round's roll is already on the brain from `reset`.
613 if (world.radio !== null) readOrders(world, bot, brain);
614677
678 // An empty gun is not a weapon: swap to whatever can still fire before
679 // deciding anything else, so the decision below is made about the gun the
680 // bot will actually be holding when the input is applied.
681 const swap = nextWeapon(bot);
682 if (swap !== null) input.switchTo = swap;
683
684 // The hands ride the pattern whatever the head is doing, so a bot that stops
685 // fighting — or stops firing — gives the pull back rather than walking off
686 // aiming at the floor. Not on the scripted brain: that one is the baseline
687 // the policy is measured against, and a baseline that moves measures nothing.
688 if (brain.useLearned) ride(bot, brain, flashed(bot, brain).grip, dt);
615689 listen(world, bot, brain);
616690 const target = pickTarget(world, bot, brain);
617691 if (target !== null) {
618692 if (brain.targetId !== target.id) {
619 // Contact, and only contact: a bot that had nobody in its sights now
620 // has somebody, and `pickTarget` has already run the trace that proves
621 // it can see them. Swapping from one enemy to another is the same fight
622 // and says nothing; so is the next tick of the same sighting, which is
623 // why a standoff down one corridor is one line and not ninety seconds
624 // of them.
625 if (brain.targetId === null) callout(world, bot, target.pos);
626693 brain.targetId = target.id;
627694 brain.seenAt = world.time;
628695 }
@@ -630,53 +697,137 @@ export function botThink(world: World, bot: Player, dt: number): Input {
630697 brain.targetId = null;
631698 }
632699
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
633707 // --- fighting, which is the policy's job
634708 if (target !== null) {
709 // The gun this input is about is the one the swap above is handing it, not
710 // the empty one still in its hands for the rest of this tick.
711 const holding = swap ?? bot.weapon;
712 if (holding === "knife") return withBomb(world, bot, knifeRush(bot, brain, target, input, dt));
635713 if (!brain.useLearned) return withBomb(world, bot, scriptedFight(world, bot, brain, target, input, dt));
636714 const intent = think(world, bot, brain, target, dt);
637715 applyIntent(world, bot, brain, intent, dt);
638 const ammo = bot.ammo[bot.weapon];
639 if (ammo !== undefined && ammo.mag === 0) input.reload = true;
716 const ammo = bot.ammo[holding];
717 // Reloading is worth asking for only when there is something to reload
718 // from; an empty reserve is what the swap just answered.
719 if (ammo !== undefined && ammo.mag === 0 && ammo.reserve > 0) input.reload = true;
640720 // Two things the policy does not get to decide. A bot that has only just
641721 // seen you does not shoot yet — that beat is what skill buys. And the
642722 // policy trained with a rifle, so it holds the trigger down; on a pistol
643723 // that is one shot and then nothing, so the trigger is released between
644724 // decisions and each decision is worth one pull.
645 const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1;
646 input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction;
647 if (pushing(world, bot, brain)) {
648 // Out of clock: the policy keeps the gun, the plan takes the feet. It
649 // runs to the site shooting at whatever it is looking at rather than
650 // trading ground for a duel it cannot afford to win slowly.
651 repath(world, bot, brain);
652 follow(bot, brain, input);
653 input.walk = false;
654 input.duck = false;
655 } else {
656 input.forward = intent.forward;
657 input.side = intent.side;
658 input.walk = intent.walk;
659 input.duck = intent.duck;
725 const held = WEAPONS[holding]?.automatic !== false || brain.hold === SKIP - 1;
726 input.fire = intent.fire && held && world.time - brain.seenAt > flashed(bot, brain).reaction;
727 input.forward = intent.forward;
728 input.side = intent.side;
729 input.walk = intent.walk;
730 input.duck = intent.duck;
731 return withBomb(world, bot, input);
732 }
733
734 // --- waiting. A support does not walk out with the entry; it gives it its
735 // beat and follows. The bomb never waits — a round where the bomb stands at
736 // spawn for two seconds is two seconds off the clock — and neither does a bot
737 // with something in front of it, because the fight above returned already.
738 if (world.time < brain.pushAt && !bot.hasBomb && world.bomb.state === "carried") {
739 if (brain.suspect !== null) {
740 const look = aimAt(bot, brain.suspect);
741 turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt);
660742 }
661743 return withBomb(world, bot, input);
662744 }
663745
664 // --- moving
665 repath(world, bot, brain);
666 const off = follow(bot, brain, input);
667 if (off !== null) {
668 const look = brain.suspect !== null ? aimAt(bot, brain.suspect) : { yaw: bot.yaw + off, pitch: 0 };
746 // --- moving. A path is asked for when there is none, when it runs out, when
747 // it goes stale, or when the objective itself changed under the bot's feet.
748 const objective = `${world.bomb.state}:${job.site}`;
749 const stale =
750 brain.path.length === 0 || brain.step >= brain.path.length ||
751 world.time > brain.repathAt || objective !== brain.plannedFor;
752 if (stale) {
753 brain.goal = chooseGoal(world, bot, brain);
754 brain.path = pathTo(world.level, bot.pos, brain.goal);
755 brain.step = 0;
756 brain.plannedFor = objective;
757 brain.repathAt = world.time + 3 + brain.random() * 3;
758 }
759 const next = brain.path[brain.step];
760 if (next !== undefined) {
761 if (dist([bot.pos[0], bot.pos[1], 0], [next[0], next[1], 0]) < 48) brain.step += 1;
762 // The path is A*'s and it knows nothing about smoke, which is right: the
763 // cells are still walkable and a cloud is gone in fifteen seconds. What
764 // bends is the *step*, here and only while there is a cloud to bend it.
765 const walk = world.clouds.length === 0 ? next : skirt(world, bot, next);
766 const aim = aimAt(bot, [walk[0], walk[1], bot.pos[2] + 48]);
767 const look = brain.suspect !== null ? aimAt(bot, brain.suspect) : { yaw: aim.yaw, pitch: 0 };
669768 turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt);
769 const off = angleDelta(bot.yaw, aim.yaw);
770 input.forward = Math.cos((off * Math.PI) / 180);
771 input.side = -Math.sin((off * Math.PI) / 180);
670772 input.walk = brain.suspect !== null && dist(bot.pos, brain.suspect) < 900;
773 } else {
774 brain.goal = null;
671775 }
672776 if (brain.suspect !== null && brain.random() < 0.004) brain.suspect = null;
673777 return withBomb(world, bot, input);
674778 }
675779
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
676827 /** Planting and defusing: press use when standing where it matters. */
677828 function withBomb(world: World, bot: Player, input: Input): Input {
678829 const bomb = world.bomb;
679 if (bot.team === "t" && bot.hasBomb && siteAt(world.map, bot.pos) !== null && world.match.phase === "live") {
830 if (bot.team === "t" && bot.hasBomb && siteAt(world.level, bot.pos) !== null && world.match.phase === "live") {
680831 input.use = true;
681832 input.forward = 0;
682833 input.side = 0;
src/client/bundle.test.ts added +229
@@ -0,0 +1,229 @@
1 /**
2 * The committed bundle, booted. `main.test.ts` proves the shooter's *sources*
3 * run against the stub page in `stage.test-helper.ts`; this proves that
4 * `static/app.<hash>.js` does, which is the file a browser actually downloads. A
5 * bundling mistake — a mangled name the entry needed, a tree shake that took the
6 * boot line with it, a half-written file — fails here rather than in the preview.
7 *
8 * Nothing under `src/client/` is imported except the stub page, which ships in
9 * no bundle: the thing under test is the artifact, found on disk by name by
10 * `scripts/bundle.test-helper.ts` — the same finder `src/range/bundle.test.ts`
11 * uses. Nothing here rebuilds it; whether what is committed is what the sources
12 * produce now is `scripts/build.test.ts`'s rule, and a rebuild would make this
13 * test a test of the sources again.
14 *
15 * A minified file exposes nothing to poke, so every assertion below is about
16 * what it did to the stub page: which ids it asked for, what it drew, what it
17 * posted, what it wrote to the store, and the sentence it leaves in the menu on
18 * a browser with no WebGL2.
19 */
20
21 import { afterAll, expect, test } from "bun:test";
22 import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23 import { tmpdir } from "node:os";
24 import { join } from "node:path";
25 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
26 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
27
28 afterAll(discardBundleCopies);
29
30 /** Whether this process had a `document` before any of this ran. It does not. */
31 const HAD_DOCUMENT = "document" in globalThis;
32
33 /** Bun's own `fetch`, held onto so the last test can see it handed back. */
34 const REAL_FETCH = globalThis.fetch;
35
36 const FRAME = 1000 / 30;
37
38 /**
39 * How many frames the entry paints its atlas over: one layer each, and this
40 * game has nine. Written out rather than imported from `src/client/materials.ts`
41 * — nothing under `src/client/` but the stub page belongs in here, and a
42 * minified file has no constant to read anyway.
43 */
44 const LAYERS = 9;
45
46 /** Every id the shell renders that the entry has to find to run a match. */
47 const NEEDED = ["scene", "hud", "fps", "touch", "play", "name", "length", "side", "sens"];
48
49 type Booted = {
50 stage: FakeStage;
51 /** Every id the bundle asked the document for, in the order it asked. */
52 asked: string[];
53 undo: () => void;
54 };
55
56 /**
57 * Install a stub page, import the committed bundle into it — the entry boots
58 * itself on the way in, because the page says it is not still loading — and hand
59 * back the page, the ids it looked up, and the undo that puts the globals back.
60 * `loading: true` is the other page: one still being parsed, which the entry
61 * has to wait for.
62 */
63 async function booted(
64 options: { webgl?: boolean; coarse?: boolean; loading?: boolean } = {},
65 ): Promise<Booted> {
66 const stage = fakeStage(options);
67 if (options.loading === true) stage.document.readyState = "loading";
68 const asked: string[] = [];
69 const find = stage.document.getElementById;
70 stage.document.getElementById = (id) => {
71 asked.push(id);
72 return find(id);
73 };
74 const undo = stage.install();
75 try {
76 await importBundle("app");
77 } catch (error) {
78 undo();
79 throw error;
80 }
81 return { stage, asked, undo };
82 }
83
84 /** Every GL call the scene canvas was given, and the draws and uploads among them. */
85 const glCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.scene.gl?.calls ?? [];
86 const drawn = (stage: FakeStage): [string, ...unknown[]][] =>
87 glCalls(stage).filter((call) => call[0] === "drawElements");
88 const uploads = (stage: FakeStage): [string, ...unknown[]][] =>
89 glCalls(stage).filter((call) => call[0] === "texImage3D");
90
91 test("the committed bundle boots the page, and a click on play plays a match", async () => {
92 const { stage, asked, undo } = await booted({ coarse: true });
93 try {
94 // Importing it was enough: it found the page, read the store and asked for
95 // a frame, with the menu still up and nothing drawn.
96 expect(stage.frames.length).toBe(1);
97 expect(stage.by("menu").hidden).toBe(false);
98 expect(drawn(stage).length).toBe(0);
99 // Nothing is uploaded yet and Play is held: the minified file paints its
100 // atlas across the frames after boot, the same as the sources do.
101 expect(uploads(stage).length).toBe(0);
102 expect(stage.by("play").disabled).toBe(true);
103 // The touch buttons the shell renders were bound too, every one of them.
104 expect(stage.buttons.every((button) => button.listening.size > 0)).toBe(true);
105
106 // A layer a frame, and on the last of them one upload of nine 128² layers.
107 for (let layer = 0; layer < LAYERS; layer++) stage.step(FRAME);
108 expect(uploads(stage).length).toBe(1);
109 expect(uploads(stage)[0]?.slice(4, 7)).toEqual([128, 128, 9]);
110 expect(stage.by("play").disabled).toBe(false);
111 expect(stage.by("prep").hidden).toBe(true);
112 expect(drawn(stage).length).toBe(0);
113
114 stage.by("name").value = "Vesper";
115 stage.by("side").value = "t";
116 stage.by("length").value = "short";
117 stage.by("sens").value = "4";
118 stage.by("play").fire("click");
119
120 // The menu went, the pointer was asked for, the phone's pad came up — this
121 // page says its pointer is coarse — and the name went to `localStorage`.
122 expect(stage.by("menu").hidden).toBe(true);
123 expect(stage.scene.locks).toBe(1);
124 expect(stage.by("touch").hidden).toBe(false);
125 expect(stage.store.get("cs.name")).toBe("Vesper");
126
127 // One frame: the room and the players drawn through the canvas's own WebGL2
128 // context, and the HUD painted onto the recording `Paint` beside it.
129 stage.hold("Tab", true);
130 stage.step(FRAME);
131 expect(drawn(stage).length).toBeGreaterThan(1);
132 expect(stage.hud.paint.calls[0]?.[0]).toBe("clearRect");
133 expect([stage.hud.width, stage.hud.height]).toEqual([800, 450]);
134 expect(stage.by("fps").textContent).toContain("ms");
135 // Drawn text, so minification has nothing to hide behind — and the side and
136 // the name typed into the menu are what the scoreboard reads back.
137 expect(stage.hud.paint.texts).toContain("$800");
138 expect(stage.hud.paint.texts).toContain("T Vesper (you)");
139
140 // Every id the page carries for this game was looked up by name.
141 for (const id of NEEDED) expect(asked).toContain(id);
142
143 // The leaderboard was posted to the page's own `fetch`, which is this stub:
144 // no network was reached for and nothing was written anywhere.
145 expect(stage.posts.map((post) => post.url)).toEqual(["/api/match"]);
146 expect(JSON.parse(stage.posts[0]?.body ?? "{}").name).toBe("Vesper");
147 } finally {
148 undo();
149 }
150 });
151
152 test("a bundled page with no WebGL2 is a sentence in the menu, not an exception", async () => {
153 const { stage, undo } = await booted({ webgl: false });
154 try {
155 // The same sentence `main.test.ts` pins, out of the minified file.
156 expect(stage.by("menu").innerHTML).toContain("No WebGL2");
157 expect(stage.by("menu").hidden).toBe(false);
158 // Nothing was started: no frame was asked for and no listener attached.
159 expect(stage.frames.length).toBe(0);
160 expect(stage.window.listening.size).toBe(0);
161 expect(stage.document.listening.size).toBe(0);
162 expect(stage.posts.length).toBe(0);
163 } finally {
164 undo();
165 }
166 });
167
168 test("a page still being parsed is waited for, not booted on", async () => {
169 const { stage, undo } = await booted({ loading: true });
170 try {
171 // The bundle is in the page's head: the ids it wants are not there yet, so
172 // the entry asks to be told when they are and nothing runs until then.
173 expect(stage.frames.length).toBe(0);
174 expect(stage.document.listening.has("DOMContentLoaded")).toBe(true);
175 expect(stage.by("fps").textContent).toBe("");
176 stage.document.fire("DOMContentLoaded");
177 expect(stage.frames.length).toBe(1);
178 expect(stage.by("menu").hidden).toBe(false);
179 } finally {
180 undo();
181 }
182 });
183
184 test("a bundle that is missing or doubled is a sentence saying what to do about it", () => {
185 const dir = mkdtempSync(join(tmpdir(), "bundle-finder-"));
186 try {
187 expect(() => bundlePath("app", dir)).toThrow(/has no bundle.*run `bun run scripts\/build\.ts`/);
188 writeFileSync(join(dir, "app.0123456789.js"), "");
189 expect(bundlePath("app", dir)).toBe(join(dir, "app.0123456789.js"));
190 writeFileSync(join(dir, "app.abcdef0123.js"), "");
191 expect(() => bundlePath("app", dir)).toThrow(/has 2 bundles.*app\.0123456789\.js/);
192 } finally {
193 rmSync(dir, { recursive: true, force: true });
194 }
195 });
196
197 test("the shooter bundles the engine's policy network, because its bots run it", () => {
198 // The other end of `src/range/bundle.test.ts`'s claim, read as names the
199 // minifier cannot rename: the activation the forward pass runs through, and
200 // the `atob` the shipped weights are decoded with. `src/client/bots.ts`
201 // unpacks once and runs it on the frame a bot thinks.
202 const code = readFileSync(bundlePath("app"), "utf8");
203 for (const name of ["Math.tanh", "atob"]) expect(code).toContain(name);
204 // And `btoa` is not here, which is the tree shaking working in the other
205 // direction: quantising a policy is `pack`, and `pack` only ever runs in
206 // `scripts/train.ts`. A page that shipped the quantiser would be shipping a
207 // trainer it cannot use.
208 expect(code).not.toContain("btoa");
209 });
210
211 test("the shooter bundles the engine's mover, tuned by its own numbers", () => {
212 // The other end of `src/range/bundle.test.ts`'s second claim. The solver is
213 // the engine's since #123 and the numbers are still this game's: the fields
214 // are here because `src/client/movement.ts` writes them, and so is the jump
215 // velocity itself, which no engine module knows.
216 const code = readFileSync(bundlePath("app"), "utf8");
217 for (const field of ["airSpeedCap", "duckRate", "jumpVelocity", "stopSpeed"])
218 expect(code).toContain(field);
219 expect(code).toContain("301.993");
220 });
221
222 test("the stub page leaves nothing of itself behind", () => {
223 // Every boot above undid its install, so no other test inherits a document —
224 // nor a `fetch` that answers every URL with a leaderboard.
225 expect("document" in globalThis).toBe(HAD_DOCUMENT);
226 expect("window" in globalThis).toBe(false);
227 expect("localStorage" in globalThis).toBe(false);
228 expect(globalThis.fetch).toBe(REAL_FETCH);
229 });
src/client/buy.test.ts +224 −45
@@ -1,60 +1,239 @@
1 /**
2 * The buy panel's own tests, which are what `main.test.ts` used to assert
3 * through a whole booted frame: the shelf a side is shown, the markup it is
4 * drawn as — to the character, for a real world in its freeze time — what a
5 * price is for the body in front of you, which tiles a wallet and a round leave
6 * pressable, and what a press hands back.
7 *
8 * The page under it is the engine's `fakePage`: one element to draw into and a
9 * `createElement` that makes buttons, which is the whole of what this module
10 * borrows from a browser. Most of it needs no world at all — a `Market` is an
11 * object with a phase in it and a `Buyer` is the kit and the wallet, the way
12 * `economy.test.ts` writes them — and the exact-markup test uses a real
13 * `createWorld`, because a module that only works against its own fixtures has
14 * proved nothing.
15 */
16
117 import { expect, test } from "bun:test";
18 import { NUMBER_ROW } from "./binding.ts";
19 import { fakePage, type FakeElement } from "../engine/index.ts";
20 import { drawBuy, HINT, labelOf, offers, REBUY_LABEL, type Counter } from "./buy.ts";
21 import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts";
22 import { DE_FORGE } from "./map.ts";
223 import { SHORT } from "./match.ts";
3 import { ARMOR_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
4 import { buy, createWorld, NO_INPUT, update } from "./world.ts";
5 import { buyMenu } from "./buy.ts";
24 import { ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
25 import { createWorld } from "./world.ts";
626
7 const row = (world: ReturnType<typeof createWorld>, item: string) =>
8 buyMenu(world, world.me).rows.find((r) => r.item === item);
27 const freeze: Market = { match: { phase: "freeze" } };
28 const live: Market = { match: { phase: "live" } };
929
10 test("too poor for a rifle: poor, and the price still shows", () => {
11 const world = createWorld(SHORT, "me", "ct", 5, 1);
12 world.me.money = 100;
13 const m4 = row(world, "m4a4");
14 expect(m4?.state).toBe("poor");
15 expect(m4?.price).toBe(WEAPONS.m4a4.price);
30 const buyer = (over: Partial<Buyer> = {}): Buyer => ({
31 team: "ct", alive: true, money: 5000,
32 armor: 0, helmet: false, defuseKit: false,
33 weapon: "usp", primary: null, secondary: "usp",
34 ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") },
35 pocket: { he: 0, flash: 0, smoke: 0 },
36 ...over,
1637 });
1738
18 test("a side only sees its own gear", () => {
19 const ct = createWorld(SHORT, "me", "ct", 5, 2);
20 expect(row(ct, "ak47")).toBeUndefined();
21 const t = createWorld(SHORT, "me", "t", 5, 2);
22 expect(row(t, "kit")).toBeUndefined();
39 /** A panel to draw into, the buttons it made, and every item a press handed back. */
40 function shop(): {
41 panel: FakeElement;
42 counter: Counter;
43 pressed: BuyItem[];
44 /** How many times the rebuy control was pressed. */
45 rebuys(): number;
46 tiles(): FakeElement[];
47 /** The one control under the grid: last round's loadout, bought again. */
48 again(): FakeElement;
49 labels(): (string | null)[];
50 /** Whether any tile's label holds this, whatever number it was printed with. */
51 says(part: string): boolean;
52 /** The tile whose label holds this word, which is how a test names one. */
53 tile(label: string): FakeElement;
54 } {
55 const page = fakePage({ ids: ["buy"] });
56 const panel = page.by("buy");
57 const pressed: BuyItem[] = [];
58 let again = 0;
59 const tiles = (): FakeElement[] => panel.querySelector(".grid")?.children ?? [];
60 return {
61 panel,
62 pressed,
63 rebuys: () => again,
64 tiles,
65 again: () => {
66 const found = panel.querySelector(".row")?.children[0];
67 if (found === undefined) throw new Error("no rebuy control");
68 return found;
69 },
70 labels: () => tiles().map((tile) => tile.textContent),
71 says: (part) => tiles().some((tile) => tile.textContent?.includes(part) === true),
72 // By what the label says and not by index: a weapon added to the table adds
73 // a tile and moves every number after it, and a test about the armour should
74 // not move when one is.
75 tile: (label) => {
76 const found = tiles().find((tile) => tile.textContent?.includes(label));
77 if (found === undefined) throw new Error(`no tile for ${label}`);
78 return found;
79 },
80 counter: {
81 panel,
82 button: () => page.document.createElement("button"),
83 press: (item) => void pressed.push(item),
84 again: () => void (again += 1),
85 },
86 };
87 }
88
89 test("the shelf is this side's guns, the armour, and the kit only a CT can carry", () => {
90 // Every buyable weapon in the table, in the table's order, which is how the
91 // smoke #165 added reached the panel without a line in here: the shelf is
92 // `weapons.ts`'s list and not a second copy of it.
93 expect(offers("ct").map((offer) => offer.item))
94 .toEqual(["usp", "deagle", "mp9", "m4a4", "awp", "he", "flash", "smoke", "armor", "helmet", "kit"]);
95 // A T's pistol is the Glock and its rifle is the AK, and there is no kit on the
96 // shelf at all: a tile a side can never press is a tile that should not be there.
97 expect(offers("t").map((offer) => offer.item))
98 .toEqual(["glock", "deagle", "mp9", "ak47", "awp", "he", "flash", "smoke", "armor", "helmet"]);
99 expect(offers("t").map((offer) => offer.label)).not.toContain("Defuse kit");
23100 });
24101
25 test("a gun already in hand is owned, not for sale again", () => {
26 const world = createWorld(SHORT, "me", "ct", 5, 3);
27 world.me.money = 5000;
28 buy(world, world.me, "m4a4");
29 const m4 = row(world, "m4a4");
30 expect(m4?.state).toBe("owned");
102 test("the panel a real world in its freeze time is drawn as, to the character", () => {
103 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
104 expect(world.match.phase).toBe("freeze");
105 expect(world.me.money).toBe(800);
106 const it = shop();
107 drawBuy(it.counter, world, world.me);
108
109 expect(it.panel.innerHTML).toBe(
110 `<h2>Buy <small>$800</small></h2><div class="grid"></div>` +
111 `<div class="row"></div><p class="hint">${HINT}</p>`,
112 );
113 // Eleven tiles, each a `<button>` — a press that submits nothing — printed
114 // with the key that presses it and the price this body pays, and dead exactly
115 // where 800 dollars cannot reach. The eleventh key is the one after 0, because
116 // a CT's shelf is one longer than the digits and a tile with no key on it is a
117 // tile you need the mouse for.
118 expect(it.tiles().map((tile) => tile.type)).toEqual(Array(11).fill("button"));
119 expect(it.tiles().map((tile) => [tile.textContent, tile.disabled])).toEqual([
120 ["1 · USP-S · $200", false],
121 ["2 · Desert Eagle · $700", false],
122 ["3 · MP9 · $1250", true],
123 ["4 · M4A4 · $3100", true],
124 ["5 · AWP · $4750", true],
125 ["6 · HE Grenade · $300", false],
126 ["7 · Flashbang · $200", false],
127 ["8 · Smoke Grenade · $300", false],
128 ["9 · Kevlar · $650", false],
129 ["0 · Kevlar + Helmet · $1000", true],
130 ["- · Defuse kit · $400", false],
131 ]);
132 // And the number a tile is printed with is the key `binding.ts` binds to that
133 // index: the panel and the keyboard read one list, so they cannot disagree.
134 expect(it.tiles().map((tile) => tile.textContent?.split(" · ")[0]))
135 .toEqual([...NUMBER_ROW]);
136 // Under them, the control that is not an item. Nothing was bought last round
137 // here, so it is dead rather than live and empty.
138 expect(it.again().textContent).toBe(REBUY_LABEL);
139 expect(it.again().disabled).toBe(true);
31140 });
32141
33 test("a second grenade is owned, not a second purchase", () => {
34 const world = createWorld(SHORT, "me", "ct", 5, 4);
35 world.me.money = 5000;
36 buy(world, world.me, "he");
37 const he = row(world, "he");
38 expect(he?.state).toBe("owned");
39 expect(he?.why).toContain("carrying");
142 test("drawing again is the panel again, not a second one under it", () => {
143 const it = shop();
144 const me = buyer();
145 drawBuy(it.counter, freeze, me);
146 const drawn = it.tiles().length;
147 expect(drawn).toBe(offers("ct").length);
148 me.money = 3000;
149 drawBuy(it.counter, freeze, me);
150 expect(it.tiles().length).toBe(drawn);
151 expect(it.panel.innerHTML).toContain("$3000");
40152 });
41153
42 test("every row goes closed together once canBuy is false", () => {
43 const world = createWorld(SHORT, "me", "ct", 5, 5);
44 world.me.money = 5000;
45 for (let t = 0; t < SHORT.freezeTime + 0.5; t += 1 / 64) update(world, 1 / 64, NO_INPUT);
46 expect(world.match.phase).toBe("live");
47 const menu = buyMenu(world, world.me);
48 expect(menu.rows.length).toBeGreaterThan(0);
49 expect(menu.rows.every((r) => r.state === "closed")).toBe(true);
154 test("a price is for the body in front of you: a vest on is a helmet at helmet price", () => {
155 const it = shop();
156 drawBuy(it.counter, freeze, buyer({ armor: 100 }));
157 expect(it.says(`Kevlar + Helmet · $${HELMET_PRICE}`)).toBe(true);
158 // The vest is already on, so it is priced at nothing — and a tile at nothing is
159 // pressable, although `economy.ts` refuses the buy. That is today's behaviour
160 // and this pull moved it rather than fixing it: see #250.
161 expect(it.says("Kevlar · $0")).toBe(true);
162 expect(it.tile("Kevlar ·").disabled).toBe(false);
163 // Bare, the helmet is the vest as well and costs both.
164 const bare = shop();
165 drawBuy(bare.counter, freeze, buyer());
166 expect(bare.says(`Kevlar + Helmet · $${ARMOR_PRICE + HELMET_PRICE}`)).toBe(true);
167 expect(bare.says(`Defuse kit · $${DEFUSE_KIT_PRICE}`)).toBe(true);
50168 });
51169
52 test("armour's price is what is already worn, not a constant", () => {
53 const world = createWorld(SHORT, "me", "ct", 5, 6);
54 world.me.money = 5000;
55 const bare = row(world, "helmet");
56 expect(bare?.price).toBe(ARMOR_PRICE + HELMET_PRICE);
57 buy(world, world.me, "armor");
58 const armored = row(world, "helmet");
59 expect(armored?.price).toBe(HELMET_PRICE);
170 test("a wallet that cannot reach a price is a dead tile, to the dollar", () => {
171 const it = shop();
172 drawBuy(it.counter, freeze, buyer({ money: WEAPONS.awp.price - 1 }));
173 expect(it.tile(`AWP · $${WEAPONS.awp.price}`).disabled).toBe(true);
174 const rich = shop();
175 drawBuy(rich.counter, freeze, buyer({ money: WEAPONS.awp.price }));
176 expect(rich.tile(`AWP · $${WEAPONS.awp.price}`).disabled).toBe(false);
177 });
178
179 test("outside the freeze time every tile is dead, and the prices are still readable", () => {
180 const it = shop();
181 drawBuy(it.counter, live, buyer({ money: 16_000 }));
182 expect(it.tiles().length).toBe(offers("ct").length);
183 expect(it.tiles().every((tile) => tile.disabled)).toBe(true);
184 expect(it.says(`AWP · $${WEAPONS.awp.price}`)).toBe(true);
185 // And to a body, wherever the round is: a corpse has nothing to spend it on.
186 const dead = shop();
187 drawBuy(dead.counter, freeze, buyer({ alive: false, money: 16_000 }));
188 expect(dead.tiles().every((tile) => tile.disabled)).toBe(true);
189 });
190
191 test("a press hands the item back and spends nothing itself", () => {
192 const it = shop();
193 const me = buyer();
194 drawBuy(it.counter, freeze, me);
195 it.tile("USP-S").fire("click");
196 it.tile("Kevlar + Helmet").fire("click");
197 expect(it.pressed).toEqual(["usp", "helmet"]);
198 // The wallet and the kit are the caller's to change: this module reads them.
199 expect(me.money).toBe(5000);
200 expect(me.helmet).toBe(false);
201 // A dead tile is dead because a browser will not click it, not because the
202 // panel checks twice — so what is asserted here is that one press is one item.
203 it.tile("USP-S").fire("click");
204 expect(it.pressed).toEqual(["usp", "helmet", "usp"]);
205 });
206
207 test("the rebuy control is live only when there is a loadout and a round to buy it in", () => {
208 const it = shop();
209 // Something was bought last round, and the round is one you can buy in.
210 drawBuy(it.counter, freeze, buyer(), ["ak47", "armor"]);
211 expect(it.again().disabled).toBe(false);
212 it.again().fire("click");
213 expect(it.rebuys()).toBe(1);
214 // It spends nothing itself, the way a tile does not: the caller buys.
215 expect(it.pressed).toEqual([]);
216
217 // The same loadout outside the freeze time is a dead control, like every tile
218 // beside it, and so is an empty one in the freeze time.
219 const late = shop();
220 drawBuy(late.counter, live, buyer(), ["ak47"]);
221 expect(late.again().disabled).toBe(true);
222 const nothing = shop();
223 drawBuy(nothing.counter, freeze, buyer(), []);
224 expect(nothing.again().disabled).toBe(true);
225 });
226
227 test("the hint under the grid says the keys, and an item answers to the name the shelf gave it", () => {
228 const it = shop();
229 drawBuy(it.counter, freeze, buyer());
230 expect(it.panel.innerHTML).toContain(HINT);
231 expect(HINT).toContain("number row");
232 expect(HINT).toContain("X");
233 expect(HINT).toContain("B or Escape closes");
234 // The sentence a rebuy says is built out of these, so they are the shelf's
235 // names and not a second table of them.
236 expect(labelOf("ak47", "t")).toBe("AK-47");
237 expect(labelOf("helmet", "ct")).toBe("Kevlar + Helmet");
238 expect(labelOf("kit", "ct")).toBe("Defuse kit");
60239 });
src/client/buy.ts +134 −63
@@ -1,73 +1,144 @@
11 /**
2 * The buy menu's rows: what each item costs, whether it can be bought right
3 * now, and why not when it cannot. Pure data in, plain data out — nothing
4 * here touches the DOM, so the panel in main.ts is free to paint it however
5 * a phone or a screen reader needs.
2 * The buy panel: the shop as a player reads it. What is on the shelf is this
3 * side's guns, the armour, and the kit only a CT can carry; what each one costs
4 * is `economy.ts`'s answer for *this* body — a vest already on is a helmet at
5 * helmet price — and whether a tile can be pressed is the wallet and the round
6 * together, which is the same `canBuy` a buy itself is refused by. Drawn from
7 * scratch every time it opens and again after every press, so the prices and
8 * the money in the heading are the prices and the money right now.
9 *
10 * Every tile is printed with the key that presses it — the number row, left to
11 * right, from `binding.ts` — because the panel is read with the mouse still on
12 * the game: the hand that buys is the hand that is walking out of spawn, and a
13 * tile you can only reach by pointing at it is a tile that costs a pointer
14 * round-trip. Under them is the one control that is not an item: last round's
15 * loadout, bought again.
16 *
17 * It is not a browser and it is not the frame. It takes the element to draw
18 * into, the two things a price is worked out from — the round and the player —
19 * and the calls it cannot make itself: one that makes a `<button>`, one that
20 * says which item was pressed, and one that says a rebuy was asked for. `main.ts` is the only file that knows where
21 * that element came from; nothing here reaches for a document, a host or a
22 * world. A press does not buy: it says what was wanted and hands it back, so
23 * the wallet is still spent in one place — the frame, on the world it is
24 * simulating — and a tile left over from a match that has ended cannot reach
25 * into a match that has not begun.
26 *
27 * Nothing rendered here comes from outside the program: every label is a
28 * constant of `weapons.ts` or of this file, and the only value interpolated is
29 * a number off the player. No name, no field, nothing a person typed is in the
30 * markup, which is why there is no escaping in it to read.
631 */
732
8 import { buyable, WEAPONS } from "./weapons.ts";
9 import { canBuy, grenadeFull, priceOf, type BuyItem, type Player, type World } from "./world.ts";
33 import { NUMBER_ROW } from "./binding.ts";
34 import type { Listens } from "../engine/index.ts";
35 import { canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts";
36 import type { Team } from "./match.ts";
37 import { buyable } from "./weapons.ts";
1038
11 export type BuyState = "ok" | "owned" | "poor" | "side" | "closed";
12
13 export type BuyRow = {
14 item: BuyItem;
15 label: string;
16 price: number;
17 state: BuyState;
18 why: string;
19 };
20
21 export type BuyMenu = {
22 rows: BuyRow[];
23 money: number;
24 /** Seconds left in the freeze time, however long it has been over — never negative. */
25 secondsLeft: number;
26 };
27
28 /** Every item a side can ever buy — off-side gear (the other rifle, a T's defuse kit) never appears. */
29 const catalog = (team: "t" | "ct"): [BuyItem, string][] => [
30 ...buyable(team).map((w) => [w.id, w.name] as [BuyItem, string]),
31 ["armor", "Kevlar"],
32 ["helmet", "Kevlar + Helmet"],
33 ...(team === "ct" ? ([["kit", "Defuse kit"]] as [BuyItem, string][]) : []),
34 ];
35
36 function owned(player: Player, item: BuyItem): boolean {
37 if (item === "armor") return player.armor >= 100;
38 if (item === "helmet") return player.helmet;
39 if (item === "kit") return player.defuseKit;
40 if (WEAPONS[item].kind === "grenade") return grenadeFull(player, WEAPONS[item].id);
41 return player.primary === item || player.secondary === item;
42 }
43
44 function ownedWhy(item: BuyItem): string {
45 if (item === "armor" || item === "helmet") return "already worn";
46 if (item === "kit" || WEAPONS[item]?.kind === "grenade") return "already carrying one";
47 return "already in hand";
48 }
49
50 function closedWhy(world: World, player: Player): string {
51 if (!player.alive) return "you're dead this round";
52 if (world.match.phase !== "freeze") return "the buy window is closed";
53 return "";
54 }
39 /** One thing on the shelf: what a player reads, and what a press of it buys. */
40 export type Offer = { item: BuyItem; label: string };
5541
5642 /**
57 * The rows the buy panel paints. Every row goes `closed` together the moment
58 * `canBuy` does — the one rule this file is not allowed a second copy of —
59 * and a row too expensive still shows what it costs, so the number is the
60 * only thing a player has to check twice.
43 * The element the panel is drawn into, as far as this file is concerned: the
44 * markup it is given, and the grid it just wrote into that markup.
45 * `unknown` because a browser's own `querySelector` is wider than any subset.
6146 */
62 export function buyMenu(world: World, player: Player): BuyMenu {
63 const open = canBuy(world, player);
64 const reason = open ? "" : closedWhy(world, player);
65 const rows: BuyRow[] = catalog(player.team).map(([item, label]) => {
47 export type Shelf = {
48 innerHTML: string;
49 querySelector(selectors: string): unknown;
50 appendChild(child: unknown): void;
51 };
52
53 /** One tile in the grid: a button, its label, whether it can be pressed. */
54 export type Tile = Listens & { type: string; textContent: string | null; disabled: boolean };
55
56 /**
57 * What the page has to lend the panel: the element, a way to make a button, and
58 * what a press means. `press` is the whole of what this module hands back —
59 * the item wanted, once per press — and the caller decides what it costs.
60 * `again` is the same thing for the one control that is not an item: last
61 * round's loadout, which the caller buys back through the same `buy()`.
62 */
63 export type Counter = {
64 panel: Shelf;
65 /** A fresh `<button>`, unattached. `document.createElement("button")` is one. */
66 button(): Tile;
67 press(item: BuyItem): void;
68 again(): void;
69 };
70
71 /**
72 * What the panel says under the grid: the keys, then the one rule. It reads the
73 * way the menu's hint line reads, and it says the number row rather than naming
74 * eleven keys, because each tile is printed with the key that presses it.
75 */
76 export const HINT =
77 "The number row buys the tile it is printed on · X buys last round's loadout again · " +
78 "B or Escape closes. You can only buy in the freeze time.";
79
80 /** The control under the grid, and the key it answers to. */
81 export const REBUY_LABEL = "X · Rebuy last round";
82
83 /** The key printed on the nth tile, or "" for a shelf longer than the row. */
84 export const keyFor = (index: number): string => NUMBER_ROW[index] ?? "";
85
86 /** What a tile says: the key that presses it, its name, and what this body pays. */
87 export const tileLabel = (index: number, label: string, price: number): string => {
88 const key = keyFor(index);
89 return `${key === "" ? "" : `${key} · `}${label} · $${price}`;
90 };
91
92 /**
93 * The shelf, in the order it is drawn: this side's guns and grenades as
94 * `weapons.ts` lists them, then the armour, then the defuse kit — which is on
95 * the shelf for a CT and not on it at all for a T, because a T cannot carry one
96 * and a tile that is never pressable is a tile that should not be there.
97 */
98 export function offers(team: Team): Offer[] {
99 const guns: Offer[] = buyable(team).map((weapon) => ({ item: weapon.id, label: weapon.name }));
100 const kit: Offer[] = team === "ct" ? [{ item: "kit", label: "Defuse kit" }] : [];
101 return [...guns, { item: "armor", label: "Kevlar" }, { item: "helmet", label: "Kevlar + Helmet" }, ...kit];
102 }
103
104 /** The name this shelf gives an item, which is the name a sentence about it uses. */
105 export const labelOf = (item: BuyItem, team: Team): string =>
106 offers(team).find((offer) => offer.item === item)?.label ?? item;
107
108 /**
109 * Draw the panel: the heading with what is left to spend, one tile per offer —
110 * numbered with the key that presses it — the rebuy control, and the hint. A
111 * tile is dead when the wallet cannot reach the price or when the round is not
112 * one a buy is allowed in at all — the panel shows the prices either way rather
113 * than emptying itself, because what the next freeze time costs is worth
114 * reading.
115 *
116 * `last` is what was bought the round before, and the whole of what this module
117 * wants to know about it is whether there is any of it: a rebuy with nothing to
118 * buy back is a dead control rather than a live one that does nothing.
119 */
120 export function drawBuy(counter: Counter, market: Market, player: Buyer, last: readonly BuyItem[] = []): void {
121 const open = canBuy(market, player);
122 counter.panel.innerHTML =
123 `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` +
124 `<div class="row"></div><p class="hint">${HINT}</p>`;
125 const grid = counter.panel.querySelector(".grid") as Shelf;
126 // The index is the tile's place on the screen and the key that presses it,
127 // read out of one list: `main.ts` turns the key back into this same index of
128 // this same `offers`, so the number on a tile is the item it buys.
129 offers(player.team).forEach(({ item, label }, index) => {
66130 const price = priceOf(item, player);
67 if (!open) return { item, label, price, state: "closed", why: reason };
68 if (owned(player, item)) return { item, label, price, state: "owned", why: ownedWhy(item) };
69 if (price > player.money) return { item, label, price, state: "poor", why: "not enough money" };
70 return { item, label, price, state: "ok", why: "" };
131 const tile = counter.button();
132 tile.type = "button";
133 tile.textContent = tileLabel(index, label, price);
134 tile.disabled = price > player.money || !open;
135 tile.addEventListener("click", () => counter.press(item));
136 grid.appendChild(tile);
71137 });
72 return { rows, money: player.money, secondsLeft: Math.max(0, world.match.timer) };
138 const again = counter.button();
139 again.type = "button";
140 again.textContent = REBUY_LABEL;
141 again.disabled = !open || last.length === 0;
142 again.addEventListener("click", () => counter.again());
143 (counter.panel.querySelector(".row") as Shelf).appendChild(again);
73144 }
src/client/callout.test.ts deleted −63
@@ -1,63 +0,0 @@
1 import { expect, test } from "bun:test";
2 import {
3 canSpeak, clearChatter, isNews, newChatter, noteCallout, sightingText,
4 REPEAT_GAP, SIDE_GAP, SPEAKER_GAP,
5 } from "./callout.ts";
6
7 test("a sighting names the teammate and the place, in that order", () => {
8 const said = sightingText("Krait", "A site");
9 expect(said).toBe("Krait: enemy — A site");
10 });
11
12 test("a place the map could not name is left off rather than guessed at", () => {
13 expect(sightingText("Pike", null)).toBe("Pike: enemy");
14 });
15
16 test("the gaps are ordered: the side's, then one bot's, then the same place", () => {
17 // The HUD holds a line for four seconds and draws only the last one, so a
18 // side that talks faster than SIDE_GAP overwrites itself unread.
19 expect(SIDE_GAP).toBe(4);
20 expect(SPEAKER_GAP).toBeGreaterThan(SIDE_GAP);
21 expect(REPEAT_GAP).toBeGreaterThan(SPEAKER_GAP);
22 });
23
24 test("a fresh side may speak, and then not again until the gaps have run", () => {
25 const chatter = newChatter(4);
26 expect(canSpeak(chatter, 1, 0)).toBe(true);
27 noteCallout(chatter, 1, "mid", 0);
28
29 // Nobody at all, until the side's own gap is up.
30 expect(canSpeak(chatter, 2, SIDE_GAP - 0.1)).toBe(false);
31 expect(canSpeak(chatter, 2, SIDE_GAP)).toBe(true);
32 // And not this bot again, for longer still: one voice cannot hold the feed.
33 expect(canSpeak(chatter, 1, SIDE_GAP)).toBe(false);
34 expect(canSpeak(chatter, 1, SPEAKER_GAP)).toBe(true);
35 });
36
37 test("the side does not repeat the same call about the same place", () => {
38 const chatter = newChatter(4);
39 noteCallout(chatter, 1, "mid", 0);
40 expect(isNews(chatter, "mid", REPEAT_GAP - 0.1)).toBe(false);
41 expect(isNews(chatter, "mid", REPEAT_GAP)).toBe(true);
42 // Somewhere else is news at once: that is a second angle, not a repeat.
43 expect(isNews(chatter, "A site", 0.1)).toBe(true);
44 expect(isNews(chatter, null, 0.1)).toBe(true);
45 });
46
47 test("clearing forgets everything, in place", () => {
48 const chatter = newChatter(4);
49 const voices = chatter.spokeAt;
50 noteCallout(chatter, 3, "mid", 100);
51 expect(canSpeak(chatter, 3, 101)).toBe(false);
52 clearChatter(chatter);
53 expect(chatter.spokeAt).toBe(voices); // the same array, zeroed, never a new one
54 expect(canSpeak(chatter, 3, 101)).toBe(true);
55 expect(isNews(chatter, "mid", 101)).toBe(true);
56 });
57
58 test("a speaker the side has no slot for cannot corrupt the record", () => {
59 const chatter = newChatter(2);
60 noteCallout(chatter, 9, "mid", 5);
61 expect(chatter.spokeAt.length).toBe(2);
62 expect(chatter.lastAt).toBe(5); // the side still said it
63 });
src/client/callout.ts deleted −113
@@ -1,113 +0,0 @@
1 /**
2 * What a teammate says out loud when it sees somebody, and how often it is
3 * allowed to say it.
4 *
5 * A bot already knows where the enemy is — it runs `canSee` against every one
6 * of them every tick to decide whether to shoot. This is the rule that turns a
7 * little of that into a line on the message feed: which words, about where,
8 * and the three gaps that keep four teammates from filling the screen.
9 *
10 * One thing is said and no more: an enemy seen. The vocabulary is `map.ts`'s —
11 * `placeAt` names the layout's own areas, so a callout can only ever say a
12 * place the map itself carved. There is no table of coordinates here.
13 *
14 * Nothing in this file touches a world or allocates: it is a clock, three
15 * numbers and a fixed set of words, so `world.ts` can ask "may this bot speak"
16 * for the price of two comparisons on the tick where the answer is no — which
17 * is nearly every tick.
18 */
19
20 /**
21 * One line, built only once the rate limit has already said yes. A place the
22 * map could not name is dropped rather than guessed at.
23 *
24 * "enemy" is somebody being looked at down a barrel and means exactly that:
25 * nothing in the game says this word about a footstep or a guess.
26 */
27 export const sightingText = (name: string, place: string | null): string =>
28 place === null ? `${name}: enemy` : `${name}: enemy — ${place}`;
29
30 // --- the rate limit -------------------------------------------------------
31 //
32 // Three gaps, because there are three different ways a radio becomes noise:
33 // the side as a whole, one bot on its own, and the same call said twice. The
34 // six-message cap in `say` is a backstop and not a limit — six lines a second
35 // is still six lines a second.
36
37 /**
38 * Seconds between any two things the side says. The HUD holds a line for four
39 * seconds (`MESSAGE_FADE` in hud.ts) and draws only the last one, so a side
40 * that talks faster than this paints over its own previous line before it can
41 * be read: one line, readable, is worth more than three that flicker.
42 */
43 export const SIDE_GAP = 4;
44
45 /**
46 * Seconds before the same bot says anything again. Six — one and a half
47 * `SIDE_GAP`s — so the four teammates together can still fill every slot the
48 * side's gap allows, but no one of them can hold the feed on its own: a bot in
49 * a long fight calls it once and then lets somebody else talk.
50 */
51 export const SPEAKER_GAP = 6;
52
53 /**
54 * Seconds before the side calls the same place again. Twelve — three lines'
55 * worth of `SIDE_GAP` — is long enough that five teammates walking into the
56 * same angle say "enemy — mid" once between them, and short enough that a site
57 * still being fought over a quarter of a minute later is worth mentioning
58 * again.
59 */
60 export const REPEAT_GAP = 12;
61
62 /**
63 * What the side has said lately. Sized once, in `createWorld`, and zeroed in
64 * place by `startRound`: nothing here is ever allocated during a round.
65 */
66 export type Chatter = {
67 /** When the side last said anything at all. */
68 lastAt: number;
69 /** When each player last said anything, by player id. */
70 spokeAt: Float64Array;
71 /** The place the side's last sighting named, and when it named it. */
72 place: string | null;
73 placeAt: number;
74 };
75
76 /** A side that has never said anything, with room for `players` voices. */
77 export const newChatter = (players: number): Chatter => ({
78 lastAt: -Infinity,
79 spokeAt: new Float64Array(players).fill(-Infinity),
80 place: null,
81 placeAt: -Infinity,
82 });
83
84 /** Forget it all, in place: a new round starts with nothing already said. */
85 export function clearChatter(chatter: Chatter): void {
86 chatter.lastAt = -Infinity;
87 chatter.spokeAt.fill(-Infinity);
88 chatter.place = null;
89 chatter.placeAt = -Infinity;
90 }
91
92 /**
93 * The cheap half of the limit: has the side, and this bot, been quiet long
94 * enough? Asked first and on every tick a bot makes contact, so it looks at
95 * two numbers and nothing else — the place is not even worked out yet.
96 */
97 export const canSpeak = (chatter: Chatter, speakerId: number, now: number): boolean =>
98 now - chatter.lastAt >= SIDE_GAP &&
99 now - (chatter.spokeAt[speakerId] ?? -Infinity) >= SPEAKER_GAP;
100
101 /** The other half: is this actually news, or the same call about the same place? */
102 export const isNews = (chatter: Chatter, place: string | null, now: number): boolean =>
103 chatter.place !== place || now - chatter.placeAt >= REPEAT_GAP;
104
105 /** Write down that it was said. The caller has already asked both questions. */
106 export function noteCallout(
107 chatter: Chatter, speakerId: number, place: string | null, now: number,
108 ): void {
109 chatter.lastAt = now;
110 if (speakerId >= 0 && speakerId < chatter.spokeAt.length) chatter.spokeAt[speakerId] = now;
111 chatter.place = place;
112 chatter.placeAt = now;
113 }
src/client/cells.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The alphabet every map of this game is written in, and what a page of it
3 * becomes. A cell is one letter — rock, floor, a crate you can climb, a crate
4 * you cannot, the two bombsites, the two spawns — and `levelOf` turns a grid of
5 * those letters into a `Level`: the boxes a bullet stops in and the renderer
6 * draws, the spawn points, the middle of each site, and the predicate that says
7 * what a bot may stand on.
8 *
9 * It exists because there are two maps now and there will be more. `map.ts`
10 * writes de_forge's page by hand and `generate.ts` writes one from a seed;
11 * neither of them owns what a letter means, so neither of them can disagree
12 * with the other about it. What a level *is* — the type, and the questions
13 * asked of one every tick — is `level.ts`, which knows no alphabet at all, and
14 * the arithmetic under this file is the engine's `grid.ts`, which knows less
15 * still: what counts as solid or walkable goes in as a predicate.
16 */
17
18 import * as g from "../engine/index.ts";
19 import type { Box, Grid, Vec3 } from "../engine/index.ts";
20 import type { Level } from "./level.ts";
21 import { FORGE, type Look } from "./looks.ts";
22
23 /** World units along one side of a cell: Source units, like everything here. */
24 export const CELL = 128;
25
26 /** How tall a wall stands. Nothing in this game can see or shoot over one. */
27 export const WALL_HEIGHT = 384;
28
29 /**
30 * One letter of a map. `#` rock, `.` floor, `x` a crate you can jump onto, `X`
31 * one you cannot, `A` and `B` the two bombsites, `t` and `c` the two spawns.
32 */
33 export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c";
34
35 /**
36 * The material ids this game's boxes are tagged with, by name — de_forge's own,
37 * and what a level is dressed in when it asks for nothing else. Which numbers
38 * these are is `looks.ts`'s business now: a generated map hands `levelOf` a
39 * different set from the same seed that carved it, and everything below is
40 * written in the names rather than in the numbers, so it never has to know.
41 */
42 export const MAT = FORGE.materials;
43
44 /**
45 * Solid to a walking bot: walls and both kinds of crate. This predicate is the
46 * whole of what the engine's search knows about this alphabet, and it is the
47 * one thing a level carries that is a function rather than a number.
48 */
49 export const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X";
50
51 /** The bounding slab of every cell paved with one letter, two units thick, in the level's own site material. */
52 function siteSlab(grid: Grid<Cell>, fill: Cell, mat: number): Box {
53 let c0 = grid.width, r0 = grid.height, c1 = 0, r1 = 0;
54 for (let r = 0; r < grid.height; r++)
55 for (let c = 0; c < grid.width; c++)
56 if (grid.cells[r * grid.width + c] === fill) {
57 c0 = Math.min(c0, c); c1 = Math.max(c1, c);
58 r0 = Math.min(r0, r); r1 = Math.max(r1, r);
59 }
60 if (c1 < c0) throw new Error(`no cell of this map is paved "${fill}": it has no site to plant on`);
61 const a = g.worldOf(grid, c0, r0), b = g.worldOf(grid, c1, r1);
62 return {
63 min: [a[0] - CELL / 2, b[1] - CELL / 2, 0],
64 max: [b[0] + CELL / 2, a[1] + CELL / 2, 2],
65 mat,
66 };
67 }
68
69 /** Every cell paved with one letter, as world points, in reading order. */
70 export function cellsOf(grid: Grid<Cell>, fill: Cell): Vec3[] {
71 const out: Vec3[] = [];
72 for (let r = 0; r < grid.height; r++)
73 for (let c = 0; c < grid.width; c++)
74 if (grid.cells[r * grid.width + c] === fill) out.push(g.worldOf(grid, c, r));
75 return out;
76 }
77
78 function centre(list: Vec3[]): Vec3 {
79 const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3);
80 return [s[0] / list.length, s[1] / list.length, 0];
81 }
82
83 /**
84 * A page of letters, read as a level: the floor slab, the two painted sites,
85 * the walls and both heights of crate merged into as few boxes as the shape
86 * allows, the spawn points and the middle of each site.
87 *
88 * One piece of data feeds everything — what you collide with, what gets drawn,
89 * where bots walk, where the bomb can be planted — so a map can never disagree
90 * with itself. A grid with no `A`, no `B`, or no cell for a side to spawn on is
91 * not a map anybody can play and says so here rather than at the first round.
92 *
93 * `look` is what it is painted in: one of the named sets in `looks.ts`, de_forge's
94 * own unless a caller says otherwise. It touches the boxes' material tags and
95 * nothing else — not a cell, not a spawn, not a site, not one coordinate — so
96 * the same page of letters in two looks is the same map twice over, and a
97 * generated one can be a different place for free.
98 */
99 export function levelOf(name: string, grid: Grid<Cell>, look: Look = FORGE): Level {
100 const halfX = (grid.width / 2) * CELL;
101 const halfY = (grid.height / 2) * CELL;
102 const mat = look.materials;
103 const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: mat.FLOOR };
104 const merge = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] =>
105 g.mergeSolid(grid, match, z0, z1, mat);
106 const spawns = { t: cellsOf(grid, "t"), ct: cellsOf(grid, "c") };
107 for (const [side, list] of Object.entries(spawns))
108 if (list.length === 0) throw new Error(`no cell of this map is a ${side} spawn`);
109 return {
110 name,
111 grid,
112 passable,
113 wallHeight: WALL_HEIGHT,
114 look: look.name,
115 materials: mat,
116 // Everything solid, in one list: the renderer draws it and bullets stop in it.
117 boxes: [
118 floor,
119 siteSlab(grid, "A", mat.SITE),
120 siteSlab(grid, "B", mat.SITE),
121 ...merge((c) => c === "#", 0, WALL_HEIGHT, mat.WALL),
122 ...merge((c) => c === "x", 0, 64, mat.CRATE),
123 ...merge((c) => c === "X", 0, 128, mat.CRATE),
124 ],
125 spawns,
126 sites: { A: centre(cellsOf(grid, "A")), B: centre(cellsOf(grid, "B")) },
127 siteCells: { A: "A", B: "B" },
128 };
129 }
src/client/combat.test.ts +85 −67
@@ -1,8 +1,11 @@
11 import { expect, test } from "bun:test";
2 import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts";
3 import { MAT, type Box } from "./map.ts";
4 import type { Vec3 } from "./math.ts";
5 import { WEAPONS, type Weapon } from "./weapons.ts";
2 import {
3 applyHit, FULL_ARMOR, FULL_HEALTH, hitboxes, killRange, shotCost, traceBullet, wholeBody,
4 type Actor,
5 } from "./combat.ts";
6 import type { Box } from "./level.ts";
7 import type { Vec3 } from "../engine/index.ts";
8 import { BULLET_RANGE, KNIFE_RANGE, WEAPONS } from "./weapons.ts";
69
710 const actor = (over: Partial<Actor> = {}): Actor => ({
811 id: 1, team: "t", pos: [500, 0, 0], yaw: 180, duckAmount: 0,
@@ -20,25 +23,25 @@ test("a crouching head sits where a crouching head looks", () => {
2023
2124 test("a bullet at head height hits the head", () => {
2225 const target = actor();
23 const hit = traceBullet([0, 0, 66], [1000, 0, 66], [], [target], 0, WEAPONS.ak47);
26 const hit = traceBullet([0, 0, 66], [1000, 0, 66], [], [target], 0);
2427 expect(hit.kind).toBe("actor");
2528 if (hit.kind === "actor") expect(hit.group).toBe("head");
2629 });
2730
2831 test("a wall in the way stops the bullet before the body", () => {
2932 const wall: Box = { min: [200, -100, 0], max: [216, 100, 384], mat: 1 };
30 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [wall], [actor()], 0, WEAPONS.ak47);
33 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [wall], [actor()], 0);
3134 expect(hit.kind).toBe("world");
3235 if (hit.kind === "world") expect(hit.point[0]).toBeCloseTo(200, 3);
3336 });
3437
3538 test("you cannot shoot yourself", () => {
3639 const me = actor({ id: 7, pos: [10, 0, 0] });
37 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [me], 7, WEAPONS.ak47).kind).toBe("none");
40 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [me], 7).kind).toBe("none");
3841 });
3942
4043 test("a dead body does not stop bullets", () => {
41 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [actor({ alive: false })], 0, WEAPONS.ak47).kind).toBe("none");
44 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [actor({ alive: false })], 0).kind).toBe("none");
4245 });
4346
4447 test("two AK headshots are one too many", () => {
@@ -50,6 +53,21 @@ test("two AK headshots are one too many", () => {
5053 expect(target.alive).toBe(false);
5154 });
5255
56 test("a Deagle one-taps a helmeted head from the far corner of the map", () => {
57 // A bullet fired the whole length one can be fired, at a head with a helmet
58 // and a full vest: the trace says head, the hit says killed, and the body is
59 // down in one. This is the end of the wire `weapons.test.ts` measures.
60 const far = actor({ pos: [BULLET_RANGE - 500, 0, 0], armor: 100, helmet: true });
61 const hit = traceBullet([0, 0, 66], [BULLET_RANGE, 0, 66], [], [far], 0);
62 expect(hit.kind).toBe("actor");
63 if (hit.kind !== "actor") return;
64 expect(hit.group).toBe("head");
65 expect(hit.distance).toBeGreaterThan(7000);
66 const result = applyHit(far, WEAPONS.deagle, hit.group, hit.distance);
67 expect(result).toEqual({ damage: 100, killed: true, headshot: true });
68 expect(far.alive).toBe(false);
69 });
70
5371 test("damage credited never exceeds the health that was there", () => {
5472 const target = actor({ health: 12 });
5573 const hit = applyHit(target, WEAPONS.ak47, "chest", 100);
@@ -57,76 +75,76 @@ test("damage credited never exceeds the health that was there", () => {
5775 expect(hit.killed).toBe(true);
5876 });
5977
60 // --- penetration ----------------------------------------------------------
78 /**
79 * `shotCost` and `killRange` are what a page of weapon numbers is allowed to
80 * ask, so every figure below is one a person can do on paper and check against
81 * the same arithmetic the round runs on. If one of these moves, a number
82 * printed somewhere moved with it, which is the point of both.
83 */
6184
62 const crate = (x0: number, x1: number, mat: number = MAT.CRATE): Box =>
63 ({ min: [x0, -100, 0], max: [x1, 100, 384], mat });
64
65 /** One shot down the x axis at a body at 500, and what it took off them. */
66 const shootThrough = (weapon: Weapon, boxes: Box[]): number => {
67 const target = actor();
68 const hit = traceBullet([0, 0, 40], [1000, 0, 40], boxes, [target], 0, weapon);
69 if (hit.kind !== "actor") return 0;
70 return applyHit(target, weapon, hit.group, hit.distance, hit.damageScale).damage;
71 };
72
73 test("a rifle crosses a thin crate and still hurts, for less than the clear shot", () => {
74 const through = shootThrough(WEAPONS.ak47, [crate(200, 280)]);
75 const clear = shootThrough(WEAPONS.ak47, []);
76 expect(through).toBeGreaterThan(0);
77 expect(through).toBeLessThan(clear);
85 test("what one shot costs is the fight's own sum, to the point", () => {
86 // 36 × 4 (head) = 144, nothing in the way of it.
87 expect(shotCost(WEAPONS.ak47, "head", 0)).toEqual({ health: 144, armor: 0, shots: 1 });
88 // 36 through 77.5% penetration is 27.9 → 28 health, and the vest takes half
89 // of the 8.1 it stopped → 4. Four of those is 112, which is more than a body.
90 expect(shotCost(WEAPONS.ak47, "chest", 0, FULL_ARMOR, true)).toEqual({ health: 28, armor: 4, shots: 4 });
91 // A leg is never covered, so the vest changes nothing about it: 115 × 0.75.
92 expect(shotCost(WEAPONS.awp, "leg", 0, FULL_ARMOR, true).health).toBe(86);
93 // The first shot's cost is uncapped by what is left, which is how a table
94 // shows the headroom a gun has: 63 × 4 × 0.955^(8192/500) × 0.925 = 110.
95 expect(shotCost(WEAPONS.deagle, "head", BULLET_RANGE, FULL_ARMOR, true).health).toBe(110);
96 // And a grenade has no bullet at all: nothing off, and nobody falls.
97 expect(shotCost(WEAPONS.he, "chest", 0)).toEqual({ health: 0, armor: 0, shots: null });
7898 });
7999
80 test("a pistol's round stays in the crate a rifle's crosses", () => {
81 expect(shootThrough(WEAPONS.glock, [crate(200, 280)])).toBe(0);
82 });
83
84 test("no round crosses a wall, a floor, a site plate or metal, however thin", () => {
85 for (const mat of [MAT.WALL, MAT.FLOOR, MAT.SITE, MAT.METAL]) {
86 expect(shootThrough(WEAPONS.awp, [crate(200, 216, mat)])).toBe(0);
100 test("the shots to kill are the shots the fight needs", () => {
101 // Counted by firing them, not by dividing: the same loop a round runs, into
102 // the same body, so the count on a page and the count in a fight are one
103 // number. Checked here against a body shot the long way round.
104 for (const weapon of [WEAPONS.glock, WEAPONS.ak47, WEAPONS.awp, WEAPONS.knife]) {
105 for (const group of ["head", "chest", "leg"] as const) {
106 const target = actor({ armor: FULL_ARMOR, helmet: true });
107 let fired = 0;
108 while (target.alive && fired < 64) {
109 applyHit(target, weapon, group, 0);
110 fired += 1;
111 }
112 expect(shotCost(weapon, group, 0, FULL_ARMOR, true).shots).toBe(fired);
113 }
87114 }
115 // And the arithmetic of one of them, on paper: a Glock's 30 to a vested chest
116 // is 14 health and 8 armour a shot, so it takes eight of them.
117 expect(shotCost(WEAPONS.glock, "chest", 0, FULL_ARMOR, true)).toEqual({ health: 14, armor: 8, shots: 8 });
88118 });
89119
90 test("a crate thicker than the round's limit stops it", () => {
91 // 200 units of crate: the AWP's 230 goes through, the AK's 175 does not.
92 expect(shootThrough(WEAPONS.ak47, [crate(200, 400)])).toBe(0);
93 expect(shootThrough(WEAPONS.awp, [crate(200, 400)])).toBeGreaterThan(0);
120 test("how far one shot to the head still kills is searched to the unit", () => {
121 // A Glock's 30 falls at 0.75 per 500 units: 30 × 4 × 0.75^(325/500) = 99.54,
122 // which rounds to 100 and is exactly a body. One unit further it is 99.48,
123 // rounds to 99, and the head gets up.
124 expect(killRange(WEAPONS.glock, "head")).toBe(325);
125 expect(shotCost(WEAPONS.glock, "head", 325).health).toBe(100);
126 expect(shotCost(WEAPONS.glock, "head", 326).health).toBe(99);
127 // The Deagle's promise, as a number: every unit a bullet can travel.
128 expect(killRange(WEAPONS.deagle, "head", FULL_ARMOR, true)).toBe(BULLET_RANGE);
129 // The M4A4's 33 × 4 through a helmet is 92, which is not a body at any range.
130 expect(killRange(WEAPONS.m4a4, "head", FULL_ARMOR, true)).toBe(null);
131 // A knife's answer is its own reach and never a bullet's.
132 expect(killRange(WEAPONS.knife, "head")).toBe(KNIFE_RANGE);
94133 });
95134
96 test("two crates in a line stop the bullet: one surface a shot", () => {
97 expect(shootThrough(WEAPONS.ak47, [crate(200, 280), crate(320, 400)])).toBe(0);
98 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 280), crate(320, 400)], [actor()], 0, WEAPONS.ak47);
99 expect(hit.kind).toBe("world");
100 if (hit.kind === "world") expect(hit.point[0]).toBeCloseTo(320, 3);
101 });
102
103 test("a punched crate reports the face it was punched at, and the tracer ends past it", () => {
104 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 280)], [actor()], 0, WEAPONS.ak47);
105 expect(hit.kind).toBe("actor");
106 expect(hit.through?.point[0]).toBeCloseTo(200, 3);
107 expect(hit.through?.normal).toEqual([-1, 0, 0]);
108 expect(hit.point[0]).toBeGreaterThan(280);
109 });
110
111 test("a body in front of the crate is hit at full damage, nothing punched", () => {
112 const target = actor({ pos: [100, 0, 0] });
113 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 280)], [target], 0, WEAPONS.ak47);
114 expect(hit.kind).toBe("actor");
115 if (hit.kind === "actor") expect(hit.damageScale).toBe(1);
116 expect(hit.through).toBeNull();
117 });
118
119 test("a knife has no penetration: a crate is a crate", () => {
120 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 216)], [actor()], 0, WEAPONS.knife);
121 expect(hit.kind).toBe("world");
135 test("a whole body is the body the world spawns and the shop dresses", () => {
136 const body = wholeBody(FULL_ARMOR, true);
137 expect(body.health).toBe(FULL_HEALTH);
138 expect(body.armor).toBe(FULL_ARMOR);
139 expect(body.alive).toBe(true);
122140 });
123141
124142 // --- the knife's back -----------------------------------------------------
125143
126144 /** One stab at a full-health, fully armoured target facing `yaw`, from `from`. */
127 const stab = (from: Vec3, yaw: number, weapon: Weapon = WEAPONS.knife) => {
128 const target = actor({ pos: [0, 0, 0], yaw, armor: 100, helmet: true });
129 const result = applyHit(target, weapon, "chest", 48, 1, from);
145 const stab = (from: Vec3, yaw: number, weapon = WEAPONS.knife) => {
146 const target = actor({ pos: [0, 0, 0], yaw, armor: FULL_ARMOR, helmet: true });
147 const result = applyHit(target, weapon, "chest", 48, from);
130148 return { target, result };
131149 };
132150
@@ -139,11 +157,11 @@ test("a stab in the back ends a 100/100 player in one; the same stab in the ches
139157 const front = stab([-40, 0, 64], 180); // same spot, they turned round
140158 expect(front.result.killed).toBe(false);
141159 expect(front.target.health).toBeGreaterThan(50);
142 expect(front.target.armor).toBeLessThan(100); // armour still took its share
160 expect(front.target.armor).toBeLessThan(FULL_ARMOR); // armour still took its share
143161 });
144162
145163 test("a hit with nowhere to have come from is never a back", () => {
146 const target = actor({ pos: [0, 0, 0], yaw: 0, armor: 100, helmet: true });
164 const target = actor({ pos: [0, 0, 0], yaw: 0, armor: FULL_ARMOR, helmet: true });
147165 expect(applyHit(target, WEAPONS.knife, "chest", 48).killed).toBe(false);
148166 });
149167
src/client/combat.ts +159 −139
@@ -2,13 +2,23 @@
22 * Hitboxes and what a bullet does when it reaches one. A body is four boxes
33 * stacked from the feet, scaled by how far the player is crouched, so a
44 * crouching head really is where a crouching head looks.
5 *
6 * What a body *is* is here too — a hundred hit points and, when it bought one,
7 * a hundred points of vest — because those two numbers are the denominator of
8 * every answer in this file: "does this kill" is a comparison against them.
9 * `src/client/world.ts` spawns on them and `src/client/economy.ts` sells them.
510 */
611
7 import { MAT, type Box } from "./map.ts";
8 import { dist, type Vec3 } from "./math.ts";
9 import { HULL } from "./physics.ts";
10 import { rayBox, traceWorld, type Hit } from "./trace.ts";
11 import { computeDamage, fromBehind, penetration, type Hitgroup, type Weapon } from "./weapons.ts";
12 import type { Box } from "./level.ts";
13 import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts";
14 import { CS_HULL } from "./movement.ts";
15 import { computeDamage, fromBehind, type Hitgroup, reachOf, type Weapon } from "./weapons.ts";
16
17 /** The health a body spawns with, and the health it is killed by losing. */
18 export const FULL_HEALTH = 100;
19
20 /** The armour a vest is worth, bought whole or not at all. */
21 export const FULL_ARMOR = 100;
1222
1323 export type Actor = {
1424 id: number;
@@ -33,7 +43,7 @@ const PARTS: [Hitgroup, number, number, number][] = [
3343 ];
3444
3545 export function hitboxes(a: Actor): Part[] {
36 const height = HULL.stand + (HULL.duck - HULL.stand) * a.duckAmount;
46 const height = CS_HULL.stand + (CS_HULL.duck - CS_HULL.stand) * a.duckAmount;
3747 return PARTS.map(([group, lo, hi, w]) => ({
3848 group,
3949 min: [a.pos[0] - w, a.pos[1] - w, a.pos[2] + height * lo] as Vec3,
@@ -41,152 +51,58 @@ export function hitboxes(a: Actor): Part[] {
4151 }));
4252 }
4353
44 /** The face a bullet went in by, when it came out the other side. */
45 export type Punch = { point: Vec3; normal: Vec3 };
46
4754 export type BulletHit =
48 | {
49 kind: "actor"; actor: Actor; group: Hitgroup; point: Vec3; distance: number;
50 /** What is left of the damage: 1, or the weapon's share through a crate. */
51 damageScale: number;
52 /** Where it entered the crate it crossed, or null if it crossed nothing. */
53 through: Punch | null;
54 }
55 | { kind: "world"; point: Vec3; normal: Vec3; distance: number; through: Punch | null }
56 | { kind: "none"; point: Vec3; through: Punch | null };
55 | { kind: "actor"; actor: Actor; group: Hitgroup; point: Vec3; distance: number }
56 | { kind: "world"; point: Vec3; normal: Vec3; distance: number }
57 | { kind: "none"; point: Vec3 };
5758
58 const at = (from: Vec3, to: Vec3, t: number): Vec3 => [
59 from[0] + (to[0] - from[0]) * t,
60 from[1] + (to[1] - from[1]) * t,
61 from[2] + (to[2] - from[2]) * t,
62 ];
63
64 /** How far past the exit face the next leg starts, so it cannot re-enter. */
65 const SKIN = 0.25;
66
67 type Leg = { body: Extract<BulletHit, { kind: "actor" }> | null; world: Hit | null };
68
69 /**
70 * One straight leg: the nearest body, and the nearest solid. `origin` is where
71 * the shot came from, which is what range falloff measures against even when
72 * this leg starts at the far side of a crate.
73 */
74 function traceLeg(
75 from: Vec3,
76 to: Vec3,
77 boxes: Box[],
78 actors: Actor[],
79 shooterId: number,
80 origin: Vec3,
81 ): Leg {
82 const world = traceWorld(from, to, boxes);
83 let bestT = world === null ? 1 : world.t;
84 let body: Leg["body"] = null;
85 for (const actor of actors) {
86 if (!actor.alive || actor.id === shooterId) continue;
87 for (const part of hitboxes(actor)) {
88 const hit = rayBox(from, to, part.min, part.max);
89 if (hit === null || hit.t >= bestT) continue;
90 bestT = hit.t;
91 const point = at(from, to, hit.t);
92 body = {
93 kind: "actor",
94 actor,
95 group: part.group,
96 point,
97 distance: dist(origin, point),
98 damageScale: 1,
99 through: null,
100 };
101 }
102 }
103 return { body, world };
104 }
105
106 /**
107 * Where a bullet leaves the solid it just entered, or null if it stays in it.
108 * Only `MAT.CRATE` is soft: a wall, a floor, a site plate and metal all stop
109 * everything. The thickness is the entry and exit fractions of the same box,
110 * so a round clipping a corner spends far less of its budget than one going
111 * through the middle of a face.
112 */
113 function punchThrough(
114 from: Vec3,
115 to: Vec3,
116 hit: Hit,
117 weapon: Weapon,
118 ): { entry: Punch; exit: Vec3 } | null {
119 const box = hit.what as Box;
120 const limit = penetration(weapon);
121 if (box.mat !== MAT.CRATE || limit.units <= 0) return null;
122 const span = rayBox(from, to, box.min, box.max);
123 if (span === null) return null;
124 const segment = dist(from, to);
125 if ((span.tExit - span.t) * segment > limit.units) return null;
126 const tOut = span.tExit + SKIN / Math.max(segment, 1);
127 if (tOut >= 1) return null; // the shot ran out of range inside the crate
128 return { entry: { point: hit.point, normal: hit.normal }, exit: at(from, to, tOut) };
129 }
130
131 /**
132 * The first thing a bullet meets: a body beats a wall only if it is nearer.
133 * A crate thin enough for the round is crossed — once. Whatever the bullet
134 * meets on the far side is what it hit, at `damageScale` of its damage, and
135 * two crates in a line stop it as surely as a wall does.
136 */
59 /** The first thing a bullet meets: a body beats a wall only if it is nearer. */
13760 export function traceBullet(
13861 from: Vec3,
13962 to: Vec3,
14063 boxes: Box[],
14164 actors: Actor[],
14265 shooterId: number,
143 weapon: Weapon,
14466 ): BulletHit {
145 const near = traceLeg(from, to, boxes, actors, shooterId, from);
146 if (near.body !== null) return near.body;
147 if (near.world === null) return { kind: "none", point: to, through: null };
148
149 const punched = punchThrough(from, to, near.world, weapon);
150 if (punched === null)
151 return {
152 kind: "world",
153 point: near.world.point,
154 normal: near.world.normal,
155 distance: dist(from, near.world.point),
156 through: null,
157 };
158
159 const { entry, exit } = punched;
160 const far = traceLeg(exit, to, boxes, actors, shooterId, from);
161 if (far.body !== null)
162 return { ...far.body, damageScale: penetration(weapon).survives, through: entry };
163 if (far.world !== null)
164 return {
165 kind: "world",
166 point: far.world.point,
167 normal: far.world.normal,
168 distance: dist(from, far.world.point),
169 through: entry,
170 };
171 return { kind: "none", point: to, through: entry };
67 const world = traceWorld(from, to, boxes);
68 let bestT = world === null ? 1 : world.t;
69 let best: BulletHit | null = null;
70 for (const actor of actors) {
71 if (!actor.alive || actor.id === shooterId) continue;
72 for (const part of hitboxes(actor)) {
73 const hit = rayBox(from, to, part.min, part.max);
74 if (hit === null || hit.t >= bestT) continue;
75 bestT = hit.t;
76 best = {
77 kind: "actor",
78 actor,
79 group: part.group,
80 point: [
81 from[0] + (to[0] - from[0]) * hit.t,
82 from[1] + (to[1] - from[1]) * hit.t,
83 from[2] + (to[2] - from[2]) * hit.t,
84 ],
85 distance: dist(from, to) * hit.t,
86 };
87 }
88 }
89 if (best !== null) return best;
90 if (world !== null)
91 return { kind: "world", point: world.point, normal: world.normal, distance: dist(from, to) * world.t };
92 return { kind: "none", point: to };
17293 }
17394
17495 /**
175 * Apply a hit and say what it cost: health off, armour off, and whether it
176 * killed. `from` is where the shot came from — the only thing the facing rule
177 * needs — and leaving it out is a hit with no direction, never a back.
96 * Take `health` off a body and `armor` off its vest, and say what it cost and
97 * whether it killed. Where the two numbers came from — a bullet's hitgroup and
98 * range, or a blast's distance — is the caller's business; what a body does
99 * about them is the same either way, and is this.
178100 */
179 export function applyHit(
101 export function applyDamage(
180102 target: Actor,
181 weapon: Weapon,
182 group: Hitgroup,
183 distance: number,
184 damageScale = 1,
185 from: Vec3 | null = null,
186 ): { damage: number; killed: boolean; headshot: boolean } {
187 const behind = from !== null && fromBehind(target.yaw, target.pos, from);
188 const { health, armor } =
189 computeDamage(weapon, distance, group, target.armor, target.helmet, damageScale, behind);
103 health: number,
104 armor: number,
105 ): { damage: number; killed: boolean } {
190106 const dealt = Math.min(health, target.health);
191107 target.armor = Math.max(0, target.armor - armor);
192108 target.health -= health;
@@ -195,5 +111,109 @@ export function applyHit(
195111 target.health = 0;
196112 target.alive = false;
197113 }
198 return { damage: dealt, killed, headshot: group === "head" };
114 return { damage: dealt, killed };
115 }
116
117 /**
118 * Apply a hit and say what it cost: health off, armour off, and whether it
119 * killed. `from` is where the shot came from — the only thing the facing rule
120 * needs — and leaving it out is a hit with no direction, never a back. It is
121 * read off the body that fired and not off the muzzle, the same way the damage
122 * arrow in `src/client/world.ts` reads a bearing off a person.
123 */
124 export function applyHit(
125 target: Actor,
126 weapon: Weapon,
127 group: Hitgroup,
128 distance: number,
129 from: Vec3 | null = null,
130 ): { damage: number; killed: boolean; headshot: boolean } {
131 const behind = from !== null && fromBehind(target.yaw, target.pos, from);
132 const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet, behind);
133 return { ...applyDamage(target, health, armor), headshot: group === "head" };
134 }
135
136 /**
137 * A body with nothing yet wrong with it: full health, the vest it was asked
138 * for, standing still. It is what a question about a weapon is asked against —
139 * `shotCost` below fires into one — so anything that wants to know what a gun
140 * does to a person shoots the same body the game does rather than inventing
141 * one with numbers of its own.
142 */
143 export const wholeBody = (armor: number, helmet: boolean): Actor => ({
144 id: -1, team: "t", pos: [0, 0, 0], yaw: 0, duckAmount: 0,
145 health: FULL_HEALTH, armor, helmet, alive: true,
146 });
147
148 /**
149 * What one shot costs a whole body, and how many of them that body takes.
150 *
151 * `health` and `armor` are the first shot's, uncapped by what is left — a
152 * Deagle that takes 110 off a 100-point body says 110, because the question
153 * "how much headroom does it have at range" is the one being asked of it.
154 * `shots` is not arithmetic on that number: the shots are *fired*, through
155 * `applyHit` above, into `wholeBody`, so a vest wearing down between them is
156 * counted the way the fight counts it. It is null when the body never falls —
157 * a grenade in this table has no bullet, and a shot that rounds to nothing off
158 * a leg at the far end of its range would stand there all day.
159 */
160 export type ShotCost = { health: number; armor: number; shots: number | null };
161
162 /** The most shots worth firing before a body that will not fall is called immortal. */
163 const NEVER = 64;
164
165 export function shotCost(
166 weapon: Weapon,
167 group: Hitgroup,
168 distance: number,
169 armor = 0,
170 helmet = false,
171 ): ShotCost {
172 const target = wholeBody(armor, helmet);
173 const first = computeDamage(weapon, distance, group, target.armor, target.helmet);
174 let shots = 0;
175 while (target.alive && shots < NEVER) {
176 applyHit(target, weapon, group, distance);
177 shots += 1;
178 }
179 return { ...first, shots: target.alive ? null : shots };
180 }
181
182 /**
183 * The farthest distance one shot to `group` still kills a whole body from, in
184 * units, or null when it does not kill even with the muzzle against them.
185 *
186 * Damage falls with distance and never rises — `computeDamage` multiplies by
187 * `rangeModifier^(d/500)`, and that is monotone — so the answer is a binary
188 * search between the muzzle and the end of what the weapon reaches, about
189 * thirteen calls to `shotCost` and exact to the unit. Rounding makes the edge a
190 * step rather than a slope, which is why it is searched rather than solved: the
191 * distance handed back is one the simulation itself would kill from.
192 *
193 * `reachOf` is the ceiling, so a knife's answer is inside its 64 units and a
194 * gun's is inside the flight of a bullet: nothing here claims a kill at a
195 * distance the weapon cannot reach.
196 */
197 export function killRange(
198 weapon: Weapon,
199 group: Hitgroup,
200 armor = 0,
201 helmet = false,
202 ): number | null {
203 // One shot into a whole body, and whether it got up: `applyHit` answers that
204 // directly, so the search asks the fight the same question thirteen times
205 // rather than firing a magazine into a corpse to learn the first shot killed.
206 const kills = (distance: number): boolean =>
207 applyHit(wholeBody(armor, helmet), weapon, group, distance).killed;
208 const reach = reachOf(weapon);
209 if (!kills(0)) return null;
210 if (kills(reach)) return reach;
211 let near = 0;
212 let far = reach;
213 while (far - near > 1) {
214 const mid = Math.floor((near + far) / 2);
215 if (kills(mid)) near = mid;
216 else far = mid;
217 }
218 return near;
199219 }
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/crosshair.test.ts deleted −90
@@ -1,90 +0,0 @@
1 /**
2 * The crosshair's shape, without a canvas: four arms, where they start and
3 * where they end. The one thing that has to stay true is the default — the
4 * crosshair this game has always drawn — and the one thing a setting has to
5 * do is move it.
6 */
7
8 import { expect, test } from "bun:test";
9 import {
10 CROSSHAIR_COLOURS, CROSSHAIR_COLOUR_NAMES, crosshairShape, DEFAULT_CROSSHAIR, SPREAD_GAP,
11 type CrosshairStyle,
12 } from "./crosshair.ts";
13
14 const style = (over: Partial<CrosshairStyle> = {}): CrosshairStyle => ({ ...DEFAULT_CROSSHAIR, ...over });
15
16 /** How far the near end of the right-hand arm sits from the middle. */
17 const innerRight = (shape: { segments: { x1: number }[] }): number => shape.segments[0].x1 - 100;
18 /** How long the right-hand arm is. */
19 const armLength = (shape: { segments: { x1: number; x2: number }[] }): number =>
20 shape.segments[0].x2 - shape.segments[0].x1;
21
22 test("the default is today's crosshair: a gap of four, arms of seven, two wide, no dot", () => {
23 const shape = crosshairShape(style(), 0, 100, 100);
24 expect(shape.colour).toBe("rgba(120, 255, 140, 0.9)");
25 expect(shape.thickness).toBe(2);
26 expect(shape.dot).toBeNull();
27 expect(shape.segments).toEqual([
28 { x1: 104, y1: 100, x2: 111, y2: 100 },
29 { x1: 96, y1: 100, x2: 89, y2: 100 },
30 { x1: 100, y1: 104, x2: 100, y2: 111 },
31 { x1: 100, y1: 96, x2: 100, y2: 89 },
32 ]);
33 });
34
35 test("a larger gap moves the arms out without making them longer", () => {
36 const near = crosshairShape(style({ gap: 4 }), 0, 100, 100);
37 const far = crosshairShape(style({ gap: 12 }), 0, 100, 100);
38 expect(innerRight(far)).toBe(innerRight(near) + 8);
39 expect(armLength(far)).toBe(armLength(near));
40 // Every arm moves, not just the one: up is eight further up.
41 expect(far.segments[3].y1).toBe(near.segments[3].y1 - 8);
42 });
43
44 test("a larger size makes the arms longer without moving them out", () => {
45 const small = crosshairShape(style({ size: 7 }), 0, 100, 100);
46 const big = crosshairShape(style({ size: 16 }), 0, 100, 100);
47 expect(armLength(big)).toBe(16);
48 expect(innerRight(big)).toBe(innerRight(small));
49 });
50
51 test("a dynamic crosshair opens with the spread, a static one never moves", () => {
52 const still = crosshairShape(style(), 0, 100, 100);
53 const spraying = crosshairShape(style(), 1.5, 100, 100);
54 expect(innerRight(spraying)).toBeCloseTo(innerRight(still) + 1.5 * SPREAD_GAP, 10);
55 expect(armLength(spraying)).toBe(armLength(still));
56
57 const fixed = crosshairShape(style({ dynamic: false }), 1.5, 100, 100);
58 expect(fixed.segments).toEqual(still.segments);
59 });
60
61 test("a spread that is not a number never reaches the arms", () => {
62 const still = crosshairShape(style(), 0, 100, 100);
63 for (const spread of [NaN, Infinity, -3]) {
64 expect(crosshairShape(style(), spread, 100, 100).segments).toEqual(still.segments);
65 }
66 });
67
68 test("the dot is there only when it is asked for, and it is as wide as the arms", () => {
69 expect(crosshairShape(style({ dot: false }), 0, 100, 100).dot).toBeNull();
70 expect(crosshairShape(style({ dot: true, thickness: 4 }), 0, 100, 100).dot)
71 .toEqual({ x: 100, y: 100, radius: 2 });
72 // The dot sits in the middle whatever the gap does.
73 const wide = crosshairShape(style({ dot: true, gap: 18 }), 2, 100, 100);
74 expect(wide.dot).toEqual({ x: 100, y: 100, radius: 1 });
75 });
76
77 test("the crosshair is drawn where it is told, not at some fixed middle", () => {
78 const shape = crosshairShape(style(), 0, 640, 360);
79 expect(shape.segments[0]).toEqual({ x1: 644, y1: 360, x2: 651, y2: 360 });
80 expect(shape.segments[2]).toEqual({ x1: 640, y1: 364, x2: 640, y2: 371 });
81 });
82
83 test("every colour name resolves to a colour, and the names are the table's own keys", () => {
84 for (const name of CROSSHAIR_COLOUR_NAMES) {
85 expect(crosshairShape(style({ colour: name }), 0, 100, 100).colour).toBe(CROSSHAIR_COLOURS[name]);
86 expect(CROSSHAIR_COLOURS[name]).toMatch(/^rgba\(/);
87 }
88 expect(CROSSHAIR_COLOUR_NAMES).toContain("green");
89 expect(CROSSHAIR_COLOUR_NAMES.length).toBeGreaterThan(3);
90 });
src/client/crosshair.ts deleted −108
@@ -1,108 +0,0 @@
1 /**
2 * The crosshair: a style and the gun's current inaccuracy in, the lines and
3 * the dot to draw out. Pure — no canvas, no world, no settings — so what a
4 * gap of six or a static crosshair actually does to the shape is a test and
5 * not a screenshot. `hud.ts` strokes whatever comes back; `settings.ts` is
6 * where a style is validated before it ever reaches here.
7 *
8 * Colour is a name, never free text: a string that ends up in
9 * `ctx.strokeStyle` is an input like any other, so the only strings that can
10 * get there are the ones written down in this file.
11 */
12
13 /** The colours a crosshair can be, and the only strings that reach the canvas. */
14 export const CROSSHAIR_COLOURS = {
15 // The default is today's crosshair exactly, to the last hundredth of alpha.
16 green: "rgba(120, 255, 140, 0.9)",
17 white: "rgba(245, 247, 250, 0.9)",
18 cyan: "rgba(96, 226, 255, 0.9)",
19 yellow: "rgba(255, 220, 96, 0.9)",
20 magenta: "rgba(255, 110, 224, 0.9)",
21 red: "rgba(255, 82, 72, 0.9)",
22 } as const;
23
24 export type CrosshairColour = keyof typeof CROSSHAIR_COLOURS;
25
26 export const CROSSHAIR_COLOUR_NAMES = Object.keys(CROSSHAIR_COLOURS) as CrosshairColour[];
27
28 /**
29 * The three numbers, with the range each one is allowed and the value it
30 * falls back to. The page renders its `min`, `max` and `step` from here and
31 * `parseSettings` clamps to the same pair, so a field and the parser can
32 * never disagree about what is a legal crosshair.
33 */
34 export const CROSSHAIR_LIMITS = {
35 size: { min: 1, max: 20, fallback: 7 },
36 gap: { min: 0, max: 20, fallback: 4 },
37 thickness: { min: 1, max: 6, fallback: 2 },
38 } as const;
39
40 export type CrosshairStyle = {
41 colour: CrosshairColour;
42 /** How long each arm is, in pixels. */
43 size: number;
44 /** How far the arms start from the middle before any spread is added. */
45 gap: number;
46 /** The stroke width of the arms, and the diameter of the dot. */
47 thickness: number;
48 dot: boolean;
49 /** Does the gap open with the gun's inaccuracy, or is it the same always? */
50 dynamic: boolean;
51 };
52
53 export const DEFAULT_CROSSHAIR: CrosshairStyle = {
54 colour: "green",
55 size: CROSSHAIR_LIMITS.size.fallback,
56 gap: CROSSHAIR_LIMITS.gap.fallback,
57 thickness: CROSSHAIR_LIMITS.thickness.fallback,
58 dot: false,
59 dynamic: true,
60 };
61
62 /**
63 * How many pixels a unit of `inaccuracy()` pushes the arms out by. Today's
64 * law, kept: the gap was `4 + spread * 14`, and the 4 is now the setting.
65 */
66 export const SPREAD_GAP = 14;
67
68 export type Segment = { x1: number; y1: number; x2: number; y2: number };
69
70 export type CrosshairShape = {
71 /** The CSS colour, resolved from the name — what goes into the context. */
72 colour: string;
73 thickness: number;
74 /** The four arms, in right, left, down, up order. */
75 segments: Segment[];
76 dot: { x: number; y: number; radius: number } | null;
77 };
78
79 /** The four directions an arm goes, in the order they are drawn. */
80 const ARMS: readonly [number, number][] = [[1, 0], [-1, 0], [0, 1], [0, -1]];
81
82 /**
83 * The crosshair at `(cx, cy)` for a gun that is `spread` inaccurate. A
84 * dynamic crosshair adds the spread to its gap — which is how this game
85 * teaches you to stop before you shoot — and a static one ignores it
86 * entirely. A spread that is not a finite, positive number is no spread.
87 */
88 export function crosshairShape(
89 style: CrosshairStyle,
90 spread: number,
91 cx: number,
92 cy: number,
93 ): CrosshairShape {
94 const open = style.dynamic && Number.isFinite(spread) && spread > 0 ? spread * SPREAD_GAP : 0;
95 const gap = style.gap + open;
96 const segments = ARMS.map(([dx, dy]) => ({
97 x1: cx + dx * gap,
98 y1: cy + dy * gap,
99 x2: cx + dx * (gap + style.size),
100 y2: cy + dy * (gap + style.size),
101 }));
102 return {
103 colour: CROSSHAIR_COLOURS[style.colour],
104 thickness: style.thickness,
105 segments,
106 dot: style.dot ? { x: cx, y: cy, radius: style.thickness / 2 } : null,
107 };
108 }
src/client/cues.test.ts deleted −58
@@ -1,58 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { beepInterval, beepPitch, stepBeep } from "./cues.ts";
3
4 const BOMB_TIME = 40; // COMPETITIVE.bombTime and SHORT.bombTime alike
5
6 test("the first beep lands right after the plant", () => {
7 const dt = 1 / 64;
8 let sinceLast = 0;
9 let elapsed = 0;
10 let pitch: number | null = null;
11 while (pitch === null && elapsed < 5) {
12 const step = stepBeep(sinceLast, BOMB_TIME, BOMB_TIME, dt);
13 sinceLast = step.sinceLast;
14 pitch = step.pitch;
15 elapsed += dt;
16 }
17 expect(pitch).not.toBeNull();
18 expect(elapsed).toBeGreaterThan(0.8);
19 expect(elapsed).toBeLessThan(1.2);
20 });
21
22 test("intervals are monotonically shorter as the fuse runs down", () => {
23 const points = [40, 30, 20, 10, 5, 1, 0];
24 for (let i = 1; i < points.length; i++) {
25 expect(beepInterval(points[i], BOMB_TIME)).toBeLessThanOrEqual(beepInterval(points[i - 1], BOMB_TIME));
26 }
27 expect(beepInterval(0, BOMB_TIME)).toBeLessThan(beepInterval(40, BOMB_TIME));
28 });
29
30 test("the pitch rises as the fuse runs down", () => {
31 const points = [40, 30, 20, 10, 5, 1, 0];
32 for (let i = 1; i < points.length; i++) {
33 expect(beepPitch(points[i], BOMB_TIME)).toBeGreaterThanOrEqual(beepPitch(points[i - 1], BOMB_TIME));
34 }
35 expect(beepPitch(0, BOMB_TIME)).toBeGreaterThan(beepPitch(40, BOMB_TIME));
36 });
37
38 test("no beep when nothing is planted", () => {
39 const step = stepBeep(0.99, null, BOMB_TIME, 1);
40 expect(step.pitch).toBeNull();
41 expect(step.sinceLast).toBe(0);
42 });
43
44 test("the rate is bounded however small dt is", () => {
45 const duration = 1;
46 const secondsLeft = 0; // the fastest cadence there is
47 const dt = 1 / 20000;
48 let sinceLast = 0;
49 let beeps = 0;
50 for (let t = 0; t < duration; t += dt) {
51 const step = stepBeep(sinceLast, secondsLeft, BOMB_TIME, dt);
52 sinceLast = step.sinceLast;
53 if (step.pitch !== null) beeps += 1;
54 }
55 const interval = beepInterval(secondsLeft, BOMB_TIME);
56 expect(beeps).toBeGreaterThan(0);
57 expect(beeps).toBeLessThanOrEqual(Math.ceil(duration / interval) + 1);
58 });
src/client/cues.ts deleted −42
@@ -1,42 +0,0 @@
1 /**
2 * When a planted bomb beeps: pure arithmetic over the seconds left on the
3 * fuse and how long it has been since the last beep. No audio, no world —
4 * `world.ts` calls this to decide whether to push a sound this tick, pitch
5 * and all, so `main.ts` only ever plays what it is told.
6 */
7
8 import { clamp } from "./math.ts";
9
10 const MAX_INTERVAL = 1; // about a second apart just after the plant
11 const MIN_INTERVAL = 0.2; // a few a second in the last moments
12 const MIN_PITCH = 500;
13 const MAX_PITCH = 1200;
14
15 /** Seconds between beeps with this many seconds left on a `bombTime`-second fuse. */
16 export function beepInterval(secondsLeft: number, bombTime: number): number {
17 const t = clamp(secondsLeft / bombTime, 0, 1);
18 return MIN_INTERVAL + (MAX_INTERVAL - MIN_INTERVAL) * t;
19 }
20
21 /** The beep's pitch with this many seconds left: rising as it quickens. */
22 export function beepPitch(secondsLeft: number, bombTime: number): number {
23 const t = clamp(secondsLeft / bombTime, 0, 1);
24 return MAX_PITCH - (MAX_PITCH - MIN_PITCH) * t;
25 }
26
27 export type BeepStep = { sinceLast: number; pitch: number | null };
28
29 /**
30 * Advance the beep clock by `dt`. `secondsLeft` is null when nothing is
31 * planted, which silences it and resets the clock so the next plant starts
32 * clean. However small `dt` is, this fires at most the whole intervals it
33 * actually crossed and carries only the remainder forward — an accumulator,
34 * the same shape `footsteps` in `world.ts` already uses for distance.
35 */
36 export function stepBeep(sinceLast: number, secondsLeft: number | null, bombTime: number, dt: number): BeepStep {
37 if (secondsLeft === null) return { sinceLast: 0, pitch: null };
38 const next = sinceLast + dt;
39 const interval = beepInterval(secondsLeft, bombTime);
40 if (next < interval) return { sinceLast: next, pitch: null };
41 return { sinceLast: next - interval, pitch: beepPitch(secondsLeft, bombTime) };
42 }
src/client/decals.test.ts deleted −125
@@ -1,125 +0,0 @@
1 /**
2 * The decal ring on its own: no GL, just the vertices it writes and the
3 * colours it ages them into. What matters here is that the ring is a ring —
4 * fixed, wrapping, replacing its oldest — that a mark lies in the surface it
5 * was given, facing out of it, and that the fade is a function of the clock.
6 */
7
8 import { expect, test } from "bun:test";
9 import {
10 clear, decals, mark, shade,
11 DECAL_CAPACITY, DECAL_FLOATS, DECAL_LIFE, DECAL_LIFT, DECAL_RADIUS, DECAL_VERTICES,
12 } from "./decals.ts";
13 import type { Vec3 } from "./math.ts";
14
15 /** Vertex `v` of hole `slot`, as position then colour. */
16 const vertex = (data: Float32Array, slot: number, v: number): number[] =>
17 Array.from(data.subarray((slot * DECAL_VERTICES + v) * 6, (slot * DECAL_VERTICES + v) * 6 + 6));
18
19 test("a mark lies in the surface it was given, lifted off it and no wider than it says", () => {
20 const d = decals();
21 const at: Vec3 = [100, 200, 40];
22 const normal: Vec3 = [0, -1, 0]; // a wall facing south
23 expect(mark(d, at, normal, 1)).toBe(true);
24 shade(d, 1);
25
26 for (let v = 0; v < DECAL_VERTICES; v += 1) {
27 const p = vertex(d.data, 0, v);
28 // Every vertex is in one plane, half a unit out along the normal: a mark
29 // in the wall z-fights with it and one off to the side is not the hit.
30 expect(p[1]).toBeCloseTo(200 - DECAL_LIFT, 4);
31 expect(Math.hypot((p[0] as number) - 100, (p[2] as number) - 40))
32 .toBeLessThanOrEqual(DECAL_RADIUS + 1e-4);
33 }
34 // The fan is wound out of the wall: the cull keeps the face you can see, and
35 // a mark wound the other way would be a hole you could only see from inside.
36 for (let t = 0; t < DECAL_VERTICES / 3; t += 1) {
37 const c = vertex(d.data, 0, t * 3), a = vertex(d.data, 0, t * 3 + 1), b = vertex(d.data, 0, t * 3 + 2);
38 const ax = (a[0] as number) - (c[0] as number), ay = (a[1] as number) - (c[1] as number), az = (a[2] as number) - (c[2] as number);
39 const bx = (b[0] as number) - (c[0] as number), by = (b[1] as number) - (c[1] as number), bz = (b[2] as number) - (c[2] as number);
40 const face: Vec3 = [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx];
41 expect(face[0] * normal[0] + face[1] * normal[1] + face[2] * normal[2]).toBeGreaterThan(0);
42 }
43 });
44
45 test("a floor and a wall both get a frame that is not degenerate", () => {
46 const d = decals();
47 for (const normal of [[0, 0, 1], [0, 0, -1], [1, 0, 0], [0, 1, 0]] as Vec3[]) {
48 const fresh = decals();
49 expect(mark(fresh, [0, 0, 0], normal, 0)).toBe(true);
50 for (let v = 0; v < DECAL_VERTICES; v += 1) {
51 const p = vertex(fresh.data, 0, v);
52 expect(Number.isFinite(p[0] as number)).toBe(true);
53 expect(Math.hypot(p[0] as number, p[1] as number, p[2] as number)).toBeGreaterThan(0);
54 }
55 }
56 // And nothing that is not a surface is marked at all: a zero normal has no
57 // plane to lie in, and `NaN` in a vertex buffer is a triangle across the sky.
58 expect(mark(d, [0, 0, 0], [0, 0, 0], 0)).toBe(false);
59 expect(mark(d, [NaN, 0, 0], [0, 0, 1], 0)).toBe(false);
60 expect(d.used).toBe(0);
61 });
62
63 test("the ring is a ring: the oldest hole is the one that goes", () => {
64 const d = decals();
65 expect(d.data.length).toBe(DECAL_FLOATS);
66 for (let i = 0; i < DECAL_CAPACITY + 3; i += 1) mark(d, [i, 0, 0], [0, 0, 1], i);
67 expect(d.used).toBe(DECAL_CAPACITY);
68 expect(shade(d, DECAL_CAPACITY)).toBe(DECAL_CAPACITY * DECAL_VERTICES);
69
70 // The three oldest were overwritten in place, and the buffer is the size it
71 // was when it was made: nothing here grows with the round.
72 const xs = [];
73 for (let slot = 0; slot < DECAL_CAPACITY; slot += 1) xs.push(vertex(d.data, slot, 0)[0] as number);
74 xs.sort((a, b) => a - b);
75 expect(xs[0]).toBe(3);
76 expect(xs[DECAL_CAPACITY - 1]).toBe(DECAL_CAPACITY + 2);
77 expect(d.data.length).toBe(DECAL_FLOATS);
78 });
79
80 test("the fade is the clock's, and a spent hole is drawn by nothing", () => {
81 const d = decals();
82 mark(d, [0, 0, 0], [0, 0, 1], 10);
83 const centre = (): number => {
84 shade(d, now);
85 return vertex(d.data, 0, 0)[3] as number;
86 };
87 let now = 10;
88 const fresh = centre();
89 expect(fresh).toBeLessThan(0.5); // dark: the wall under it is a quarter of itself
90
91 // Held through most of its life, then on its way back to the wall as it was.
92 now = 10 + DECAL_LIFE * 0.5;
93 expect(centre()).toBe(fresh);
94 now = 10 + DECAL_LIFE * 0.9;
95 expect(centre()).toBeGreaterThan(fresh);
96 expect(centre()).toBeLessThan(1);
97
98 // Past its life there is nothing to draw: not a faint one, none.
99 now = 10 + DECAL_LIFE;
100 expect(shade(d, now)).toBe(0);
101 expect(centre()).toBe(1); // white, multiplied, is the wall untouched
102
103 // A hole made after it is drawn alongside it, and the spent slot costs a
104 // triangle the wall does not notice rather than a second draw to skip it.
105 mark(d, [50, 0, 0], [0, 0, 1], now);
106 expect(shade(d, now)).toBe(2 * DECAL_VERTICES);
107 });
108
109 test("clearing takes the holes and the memory of the impacts that made them", () => {
110 const d = decals();
111 for (let i = 0; i < 5; i += 1) mark(d, [i, 0, 0], [0, 0, 1], i);
112 d.taken = 4;
113 expect(shade(d, 4)).toBeGreaterThan(0);
114
115 clear(d, 10);
116 expect(d.used).toBe(0);
117 expect(d.next).toBe(0);
118 expect(shade(d, 10)).toBe(0);
119 // `since` is what the next round starts on: an impact from the round that
120 // just ended is still in the effect list for two seconds, and it is not a
121 // mark on this round's walls.
122 expect(d.taken).toBe(10);
123 clear(d);
124 expect(d.taken).toBe(-Infinity);
125 });
src/client/decals.ts deleted −171
@@ -1,171 +0,0 @@
1 /**
2 * Bullet holes: a fixed ring of marks on the level, drawn in one call.
3 *
4 * A spark says a round landed somewhere a quarter of a second ago. A hole says
5 * it landed *there*, and it is still saying it when you come back round the
6 * corner. That is the whole of this file: where a shot hit the level, which way
7 * the surface faced, and when — as vertices, ready for the buffer.
8 *
9 * `DECAL_CAPACITY` slots are allocated once, at start-up, and the `next` one is
10 * overwritten when the ring is full: the oldest hole in the round is the one
11 * that goes. Sixty-four of them is two full AK magazines of evidence, 1152
12 * vertices and 27 KB of buffer — a corner someone has been spraying reads as
13 * sprayed, and a round that fires five hundred shots costs exactly the same as
14 * one that fires sixty-five. Nothing here allocates after `decals()`: a shot
15 * writes over a slot's vertices in place and a frame rewrites six floats of it.
16 *
17 * The colours are the whole of the fade, and the reason this needs no texture,
18 * no second program and no alpha channel. Each mark is a fan of `DECAL_SPOKES`
19 * triangles about a dark centre, drawn multiplied into what is already on the
20 * wall (`DST_COLOR, ZERO`), so a vertex colour of 1 changes nothing at all: the
21 * rim of every mark is white and invisible, the centre darkens the wall under
22 * it, and as a hole ages its centre walks back up to white. Two attributes, a
23 * position and a colour — which is exactly what the line program already takes.
24 */
25
26 import type { Vec3 } from "./math.ts";
27
28 /** How many holes a round may show at once. The oldest is replaced. */
29 export const DECAL_CAPACITY = 64;
30 /** Triangles in the fan: six is round enough at the size a hole is drawn. */
31 export const DECAL_SPOKES = 6;
32 /** Seconds a hole lasts, of which the last `DECAL_FADE` of it is the fading. */
33 export const DECAL_LIFE = 25;
34 const DECAL_FADE = 0.35;
35 /** How wide a mark is, in world units — a player is 72 tall. */
36 export const DECAL_RADIUS = 3.4;
37 /**
38 * How far off the surface the mark sits. The depth buffer's precision at three
39 * thousand units out, with this projection's near plane, is under a tenth of a
40 * unit, so half a unit is clear of z-fighting everywhere the eye can see a wall
41 * and close enough that nothing reads as floating.
42 */
43 export const DECAL_LIFT = 0.5;
44 /** How dark the centre of a fresh hole leaves the wall it is multiplied into. */
45 const DECAL_DARK = 0.24;
46
47 export const DECAL_VERTICES = DECAL_SPOKES * 3;
48 const FLOATS_PER_VERTEX = 6; // x, y, z, r, g, b — the line program's two attributes
49 const FLOATS_PER_DECAL = DECAL_VERTICES * FLOATS_PER_VERTEX;
50 /** The size of the ring's buffer, in floats: what the renderer allocates once. */
51 export const DECAL_FLOATS = DECAL_CAPACITY * FLOATS_PER_DECAL;
52
53 export type Decals = {
54 /** The vertices themselves, interleaved the way the line buffer is. */
55 data: Float32Array;
56 /** When each slot was marked; `-Infinity` for one that never has been. */
57 born: Float32Array;
58 /** The slot the next hole takes. */
59 next: number;
60 /** How many slots have ever been filled: what is worth drawing and no more. */
61 used: number;
62 /**
63 * The newest impact already turned into a hole. An impact effect lives two
64 * seconds and a frame sees it a hundred and twenty times; this is what makes
65 * it one hole rather than a hundred and twenty.
66 */
67 taken: number;
68 };
69
70 export function decals(): Decals {
71 const born = new Float32Array(DECAL_CAPACITY);
72 born.fill(-Infinity);
73 return { data: new Float32Array(DECAL_FLOATS), born, next: 0, used: 0, taken: -Infinity };
74 }
75
76 /**
77 * A fresh round starts on clean walls — and nothing is freed doing it.
78 *
79 * `since` is the clock the ring starts again on: an effect list holds two
80 * seconds of impacts, and the last round's are not marks on this round's walls.
81 */
82 export function clear(d: Decals, since = -Infinity): void {
83 d.born.fill(-Infinity);
84 d.next = 0;
85 d.used = 0;
86 d.taken = since;
87 }
88
89 const TAU = Math.PI * 2;
90
91 /**
92 * Put a hole at `pos`, lying in the surface whose outward normal is `normal`.
93 * A normal that is not a direction and a position that is not a number are the
94 * two ways this can be asked for something it cannot draw, and both are refused
95 * here rather than written into the buffer as `NaN`.
96 */
97 export function mark(d: Decals, pos: Vec3, normal: Vec3, at: number): boolean {
98 const len = Math.hypot(normal[0], normal[1], normal[2]);
99 if (!(len > 1e-6) || !Number.isFinite(pos[0] + pos[1] + pos[2]) || !Number.isFinite(at)) return false;
100 const nx = normal[0] / len, ny = normal[1] / len, nz = normal[2] / len;
101
102 // Two axes across the surface. The one the normal is least aligned with is
103 // crossed into it, so a floor and a wall both get a frame that is not
104 // degenerate, and `t × b = n` keeps the fan wound outwards for the cull.
105 const flat = Math.abs(nz) > 0.9;
106 let tx = flat ? 0 : -ny, ty = flat ? -nz : nx, tz = flat ? ny : 0;
107 const tl = Math.hypot(tx, ty, tz);
108 tx /= tl; ty /= tl; tz /= tl;
109 const bx = ny * tz - nz * ty, by = nz * tx - nx * tz, bz = nx * ty - ny * tx;
110
111 const slot = d.next;
112 d.next = (d.next + 1) % DECAL_CAPACITY;
113 if (d.used < DECAL_CAPACITY) d.used += 1;
114 d.born[slot] = at;
115
116 const cx = pos[0] + nx * DECAL_LIFT, cy = pos[1] + ny * DECAL_LIFT, cz = pos[2] + nz * DECAL_LIFT;
117 // The fan is spun by the shot's own timestamp: two holes side by side are
118 // not the same hexagon, and no frame asks `Math.random`.
119 const spin = at * 2.3;
120 const data = d.data;
121 let o = slot * FLOATS_PER_DECAL;
122 for (let s = 0; s < DECAL_SPOKES; s += 1) {
123 // The centre. Its colour is the fade, written every frame by `shade`.
124 data[o] = cx; data[o + 1] = cy; data[o + 2] = cz;
125 o += FLOATS_PER_VERTEX;
126 // The two rim corners of this triangle, in order: a `for … of` over a pair
127 // would be one array allocated per spoke per shot, which is the one thing
128 // a ring allocated once is for.
129 for (let k = 0; k < 2; k += 1) {
130 const a = spin + ((s + k) / DECAL_SPOKES) * TAU;
131 const ca = Math.cos(a) * DECAL_RADIUS, sa = Math.sin(a) * DECAL_RADIUS;
132 data[o] = cx + tx * ca + bx * sa;
133 data[o + 1] = cy + ty * ca + by * sa;
134 data[o + 2] = cz + tz * ca + bz * sa;
135 // The rim changes nothing: white, multiplied, is the wall as it was.
136 data[o + 3] = 1; data[o + 4] = 1; data[o + 5] = 1;
137 o += FLOATS_PER_VERTEX;
138 }
139 }
140 return true;
141 }
142
143 /**
144 * Age every hole into its centre's colour, and say how many vertices are worth
145 * drawing: `0` when the ring is empty or everything in it has faded out, so a
146 * round nobody has fired in pays no draw and no upload at all.
147 *
148 * A slot that has faded inside a ring that has not is written white, which is
149 * a triangle the wall does not notice — the alternative is a second draw to
150 * skip it with, and one draw is the point.
151 */
152 export function shade(d: Decals, time: number): number {
153 let live = false;
154 const data = d.data;
155 for (let slot = 0; slot < d.used; slot += 1) {
156 // A float32 stamp read back against a float64 clock can be a shade in the
157 // future, so an age at or below zero is a hole that has just been made and
158 // not one to hide: only the far end of the life turns a mark off.
159 const age = time - (d.born[slot] as number);
160 const left = (DECAL_LIFE - age) / (DECAL_LIFE * DECAL_FADE);
161 const strength = age >= DECAL_LIFE ? 0 : Math.min(1, left);
162 if (strength > 0) live = true;
163 const dark = 1 - (1 - DECAL_DARK) * strength;
164 let o = slot * FLOATS_PER_DECAL + 3;
165 for (let s = 0; s < DECAL_SPOKES; s += 1) {
166 data[o] = dark; data[o + 1] = dark; data[o + 2] = dark;
167 o += FLOATS_PER_VERTEX * 3; // past this triangle's two rim corners
168 }
169 }
170 return live ? d.used * DECAL_VERTICES : 0;
171 }
src/client/ears.test.ts deleted −34
@@ -1,34 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { ears } from "./ears.ts";
3
4 test("a source dead ahead pans centre", () => {
5 const { pan } = ears([100, 0, 0], [0, 0, 0], 0);
6 expect(pan).toBeCloseTo(0, 6);
7 });
8
9 test("a source 90 degrees to the left pans left, and to the right pans right", () => {
10 // Yaw grows anticlockwise (math.ts), so turning +90 from facing +x faces +y,
11 // which puts +y on the listener's left.
12 const left = ears([0, 100, 0], [0, 0, 0], 0);
13 expect(left.pan).toBeLessThan(0);
14 const right = ears([0, -100, 0], [0, 0, 0], 0);
15 expect(right.pan).toBeGreaterThan(0);
16 });
17
18 test("a source directly behind is roughly centred and no louder than the same distance ahead", () => {
19 const ahead = ears([100, 0, 0], [0, 0, 0], 0);
20 const behind = ears([-100, 0, 0], [0, 0, 0], 0);
21 expect(Math.abs(behind.pan)).toBeLessThan(0.05);
22 expect(behind.volume).toBeLessThanOrEqual(ahead.volume + 1e-9);
23 });
24
25 test("volume falls with distance, never exceeds 1, and never divides by zero at zero distance", () => {
26 const near = ears([50, 0, 0], [0, 0, 0], 0);
27 const far = ears([2000, 0, 0], [0, 0, 0], 0);
28 expect(near.volume).toBeLessThanOrEqual(1);
29 expect(far.volume).toBeLessThan(near.volume);
30 expect(far.volume).toBeGreaterThan(0);
31 const here = ears([0, 0, 0], [0, 0, 0], 0);
32 expect(Number.isFinite(here.volume)).toBe(true);
33 expect(here.volume).toBeLessThanOrEqual(1);
34 });
src/client/ears.ts deleted −26
@@ -1,26 +0,0 @@
1 /**
2 * Where a sound sits around the listener, as plain data — no Web Audio here,
3 * so it is tested headlessly. `pos` and `listener` are world positions; `yaw`
4 * is the listener's view yaw in degrees, the same convention `math.ts` uses.
5 */
6
7 import { angleDelta, dist2d, type Vec3 } from "./math.ts";
8
9 export type Ears = { pan: number; volume: number };
10
11 /** The same falloff `audio.shot` already used: full volume inside 120 units, gone by 900. */
12 const REFERENCE_DISTANCE = 900;
13 const MIN_DISTANCE = 120;
14
15 /** How a source at `pos` sounds to someone at `listener` looking along `yaw`. */
16 export function ears(pos: Vec3, listener: Vec3, yaw: number): Ears {
17 const bearing = (Math.atan2(pos[1] - listener[1], pos[0] - listener[0]) * 180) / Math.PI;
18 // Yaw grows anticlockwise (math.ts), so a source to the listener's left sits
19 // at a positive angle off the view; a StereoPannerNode's -1 is the left
20 // channel, so the sign is flipped to get there.
21 const rel = (angleDelta(yaw, bearing) * Math.PI) / 180;
22 const pan = -Math.sin(rel);
23 const d = dist2d(pos, listener);
24 const volume = Math.min(1, REFERENCE_DISTANCE / Math.max(MIN_DISTANCE, d));
25 return { pan, volume };
26 }
src/client/earshot.test.ts deleted −87
@@ -1,87 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { cuesHeard, EMPTY_EARSHOT, FOOTSTEP_EARSHOT, SITE_EARSHOT } from "./earshot.ts";
3 import type { Effect } from "./world.ts";
4 import type { Vec3 } from "./math.ts";
5
6 const HERE: Vec3 = [0, 0, 0];
7
8 test("a footstep past its earshot is not heard, and one inside it is", () => {
9 const near: Effect = { kind: "sound", at: 1, pos: [FOOTSTEP_EARSHOT - 1, 0, 0], name: "step" };
10 const far: Effect = { kind: "sound", at: 1, pos: [FOOTSTEP_EARSHOT + 1, 0, 0], name: "step" };
11 expect(cuesHeard([near], 0, HERE, 1)).toEqual([{ kind: "step", pos: near.pos }]);
12 expect(cuesHeard([far], 0, HERE, 1)).toEqual([]);
13 });
14
15 test("a dry trigger has its own, shorter earshot", () => {
16 const near: Effect = { kind: "sound", at: 1, pos: [EMPTY_EARSHOT - 1, 0, 0], name: "empty" };
17 const far: Effect = { kind: "sound", at: 1, pos: [EMPTY_EARSHOT + 1, 0, 0], name: "empty" };
18 expect(cuesHeard([near], 0, HERE, 1)).toEqual([{ kind: "world", name: "empty", pos: near.pos, pitch: undefined }]);
19 expect(cuesHeard([far], 0, HERE, 1)).toEqual([]);
20 });
21
22 test("a plant and a defuse carry past the site, out to the same ceiling", () => {
23 const plant: Effect = { kind: "sound", at: 1, pos: [SITE_EARSHOT - 1, 0, 0], name: "plant" };
24 const defuse: Effect = { kind: "sound", at: 1, pos: [SITE_EARSHOT - 1, 0, 0], name: "defuse" };
25 const tooFar: Effect = { kind: "sound", at: 1, pos: [SITE_EARSHOT + 1, 0, 0], name: "plant" };
26 expect(cuesHeard([plant], 0, HERE, 1)).toHaveLength(1);
27 expect(cuesHeard([defuse], 0, HERE, 1)).toHaveLength(1);
28 expect(cuesHeard([tooFar], 0, HERE, 1)).toEqual([]);
29 });
30
31 test("the bomb's beep and its blast carry from anywhere at all", () => {
32 const beep: Effect = { kind: "sound", at: 1, pos: [1_000_000, 0, 0], name: "beep", pitch: 900 };
33 const blast: Effect = { kind: "sound", at: 1, pos: [1_000_000, 0, 0], name: "blast" };
34 expect(cuesHeard([beep], 0, HERE, 1)).toEqual([{ kind: "world", name: "beep", pos: beep.pos, pitch: 900 }]);
35 expect(cuesHeard([blast], 0, HERE, 1)).toEqual([{ kind: "world", name: "blast", pos: blast.pos, pitch: undefined }]);
36 });
37
38 test("an effect at or before the cutoff is never returned", () => {
39 const effect: Effect = { kind: "shot", at: 5, pos: HERE, weapon: "ak47", mine: false, shooter: 2 };
40 expect(cuesHeard([effect], 5, HERE, 1)).toEqual([]);
41 expect(cuesHeard([effect], 6, HERE, 1)).toEqual([]);
42 expect(cuesHeard([effect], 4, HERE, 1)).toHaveLength(1);
43 });
44
45 test("no effect is ever heard twice across two calls, one after the other", () => {
46 const first: Effect = { kind: "shot", at: 1, pos: HERE, weapon: "ak47", mine: false, shooter: 2 };
47 const second: Effect = { kind: "shot", at: 2, pos: HERE, weapon: "ak47", mine: false, shooter: 2 };
48 const effects = [first, second];
49 // The first call hears both; a second call against the same list, its
50 // cutoff moved on to the last thing it played, hears neither again — the
51 // resume bug this rule exists to close.
52 const firstPass = cuesHeard(effects, 0, HERE, 1);
53 expect(firstPass).toHaveLength(2);
54 const secondPass = cuesHeard(effects, 2, HERE, 1);
55 expect(secondPass).toEqual([]);
56 });
57
58 test("a hurt cue is only ever the local player's", () => {
59 const mine: Effect = { kind: "hurt", at: 1, victim: 1, from: [1, 0, 0], amount: 27 };
60 const theirs: Effect = { kind: "hurt", at: 1, victim: 9, from: [1, 0, 0], amount: 27 };
61 expect(cuesHeard([mine], 0, HERE, 1)).toEqual([{ kind: "hurt", from: [1, 0, 0], amount: 27 }]);
62 expect(cuesHeard([theirs], 0, HERE, 1)).toEqual([]);
63 });
64
65 test("distance is measured from wherever `listener` is passed as, not any fixed place", () => {
66 // Standing where a teammate's eyes are, spectating, rather than at the
67 // corpse: a footstep near the teammate is heard, the same one near the
68 // corpse position is not, because the corpse is not who is passed in.
69 const teammateEyes: Vec3 = [2000, 0, 0];
70 const step: Effect = { kind: "sound", at: 1, pos: [2000 + FOOTSTEP_EARSHOT - 1, 0, 0], name: "step" };
71 expect(cuesHeard([step], 0, HERE, 1)).toEqual([]);
72 expect(cuesHeard([step], 0, teammateEyes, 1)).toHaveLength(1);
73 });
74
75 test("a shot and a hitmark carry whatever audio.ts needs and nothing else", () => {
76 const shot: Effect = { kind: "shot", at: 1, pos: [10, 20, 30], weapon: "awp", mine: true, shooter: 1 };
77 const hit: Effect = { kind: "hitmark", at: 1, headshot: true, killed: false };
78 expect(cuesHeard([shot], 0, HERE, 1)).toEqual([{ kind: "shot", pos: [10, 20, 30], weapon: "awp" }]);
79 expect(cuesHeard([hit], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: true, killed: false }]);
80 });
81
82 test("the hit cue carries whether it was the one that killed, ready for the kill tone", () => {
83 // `audio.ts` still plays `hit(headshot)` and ignores this: the flag is on
84 // the cue so the rewrite under #110 has it without touching earshot again.
85 const fatal: Effect = { kind: "hitmark", at: 1, headshot: false, killed: true };
86 expect(cuesHeard([fatal], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: false, killed: true }]);
87 });
src/client/earshot.ts deleted −72
@@ -1,72 +0,0 @@
1 /**
2 * What the ear is allowed to hear: which of a tick's effects turn into a cue
3 * for `audio.ts` and which don't. Plain data in — the world's effects, the
4 * time before which everything has already been played, and where the
5 * listener stands — and plain cues out, one per thing that should be heard,
6 * carrying only what `audio.ts` needs to play it: position, name, weapon,
7 * pitch, amount. No Web Audio, no DOM, the shape `ears.ts` already set.
8 *
9 * `hearing.ts` is a bot's ears, wired for a node graph a rifle can turn its
10 * head toward across the whole map. This is a person's: a handful of fixed
11 * ceilings and a straight-line distance, because a human never had a node
12 * graph to look things up in — a beep behind a wall is still a beep.
13 */
14
15 import type { Effect, SoundName } from "./world.ts";
16 import { dist, type Vec3 } from "./math.ts";
17 import type { WorldSoundName } from "./audio.ts";
18 import type { WeaponId } from "./weapons.ts";
19
20 // A footstep further than this is not worth a node graph — about the width
21 // of a couple of site rooms in de_forge, not the whole map. A dry trigger
22 // carries about the same: it is the gun in your hands, or an enemy's right
23 // in front of you. A plant or a defuse is louder and more deliberate, heard
24 // well past the site but not the whole map. The bomb's own beep and the
25 // blast that ends it have no entry here at all: they carry everywhere, so a
26 // CT can walk to the site — or learn it just went off — by ear alone.
27 export const FOOTSTEP_EARSHOT = 1500;
28 export const EMPTY_EARSHOT = 900;
29 export const SITE_EARSHOT = 3000;
30
31 const EARSHOT: Partial<Record<SoundName, number>> = {
32 step: FOOTSTEP_EARSHOT, empty: EMPTY_EARSHOT, plant: SITE_EARSHOT, defuse: SITE_EARSHOT,
33 };
34
35 /** One thing worth playing: exactly what `audio.ts` and `flinch` need, and nothing else. */
36 export type Cue =
37 | { kind: "shot"; pos: Vec3; weapon: WeaponId }
38 // A hit of your own: where it landed, and whether it was the one that
39 // finished them. `audio.ts` plays `hit(headshot)` today and ignores
40 // `killed` — the kill tone is #110's, and this is the flag waiting for it.
41 | { kind: "hit"; headshot: boolean; killed: boolean }
42 | { kind: "hurt"; from: Vec3; amount: number }
43 | { kind: "step"; pos: Vec3 }
44 | { kind: "world"; name: WorldSoundName; pos: Vec3; pitch?: number };
45
46 /**
47 * The cues a listener at `listener` picks up out of `effects`, having
48 * already heard everything at or before `since`. `localId` is whose pain
49 * counts: a `hurt` effect is only ever the local player's, whoever the
50 * camera is watching while they are dead.
51 */
52 export function cuesHeard(effects: Effect[], since: number, listener: Vec3, localId: number): Cue[] {
53 const cues: Cue[] = [];
54 for (const effect of effects) {
55 if (effect.at <= since) continue;
56 if (effect.kind === "shot") cues.push({ kind: "shot", pos: effect.pos, weapon: effect.weapon });
57 else if (effect.kind === "hitmark")
58 cues.push({ kind: "hit", headshot: effect.headshot, killed: effect.killed });
59 else if (effect.kind === "hurt") {
60 if (effect.victim === localId) cues.push({ kind: "hurt", from: effect.from, amount: effect.amount });
61 } else if (effect.kind === "sound") {
62 const limit = EARSHOT[effect.name];
63 if (limit !== undefined && dist(effect.pos, listener) > limit) continue;
64 cues.push(
65 effect.name === "step"
66 ? { kind: "step", pos: effect.pos }
67 : { kind: "world", name: effect.name, pos: effect.pos, pitch: effect.pitch },
68 );
69 }
70 }
71 return cues;
72 }
src/client/economy.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The money's own tests. Most of them need no world at all: a buy reads one
3 * field off the round and the rest off the player, so a `Market` here is an
4 * object with a phase in it and a `Buyer` is the kit and the wallet. The last
5 * test is the wiring — the same calls through a real `World` — because a module
6 * that only works against its own fixtures has proved nothing.
7 */
8
9 import { expect, test } from "bun:test";
10 import { buy, canBuy, CARRY, emptyAmmo, priceOf, type Buyer, type Market } from "./economy.ts";
11 import { DE_FORGE } from "./map.ts";
12 import { SHORT } from "./match.ts";
13 import { createWorld, NO_INPUT, update } from "./world.ts";
14 import { ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
15
16 const freeze: Market = { match: { phase: "freeze" } };
17 const live: Market = { match: { phase: "live" } };
18
19 const buyer = (over: Partial<Buyer> = {}): Buyer => ({
20 team: "ct", alive: true, money: 5000,
21 armor: 0, helmet: false, defuseKit: false,
22 weapon: "usp", primary: null, secondary: "usp",
23 ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") },
24 pocket: { he: 0, flash: 0, smoke: 0 },
25 ...over,
26 });
27
28 test("a gun is paid for, put in your hands, and loaded", () => {
29 const me = buyer();
30 expect(buy(freeze, me, "m4a4")).toBe(true);
31 expect(me.money).toBe(5000 - WEAPONS.m4a4.price);
32 expect(me.primary).toBe("m4a4");
33 expect(me.weapon).toBe("m4a4");
34 expect(me.ammo.m4a4).toEqual({ mag: WEAPONS.m4a4.magazine, reserve: WEAPONS.m4a4.reserve });
35 // A pistol is the other slot, and it does not take the rifle out of your hands
36 // by being cheaper — it takes it out by being the thing you just bought.
37 expect(buy(freeze, me, "deagle")).toBe(true);
38 expect(me.secondary).toBe("deagle");
39 expect(me.primary).toBe("m4a4");
40 });
41
42 test("a gun is the side's or it is nobody's, and the knife is not for sale", () => {
43 const ct = buyer();
44 expect(buy(freeze, ct, "ak47")).toBe(false);
45 expect(buy(freeze, ct, "knife")).toBe(false);
46 expect(ct.money).toBe(5000);
47 expect(buy(freeze, buyer({ team: "t" }), "ak47")).toBe(true);
48 });
49
50 test("armour bought twice is not paid for twice", () => {
51 const me = buyer();
52 expect(buy(freeze, me, "armor")).toBe(true);
53 expect(me.armor).toBe(100);
54 expect(me.money).toBe(5000 - ARMOR_PRICE);
55 // The vest is on: the second press is refused and costs nothing.
56 expect(priceOf("armor", me)).toBe(0);
57 expect(buy(freeze, me, "armor")).toBe(false);
58 expect(me.money).toBe(5000 - ARMOR_PRICE);
59 });
60
61 test("a helmet on a bare chest is the vest as well, and on a vest it is the helmet alone", () => {
62 const bare = buyer();
63 expect(priceOf("helmet", bare)).toBe(ARMOR_PRICE + HELMET_PRICE);
64 expect(buy(freeze, bare, "helmet")).toBe(true);
65 expect(bare.armor).toBe(100);
66 expect(bare.helmet).toBe(true);
67 expect(bare.money).toBe(5000 - ARMOR_PRICE - HELMET_PRICE);
68 expect(buy(freeze, bare, "helmet")).toBe(false); // and not a third time
69
70 const vested = buyer({ armor: 100 });
71 expect(priceOf("helmet", vested)).toBe(HELMET_PRICE);
72 expect(buy(freeze, vested, "helmet")).toBe(true);
73 expect(vested.money).toBe(5000 - HELMET_PRICE);
74 });
75
76 test("the kit is the CT's alone, and one each", () => {
77 const ct = buyer();
78 expect(priceOf("kit", ct)).toBe(DEFUSE_KIT_PRICE);
79 expect(buy(freeze, ct, "kit")).toBe(true);
80 expect(ct.defuseKit).toBe(true);
81 expect(buy(freeze, ct, "kit")).toBe(false);
82 expect(ct.money).toBe(5000 - DEFUSE_KIT_PRICE);
83 expect(buy(freeze, buyer({ team: "t" }), "kit")).toBe(false);
84 });
85
86 test("a grenade goes in the pocket, up to what a pocket holds", () => {
87 const me = buyer();
88 expect(buy(freeze, me, "he")).toBe(true);
89 expect(me.pocket.he).toBe(CARRY.he);
90 expect(buy(freeze, me, "he")).toBe(false);
91 // It is a pocket and not a slot: the gun in your hands is untouched.
92 expect(me.weapon).toBe("usp");
93 expect(me.money).toBe(5000 - WEAPONS.he.price);
94 });
95
96 test("a wallet that cannot reach it is refused, to the dollar", () => {
97 const me = buyer({ money: WEAPONS.awp.price - 1 });
98 expect(buy(freeze, me, "awp")).toBe(false);
99 me.money = WEAPONS.awp.price;
100 expect(buy(freeze, me, "awp")).toBe(true);
101 expect(me.money).toBe(0);
102 });
103
104 test("a buy is refused once the round is live, and to a body", () => {
105 const me = buyer();
106 expect(canBuy(live, me)).toBe(false);
107 expect(buy(live, me, "m4a4")).toBe(false);
108 expect(me.money).toBe(5000);
109 expect(me.primary).toBe(null);
110
111 const dead = buyer({ alive: false });
112 expect(canBuy(freeze, dead)).toBe(false);
113 expect(buy(freeze, dead, "m4a4")).toBe(false);
114 });
115
116 test("the same calls are what the world makes: a buy in the freeze, refused once it is live", () => {
117 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
118 world.me.money = 5000;
119 expect(canBuy(world, world.me)).toBe(true);
120 expect(buy(world, world.me, "m4a4")).toBe(true);
121 expect(world.me.money).toBe(5000 - WEAPONS.m4a4.price);
122 expect(world.me.weapon).toBe("m4a4");
123 expect(buy(world, world.me, "ak47")).toBe(false); // the other side's rifle
124 expect(buy(world, world.me, "helmet")).toBe(true);
125 expect(world.me.helmet).toBe(true);
126 for (let t = 0; t < SHORT.freezeTime + 0.5; t += 1 / 64) update(world, 1 / 64, NO_INPUT);
127 expect(world.match.phase).toBe("live");
128 expect(canBuy(world, world.me)).toBe(false);
129 expect(buy(world, world.me, "deagle")).toBe(false);
130 });
src/client/economy.ts added +104
@@ -0,0 +1,104 @@
1 /**
2 * The money: what an item costs the player in front of you, whether the round
3 * will let them have it, and what handing it over does to their wallet and
4 * their kit. `match.ts` decides what a round *pays* — the win, the loss bonus,
5 * the cap — and this decides what they can spend it on.
6 *
7 * It knows nothing about the world it is called from. A buy reads one thing off
8 * the round (which phase it is in) and everything else off the player, so both
9 * are named here by the members a buy actually touches, the way `combat.ts`
10 * names an `Actor`: a `World` and a `Player` satisfy them, and this file cannot
11 * reach back for either.
12 */
13
14 import { FULL_ARMOR } from "./combat.ts";
15 import type { Phase, Team } from "./match.ts";
16 import {
17 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, isThrown,
18 type ThrownId, type WeaponId,
19 } from "./weapons.ts";
20
21 /** One gun's rounds: what is in the magazine, and what is left to fill it with. */
22 export type Ammo = { mag: number; reserve: number };
23
24 /** A gun as it comes out of the shop, and as a fresh round hands it back. */
25 export const emptyAmmo = (id: WeaponId): Ammo =>
26 ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve });
27
28 /**
29 * How many of each thrown weapon a player may carry into a round. One HE, one
30 * flashbang and one smoke — three grenades, bought separately in the freeze,
31 * each refused once the pocket holds its limit, and all gone with the body that
32 * held them.
33 */
34 export const CARRY: Record<ThrownId, number> = { he: 1, flash: 1, smoke: 1 };
35
36 export type BuyItem = WeaponId | "armor" | "helmet" | "kit";
37
38 /** Everything a buy reads or writes on the person doing it, and nothing else. */
39 export type Buyer = {
40 team: Team;
41 alive: boolean;
42 money: number;
43 armor: number;
44 helmet: boolean;
45 defuseKit: boolean;
46 weapon: WeaponId;
47 primary: WeaponId | null;
48 secondary: WeaponId;
49 ammo: Record<string, Ammo>;
50 pocket: Record<ThrownId, number>;
51 };
52
53 /**
54 * The round as a buy sees it: which phase it is in. That is the whole of what
55 * the world has to say about whether the shop is open.
56 */
57 export type Market = { match: { phase: Phase } };
58
59 export function priceOf(item: BuyItem, player: Buyer): number {
60 if (item === "armor") return player.armor > 0 ? 0 : ARMOR_PRICE;
61 if (item === "helmet") return player.armor > 0 && !player.helmet ? HELMET_PRICE : ARMOR_PRICE + HELMET_PRICE;
62 if (item === "kit") return DEFUSE_KIT_PRICE;
63 return WEAPONS[item]?.price ?? Infinity;
64 }
65
66 export const canBuy = (market: Market, player: Buyer): boolean =>
67 market.match.phase === "freeze" && player.alive;
68
69 /** Buy one thing. Refuses anything the round, the side or the wallet says no to. */
70 export function buy(market: Market, player: Buyer, item: BuyItem): boolean {
71 if (!canBuy(market, player)) return false;
72 const price = priceOf(item, player);
73 if (!Number.isFinite(price) || price > player.money) return false;
74 if (item === "armor") {
75 if (player.armor >= FULL_ARMOR) return false;
76 player.armor = FULL_ARMOR;
77 } else if (item === "helmet") {
78 if (player.helmet) return false;
79 player.armor = FULL_ARMOR;
80 player.helmet = true;
81 } else if (item === "kit") {
82 if (player.team !== "ct" || player.defuseKit) return false;
83 player.defuseKit = true;
84 } else {
85 const weapon = WEAPONS[item];
86 if (weapon === undefined || weapon.kind === "knife") return false;
87 if (weapon.team !== "both" && weapon.team !== player.team) return false;
88 // A grenade goes in a pocket, not in your hands: it does not become the
89 // weapon you are holding and it does not displace the gun you bought, and
90 // one kind in there does not crowd out the other.
91 if (isThrown(item)) {
92 if (player.pocket[item] >= CARRY[item]) return false;
93 player.pocket[item] += 1;
94 player.money -= price;
95 return true;
96 }
97 if (weapon.kind === "pistol") player.secondary = item;
98 else player.primary = item;
99 player.ammo[item] = emptyAmmo(item);
100 player.weapon = item;
101 }
102 player.money -= price;
103 return true;
104 }
src/client/fullscreen.test.ts deleted −21
@@ -1,21 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { fullscreenControl } from "./fullscreen.ts";
3
4 test("a browser that cannot go fullscreen never shows the control", () => {
5 expect(fullscreenControl(false, false)).toEqual({ shown: false, label: "Fullscreen", pressed: false });
6 expect(fullscreenControl(false, true)).toEqual({ shown: false, label: "Fullscreen", pressed: false });
7 });
8
9 test("supported and not active: shown, unpressed, and says which way it will go", () => {
10 const state = fullscreenControl(true, false);
11 expect(state.shown).toBe(true);
12 expect(state.pressed).toBe(false);
13 expect(state.label).toBe("Fullscreen");
14 });
15
16 test("supported and active: shown, pressed, and the label reverses", () => {
17 const state = fullscreenControl(true, true);
18 expect(state.shown).toBe(true);
19 expect(state.pressed).toBe(true);
20 expect(state.label).toBe("Exit fullscreen");
21 });
src/client/fullscreen.ts deleted −75
@@ -1,75 +0,0 @@
1 /**
2 * Fullscreen: a pure decision — is the control shown, and what does it say —
3 * plus the thin DOM glue that asks for it. It always fullscreens the
4 * *wrapper*, the element holding both `#stage` and `#touch`, never `#stage`
5 * alone: fullscreening the canvas hides the pad and leaves a phone with no
6 * buttons at all.
7 */
8
9 export type FullscreenControl = {
10 /** False on a browser that cannot do it at all — iOS Safari on an iPhone. */
11 shown: boolean;
12 label: string;
13 pressed: boolean;
14 };
15
16 /** Pure: what the button shows, from support and the current element alone. */
17 export function fullscreenControl(supported: boolean, active: boolean): FullscreenControl {
18 if (!supported) return { shown: false, label: "Fullscreen", pressed: false };
19 return { shown: true, label: active ? "Exit fullscreen" : "Fullscreen", pressed: active };
20 }
21
22 export type FullscreenHandle = {
23 toggle(): void;
24 detach(): void;
25 };
26
27 /**
28 * Wires one or more buttons — the menu's and the pause overlay's are the same
29 * switch — to the wrapper's fullscreen state. `onExit` fires only when the
30 * wrapper itself drops out of fullscreen (the system gesture or `Esc`), never
31 * for some other element's fullscreen coming or going, and never on entry.
32 */
33 export function attachFullscreen(
34 wrapper: HTMLElement,
35 buttons: HTMLButtonElement[],
36 onExit: () => void,
37 ): FullscreenHandle {
38 const supported = document.fullscreenEnabled === true;
39 const isActive = (): boolean => document.fullscreenElement === wrapper;
40 let wasActive = isActive();
41
42 const render = (): void => {
43 const state = fullscreenControl(supported, isActive());
44 for (const button of buttons) {
45 button.hidden = !state.shown;
46 button.textContent = state.label;
47 button.setAttribute("aria-pressed", String(state.pressed));
48 }
49 };
50
51 const onChange = (): void => {
52 const active = isActive();
53 if (wasActive && !active) onExit();
54 wasActive = active;
55 render();
56 };
57
58 const toggle = (): void => {
59 if (!supported) return;
60 if (isActive()) void document.exitFullscreen();
61 else void wrapper.requestFullscreen().catch(() => {});
62 };
63
64 for (const button of buttons) button.addEventListener("click", toggle);
65 document.addEventListener("fullscreenchange", onChange);
66 render();
67
68 return {
69 toggle,
70 detach: () => {
71 document.removeEventListener("fullscreenchange", onChange);
72 for (const button of buttons) button.removeEventListener("click", toggle);
73 },
74 };
75 }
src/client/generate.test.ts added +388
@@ -0,0 +1,388 @@
1 /**
2 * The generator, held to the two things it promises: that a seed is a map, the
3 * same map on every machine, and that a map it hands back can be played — one
4 * place, two sites, ten spawns, and a walk from each spawn to each site that A*
5 * finds rather than a flood fill hopes for.
6 *
7 * Nothing here reads the generator's own gate and calls it a day: `flaws` is
8 * what the generator itself refuses on, so the checks below path, flood and
9 * count the level again from the outside. The last two tests are the point of
10 * the whole file — a round of the real game, with the real bots, on a map
11 * nobody wrote, played headlessly and pinned to a digest.
12 */
13
14 import { expect, test } from "bun:test";
15 import { botThink } from "./bots.ts";
16 import { levelOf, type Cell } from "./cells.ts";
17 import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts";
18 import { compare as compareMap, layout } from "./layout.ts";
19 import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Box, type Level } from "./level.ts";
20 import { LOOKS, lookFor, type Look } from "./looks.ts";
21 import { LAYERS } from "./materials.ts";
22 import { levelMesh } from "./models.ts";
23 import { SHORT } from "./match.ts";
24 import { digest, withoutEntropy } from "./replay.ts";
25 import { applyInput, createWorld, type Player } from "./world.ts";
26 import { compare, fold, region, type Vec3 } from "../engine/index.ts";
27
28 /** How long a walk is, in Source units: the sum of the steps A* handed back. */
29 const walkLength = (path: Vec3[], from: Vec3): number => {
30 let at = from, total = 0;
31 for (const step of path) {
32 total += Math.hypot(step[0] - at[0], step[1] - at[1]);
33 at = step;
34 }
35 return total;
36 };
37
38 const letters = (level: Level): Cell[] => level.grid.cells as Cell[];
39
40 /** A level's own cells as a page of letters: `page` over the grid it carries. */
41 const rowsOf = (level: Level): string[] => page({ ...level.grid, cells: letters(level) });
42
43 test("a seed is a map, and the same seed is the same map for ever", () => {
44 expect(page(generateGrid(7))).toEqual(page(generateGrid(7)));
45 expect(page(generateGrid(7))).not.toEqual(page(generateGrid(8)));
46 // Two levels from one seed agree about every point a round is played at.
47 const a = generateLevel(7), b = generateLevel(7);
48 expect(a.name).toBe("gen_7");
49 expect(nameOf(1234)).toBe("gen_ya");
50 expect(a.boxes).toEqual(b.boxes);
51 expect(a.spawns).toEqual(b.spawns);
52 expect(a.sites).toEqual(b.sites);
53 });
54
55 /**
56 * The map `gen_14u5j6g` is, folded cell by cell into a hash by `layout.ts` —
57 * which is the engine's `digest` door, the same instrument the rounds are
58 * pinned with, given rows of letters instead of ticks of a world. The fold
59 * moved into a module of its own for #159, where a generated map is pinned
60 * beside the match played on it; the digest below is the one this test has
61 * always had, to the bit.
62 *
63 * This is the claim the name makes: a person writes `gen_14u5j6g` down, hands
64 * it to somebody on another machine, and that machine carves the same forty by
65 * forty cells. Nothing here is timed, seeded by a clock or read off a file, so
66 * a digest that moves is the generator's shape changing — which is allowed, and
67 * is a line in the pull that says which maps changed, with these constants
68 * pasted in from the failure.
69 */
70 const NAMED = "gen_14u5j6g";
71 const NAMED_MAP = "d548dcae07259c836d93ad7d8df3a195281c4da8ee1ef53f9000f5d3e115def9";
72 const NAMED_BANDS: readonly string[] = [
73 "c674e6e4c9f2", "45bb896cd286", "e16819fdf569", "6b799f4e0392",
74 ];
75
76 test("a map you can name is the same map on every machine, cell for cell", () => {
77 const seed = seedOf(NAMED);
78 if (seed === null) throw new Error(`${NAMED} is not a map name, and it is the one this test is about`);
79 const level = generateLevel(seed);
80 // The name is the map's own name, not a label put beside it.
81 expect(level.name).toBe(NAMED);
82 expect(nameOf(seed)).toBe(NAMED);
83
84 const why = compareMap(
85 layout(level),
86 { label: NAMED, constant: "NAMED_MAP", list: "NAMED_BANDS", digest: NAMED_MAP, bands: NAMED_BANDS },
87 "src/client/generate.test.ts",
88 );
89 if (why !== null) throw new Error(why);
90 // Asking twice is asking once: the whole point of a seed.
91 expect(rowsOf(generateLevel(seed))).toEqual(rowsOf(level));
92 });
93
94 /**
95 * The mesh `gen_14u5j6g` is drawn from, folded vertex by vertex: position, uv
96 * and — the point of #151 — the atlas layer each one is painted from. The map
97 * digest above pins the letters; this pins what a player actually sees, which
98 * is the letters turned into boxes *and* the look those boxes wear.
99 *
100 * It moves when the generator's shape moves, when the mesher's does, or when
101 * this seed is dressed in another look. All three are allowed and all three are
102 * a line in the pull with these constants pasted in from the failure.
103 */
104 const NAMED_LOOK = "plaza";
105 const NAMED_MESH = "16133a4f413aa063f8c76efced2dddfc9e00f6502d089ed0ccbcb3f15b9c641c";
106 const NAMED_MESH_BANDS: readonly string[] = [
107 "0e887e4b6e6f", "4f8f475e35b3", "d970b6af42c6", "8108bf5a61ad",
108 "9eb216318777", "c5a0dd4cf4b4", "4b75c09568eb", "33f24951e64b",
109 "d5ba59402c79", "d7983405773e", "0e370c4f60ef",
110 ];
111
112 test("a named map is the same geometry, painted from the same layers, everywhere", () => {
113 const seed = seedOf(NAMED);
114 if (seed === null) throw new Error(`${NAMED} is not a map name, and it is the one this test is about`);
115 const level = generateLevel(seed);
116 expect(level.look).toBe(NAMED_LOOK);
117
118 const mesh = levelMesh(level.boxes);
119 let at = 0;
120 const run = fold({
121 ticks: mesh.layer.length,
122 step: (vertex) => void (at = vertex),
123 line: () =>
124 [
125 mesh.position[at * 3], mesh.position[at * 3 + 1], mesh.position[at * 3 + 2],
126 mesh.uv[at * 2], mesh.uv[at * 2 + 1], mesh.layer[at],
127 ].join(" "),
128 every: 120,
129 });
130 const why = compare(
131 run,
132 { label: `mesh of ${NAMED}`, constant: "NAMED_MESH", digest: NAMED_MESH, checkpoints: NAMED_MESH_BANDS },
133 {
134 file: "src/client/generate.test.ts",
135 list: "NAMED_MESH_BANDS",
136 hashes: "every vertex of the level mesh: its position, its uv and its atlas layer",
137 every: 120,
138 unit: "band",
139 },
140 );
141 if (why !== null) throw new Error(why);
142 });
143
144 /**
145 * A look is the seed's, the same on every machine, and a run of seeds does not
146 * walk the table in order — the whole reason `lookFor` stirs its seed instead of
147 * taking it modulo eight.
148 *
149 * The fraction measured here, and what it is worth: over seeds 0..99 all eight
150 * looks turn up, 88 of the 99 consecutive pairs differ, and 87.5% of all 4,950
151 * pairs do — which is the 7/8 a uniform draw over eight gives. The floors below
152 * are well under those so a tweak to the stirring is not a failing test, but a
153 * generator that dressed every map the same, or that dealt the looks in order,
154 * fails both of them.
155 */
156 test("a seed is a look, and two seeds are usually two looks", () => {
157 expect(generateLevel(7).look).toBe(generateLevel(7).look);
158 expect(lookFor(7).name).toBe(generateLevel(7).look);
159
160 const looks = Array.from({ length: 100 }, (_, seed) => generateLevel(seed).look);
161 expect(new Set(looks).size).toBe(LOOKS.length);
162 let differ = 0;
163 for (let i = 1; i < looks.length; i++) if (looks[i] !== looks[i - 1]) differ++;
164 expect(differ).toBeGreaterThanOrEqual(75); // 88 of 99 measured
165 let pairs = 0;
166 for (let i = 0; i < looks.length; i++)
167 for (let j = i + 1; j < looks.length; j++) if (looks[i] !== looks[j]) pairs++;
168 expect(pairs / ((looks.length * (looks.length - 1)) / 2)).toBeGreaterThan(0.8); // 0.875 measured
169 });
170
171 test("a look paints a map and moves nothing else about it", () => {
172 // Two levels off one seed, dressed differently by hand: same cells, same
173 // spawns, same sites, same boxes to the unit — and different layers on them.
174 const grid = generateGrid(31);
175 const a = levelOf("gen_31", grid, LOOKS[0] as Look);
176 const b = levelOf("gen_31", grid, LOOKS[4] as Look);
177 expect(a.grid.cells).toEqual(b.grid.cells);
178 expect(a.spawns).toEqual(b.spawns);
179 expect(a.sites).toEqual(b.sites);
180 expect(a.boxes.length).toBe(b.boxes.length);
181 for (let i = 0; i < a.boxes.length; i++) {
182 const one = a.boxes[i] as Box, two = b.boxes[i] as Box;
183 expect(one.min).toEqual(two.min);
184 expect(one.max).toEqual(two.max);
185 }
186 expect(a.boxes.map((box) => box.mat)).not.toEqual(b.boxes.map((box) => box.mat));
187 expect(a.look).toBe("forge");
188 expect(b.look).toBe("bunker");
189 });
190
191 test("a map is written on a page of letters, one string a row", () => {
192 const grid = generateGrid(3);
193 const rows = page(grid);
194 expect(rows.length).toBe(SIZE);
195 for (const row of rows) expect(row.length).toBe(SIZE);
196 expect(rows[0]).toBe("#".repeat(SIZE)); // the border is rock, all the way round
197 expect(rows[SIZE - 1]).toBe("#".repeat(SIZE));
198 for (const row of rows) expect(row[0]).toBe("#");
199 expect(rows.join("")).toBe(grid.cells.join(""));
200 });
201
202 test("every map is the other side's map turned half a circle", () => {
203 // The whole of fairness: the cell at (col, row) is the cell at the far end of
204 // the diagonal through the middle, with T for CT and A for B.
205 const mate: Record<string, string> = { "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t" };
206 for (let seed = 1; seed <= 40; seed++) {
207 const grid = generateGrid(seed);
208 for (let row = 0; row < grid.height; row++)
209 for (let col = 0; col < grid.width; col++) {
210 const here = grid.cells[row * grid.width + col] as string;
211 const there = grid.cells[(grid.height - 1 - row) * grid.width + (grid.width - 1 - col)] as string;
212 if (mate[here] !== there) throw new Error(`seed ${seed}: (${col}, ${row}) is ${here} but its mate is ${there}`);
213 }
214 }
215 });
216
217 test("the walk to your site is the walk to theirs, step for step", () => {
218 // Turning the map half a circle turns the T spawn into the CT spawn and A
219 // into B, so the two walks are the same walk. Reading order runs north to
220 // south, so the mate of the first CT spawn cell is the *last* T spawn cell.
221 for (const seed of [2, 13, 44, 101, 777]) {
222 const level = generateLevel(seed);
223 // The middle of the two sites are exact opposites, because a site is an odd
224 // number of cells on both sides and the middle of an odd run is a cell.
225 expect(level.sites.A[0]).toBe(-level.sites.B[0]);
226 expect(level.sites.A[1]).toBe(-level.sites.B[1]);
227 const ct = level.spawns.ct[0] as Vec3;
228 const t = level.spawns.t[level.spawns.t.length - 1] as Vec3;
229 const ctToA = walkLength(findPath(level, ct, level.sites.A), ct);
230 const tToB = walkLength(findPath(level, t, level.sites.B), t);
231 const ctToB = walkLength(findPath(level, ct, level.sites.B), ct);
232 const tToA = walkLength(findPath(level, t, level.sites.A), t);
233 expect(ctToA).toBeGreaterThan(0);
234 expect(ctToA).toBeCloseTo(tToB, 6);
235 expect(ctToB).toBeCloseTo(tToA, 6);
236 }
237 });
238
239 test("two hundred seeds all come out playable, first time", () => {
240 // The retry loop in `generateLevel` is insurance; this is the claim that it
241 // is never spent. Every level here is the seed's first attempt.
242 for (let seed = 0; seed < 200; seed++) {
243 const level = generateLevel(seed);
244 expect(flaws(level)).toEqual([]);
245
246 // Ten players have somewhere of their own to stand, and it is floor.
247 expect(level.spawns.t.length).toBeGreaterThanOrEqual(8);
248 expect(level.spawns.ct.length).toBe(level.spawns.t.length);
249 for (const side of ["t", "ct"] as const)
250 for (const spot of level.spawns[side])
251 expect(walkable(level, colOf(level, spot[0]), rowOf(level, spot[1]))).toBe(true);
252
253 // The middle of each site is on that site and can be planted on.
254 expect(siteAt(level, level.sites.A)).toBe("A");
255 expect(siteAt(level, level.sites.B)).toBe("B");
256
257 // One place: everything walkable can be walked to from a spawn.
258 const first = level.spawns.t[0] as Vec3;
259 const reached = region(level.grid, level.passable, colOf(level, first[0]), rowOf(level, first[1]));
260 const stranded = letters(level).filter((c, i) => level.passable(c) && reached[i] === 0);
261 expect(stranded).toEqual([]);
262
263 // A map with room to move. Measured over a thousand seeds, the tightest
264 // map is 286 cells of floor and the most open is 684, of 1,600.
265 expect(openCells(level).length).toBeGreaterThan(SIZE * SIZE / 6);
266 }
267 });
268
269 test("no generated map leaks outside its own grid, or paints a surface this game has not got", () => {
270 for (let seed = 500; seed < 540; seed++) {
271 const level = generateLevel(seed);
272 // Since #151 a map is dressed from the seed, so the surfaces it may paint
273 // are its own look's and not de_forge's — but they are still nine materials
274 // already in the atlas, and never a tenth: a new layer is a repaint and a
275 // bundle nobody pays for.
276 const known = new Set<number>(Object.values(level.materials));
277 const edge = [(level.grid.width / 2) * level.grid.cell, (level.grid.height / 2) * level.grid.cell];
278 for (const box of level.boxes) {
279 for (const axis of [0, 1]) {
280 expect(box.min[axis as 0 | 1]).toBeGreaterThanOrEqual(-(edge[axis] as number));
281 expect(box.max[axis as 0 | 1]).toBeLessThanOrEqual(edge[axis] as number);
282 }
283 expect(known.has(box.mat)).toBe(true);
284 expect(box.mat).toBeLessThan(LAYERS);
285 }
286 }
287 });
288
289 test("the gate bites: a map with its site walled off, and one with nowhere to spawn", () => {
290 const level = generateLevel(21);
291 expect(flaws(level)).toEqual([]);
292
293 // Fill the ring around bombsite A with rock and nobody can reach it. The
294 // cells inside are still walkable and now cannot be walked to, so the gate
295 // says both things.
296 const shut = { ...level, grid: { ...level.grid, cells: [...level.grid.cells] } } as Level;
297 const { width, height } = shut.grid;
298 for (let row = 0; row < height; row++)
299 for (let col = 0; col < width; col++)
300 if (shut.grid.cells[row * width + col] === "A")
301 for (let dr = -1; dr <= 1; dr++)
302 for (let dc = -1; dc <= 1; dc++) {
303 const c = col + dc, r = row + dr;
304 if (c < 0 || r < 0 || c >= width || r >= height) continue;
305 if (shut.grid.cells[r * width + c] !== "A") shut.grid.cells[r * width + c] = "#";
306 }
307 const complaints = flaws(shut).join("\n");
308 expect(complaints).toContain("no path from the t spawn to bombsite A");
309 expect(complaints).toContain("no path from the ct spawn to bombsite A");
310 expect(complaints).toContain("cannot be walked to");
311
312 // And a level with one spawn cell a side is not a level for ten players.
313 const thin = { ...level, spawns: { t: [level.spawns.t[0] as Vec3], ct: [level.spawns.ct[0] as Vec3] } };
314 expect(flaws(thin).length).toBe(2);
315 expect(flaws(thin)[0]).toContain("a side is five players");
316 });
317
318 test("a map arrives in a couple of milliseconds, which is a menu press", () => {
319 for (let seed = 0; seed < 20; seed++) generateLevel(seed); // warm
320 const started = performance.now();
321 const runs = 40;
322 for (let seed = 900; seed < 900 + runs; seed++) generateLevel(seed);
323 const each = (performance.now() - started) / runs;
324 // Measured at 2.1 ms a map on this machine: the carving, the crates that are
325 // checked for cutting the map in two, the meshing and four A* searches.
326 // Twenty is the ceiling a slow CI box has to beat, not the number to read.
327 expect(each).toBeLessThan(20);
328 });
329
330 // --- and the game played on it --------------------------------------------
331
332 test("a bot walks a map nobody wrote, on that map's own cells", () => {
333 const level = generateLevel(64);
334 const world = createWorld(level, SHORT, "me", "ct", 1, 5);
335 expect(world.level).toBe(level);
336 world.match.phase = "live";
337 world.me.alive = false;
338 const bot = world.players.find((p) => p.bot) as Player;
339 const from: [number, number] = [bot.pos[0], bot.pos[1]];
340 for (let tick = 0; tick < 64 * 3; tick++) {
341 world.time += 1 / 64;
342 applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64);
343 }
344 // It went somewhere, along cells of this map, and this map's walls held it in.
345 expect(Math.hypot(bot.pos[0] - from[0], bot.pos[1] - from[1])).toBeGreaterThan(200);
346 const path = (bot.brain as { path: Vec3[] }).path;
347 expect(path.length).toBeGreaterThan(1);
348 for (const step of path)
349 expect(walkable(level, colOf(level, step[0]), rowOf(level, step[1]))).toBe(true);
350 expect(Math.abs(bot.pos[0])).toBeLessThan((level.grid.width / 2) * level.grid.cell);
351 expect(Math.abs(bot.pos[1])).toBeLessThan((level.grid.height / 2) * level.grid.cell);
352 });
353
354 /**
355 * Ten seconds of a five-a-side round on `gen_5`, seed 11, through the same
356 * script `replay.ts` pins de_forge with. Computed on this branch, where the
357 * generator was written.
358 *
359 * It pins two things at once: that the simulation is deterministic on a
360 * generated map, and that this seed still makes *this* map. Tuning the
361 * generator — a room more, a crate fewer — moves it, and that is the point:
362 * paste the new digest in the pull that tuned it and say which seed's map
363 * changed shape. A digest that moves for any other reason is a bug.
364 *
365 * Re-pinned for #164 with the other two: the snapshot in `replay.ts` now
366 * carries both pockets, how long each body is blinded for and which kind each
367 * live grenade is, so every digest in this repository moved with it. The map
368 * and the play are untouched — the assertions under this one still find the
369 * same spawns on the same cells, and the same ten seconds folded with the old
370 * field list still comes to the digest this line held before,
371 * 48a1d4466065e045b8933b3fbf5ece767a825a60165e6455b4e310a8d7dd5cae.
372 */
373 // Re-pinned for #165, which put a smoke grenade in the game: the hash moved
374 // and the play did not. A pocket is three counts now rather than two, a
375 // grenade's kind is an index into three rather than a flag, and every tick
376 // carries however many clouds are standing — and nothing in this run throws
377 // anything, so `clouds` is empty on all 640 ticks of it.
378 const GENERATED_ROUND = "619ce3102805d11dc40e8d545267e99034bfb3bdab2961202703423a87f6e500";
379
380 test("a seeded round on a generated map plays exactly as it played", () => {
381 const level = generateLevel(5);
382 const run = withoutEntropy(() => digest(createWorld(level, SHORT, "me", "ct", 5, 11), 10 * 64));
383 expect(run.digest).toBe(GENERATED_ROUND);
384 // Nobody fell through the floor or stood in a wall for ten seconds.
385 for (const p of createWorld(level, SHORT, "me", "ct", 5, 11).players)
386 expect(walkable(level, colOf(level, p.pos[0]), rowOf(level, p.pos[1]))).toBe(true);
387 expect(worldOf(level, 0, 0)[2]).toBe(0);
388 });
src/client/generate.ts added +331
@@ -0,0 +1,331 @@
1 /**
2 * A map from a number. Rooms, corridors, two bombsites, two spawns and cover,
3 * carved out of solid rock into the alphabet `cells.ts` reads — so a generated
4 * map is one more value of `Level` and nothing in the simulation, the renderer
5 * or the bots learns a thing to play it.
6 *
7 * Two promises hold the whole file up.
8 *
9 * **It is fair.** Everything is stamped twice: a cell at (col, row) and the cell
10 * at (width - 1 - col, height - 1 - row), with `t` becoming `c` and `A`
11 * becoming `B` on the way. The map is therefore its own opponent's map turned
12 * half a circle, so the walk from T spawn to A is the walk from CT spawn to B,
13 * step for step, and no seed can hand one side a shorter round. A test paths
14 * both and compares the lengths.
15 *
16 * **It is playable.** Every room hangs off a spanning tree of corridors that
17 * reaches the middle, which is the one room both halves share, so the map is
18 * one place by construction rather than by luck; `region` then rubs out
19 * anything the tree missed, a crate that would cut the map in two is taken back
20 * off, and `flaws` reads the finished level for the things a round needs —
21 * spawns to stand on, sites to plant on, and a path from each spawn to each
22 * site. A seed whose map has a flaw is not shipped: the next seed is derived
23 * from it and the work is done again.
24 *
25 * Determinism is the point: a seed is a map, on every machine, for ever. The
26 * only source of randomness is the engine's `rng`, which is the same generator
27 * the rounds themselves run on.
28 */
29
30 import { region, rng, type Grid, type Vec3 } from "../engine/index.ts";
31 import { nameOf, seedFrom, seedOf } from "../mapname.ts";
32 import { CELL, levelOf, type Cell } from "./cells.ts";
33 import { colOf, findPath, rowOf, type Level } from "./level.ts";
34 import { lookFor } from "./looks.ts";
35
36 /**
37 * What a map is called and what a name means, both from `src/mapname.ts` and
38 * re-exported here so the three live beside each other for a reader: `nameOf`
39 * is the name of a seed's map, `seedOf` is the seed of a name — which is how a
40 * map you played is a map you can ask for again — and `seedFrom` is the seed a
41 * match id means, which is the number every carving below starts from. All
42 * three are there rather than here because the server validates `?map=` and
43 * names today's map with them, and may not import a generator to do it.
44 */
45 export { nameOf, seedFrom, seedOf };
46
47 /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */
48 export const SIZE = 40;
49
50 /** How many attempts a seed gets before the generator gives up on it. */
51 const ATTEMPTS = 8;
52
53 /** [col0, row0, col1, row1], inclusive both ends, like de_forge's areas. */
54 type Rect = { c0: number; r0: number; c1: number; r1: number };
55
56 type Point = { col: number; row: number };
57
58 /** What a letter becomes when it is stamped on the other side of the map. */
59 const MATE: Record<Cell, Cell> = {
60 "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t",
61 };
62
63 /** The page of letters a grid is, one string a row: what a test or a terminal reads. */
64 export const page = (grid: Grid<Cell>): string[] =>
65 Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join(""));
66
67 // --- carving --------------------------------------------------------------
68
69 /**
70 * One map under construction: the cells, and the only way anything is written
71 * into them. Every write is two writes — the cell, and the cell half a circle
72 * away with its letter turned round — so symmetry is not something to remember
73 * at the end but the only thing this page can do.
74 */
75 function sheet(size: number): {
76 cells: Cell[];
77 set: (col: number, row: number, cell: Cell) => void;
78 fill: (rect: Rect, cell: Cell) => void;
79 } {
80 const cells: Cell[] = new Array(size * size).fill("#");
81 // The border is rock and stays rock: nothing is ever carved into the outer ring.
82 const inside = (col: number, row: number): boolean =>
83 col >= 1 && row >= 1 && col <= size - 2 && row <= size - 2;
84 const set = (col: number, row: number, cell: Cell): void => {
85 if (!inside(col, row)) return;
86 cells[row * size + col] = cell;
87 cells[(size - 1 - row) * size + (size - 1 - col)] = MATE[cell];
88 };
89 const fill = (rect: Rect, cell: Cell): void => {
90 for (let r = rect.r0; r <= rect.r1; r++)
91 for (let c = rect.c0; c <= rect.c1; c++) set(c, r, cell);
92 };
93 return { cells, set, fill };
94 }
95
96 const centreOf = (rect: Rect): Point => ({
97 col: Math.floor((rect.c0 + rect.c1) / 2),
98 row: Math.floor((rect.r0 + rect.r1) / 2),
99 });
100
101 /**
102 * An L from one room to another, two or three cells wide: along the columns to
103 * the elbow, then down the rows, both runs carved as rectangles so the corner
104 * they share is open and nobody has to cut it. Which run goes first is the
105 * seed's business, which is what keeps a map from being all right angles the
106 * same way round.
107 */
108 function corridor(
109 paint: ReturnType<typeof sheet>,
110 a: Point,
111 b: Point,
112 width: number,
113 columnsFirst: boolean,
114 ): void {
115 const elbow = columnsFirst ? { col: b.col, row: a.row } : { col: a.col, row: b.row };
116 const span = (from: Point, to: Point): Rect => ({
117 c0: Math.min(from.col, to.col),
118 r0: Math.min(from.row, to.row),
119 c1: Math.max(from.col, to.col) + (from.row === to.row ? 0 : width - 1),
120 r1: Math.max(from.row, to.row) + (from.col === to.col ? 0 : width - 1),
121 });
122 paint.fill(span(a, elbow), ".");
123 paint.fill(span(elbow, b), ".");
124 }
125
126 /**
127 * The corridors: a spanning tree over the rooms, nearest room first, grown from
128 * the middle — so every room reaches the middle, and the middle is the room
129 * both halves of the map share. Then one or two edges more, chosen at random,
130 * because a map with exactly one route between two places is a queue rather
131 * than a fight.
132 */
133 function connect(paint: ReturnType<typeof sheet>, rooms: Point[], random: () => number): void {
134 const joined = [0];
135 const waiting = rooms.map((_, i) => i).slice(1);
136 const width = (): number => (random() < 0.45 ? 3 : 2);
137 while (waiting.length > 0) {
138 let best = 0, from = 0, far = Infinity;
139 for (let w = 0; w < waiting.length; w++)
140 for (const j of joined) {
141 const p = rooms[waiting[w] as number] as Point, q = rooms[j] as Point;
142 const d = Math.hypot(p.col - q.col, p.row - q.row);
143 if (d < far) { far = d; best = w; from = j; }
144 }
145 const next = waiting.splice(best, 1)[0] as number;
146 corridor(paint, rooms[from] as Point, rooms[next] as Point, width(), random() < 0.5);
147 joined.push(next);
148 }
149 const extra = 1 + Math.floor(random() * 2);
150 for (let i = 0; i < extra && rooms.length > 2; i++) {
151 const a = Math.floor(random() * rooms.length);
152 const b = (a + 1 + Math.floor(random() * (rooms.length - 1))) % rooms.length;
153 corridor(paint, rooms[a] as Point, rooms[b] as Point, width(), random() < 0.5);
154 }
155 }
156
157 // --- the map --------------------------------------------------------------
158
159 /**
160 * One attempt at a layout, as a grid of letters. Everything is decided in the
161 * northern half and stamped into the southern half turned half a circle: the CT
162 * spawn is the T spawn, the A site is the B site, and the middle room is its
163 * own reflection, which is where the two halves meet.
164 */
165 export function generateGrid(seed: number, size = SIZE): Grid<Cell> {
166 const random = rng(seed);
167 const pick = (from: number, to: number): number => from + Math.floor(random() * (to - from + 1));
168 const paint = sheet(size);
169 const middle = size / 2;
170
171 // The CT spawn: eight by four, across the top, near the middle column.
172 const spawnC0 = pick(middle - 6, middle - 2);
173 const spawn: Rect = { c0: spawnC0, r0: 2, c1: spawnC0 + 7, r1: 5 };
174
175 // The A site: seven wide, five or seven deep, out on one flank — clear of the
176 // spawn's columns and wholly in the northern half, so its mate is the B site
177 // on the other flank and the two can never touch. Both sides of it are odd
178 // on purpose: the middle of an odd run of cells is a cell, so the point the
179 // bomb is planted at is the centre of a cell rather than the seam between
180 // two, and the two sites' middles are exact opposites of each other. A seam
181 // rounds the wrong way on one side of the map and hands that side a shorter
182 // walk, which is the one thing this file is not allowed to do.
183 const siteC0 = random() < 0.5 ? pick(size - 12, size - 9) : pick(2, 6);
184 const siteR0 = pick(3, 12);
185 const site: Rect = { c0: siteC0, r0: siteR0, c1: siteC0 + 6, r1: siteR0 + (random() < 0.5 ? 4 : 6) };
186
187 // The middle: the one room that is its own mate, and the seam of the map.
188 const midC0 = middle - pick(3, 5), midR0 = middle - pick(2, 4);
189 const mid: Rect = { c0: midC0, r0: midR0, c1: size - 1 - midC0, r1: size - 1 - midR0 };
190
191 // Three or four halls in the northern half, wherever they land: they may cross
192 // anything, because the letters go on last.
193 const halls: Rect[] = [];
194 for (let i = 0, want = pick(3, 4); i < want; i++) {
195 const w = pick(4, 7), h = pick(3, 5);
196 const c0 = pick(2, size - 3 - w), r0 = pick(2, middle - 3 - h);
197 halls.push({ c0, r0, c1: c0 + w, r1: r0 + h });
198 }
199
200 for (const rect of [spawn, site, mid, ...halls]) paint.fill(rect, ".");
201 connect(paint, [centreOf(mid), centreOf(spawn), centreOf(site), ...halls.map(centreOf)], random);
202
203 // The letters last, so a corridor that crossed a spawn is spawn floor rather
204 // than a hole in it.
205 paint.fill(site, "A");
206 paint.fill(spawn, "c");
207
208 // Anything the tree missed is rock again: a courtyard nobody can reach is a
209 // place the renderer draws, the bots path into and the round never visits.
210 // Rubbing it out cell by cell keeps the symmetry it was carved with, because
211 // what one spawn can reach is what the other can: the two are the same room
212 // turned half a circle, and the middle joins them.
213 const grid: Grid<Cell> = { width: size, height: size, cell: CELL, cells: paint.cells };
214 const home = centreOf(spawn);
215 const reached = region(grid, (c) => c !== "#", home.col, home.row);
216 for (let i = 0; i < grid.cells.length; i++)
217 if (reached[i] === 0 && grid.cells[i] !== "#") grid.cells[i] = "#";
218
219 crates(paint, grid, centreOf(site), random);
220 return grid;
221 }
222
223 /**
224 * Cover, in pairs: a crate and the crate half a circle away, lower-case to jump
225 * on and upper-case to hide behind. A crate goes on floor in a roomy place —
226 * every orthogonal neighbour open and six of the eight — never on a spawn and
227 * never on a bombsite, whose cells decide where the bomb is planted. If the
228 * pair cuts the map in two it comes back off, which is why a generated map can
229 * be crated at all: a plug in a two-wide corridor is a round nobody can finish.
230 */
231 function crates(
232 paint: ReturnType<typeof sheet>,
233 grid: Grid<Cell>,
234 siteMiddle: Point,
235 random: () => number,
236 ): void {
237 const { width, cells } = grid;
238 const open = (col: number, row: number): boolean =>
239 col >= 0 && row >= 0 && col < width && row < grid.height && cells[row * width + col] !== "#";
240 const walkable = (c: Cell): boolean => c !== "#" && c !== "x" && c !== "X";
241 const roomy = (col: number, row: number): boolean => {
242 if (!open(col, row)) return false;
243 if (cells[row * width + col] !== ".") return false;
244 if (!open(col - 1, row) || !open(col + 1, row) || !open(col, row - 1) || !open(col, row + 1)) return false;
245 let around = 0;
246 for (let dr = -1; dr <= 1; dr++)
247 for (let dc = -1; dc <= 1; dc++) if ((dc !== 0 || dr !== 0) && open(col + dc, row + dr)) around++;
248 return around >= 6;
249 };
250 const spots: Point[] = [];
251 for (let row = 1; row < grid.height / 2; row++)
252 for (let col = 1; col < width - 1; col++) if (roomy(col, row)) spots.push({ col, row });
253 let want = 6 + Math.floor(random() * 5);
254 while (want > 0 && spots.length > 0) {
255 const spot = spots.splice(Math.floor(random() * spots.length), 1)[0] as Point;
256 if (!roomy(spot.col, spot.row)) continue;
257 const before = cells[spot.row * width + spot.col] as Cell;
258 const mate = cells[(grid.height - 1 - spot.row) * width + (width - 1 - spot.col)] as Cell;
259 paint.set(spot.col, spot.row, random() < 0.7 ? "x" : "X");
260 const still = region(grid, walkable, siteMiddle.col, siteMiddle.row);
261 let cut = false;
262 for (let i = 0; i < cells.length && !cut; i++)
263 if (still[i] === 0 && walkable(cells[i] as Cell)) cut = true;
264 if (cut) {
265 cells[spot.row * width + spot.col] = before;
266 cells[(grid.height - 1 - spot.row) * width + (width - 1 - spot.col)] = mate;
267 continue;
268 }
269 want--;
270 }
271 }
272
273 // --- the gate -------------------------------------------------------------
274
275 /**
276 * What is wrong with this map, in sentences. A round needs somewhere for ten
277 * players to stand, two sites big enough to plant on, and a way from each spawn
278 * to each site that A* can actually find — the bots walk it, so it is asked of
279 * A* rather than of a flood fill. An empty list is a map that can be played.
280 */
281 export function flaws(level: Level): string[] {
282 const bad: string[] = [];
283 for (const side of ["t", "ct"] as const)
284 if (level.spawns[side].length < 8)
285 bad.push(`${level.name}: ${level.spawns[side].length} cells to spawn ${side} on, and a side is five players`);
286 for (const letter of ["A", "B"] as const) {
287 const paved = level.grid.cells.filter((c) => c === letter).length;
288 if (paved < 10) bad.push(`${level.name}: bombsite ${letter} is ${paved} cells, too small to fight over`);
289 }
290 for (const side of ["t", "ct"] as const)
291 for (const letter of ["A", "B"] as const) {
292 const from = level.spawns[side][0] as Vec3;
293 if (findPath(level, from, level.sites[letter]).length === 0)
294 bad.push(`${level.name}: no path from the ${side} spawn to bombsite ${letter}`);
295 }
296 const first = level.spawns.t[0] as Vec3;
297 const reached = region(level.grid, level.passable, colOf(level, first[0]), rowOf(level, first[1]));
298 let stranded = 0;
299 for (let i = 0; i < level.grid.cells.length; i++)
300 if (reached[i] === 0 && level.passable(level.grid.cells[i] as Cell)) stranded++;
301 if (stranded > 0) bad.push(`${level.name}: ${stranded} cells are walkable and cannot be walked to`);
302 return bad;
303 }
304
305 /**
306 * A map from a seed, whole and playable. The seed is tried, and a layout with a
307 * flaw in it is not shipped: the seed is mixed into another one and the work is
308 * done again, up to eight times. In 2,000 seeds measured none needed a second
309 * attempt, so the loop is insurance rather than a plan, and a seed that somehow
310 * spends all eight says so loudly instead of handing back a map nobody can
311 * finish a round on.
312 *
313 * The look is the seed's too, out of `looks.ts`: the floor, the walls, the
314 * cover and the sites are painted from one of eight named sets of the nine
315 * materials already in the atlas, so a name is a layout *and* a place. It is
316 * taken from the seed a person wrote down and not from `at`, which a retry
317 * moves — the name promises the look, and a map that needed a second attempt
318 * is still the map that name asked for.
319 */
320 export function generateLevel(seed: number, size = SIZE): Level {
321 let at = seed >>> 0;
322 let last: string[] = [];
323 const look = lookFor(seed);
324 for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
325 const level = levelOf(nameOf(seed), generateGrid(at, size), look);
326 last = flaws(level);
327 if (last.length === 0) return level;
328 at = (Math.imul(at, 1664525) + 1013904223) >>> 0;
329 }
330 throw new Error(`no map came out of seed ${seed >>> 0} in ${ATTEMPTS} attempts: ${last.join("; ")}`);
331 }
src/client/grenade.test.ts added +556
@@ -0,0 +1,556 @@
1 /**
2 * The grenade, checked by hand.
3 *
4 * The flight is arithmetic and is asserted as arithmetic: the solver takes
5 * both half-kicks of gravity before it moves a body down, which is
6 * semi-implicit Euler, so after `n` ticks of `dt` a body thrown level from
7 * `z0` at `v` is at
8 *
9 * x = v · n · dt z = z0 − g · dt² · n(n+1)/2
10 *
11 * — half a tick of gravity below the continuous parabola, and exact to the
12 * bit. Everything below is that formula, the bounce (0.45 of what it arrived
13 * with, reversed), and the blast's own numbers: 98 in the lap, 49 at half the
14 * radius, nothing at 350.
15 */
16
17 import { expect, test } from "bun:test";
18 import { DE_FORGE } from "./map.ts";
19 import { SHORT } from "./match.ts";
20 import {
21 blastCentre, blastHits, flashHits, MIN_BLIND, stepGrenade, throwGrenade, THROW_LIFT, THROW_SPEED,
22 viewAngle, type Looker,
23 } from "./grenade.ts";
24 import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts";
25 import { blastDamage, blindSeconds, FLASH, HE, WEAPONS } from "./weapons.ts";
26 import type { Actor } from "./combat.ts";
27 import type { Box, Vec3 } from "../engine/index.ts";
28 import { digest, TICK, withoutEntropy } from "./replay.ts";
29 import { buy, CARRY } from "./economy.ts";
30 import {
31 causeName, createWorld, eyePos, NO_INPUT, throwOne, update,
32 type Effect, type Input, type Player, type World,
33 } from "./world.ts";
34 import { effectAt, eyeHeight } from "../engine/index.ts";
35
36 /** Did anything the world remembers look like this? The buffer is read by index. */
37 function heard(world: World, is: (e: Effect) => boolean): boolean {
38 for (let i = 0; i < world.effects.count; i++) {
39 const effect = effectAt(world.effects, i);
40 if (effect !== undefined && is(effect)) return true;
41 }
42 return false;
43 }
44
45 const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 };
46 const EAST_WALL: Box = { min: [300, -4000, 0], max: [364, 4000, 400], mat: 0 };
47
48 /** A body at `pos`, made the way a test can read. */
49 const actor = (pos: Vec3, over: Partial<Actor> = {}): Actor => ({
50 id: 9, team: "t", pos, yaw: 0, duckAmount: 0, health: 100, armor: 0, helmet: false, alive: true,
51 ...over,
52 });
53
54 const EAST: Vec3 = [1, 0, 0];
55
56 test("the grenade is in the buy menu with CS's numbers, and carries a blast", () => {
57 expect(WEAPONS.he).toBe(HE);
58 expect(HE.price).toBe(300);
59 expect(HE.blast).toEqual({ damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 });
60 // Its own tuning, and not the player's: 0.4 of CS's gravity, a bounce, and
61 // no step — a grenade does not climb an eighteen-unit ledge like a person.
62 expect(NADE_MOVE.gravity).toBe(320);
63 expect(NADE_MOVE.restitution).toBe(0.45);
64 expect(NADE_MOVE.stepSize).toBe(0);
65 expect(NADE_HULL.stand).toBe(4);
66 });
67
68 test("a grenade thrown level flies where the arithmetic says", () => {
69 const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]);
70 // It leaves the hand eighteen units ahead of the eye, at 750 u/s.
71 expect(g.pos).toEqual([18, 0, 64]);
72 expect(g.vel).toEqual([THROW_SPEED, 0, 0]);
73 const gravity = NADE_MOVE.gravity;
74 for (let n = 1; n <= 16; n++) {
75 stepGrenade(g, TICK, [FLOOR]);
76 expect(g.pos[0]).toBeCloseTo(18 + THROW_SPEED * n * TICK, 6);
77 expect(g.pos[2]).toBeCloseTo(64 - gravity * TICK * TICK * ((n * (n + 1)) / 2), 6);
78 expect(g.fuse).toBeCloseTo(HE.blast.fuse - n * TICK, 10);
79 }
80 // A quarter of a second in: 187.5 units along, and 10.2 units of drop.
81 const quarter = 16;
82 expect(g.pos[0] - 18).toBeCloseTo(THROW_SPEED * quarter * TICK, 6);
83 expect(64 - g.pos[2]).toBeCloseTo(gravity * TICK * TICK * ((quarter * (quarter + 1)) / 2), 6);
84 });
85
86 test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", () => {
87 const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR, EAST_WALL]);
88 let bounces = 0;
89 let after = 0;
90 for (let n = 0; n < 64 && bounces === 0; n++) {
91 if (stepGrenade(g, TICK, [FLOOR, EAST_WALL])) {
92 bounces += 1;
93 after = g.vel[0];
94 }
95 }
96 expect(bounces).toBe(1);
97 // 750 out, 0.45 of it back: −337.5 u/s, and it is against the wall's face,
98 // its own two-unit radius short of it.
99 expect(after).toBeCloseTo(-THROW_SPEED * NADE_MOVE.restitution, 6);
100 expect(g.pos[0]).toBeCloseTo(EAST_WALL.min[0] - NADE_HULL.radius, 6);
101 });
102
103 test("a grenade dropped on the floor bounces, then comes to rest on it", () => {
104 const g = throwGrenade(0, "ct", "he", [0, 0, 200], [0, 0, -1], [FLOOR]);
105 let bounces = 0;
106 for (let n = 0; n < 64 * 6; n++) if (stepGrenade(g, TICK, [FLOOR])) bounces += 1;
107 expect(bounces).toBeGreaterThan(0);
108 expect(g.onGround).toBe(true);
109 expect(g.pos[2]).toBe(0);
110 expect(Math.hypot(g.vel[0], g.vel[1], g.vel[2])).toBeLessThan(1);
111 });
112
113 test("the blast falls off in a straight line to nothing at the radius", () => {
114 const blast = HE.blast;
115 expect(blastDamage(blast, 0)).toBeCloseTo(98, 10);
116 expect(blastDamage(blast, blast.radius / 2)).toBeCloseTo(49, 10);
117 expect(blastDamage(blast, blast.radius)).toBe(0);
118 expect(blastDamage(blast, blast.radius + 1)).toBe(0);
119 expect(blastDamage(blast, 87.5)).toBeCloseTo(73.5, 10);
120 });
121
122 test("the blast is 98 at the centre, 49 at half the radius, and armour halves it", () => {
123 // The grenade at the feet of a body standing at the origin: the centre of
124 // the blast is 2 units up and the middle of the chest is 54, so 52 units is
125 // as close as a standing body gets — 98 · (1 − 52/350) = 83.4.
126 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
127 const at = (x: number, over: Partial<Actor> = {}): { health: number; armor: number } => {
128 const hits = blastHits(g, [actor([x, 0, 0], over)], []);
129 return { health: hits[0]?.health ?? 0, armor: hits[0]?.armor ?? 0 };
130 };
131 const centre = blastCentre(g);
132 const chest = 0.75 * 72; // the middle of the chest box, 62%..88% of a 72-unit hull
133 const near = Math.hypot(0, chest - centre[2]);
134 expect(at(0).health).toBe(Math.round(blastDamage(HE.blast, near)));
135 expect(at(0).health).toBe(83);
136
137 // Half the radius away along the floor: √(175² + 52²) = 182.6 units, which
138 // is 98 · (1 − 182.6/350) = 46.9.
139 const half = Math.hypot(175, chest - centre[2]);
140 expect(at(175).health).toBe(Math.round(blastDamage(HE.blast, half)));
141 expect(at(175).health).toBe(47);
142
143 // Past it, nothing at all — no entry, not a zero.
144 expect(blastHits(g, [actor([400, 0, 0])], [])).toEqual([]);
145
146 // Armour takes half of what it stops, like every other source of damage:
147 // 83.44 becomes 42 through the vest, and the vest wears 21 down.
148 expect(at(0, { armor: 100 })).toEqual({ health: 42, armor: 21 });
149 });
150
151 test("a grenade behind a crate hurts nobody on the far side", () => {
152 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
153 const behind = actor([200, 0, 0]);
154 // In the open, 200 units is well inside the radius and it hurts.
155 expect(blastHits(g, [behind], [FLOOR])[0]?.health).toBeGreaterThan(0);
156 // A crate between the two takes all of it: `traceWorld` decides, not distance.
157 const crate: Box = { min: [100, -80, 0], max: [140, 80, 90], mat: 0 };
158 expect(blastHits(g, [behind], [FLOOR, crate])).toEqual([]);
159 // And the man standing beside the crate, with nothing in the way, still dies.
160 expect(blastHits(g, [actor([200, 200, 0])], [FLOOR, crate])[0]?.health).toBeGreaterThan(0);
161 });
162
163 test("the blast does not reach the thrower's own side", () => {
164 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
165 expect(blastHits(g, [actor([0, 0, 0], { team: "ct" })], [FLOOR])).toEqual([]);
166 expect(blastHits(g, [actor([0, 0, 0], { team: "t" })], [FLOOR]).length).toBe(1);
167 });
168
169 // --- in a round -----------------------------------------------------------
170
171 const TICKS_PER_SECOND = 64;
172
173 /** A world past the freeze, with `me` holding one grenade and nothing else to do. */
174 function ready(seed: number, teamSize = 5): World {
175 const world = createWorld(DE_FORGE, SHORT, "me", "ct", teamSize, seed);
176 world.me.money = 5000;
177 expect(buy(world, world.me, "he")).toBe(true);
178 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
179 expect(world.match.phase).toBe("live");
180 return world;
181 }
182
183 test("a grenade is bought like anything else, one at a time, and goes in a pocket", () => {
184 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 11);
185 world.me.money = 5000;
186 const before = world.me.weapon;
187 expect(buy(world, world.me, "he")).toBe(true);
188 expect(world.me.pocket.he).toBe(CARRY.he);
189 expect(world.me.money).toBe(5000 - HE.price);
190 // It did not become the gun in your hands and it did not displace one.
191 expect(world.me.weapon).toBe(before);
192 expect(world.me.primary).toBe(null);
193 // One a round, CS's rule: the second is refused and costs nothing.
194 expect(buy(world, world.me, "he")).toBe(false);
195 expect(world.me.money).toBe(5000 - HE.price);
196 });
197
198 test("the throw key throws one, once a press, and never during the freeze", () => {
199 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 12);
200 world.me.money = 5000;
201 buy(world, world.me, "he");
202 const held: Input = { ...NO_INPUT, throwing: "he" };
203 // Frozen: the key does nothing and the grenade stays in the pocket.
204 update(world, TICK, held);
205 expect(world.grenades.length).toBe(0);
206 expect(world.me.pocket.he).toBe(1);
207
208 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
209 // Live, and held down for half a second: exactly one leaves the hand.
210 for (let t = 0; t < 32; t++) update(world, TICK, held);
211 expect(world.grenades.length).toBe(1);
212 expect(world.me.pocket.he).toBe(0);
213 expect(world.grenades[0]?.owner).toBe(world.me.id);
214 // And the throw was heard where the hand was.
215 expect(heard(world, (e) => e.kind === "sound" && e.name === "throw")).toBe(true);
216 });
217
218 test("the fuse is the same number of ticks whatever the frame rate", () => {
219 /** Step `world` in frames of `perFrame` ticks; return the tick it went off on. */
220 const fuseTicks = (perFrame: number): number => {
221 const world = ready(21);
222 world.me.pitch = THROW_LIFT; // aim down by the lift: a dead level throw
223 expect(throwOne(world, world.me)).toBe(true);
224 let ticks = 0;
225 while (world.grenades.length > 0 && ticks < 64 * 5) {
226 for (let f = 0; f < perFrame; f++) {
227 update(world, TICK, NO_INPUT);
228 ticks += 1;
229 }
230 }
231 return ticks;
232 };
233 // 1.5 seconds of 64 Hz ticks is 96 of them, and a frame that batches four of
234 // them at a time — a sixteen-frame-a-second phone — detonates on the same one.
235 expect(fuseTicks(1)).toBe(Math.ceil(HE.blast.fuse * TICKS_PER_SECOND));
236 expect(fuseTicks(4)).toBe(fuseTicks(1));
237 });
238
239 test("a blast that hurts someone marks the hit, and one that kills names the grenade", () => {
240 // One a side, so the only thing that can hurt anybody here is the grenade:
241 // nobody on this player's team exists to shoot, and this player never fires.
242 const world = ready(31, 1);
243 const victim = world.players.find((p) => p.team === "t") as Player;
244 const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]];
245 victim.armor = 0;
246 const kills = world.me.kills;
247 expect(throwOne(world, world.me)).toBe(true);
248 let healthBefore = victim.health;
249 let damageBefore = world.me.damage;
250 for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
251 // Hold the victim still and the grenade at their feet: what is under test
252 // is the blast, not whether a thrown one can be aimed at a moving bot.
253 victim.pos = [where[0], where[1], where[2]];
254 for (const g of world.grenades) {
255 g.pos = [where[0], where[1], where[2]];
256 g.vel = [0, 0, 0];
257 }
258 healthBefore = victim.health;
259 damageBefore = world.me.damage;
260 update(world, TICK, NO_INPUT);
261 }
262
263 expect(victim.health).toBeLessThan(healthBefore);
264 expect(world.me.damage).toBeGreaterThan(damageBefore);
265 expect(heard(world, (e) => e.kind === "hitmark")).toBe(true);
266 // The mark says whether the blast finished them, as a bullet's does.
267 expect(heard(world, (e) => e.kind === "hitmark" && e.killed === !victim.alive)).toBe(true);
268 expect(heard(world, (e) => e.kind === "blast")).toBe(true);
269 expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(true);
270 // 83 hit points at the feet of a body with no vest is not a kill on its own,
271 // so a kill is only asserted when the blast actually finished them.
272 if (!victim.alive) {
273 expect(world.me.kills).toBe(kills + 1);
274 const entry = world.feed[world.feed.length - 1];
275 expect(entry?.weapon).toBe("he");
276 expect(causeName(entry?.weapon ?? "knife")).toBe("HE Grenade");
277 }
278 });
279
280 test("a blast that finishes a hurt man puts the grenade in the feed", () => {
281 const world = ready(33, 1);
282 const victim = world.players.find((p) => p.team === "t") as Player;
283 victim.health = 20; // one blast away, whatever the falloff says
284 const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]];
285 expect(throwOne(world, world.me)).toBe(true);
286 for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
287 victim.pos = [where[0], where[1], where[2]];
288 for (const g of world.grenades) {
289 g.pos = [where[0], where[1], where[2]];
290 g.vel = [0, 0, 0];
291 }
292 update(world, TICK, NO_INPUT);
293 }
294 expect(victim.alive).toBe(false);
295 expect(world.me.kills).toBe(1);
296 const entry = world.feed[world.feed.length - 1];
297 expect(entry?.weapon).toBe("he");
298 expect(entry?.killer).toBe(world.me.name);
299 expect(WEAPONS.he.name).toBe("HE Grenade");
300 });
301
302 test("a grenade is drawn where the body is, and forgotten when it goes off", () => {
303 const world = ready(41);
304 expect(throwOne(world, world.me)).toBe(true);
305 const g = world.grenades[0];
306 if (g === undefined) throw new Error("the throw made no grenade");
307 const start: Vec3 = [g.pos[0], g.pos[1], g.pos[2]];
308 for (let t = 0; t < 16; t++) update(world, TICK, NO_INPUT);
309 expect(world.grenades.length).toBe(1);
310 expect(g.pos).not.toEqual(start);
311 for (let t = 0; t < 2 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
312 expect(world.grenades.length).toBe(0);
313 });
314
315 test("a round with a grenade in it is still bit-for-bit reproducible", () => {
316 const TICKS = 8 * TICKS_PER_SECOND;
317 // A script that buys one in the freeze and throws it the moment it is live.
318 const script = [
319 { atTick: 0, input: {}, buy: "he" as const },
320 { atTick: 5 * TICKS_PER_SECOND, input: { throwing: "he" as const } },
321 { atTick: 5 * TICKS_PER_SECOND + 8, input: {} },
322 ];
323 const run = (): ReturnType<typeof digest> =>
324 withoutEntropy(() => {
325 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
326 world.me.money = 5000;
327 return digest(world, TICKS, script);
328 });
329 const quiet = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), TICKS));
330 const a = run();
331 const b = run();
332 expect(a.digest).toBe(b.digest);
333 expect(a.seconds).toEqual(b.seconds);
334 // And it is a different run from the one where nobody threw anything: the
335 // grenade is in the hash, so a thrown one cannot hide.
336 expect(a.digest).not.toBe(quiet.digest);
337 });
338
339 test("an input that never sets the throw plays exactly as it did before", () => {
340 // The whole of what a bot had to learn for this feature: nothing.
341 expect(NO_INPUT.throwing).toBe(null);
342 const world = ready(51);
343 world.me.pocket.he = 1;
344 const before = eyePos(world.me);
345 for (let t = 0; t < 64; t++) update(world, TICK, NO_INPUT);
346 expect(world.grenades.length).toBe(0);
347 expect(world.me.pocket.he).toBe(1);
348 expect(before.length).toBe(3);
349 });
350
351 // --- the flash ------------------------------------------------------------
352
353 /** A body at `pos` looking along `yaw`/`pitch`: what a flash needs to see. */
354 const looker = (pos: Vec3, over: Partial<Looker> = {}): Looker => ({
355 ...actor(pos), pitch: 0, ...over,
356 });
357
358 /** The height a standing body's eye is at: what a flash is measured to. */
359 const EYE = eyeHeight({ duckAmount: 0 }, CS_HULL);
360
361 /**
362 * A flashbang lying still with its centre at `where`, out of nobody's hand.
363 * The tests below put it at eye height so the distances are the round numbers
364 * they are written as rather than a hypotenuse nobody can check by hand.
365 */
366 const lit = (where: Vec3): ReturnType<typeof throwGrenade> =>
367 throwGrenade(0, "ct", "flash", [where[0], where[1], where[2] - NADE_HULL.stand / 2], [0, 0, 0], [FLOOR]);
368
369 test("a flashbang is a grenade like the other one: same flight, its own fuse", () => {
370 const he = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]);
371 const flash = throwGrenade(0, "ct", "flash", [0, 0, 64], EAST, [FLOOR]);
372 expect(flash.weapon).toBe("flash");
373 expect(flash.fuse).toBe(FLASH.flash.fuse);
374 expect(he.fuse).toBe(HE.blast.fuse);
375 // The same body through the same solver: sixteen ticks apart they are still
376 // in exactly the same place, because nothing about the flight is the kind's.
377 for (let n = 0; n < 16; n++) {
378 stepGrenade(he, TICK, [FLOOR]);
379 stepGrenade(flash, TICK, [FLOOR]);
380 }
381 expect(flash.pos).toEqual(he.pos);
382 expect(flash.vel).toEqual(he.vel);
383 // And it does no damage to anybody, however close they stand.
384 expect(blastHits(flash, [actor([0, 0, 0])], [FLOOR])).toEqual([]);
385 // While the HE blinds nobody: the two ends are the only thing that differs.
386 expect(flashHits(he, [looker([0, 0, 0])], [FLOOR])).toEqual([]);
387 });
388
389 test("a flash blinds by how much of it you could see, and not at all with no line", () => {
390 const g = lit([0, 0, EYE]);
391 const seconds = (l: Looker, boxes: Box[] = [FLOOR]): number =>
392 flashHits(g, [l], boxes)[0]?.seconds ?? 0;
393 const max = FLASH.flash.maxBlind;
394
395 // Standing 300 units east, looking west straight at it: 1.6 · 1 · (1 − 300/1200).
396 const staring = looker([300, 0, 0], { yaw: 180 });
397 expect(seconds(staring)).toBeCloseTo(max * 0.75, 6);
398
399 // Looking away works: the same body turned round sees none of it.
400 expect(seconds(looker([300, 0, 0], { yaw: 0 }))).toBe(0);
401 // And half turned away — 90° off, which is 45/105 of the way from 30 to 135.
402 expect(seconds(looker([300, 0, 0], { yaw: 90 }))).toBeCloseTo(max * (45 / 105) * 0.75, 6);
403
404 // A wall works: `traceWorld` decides whether there was a line at all, and a
405 // crate between the two takes every bit of it however squarely they looked.
406 const crate: Box = { min: [100, -80, 0], max: [140, 80, 120], mat: 0 };
407 expect(seconds(staring, [FLOOR, crate])).toBe(0);
408 // The man beside the crate, with nothing in the way, is blinded as before.
409 expect(seconds(looker([300, 300, 0], { yaw: 225 }), [FLOOR, crate])).toBeGreaterThan(0);
410
411 // Being behind it works: a flash at your back, with your view down the far
412 // way, is over your shoulder and costs nothing however close it went off.
413 expect(seconds(looker([-300, 0, 0], { yaw: 180 }))).toBe(0);
414 expect(seconds(looker([-20, 0, 0], { yaw: 180 }))).toBe(0);
415
416 // Being behind *someone* is not cover: a body is not a wall, and the trace
417 // is against the world's boxes exactly like the blast's is.
418 const shield = looker([150, 0, 0], { yaw: 180 });
419 expect(flashHits(g, [shield, staring], [FLOOR]).length).toBe(2);
420
421 // Past the radius it is a light in another room.
422 expect(seconds(looker([1300, 0, 0], { yaw: 180 }))).toBe(0);
423 // And a flicker is not a blinding: anything under MIN_BLIND is no entry.
424 expect(blindSeconds(FLASH.flash, 0, 1150)).toBeLessThan(MIN_BLIND);
425 expect(flashHits(g, [looker([1150, 0, 0], { yaw: 180 })], [FLOOR])).toEqual([]);
426 });
427
428 test("a flash does not care whose side you are on, and the dead keep their sight", () => {
429 const g = lit([0, 0, EYE]);
430 const mine = looker([200, 0, 0], { yaw: 180, team: "ct" }); // the thrower's own side
431 const theirs = looker([200, 0, 0], { yaw: 180, team: "t" });
432 const dead = looker([200, 0, 0], { yaw: 180, team: "t", alive: false });
433 expect(flashHits(g, [mine], [FLOOR]).length).toBe(1);
434 expect(flashHits(g, [theirs], [FLOOR]).length).toBe(1);
435 expect(flashHits(g, [dead], [FLOOR])).toEqual([]);
436 // The same two under the HE: it spares the thrower's team and hurts the other.
437 const he = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
438 expect(blastHits(he, [mine], [FLOOR])).toEqual([]);
439 expect(blastHits(he, [theirs], [FLOOR]).length).toBe(1);
440 });
441
442 test("the angle a flash is judged at is the whole view, not the yaw", () => {
443 const overhead: Vec3 = [0, 0, EYE + 200];
444 const g = lit(overhead);
445 const below = looker([0, 0, 0]);
446 // It went off straight overhead: looking up is looking at it, and looking
447 // level is ninety degrees off it whichever way the body is turned.
448 expect(viewAngle({ ...below, pitch: -90 }, overhead)).toBeCloseTo(0, 6);
449 expect(viewAngle({ ...below, pitch: 0 }, overhead)).toBeCloseTo(90, 6);
450 expect(viewAngle({ ...below, pitch: 0, yaw: 90 }, overhead)).toBeCloseTo(90, 6);
451 const up = flashHits(g, [{ ...below, pitch: -90 }], [FLOOR])[0]?.seconds ?? 0;
452 const level = flashHits(g, [{ ...below, pitch: 0 }], [FLOOR])[0]?.seconds ?? 0;
453 expect(up).toBeGreaterThan(level);
454 expect(up).toBeCloseTo(FLASH.flash.maxBlind * (1 - 200 / FLASH.flash.radius), 6);
455 });
456
457 test("a flash is bought alongside the HE, one of each, and thrown on its own key", () => {
458 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13);
459 world.me.money = 5000;
460 expect(buy(world, world.me, "he")).toBe(true);
461 expect(buy(world, world.me, "flash")).toBe(true);
462 // Carried alongside, not instead of: both are in the pocket at once.
463 expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash, smoke: 0 });
464 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
465 // One of each a round: the second of either is refused and costs nothing.
466 expect(buy(world, world.me, "flash")).toBe(false);
467 expect(buy(world, world.me, "he")).toBe(false);
468 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
469 // And neither went into your hands.
470 expect(world.me.weapon).toBe(world.me.secondary);
471
472 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
473 // The flash key throws the flash and leaves the HE where it was.
474 for (let t = 0; t < 32; t++) update(world, TICK, { ...NO_INPUT, throwing: "flash" });
475 expect(world.grenades.length).toBe(1);
476 expect(world.grenades[0]?.weapon).toBe("flash");
477 expect(world.me.pocket).toEqual({ he: 1, flash: 0, smoke: 0 });
478 });
479
480 /** Everyone but `p` put out of the round, so only the flash can change anything. */
481 const alone = (world: World, p: Player): void => {
482 for (const other of world.players) if (other !== p) other.alive = false;
483 };
484
485 test("a flash that goes off blinds everyone looking at it, and the clock burns it down", () => {
486 // One a side, and both bodies held still, alive and whole for the second and
487 // a half the fuse burns: what is under test is who the flash reaches.
488 const world = ready(37, 1);
489 const me = world.me;
490 const victim = world.players.find((p) => p.team === "t" && p.alive) as Player;
491 // Standing next to each other in the open, looking at one another, with the
492 // flash between them: what is under test is the blinding, not the throw.
493 victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]];
494 victim.yaw = 180;
495 me.yaw = 0;
496 const where: Vec3 = [me.pos[0] + 32, me.pos[1], me.pos[2] + EYE];
497 me.pocket.flash = 1;
498 expect(throwOne(world, me, "flash")).toBe(true);
499 for (let t = 0; t < 3 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
500 for (const g of world.grenades) {
501 g.pos = [where[0], where[1], where[2]];
502 g.vel = [0, 0, 0];
503 }
504 victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]];
505 victim.yaw = 180;
506 victim.health = 100;
507 me.health = 100;
508 me.yaw = 0;
509 update(world, TICK, NO_INPUT);
510 }
511 // Both of them were looking at it, so both are blinded: light is not on a side.
512 expect(victim.blind).toBeGreaterThan(0);
513 expect(me.blind).toBeGreaterThan(0);
514 expect(me.blind).toBeLessThanOrEqual(FLASH.flash.maxBlind);
515 // It was heard as a flash, not as a blast, and it drew its own pop.
516 expect(heard(world, (e) => e.kind === "sound" && e.name === "flash")).toBe(true);
517 expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(false);
518 expect(heard(world, (e) => e.kind === "blast" && e.weapon === "flash")).toBe(true);
519 // Nobody was hurt and nothing went in the feed: a flash is a chance, not a kill.
520 expect(victim.health).toBe(100);
521 expect(world.feed.length).toBe(0);
522
523 // And it burns down on the simulation's clock, a tick at a time, to nothing.
524 const left = me.blind;
525 update(world, TICK, NO_INPUT);
526 expect(me.blind).toBeCloseTo(left - TICK, 10);
527 for (let t = 0; t < 3 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
528 expect(me.blind).toBe(0);
529 expect(world.match.phase === "live" || world.match.phase === "over").toBe(true);
530 });
531
532 test("two flashes in the face are not twice the blindness: neither passes the ceiling", () => {
533 const world = ready(39, 1);
534 const me = world.me;
535 alone(world, me);
536 me.yaw = 0; // looking straight at where each of them goes off, forty units out
537 const max = FLASH.flash.maxBlind;
538 /** Go off at the eye, and step until it is gone. */
539 const pop = (): void => {
540 const g = throwGrenade(
541 me.id, me.team, "flash", [me.pos[0] + 40, me.pos[1], me.pos[2] + EYE - NADE_HULL.stand / 2], [0, 0, 0], [],
542 );
543 g.fuse = TICK;
544 world.grenades.push(g);
545 while (world.grenades.length > 0) update(world, TICK, NO_INPUT);
546 };
547 pop();
548 const first = me.blind;
549 expect(first).toBeGreaterThan(max * 0.9);
550 expect(first).toBeLessThanOrEqual(max);
551 // A second one on top of the first: the longer of the two, never the sum.
552 pop();
553 expect(me.blind).toBeLessThanOrEqual(max);
554 expect(me.blind).toBeGreaterThan(max * 0.9);
555 expect(me.blind).toBeLessThan(first * 2);
556 });
src/client/grenade.ts added +356
@@ -0,0 +1,356 @@
1 /**
2 * The thrown grenade: a body, a fuse, and whatever it does when the fuse runs
3 * out. Three kinds are thrown today — the HE, the flashbang and the smoke — and
4 * the only thing in this file that knows which is `Grenade.weapon`: the flight,
5 * the bounce, the fuse and the sounds are one piece of machinery all three use,
6 * and what arrives at the far end is `blastHits` for one, `flashHits` for
7 * another and `cloudFrom` for the third. A fourth kind is a row in `weapons.ts`
8 * and a function here.
9 *
10 * There is no solver in this file. A grenade in flight is a `Body` like a
11 * player is, and it is moved by the same `move` in `src/engine/physics.ts` —
12 * handed `NADE_MOVE` and `NADE_HULL` from `src/client/movement.ts` instead of
13 * the player's pair, which is the whole of why it floats, bounces and comes to
14 * rest instead of walking. It has its own four-unit hull on purpose: sharing
15 * the player's would let it step eighteen units up a ledge like a person.
16 *
17 * The fuse burns on the simulation's clock — `dt` seconds a tick, at the fixed
18 * 64 Hz `world.ts` steps at — so it is the same number of ticks on a phone as
19 * on a desktop, and the same number in a replay as in a match.
20 *
21 * Neither of the first two reaches anything it cannot see. `blastHits` and
22 * `flashHits` both trace from the middle of the detonation through the engine's
23 * own `visible`, so a wall, a crate or a corner takes all of it: a grenade
24 * cannot kill through a wall here and a flash cannot blind through one, which
25 * are the properties the two tests of that name hold.
26 *
27 * The smoke is the odd one and it is odd in the other direction: it reaches
28 * nobody at all, and what it leaves behind outlives it. `cloudFrom` turns a
29 * spent canister into a `Cloud` — a ball with a clock — which the world keeps
30 * and `canSee` in `src/client/world.ts` traces against. Nothing about a cloud
31 * is drawing: it is the same ball on the screen and in the simulation.
32 */
33
34 import {
35 angleVectors, clamp, dist, dot, eyeHeight, move, normalize, stuck, sub, visible,
36 type Body, type Box, type Vec3, type Wish,
37 } from "../engine/index.ts";
38 import { hitboxes, type Actor } from "./combat.ts";
39 import { CS_HULL, NADE_HULL, NADE_MOVE } from "./movement.ts";
40 import {
41 blastDamage, blindSeconds, fuseOf, SMOKE, WEAPONS, throughArmor,
42 type Smoke, type ThrownId,
43 } from "./weapons.ts";
44
45 /** A grenade in the world: a body, which kind it is, whose it is, and how long it has left. */
46 export type Grenade = Body & {
47 /** Which thrown weapon this is — what happens at zero on the fuse. */
48 weapon: ThrownId;
49 /** The player id that threw it — who the kill is credited to. */
50 owner: number;
51 team: "t" | "ct";
52 /** Seconds left on the fuse. It detonates on the tick this reaches zero. */
53 fuse: number;
54 };
55
56 /**
57 * A grenade wishes for nothing: it has no legs. The same object every tick,
58 * because it never changes and a frame allocates nothing.
59 */
60 const STILL: Wish = {
61 forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: 0,
62 };
63
64 /** How fast it leaves the hand, in u/s. CS's full throw is 750. */
65 export const THROW_SPEED = 750;
66
67 /**
68 * Degrees above the crosshair a throw goes. CS tilts a throw up by ten so that
69 * a grenade thrown at a wall across the room arrives at head height rather
70 * than at your feet; aim ten degrees down and it flies dead level.
71 */
72 export const THROW_LIFT = 10;
73
74 /** How far in front of the eye it appears, in units: out of the hand, not the face. */
75 const HAND = 18;
76
77 /**
78 * The sounds a grenade makes, in the order one round of it happens. The first
79 * two belong to every thrown thing; the rest are each a kind's own — the HE's
80 * `blast`, the flashbang's `flash`, the canister's `smoke` — which is why this
81 * is one widening type and not a second one per grenade.
82 */
83 export type GrenadeSound = "throw" | "bounce" | "blast" | "flash" | "smoke";
84
85 /**
86 * Below this the knock of a bounce is not worth a sound: a grenade rolling to
87 * a stop taps the floor every tick and nobody wants to hear that. In u/s of
88 * the component that reversed.
89 */
90 const HEARD = 90;
91
92 /**
93 * A grenade of kind `weapon` leaving a hand at `from` along `dir`. It appears a
94 * little ahead of the eye so it is not thrown from inside the thrower's own head
95 * — unless that would put it inside a wall, in which case it starts at the eye,
96 * because a grenade that spawns inside geometry is a grenade that falls through
97 * the map. The fuse is the kind's own and comes from the weapon table; nothing
98 * about the flight does, which is why both kinds fly identically.
99 */
100 export function throwGrenade(
101 owner: number,
102 team: "t" | "ct",
103 weapon: ThrownId,
104 from: Vec3,
105 dir: Vec3,
106 boxes: Box[],
107 ): Grenade {
108 const ahead: Vec3 = [
109 from[0] + dir[0] * HAND,
110 from[1] + dir[1] * HAND,
111 from[2] + dir[2] * HAND,
112 ];
113 const clear = !stuck(ahead, NADE_HULL.stand, boxes, NADE_HULL);
114 const pos: Vec3 = clear ? ahead : [from[0], from[1], from[2]];
115 return {
116 pos,
117 vel: [dir[0] * THROW_SPEED, dir[1] * THROW_SPEED, dir[2] * THROW_SPEED],
118 onGround: false,
119 ducking: false,
120 duckAmount: 0,
121 weapon,
122 owner,
123 team,
124 fuse: fuseOf(WEAPONS[weapon]),
125 };
126 }
127
128 /**
129 * One tick of a grenade: the engine's solver moves it, the fuse burns by `dt`.
130 * True when it hit something hard enough to be heard — which is a component of
131 * its velocity coming back the other way, since a bounce is the only thing
132 * that can reverse one.
133 */
134 export function stepGrenade(g: Grenade, dt: number, boxes: Box[]): boolean {
135 const vx = g.vel[0], vy = g.vel[1], vz = g.vel[2];
136 move(g, STILL, dt, boxes, NADE_MOVE, NADE_HULL);
137 g.fuse -= dt;
138 return (
139 (vx * g.vel[0] < 0 && Math.abs(vx) > HEARD) ||
140 (vy * g.vel[1] < 0 && Math.abs(vy) > HEARD) ||
141 (vz * g.vel[2] < 0 && Math.abs(vz) > HEARD)
142 );
143 }
144
145 /**
146 * How slowly a grenade has to be moving, in u/s, to count as having stopped.
147 * A shell settling on concrete never quite reaches zero — the solver leaves it
148 * a fraction of a unit a second — so "at rest" is a small number and not one.
149 */
150 const REST_SPEED = 20;
151
152 /**
153 * How many seconds past its fuse a smoke will wait for the floor before it
154 * opens anyway. A canister thrown off a ledge, wedged in a corner or bouncing
155 * down a long stair is still a grenade somebody threw, and one that never opens
156 * is worse than one that opens in the air. Two seconds is longer than any throw
157 * on any map this game generates takes to settle.
158 */
159 const REST_PATIENCE = 2;
160
161 /** Is this grenade lying still enough for a cloud to grow where it stopped? */
162 export const atRest = (g: Grenade): boolean =>
163 g.onGround && Math.hypot(g.vel[0], g.vel[1], g.vel[2]) < REST_SPEED;
164
165 /**
166 * Has this one's fuse run out — has it done whatever it does yet?
167 *
168 * For the HE and the flashbang this is the comparison it has always been: zero
169 * on the fuse is the tick it goes off, in the air or on the floor, because a
170 * blast and a flash happen wherever the thing happens to be.
171 *
172 * A smoke is different in the one way that matters to a round: a cloud grows
173 * where the canister *stopped*, so a fuse that runs out in mid-flight is held
174 * — the fuse keeps burning negative, which is what makes the wait a number the
175 * digest can see — until the thing is on the floor and still, or until
176 * `REST_PATIENCE` seconds have gone by and it opens where it is.
177 */
178 export function spent(g: Grenade): boolean {
179 if (g.weapon !== "smoke") return g.fuse <= 0;
180 return g.fuse <= -REST_PATIENCE || (g.fuse <= 0 && atRest(g));
181 }
182
183 /** The middle of the explosion: the middle of the grenade, not its feet. */
184 export const blastCentre = (g: Grenade): Vec3 => [g.pos[0], g.pos[1], g.pos[2] + NADE_HULL.stand / 2];
185
186 /** What the blast is aimed at on a body: the middle of the chest box. */
187 function chestOf(actor: Actor): Vec3 {
188 const parts = hitboxes(actor);
189 const chest = parts.find((p) => p.group === "chest") ?? parts[0];
190 if (chest === undefined) return [actor.pos[0], actor.pos[1], actor.pos[2]];
191 return [
192 (chest.min[0] + chest.max[0]) / 2,
193 (chest.min[1] + chest.max[1]) / 2,
194 (chest.min[2] + chest.max[2]) / 2,
195 ];
196 }
197
198 /** One body the blast reached, and what it cost them. */
199 export type Caught = { actor: Actor; distance: number; health: number; armor: number };
200
201 /**
202 * Who this blast reaches and for how much: the falloff in `weapons.ts` decides
203 * the damage by distance, `throughArmor` takes the vest's share of it like
204 * every other source of damage in this game, and the engine's `visible`
205 * decides whether any of it arrives at all.
206 *
207 * The thrower's own side is left out — this match has no friendly fire, the
208 * same rule a bullet is under in `world.ts` — which also means a grenade does
209 * not hurt the person who threw it.
210 */
211 export function blastHits(g: Grenade, actors: Actor[], boxes: Box[]): Caught[] {
212 const blast = WEAPONS[g.weapon].blast;
213 if (blast === undefined) return [];
214 const centre = blastCentre(g);
215 const caught: Caught[] = [];
216 for (const actor of actors) {
217 if (!actor.alive || actor.team === g.team) continue;
218 const chest = chestOf(actor);
219 const distance = dist(centre, chest);
220 const raw = blastDamage(blast, distance);
221 if (raw <= 0) continue;
222 if (!visible(centre, chest, boxes)) continue;
223 caught.push({ actor, distance, ...throughArmor(raw, actor.armor, blast.armorPenetration) });
224 }
225 return caught;
226 }
227
228 // --- the flash ------------------------------------------------------------
229
230 /** What a flash needs on top of a body: which way the eyes were pointing. */
231 export type Looker = Actor & { pitch: number };
232
233 /** The eye a flash is seen with — the same height a bullet leaves from. */
234 const eyeOf = (a: Looker): Vec3 => [a.pos[0], a.pos[1], a.pos[2] + eyeHeight(a, CS_HULL)];
235
236 /**
237 * Degrees between where `a` was looking and `at`. The whole view direction, not
238 * just the yaw: a flash at your feet is one you are looking away from, and a
239 * player who was staring at the floor should keep their sight.
240 */
241 export function viewAngle(a: Looker, at: Vec3): number {
242 const to = sub(at, eyeOf(a));
243 if (dist(at, eyeOf(a)) < 1e-6) return 0;
244 const look = angleVectors(a.yaw, a.pitch).forward;
245 return (Math.acos(clamp(dot(look, normalize(to)), -1, 1)) * 180) / Math.PI;
246 }
247
248 /**
249 * Below this a flash is a flicker, not a blinding: a tenth of a second is six
250 * ticks, less than a frame of white, and not worth taking anybody's screen for.
251 */
252 export const MIN_BLIND = 0.1;
253
254 /** One body a flash reached, and for how long it took their sight. */
255 export type Blinded = { actor: Looker; seconds: number; angle: number; distance: number };
256
257 /**
258 * Who this flash blinds and for how long. Three questions in this order, and
259 * the cheap ones first: was there a line at all (the engine's `visible`, eye to
260 * pop — a wall takes all of it), how far away was it, and how much of the view
261 * it went off in. `blindSeconds` in `weapons.ts` is the curve; this is the
262 * geometry it is fed.
263 *
264 * Unlike the blast, a flash does not care whose side you are on. It is light in
265 * a room: it blinds the thrower's team, the thrower, and the enemy alike, which
266 * is CS's rule and the only one that makes throwing one a decision.
267 *
268 * Anything under `MIN_BLIND` is dropped rather than returned as a flicker
269 * nobody can act on.
270 */
271 export function flashHits(g: Grenade, actors: Looker[], boxes: Box[]): Blinded[] {
272 const flash = WEAPONS[g.weapon].flash;
273 if (flash === undefined) return [];
274 const centre = blastCentre(g);
275 const blinded: Blinded[] = [];
276 for (const actor of actors) {
277 if (!actor.alive) continue;
278 const eye = eyeOf(actor);
279 const distance = dist(centre, eye);
280 if (distance >= flash.radius) continue;
281 if (!visible(centre, eye, boxes)) continue;
282 const angle = viewAngle(actor, centre);
283 const seconds = blindSeconds(flash, angle, distance);
284 if (seconds < MIN_BLIND) continue;
285 blinded.push({ actor, seconds, angle, distance });
286 }
287 return blinded;
288 }
289
290 // --- the cloud ------------------------------------------------------------
291
292 /**
293 * A cloud of smoke standing in the world: where its middle is, when it opened,
294 * and whose canister it was. Everything else about it — how wide it is right
295 * now, whether it is still there at all — is arithmetic on `born` and the
296 * world's clock, which is why there is no radius on this record and no tick of
297 * a cloud anywhere in the game. A cloud is not stepped; it is *read*.
298 */
299 export type Cloud = {
300 pos: Vec3;
301 /** The simulated second the canister opened. */
302 born: number;
303 /** The player id that threw it. Nothing reads it yet; the feed will. */
304 owner: number;
305 };
306
307 /**
308 * How high above the canister the middle of the ball sits, in units. A smoke
309 * lying on the floor with its middle at its own height would be a ball half
310 * buried in concrete and a hole a crouching body could see under; lifting it
311 * most of a standing hull puts the bottom of the ball on the floor and the top
312 * of it over a head, which is the wall a smoke is supposed to be.
313 */
314 const CLOUD_LIFT = 58;
315
316 /** The cloud a spent canister leaves behind, opening at `time`. */
317 export const cloudFrom = (g: Grenade, time: number): Cloud => ({
318 pos: [g.pos[0], g.pos[1], g.pos[2] + CLOUD_LIFT],
319 born: time,
320 owner: g.owner,
321 });
322
323 /**
324 * How wide this cloud is at `time`, in units, and zero once it is gone —
325 *
326 * r(age) = radius · age / grow age < grow
327 * = radius grow ≤ age < life − fade
328 * = radius · (life − age) / fade life − fade ≤ age < life
329 * = 0 age < 0 or age ≥ life
330 *
331 * One number, read by three callers that must never disagree: the sight test in
332 * `world.ts`, the ball `scene.ts` places, and the wash `hud.ts` puts over the
333 * screen when your eye is inside it. A cloud that blocked a bot's line for a
334 * second longer than it was drawn would be exactly the bug this shape rules
335 * out.
336 *
337 * The default is the one smoke grenade in the table. It is an argument because
338 * a test wants to say "a cloud this wide" without reaching for the weapon.
339 */
340 /**
341 * Is this cloud over? Asked by the world's housekeeping rather than by the
342 * radius above, because the two differ at exactly one instant and it is the
343 * instant that matters: a cloud is *born* at radius zero and is not gone, and a
344 * prune written against the radius would drop every cloud on the tick it opened.
345 */
346 export const cloudGone = (cloud: Cloud, time: number, smoke: Smoke = SMOKE.smoke): boolean =>
347 time - cloud.born >= smoke.life;
348
349 export function cloudRadius(cloud: Cloud, time: number, smoke: Smoke = SMOKE.smoke): number {
350 const age = time - cloud.born;
351 if (age < 0 || age >= smoke.life) return 0;
352 if (age < smoke.grow) return smoke.radius * (age / smoke.grow);
353 const left = smoke.life - age;
354 if (left < smoke.fade) return smoke.radius * (left / smoke.fade);
355 return smoke.radius;
356 }
src/client/grenades.test.ts deleted −157
@@ -1,157 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { blastDamage, blastFalloff, GRENADE, step, thrown, type Grenade } from "./grenades.ts";
3 import { TELEPORT } from "./interpolate.ts";
4 import { colOf, DE_FORGE, rowOf, walkable, WALL_HEIGHT, type Box } from "./map.ts";
5 import type { Vec3 } from "./math.ts";
6 import { BOMB_BLAST_RADIUS } from "./world.ts";
7 import { WEAPONS } from "./weapons.ts";
8
9 const TICK = 1 / 64;
10 const FLOOR: Box[] = [{ min: [-10000, -10000, -64], max: [10000, 10000, 0], mat: 0 }];
11
12 /** Step `g` for `seconds`, stopping at the tick its fuse runs out. */
13 const fly = (g: Grenade, seconds: number, boxes: Box[]): number => {
14 let bounces = 0;
15 for (let t = 0; t < seconds; t += TICK) {
16 const { bounced, detonated } = step(g, TICK, boxes);
17 if (bounced) bounces += 1;
18 if (detonated) break;
19 }
20 return bounces;
21 };
22
23 test("a flat throw falls like a thrown thing: the range the numbers say", () => {
24 // Thrown level — the pitch cancels the lift the throw adds — from 200 units
25 // up over a floor at zero. It is a projectile from there: it should land
26 // v·√(2h/g) away, the same answer as the back of an envelope.
27 const from: Vec3 = [0, 0, 200];
28 const g = thrown(0, "t", from, 0, GRENADE.throwLift, [0, 0, 0]);
29 const drop = (g.pos[2] as number) - GRENADE.radius;
30 const expected = GRENADE.throwSpeed * Math.sqrt((2 * drop) / GRENADE.gravity);
31
32 let landed = 0;
33 for (let t = 0; t < 2; t += TICK) {
34 const before = g.pos[2] as number;
35 step(g, TICK, FLOOR);
36 if ((g.pos[2] as number) <= GRENADE.radius + 0.01 && before > GRENADE.radius + 0.01) {
37 landed = g.pos[0] as number;
38 break;
39 }
40 }
41 expect(landed).toBeGreaterThan(expected * 0.95);
42 expect(landed).toBeLessThan(expected * 1.05);
43 expect(Math.abs(g.pos[1] as number)).toBeLessThan(1); // straight ahead, +x
44 });
45
46 test("the thrower's own speed goes with it: running throws further", () => {
47 const still = thrown(0, "t", [0, 0, 200], 0, GRENADE.throwLift, [0, 0, 0]);
48 const running = thrown(0, "t", [0, 0, 200], 0, GRENADE.throwLift, [250, 0, 0]);
49 fly(still, 0.5, FLOOR);
50 fly(running, 0.5, FLOOR);
51 expect(running.pos[0] as number).toBeGreaterThan((still.pos[0] as number) + 100);
52 });
53
54 test("a wall bounces it back, slower, and it never ends up inside one", () => {
55 const wall: Box[] = [...FLOOR, { min: [300, -500, 0], max: [340, 500, 400], mat: 0 }];
56 const g = thrown(0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
57 const bounces = fly(g, GRENADE.fuse, wall);
58 expect(bounces).toBeGreaterThan(0);
59 expect(g.pos[0] as number).toBeLessThan(300);
60 expect(Math.hypot(g.vel[0], g.vel[1], g.vel[2])).toBeLessThan(GRENADE.throwSpeed);
61 // Out of the wall and above the floor, by its own radius, wherever it ended.
62 expect(g.pos[0] as number).toBeLessThanOrEqual(300 - GRENADE.radius + 0.001);
63 expect(g.pos[2] as number).toBeGreaterThanOrEqual(GRENADE.radius - 0.001);
64 });
65
66 test("thrown at a wall of de_forge it stays in the map and comes to rest", () => {
67 const at = DE_FORGE.spawns.t[0] as Vec3;
68 const eye: Vec3 = [at[0], at[1], 64];
69 // Eight directions from a spawn, hard: none of them may put it outside.
70 for (let i = 0; i < 8; i++) {
71 const g = thrown(0, "t", eye, i * 45, -20, [0, 0, 0]);
72 fly(g, GRENADE.fuse, DE_FORGE.boxes);
73 expect(Number.isFinite(g.pos[0]) && Number.isFinite(g.pos[2])).toBe(true);
74 expect(walkable(DE_FORGE, colOf(DE_FORGE, g.pos[0] as number), rowOf(DE_FORGE, g.pos[1] as number))).toBe(true);
75 expect(g.pos[2] as number).toBeGreaterThan(0);
76 expect(g.pos[2] as number).toBeLessThan(WALL_HEIGHT);
77 }
78 });
79
80 test("a grenade dropped on the floor stops instead of trembling on it", () => {
81 const g: Grenade = { kind: "he", owner: 0, team: "t", pos: [0, 0, 120], prev: [0, 0, 120], vel: [0, 0, 0], fuse: 5 };
82 fly(g, 1.5, FLOOR);
83 expect(g.vel).toEqual([0, 0, 0]);
84 expect(g.pos[2]).toBeCloseTo(GRENADE.radius, 6);
85 });
86
87 test("the fuse runs on the clock: nothing before 1.6s, and then it goes", () => {
88 const g = thrown(0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
89 let fired = -1;
90 for (let i = 0; i < 200; i++) {
91 if (step(g, TICK, FLOOR).detonated) { fired = i; break; }
92 }
93 expect(fired).toBeGreaterThanOrEqual(Math.floor(GRENADE.fuse / TICK) - 1);
94 expect(fired).toBeLessThanOrEqual(Math.ceil(GRENADE.fuse / TICK));
95 });
96
97 test("a hard hit sounds and a settling one does not", () => {
98 const fast: Grenade = { kind: "he", owner: 0, team: "t", pos: [0, 0, 60], prev: [0, 0, 60], vel: [0, 0, -400], fuse: 5 };
99 expect(fly(fast, 0.25, FLOOR)).toBeGreaterThan(0);
100 const settled: Grenade = { kind: "he", owner: 0, team: "t", pos: [0, 0, GRENADE.radius], prev: [0, 0, GRENADE.radius], vel: [0, 0, 0], fuse: 5 };
101 for (let i = 0; i < 10; i++) expect(step(settled, TICK, FLOOR).bounced).toBe(false);
102 });
103
104 test("damage is full at the centre, nothing at the radius, and armour halves it", () => {
105 expect(blastFalloff(0)).toBe(1);
106 expect(blastFalloff(GRENADE.blastRadius)).toBe(0);
107 const centre = blastDamage(0, 0).health;
108 const middle = blastDamage(GRENADE.blastRadius / 2, 0).health;
109 const edge = blastDamage(GRENADE.blastRadius, 0).health;
110 expect(centre).toBeGreaterThan(90);
111 expect(middle).toBeLessThan(centre);
112 expect(middle).toBeGreaterThan(edge);
113 expect(edge).toBe(0);
114 expect(blastDamage(GRENADE.blastRadius * 2, 0).health).toBe(0);
115
116 const armoured = blastDamage(0, 100);
117 expect(armoured.health).toBeLessThan(centre * 0.6);
118 expect(armoured.armor).toBeGreaterThan(0);
119 });
120
121 test("the bomb's blast is the same function as the HE's, with its own radius and damage", () => {
122 const bomb = WEAPONS.bomb;
123 const at = (distance: number, armor: number) =>
124 blastDamage(distance, armor, BOMB_BLAST_RADIUS, bomb.damage, bomb.armorPenetration);
125
126 // A lethal core: full armour still leaves less than nothing at its centre.
127 const centre = at(0, 100);
128 expect(centre.health).toBeGreaterThanOrEqual(100);
129
130 // Falling to zero at the edge, same as the HE's.
131 expect(at(BOMB_BLAST_RADIUS, 0).health).toBe(0);
132 expect(at(BOMB_BLAST_RADIUS * 2, 0).health).toBe(0);
133
134 // Armour still takes its share in between.
135 const middle = BOMB_BLAST_RADIUS * 5 / 6;
136 expect(at(middle, 100).health).toBeLessThan(at(middle, 0).health);
137 });
138
139 test("a grenade remembers where it was, so the renderer can draw it between two ticks", () => {
140 const g = thrown(0, "t", [0, 0, 64], 0, 0, [0, 0, 0]);
141 // It has not moved yet: the first frame draws it at the hand, not between
142 // the hand and the origin.
143 expect(g.prev).toEqual(g.pos);
144 const held = g.prev;
145
146 const was = [...g.pos] as Vec3;
147 step(g, TICK, FLOOR);
148 expect(g.prev).toEqual(was);
149 expect(g.pos[0]).toBeGreaterThan(was[0]); // it flew
150 // Twelve units at most in a tick, which is a move and not a teleport.
151 expect(Math.hypot(g.pos[0] - g.prev[0], g.pos[1] - g.prev[1], g.pos[2] - g.prev[2])).toBeLessThan(TELEPORT);
152
153 const then = [...g.pos] as Vec3;
154 step(g, TICK, FLOOR);
155 expect(g.prev).toEqual(then);
156 expect(g.prev).toBe(held); // written into the array it already had
157 });
src/client/grenades.ts deleted −166
@@ -1,166 +0,0 @@
1 /**
2 * A thrown object's flight, and nothing else: it leaves a hand with a velocity,
3 * falls under the same gravity a player does, bounces off the world losing
4 * speed each time, comes to rest, and its fuse runs out wherever it is by then.
5 *
6 * There is no WebGL, no audio and no HUD here — `world.ts` steps these and
7 * decides what a detonation means — so a throw can be checked in a test by
8 * reading three numbers, which is the only way to know a grenade lands where
9 * the arc says it does.
10 *
11 * A grenade is a ball of `GRENADE.radius`, not a hull: it is resolved out of
12 * whatever box it ended up inside along the shallowest face, and reflects off
13 * that face. One ball against fifty boxes a tick, and only while one is in the
14 * air, so nothing here is on the path of a round that never buys one.
15 */
16
17 import type { Box } from "./map.ts";
18 import { rememberPos } from "./interpolate.ts";
19 import { angleVectors, clamp, type Vec3 } from "./math.ts";
20 import { armorSplit, WEAPONS } from "./weapons.ts";
21
22 export type Grenade = {
23 kind: "he";
24 owner: number; // the player id who threw it; the blast is credited to them
25 team: "t" | "ct"; // their side when it left the hand, for friendly fire
26 pos: Vec3;
27 vel: Vec3;
28 fuse: number; // seconds left, counted down every step
29 /**
30 * Where it was when the previous step ended, written by `step` itself before
31 * it moves. Nothing in the simulation reads it: it is what lets the renderer
32 * draw a grenade partway between two ticks instead of twelve units on from
33 * the last one.
34 */
35 prev: Vec3;
36 };
37
38 export const GRENADE = {
39 radius: 4, // the ball the world is resolved against
40 gravity: 800, // the same gravity a player falls under
41 bounce: 0.45, // the share of the speed into a surface that comes back out
42 friction: 0.75, // the share of the speed along it that survives the hit
43 rest: 50, // below this, a grenade against a floor stops dead
44 fuse: 1.6, // seconds from the hand to the bang, whatever it hit on the way
45 throwSpeed: 780, // u/s out of the hand, before the thrower's own velocity
46 throwLift: 12, // degrees above where you are looking
47 throwFrom: 18, // units in front of the eye it appears
48 blastRadius: 350, // where the damage has fallen to nothing
49 /** A bounce softer than this is a grenade settling, not a wall being hit. */
50 bounceSound: 90,
51 };
52
53 /** What one step did, for the caller that owns sound and damage. */
54 export type Step = { bounced: boolean; detonated: boolean };
55
56 /** What the world did to a grenade this step: the hardest hit, and a floor. */
57 type Contact = { into: number; floor: boolean };
58
59 /**
60 * Push `g` out of `box` along the face it is least far through and reflect it
61 * off that face, recording the speed it went in with. A grenade that is not
62 * inside the box at all costs three comparisons and nothing else.
63 */
64 function resolve(g: Grenade, box: Box, contact: Contact): void {
65 const r = GRENADE.radius;
66 let axis = 0, sign = 0, depth = Infinity;
67 for (let i = 0; i < 3; i++) {
68 const above = (g.pos[i] as number) - r - (box.max[i] as number); // clear of the far face
69 const below = (box.min[i] as number) - (g.pos[i] as number) - r; // clear of the near one
70 if (above >= 0 || below >= 0) return;
71 if (-above < depth) { depth = -above; axis = i; sign = 1; }
72 if (-below < depth) { depth = -below; axis = i; sign = -1; }
73 }
74 g.pos[axis] += depth * sign;
75 if (axis === 2 && sign === 1) contact.floor = true;
76 const into = -(g.vel[axis] as number) * sign;
77 if (into <= 0) return; // it was already leaving; the push-out is enough
78 if (into > contact.into) contact.into = into;
79 g.vel[axis] = (g.vel[axis] as number) * -GRENADE.bounce;
80 for (let i = 0; i < 3; i++) if (i !== axis) g.vel[i] = (g.vel[i] as number) * GRENADE.friction;
81 }
82
83 /**
84 * One tick of flight: gravity, the move, then the world. The fuse runs on the
85 * clock and not on what it hit, so `detonated` is true exactly once — the
86 * caller drops the grenade from its list when it sees it.
87 */
88 export function step(g: Grenade, dt: number, boxes: Box[]): Step {
89 rememberPos(g.prev, g.pos);
90 g.fuse -= dt;
91 g.vel[2] -= GRENADE.gravity * dt;
92 g.pos[0] += (g.vel[0] as number) * dt;
93 g.pos[1] += (g.vel[1] as number) * dt;
94 g.pos[2] += (g.vel[2] as number) * dt;
95
96 const contact: Contact = { into: 0, floor: false };
97 for (const box of boxes) resolve(g, box, contact);
98 // A grenade rolling out of speed on a floor stops, rather than trembling on
99 // it for the rest of the fuse: gravity would give it a new hop every tick.
100 if (contact.floor && Math.hypot(g.vel[0], g.vel[1], g.vel[2]) < GRENADE.rest) {
101 g.vel[0] = 0;
102 g.vel[1] = 0;
103 g.vel[2] = 0;
104 }
105 return { bounced: contact.into >= GRENADE.bounceSound, detonated: g.fuse <= 0 };
106 }
107
108 /**
109 * A grenade leaving `eye`: forward along the view, lifted a little so it
110 * arcs rather than sliding along the floor, with the thrower's own velocity
111 * added — running out of a doorway throws it further than standing does.
112 */
113 export function thrown(
114 owner: number,
115 team: "t" | "ct",
116 eye: Vec3,
117 yaw: number,
118 pitch: number,
119 velocity: Vec3,
120 ): Grenade {
121 // Pitch is positive downwards, so lifting the throw subtracts from it.
122 const dir = angleVectors(yaw, pitch - GRENADE.throwLift).forward;
123 const from: Vec3 = [
124 eye[0] + dir[0] * GRENADE.throwFrom,
125 eye[1] + dir[1] * GRENADE.throwFrom,
126 eye[2] + dir[2] * GRENADE.throwFrom,
127 ];
128 return {
129 kind: "he",
130 owner,
131 team,
132 pos: [from[0], from[1], from[2]],
133 // It has not moved yet, so it has not moved: a grenade's first frame is
134 // drawn at the hand it left and never between there and the origin.
135 prev: [from[0], from[1], from[2]],
136 vel: [
137 dir[0] * GRENADE.throwSpeed + velocity[0],
138 dir[1] * GRENADE.throwSpeed + velocity[1],
139 dir[2] * GRENADE.throwSpeed + velocity[2],
140 ],
141 fuse: GRENADE.fuse,
142 };
143 }
144
145 /** The share of a blast something `distance` from its `radius` takes: 1 to 0. */
146 export const blastFalloff = (distance: number, radius: number = GRENADE.blastRadius): number =>
147 clamp(1 - distance / radius, 0, 1);
148
149 /**
150 * What a blast `distance` away costs someone wearing `armor`. Full at the
151 * centre, nothing at `radius`, and armour takes its share the way it does off
152 * a bullet — a vest is worth about half an HE. The bomb calls this too, with
153 * its own radius and centre damage in place of the HE's: one blast rule, not
154 * two, so `distance` is the only thing either caller has to get right.
155 */
156 export function blastDamage(
157 distance: number,
158 armor: number,
159 radius: number = GRENADE.blastRadius,
160 centreDamage: number = WEAPONS.he.damage,
161 penetration: number = WEAPONS.he.armorPenetration,
162 ): { health: number; armor: number } {
163 const damage = centreDamage * blastFalloff(distance, radius);
164 if (armor <= 0 || damage <= 0) return { health: Math.round(damage), armor: 0 };
165 return armorSplit(damage, armor, penetration);
166 }
src/client/hearing.test.ts deleted −110
@@ -1,110 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { AUDIBLE, fromOther, loudness, loudnessOf, reported, wallsBetween, type HeardName } from "./hearing.ts";
3 import { DE_FORGE } from "./map.ts";
4 import { dist2d, type Vec3 } from "./math.ts";
5
6 /**
7 * Two pairs of standing spots on de_forge, 512 units apart each. One pair is
8 * two ends of ct mid with nothing between them; the other is b link and ct
9 * mid, which are the same distance apart with the wall at column 19 in the
10 * way. Everything below is one against the other.
11 */
12 const CLEAR: [Vec3, Vec3] = [[-192, 1856, 2], [320, 1856, 2]];
13 const THROUGH_A_WALL: [Vec3, Vec3] = [[-448, 1856, 2], [64, 1856, 2]];
14
15 /** Ct mid to halfway down mid: 896 units of open corridor, for what a wall is worth. */
16 const DOWN_MID: [Vec3, Vec3] = [[64, 1856, 2], [64, 960, 2]];
17
18 test("the two test pairs are the same distance apart, one of them through a wall", () => {
19 expect(dist2d(CLEAR[0], CLEAR[1])).toBeCloseTo(512, 6);
20 expect(dist2d(THROUGH_A_WALL[0], THROUGH_A_WALL[1])).toBeCloseTo(512, 6);
21 expect(wallsBetween(DE_FORGE, CLEAR[0], CLEAR[1])).toBe(0);
22 expect(wallsBetween(DE_FORGE, THROUGH_A_WALL[0], THROUGH_A_WALL[1])).toBe(1);
23 });
24
25 test("a sound falls away with distance until it is not worth hearing", () => {
26 const here: Vec3 = [-192, 1856, 2];
27 const near = loudness(here, [64, 1856, 2], "ak47", DE_FORGE);
28 const far = loudness(here, [1856, 1856, 2], "ak47", DE_FORGE);
29 expect(near).toBeGreaterThan(far);
30 expect(far).toBeGreaterThan(0);
31 // And far enough away, nothing at all: a rifle across the whole map.
32 expect(loudness(here, [1856, -2600, 2], "ak47", DE_FORGE)).toBe(0);
33 // Right on top of the listener a sound is at its own full loudness, no more.
34 expect(loudness(here, here, "ak47", DE_FORGE)).toBeCloseTo(loudnessOf("ak47"), 6);
35 });
36
37 test("a gunshot is the loudest thing on the map", () => {
38 const [here, there] = CLEAR;
39 const rifle = loudness(here, there, "ak47", DE_FORGE);
40 const quieter: HeardName[] = ["step", "plant", "defuse", "explode", "blast", "bounce", "empty", "beep", "knife"];
41 for (const name of quieter) {
42 expect(loudness(here, there, name, DE_FORGE)).toBeLessThan(rifle);
43 }
44 // The AWP is the one exception, and it is a gun.
45 expect(loudness(here, there, "awp", DE_FORGE)).toBeGreaterThan(rifle);
46 });
47
48 test("a wall costs distance, not silence", () => {
49 const clear = loudness(CLEAR[0], CLEAR[1], "step", DE_FORGE);
50 const muffled = loudness(THROUGH_A_WALL[0], THROUGH_A_WALL[1], "step", DE_FORGE);
51 expect(muffled).toBeGreaterThan(0);
52 expect(muffled).toBeLessThan(clear);
53 // The same footstep 896 units away in the clear — down mid, which is open
54 // that far — is about as quiet: the wall is worth a few hundred units of
55 // walking, which is what it should be.
56 const far = loudness(DOWN_MID[0], DOWN_MID[1], "step", DE_FORGE);
57 expect(wallsBetween(DE_FORGE, DOWN_MID[0], DOWN_MID[1])).toBe(0);
58 expect(Math.abs(muffled - far)).toBeLessThan(0.02);
59 });
60
61 test("far enough away, a wall is the difference between heard and not", () => {
62 // A footstep a thousand units away carries down open mid, and does not from
63 // b link to the far end of ct mid — the same distance, with a wall in it.
64 const clear = loudness([64, 1856, 2], [64, 856, 2], "step", DE_FORGE);
65 const blocked = loudness([-704, 1856, 2], [296, 1856, 2], "step", DE_FORGE);
66 expect(clear).toBeGreaterThanOrEqual(AUDIBLE);
67 expect(blocked).toBe(0);
68 });
69
70 test("where a sound seems to come from is a point near by and a direction far off", () => {
71 // A random source stuck at its extreme, so the error is the whole error.
72 const worst = (): number => 1;
73 const here: Vec3 = [-192, 1856, 2];
74 const close: Vec3 = [here[0] + 200, here[1], 2];
75 const far: Vec3 = [here[0] + 2000, here[1], 2];
76 const closeError = dist2d(reported(here, close, DE_FORGE, worst), close);
77 const farError = dist2d(reported(here, far, DE_FORGE, worst), far);
78 expect(closeError).toBeLessThan(40);
79 expect(farError).toBeGreaterThan(200);
80 // The bearing, though, is about as good either way: a far sound is a
81 // direction to look, not a place to shoot at.
82 const bearing = (from: Vec3, to: Vec3): number => Math.atan2(to[1] - from[1], to[0] - from[0]);
83 const off = (truth: Vec3): number =>
84 Math.abs(bearing(here, reported(here, truth, DE_FORGE, worst)) - bearing(here, truth));
85 expect(off(far)).toBeLessThan(off(close) * 2 + 0.05);
86 expect(off(far)).toBeLessThan(0.3); // under twenty degrees at two thousand units
87 });
88
89 test("a wall makes a sound harder to place as well as quieter", () => {
90 const worst = (): number => 1;
91 const clear = dist2d(reported(CLEAR[0], CLEAR[1], DE_FORGE, worst), CLEAR[1]);
92 const muffled = dist2d(reported(THROUGH_A_WALL[0], THROUGH_A_WALL[1], DE_FORGE, worst), THROUGH_A_WALL[1]);
93 expect(muffled).toBeGreaterThan(clear);
94 });
95
96 test("a heard point is at shoulder height, not on the floor", () => {
97 const still = (): number => 0.5; // no error at all
98 const at = reported([-192, 1856, 2], [320, 1856, 2], DE_FORGE, still);
99 expect(at[0]).toBeCloseTo(320, 6);
100 expect(at[1]).toBeCloseTo(1856, 6);
101 expect(at[2]).toBeGreaterThan(2);
102 });
103
104 test("your own side's noise is not news; anyone else's is", () => {
105 const me = { id: 3, team: "ct" as const };
106 expect(fromOther(me, { id: 3, team: "ct" })).toBe(false); // yourself
107 expect(fromOther(me, { id: 5, team: "ct" })).toBe(false); // a teammate
108 expect(fromOther(me, { id: 5, team: "t" })).toBe(true); // an enemy
109 expect(fromOther(me, undefined)).toBe(true); // the bomb, or a grenade in the air
110 });
src/client/hearing.ts deleted −130
@@ -1,130 +0,0 @@
1 /**
2 * What a sound is worth to whoever is standing somewhere else: how loud it
3 * arrives, and where they think it came from. Plain data in and out — a
4 * position, a name and the map — so it is the same answer for a bot as it
5 * would be for anything else that wanted to listen.
6 *
7 * A sound is named by what made it: one of `world.ts`'s `SoundName`s, or, for
8 * a gunshot, the weapon that fired it. That is the whole of the loudness
9 * table: an AWP carries across the map, a footstep carries across a room, and
10 * a dry trigger barely leaves the hands it clicked in.
11 *
12 * Walls muffle rather than silence. Working out how much would be a trace per
13 * sound per listener, which is five listeners times every effect every tick,
14 * so this counts the solid cells on the straight line instead and charges each
15 * one extra distance: cheap, deterministic, and wrong in the direction that
16 * matters least — a sound around a corner reads as a sound further away.
17 */
18
19 import { cellAt, colOf, rowOf, CELL, type MapDef } from "./map.ts";
20 import { dist2d, type Vec3 } from "./math.ts";
21 import type { SoundName, Team } from "./world.ts";
22 import type { WeaponId } from "./weapons.ts";
23
24 /** Everything that can be heard: the world's own noises, and a shot per gun. */
25 export type HeardName = SoundName | WeaponId;
26
27 /**
28 * How loud each thing is at the source, relative to each other. A rifle is
29 * 1 by construction and nothing else is louder, which is what keeps a gunshot
30 * the thing a bot turns to when a gunshot and a footstep arrive together.
31 */
32 const LOUDNESS: Record<HeardName, number> = {
33 // Guns, loudest first. The AWP is the only thing on the map that beats a rifle.
34 awp: 1.25, deagle: 1.05, ak47: 1, m4a4: 0.95, mp9: 0.8, glock: 0.7, usp: 0.6,
35 // A knife swing is a whisper, and neither of these is ever fired as a shot:
36 // a grenade is thrown and the bomb is never held.
37 knife: 0.08, he: 0.1, bomb: 0,
38 // The world. A plant and a defuse are deliberate and carry past the site; a
39 // footstep is a room away; a dry trigger is the gun in your own hands.
40 explode: 0.95, blast: 0.9, plant: 0.9, defuse: 0.8, beep: 0.5,
41 step: 0.45, bounce: 0.35, empty: 0.25,
42 };
43
44 /**
45 * Below this a sound is not information, it is the room. It is set where it
46 * is because a rifle shot lands on it at about 2500 units — the distance the
47 * bots have always turned round for — and a running footstep at about 1100,
48 * which is roughly a site room and a corridor.
49 */
50 export const AUDIBLE = 0.1;
51
52 /** Inside this everything is at its own full loudness; past it, it falls away. */
53 const NEAR = 250;
54
55 /** What one solid cell between the two of you costs, in units of apparent distance. */
56 const WALL_COST = 3 * CELL;
57
58 /** The error on a heard point as a share of how far away it is, in the clear. */
59 const SPREAD = 0.08;
60
61 /** What each wall in the way adds to that share: muffled is also harder to place. */
62 const SPREAD_PER_WALL = 0.07;
63
64 /** Where a bot looks when it looks at a noise: shoulder height, not the floor. */
65 const SHOULDER = 48;
66
67 /** The loudness this name leaves its source at, 0 for anything that cannot be heard. */
68 export const loudnessOf = (name: HeardName): number => LOUDNESS[name] ?? 0;
69
70 /**
71 * Solid cells on the straight line from `from` to `to`, sampled a cell at a
72 * time. Only full-height walls count: a crate is something you hear over.
73 */
74 export function wallsBetween(map: MapDef, from: Vec3, to: Vec3): number {
75 const span = dist2d(from, to);
76 const steps = Math.max(1, Math.round(span / CELL));
77 let walls = 0;
78 for (let i = 0; i < steps; i++) {
79 const t = (i + 0.5) / steps;
80 const x = from[0] + (to[0] - from[0]) * t;
81 const y = from[1] + (to[1] - from[1]) * t;
82 if (cellAt(map, colOf(map, x), rowOf(map, y)) === "#") walls += 1;
83 }
84 return walls;
85 }
86
87 const falloff = (base: number, distance: number): number => (base * NEAR) / Math.max(NEAR, distance);
88
89 /**
90 * How loud a sound made at `pos` arrives at `listener`, or 0 when it does not
91 * arrive at all. The clear-line answer is worked out first and rejected before
92 * any cell is looked at, so most of what happens on a tick costs one hypot.
93 */
94 export function loudness(listener: Vec3, pos: Vec3, name: HeardName, map: MapDef): number {
95 const base = loudnessOf(name);
96 if (base <= 0) return 0;
97 const span = dist2d(listener, pos);
98 if (falloff(base, span) < AUDIBLE) return 0; // too far even with nothing in the way
99 const heard = falloff(base, span + wallsBetween(map, listener, pos) * WALL_COST);
100 return heard < AUDIBLE ? 0 : heard;
101 }
102
103 /**
104 * Where the listener believes the sound came from: the truth, thrown off by
105 * an error that grows with distance and with every wall it came through. The
106 * angle it is wrong by stays about the same, which is the point — a footstep
107 * across the map is a direction to look, not a place to shoot at.
108 */
109 export function reported(
110 listener: Vec3, pos: Vec3, map: MapDef, random: () => number,
111 ): Vec3 {
112 const span = dist2d(listener, pos);
113 const error = span * (SPREAD + wallsBetween(map, listener, pos) * SPREAD_PER_WALL);
114 return [
115 pos[0] + (random() * 2 - 1) * error,
116 pos[1] + (random() * 2 - 1) * error,
117 pos[2] + SHOULDER,
118 ];
119 }
120
121 /** Who made a sound: a player, or nobody at all — the bomb, or a grenade already in the air. */
122 export type Maker = { id: number; team: Team } | undefined;
123
124 /**
125 * Is this sound news? Your own footsteps are not, and neither are your side's:
126 * five bots walking together would otherwise spend the round turning round to
127 * look at each other.
128 */
129 export const fromOther = (listener: { id: number; team: Team }, maker: Maker): boolean =>
130 maker === undefined || (maker.id !== listener.id && maker.team !== listener.team);
src/client/hud.test.ts +885 −704
@@ -1,732 +1,913 @@
11 /**
2 * The HUD against a recording 2D context: no browser, no canvas, just the
3 * calls it would have made. It is the only way to check the damage indicator
4 * points the right way round, which is the one thing about it that can be
5 * wrong without looking wrong.
2 * This test exists so #18 could move the HUD onto the engine's overlay and
3 * prove it drew the same thing. It did, and this is what says it still does.
4 *
5 * It says what a player reads off the screen — the number that goes red, the
6 * clock that stops, the crosshair that opens, the feed that fades — and not how
7 * `hud.ts` is arranged to draw it. Nothing here asserts a coordinate that only
8 * means something to the current layout: a panel that moves two pixels left is
9 * not a change a player sees, and a pin that fails on one is a pin someone
10 * deletes rather than uses.
11 *
12 * The `Paint` it draws onto is the HUD's — the engine's surface with the styles
13 * narrowed to the strings this game writes into them, which is why the recorder
14 * hands a `string` back when the brush is read. The recorder writes down the
15 * brush as well as the call, because the colour a line was drawn in is half of
16 * what is pinned here; it lived in this file until the radar wanted the same
17 * one, and it is `paint.test-helper.ts` now.
18 *
19 * No canvas, no document, no browser.
620 */
721
822 import { expect, test } from "bun:test";
9 import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
10 import { drawHud } from "./hud.ts";
11 import { TICK } from "./loop.ts";
12 import { SHORT } from "./match.ts";
1323 import type { Motion } from "./motion.ts";
14 import type { Camera } from "./renderer.ts";
15 import { WEAPONS } from "./weapons.ts";
16 import { applyInput, createWorld, eyePos, NO_INPUT, type Player, type World } from "./world.ts";
24 import { drawHud, HIT_MARK_LIFE, HURT_LIFE, KILL_MARK_LIFE, NOT_READY_ALPHA, painAlpha, whiteout } from "./hud.ts";
25 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
26 import { DE_FORGE } from "./map.ts";
27 import { SHORT } from "./match.ts";
28 import { inaccuracy, WEAPONS } from "./weapons.ts";
29 import {
30 createWorld, currentWeapon, EFFECT_CAPACITY,
31 type Effect, type Player, type World,
32 } from "./world.ts";
33 // `Ammo` is economy.ts's; world.ts imports it rather than exporting it.
34 import type { Ammo } from "./economy.ts";
35 import { createEffects, pushEffect } from "../engine/index.ts";
1736
18 type Call = {
19 name: string; args: number[]; text: string; alpha: number;
20 stroke: string; fill: string; lineWidth: number;
21 };
22
23 function recorder(): { ctx: CanvasRenderingContext2D; calls: Call[] } {
24 const calls: Call[] = [];
25 // The alpha in force when the call was made, snapshotted: a fade is a
26 // property and not an argument, so it is the only way to see one. The
27 // colours and the line width are read the same way and for the same
28 // reason — a crosshair's colour is a property of the context, not of the
29 // stroke that used it.
30 const log = (name: string) => (...args: unknown[]): void => {
31 calls.push({
32 name,
33 args: args.filter((a) => typeof a === "number") as number[],
34 text: args.filter((a) => typeof a === "string").join(" "),
35 alpha: ctx.globalAlpha,
36 stroke: typeof ctx.strokeStyle === "string" ? ctx.strokeStyle : "",
37 fill: typeof ctx.fillStyle === "string" ? ctx.fillStyle : "",
38 lineWidth: ctx.lineWidth,
39 });
40 };
41 const ctx = {
42 clearRect: log("clearRect"), fillRect: log("fillRect"), rect: log("rect"),
43 beginPath: log("beginPath"), moveTo: log("moveTo"), lineTo: log("lineTo"),
44 arc: log("arc"), stroke: log("stroke"), fill: log("fill"),
45 fillText: log("fillText"), save: log("save"), restore: log("restore"),
46 clip: log("clip"), translate: log("translate"), rotate: log("rotate"),
47 drawImage: log("drawImage"), closePath: log("closePath"),
48 measureText: () => ({ width: 10 }),
49 createRadialGradient: (...args: unknown[]) => {
50 calls.push({
51 name: "createRadialGradient", args: args as number[], text: "", alpha: 1,
52 stroke: "", fill: "", lineWidth: 1,
53 });
54 return { addColorStop: (): void => {} };
55 },
56 globalAlpha: 1, lineWidth: 1, fillStyle: "", strokeStyle: "",
57 font: "", textAlign: "", textBaseline: "",
58 };
59 return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
37 /** A buffer holding exactly these effects, in this order, and nothing else. */
38 function these(...list: Effect[]): ReturnType<typeof createEffects<Effect>> {
39 const effects = createEffects<Effect>(EFFECT_CAPACITY);
40 for (const effect of list) pushEffect(effects, effect, effect.at);
41 return effects;
6042 }
6143
62 const W = 1280, H = 720;
44 /** The one effect this frame is about, in a buffer of its own. */
45 const only = (effect: Effect): ReturnType<typeof createEffects<Effect>> => these(effect);
6346
64 /** The camera `main.ts` builds for the player being drawn, punch and all aside. */
65 const cameraFor = (view: Player): Camera =>
66 ({ pos: eyePos(view), yaw: view.yaw, pitch: view.pitch, fov: view.scoped ? 40 : 90 });
67
68 /** One frame of the HUD, drawing `view` — the person unless a spectator is watching. */
69 const draw = (into: { ctx: CanvasRenderingContext2D }, world: World, view: Player = world.me,
70 scoreboard = false, crosshair?: CrosshairStyle, alpha = 1): void =>
71 drawHud(into.ctx, world, view, cameraFor(view), W, H, scoreboard, crosshair, alpha);
72
73 /** Where the middle of a stroked wedge lands on the screen. */
74 const wedgePoint = (call: Call): { x: number; y: number } => {
75 const [x, y, r, start, end] = call.args as [number, number, number, number, number];
76 const middle = (start + end) / 2;
77 return { x: x + Math.cos(middle) * r, y: y + Math.sin(middle) * r };
78 };
79
80 const hurtWorld = (yaw: number, from: [number, number, number], victim: number): World => {
81 const world = createWorld(SHORT, "me", "ct", 5, 71);
82 world.me.pos = [0, 0, 0];
83 world.me.yaw = yaw;
84 world.effects.length = 0;
85 world.effects.push({ kind: "hurt", at: world.time, victim, from, amount: 30 });
86 return world;
87 };
88
89 // The damage wedges are arcs centred on the crosshair; the radar draws its
90 // own arcs too, off in the corner, so a plain count of "arc" calls is no
91 // longer enough to isolate them.
92 const wedgeArcs = (calls: Call[]): Call[] => calls.filter((c) => c.name === "arc" && c.args[0] === W / 2);
93
94 test("a hit from behind puts the wedge below the crosshair, one from the left on its left", () => {
95 const behind = recorder();
96 draw(behind, hurtWorld(0, [-500, 0, 40], 0));
97 const back = wedgeArcs(behind.calls).map(wedgePoint);
98 expect(back.length).toBe(1);
99 expect(back[0].y).toBeGreaterThan(H / 2 + 20);
100 expect(Math.abs(back[0].x - W / 2)).toBeLessThan(20);
101
102 const left = recorder();
103 draw(left, hurtWorld(0, [0, 500, 40], 0));
104 const side = wedgeArcs(left.calls).map(wedgePoint);
105 expect(side.length).toBe(1);
106 expect(side[0].x).toBeLessThan(W / 2 - 20);
107 expect(Math.abs(side[0].y - H / 2)).toBeLessThan(20);
108 });
109
110 test("a fall hurts from straight below: no wedge, since there is no way it came from", () => {
111 const frame = recorder();
112 draw(frame, hurtWorld(0, [0, 0, 0], 0));
113 expect(wedgeArcs(frame.calls).length).toBe(0);
114 });
115
116 test("hits stack, and damage to anyone but the player being drawn is not drawn", () => {
117 const two = recorder();
118 const world = hurtWorld(0, [-500, 0, 40], 0);
119 world.effects.push({ kind: "hurt", at: world.time, victim: 0, from: [0, -500, 40], amount: 18 });
120 draw(two, world);
121 expect(wedgeArcs(two.calls).length).toBe(2);
122
123 const bot = recorder();
124 draw(bot, hurtWorld(0, [-500, 0, 40], 4));
125 expect(wedgeArcs(bot.calls).length).toBe(0);
126 });
127
128 test("an old hit has faded out by the time the effects list is trimmed", () => {
129 const world = hurtWorld(0, [-500, 0, 40], 0);
130 world.time += 1.5; // still in the list, which update keeps for two seconds
131 const frame = recorder();
132 draw(frame, world);
133 expect(wedgeArcs(frame.calls).length).toBe(0);
134 });
135
136 test("the hurt vignette follows health and its gradient is built once, not per frame", () => {
137 const healthy = recorder();
138 const world = createWorld(SHORT, "me", "ct", 5, 73);
139 draw(healthy, world);
140 const full = (calls: Call[]): Call[] =>
141 calls.filter((c) => c.name === "fillRect" && c.args[0] === 0 && c.args[1] === 0 && c.args[2] === W);
142 expect(full(healthy.calls).length).toBe(0);
143
144 world.me.health = 20;
145 const hurt = recorder();
146 draw(hurt, world);
147 expect(full(hurt.calls).length).toBe(1);
148
149 // The second frame at the same size reuses the gradient the first one made.
150 const again = recorder();
151 draw(again, world);
152 expect(full(again.calls).length).toBe(1);
153 expect(again.calls.filter((c) => c.name === "createRadialGradient").length).toBe(0);
154 });
47 // --- the world this suite draws --------------------------------------------
15548
15649 /**
157 * The crosshair's arms as the context was told to draw them: each `moveTo`
158 * paired with the `lineTo` that follows it, kept only when it starts at the
159 * middle of the screen. Nothing else in a quiet frame starts there — the
160 * radar's own lines are off in the corner and the hit markers need an effect.
50 * One match, built once for the whole file: `createWorld` lays out ten players,
51 * spawns, bots and a bomb, and doing that per test is seconds nobody needs.
52 * Every test draws a *copy* of it instead, so a test that spends a magazine or
53 * kills a bot does not reach the next one.
16154 */
162 function armsDrawn(calls: Call[]): { from: number[]; to: number[]; stroke: string; lineWidth: number }[] {
163 const arms: { from: number[]; to: number[]; stroke: string; lineWidth: number }[] = [];
164 for (let i = 0; i < calls.length - 1; i++) {
165 const move = calls[i], line = calls[i + 1];
166 if (move.name !== "moveTo" || line.name !== "lineTo") continue;
167 const along = (move.args[0] === W / 2) !== (move.args[1] === H / 2);
168 if (!along) continue;
169 arms.push({ from: move.args, to: line.args, stroke: move.stroke, lineWidth: move.lineWidth });
170 }
171 return arms;
55 const BASE = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 64);
56
57 const copyAmmo = (ammo: Record<string, Ammo>): Record<string, Ammo> =>
58 Object.fromEntries(Object.entries(ammo).map(([id, a]) => [id, { ...a }]));
59
60 /** A copy of the base world, changed the one way this test is about. */
61 function scene(change: (world: World) => void = () => {}): World {
62 const players = BASE.players.map((p) => ({ ...p, ammo: copyAmmo(p.ammo) }));
63 const world: World = {
64 ...BASE,
65 match: { ...BASE.match, score: { ...BASE.match.score }, losses: { ...BASE.match.losses } },
66 players,
67 me: players[0] as Player,
68 bomb: { ...BASE.bomb, defuser: null },
69 effects: createEffects<Effect>(EFFECT_CAPACITY),
70 feed: [],
71 messages: [],
72 };
73 change(world);
74 return world;
17275 }
17376
174 /** A quiet frame: alive, still, and holding the knife, which has no spread. */
175 const quietWorld = (): World => {
176 const world = createWorld(SHORT, "me", "ct", 5, 67);
177 world.me.weapon = "knife";
178 world.me.vel = [0, 0, 0];
179 return world;
180 };
77 const WIDTH = 1280;
78 const HEIGHT = 720;
18179
182 test("the default crosshair is the one this game has always drawn", () => {
183 const frame = recorder();
184 draw(frame, quietWorld());
185 // Four green arms, starting 4px out and running 7px, stroked 2px wide —
186 // right, left, down, up, exactly as the hard-coded loop drew them.
187 expect(armsDrawn(frame.calls).map((a) => [...a.from, ...a.to])).toEqual([
188 [W / 2 + 4, H / 2, W / 2 + 11, H / 2],
189 [W / 2 - 4, H / 2, W / 2 - 11, H / 2],
190 [W / 2, H / 2 + 4, W / 2, H / 2 + 11],
191 [W / 2, H / 2 - 4, W / 2, H / 2 - 11],
192 ]);
193 for (const arm of armsDrawn(frame.calls)) {
194 expect(arm.stroke).toBe("rgba(120, 255, 140, 0.9)");
195 expect(arm.lineWidth).toBe(2);
196 }
197 // No dot by default: nothing is filled in the middle of the screen.
198 expect(frame.calls.some((c) => c.name === "arc" && c.args[0] === W / 2 && c.args[1] === H / 2)).toBe(false);
199
200 // And the default is what a frame drawn with no style at all gets, so the
201 // HUD and the settings agree about what "today's crosshair" means.
202 const explicit = recorder();
203 draw(explicit, quietWorld(), undefined, false, DEFAULT_CROSSHAIR);
204 expect(armsDrawn(explicit.calls)).toEqual(armsDrawn(frame.calls));
205 });
206
207 test("the style is what gets drawn: colour, gap, size, thickness and the dot", () => {
208 const style: CrosshairStyle =
209 { colour: "cyan", size: 16, gap: 10, thickness: 4, dot: true, dynamic: false };
210 const frame = recorder();
211 draw(frame, quietWorld(), undefined, false, style);
212 const arms = armsDrawn(frame.calls);
213 expect(arms.map((a) => [...a.from, ...a.to])).toEqual([
214 [W / 2 + 10, H / 2, W / 2 + 26, H / 2],
215 [W / 2 - 10, H / 2, W / 2 - 26, H / 2],
216 [W / 2, H / 2 + 10, W / 2, H / 2 + 26],
217 [W / 2, H / 2 - 10, W / 2, H / 2 - 26],
218 ]);
219 expect(arms[0].stroke).toBe("rgba(96, 226, 255, 0.9)");
220 expect(arms[0].lineWidth).toBe(4);
221 // The dot: a filled circle in the middle, in the same colour as the arms.
222 const dot = frame.calls.find((c) => c.name === "arc" && c.args[0] === W / 2 && c.args[1] === H / 2);
223 expect(dot).toBeDefined();
224 expect((dot as Call).args[2]).toBe(2);
225 expect((dot as Call).fill).toBe("rgba(96, 226, 255, 0.9)");
226 });
227
228 test("a dynamic crosshair opens as the gun loses accuracy; a static one holds still", () => {
229 const running = (crosshair?: CrosshairStyle): number => {
230 const world = quietWorld();
231 world.me.weapon = "ak47";
232 world.me.vel = [250, 0, 0];
233 world.me.onGround = false; // in the air, where a rifle is at its worst
234 const frame = recorder();
235 draw(frame, world, undefined, false, crosshair);
236 return armsDrawn(frame.calls)[0].from[0] - W / 2;
237 };
238 const still = (): number => {
239 const frame = recorder();
240 draw(frame, quietWorld(), undefined, false);
241 return armsDrawn(frame.calls)[0].from[0] - W / 2;
242 };
243
244 expect(running()).toBeGreaterThan(still() + 10);
245 // The same jump with the same rifle, and the arms do not move at all.
246 expect(running({ ...DEFAULT_CROSSHAIR, dynamic: false })).toBe(still());
247 });
248
249 /** A world where the person is dead and one named teammate is still up. */
250 const spectatedWorld = (): World => {
251 const world = createWorld(SHORT, "me", "ct", 5, 79);
252 const mates = world.players.filter((p) => p.team === "ct" && p !== world.me);
253 for (const p of mates.slice(1)) p.alive = false;
254 world.me.alive = false;
255 world.me.health = 0;
256 world.feed.push({
257 at: world.time, killer: "Krait", killerId: 9, victim: world.me.name, victimId: world.me.id,
258 weapon: "ak47", headshot: true, killerTeam: "t",
259 });
260 return world;
261 };
262
263 test("dead, the HUD is the watched player's: their numbers, no crosshair, and who killed you", () => {
264 const world = spectatedWorld();
265 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
266 mate.health = 64;
267 mate.money = 4321;
268 const frame = recorder();
269 draw(frame, world, mate);
270 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text);
271
272 expect(texts).toContain("64"); // the watched player's health, not the corpse's zero
273 expect(texts).toContain("$4321");
274 expect(texts.join("|")).toContain(`Watching ${mate.name}`);
275 expect(texts.join("|")).toContain("Krait killed you with AK-47");
276 expect(texts.join("|")).toContain("headshot");
277 // The crosshair is four strokes out from the middle; a spectator gets none.
278 expect(frame.calls.filter((c) => c.name === "moveTo" && c.args[1] === H / 2).length).toBe(0);
279 });
280
281 test("a fall that killed you says so, rather than naming you as your own killer", () => {
282 const world = spectatedWorld();
283 world.feed.push({
284 at: world.time, killer: world.me.name, killerId: world.me.id, victim: world.me.name, victimId: world.me.id,
285 weapon: "fall", headshot: false, killerTeam: "ct",
286 });
287 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
288 const frame = recorder();
289 draw(frame, world, mate);
290 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text).join("|");
291 expect(texts).toContain("You fell too far");
292 expect(texts).toContain(`${world.me.name} Fall ${world.me.name}`);
293 });
294
295 test("a spectator does not inherit the dead player's hit markers", () => {
296 const world = spectatedWorld();
297 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
298 world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed: false });
299 const watching = recorder();
300 draw(watching, world, mate);
301 // The radar draws its own moveTo near the corner for the self arrow; the
302 // hit marker's crosses are the ones centred on the crosshair.
303 const centred = watching.calls.filter((c) => c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50);
304 expect(centred.length).toBe(0);
305
306 // Alive, the same effect is the marker it was drawn for.
307 const alive = createWorld(SHORT, "me", "ct", 5, 79);
308 alive.effects.push({ kind: "hitmark", at: alive.time, headshot: false, killed: false });
309 const own = recorder();
310 draw(own, alive);
311 expect(own.calls.filter((c) => c.name === "moveTo").length).toBeGreaterThan(4);
312 });
313
314 test("the mark for a kill is a different shape from the one for a hit, and lasts longer", () => {
315 /** The strokes a frame put in the middle of the screen: the crosshair's and the marker's. */
316 const centred = (killed: boolean, age = 0, headshot = false): Call[] => {
317 const world = createWorld(SHORT, "me", "ct", 5, 81);
318 world.effects.push({ kind: "hitmark", at: world.time - age, headshot, killed });
319 const frame = recorder();
320 draw(frame, world);
321 return frame.calls.filter((c) =>
322 c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50 && Math.abs(c.args[1] - H / 2) < 50);
323 };
324 // The crosshair alone, with the marker long expired: the baseline to subtract.
325 const crosshairOnly = centred(false, 10).length;
326 expect(centred(false, 10, false).length).toBe(crosshairOnly);
327
328 expect(centred(false).length - crosshairOnly).toBe(4); // four ticks out of the corners
329 expect(centred(true).length - crosshairOnly).toBe(6); // and an X through the middle
330 expect(centred(true).some((c) => c.lineWidth === 3)).toBe(true); // heavier
331 expect(centred(false).every((c) => c.lineWidth !== 3)).toBe(true);
332
333 // A third of a second on, the plain mark is gone and the kill is still up.
334 expect(centred(false, 0.35).length).toBe(crosshairOnly);
335 expect(centred(true, 0.35).length - crosshairOnly).toBe(6);
336
337 // A headshot kill is still the headshot's red, X and all.
338 expect(centred(true, 0, true).some((c) => c.stroke === "rgba(255,90,90,0.95)")).toBe(true);
339 });
340
341 test("a gun under your feet that you would have to ask for says so", () => {
342 const world = createWorld(SHORT, "me", "ct", 5, 89);
343 world.me.primary = "m4a4";
344 world.pickups.push({
345 weapon: "ak47",
346 pos: [world.me.pos[0], world.me.pos[1], world.me.pos[2] + 4],
347 ammo: { mag: 12, reserve: 90 },
348 });
349 const carrying = recorder();
350 draw(carrying, world);
351 expect(carrying.calls.some((c) => c.text === "E — take AK-47")).toBe(true);
352
353 // With no primary it is taken by walking over it, so there is nothing to ask.
354 world.me.primary = null;
355 const empty = recorder();
356 draw(empty, world);
357 expect(empty.calls.some((c) => c.text.includes("take AK-47"))).toBe(false);
358
359 // And a gun across the map is not under anyone's feet.
360 world.me.primary = "m4a4";
361 world.pickups[0].pos = [world.me.pos[0] + 400, world.me.pos[1], world.me.pos[2]];
362 const far = recorder();
363 draw(far, world);
364 expect(far.calls.some((c) => c.text.includes("take AK-47"))).toBe(false);
365 });
366
367 test("the round result stays up through the end delay and the freeze that follows", () => {
368 const world = createWorld(SHORT, "me", "ct", 5, 83);
369 world.match.phase = "over";
370 world.messages.push({ at: world.time, text: "Counter-Terrorists win — defused the bomb" });
371 world.time += SHORT.endDelay + SHORT.freezeTime - 1; // well past the four-second fade
372 const ending = recorder();
373 draw(ending, world);
374 expect(ending.calls.some((c) => c.text.includes("defused the bomb"))).toBe(true);
375
376 world.match.phase = "freeze";
377 const freezing = recorder();
378 draw(freezing, world);
379 expect(freezing.calls.some((c) => c.text.includes("defused the bomb"))).toBe(true);
380
381 // Live again, an old line is gone: it is the round's result, not wallpaper.
382 world.match.phase = "live";
383 const live = recorder();
384 draw(live, world);
385 expect(live.calls.some((c) => c.text.includes("defused the bomb"))).toBe(false);
386 });
387
388 test("the damage report is drawn while the round is over, and not while it is live", () => {
389 const world = createWorld(SHORT, "me", "ct", 5, 103);
390 const enemy = world.players.find((p) => p.team === "t") as Player;
391 world.damageLog[world.me.id][enemy.id] = { damage: 42, hits: 2, killed: true };
392
393 world.match.phase = "over";
394 const over = recorder();
395 draw(over, world);
396 const overTexts = over.calls.filter((c) => c.name === "fillText").map((c) => c.text);
397 expect(overTexts.some((t) => t.includes(`${enemy.name} — 42 dmg`))).toBe(true);
398 expect(overTexts.some((t) => t.startsWith("Dealt"))).toBe(true);
399
400 world.match.phase = "live";
401 const live = recorder();
402 draw(live, world);
403 const liveTexts = live.calls.filter((c) => c.name === "fillText").map((c) => c.text);
404 expect(liveTexts.some((t) => t.includes(`${enemy.name} — 42 dmg`))).toBe(false);
405
406 // The scoreboard covers the same pixels, so a held Tab wins over the report.
407 world.match.phase = "over";
408 const withScoreboard = recorder();
409 draw(withScoreboard, world, world.me, true);
410 const scoreboardTexts = withScoreboard.calls.filter((c) => c.name === "fillText").map((c) => c.text);
411 expect(scoreboardTexts.some((t) => t.startsWith("Dealt"))).toBe(false);
412 });
413
414 test("the radar sits in the bottom-left corner and labels both sites", () => {
415 const world = createWorld(SHORT, "me", "ct", 5, 91);
416 const frame = recorder();
417 draw(frame, world);
418 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text);
419 expect(texts).toContain("A");
420 expect(texts).toContain("B");
421
422 // The radar's border is a full circle, away from the money line, the kill
423 // feed and the clock, all of which live in the top half of the screen.
424 const border = frame.calls.find(
425 (c) => c.name === "arc" && c.args[0] < W / 2 && c.args[1] > H / 2 && c.args[3] === 0,
80 /**
81 * Draw one frame of the HUD onto a fresh recorder. `view` is whose eyes it is
82 * drawn from — left out, yours, which is every frame of a round you are alive
83 * in; a player once you are dead and watching them.
84 */
85 function draw(
86 world: World,
87 options: {
88 width?: number; height?: number; scoreboard?: boolean; view?: Player; dpr?: number; motion?: Motion;
89 } = {},
90 ): Recorder {
91 const paint = recorder();
92 drawHud(
93 paint, world, options.width ?? WIDTH, options.height ?? HEIGHT,
94 options.scoreboard ?? false, options.view ?? world.me, options.dpr ?? 1, options.motion,
42695 );
427 expect(border).toBeDefined();
428 });
429
430 test("the ammo counter dims while the gun is coming up, and lifts as it comes level", () => {
431 const world = createWorld(SHORT, "me", "ct", 5, 208);
432 world.me.primary = "awp";
433 world.me.weapon = "awp";
434 world.me.ammo.awp = { mag: WEAPONS.awp.magazine, reserve: WEAPONS.awp.reserve };
435 world.time = 30;
436 // A round start hands the gun over already up: nothing to say about it.
437 const ammoOf = (r: { calls: Call[] }): Call =>
438 r.calls.find((c) => c.name === "fillText" && c.text.startsWith(`${WEAPONS.awp.magazine} /`)) as Call;
439 const up = recorder();
440 draw(up, world);
441 expect(ammoOf(up).alpha).toBe(1);
442
443 // Drawn off the knife: 1.25 s in which the trigger does nothing, said in the
444 // one place on the HUD that claims the gun is ready to use.
445 applyInput(world, world.me, { ...NO_INPUT, switchTo: "knife" }, TICK);
446 world.time += TICK;
447 applyInput(world, world.me, { ...NO_INPUT, switchTo: "awp" }, TICK);
448 const coming = recorder();
449 draw(coming, world);
450 expect(ammoOf(coming).alpha).toBeLessThan(1);
451 // The weapon's name goes with it, and nothing above them does.
452 expect((coming.calls.find((c) => c.text === "AWP") as Call).alpha).toBeLessThan(1);
453 expect((coming.calls.find((c) => c.text === "$800") as Call).alpha).toBe(1);
454
455 // Still dim a whole second in, and full strength the moment the shot clock
456 // frees the trigger — the frame the view model is level.
457 world.time += 1;
458 const nearly = recorder();
459 draw(nearly, world);
460 expect(ammoOf(nearly).alpha).toBeLessThan(1);
461 world.time = world.me.nextShotAt;
462 const ready = recorder();
463 draw(ready, world);
464 expect(ammoOf(ready).alpha).toBe(1);
465 });
466
467 test("the cooldown between two shots is not a deploy, so the ammo counter holds", () => {
468 const world = createWorld(SHORT, "me", "ct", 5, 209);
469 world.time = 30;
470 const ammoOf = (r: { calls: Call[] }): Call =>
471 r.calls.find((c) => c.name === "fillText" && c.text.includes(" / ")) as Call;
472 // The USP mid-cooldown: the gun is up and working, not arriving.
473 world.me.lastShotAt = world.time;
474 world.me.nextShotAt = world.time + 60 / WEAPONS.usp.rpm;
475 const firing = recorder();
476 draw(firing, world);
477 expect(ammoOf(firing).alpha).toBe(1);
478 });
479
480 test("the HUD says what is in the pocket, and counts it where the ammo goes", () => {
481 const world = createWorld(SHORT, "me", "ct", 5, 97);
482 world.me.grenades = { he: 1 };
483 const carrying = recorder();
484 draw(carrying, world);
485 const texts = (r: { calls: Call[] }): string[] =>
486 r.calls.filter((c) => c.name === "fillText").map((c) => c.text);
487 expect(texts(carrying).some((t) => t.startsWith("HE ×1"))).toBe(true);
488
489 // Held, the count is what the magazine would have said, under its name.
490 world.me.weapon = "he";
491 const held = recorder();
492 draw(held, world);
493 expect(texts(held)).toContain("×1");
494 expect(texts(held)).toContain("HE Grenade");
495
496 // With none, neither line is there to read.
497 world.me.grenades = {};
498 world.me.weapon = world.me.secondary;
499 const without = recorder();
500 draw(without, world);
501 expect(without.calls.some((c) => c.text.includes("HE"))).toBe(false);
502 });
503
504 /**
505 * A world with nothing solid in it, the person at the origin looking east, and
506 * one live teammate and one live enemy to put wherever a test needs them. The
507 * boxes are replaced rather than emptied in place: `world.boxes` starts out as
508 * the shared map's own list, which no test may edit.
509 */
510 function plateWorld(): { world: World; me: Player; mate: Player; enemy: Player } {
511 const world = createWorld(SHORT, "me", "ct", 5, 61);
512 const me = world.me;
513 me.pos = [0, 0, 0];
514 me.yaw = 0;
515 me.pitch = 0;
516 const mates = world.players.filter((p) => p.team === "ct" && p !== me);
517 const enemies = world.players.filter((p) => p.team === "t");
518 for (const p of [...mates.slice(1), ...enemies.slice(1)]) p.alive = false;
519 const mate = mates[0] as Player, enemy = enemies[0] as Player;
520 mate.pos = [600, 60, 0];
521 enemy.pos = [600, -60, 0];
522 world.boxes = [];
523 return { world, me, mate, enemy };
96 return paint;
52497 }
52598
526 /** A wall across the view at `x`, tall enough to hide anyone standing behind it. */
527 const wallAt = (x: number): { min: [number, number, number]; max: [number, number, number]; mat: number } =>
528 ({ min: [x, -400, 0], max: [x + 16, 400, 200], mat: 0 });
99 /** Every line on the screen a player could actually read: alpha zero is not on it. */
100 const readable = (paint: Recorder): Call[] =>
101 paint.calls.filter((c) => c.op === "fillText" && c.alpha > 0);
529102
530 const written = (calls: Call[]): string[] => calls.filter((c) => c.name === "fillText").map((c) => c.text);
103 const words = (paint: Recorder): string[] => readable(paint).map((c) => String(c.args[0]));
531104
532 test("a name over the player in front of you, with health when they are yours", () => {
533 const { world, mate, enemy } = plateWorld();
534 mate.health = 64;
535 enemy.health = 37;
536 const frame = recorder();
537 draw(frame, world);
538 const texts = written(frame.calls);
105 const says = (paint: Recorder, text: string): boolean => words(paint).includes(text);
539106
540 expect(texts).toContain(mate.name);
541 expect(texts).toContain(enemy.name);
542 expect(texts).toContain("64"); // the teammate's health, under their name
543 expect(texts).not.toContain("37"); // an enemy's health is nobody's business
544 });
107 /** The one readable line that reads exactly this, and the brush that drew it. */
108 function line(paint: Recorder, text: string): Call {
109 const hits = readable(paint).filter((c) => c.args[0] === text);
110 expect(hits.length).toBe(1);
111 return hits[0] as Call;
112 }
545113
546 test("no wallhack: a player behind a box has no plate, and gets one again in the open", () => {
547 const { world, mate, enemy } = plateWorld();
548 world.boxes = [wallAt(300)];
549 const hidden = recorder();
550 draw(hidden, world);
551 expect(written(hidden.calls)).not.toContain(mate.name);
552 expect(written(hidden.calls)).not.toContain(enemy.name);
114 // --- the crosshair ---------------------------------------------------------
553115
554 // Same frame, same players, with the wall gone.
555 world.boxes = [];
556 const open = recorder();
557 draw(open, world);
558 expect(written(open.calls)).toContain(mate.name);
559 expect(written(open.calls)).toContain(enemy.name);
560 });
116 /**
117 * The crosshair's arm reaching right, as how far each of its two ends is from
118 * the middle of the screen. An arm is a filled rectangle of whole device pixels
119 * now rather than a stroked segment — see `engine/pixels.ts` — so it is found
120 * as the first fill that starts right of the centre and straddles the middle
121 * row, which nothing else on the HUD does.
122 */
123 function armOf(paint: Recorder, width = WIDTH, height = HEIGHT): { near: number; far: number } {
124 const cx = width / 2, cy = height / 2;
125 const arm = paint.calls.find((c) =>
126 c.op === "fillRect" && Number(c.args[0]) > cx &&
127 Number(c.args[1]) <= cy && Number(c.args[1]) + Number(c.args[3]) >= cy);
128 if (arm === undefined) throw new Error("no crosshair arm was filled");
129 const near = Number(arm.args[0]) - cx;
130 return { near, far: near + Number(arm.args[2]) };
131 }
561132
562 test("a plate needs a live player in front of the eye, and no scoreboard over it", () => {
563 const { world, mate, enemy } = plateWorld();
564 enemy.alive = false; // a body is not a player
565 mate.pos = [-600, 0, 0]; // behind the camera, which looks east
566 const away = recorder();
567 draw(away, world);
568 expect(written(away.calls)).not.toContain(mate.name);
569 expect(written(away.calls)).not.toContain(enemy.name);
133 /** Whether a crosshair was drawn at all: four arms, or none. */
134 const aimed = (paint: Recorder, width = WIDTH, height = HEIGHT): boolean =>
135 paint.calls.some((c) =>
136 c.op === "fillRect" && Number(c.args[0]) > width / 2 &&
137 Number(c.args[1]) <= height / 2 && Number(c.args[1]) + Number(c.args[3]) >= height / 2);
570138
571 // In front but off to the side past the 90° frustum: still nothing.
572 mate.pos = [600, 1400, 0];
573 const wide = recorder();
574 draw(wide, world);
575 expect(written(wide.calls)).not.toContain(mate.name);
139 /** How far the crosshair's first arm starts from the middle of the screen. */
140 const gapOf = (paint: Recorder, width = WIDTH): number => armOf(paint, width).near;
576141
577 // In front, alive, and visible — but a held Tab is showing the scoreboard,
578 // which names everyone anyway, so the plates stay out of it.
579 mate.pos = [600, 0, 0];
580 const held = recorder();
581 draw(held, world, world.me, true);
582 expect(written(held.calls).filter((t) => t === mate.name).length).toBe(0);
583 const plain = recorder();
584 draw(plain, world);
585 expect(written(plain.calls)).toContain(mate.name);
586 });
587
588 test("the plates are the watched player's while spectating, not the corpse's", () => {
589 const { world, me, mate, enemy } = plateWorld();
590 me.alive = false;
591 me.health = 0;
592 mate.pos = [600, 0, 0];
593 mate.yaw = 0;
594 enemy.pos = [1200, 0, 0]; // clear of the teammate, blocked from the corpse
595 world.boxes = [wallAt(300)];
596
597 const watching = recorder();
598 draw(watching, world, mate);
599 expect(written(watching.calls)).toContain(enemy.name);
600 expect(written(watching.calls)).not.toContain(mate.name); // never the player you are
601
602 // From the corpse's own eye the wall is in the way, which is the difference.
603 const corpse = recorder();
604 draw(corpse, world, me);
605 expect(written(corpse.calls)).not.toContain(enemy.name);
606 });
607
608 test("a plate across the map is a hint: fainter, and clamped small", () => {
609 const { world, mate, enemy } = plateWorld();
610 mate.alive = false; // one plate in the frame, and no health bar under it
611 enemy.pos = [500, 0, 0];
612 const near = recorder();
613 draw(near, world);
614 enemy.pos = [4000, 0, 0];
615 const far = recorder();
616 draw(far, world);
617
618 const plate = (calls: Call[]): Call => calls.find((c) => c.name === "fillText" && c.text === enemy.name) as Call;
619 // The backing behind the name is the only small fillRect in the frame; its
620 // height is the font size the plate was drawn at.
621 const backing = (calls: Call[]): Call => calls.find((c) => c.name === "fillRect" && c.args[2] < 30) as Call;
622
623 expect(plate(near.calls)).toBeDefined();
624 expect(plate(far.calls)).toBeDefined();
625 expect(plate(far.calls).alpha).toBeLessThan(plate(near.calls).alpha * 0.5);
626 expect(plate(far.calls).alpha).toBeGreaterThan(0.1); // faint, never invisible
627 expect(backing(far.calls).args[3]).toBeLessThan(backing(near.calls).args[3]);
628 // Clamped at both ends: a player in your face is not twice the size either.
629 enemy.pos = [120, 0, 0];
630 const close = recorder();
631 draw(close, world);
632 expect(backing(close.calls).args[3]).toBeCloseTo(backing(near.calls).args[3], 6);
633 });
634
635 test("a name plate sits over the body the renderer drew, not the tick it left", () => {
636 // The scene is drawn between two ticks; a plate taken from the newest one
637 // would swim above the head it belongs to. `alpha` is the same number both.
638 const { world, mate, enemy } = plateWorld();
639 mate.alive = false;
640 enemy.prev.pos = [600, -20, 0];
641 enemy.pos = [600, 20, 0];
642
643 const plateX = (alpha: number): number => {
644 const frame = recorder();
645 draw(frame, world, world.me, false, undefined, alpha);
646 const call = frame.calls.find((c) => c.name === "fillText" && c.text === enemy.name) as Call;
647 return call.args[0] as number;
648 };
649
650 const oldest = plateX(0), half = plateX(0.5), newest = plateX(1);
651 // Looking east with the player crossing from right to left of the screen.
652 expect(oldest).not.toBeCloseTo(newest, 0);
653 expect(half).toBeCloseTo((oldest + newest) / 2, 0);
654 });
655
656 // --- motion: what reduced motion is allowed to turn down, and what it is not.
657
658 /** One frame drawn at a motion setting, and the calls it made. */
659 const drawAt = (world: World, motion: Motion, view: Player = world.me): Call[] => {
660 const into = recorder();
661 drawHud(into.ctx, world, view, cameraFor(view), W, H, false, DEFAULT_CROSSHAIR, 1, motion);
662 return into.calls;
663 };
664
665 test("reduced motion holds a damage wedge steady where full motion fades it out", () => {
666 const fresh = hurtWorld(0, [-500, 0, 40], 0);
667 const older = hurtWorld(0, [-500, 0, 40], 0);
668 older.time += 0.8; // most of the way through HURT_FADE, and still in the list
669
670 const wedge = (world: World, motion: Motion): Call => wedgeArcs(drawAt(world, motion))[0] as Call;
671 // Full motion: the same wedge, most of a second later, is most of the way gone.
672 expect(wedge(older, "full").alpha).toBeLessThan(wedge(fresh, "full").alpha * 0.5);
673 // Reduced: it is the same strength for its whole life. Nothing slides.
674 expect(wedge(older, "reduced").alpha).toBeCloseTo(wedge(fresh, "reduced").alpha, 10);
675
676 // And it still says everything it said: where the hit came from, and how
677 // hard — a 60-damage hit is a stronger wedge than a 10-damage one, reduced
678 // motion or not, and it is in the same place either way.
679 const soft = hurtWorld(0, [-500, 0, 40], 0);
680 (soft.effects[0] as { amount: number }).amount = 10;
681 expect(wedge(soft, "reduced").alpha).toBeLessThan(wedge(fresh, "reduced").alpha);
682 expect(wedgePoint(wedge(fresh, "reduced"))).toEqual(wedgePoint(wedge(fresh, "full")));
683
684 // It ends where it always ended: a wedge past HURT_FADE is not drawn at all,
685 // so "steady" is a strength held and never a wedge that outstays its hit.
686 const gone = hurtWorld(0, [-500, 0, 40], 0);
687 gone.time += 1.5;
688 expect(wedgeArcs(drawAt(gone, "reduced")).length).toBe(0);
689 });
690
691 test("the hurt vignette is the same steady tint under either motion, and no frame of it reads a clock", () => {
692 const world = createWorld(SHORT, "me", "ct", 5, 79);
693 world.me.health = 20;
694 const vignette = (motion: Motion): Call =>
695 drawAt(world, motion).filter(
696 (c) => c.name === "fillRect" && c.args[0] === 0 && c.args[1] === 0 && c.args[2] === W,
697 )[0] as Call;
698
699 // There is no time term in it — only `view.health` — so it is already the
700 // steady tint reduced motion would ask for, and three seconds of world time
701 // change nothing about it on either setting.
702 const before = vignette("full").alpha;
703 expect(vignette("reduced").alpha).toBe(before);
704 world.time += 3;
705 expect(vignette("full").alpha).toBe(before);
706 expect(vignette("reduced").alpha).toBe(before);
707
708 // What it does say is how close to dead you are, and reduced motion is not
709 // allowed to take that away: nearly dead is a deeper tint than nearly hurt.
710 world.me.health = 5;
711 expect(vignette("reduced").alpha).toBeGreaterThan(before);
712 // And a healthy player has no tint at all, either way.
713 world.me.health = 100;
714 expect(vignette("reduced")).toBeUndefined();
715 expect(vignette("full")).toBeUndefined();
716 });
717
718 test("reduced motion leaves the hit marker, the kill feed and the crosshair exactly where they were", () => {
719 const world = createWorld(SHORT, "me", "ct", 5, 83);
720 world.me.weapon = "ak47";
721 world.me.vel = [180, 0, 0]; // moving, so the dynamic crosshair is open
722 world.effects.length = 0;
723 world.effects.push({ kind: "hitmark", at: world.time - 0.1, headshot: true, killed: true });
724 world.feed.push({
725 at: world.time, killer: "me", killerId: world.me.id, killerTeam: "ct", victim: "them",
726 weapon: "ak47", headshot: true, victimId: 5,
142 const spreadOf = (world: World, moving: number): number =>
143 inaccuracy(currentWeapon(world.me), {
144 moving,
145 inAir: !world.me.onGround,
146 ducking: world.me.duckAmount > 0.5,
147 scoped: world.me.scoped,
727148 });
728 // Every call, argument for argument and alpha for alpha: the whole of the
729 // rest of the HUD is information, and a setting that moved any of it would
730 // be a setting that changes the game.
731 expect(drawAt(world, "reduced")).toEqual(drawAt(world, "full"));
149
150 test("the crosshair opens exactly as far as the gun is inaccurate, and is gone when you are", () => {
151 const still = scene();
152 const rest = draw(still);
153 // At a standstill it is the gun's own spread and nothing else — in whole
154 // device pixels, because half a pixel of crosshair is a grey smear and not a
155 // finer aim. At a ratio of 1, which is what this file draws at, a device
156 // pixel is a CSS pixel and the number is the one it always was, rounded.
157 expect(gapOf(rest)).toBe(Math.round(4 + spreadOf(still, 0) * 14));
158 // Each arm is seven pixels long, from the gap outwards.
159 expect(armOf(rest).far).toBe(gapOf(rest) + 7);
160
161 // Running wide open: any speed at or over the walk cap counts as fully moving.
162 const running = scene((w) => { w.me.vel = [1000, 0, 0]; });
163 expect(gapOf(draw(running))).toBe(Math.round(4 + spreadOf(running, 1) * 14));
164 expect(gapOf(draw(running))).toBeGreaterThan(gapOf(rest));
165
166 // Scoped it draws tight, and darkens everything outside the sight.
167 const scoped = draw(scene((w) => { w.me.scoped = true; }));
168 expect(gapOf(scoped)).toBeLessThan(gapOf(rest));
169 expect(scoped.calls.some((c) => c.op === "arc")).toBe(true);
170 expect(scoped.calls.find((c) => c.op === "fill")?.args[0]).toBe("evenodd");
171
172 // Dead players do not aim: no crosshair, no sight, nothing stroked at all.
173 const dead = draw(scene((w) => { w.me.alive = false; }));
174 expect(dead.calls.some((c) => c.op === "stroke")).toBe(false);
175 expect(aimed(dead)).toBe(false);
176 });
177
178 test("the crosshair is whole device pixels and centred, at every ratio Windows has", () => {
179 // The report behind this: Windows ships display scaling at 125% and 150%, and
180 // a crosshair stroked through a centre at 800.5 is a grey smear three columns
181 // wide. Every arm is a filled rectangle of whole pixels now, and the four of
182 // them are symmetrical about the true centre of the canvas.
183 for (const dpr of [1, 1.25, 1.5, 2]) {
184 // An even backing store and an odd one: 125% of an odd CSS width is where
185 // the centre lands in the middle of a pixel rather than on a boundary.
186 for (const width of [Math.round(1280 * dpr), Math.round(1280 * dpr) + 1]) {
187 const height = width - 1;
188 const paint = draw(scene(), { width, height, dpr });
189 const arms = paint.calls.filter((c) =>
190 c.op === "fillRect" && Number(c.args[2]) < width / 4 && Number(c.args[3]) < height / 4);
191 expect(arms.length).toBeGreaterThanOrEqual(4);
192 for (const arm of arms.slice(0, 4))
193 for (const n of arm.args.slice(0, 4)) expect(Number.isInteger(Number(n))).toBe(true);
194 // The arm reaching right and the one reaching left are the same distance
195 // from the centre, so the cross does not lean at 125%.
196 const { near, far } = armOf(paint, width, height);
197 const left = paint.calls.filter((c) =>
198 c.op === "fillRect" && Number(c.args[0]) + Number(c.args[2]) < width / 2 &&
199 Number(c.args[1]) <= height / 2 && Number(c.args[1]) + Number(c.args[3]) >= height / 2);
200 const mirror = left[0] as Call;
201 expect(width / 2 - (Number(mirror.args[0]) + Number(mirror.args[2]))).toBe(near);
202 expect(width / 2 - Number(mirror.args[0])).toBe(far);
203 }
204 }
205 });
206
207 test("a crosshair is the same size on the glass however dense the screen is", () => {
208 // Seven CSS pixels of arm is seven device pixels at 1 and fourteen at 2. It
209 // used to be seven either way, so on a retina screen the cross was half the
210 // size it is on a plain one — and at 125% it was four fifths of it.
211 const at = (dpr: number): number => {
212 const { near, far } = armOf(
213 draw(scene(), { width: Math.round(1280 * dpr), height: Math.round(720 * dpr), dpr }),
214 Math.round(1280 * dpr), Math.round(720 * dpr),
215 );
216 return far - near;
217 };
218 // Within half a device pixel of seven CSS pixels at each ratio, which is as
219 // exact as whole pixels allow — and the point is that it scales at all.
220 for (const dpr of [1, 1.25, 1.5, 2]) expect(Math.abs(at(dpr) - 7 * dpr)).toBeLessThanOrEqual(0.5);
221 expect(at(1)).toBe(7);
222 expect(at(2)).toBe(14);
223 });
224
225 test("a hit is marked white, a headshot red, and both are gone a quarter second later", () => {
226 const hit = draw(scene((w) => {
227 w.time = 10;
228 w.effects = only({ kind: "hitmark", at: 9.9, headshot: false, killed: false });
229 }));
230 // One stroke, and it is the marker: the crosshair is filled, not stroked.
231 const body = hit.calls.filter((c) => c.op === "stroke");
232 expect(body.length).toBe(1);
233 expect(aimed(hit)).toBe(true);
234 expect(body[0]?.stroke).toBe("rgba(255,255,255,0.9)");
235
236 const head = draw(scene((w) => {
237 w.time = 10;
238 w.effects = only({ kind: "hitmark", at: 9.9, headshot: true, killed: false });
239 }));
240 expect(head.calls.filter((c) => c.op === "stroke")[0]?.stroke).toBe("rgba(255,90,90,0.95)");
241
242 const old = draw(scene((w) => {
243 w.time = 10;
244 w.effects = only({ kind: "hitmark", at: 9.7, headshot: true, killed: false });
245 }));
246 // A quarter second later the marker is gone and nothing is stroked at all —
247 // the crosshair, which is still drawn, is four fills.
248 expect(old.calls.filter((c) => c.op === "stroke").length).toBe(0);
249 expect(aimed(old)).toBe(true);
250 });
251
252 test("the hit that kills is an X, not a fifth tick, and it stays up longer", () => {
253 const mark = (at: number, killed: boolean): Recorder => draw(scene((w) => {
254 w.time = 10;
255 w.effects = only({ kind: "hitmark", at, headshot: false, killed });
256 }));
257 const strokes = (paint: Recorder): number => paint.calls.filter((c) => c.op === "lineTo").length;
258 const hit = mark(9.9, false);
259 const kill = mark(9.9, true);
260 // The same four ticks, and for a kill two more strokes through the middle.
261 expect(strokes(kill) - strokes(hit)).toBe(2);
262 expect(strokes(hit) - strokes(mark(9, false))).toBe(4);
263 // Past a hit's quarter second a kill's mark is still up; past its own
264 // KILL_MARK_LIFE it is gone too.
265 const later = 10 - (HIT_MARK_LIFE + KILL_MARK_LIFE) / 2;
266 expect(mark(later, false).calls.some((c) => c.op === "stroke")).toBe(false);
267 expect(mark(later, true).calls.some((c) => c.op === "stroke")).toBe(true);
268 expect(mark(10 - KILL_MARK_LIFE - 0.01, true).calls.some((c) => c.op === "stroke")).toBe(false);
269 });
270
271 // --- health, money and armour ----------------------------------------------
272
273 test("health reads white while you are standing and red when you are nearly gone", () => {
274 expect(line(draw(scene()), "100").fill).toBe("#f3f5f8");
275 expect(line(draw(scene((w) => { w.me.health = 31; })), "31").fill).toBe("#f3f5f8");
276 // Thirty itself is already the red one: the HUD asks `health > 30`.
277 expect(line(draw(scene((w) => { w.me.health = 30; })), "30").fill).toBe("#ff6b5b");
278 expect(line(draw(scene((w) => { w.me.health = 9; })), "9").fill).toBe("#ff6b5b");
279
280 // It is a whole number, and never a negative one.
281 expect(says(draw(scene((w) => { w.me.health = 45.6; })), "46")).toBe(true);
282 expect(says(draw(scene((w) => { w.me.health = -12; })), "0")).toBe(true);
283 });
284
285 test("the wallet, the armour and the kit say what you bought", () => {
286 const poor = draw(scene());
287 expect(says(poor, "$800")).toBe(true);
288 expect(says(poor, "no armour")).toBe(true);
289 expect(says(poor, "defuse kit")).toBe(false);
290
291 const kitted = draw(scene((w) => {
292 w.me.money = 4250;
293 w.me.armor = 100;
294 w.me.helmet = true;
295 w.me.defuseKit = true;
296 }));
297 expect(says(kitted, "$4250")).toBe(true);
298 expect(says(kitted, "armour 100 + helmet")).toBe(true);
299 expect(says(kitted, "defuse kit")).toBe(true);
300
301 // A terrorist with a kit in the inventory is still a terrorist: no kit line.
302 const t = draw(scene((w) => { w.me.team = "t"; w.me.defuseKit = true; }));
303 expect(says(t, "defuse kit")).toBe(false);
304 });
305
306 // --- ammo ------------------------------------------------------------------
307
308 test("ammo reads mag / reserve, the knife reads a dash, and reloading says so", () => {
309 const loaded = draw(scene((w) => {
310 w.me.weapon = "usp";
311 w.me.ammo.usp = { mag: 5, reserve: 20 };
312 }));
313 expect(says(loaded, "5 / 20")).toBe(true);
314 expect(says(loaded, WEAPONS.usp.name)).toBe(true);
315 expect(says(loaded, "reloading")).toBe(false);
316
317 const knife = draw(scene((w) => { w.me.weapon = "knife"; }));
318 expect(says(knife, "—")).toBe(true);
319 expect(says(knife, WEAPONS.knife.name)).toBe(true);
320
321 // Mid-reload the magazine still reads empty, and the word is on the screen.
322 const reloading = draw(scene((w) => {
323 w.time = 10;
324 w.me.reloadUntil = 10.5;
325 w.me.ammo.usp = { mag: 0, reserve: 24 };
326 }));
327 expect(says(reloading, "0 / 24")).toBe(true);
328 expect(line(reloading, "reloading").fill).toBe("#f0c060");
329
330 // The instant it finishes the word goes.
331 expect(says(draw(scene((w) => { w.time = 10; w.me.reloadUntil = 10; })), "reloading")).toBe(false);
332
333 // Mid-switch the same line says so instead: the weapon is in the hands but
334 // not up yet, and the word goes the instant it is.
335 const switching = draw(scene((w) => { w.time = 10; w.me.deployUntil = 10.5; }));
336 expect(line(switching, "switching").fill).toBe("#f0c060");
337 expect(says(switching, "reloading")).toBe(false);
338 expect(says(draw(scene((w) => { w.time = 10; w.me.deployUntil = 10; })), "switching")).toBe(false);
339 // And a switch that cut a reload short reads as the switch it is.
340 const both = draw(scene((w) => { w.time = 10; w.me.deployUntil = 10.5; w.me.reloadUntil = 12; }));
341 expect(says(both, "switching")).toBe(true);
342 expect(says(both, "reloading")).toBe(false);
343
344 // While it is on its way up the counter and the name are dimmed, and the
345 // word that explains it is not; the tick it is up they are whole again.
346 const drawing = (until: number): Recorder => draw(scene((w) => {
347 w.time = 10;
348 w.me.weapon = "usp";
349 w.me.ammo.usp = { mag: 12, reserve: 24 };
350 w.me.deployUntil = until;
351 }));
352 const rising = drawing(10.5);
353 expect(line(rising, "12 / 24").alpha).toBeCloseTo(NOT_READY_ALPHA, 10);
354 expect(line(rising, WEAPONS.usp.name).alpha).toBeCloseTo(NOT_READY_ALPHA, 10);
355 expect(line(rising, "switching").alpha).toBe(1);
356 const up = drawing(10);
357 expect(line(up, "12 / 24").alpha).toBe(1);
358 expect(line(up, WEAPONS.usp.name).alpha).toBe(1);
359
360 // Carrying the bomb is written beside the gun's name.
361 const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; }));
362 expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true);
363 });
364
365 // --- the clock and the score -----------------------------------------------
366
367 test("the clock counts the phase down, goes red when the bomb is down, and stops between rounds", () => {
368 const freeze = draw(scene((w) => { w.match.phase = "freeze"; w.match.timer = 8; }));
369 expect(line(freeze, "0:08").fill).toBe("#f3f5f8");
370
371 const live = draw(scene((w) => { w.match.phase = "live"; w.match.timer = 95; }));
372 expect(line(live, "1:35").fill).toBe("#f3f5f8");
373
374 const planted = draw(scene((w) => { w.match.phase = "planted"; w.match.timer = 40; }));
375 expect(line(planted, "0:40").fill).toBe("#ff6b5b");
376
377 // Nothing is being counted between rounds or after the match: no clock to read.
378 for (const phase of ["over", "match_over"] as const) {
379 const paint = draw(scene((w) => { w.match.phase = phase; w.match.timer = 5; }));
380 expect(says(paint, "--:--")).toBe(true);
381 expect(says(paint, "0:05")).toBe(false);
382 }
383 });
384
385 test("the score sits either side of the clock, each in its team's colour, and so do the survivors", () => {
386 const paint = draw(scene((w) => {
387 w.match.score = { t: 7, ct: 11 };
388 for (const p of w.players) if (p.team === "t" && p !== w.me) p.alive = false;
389 }));
390 expect(line(paint, "7").fill).toBe("#e2b04a");
391 expect(line(paint, "11").fill).toBe("#6fa8e8");
392 // Five terrorists, all of them down; five counter-terrorists, all still up.
393 expect(line(paint, "T 0").fill).toBe("#e2b04a");
394 expect(line(paint, "CT 5").fill).toBe("#6fa8e8");
395 });
396
397 // --- the kill feed ---------------------------------------------------------
398
399 const kill = (at: number, over: Partial<World["feed"][number]> = {}): World["feed"][number] => ({
400 at, killer: "Kestrel", victim: "Pike", weapon: "ak47", headshot: false, killerTeam: "t", ...over,
401 });
402
403 test("a kill is named with the gun that made it, fades for eight seconds and is then gone", () => {
404 const fresh = draw(scene((w) => { w.time = 100; w.feed = [kill(100)]; }));
405 const entry = line(fresh, `Kestrel ${WEAPONS.ak47.name} Pike`);
406 expect(entry.alpha).toBe(1);
407 expect(entry.fill).toBe("#e2b04a");
408
409 // Halfway out it is half there, and a CT's kill is the other colour.
410 const fading = draw(scene((w) => {
411 w.time = 100;
412 w.feed = [kill(92.5, { killerTeam: "ct", headshot: true })];
413 }));
414 const faded = line(fading, `Kestrel ${WEAPONS.ak47.name} ⌖ Pike`);
415 expect(faded.alpha).toBeCloseTo(0.5, 10);
416 expect(faded.fill).toBe("#6fa8e8");
417
418 // Eight seconds old is the end of it: nothing readable, and nothing at all after.
419 const spent = draw(scene((w) => { w.time = 100; w.feed = [kill(92)]; }));
420 expect(readable(spent).some((c) => String(c.args[0]).includes("Kestrel"))).toBe(false);
421 const gone = draw(scene((w) => { w.time = 100; w.feed = [kill(91.9)]; }));
422 expect(gone.calls.some((c) => c.op === "fillText" && String(c.args[0]).includes("Kestrel"))).toBe(false);
423
424 // A fall is its own cause: the victim on both sides and the word, not a gun.
425 const fell = draw(scene((w) => { w.time = 100; w.feed = [kill(100, { killer: "Pike", weapon: "fall" })]; }));
426 expect(line(fell, "Pike Fall Pike").alpha).toBe(1);
427
428 // The feed shows the last five kills, newest last, oldest dropped.
429 const busy = draw(scene((w) => {
430 w.time = 100;
431 w.feed = [1, 2, 3, 4, 5, 6].map((n) => kill(99, { victim: `Down ${n}` }));
432 }));
433 expect(words(busy).filter((s) => s.includes("Down")).length).toBe(5);
434 expect(words(busy).some((s) => s.includes("Down 1"))).toBe(false);
435 expect(words(busy).some((s) => s.includes("Down 6"))).toBe(true);
436 });
437
438 test("the round's last word is on the screen for four seconds", () => {
439 const said = draw(scene((w) => {
440 w.time = 100;
441 w.messages = [{ at: 99.9, text: "The bomb has been planted at A." }];
442 }));
443 expect(says(said, "The bomb has been planted at A.")).toBe(true);
444
445 const stale = draw(scene((w) => {
446 w.time = 100;
447 w.messages = [{ at: 96, text: "The bomb has been planted at A." }];
448 }));
449 expect(says(stale, "The bomb has been planted at A.")).toBe(false);
450 });
451
452 // --- planting and defusing -------------------------------------------------
453
454 /** The filled fraction of the use bar: the bar's width over the panel behind it. */
455 function useBar(paint: Recorder): { fraction: number; fill: string } {
456 const rects = paint.calls.filter((c) => c.op === "fillRect");
457 const bar = rects[rects.length - 1] as Call;
458 const panel = rects[rects.length - 2] as Call;
459 return { fraction: Number(bar.args[2]) / (Number(panel.args[2]) - 4), fill: bar.fill };
460 }
461
462 test("the use bar fills as far as the plant or the defuse has got", () => {
463 const nothing = draw(scene());
464 expect(says(nothing, "Planting")).toBe(false);
465
466 const planting = draw(scene((w) => {
467 w.me.team = "t";
468 w.me.hasBomb = true;
469 w.bomb.plantProgress = SHORT.plantTime / 2;
470 }));
471 expect(says(planting, "Planting")).toBe(true);
472 expect(useBar(planting).fraction).toBeCloseTo(0.5, 10);
473 expect(useBar(planting).fill).toBe("#e2b04a");
474
475 const defusing = draw(scene((w) => {
476 w.bomb.state = "planted";
477 w.bomb.defuser = w.me;
478 w.bomb.defuseProgress = SHORT.defuseTime * 0.25;
479 }));
480 expect(says(defusing, "Defusing")).toBe(true);
481 expect(useBar(defusing).fraction).toBeCloseTo(0.25, 10);
482 expect(useBar(defusing).fill).toBe("#6fa8e8");
483
484 // A kit is faster, and says so, and the bar is full when the wire is cut.
485 const kit = draw(scene((w) => {
486 w.me.defuseKit = true;
487 w.bomb.state = "planted";
488 w.bomb.defuser = w.me;
489 w.bomb.defuseProgress = SHORT.defuseTimeWithKit;
490 }));
491 expect(says(kit, "Defusing (kit)")).toBe(true);
492 expect(useBar(kit).fraction).toBeCloseTo(1, 10);
493 });
494
495 // --- dead, and the scoreboard ----------------------------------------------
496
497 const ALONE = "You are dead — watching the round out";
498 const SWITCH = "fire — next player · aim — the one before";
499
500 /** A teammate to watch, and the world with you dead in it. */
501 function spectating(change: (world: World) => void = () => {}): { world: World; mate: Player } {
502 const world = scene((w) => {
503 w.me.alive = false;
504 change(w);
505 });
506 const mate = world.players.find((p) => p !== world.me && p.team === world.me.team) as Player;
507 return { world, mate };
508 }
509
510 test("a dead player watching nobody is told so, until the match itself is over", () => {
511 // The whole side down and the round still running — a planted bomb — is the
512 // only frame left where the camera is your own body and there is no name.
513 expect(says(draw(scene()), ALONE)).toBe(false);
514 expect(says(draw(scene((w) => { w.me.alive = false; })), ALONE)).toBe(true);
515 expect(says(draw(scene((w) => {
516 w.me.alive = false;
517 w.match.phase = "match_over";
518 })), ALONE)).toBe(false);
519 });
520
521 test("watching someone says whose eyes these are, in their team's colour, and how to change them", () => {
522 const { world, mate } = spectating();
523 const paint = draw(world, { view: mate });
524 expect(says(paint, `Watching ${mate.name}`)).toBe(true);
525 expect(says(paint, SWITCH)).toBe(true);
526 expect(says(paint, ALONE)).toBe(false);
527 expect(line(paint, `Watching ${mate.name}`).fill).toBe("#6fa8e8"); // this side is CT
528
529 // Alive, there is nothing to say: no banner, no keys.
530 const playing = draw(scene());
531 expect(words(playing).some((w) => w.startsWith("Watching"))).toBe(false);
532 expect(says(playing, SWITCH)).toBe(false);
533 });
534
535 test("the numbers under a spectated round are the watched player's, not your corpse's", () => {
536 const { world, mate } = spectating((w) => {
537 w.me.health = 89; // a corpse's last number, which is nobody's business now
538 w.me.money = 1234;
539 });
540 mate.health = 57;
541 mate.money = 4300;
542 mate.weapon = "ak47";
543 mate.ammo["ak47"] = { mag: 21, reserve: 60 };
544 const paint = draw(world, { view: mate });
545 expect(says(paint, "57")).toBe(true);
546 expect(says(paint, "$4300")).toBe(true);
547 expect(says(paint, "21 / 60")).toBe(true);
548 expect(says(paint, WEAPONS.ak47.name)).toBe(true);
549 // Nothing of yours is on the screen: not your health, not your wallet.
550 expect(says(paint, "89")).toBe(false);
551 expect(says(paint, "$1234")).toBe(false);
552
553 // And their crosshair is drawn, because *they* are aiming: a dead player's
554 // own view had none, which is what made the old spectate look broken.
555 expect(paint.calls.some((c) => c.op === "moveTo")).toBe(true);
556 });
557
558 test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => {
559 const world = scene((w) => {
560 const [me, second, third, fourth] = w.players;
561 if (!me || !second || !third || !fourth) throw new Error("this scene wants four players");
562 me.kills = 2; me.deaths = 1; me.headshots = 1; me.damage = 310.4; me.money = 3200;
563 second.kills = 5; second.damage = 700;
564 third.kills = 2; third.damage = 480; // same kills as me, more damage: above me
565 fourth.kills = 0; fourth.alive = false;
566 });
567 const paint = draw(world, { scoreboard: true });
568 const rows = readable(paint).filter((c) => c.align === "left" && String(c.args[0]).includes(" "));
569
570 const names = rows.map((c) => String(c.args[0]));
571 expect(names[0]).toContain(world.players[1]?.name);
572 expect(names[1]).toContain(world.players[2]?.name);
573 expect(names[2]).toContain("you");
574
575 // Your own row is the white one, and it is the only one that says so.
576 const mine = rows[2] as Call;
577 expect(mine.fill).toBe("#ffffff");
578 expect(names.filter((n) => n.includes("(you)")).length).toBe(1);
579 expect(rows.filter((r) => r.fill === "#ffffff").length).toBe(1);
580
581 // Every other row wears its team's colour, and a dead one is dimmed.
582 expect(rows.filter((r) => r.fill === "#e2b04a" || r.fill === "#6fa8e8").length).toBe(9);
583 const down = rows.find((r) => String(r.args[0]).includes(world.players[3]?.name ?? "")) as Call;
584 expect(down.alpha).toBeCloseTo(0.45, 10);
585 expect(mine.alpha).toBe(1);
586
587 // The columns are there, and your figures are under them, rounded.
588 expect(words(paint)).toContain("kills");
589 expect(words(paint)).toContain("damage");
590 const figures = readable(paint).filter((c) => c.align === "right");
591 expect(figures.map((c) => String(c.args[0]))).toContain("310");
592 expect(figures.map((c) => String(c.args[0]))).toContain("$3200");
593
594 // It is drawn over everything: its scrim comes after the health it covers.
595 const scrim = paint.calls.findIndex((c) => c.fill === "rgba(10, 12, 16, 0.88)");
596 const health = paint.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100");
597 expect(scrim).toBeGreaterThan(health);
598
599 // And nothing of it is on the screen when it is not being held open.
600 expect(says(draw(world), "kills")).toBe(false);
601 });
602
603 // --- both ends of the screen -----------------------------------------------
604
605 /** A frame with every panel on it at once, so a layout test has something to lay out. */
606 const busyWorld = (): World => scene((w) => {
607 w.time = 100;
608 w.match.phase = "planted";
609 w.match.timer = 37;
610 w.match.score = { t: 9, ct: 12 };
611 w.me.health = 24;
612 w.me.armor = 100;
613 w.me.helmet = true;
614 w.me.defuseKit = true;
615 w.me.money = 12450;
616 w.me.reloadUntil = 101;
617 w.bomb.state = "planted";
618 w.bomb.defuser = w.me;
619 w.bomb.defuseProgress = 1;
620 w.effects = these(
621 { kind: "hitmark", at: 99.95, headshot: true, killed: false },
622 // Somebody is shooting back, which is a wash and an arc on the same frame.
623 { kind: "hurt", at: 99.9, from: [w.me.pos[0] - 400, w.me.pos[1] + 250, 0], damage: 27 },
624 );
625 w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })];
626 w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }];
627 for (const p of w.players) p.kills = p.id;
628 (w.players[4] as Player).alive = false;
629 });
630
631 /** Every call that put something over the edge of the canvas. */
632 function offCanvas(paint: Recorder, width: number, height: number): Call[] {
633 const on = (x: number, y: number): boolean => x >= 0 && y >= 0 && x <= width && y <= height;
634 return paint.calls.filter((c) => {
635 const [a, b, c3, d] = c.args.map(Number);
636 if (c.op === "fillText") return !on(Number(c.args[1]), Number(c.args[2]));
637 if (c.op === "moveTo" || c.op === "lineTo") return !on(a as number, b as number);
638 if (c.op === "arc") return !on((a as number) - (c3 as number), (b as number) - (c3 as number)) ||
639 !on((a as number) + (c3 as number), (b as number) + (c3 as number));
640 if (c.op === "fillRect" || c.op === "rect" || c.op === "clearRect")
641 return !on(a as number, b as number) || !on((a as number) + (c3 as number), (b as number) + (d as number));
642 return false;
643 });
644 }
645
646 const fontsUsed = (paint: Recorder): string[] =>
647 [...new Set(paint.calls.filter((c) => c.op === "fillText").map((c) => c.font))].sort();
648
649 test("the whole readout fits a phone and a desk, and says the same amount at both", () => {
650 const world = busyWorld();
651 const phone = draw(world, { width: 360, height: 640, scoreboard: true });
652 const desk = draw(world, { width: 1920, height: 1080, scoreboard: true });
653
654 expect(offCanvas(phone, 360, 640)).toEqual([]);
655 expect(offCanvas(desk, 1920, 1080)).toEqual([]);
656
657 // The scale is `width / 1280`, clamped to 0.7 .. 1.4: 21/13/11 px on a phone,
658 // 42/27/21 on a desk, and no other size on either.
659 expect(fontsUsed(phone)).toEqual([
660 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
661 ]);
662 expect(fontsUsed(desk)).toEqual([
663 "21px system-ui, sans-serif", "27px system-ui, sans-serif", "42px system-ui, sans-serif",
664 ]);
665
666 // The same world reads the same on both: every line of it, in the same order.
667 expect(words(phone)).toEqual(words(desk));
668 expect(phone.calls.length).toBe(desk.calls.length);
669 expect(phone.calls.map((c) => c.op)).toEqual(desk.calls.map((c) => c.op));
670
671 // And the frame starts by clearing the one it is drawn on, whatever its size.
672 expect(phone.calls[0]?.op).toBe("clearRect");
673 expect(phone.calls[0]?.args).toEqual([0, 0, 360, 640]);
674 });
675
676 test("the same canvas turned sideways is drawn at the new size, not the one before", () => {
677 // The engine's tools are built for one surface at one size and kept between
678 // frames, because a frame that rebuilt them would allocate for no new number.
679 // A phone rotating is the frame where a cache that ignored the size would
680 // draw the old one: same canvas, same world, different width.
681 const world = busyWorld();
682 const paint = recorder();
683 drawHud(paint, world, 1280, 720, false, world.me);
684 const before = paint.calls.length;
685 drawHud(paint, world, 360, 640, false, world.me);
686 const after = paint.calls.slice(before);
687
688 expect(after[0]?.args).toEqual([0, 0, 360, 640]);
689 expect([...new Set(after.filter((c) => c.op === "fillText").map((c) => c.font))].sort()).toEqual([
690 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
691 ]);
692 // The clock bar is 240 units wide at the scale the *new* width says. It is
693 // the first filled rectangle that is not a full-screen wash — this world has
694 // somebody shooting back in it, and the pain flash goes on before anything.
695 // (The four narrow rectangles before it are the crosshair's arms.)
696 const bar = after.find((c) =>
697 c.op === "fillRect" && c.args[2] !== 360 && Number(c.args[2]) > 100) as Call;
698 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
699 });
700
701 // --- flashed ---------------------------------------------------------------
702
703 /** Every full-screen wash this frame drew, with the brush that drew it. */
704 const washes = (paint: Recorder, width = WIDTH, height = HEIGHT): Call[] =>
705 paint.calls.filter((c) => c.op === "fillRect" && c.args[2] === width && c.args[3] === height);
706
707 /** The opacity of an `rgba(...)` fill, or 0 for anything else. */
708 const opacity = (fill: string): number => Number(/rgba\([^)]*,([^,)]+)\)$/.exec(fill)?.[1] ?? 0);
709
710 test("a flashed player gets white over the world, and the crosshair back before it ends", () => {
711 const max = 1.6;
712 // Unflashed there is no wash at all: this costs a frame nothing.
713 expect(washes(draw(scene()))).toEqual([]);
714
715 // The white is read off the simulation's own number, in seconds.
716 const full = washes(draw(scene((w) => { (w.me as Player).blind = max; })));
717 expect(full.length).toBe(1);
718 expect(opacity(full[0]?.fill ?? "")).toBeCloseTo(whiteout(max), 6);
719 // It never quite reaches one: the world is always a ghost behind it.
720 expect(whiteout(max)).toBeLessThan(1);
721 expect(whiteout(max)).toBeGreaterThan(0.85);
722
723 // It fades, and it is gone with the blindness.
724 expect(whiteout(0.45)).toBeLessThan(whiteout(0.9));
725 expect(whiteout(0.9)).toBe(whiteout(max)); // held at full while there is time left
726 expect(washes(draw(scene((w) => { (w.me as Player).blind = 0; })))).toEqual([]);
727
728 // The crosshair is drawn *under* the wash and is readable again for the last
729 // half second of any flash: at 0.45 s left the white is under a half.
730 expect(whiteout(0.45)).toBeLessThan(0.5);
731 const half = draw(scene((w) => { (w.me as Player).blind = 0.45; }));
732 const wash = washes(half)[0] as Call;
733 const crosshair = half.calls.findIndex((c) =>
734 c.op === "fillRect" && Number(c.args[0]) > WIDTH / 2 &&
735 Number(c.args[1]) <= HEIGHT / 2 && Number(c.args[1]) + Number(c.args[3]) >= HEIGHT / 2);
736 expect(crosshair).toBeGreaterThanOrEqual(0);
737 expect(half.calls.indexOf(wash)).toBeGreaterThan(crosshair);
738
739 // And the numbers go on top of it: a flashed player still reads their health.
740 const flashed = draw(scene((w) => { (w.me as Player).blind = max; }));
741 const health = flashed.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100");
742 expect(health).toBeGreaterThan(flashed.calls.indexOf(washes(flashed)[0] as Call));
743 });
744
745 // --- hurt: where it came from, and how hard ---------------------------------
746
747 /**
748 * Every damage arc this frame drew, as the angle each sits at *on the screen*:
749 * zero is straight ahead, ninety is off to your right and a hundred and eighty
750 * is behind you. The recorded call is a canvas angle — zero to the right of the
751 * middle, growing clockwise — so this is that, turned a quarter turn.
752 *
753 * Only the arcs: the scope's sight and the radar's dots go through `arc` too
754 * and are *filled*, so what is taken here is an arc in the hurt red that was
755 * stroked — the brush and the call that closed the path, both of which the
756 * recorder wrote down.
757 */
758 const arcs = (paint: Recorder): { at: number; alpha: number; radius: number }[] =>
759 paint.calls
760 .filter((c, i) =>
761 c.op === "arc" && c.stroke.startsWith("rgba(255,70,60") && paint.calls[i + 1]?.op === "stroke")
762 .map((c) => ({
763 at: turn(((Number(c.args[3]) + Number(c.args[4])) / 2) * (180 / Math.PI) + 90),
764 alpha: Number(/,([^,)]+)\)$/.exec(c.stroke)?.[1] ?? 0),
765 radius: Number(c.args[2]),
766 }));
767
768 /** An angle in degrees, folded into -180 .. 180. */
769 const turn = (degrees: number): number => ((((degrees + 180) % 360) + 360) % 360) - 180;
770
771 /** How far from straight ahead an arc is, whichever way round it went. */
772 const behindness = (at: number): number => Math.abs(at);
773
774 /** A world in which somebody at `from` has just shot you, while you face `yaw`. */
775 const shotAt = (from: [number, number], yaw: number, damage = 27, ago = 0.1): World =>
776 scene((w) => {
777 w.time = 10;
778 w.me.pos = [0, 0, 0];
779 w.me.yaw = yaw;
780 w.effects = only({ kind: "hurt", at: 10 - ago, from: [from[0], from[1], 0], damage });
781 });
782
783 test("the arc points at whoever hit you, and turning takes it with you", () => {
784 // Facing east. North is your left, so an attacker due north is drawn left.
785 const left = arcs(draw(shotAt([0, 500], 0)));
786 expect(left.length).toBe(1);
787 expect(left[0]?.at).toBeCloseTo(-90, 6);
788
789 // The same attacker, you turned ninety degrees to face them: dead ahead.
790 expect(arcs(draw(shotAt([0, 500], 90)))[0]?.at).toBeCloseTo(0, 6);
791 // And ninety the other way puts them behind you, which is the same ninety
792 // degrees of arc travel in the other direction.
793 expect(behindness(arcs(draw(shotAt([0, 500], -90)))[0]?.at ?? 0)).toBeCloseTo(180, 6);
794
795 // Shot in the back, facing east: an attacker due west is at the bottom.
796 expect(behindness(arcs(draw(shotAt([-500, 0], 0)))[0]?.at ?? 0)).toBeCloseTo(180, 6);
797 // Turn ninety degrees to your left and the arc walks exactly ninety degrees
798 // round the ring the other way: what was behind you is now off your left.
799 const turned = arcs(draw(shotAt([-500, 0], 90)))[0]?.at ?? 0;
800 expect(turned).toBeCloseTo(-90, 6);
801 expect(behindness(turn(180 - turned))).toBeCloseTo(90, 6);
802
803 // To your right, and nowhere near the crosshair: the ring is 28% of the
804 // shorter side out, which on this canvas is 201 pixels from the middle.
805 const right = arcs(draw(shotAt([0, -500], 0)));
806 expect(right[0]?.at).toBeCloseTo(90, 6);
807 expect(right[0]?.radius).toBeCloseTo(HEIGHT * 0.28, 6);
808 });
809
810 test("under reduced motion an arc holds its strength for its life and then goes", () => {
811 const steady = (ago: number) =>
812 arcs(draw(shotAt([-500, 0], 0, 27, ago), { motion: "reduced" }));
813 expect(steady(0)[0]?.alpha).toBeCloseTo(0.85, 3);
814 // Where the full setting has faded to half, this one has not moved.
815 expect(steady(HURT_LIFE / 2)[0]?.alpha).toBeCloseTo(0.85, 3);
816 expect(steady(HURT_LIFE - 0.01)[0]?.alpha).toBeCloseTo(0.85, 3);
817 // Its life is the same life: gone on the same tick.
818 expect(steady(HURT_LIFE)).toEqual([]);
819 // And the bearing is the bearing — only the fade was decoration.
820 expect(behindness(steady(0.1)[0]?.at ?? 0)).toBeCloseTo(180, 6);
821 });
822
823 test("an arc fades over its life and is gone at the end of it, and two attackers are two arcs", () => {
824 const fresh = arcs(draw(shotAt([-500, 0], 0, 27, 0)));
825 expect(fresh[0]?.alpha).toBeCloseTo(0.85, 3);
826
827 // Half way through its life it is half there.
828 const half = arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE / 2)));
829 expect(half[0]?.alpha).toBeCloseTo(0.425, 3);
830
831 // A hair inside its life is still drawn; the life itself is the end of it.
832 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE - 0.01))).length).toBe(1);
833 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE)))).toEqual([]);
834 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE + 1)))).toEqual([]);
835
836 // Two people shooting you from two sides is two arcs, each at its own bearing.
837 const crossfire = arcs(draw(scene((w) => {
838 w.time = 10;
839 w.me.pos = [0, 0, 0];
840 w.me.yaw = 0;
841 w.effects = these(
842 { kind: "hurt", at: 9.9, from: [0, 500, 0], damage: 27 },
843 { kind: "hurt", at: 9.9, from: [0, -500, 0], damage: 27 },
844 );
845 })));
846 expect(crossfire.length).toBe(2);
847 expect(crossfire.map((a) => Math.round(a.at)).sort((a, b) => a - b)).toEqual([-90, 90]);
848
849 // Untouched, there is no arc at all: this costs a quiet frame nothing.
850 expect(arcs(draw(scene()))).toEqual([]);
851 });
852
853 test("a fall hurts from straight below: the pain flash says it, and there is no arc since it came from no side", () => {
854 // `shotAt` from where you stand is exactly what `land` records: your own feet.
855 const fell = draw(shotAt([0, 0], 0, 12, 0));
856 expect(arcs(fell)).toEqual([]);
857 expect(opacity(washes(fell)[0]?.fill ?? "")).toBeCloseTo(painAlpha(12, 0), 3);
858 });
859
860 test("the pain flash is as hard as the hit was, is gone in a quarter second, and is under the crosshair", () => {
861 // Nothing taken, nothing washed.
862 expect(washes(draw(scene()))).toEqual([]);
863
864 // An AK round to an armoured chest: a sixth of a red screen at its worst.
865 const round = draw(shotAt([-500, 0], 0, 27, 0));
866 expect(opacity(washes(round)[0]?.fill ?? "")).toBeCloseTo(painAlpha(27, 0), 3);
867 expect(painAlpha(27, 0)).toBeCloseTo(0.162, 3);
868
869 // A body-shot AWP is the whole of it, and half a body is already the cap: a
870 // hit that takes more than fifty does not wash the screen out any further.
871 expect(painAlpha(100, 0)).toBeCloseTo(0.3, 6);
872 expect(painAlpha(50, 0)).toBe(painAlpha(100, 0));
873 // It never comes near what a flashbang is allowed to do.
874 expect(painAlpha(100, 0)).toBeLessThan(whiteout(1.6) / 3);
875
876 // It fades, and a third of a second is the end of it.
877 expect(painAlpha(100, 0.125)).toBeCloseTo(0.15, 6);
878 expect(painAlpha(100, 0.25)).toBe(0);
879 expect(washes(draw(shotAt([-500, 0], 0, 100, 0.25)))).toEqual([]);
880 // The arc outlives it by a long way: the wash says *ouch* and the arc says
881 // where, and only one of those is worth having over your sights.
882 expect(arcs(draw(shotAt([-500, 0], 0, 100, 0.25))).length).toBe(1);
883
884 // And the crosshair goes on *after* it, so the shot you take back is aimed
885 // at exactly what it was aimed at before — the opposite way round from the
886 // white-out, which is meant to blind you.
887 const wash = washes(round)[0] as Call;
888 expect(round.calls.indexOf(wash)).toBeLessThan(round.calls.findIndex((c) => c.op === "stroke"));
889
890 // A spectator is not the one being shot at: a dead player watching a mate
891 // gets neither the wash nor the arc, whatever your corpse last took.
892 const { world, mate } = spectating((w) => {
893 w.time = 10;
894 w.effects = only({ kind: "hurt", at: 9.95, from: [-500, 0, 0], damage: 100 });
895 });
896 const watching = draw(world, { view: mate });
897 expect(washes(watching)).toEqual([]);
898 expect(arcs(watching)).toEqual([]);
899 });
900
901 test("the pocket line names each grenade you are carrying, and both at once", () => {
902 const none = draw(scene());
903 expect(words(none).some((t) => t.includes("HE"))).toBe(false);
904 const both = draw(scene((w) => {
905 (w.me as Player).pocket = { he: 1, flash: 1, smoke: 0 };
906 }));
907 const named = words(both).find((t) => t.includes("HE")) ?? "";
908 expect(named).toContain("HE");
909 expect(named).toContain("flash");
910 const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1, smoke: 0 }; }));
911 const line = words(only).find((t) => t.includes("flash")) ?? "";
912 expect(line).not.toContain("HE");
732913 });
src/client/hud.ts +398 −552
@@ -1,653 +1,499 @@
11 /**
22 * The HUD, drawn on a 2D canvas over the scene: crosshair, health, ammo, the
3 * round clock, the kill feed, the names over the players you can see, and the
4 * scoreboard. It reads the world and draws; it never changes anything.
3 * round clock, the kill feed, the scoreboard, the radar, and — the one thing on
4 * it about damage coming the other way — the flash and the arcs that say you
5 * were hit and from where. It reads the world and draws; it never changes
6 * anything.
57 *
6 * It is handed the player it is drawing rather than reaching for `world.me`:
7 * while the person is dead the camera is on a teammate, and the numbers under
8 * it have to be that teammate's or the HUD belongs to a corpse.
8 * The radar is `src/client/minimap.ts` — the level from above is a picture of
9 * its own and everything about drawing one is in there. What stays here is what
10 * stays here for every other thing on the screen: where the square sits and how
11 * big it is, which is the one question this file answers.
12 *
13 * What it draws onto is a `Paint` — the seventeen members of a 2D context it
14 * actually uses, and not a whole `CanvasRenderingContext2D`. A browser's
15 * context is one; so is a recorder in a test, which is how `main.test.ts` runs
16 * a frame of this with no canvas in the room. It is the engine's `Paint`, with
17 * the styles narrowed to the strings this game writes into them.
18 *
19 * The furniture is the engine's: `overlay` hands back the scale, the pad, the
20 * scaled fonts, the scrim, a line of text with an alignment and a fade, the
21 * crosshair, a progress bar, a row of right-aligned columns and the `m:ss`
22 * clock — the same tools `src/range/readout.ts` draws its panel with. What is
23 * left in this file is Counter-Strike's and nothing else: where a thing sits,
24 * which phase has a clock, and the words. What colour a side is drawn in is
25 * `src/client/palette.ts`, because the radar draws the same ten players.
926 */
1027
11 import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
12 import { lerpPos } from "./interpolate.ts";
13 import { angleDelta, clamp, rad, type Vec3 } from "./math.ts";
14 import { walkable, type MapDef } from "./map.ts";
15 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
16 import { hullHeight, MOVE } from "./physics.ts";
17 import { project, type Projected } from "./project.ts";
18 import { buildRadar } from "./radar.ts";
19 import type { Camera } from "./renderer.ts";
20 import { drawReport } from "./reportPanel.ts";
21 import { deathNotice } from "./spectate.ts";
22 import { inaccuracy, WEAPONS } from "./weapons.ts";
2328 import {
24 canSee, causeName, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress,
25 type Player, type World,
26 } from "./world.ts";
27
28 const T_COLOR = "#e2b04a";
29 const CT_COLOR = "#6fa8e8";
29 angleDelta, clamp, effectAt, overlay, ratioOf,
30 type Overlay, type Paint as Surface, type Vec3,
31 } from "../engine/index.ts";
32 import { drawMinimap } from "./minimap.ts";
33 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
34 import { CS_MOVE } from "./movement.ts";
35 import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts";
36 import { inaccuracy } from "./weapons.ts";
37 import { useProgress } from "./bomb.ts";
38 import { scoreLines } from "./summary.ts";
39 import { causeName, currentWeapon, eyePos, insideSmoke, type Player, type World } from "./world.ts";
3040
3141 /**
32 * How long a damage indicator lives. Well inside the two seconds `update`
33 * keeps an effect for, so the trim window stays where it is.
42 * Everything this module asks of the thing it draws on. Nothing else: it is the
43 * engine's surface, with `fillStyle` and the rest narrowed to `string`, because
44 * this game only ever writes colours into them and a test that records the
45 * brush wants a string back.
3446 */
35 const HURT_FADE = 1.1;
47 export type Paint = Surface & {
48 fillStyle: string;
49 strokeStyle: string;
50 font: string;
51 textAlign: string;
52 textBaseline: string;
53 };
3654
37 /** How long a round message stays up when the clock is not holding it there. */
38 const MESSAGE_FADE = 4;
55 /**
56 * The radar's square, before the scale: 180 of them on a 1280-wide canvas. It
57 * is also capped at a third of the shorter side, so a short landscape phone
58 * does not get a map with the clock sitting on it.
59 */
60 const RADAR = 180;
3961
4062 /**
4163 * What is left of the ammo counter and the weapon's name while the gun is
42 * still coming up. The gun rising in the middle of the screen is the cue; this
43 * is the one that says what it means, because a person who clicks and gets
44 * nothing for the 1.25 s an AWP takes should not have to guess why. It lifts
45 * on the frame `nextShotAt` passes — the frame the gun is level and the
46 * trigger answers — so the two cues end together.
64 * still coming up: a click that does nothing for the 1.25 s an AWP takes
65 * should not leave anyone guessing why.
4766 */
48 const NOT_READY_ALPHA = 0.35;
67 export const NOT_READY_ALPHA = 0.35;
4968
5069 /**
5170 * How long a hit marker lives, and how much longer the one for a kill does:
52 * long enough to register as its own thing at a glance, still well inside the
53 * two seconds `update` keeps an effect for.
71 * long enough to register as its own thing at a glance, well inside the time
72 * the effect ring keeps an effect for.
5473 */
55 const HIT_MARK_LIFE = 0.25;
56 const KILL_MARK_LIFE = 0.45;
74 export const HIT_MARK_LIFE = 0.25;
75 export const KILL_MARK_LIFE = 0.45;
76 const KILL_STROKES: [number, number][] = [[1, 1], [1, -1]];
5777
58 // The vignette is one radial gradient per canvas size, kept between frames:
59 // building it costs an allocation and drawHud runs sixty times a second, so it
60 // is built with its colours at full strength and faded with globalAlpha.
61 let vignette: CanvasGradient | null = null;
62 let vignetteSize = "";
78 /** What a dead player is told, in the two cases there are. */
79 const DEAD = "You are dead — watching the round out";
80 const WATCHING = "Watching";
81 const SWITCH = "fire — next player · aim — the one before";
6382
64 function hurtVignette(ctx: CanvasRenderingContext2D, width: number, height: number): CanvasGradient {
65 const size = `${width}x${height}`;
66 if (vignette === null || vignetteSize !== size) {
67 const gradient = ctx.createRadialGradient(
68 width / 2, height / 2, Math.min(width, height) * 0.22,
69 width / 2, height / 2, Math.max(width, height) * 0.62,
70 );
71 gradient.addColorStop(0, "rgba(140, 0, 0, 0)");
72 gradient.addColorStop(0.55, "rgba(160, 6, 6, 0.4)");
73 gradient.addColorStop(1, "rgba(190, 14, 14, 0.95)");
74 vignette = gradient;
75 vignetteSize = size;
83 /** The scoreboard's heading row, and the empty other half of each `columns` call. */
84 const FIGURES = ["kills", "deaths", "hs", "damage", "money"] as const;
85 const NOTHING: readonly string[] = [];
86
87 /**
88 * The tools, kept from frame to frame. `overlay` is built for one surface at
89 * one size — a resize, not a frame — and a frame that rebuilt it would allocate
90 * an object and ten closures sixty times a second for no new number. The cache
91 * is one entry deep and keyed on what it was built from, so a test drawing onto
92 * a fresh recorder gets fresh tools and never another test's.
93 */
94 let tools: Overlay | null = null;
95 let builtOn: Paint | null = null;
96
97 function toolsFor(ctx: Paint, width: number, height: number, dpr: number): Overlay {
98 if (
99 tools === null || builtOn !== ctx ||
100 tools.width !== width || tools.height !== height || tools.ratio !== ratioOf(dpr)
101 ) {
102 tools = overlay(ctx, width, height, dpr);
103 builtOn = ctx;
76104 }
77 return vignette;
105 return tools;
78106 }
79107
80108 /**
81 * The wedges around the crosshair pointing at whatever just hurt `view`, in its
82 * own yaw frame, and the red vignette that deepens as its health drops. Several
83 * hits stack: each is its own wedge, fading on its own clock. A spectator sees
84 * neither: `hurt` effects are only ever pushed for the person, so a teammate's
85 * id matches nothing here.
109 * The white-out, as opacity: how much white is over the world with `blind`
110 * seconds of a flash left to run —
86111 *
87 * `steady` is reduced motion, decided once by `drawHud` and passed down rather
88 * than asked per wedge. It takes the fade off a wedge and nothing else: where
89 * it came from and how hard it hit are what a wedge says, and a strength that
90 * slides to nothing over a second is only how it leaves. The vignette is the
91 * same picture on both settings by construction — there is no clock anywhere
92 * in it, only `view.health`, so it is already the steady tint reduced motion
93 * would ask for, and deepening as you are shot is information rather than
94 * decoration.
95 */
96 function drawDamage(
97 ctx: CanvasRenderingContext2D,
98 world: World,
99 view: Player,
100 width: number,
101 height: number,
102 steady: boolean,
103 ): void {
104 const low = clamp((55 - view.health) / 55, 0, 1);
105 if (view.alive && low > 0) {
106 ctx.globalAlpha = 0.65 * low * low;
107 ctx.fillStyle = hurtVignette(ctx, width, height);
108 ctx.fillRect(0, 0, width, height);
109 ctx.globalAlpha = 1;
110 }
111
112 const eye = eyePos(view);
113 const radius = Math.min(width, height) * 0.14;
114 for (const effect of world.effects) {
115 if (effect.kind !== "hurt" || effect.victim !== view.id) continue;
116 const age = world.time - effect.at;
117 if (age < 0 || age > HURT_FADE) continue;
118 // A fall hurts from straight below: no bearing, so no wedge — the
119 // vignette and the flinch are the whole of it.
120 if (Math.hypot(effect.from[0] - eye[0], effect.from[1] - eye[1]) < 1) continue;
121 const bearing = (Math.atan2(effect.from[1] - eye[1], effect.from[0] - eye[0]) * 180) / Math.PI;
122 // Yaw grows anticlockwise, so a source to the left is a positive angle off
123 // the view; the canvas turns clockwise from +x, so the wedge sits at minus
124 // that angle, a quarter turn back from straight up.
125 const middle = rad(-angleDelta(view.yaw, bearing)) - Math.PI / 2;
126 const half = rad(14 + clamp(effect.amount, 0, 60) * 0.2);
127 const fade = steady ? 1 : clamp(1 - age / HURT_FADE, 0, 1);
128 ctx.globalAlpha = fade * clamp(0.35 + effect.amount / 50, 0, 0.9);
129 ctx.strokeStyle = "#ff4238";
130 ctx.lineWidth = Math.max(3, Math.round(radius * 0.13));
131 ctx.beginPath();
132 ctx.arc(width / 2, height / 2, radius, middle - half, middle + half);
133 ctx.stroke();
134 }
135 ctx.globalAlpha = 1;
136 }
137
138 const clock = (seconds: number): string => {
139 const s = Math.max(0, Math.ceil(seconds));
140 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
141 };
142
143 // The radar's map is a few hundred walkable cells, the same every frame: baked
144 // once into an offscreen canvas at the on-screen size and rotated by the
145 // canvas transform as the view turns, instead of walked and re-rotated by
146 // hand sixty times a second. Rebuilt only when the size or the map changes,
147 // same as the vignette above — keyed by the map itself, so a switch to a
148 // second layout can never draw the first one's walls.
149 let radarMap: OffscreenCanvas | HTMLCanvasElement | null = null;
150 let radarMapSize = -1;
151 let radarMapFor: MapDef | null = null;
152
153 function radarMapImage(map: MapDef, size: number): CanvasImageSource | null {
154 if (radarMap === null || radarMapSize !== size || radarMapFor !== map) {
155 if (typeof OffscreenCanvas !== "undefined") radarMap = new OffscreenCanvas(size, size);
156 else if (typeof document !== "undefined") {
157 const canvas = document.createElement("canvas");
158 canvas.width = size;
159 canvas.height = size;
160 radarMap = canvas;
161 } else return null; // no canvas to build one on, e.g. a headless test
162 const rctx = radarMap.getContext("2d") as CanvasRenderingContext2D;
163 const cw = size / map.gridW, ch = size / map.gridH;
164 rctx.fillStyle = "rgba(190, 198, 212, 0.55)";
165 for (let r = 0; r < map.gridH; r++)
166 for (let c = 0; c < map.gridW; c++)
167 if (walkable(map, c, r)) rctx.fillRect(c * cw, r * ch, cw + 0.5, ch + 0.5);
168 radarMapSize = size;
169 radarMapFor = map;
170 }
171 return radarMap;
172 }
173
174 /**
175 * The radar: `view`'s map, rotated so its yaw points up, in a circle at
176 * `(x, y)` of `size` pixels across. Only ever `view`'s team, the bomb once
177 * it is planted, and the two sites — the data `buildRadar` hands back, with
178 * no enemy in it to draw even by mistake.
179 */
180 function drawRadar(
181 ctx: CanvasRenderingContext2D,
182 world: World,
183 view: Player,
184 x: number,
185 y: number,
186 size: number,
187 ): void {
188 const radius = size / 2;
189 const cx = x + radius, cy = y + radius;
190 ctx.fillStyle = "rgba(8, 10, 14, 0.6)";
191 ctx.beginPath();
192 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
193 ctx.fill();
194
195 ctx.save();
196 ctx.beginPath();
197 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
198 ctx.clip();
199 const image = radarMapImage(world.map, Math.max(1, Math.round(size)));
200 if (image !== null) {
201 // The image is baked with north up; turning it to match the view's yaw
202 // is one canvas rotation, not a re-walk of the grid.
203 ctx.save();
204 ctx.translate(cx, cy);
205 ctx.rotate(rad(view.yaw - 90));
206 ctx.drawImage(image, -radius, -radius, size, size);
207 ctx.restore();
208 }
209
210 const teamColor = view.team === "t" ? T_COLOR : CT_COLOR;
211 ctx.textAlign = "center";
212 for (const blip of buildRadar(world, view)) {
213 const px = cx + blip.x * radius, py = cy + blip.y * radius;
214 if (blip.kind === "site") {
215 ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
216 ctx.beginPath();
217 ctx.arc(px, py, Math.max(3, radius * 0.05), 0, Math.PI * 2);
218 ctx.fill();
219 ctx.font = `${Math.round(radius * 0.16)}px system-ui, sans-serif`;
220 ctx.fillText(blip.label, px, py - radius * 0.09);
221 } else if (blip.kind === "bomb") {
222 ctx.fillStyle = "#ff6b5b";
223 ctx.beginPath();
224 ctx.arc(px, py, Math.max(3, radius * 0.06), 0, Math.PI * 2);
225 ctx.fill();
226 } else if (blip.kind === "mate") {
227 ctx.fillStyle = teamColor;
228 ctx.beginPath();
229 ctx.arc(px, py, Math.max(2, radius * 0.045), 0, Math.PI * 2);
230 ctx.fill();
231 } else {
232 // self: an arrow, not a dot, pointing at `facing` — always straight up,
233 // since the whole radar is already turned to match the view's yaw.
234 const r = Math.max(4, radius * 0.08);
235 const dir = blip.facing - Math.PI / 2;
236 ctx.fillStyle = "#ffffff";
237 ctx.beginPath();
238 ctx.moveTo(px + Math.cos(dir) * r, py + Math.sin(dir) * r);
239 ctx.lineTo(px + Math.cos(dir + 2.4) * r * 0.6, py + Math.sin(dir + 2.4) * r * 0.6);
240 ctx.lineTo(px + Math.cos(dir - 2.4) * r * 0.6, py + Math.sin(dir - 2.4) * r * 0.6);
241 ctx.closePath();
242 ctx.fill();
243 }
244 }
245 ctx.restore();
246
247 ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
248 ctx.lineWidth = 2;
249 ctx.beginPath();
250 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
251 ctx.stroke();
252 }
253
254 /** A plate is full strength this close and at its faintest by here. */
255 const PLATE_NEAR = 400;
256 const PLATE_FAR = 3000;
257 /** How far above the top of the hull the plate floats, in world units. */
258 const PLATE_LIFT = 14;
259
260 /**
261 * A name over every player the eye can actually see: in front of it, inside
262 * the frustum, traced clear of the level's boxes, and alive. That list is the
263 * whole point — a name drawn through a wall is a wallhack — so the test that
264 * puts a box in the way is the one that matters, and the trace is `canSee`,
265 * the same eye-to-eye geometry the bots shoot by.
112 * alpha(b) = PEAK · min(1, b / WHITEOUT_FADE)
266113 *
267 * Teammates carry their health, in your side's colour; an enemy is a name and
268 * nothing else. Both fade with distance and are clamped in size, so the far
269 * side of the map is a hint rather than a billboard. The geometry is
270 * `project.ts`'s: this function only decides what a plate looks like.
114 * Two deliberate limits, because a full-screen white-out is the easiest thing
115 * in this game to make unplayable. It never reaches 1, so the world is always a
116 * ghost behind it rather than gone. And it falls to nothing over the last
117 * `WHITEOUT_FADE` seconds, so the crosshair under it is readable again once
118 * alpha drops under about a half — at `b = 0.49 s` — whatever the flash was
119 * worth. The longest a flash can be is `FLASH.maxBlind`, 1.6 seconds, of which
120 * at most the first 0.7 is full white.
271121 */
272 function drawPlates(
273 ctx: CanvasRenderingContext2D,
274 world: World,
275 view: Player,
276 camera: Camera,
277 width: number,
278 height: number,
279 scale: number,
280 alpha: number,
281 ): void {
282 const plates: { player: Player; at: Projected }[] = [];
283 const blend: Vec3 = [0, 0, 0];
284 for (const p of world.players) {
285 if (p === view || !p.alive) continue;
286 if (!canSee(world, view, p)) continue; // one trace per player per frame
287 // Over the head the renderer drew, not the head the last tick left: a
288 // plate that sat on the newest tick would swim above a body drawn between
289 // two of them. `alpha` is the same number the scene was drawn with.
290 lerpPos(p.prev.pos, p.pos, alpha, blend);
291 const head: [number, number, number] = [blend[0], blend[1], blend[2] + hullHeight(p) + PLATE_LIFT];
292 const at = project(head, camera, width, height);
293 if (at !== null) plates.push({ player: p, at });
294 }
295 // Furthest first, so the near one is the one you read where two overlap.
296 plates.sort((a, b) => b.at.depth - a.at.depth);
122 export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WHITEOUT_FADE);
297123
298 ctx.textAlign = "center";
299 const teamColor = view.team === "t" ? T_COLOR : CT_COLOR;
300 for (const { player, at } of plates) {
301 // 15px at 900 units, shrinking with the perspective divide the way the
302 // player under it does — but clamped both ways, so a plate is never a
303 // headline in your face nor a line of pixels you cannot read.
304 const size = Math.round(clamp((15 * 900 * scale) / at.depth, 9 * scale, 17 * scale));
305 const mate = player.team === view.team;
306 ctx.globalAlpha = clamp(1 - (at.depth - PLATE_NEAR) / (PLATE_FAR - PLATE_NEAR), 0.16, 1);
307 ctx.font = `${size}px system-ui, sans-serif`;
308 const nameWidth = ctx.measureText(player.name).width;
309 ctx.fillStyle = "rgba(10, 12, 16, 0.42)";
310 ctx.fillRect(at.x - nameWidth / 2 - size * 0.3, at.y - size, nameWidth + size * 0.6, size * 1.25);
311 ctx.fillStyle = mate ? teamColor : "#eef0f5";
312 ctx.fillText(player.name, at.x, at.y);
313 if (mate) {
314 // Health as a bar under the name: a number to read and a length to
315 // glance at, which is what "is this one worth trading for" asks.
316 const fraction = clamp(player.health / 100, 0, 1);
317 const barW = Math.round(size * 3), barH = Math.max(2, Math.round(size * 0.18));
318 const barY = at.y + size * 0.4;
319 ctx.fillStyle = "rgba(10, 12, 16, 0.5)";
320 ctx.fillRect(at.x - barW / 2, barY, barW, barH);
321 ctx.fillStyle = fraction > 0.3 ? "#7ddc8a" : "#ff6b5b";
322 ctx.fillRect(at.x - barW / 2, barY, barW * fraction, barH);
323 // The number keeps the bar's colour: one glance says how much and
324 // whether it is the kind of much you go and help with.
325 ctx.font = `${Math.max(8, Math.round(size * 0.78))}px system-ui, sans-serif`;
326 ctx.fillText(String(Math.max(0, Math.round(player.health))), at.x, barY + barH + size * 0.78);
327 }
328 }
329 ctx.globalAlpha = 1;
124 /** The most white there ever is, and the seconds it takes to clear. */
125 const PEAK = 0.92;
126 const WHITEOUT_FADE = 0.9;
127
128 /**
129 * Taking damage, on the screen, is two things with two clocks.
130 *
131 * **The arc** says where it came from: a stroke on a ring around the middle of
132 * the view, in the direction of whoever hit you *relative to where you are
133 * facing now*, so it swings as you turn and sits still if you do not. It lives
134 * `HURT_LIFE` seconds and fades linearly to nothing over them — long enough to
135 * read while you are still turning, short enough that it is never telling you
136 * about a fight that is over. Every live hit draws its own, so two people
137 * shooting you from two sides is two arcs and not an average of them.
138 *
139 * **The flash** says how hard: a red wash whose opacity is the damage over
140 * `PAIN_FULL` — half a body — capped, and gone `PAIN_LIFE` seconds later.
141 *
142 * The numbers are meant to be argued with, so here they are out loud. An AK
143 * round to an armoured chest at 500 units is 27 damage, which peaks at
144 * `0.3 · 27/50 ≈ 0.16` of red; an AWP body shot is 111 and peaks at the full
145 * 0.3. A quarter of a second at a third of a colour is a hit you cannot miss
146 * and nothing you have to wait out: an M4 fires every 0.09 s and an AWP every
147 * 1.46, so the flash is over before your next shot either way, and the worst of
148 * it is a third of what `whiteout` above is allowed to do — a flashbang is
149 * meant to take the round off you and a bullet is not.
150 *
151 * And it draws *under* the crosshair, which is the one rule a full-screen wash
152 * on an aiming game has to keep: the crosshair goes on after it, at its own
153 * colour, so the shot you take back is aimed at exactly what it was aimed at
154 * before. `whiteout` is the deliberate other way round.
155 */
156 export const HURT_LIFE = 1.25;
157 const PAIN_LIFE = 0.25;
158 const PAIN_FULL = 50;
159 const PAIN_PEAK = 0.3;
160
161 /** How much red is over the world from one hit of `damage`, `age` seconds on. */
162 export const painAlpha = (damage: number, age: number): number =>
163 age < 0 || age >= PAIN_LIFE ? 0 : PAIN_PEAK * Math.min(1, damage / PAIN_FULL) * (1 - age / PAIN_LIFE);
164
165 /** The red it washes in, and the red the arcs are stroked in. */
166 const PAIN = "190,30,26";
167 const HURT = "255,70,60";
168
169 /** How far out the arcs sit as a fraction of the shorter side, and how wide each is. */
170 const HURT_RADIUS = 0.28;
171 const HURT_HALF = 0.34; // radians: an arc about 39° across
172 const HURT_ALPHA = 0.85;
173
174 /**
175 * Where a hit at `from` sits on that ring, for a body at `at` facing `yaw`, as
176 * the angle `ctx.arc` wants: zero to the right of the middle of the screen,
177 * growing clockwise. Straight ahead is the top of the ring and behind you is
178 * the bottom, so turning ninety degrees walks the arc ninety degrees round it.
179 *
180 * Height is dropped on purpose: this is a bearing you turn by, and a shot from
181 * a floor above is still a shot from over there.
182 */
183 export function hurtAngle(yaw: number, from: Vec3, at: Vec3): number {
184 const bearing = (Math.atan2(from[1] - at[1], from[0] - at[0]) * 180) / Math.PI;
185 // `angleDelta` counts counter-clockwise, which on the ground is to your left;
186 // the screen's angles run the other way, and that is the minus sign.
187 return -Math.PI / 2 - (angleDelta(yaw, bearing) * Math.PI) / 180;
330188 }
331189
190 /**
191 * How much grey is over the world when the eye is inside a cloud of smoke.
192 *
193 * It is a constant and not a ramp, on purpose: the simulation's answer inside a
194 * cloud is a flat *no* — `canSee` refuses a line that meets the ball at all, so
195 * a bot one unit inside one is as blind as a bot in the middle — and a wash
196 * that faded with depth would be the screen disagreeing with the round. Like
197 * the white-out above it stops short of 1, so the crosshair and the world are a
198 * ghost behind it rather than gone; unlike the white-out it does not fade,
199 * because a cloud does not fade while you are standing in it.
200 *
201 * From outside, a cloud is a ball `scene.ts` places and this draws nothing at
202 * all. The two are the same fact from the two sides of one surface.
203 */
204 export const FOG = 0.9;
205
206 /**
207 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
208 * the player you are watching once you are dead — and it is what every number
209 * about a body is read off: the crosshair that opens as far as *their* gun is
210 * inaccurate, their health, their armour, their magazine, their defuse bar. The
211 * round, the feed and the scoreboard belong to the match and are the same from
212 * anybody's eyes. Left out, the view is you, which is every frame of a round
213 * you are alive in.
214 *
215 * `width` and `height` are the canvas's backing store — device pixels, not CSS
216 * pixels — and `dpr` is how many of those go to a CSS pixel, so that the
217 * crosshair is the same size on the glass and exact on the grid at 125% and
218 * 150% as well as at 100%. Left out it is 1, which is what every test draws at.
219 */
332220 export function drawHud(
333 ctx: CanvasRenderingContext2D,
221 ctx: Paint,
334222 world: World,
335 view: Player,
336 camera: Camera,
337223 width: number,
338224 height: number,
339225 showScoreboard: boolean,
340 crosshair: CrosshairStyle = DEFAULT_CROSSHAIR,
341 alpha = 1,
226 view: Player = world.me,
227 dpr = 1,
342228 motion: Motion = DEFAULT_MOTION,
343229 ): void {
230 const ui = toolsFor(ctx, width, height, dpr);
344231 ctx.clearRect(0, 0, width, height);
345232 ctx.textBaseline = "alphabetic";
346 // One value, read once a frame, the way the view and the crosshair are:
347 // what it turns down is decided here and handed on, never re-decided per
348 // element further down.
349 const steady = motion === "reduced";
350 const spectating = view !== world.me;
351 const over = world.match.phase === "match_over";
352233 const weapon = currentWeapon(view);
353 const pad = Math.max(12, Math.round(width * 0.02));
234 const pad = ui.pad;
235 // Damage is something a body takes, so the indicator is drawn only from the
236 // eyes of the body that took it: yours, while you are standing. A spectator
237 // is not being shot at, and a corpse is not turning to face anything.
238 const hurting = view === world.me && view.alive;
354239
355 // --- being shot: where it came from, and how close to dead it left us
356 drawDamage(ctx, world, view, width, height, steady);
240 // --- the pain flash, first of everything and under the crosshair. The
241 // strongest hit still on the clock decides it rather than the sum of them:
242 // two rifle rounds in one tick is one hit's worth of red and not two, which
243 // is what keeps a burst from washing the screen out.
244 if (hurting) {
245 let pain = 0;
246 for (let i = 0; i < world.effects.count; i++) {
247 const effect = effectAt(world.effects, i);
248 if (effect === undefined || effect.kind !== "hurt") continue;
249 pain = Math.max(pain, painAlpha(effect.damage, world.time - effect.at));
250 }
251 if (pain > 0) {
252 ctx.fillStyle = `rgba(${PAIN},${pain.toFixed(3)})`;
253 ctx.fillRect(0, 0, width, height);
254 }
255 }
357256
358 // --- crosshair, opening up exactly as much as the gun is inaccurate — as
359 // far as the player's own style lets it: what the arms look like is
360 // crosshair.ts's, and the only thing decided here is the spread they answer
361 // to. A colour is a name that module resolves, never a string from storage.
362 if (view.alive && !spectating && !over) {
257 // --- crosshair, opening up exactly as much as the gun is inaccurate
258 if (view.alive) {
363259 const speed = Math.hypot(view.vel[0], view.vel[1]);
364260 const spread = inaccuracy(weapon, {
365 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
261 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
366262 inAir: !view.onGround,
367263 ducking: view.duckAmount > 0.5,
368264 scoped: view.scoped,
369265 });
370 const shape = crosshairShape(crosshair, spread, width / 2, height / 2);
371 ctx.strokeStyle = shape.colour;
372 ctx.lineWidth = shape.thickness;
373 ctx.beginPath();
374 for (const segment of shape.segments) {
375 ctx.moveTo(segment.x1, segment.y1);
376 ctx.lineTo(segment.x2, segment.y2);
377 }
378 ctx.stroke();
379 if (shape.dot !== null) {
380 ctx.fillStyle = shape.colour;
266 // The gap and the arm in CSS pixels, turned into whole device pixels here:
267 // the cross is the same size on the glass at 100%, 125% and 200%, and every
268 // edge of it is a pixel's edge. `px` never rounds down to nothing, so the
269 // tightest scoped crosshair is still a cross.
270 ui.crosshair(width / 2, height / 2, ui.px(4 + spread * 14), ui.px(7), CROSSHAIR);
271 if (view.scoped) {
272 ctx.fillStyle = "rgba(0,0,0,0.55)";
381273 ctx.beginPath();
382 ctx.arc(shape.dot.x, shape.dot.y, shape.dot.radius, 0, Math.PI * 2);
383 ctx.fill();
274 ctx.rect(0, 0, width, height);
275 ctx.arc(width / 2, height / 2, Math.min(width, height) * 0.36, 0, Math.PI * 2, true);
276 ctx.fill("evenodd");
384277 }
385278 }
386 // The scope mask belongs to the camera, not to the crosshair: a spectator
387 // watching a scoped teammate is looking down that scope's field of view.
388 if (view.alive && view.scoped) {
389 ctx.fillStyle = "rgba(0,0,0,0.55)";
390 ctx.beginPath();
391 ctx.rect(0, 0, width, height);
392 ctx.arc(width / 2, height / 2, Math.min(width, height) * 0.36, 0, Math.PI * 2, true);
393 ctx.fill("evenodd");
394 }
395279
396 // --- hit markers, which are the person's own hits and nobody else's: a
397 // spectator never inherits the ones the corpse left behind.
398 if (!spectating && view.alive) {
399 for (const effect of world.effects) {
400 if (effect.kind !== "hitmark") continue;
401 const age = world.time - effect.at;
402 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
403 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
404 ctx.lineWidth = effect.killed ? 3 : 2;
405 const r = 9 + age * 20;
406 ctx.beginPath();
407 for (const [dx, dy] of [[1, 1], [-1, -1], [1, -1], [-1, 1]]) {
408 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
409 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
280 // --- hit markers. Four arms like the crosshair's, but on the diagonals and
281 // growing as the mark ages, which is not a shape the engine hands out.
282 for (let i = 0; i < world.effects.count; i++) {
283 const effect = effectAt(world.effects, i);
284 if (effect === undefined || effect.kind !== "hitmark") continue;
285 const age = world.time - effect.at;
286 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
287 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
288 // On the diagonals, so there is no pixel grid to land on and no point
289 // pretending there is — but it is still the same size on the glass at every
290 // ratio, which is the half of this a dense screen used to lose.
291 ctx.lineWidth = ui.px(effect.killed ? 3 : 2);
292 const r = ui.px(9 + age * 20);
293 ctx.beginPath();
294 const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]];
295 for (const [dx, dy] of arms) {
296 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
297 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
298 }
299 // The hit that finished them closes the four ticks into an X through the
300 // middle: a different shape, not a shade of the same one.
301 if (effect.killed)
302 for (const [dx, dy] of KILL_STROKES) {
303 ctx.moveTo(width / 2 - dx * r * 0.45, height / 2 - dy * r * 0.45);
304 ctx.lineTo(width / 2 + dx * r * 0.45, height / 2 + dy * r * 0.45);
410305 }
411 // The one that finished them closes the four ticks into an X through the
412 // middle — two more strokes on the same path, heavier and held longer,
413 // so a kill is a different shape and not a shade of the same one. A
414 // headshot kill is that X in the headshot's red.
415 if (effect.killed)
416 for (const [dx, dy] of [[1, 1], [1, -1]]) {
417 ctx.moveTo(width / 2 - dx * r * 0.45, height / 2 - dy * r * 0.45);
418 ctx.lineTo(width / 2 + dx * r * 0.45, height / 2 + dy * r * 0.45);
419 }
306 ctx.stroke();
307 }
308
309 // --- flashed: white over the world, and nothing else. The blindness itself
310 // is the simulation's — `view.blind`, in seconds, burned down at 64 Hz — so
311 // this is only how it looks and a bot flashed by the same grenade suffers the
312 // same number. It goes on after the crosshair and before the numbers: it
313 // dims the crosshair as it should and never hides the clock or your health.
314 if (view.blind > 0) {
315 ctx.fillStyle = `rgba(255,255,255,${whiteout(view.blind).toFixed(3)})`;
316 ctx.fillRect(0, 0, width, height);
317 }
318
319 // --- and in the smoke: the same idea from inside a cloud instead of outside
320 // one. The ball `scene.ts` draws is opaque and its far faces are culled, so
321 // an eye within it would otherwise see straight through the thing that is
322 // blocking every sightline in the simulation. Which eye is the view's, so a
323 // spectator sees the fog the body they are watching is standing in.
324 if (view.alive && insideSmoke(world, eyePos(view))) {
325 ctx.fillStyle = `rgba(196,200,208,${FOG})`;
326 ctx.fillRect(0, 0, width, height);
327 }
328
329 // --- and where it came from: one arc per hit, over the washes for the same
330 // reason your health is drawn over them — being blinded or standing in smoke
331 // is exactly when knowing which way to turn is worth most.
332 if (hurting) {
333 const radius = Math.min(width, height) * HURT_RADIUS;
334 ctx.lineWidth = Math.max(3, Math.round(5 * ui.scale));
335 for (let i = 0; i < world.effects.count; i++) {
336 const effect = effectAt(world.effects, i);
337 if (effect === undefined || effect.kind !== "hurt") continue;
338 const age = world.time - effect.at;
339 if (age < 0 || age >= HURT_LIFE) continue;
340 // A fall hurts from straight below: no bearing, so no arc — the pain
341 // flash above is the whole of it.
342 if (Math.hypot(effect.from[0] - view.pos[0], effect.from[1] - view.pos[1]) < 1) continue;
343 const mid = hurtAngle(view.yaw, effect.from, view.pos);
344 // Under reduced motion the arc holds its strength and then goes: where
345 // it came from is what it says, and a slide to nothing is only how it
346 // leaves. The pain flash above is left alone — how hard is information.
347 const fade = motion === "reduced" ? 1 : 1 - age / HURT_LIFE;
348 ctx.strokeStyle = `rgba(${HURT},${(HURT_ALPHA * fade).toFixed(3)})`;
349 ctx.beginPath();
350 ctx.arc(width / 2, height / 2, radius, mid - HURT_HALF, mid + HURT_HALF);
420351 ctx.stroke();
421352 }
422353 }
423354
424 const scale = clamp(width / 1280, 0.7, 1.4);
425 const big = `${Math.round(30 * scale)}px system-ui, sans-serif`;
426 const mid = `${Math.round(19 * scale)}px system-ui, sans-serif`;
427 const small = `${Math.round(15 * scale)}px system-ui, sans-serif`;
428
429 const panel = (x: number, y: number, w: number, h: number): void => {
430 ctx.fillStyle = "rgba(12, 14, 18, 0.55)";
431 ctx.fillRect(x, y, w, h);
432 };
433
434 // --- who is that: names over the players the eye can see, drawn under the
435 // rest of the HUD so a plate never covers the clock or the ammo, and not at
436 // all while the scoreboard — which says the same thing better — is up.
437 if (!showScoreboard) drawPlates(ctx, world, view, camera, width, height, scale, alpha);
438
439 // --- radar: bottom-left, clear of the money line above and the health and
440 // armour numbers below, at 360px of a 1280px-wide HUD — about 28% of the
441 // width, the same share at any size since it scales with everything else.
442 const radarSize = Math.round(360 * scale);
443 drawRadar(ctx, world, view, pad, height - pad - Math.round(78 * scale) - radarSize, radarSize);
355 const scale = ui.scale;
356 const big = ui.font(30);
357 const mid = ui.font(19);
358 const small = ui.font(15);
444359
445360 // --- round clock and score
446361 const barW = Math.round(240 * scale);
447 panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
448 ctx.textAlign = "center";
449 ctx.font = big;
362 ui.panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
450363 const phase = world.match.phase;
451 const time = phase === "freeze" || phase === "live" || phase === "planted" ? clock(world.match.timer) : "--:--";
452 ctx.fillStyle = phase === "planted" ? "#ff6b5b" : "#f3f5f8";
453 ctx.fillText(time, width / 2, Math.round(32 * scale));
454 ctx.font = mid;
455 ctx.fillStyle = T_COLOR;
456 ctx.textAlign = "right";
457 ctx.fillText(String(world.match.score.t), width / 2 - barW / 2 - 8 * scale, Math.round(32 * scale));
458 ctx.fillStyle = CT_COLOR;
459 ctx.textAlign = "left";
460 ctx.fillText(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, Math.round(32 * scale));
364 const timed = phase === "freeze" || phase === "live" || phase === "planted";
365 const clockY = Math.round(32 * scale);
366 ui.text(timed ? ui.clock(world.match.timer) : "--:--", width / 2, clockY, {
367 align: "center", font: big, fill: phase === "planted" ? RED : WHITE,
368 });
369 ui.text(String(world.match.score.t), width / 2 - barW / 2 - 8 * scale, clockY,
370 { align: "right", font: mid, fill: T_COLOR });
371 ui.text(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, clockY,
372 { align: "left", font: mid, fill: CT_COLOR });
461373
462374 // --- health, armour, money
463 ctx.textAlign = "left";
464 ctx.font = big;
465 ctx.fillStyle = view.health > 30 ? "#f3f5f8" : "#ff6b5b";
466 ctx.fillText(String(Math.max(0, Math.round(view.health))), pad, height - pad);
467 ctx.font = small;
468 ctx.fillStyle = "#9fd0ff";
469 ctx.fillText(
470 `${view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour"}`,
375 ui.text(String(Math.max(0, Math.round(view.health))), pad, height - pad,
376 { align: "left", font: big, fill: view.health > 30 ? WHITE : RED });
377 ui.text(
378 view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour",
471379 pad, height - pad - Math.round(30 * scale),
380 { align: "left", font: small, fill: BLUE },
472381 );
473 ctx.fillStyle = "#7ddc8a";
474 ctx.font = mid;
475 ctx.fillText(`$${view.money}`, pad, pad + Math.round(18 * scale));
476 ctx.font = small;
477 let carried = pad + Math.round(38 * scale);
478 if (view.team === "ct" && view.defuseKit) {
479 ctx.fillStyle = "#9fd0ff";
480 ctx.fillText("defuse kit", pad, carried);
481 carried += Math.round(20 * scale);
482 }
483 // What is in the pocket, whether or not it is in the hands: a grenade you
484 // forgot you bought is a grenade you never threw. Every kind carried reads
485 // on the one line — with one kind, as today; empty draws nothing, which is
486 // the whole of how this avoids a layout shift when the pocket runs out.
487 // Untestable until a second real kind exists to carry: check this line's
488 // width at 360px once one does, since it grows sideways with each kind.
489 const pocket = grenadeKinds(view);
490 if (pocket.length > 0) {
491 ctx.fillStyle = "#c8e6a0";
492 const label = pocket
493 .map((id) => `${WEAPONS[id].name.replace(" Grenade", "")} ×${grenadeCount(view, id)}`)
494 .join(" ");
495 ctx.fillText(`${label} · 4`, pad, carried);
496 }
382 // --- the radar, top left, and the money under it rather than over it
383 const radar = Math.min(Math.round(RADAR * scale), Math.round(Math.min(width, height) * 0.3));
384 drawMinimap(ctx, ui, world, view, pad, pad, radar);
385 const moneyY = pad + radar + Math.round(22 * scale);
386 ui.text(`$${view.money}`, pad, moneyY, { align: "left", font: mid, fill: "#7ddc8a" });
387 if (view.team === "ct" && view.defuseKit)
388 ui.text("defuse kit", pad, moneyY + Math.round(20 * scale), { align: "left", font: small, fill: BLUE });
497389
498390 // --- ammo and weapon, dimmed while the gun is still on its way up: the
499 // simulation refuses the trigger until `nextShotAt` passes, and an AWP owes
500 // 1.25 s of that. The reload line below keeps its own full strength — a
501 // reload says what it is in words, and the two never overlap anyway.
502 ctx.textAlign = "right";
503 ctx.globalAlpha = deploying(world, view) ? NOT_READY_ALPHA : 1;
504 ctx.font = big;
505 ctx.fillStyle = "#f3f5f8";
391 // simulation refuses the trigger until `deployUntil`, and the gun rising in
392 // the middle of the screen ends on that same tick, so the two cues end
393 // together. The busy line below keeps its full strength — it says in words
394 // what the dimming means.
395 const ready = world.time < view.deployUntil ? NOT_READY_ALPHA : 1;
506396 const ammo = view.ammo[weapon.id];
507 // A grenade counts what is left in the pocket where a gun counts its magazine.
508 const ammoText = weapon.kind === "grenade"
509 ? `×${grenadeCount(view, weapon.id)}`
510 : weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
511 ctx.fillText(ammoText, width - pad, height - pad);
512 ctx.font = small;
513 ctx.fillStyle = "#c8ccd4";
514 ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale));
515 ctx.globalAlpha = 1;
516 if (world.time < view.reloadUntil) {
517 ctx.fillStyle = "#f0c060";
518 ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale));
519 }
397 const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
398 ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE, alpha: ready });
399 // What is in your hands, and then what is in your pockets: the grenades you
400 // bought and the bomb, each named only while you are carrying it.
401 ui.text(
402 weapon.name +
403 (view.pocket.he > 0 ? " · HE" : "") +
404 (view.pocket.flash > 0 ? " · flash" : "") +
405 (view.pocket.smoke > 0 ? " · smoke" : "") +
406 (view.hasBomb ? " · bomb" : ""),
407 width - pad, height - pad - Math.round(30 * scale),
408 { align: "right", font: small, fill: GREY, alpha: ready },
409 );
410 // What the gun is busy doing, in the one line there is room for. The two
411 // cannot both be true — a switch cancels the reload it interrupted and a
412 // reload cannot start until the new weapon is up — so this is an order of
413 // reading and not a choice between two things happening at once.
414 const busy = world.time < view.deployUntil ? "switching" : world.time < view.reloadUntil ? "reloading" : null;
415 if (busy !== null)
416 ui.text(busy, width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" });
520417
521418 // --- who is left
522 ctx.textAlign = "center";
523 ctx.font = small;
524419 const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length;
525420 const aliveCT = world.players.filter((p) => p.team === "ct" && p.alive).length;
526 ctx.fillStyle = T_COLOR;
527 ctx.fillText(`T ${aliveT}`, width / 2 - Math.round(60 * scale), Math.round(62 * scale));
528 ctx.fillStyle = CT_COLOR;
529 ctx.fillText(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), Math.round(62 * scale));
421 const leftY = Math.round(62 * scale);
422 ui.text(`T ${aliveT}`, width / 2 - Math.round(60 * scale), leftY, { align: "center", font: small, fill: T_COLOR });
423 ui.text(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), leftY, { align: "center", font: small, fill: CT_COLOR });
530424
531425 // --- kill feed
532 ctx.textAlign = "right";
533 ctx.font = small;
534426 world.feed.slice(-5).forEach((entry, i) => {
535427 const age = world.time - entry.at;
536428 if (age > 8) return;
537 const y = pad + Math.round((20 + i * 22) * scale);
538 ctx.globalAlpha = clamp(8 - age, 0, 1);
539 ctx.fillStyle = entry.killerTeam === "t" ? T_COLOR : CT_COLOR;
540429 const text = `${entry.killer} ${causeName(entry.weapon)}${entry.headshot ? " ⌖" : ""} ${entry.victim}`;
541 ctx.fillText(text, width - pad, y);
542 ctx.globalAlpha = 1;
430 ui.text(text, width - pad, pad + Math.round((20 + i * 22) * scale), {
431 align: "right", font: small, fill: teamColor(entry.killerTeam), alpha: 8 - age,
432 });
543433 });
544434
545 // --- what the round is saying. The line stays up for four seconds, and a
546 // round result stays until the next round goes live: the end delay plus the
547 // freeze is long enough to read who won and why, which is the point of it.
435 // --- what the round is saying
548436 const message = world.messages[world.messages.length - 1];
549 const holding = phase === "over" || phase === "freeze" || phase === "match_over";
550 if (message !== undefined && (holding || world.time - message.at < MESSAGE_FADE)) {
551 ctx.textAlign = "center";
552 ctx.font = mid;
553 ctx.fillStyle = "rgba(255,255,255,0.92)";
554 ctx.fillText(message.text, width / 2, height * 0.22);
555 }
556
557 // --- a gun under your feet that you would have to ask for. A gun you have
558 // no slot for is taken by walking over it and says nothing, because by the
559 // time it could be read it is already in your hands.
560 const offered = !spectating && view.alive ? offeredPickup(world, view) : null;
561 if (offered !== null) {
562 ctx.textAlign = "center";
563 ctx.font = mid;
564 ctx.fillStyle = "rgba(255,255,255,0.92)";
565 ctx.fillText(`E — take ${WEAPONS[offered.weapon].name}`, width / 2, height * 0.58);
566 }
437 if (message !== undefined && world.time - message.at < 4)
438 ui.text(message.text, width / 2, height * 0.22, { align: "center", font: mid, fill: "rgba(255,255,255,0.92)" });
567439
568440 // --- planting or defusing
569441 const progress = useProgress(world, view);
570442 if (progress !== null) {
571443 const w = Math.round(260 * scale), h = Math.round(14 * scale);
572444 const x = width / 2 - w / 2, y = height * 0.62;
573 panel(x - 2, y - 2, w + 4, h + 4);
574 ctx.fillStyle = progress.label.startsWith("Def") ? "#6fa8e8" : "#e2b04a";
575 ctx.fillRect(x, y, w * progress.fraction, h);
576 ctx.font = small;
577 ctx.fillStyle = "#fff";
578 ctx.textAlign = "center";
579 ctx.fillText(progress.label, width / 2, y - 8 * scale);
445 ui.panel(x - 2, y - 2, w + 4, h + 4);
446 ui.bar(x, y, w, h, progress.fraction, progress.label.startsWith("Def") ? CT_COLOR : T_COLOR);
447 ui.text(progress.label, width / 2, y - 8 * scale, { align: "center", font: small, fill: "#fff" });
580448 }
581449
582 // --- dead: who killed you, and whose eyes you are borrowing until the round
583 // is over. The kill feed is where the killer comes from; the bomb credits
584 // nobody, so it names you as your own killer there and this reads that way.
585 if (!world.me.alive && !over) {
586 const top = height * 0.38;
587 ctx.textAlign = "center";
588 ctx.font = big;
589 ctx.fillStyle = "rgba(255,255,255,0.88)";
590 ctx.fillText("You are dead", width / 2, top);
591 const notice = deathNotice(world, world.me);
592 if (notice !== null) {
593 ctx.font = mid;
594 ctx.fillStyle = "#ff8f86";
595 const how = notice.weapon === "fall" ? "You fell too far" :
596 `${notice.killer} killed you with ${causeName(notice.weapon)}` + (notice.headshot ? " — headshot" : "");
597 ctx.fillText(how, width / 2, top + Math.round(30 * scale));
450 // --- dead: whose eyes this is, and how to move to the next pair
451 if (!world.me.alive && world.match.phase !== "match_over") {
452 if (view === world.me) {
453 // Nobody left to watch: your own body is the only camera in the round.
454 ui.text(DEAD, width / 2, height * 0.4, { align: "center", font: big, fill: "rgba(255,255,255,0.85)" });
455 } else {
456 // Above the bottom row, so it never lands on the health or the ammo —
457 // at 360 pixels wide those are a thumb's width from the middle.
458 const w = Math.round(420 * scale), h = Math.round(34 * scale);
459 const y = height - pad - Math.round(64 * scale);
460 ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
461 ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale),
462 { align: "center", font: mid, fill: teamColor(view.team) });
463 ui.text(SWITCH, width / 2, y, { align: "center", font: small, fill: GREY });
598464 }
599 ctx.font = small;
600 ctx.fillStyle = "#c8ccd4";
601 ctx.fillText(
602 spectating ? `Watching ${view.name} — fire or click for the next` : "Nobody left to watch",
603 width / 2, top + Math.round(56 * scale),
604 );
605465 }
606466
607 // --- the damage report: only during the round-over delay, and never under
608 // the scoreboard, which a held Tab can ask for at the same moment.
609 if (!showScoreboard) drawReport(ctx, world, view, width, height, scale);
610
611 if (showScoreboard) drawScoreboard(ctx, world, width, height, scale);
467 if (showScoreboard) drawScoreboard(ctx, ui, world);
612468 }
613469
614 function drawScoreboard(
615 ctx: CanvasRenderingContext2D,
616 world: World,
617 width: number,
618 height: number,
619 scale: number,
620 ): void {
470 function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void {
471 const { width, height, scale } = ui;
621472 const w = Math.min(width - 40, Math.round(760 * scale));
622473 const rowH = Math.round(26 * scale);
623 const h = rowH * (world.players.length + 5);
474 const h = rowH * (world.players.length + 4);
624475 const x = width / 2 - w / 2, y = height / 2 - h / 2;
625 ctx.fillStyle = "rgba(10, 12, 16, 0.88)";
626 ctx.fillRect(x, y, w, h);
627 ctx.font = `${Math.round(15 * scale)}px system-ui, sans-serif`;
628 // Which band the fight is being played at, so a screenshot of the scoreline
629 // still means something once the round it was taken in is long over.
630 ctx.textAlign = "center";
631 ctx.fillStyle = "#9aa4b4";
632 const difficulty = world.difficulty.charAt(0).toUpperCase() + world.difficulty.slice(1);
633 ctx.fillText(`Difficulty: ${difficulty}`, x + w / 2, y + rowH);
634 ctx.textAlign = "left";
635 ctx.fillStyle = "#c8ccd4";
636 ctx.fillText("player", x + 16, y + rowH * 2);
637 ctx.textAlign = "right";
638 for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries())
639 ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH * 2);
476 const right = x + w - 16, stride = Math.round(90 * scale);
477 ui.panel(x, y, w, h, "rgba(10, 12, 16, 0.88)");
478 ui.text("player", x + 16, y + rowH, { align: "left", font: ui.font(15), fill: GREY });
479 // The headings sit on the row they head, so there is no lead over a value.
480 ui.columns(FIGURES, NOTHING, { x: right, y: y + rowH, lead: 0 }, stride);
640481
641 const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage);
642 order.forEach((p, row) => {
643 const ty = y + rowH * (row + 3.4);
644 ctx.textAlign = "left";
645 ctx.fillStyle = p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR;
646 ctx.globalAlpha = p.alive ? 1 : 0.45;
647 ctx.fillText(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty);
648 ctx.textAlign = "right";
649 for (const [i, value] of [p.kills, p.deaths, p.headshots, Math.round(p.damage), `$${p.money}`].entries())
650 ctx.fillText(String(value), x + w - 16 - i * Math.round(90 * scale), ty);
482 // The rows, and the order they are in, are `summary.ts`'s: the screen at the
483 // end of the match draws the same ten lines out of the same function, so what
484 // you read holding Tab and what you read afterwards cannot be in two orders.
485 scoreLines(world).forEach((p, row) => {
486 const ty = y + rowH * (row + 2.4);
487 // A dead player's whole row is dimmed — the name and the figures both — so
488 // the fade is set around them rather than passed to each line.
489 const dim = p.alive ? 1 : 0.45;
490 ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.you ? " (you)" : ""}`, x + 16, ty, {
491 align: "left", fill: p.you ? "#ffffff" : teamColor(p.team), alpha: dim,
492 });
493 ctx.globalAlpha = dim;
494 ui.columns(NOTHING, [
495 String(p.kills), String(p.deaths), String(p.headshots), String(p.damage), `$${p.money}`,
496 ], { x: right, y: ty }, stride);
651497 ctx.globalAlpha = 1;
652498 });
653499 }
src/client/input.test.ts deleted −399
@@ -1,399 +0,0 @@
1 /**
2 * Input against a canvas and a document that are three lines of fake each: no
3 * browser, just the events one would send. What is worth checking here is the
4 * touch build, because that is where two fingers have to mean two different
5 * things at the same moment — the one that steers and the one that fires.
6 */
7
8 import { afterEach, expect, test } from "bun:test";
9 import {
10 attachButtons, createControls, DEFAULT_BINDINGS, lookDelta, MOUSE_DEGREES, TOUCH_DEGREES,
11 type Controls,
12 } from "./input.ts";
13 import { downAt } from "./press.ts";
14 import { DEFAULT_ZOOM_RATIO } from "./zoom.ts";
15
16 type Listener = (event: unknown) => void;
17
18 /** An EventTarget that remembers its listeners so a test can fire them. */
19 function target(extra: Record<string, unknown> = {}): Record<string, unknown> & {
20 fire(type: string, event?: Record<string, unknown>): void;
21 } {
22 const listeners = new Map<string, Set<Listener>>();
23 return {
24 ...extra,
25 addEventListener(type: string, fn: Listener): void {
26 const set = listeners.get(type) ?? new Set<Listener>();
27 set.add(fn);
28 listeners.set(type, set);
29 },
30 removeEventListener(type: string, fn: Listener): void {
31 listeners.get(type)?.delete(fn);
32 },
33 fire(type: string, event: Record<string, unknown> = {}): void {
34 for (const fn of Array.from(listeners.get(type) ?? [])) {
35 fn({ preventDefault: () => {}, ...event });
36 }
37 },
38 };
39 }
40
41 type Canvas = ReturnType<typeof target>;
42
43 const globals = globalThis as unknown as Record<string, unknown>;
44 const before = { window: globals.window, document: globals.document };
45
46 // The fakes are this file's and nobody else's: the rest of the suite runs in
47 // the same process, and hud.ts asks whether `document` exists.
48 afterEach(() => {
49 globals.window = before.window;
50 globals.document = before.document;
51 });
52
53 /** A canvas 1000 wide: the stick is x < 380, the look and the trigger are past it. */
54 function harness(bindings = DEFAULT_BINDINGS): { canvas: Canvas; controls: Controls } {
55 const canvas = target({ clientWidth: 1000, clientHeight: 600 });
56 globals.window = target();
57 globals.document = target({ pointerLockElement: null });
58 const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2, bindings);
59 return { canvas, controls };
60 }
61
62 /** One touch point, as the browser would hand it over. */
63 const at = (identifier: number, clientX: number, clientY = 300): Record<string, unknown> =>
64 ({ identifier, clientX, clientY });
65
66 const touch = (canvas: Canvas, type: string, ...points: Record<string, unknown>[]): void =>
67 canvas.fire(type, { changedTouches: points });
68
69 /** Is `control` down on the first step of a frame of `steps` steps? */
70 const firstStep = (controls: Controls, control: string, steps = 2): boolean =>
71 downAt(controls.take(steps), control, 0);
72
73 test("a second finger on the looking side fires, and the first keeps aiming", () => {
74 const { canvas, controls } = harness();
75 touch(canvas, "touchstart", at(1, 700));
76 touch(canvas, "touchmove", at(1, 740));
77 const [turned] = controls.takeLook();
78 expect(turned).toBeGreaterThan(0);
79
80 // The trigger lands while the aiming finger is still down.
81 touch(canvas, "touchstart", at(2, 900, 500));
82 expect(firstStep(controls, "fire")).toBe(true);
83
84 // ...and the aiming finger goes on steering, from where it actually is.
85 touch(canvas, "touchmove", at(1, 780));
86 const [again] = controls.takeLook();
87 expect(again).toBeCloseTo(turned, 6);
88 expect(firstStep(controls, "fire")).toBe(true); // still held
89
90 touch(canvas, "touchend", at(2, 900, 500));
91 expect(firstStep(controls, "fire")).toBe(false);
92 touch(canvas, "touchmove", at(1, 820));
93 expect(controls.takeLook()[0]).toBeCloseTo(turned, 6);
94 });
95
96 test("the finger that fires does not steer", () => {
97 const { canvas, controls } = harness();
98 touch(canvas, "touchstart", at(1, 700));
99 controls.takeLook();
100 touch(canvas, "touchstart", at(2, 900));
101 touch(canvas, "touchmove", at(2, 500, 100)); // a trigger finger twitching
102 expect(controls.takeLook()).toEqual([0, 0]);
103 });
104
105 test("a cancelled trigger releases: no stuck fire on a phone", () => {
106 const { canvas, controls } = harness();
107 touch(canvas, "touchstart", at(1, 700));
108 touch(canvas, "touchstart", at(2, 900));
109 controls.take(1);
110 touch(canvas, "touchcancel", at(2, 900));
111 expect(controls.take(1).held.has("fire")).toBe(false);
112 });
113
114 test("two trigger fingers are one trigger: the last one up releases it", () => {
115 const { canvas, controls } = harness();
116 touch(canvas, "touchstart", at(1, 700));
117 touch(canvas, "touchstart", at(2, 850), at(3, 950));
118 touch(canvas, "touchend", at(2, 850));
119 expect(controls.take(1).held.has("fire")).toBe(true);
120 touch(canvas, "touchend", at(3, 950));
121 expect(controls.take(1).held.has("fire")).toBe(false);
122 });
123
124 test("the left third is still the stick, and a second finger there does not fire", () => {
125 const { canvas, controls } = harness();
126 touch(canvas, "touchstart", at(1, 100));
127 touch(canvas, "touchmove", at(1, 160));
128 const frame = controls.take(1);
129 expect(controls.inputAt(frame, 0).side).toBeCloseTo(1, 6);
130 expect(frame.held.has("fire")).toBe(false);
131 touch(canvas, "touchend", at(1, 160));
132 expect(controls.inputAt(controls.take(1), 0).side).toBe(0);
133 });
134
135 // --- the scope's share of the look: the same movement is worth fewer degrees
136 // while the view is zoomed, and the pure function is where that is decided.
137
138 test("a mouse delta is scaled by the zoom while scoped, and untouched while not", () => {
139 const plain = lookDelta(100, 2.2, false, DEFAULT_ZOOM_RATIO);
140 expect(plain).toBeCloseTo(100 * 2.2 * MOUSE_DEGREES, 10);
141 expect(lookDelta(100, 2.2, true, DEFAULT_ZOOM_RATIO)).toBeCloseTo(plain * DEFAULT_ZOOM_RATIO, 10);
142 // Less than half as fast at the default: the whole point of the setting.
143 expect(lookDelta(100, 2.2, true, DEFAULT_ZOOM_RATIO)).toBeLessThan(plain / 2);
144 // A ratio of 1 is the old behaviour, scoped or not.
145 expect(lookDelta(100, 2.2, true, 1)).toBeCloseTo(plain, 10);
146 // Direction survives the scaling.
147 expect(lookDelta(-100, 2.2, true, DEFAULT_ZOOM_RATIO)).toBeCloseTo(-plain * DEFAULT_ZOOM_RATIO, 10);
148 // A thumb's pixels are worth more degrees than a mouse's, scaled the same way.
149 const thumb = lookDelta(100, 2.2, false, DEFAULT_ZOOM_RATIO, TOUCH_DEGREES);
150 expect(thumb).toBeCloseTo(100 * 2.2 * TOUCH_DEGREES, 10);
151 expect(lookDelta(100, 2.2, true, DEFAULT_ZOOM_RATIO, TOUCH_DEGREES))
152 .toBeCloseTo(thumb * DEFAULT_ZOOM_RATIO, 10);
153 });
154
155 test("a locked mouse turns less while the scope is up, and the ratio takes effect mid-scope", () => {
156 const { canvas: _canvas, controls } = harness();
157 const window = globals.window as Canvas;
158 controls.locked = true;
159
160 window.fire("mousemove", { movementX: 50, movementY: 0 });
161 const [hip] = controls.takeLook();
162 expect(hip).toBeCloseTo(50 * 2.2 * MOUSE_DEGREES, 10);
163
164 controls.scoped = true;
165 window.fire("mousemove", { movementX: 50, movementY: 0 });
166 expect(controls.takeLook()[0]).toBeCloseTo(hip * DEFAULT_ZOOM_RATIO, 10);
167
168 // What the pause overlay's field does: the next movement feels it at once.
169 controls.zoomRatio = 1;
170 window.fire("mousemove", { movementX: 50, movementY: 0 });
171 expect(controls.takeLook()[0]).toBeCloseTo(hip, 10);
172 });
173
174 test("a drag while scoped is scaled too — the pad is not left with the old behaviour", () => {
175 const { canvas, controls } = harness();
176 touch(canvas, "touchstart", at(1, 700));
177 touch(canvas, "touchmove", at(1, 740));
178 const [hip] = controls.takeLook();
179 expect(hip).toBeCloseTo(40 * 2.2 * TOUCH_DEGREES, 10);
180
181 controls.scoped = true;
182 touch(canvas, "touchmove", at(1, 780));
183 expect(controls.takeLook()[0]).toBeCloseTo(hip * DEFAULT_ZOOM_RATIO, 10);
184 });
185
186 // --- the buttons on the pad, which are the same action names as the keys
187
188 /** A fake `[data-action]` button, and a root that hands it to attachButtons. */
189 function pad(...actions: string[]): {
190 root: ParentNode;
191 press(action: string): void;
192 release(action: string): void;
193 classes(action: string): string[];
194 } {
195 const buttons = new Map<string, Canvas>();
196 for (const action of actions) {
197 const classes: string[] = [];
198 buttons.set(action, target({
199 dataset: { action },
200 classList: {
201 add: (name: string) => { if (!classes.includes(name)) classes.push(name); },
202 remove: (name: string) => {
203 const at = classes.indexOf(name);
204 if (at >= 0) classes.splice(at, 1);
205 },
206 names: classes,
207 },
208 }));
209 }
210 const root = { querySelectorAll: () => Array.from(buttons.values()) };
211 return {
212 root: root as unknown as ParentNode,
213 press: (action) => buttons.get(action)?.fire("pointerdown"),
214 release: (action) => buttons.get(action)?.fire("pointerup"),
215 classes: (action) => ((buttons.get(action)?.classList as { names: string[] }).names),
216 };
217 }
218
219 test("the menu button pauses without pointer lock", () => {
220 const { controls } = harness();
221 const buttons = pad("menu");
222 attachButtons(buttons.root, controls);
223 let paused = 0;
224 controls.onPause = () => { paused += 1; };
225 expect(controls.locked).toBe(false);
226 buttons.press("menu");
227 expect(paused).toBe(1);
228 buttons.release("menu");
229 expect(paused).toBe(1); // the release is not a second pause
230 });
231
232 test("clear forgets the presses and the look a paused match never took", () => {
233 // What a pause does with what arrived under the overlay: the Space that
234 // pressed Resume is not a jump on the first frame back, and a drag the game
235 // did not watch is not a flick of the aim.
236 const { canvas, controls } = harness();
237 const buttons = pad("jump");
238 attachButtons(buttons.root, controls);
239 buttons.press("jump");
240 touch(canvas, "touchstart", at(1, 600));
241 touch(canvas, "touchmove", at(1, 700));
242 expect(controls.takeLook()[0]).not.toBe(0);
243
244 buttons.press("jump");
245 touch(canvas, "touchmove", at(1, 800));
246 controls.clear();
247 expect(controls.takeLook()).toEqual([0, 0]);
248 expect(firstStep(controls, "jump")).toBe(false);
249 });
250
251 test("the pad's slot, scoreboard and buy buttons are the keyboard's actions", () => {
252 const { controls } = harness();
253 const buttons = pad("slot1", "slot2", "slot3", "slot4", "scoreboard", "buy");
254 attachButtons(buttons.root, controls);
255 let buys = 0;
256 controls.onBuy = () => { buys += 1; };
257
258 expect(controls.slotWanted).toBe(null);
259 for (const [action, slot] of [["slot1", 1], ["slot2", 2], ["slot3", 3], ["slot4", 4]] as const) {
260 buttons.press(action);
261 expect(controls.slotWanted).toBe(slot);
262 buttons.release(action);
263 }
264 expect(controls.slotWanted).toBe(null);
265
266 expect(controls.scoreboard).toBe(false);
267 buttons.press("scoreboard");
268 expect(controls.scoreboard).toBe(true); // held, not a toggle
269 expect(buttons.classes("scoreboard")).toEqual(["on"]); // and it looks held
270 buttons.release("scoreboard");
271 expect(controls.scoreboard).toBe(false);
272 expect(buttons.classes("scoreboard")).toEqual([]);
273
274 buttons.press("buy");
275 expect(buys).toBe(1);
276 });
277
278 test("scope is a touch button, held like the mouse's right button", () => {
279 const { controls } = harness();
280 const buttons = pad("scope");
281 attachButtons(buttons.root, controls);
282
283 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
284 buttons.press("scope");
285 expect(controls.inputAt(controls.take(1), 0).scope).toBe(true); // held, not a toggle
286 expect(buttons.classes("scope")).toEqual(["on"]);
287 buttons.release("scope");
288 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
289 expect(buttons.classes("scope")).toEqual([]);
290 });
291
292 test("Q scopes from the keyboard the same way", () => {
293 const { controls } = harness();
294 const window = globals.window as Canvas;
295 window.fire("keydown", { code: "KeyQ" });
296 expect(controls.inputAt(controls.take(1), 0).scope).toBe(true);
297 window.fire("keyup", { code: "KeyQ" });
298 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
299 });
300
301 test("the radio is one key and one pad button, and opens on the press", () => {
302 const { controls } = harness();
303 const buttons = pad("radio");
304 attachButtons(buttons.root, controls);
305 const window = globals.window as Canvas;
306 let opened = 0;
307 controls.onRadio = () => { opened += 1; };
308
309 window.fire("keydown", { code: "KeyZ" });
310 expect(opened).toBe(1);
311 // The release is not a second opening, and neither is the key repeating.
312 window.fire("keyup", { code: "KeyZ" });
313 window.fire("keydown", { code: "KeyZ", repeat: true });
314 expect(opened).toBe(1);
315
316 buttons.press("radio");
317 expect(opened).toBe(2);
318 buttons.release("radio");
319 expect(opened).toBe(2);
320 });
321
322 test("the radio is a binding like any other: rebound, it answers to the new key", () => {
323 const { controls } = harness({ ...DEFAULT_BINDINGS, radio: "KeyG" });
324 const window = globals.window as Canvas;
325 let opened = 0;
326 controls.onRadio = () => { opened += 1; };
327
328 window.fire("keydown", { code: "KeyZ" });
329 expect(opened).toBe(0);
330 window.fire("keydown", { code: "KeyG" });
331 expect(opened).toBe(1);
332 });
333
334 // --- bindings: a non-default map moves the game, not the printed letters
335
336 test("a non-default map moves on the bound key and does nothing on KeyW", () => {
337 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyG" });
338 const window = globals.window as Canvas;
339
340 window.fire("keydown", { code: "KeyW" });
341 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
342
343 window.fire("keydown", { code: "KeyG" });
344 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
345 });
346
347 test("a rebind takes effect on the very next key event, with no detach or re-attach", () => {
348 const { controls } = harness();
349 const window = globals.window as Canvas;
350
351 window.fire("keydown", { code: "KeyW" });
352 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
353 window.fire("keyup", { code: "KeyW" });
354
355 controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyG" });
356
357 window.fire("keydown", { code: "KeyW" });
358 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
359 window.fire("keydown", { code: "KeyG" });
360 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
361 });
362
363 test("the arrow keys and C still move and crouch whatever forward is bound to", () => {
364 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyG", duck: "KeyU" });
365 const window = globals.window as Canvas;
366
367 window.fire("keydown", { code: "ArrowUp" });
368 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
369 window.fire("keydown", { code: "KeyC" });
370 expect(controls.inputAt(controls.take(1), 0).duck).toBe(true);
371 });
372
373 test("under pointer lock, an unbound key is still kept off the browser — Tab cannot walk focus off the canvas", () => {
374 const { controls } = harness();
375 const window = globals.window as Canvas;
376 let prevented = 0;
377 const press = (code: string): void => window.fire("keydown", { code, preventDefault: () => { prevented += 1; } });
378
379 press("Backquote"); // unbound, and not locked yet
380 expect(prevented).toBe(0);
381
382 controls.locked = true;
383 press("Backquote"); // unbound, but the lock claims every key
384 expect(prevented).toBe(1);
385 });
386
387 test("preventDefault follows the action, not the code: a jump rebound off Space no longer eats it", () => {
388 const { controls } = harness({ ...DEFAULT_BINDINGS, jump: "KeyJ" });
389 const window = globals.window as Canvas;
390 let prevented = 0;
391
392 window.fire("keydown", { code: "Space", preventDefault: () => { prevented += 1; } });
393 expect(prevented).toBe(0);
394 expect(controls.inputAt(controls.take(1), 0).jump).toBe(false);
395
396 window.fire("keydown", { code: "KeyJ", preventDefault: () => { prevented += 1; } });
397 expect(prevented).toBe(1);
398 expect(controls.inputAt(controls.take(1), 0).jump).toBe(true);
399 });
src/client/input.ts deleted −332
@@ -1,332 +0,0 @@
1 /**
2 * Input: keyboard and mouse under pointer lock, and a thumb stick and buttons
3 * on a touch screen. It collects intent into the same Input the simulation
4 * takes from a bot, and hands over accumulated mouse movement once a frame.
5 *
6 * Events are edges, and the loop only looks once a frame, so press.ts holds
7 * the edges in between: a tap that starts and ends between two frames still
8 * fires, exactly once.
9 *
10 * `lookX`/`lookY` accumulate in exactly two places — a locked mouse move and a
11 * touch whose role is `look` — and both go through `lookDelta`, which is where
12 * a pointer's movement becomes degrees of turn and the only place the scope's
13 * zoom touches the mouse. Buttons never touch them; they only ever name an
14 * action.
15 */
16
17 import { clamp } from "./math.ts";
18 import { createPress, downAt, type Frame } from "./press.ts";
19 import { NO_INPUT, type Input } from "./world.ts";
20 import { DEFAULT_ZOOM_RATIO, lookScale } from "./zoom.ts";
21
22 /** Degrees of turn per pixel of locked mouse movement, at sensitivity 1. */
23 export const MOUSE_DEGREES = 0.022;
24 /** The same for a dragging finger, which travels far less than a mouse does. */
25 export const TOUCH_DEGREES = 0.06;
26
27 /**
28 * Pure: the degrees of turn one pointer movement is worth. `perPixel` says
29 * which pointer moved, and `scoped` with `zoomRatio` is the scope's share of
30 * it — zoom.ts owns that relationship, this only applies it.
31 */
32 export function lookDelta(
33 movement: number,
34 sensitivity: number,
35 scoped: boolean,
36 zoomRatio: number,
37 perPixel: number = MOUSE_DEGREES,
38 ): number {
39 return movement * sensitivity * perPixel * lookScale(scoped, zoomRatio);
40 }
41
42 /** The controls a press of counts even if the release beat the next frame. */
43 export const LATCHED = ["fire", "jump", "use", "reload"];
44
45 export type Controls = {
46 takeLook(): [number, number];
47 /** The frame's intent, taken once before the fixed steps it is about to run. */
48 take(steps: number): Frame;
49 /** The Input for one step of a taken frame. The object is reused. */
50 inputAt(frame: Frame, step: number): Input;
51 readonly scoreboard: boolean;
52 /** 1 primary, 2 pistol, 3 knife, 4 grenade — main knows what they are. */
53 readonly slotWanted: 1 | 2 | 3 | 4 | null;
54 sensitivity: number;
55 /** The scope's share of that sensitivity while it is up — see zoom.ts. */
56 zoomRatio: number;
57 /**
58 * Whether the person is looking down a scope right now. The loop sets it from
59 * the world once a frame; the events in between scale their look by it, so the
60 * worst a scope going up or down costs is the one frame it happened on.
61 */
62 scoped: boolean;
63 locked: boolean;
64 setAction(action: string, down: boolean): void;
65 /** A rebind takes hold on the very next key event — no detach, no reload. */
66 setBindings(bindings: Record<Action, string>): void;
67 /**
68 * Forget every intent not yet taken — held controls, latched presses and the
69 * look that has piled up. What the focus, the pointer lock and the pause
70 * overlay all want: nothing a player did while the game was not listening
71 * should arrive on the first frame it listens again.
72 */
73 clear(): void;
74 onBuy: () => void;
75 onRadio: () => void;
76 onPause: () => void;
77 detach(): void;
78 };
79
80 /**
81 * Every action a key can drive, and precisely today's map — the default a
82 * corrupt or missing setting falls back to. `settings.ts` validates a stored
83 * map against this same list; nothing else should invent an action name.
84 */
85 export const ACTIONS = [
86 "forward", "back", "left", "right", "jump", "duck", "walk",
87 "reload", "use", "buy", "radio", "scoreboard", "scope",
88 "slot1", "slot2", "slot3", "slot4",
89 ] as const;
90 export type Action = typeof ACTIONS[number];
91
92 export const DEFAULT_BINDINGS: Record<Action, string> = {
93 forward: "KeyW", back: "KeyS", left: "KeyA", right: "KeyD",
94 jump: "Space", duck: "ControlLeft", walk: "ShiftLeft",
95 reload: "KeyR", use: "KeyE", buy: "KeyB", radio: "KeyZ", scoreboard: "Tab", scope: "KeyQ",
96 slot1: "Digit1", slot2: "Digit2", slot3: "Digit3", slot4: "Digit4",
97 };
98
99 /**
100 * Codes no binding can claim: the arrow keys and `C` are a fixed movement and
101 * crouch fallback, wired below regardless of what a player has bound, so a
102 * rebind gone wrong is never a player who cannot move.
103 */
104 export const RESERVED_CODES: ReadonlySet<string> =
105 new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]);
106
107 /** Code to action, the fixed fallback last so nothing bound can ever shadow it. */
108 function reverseBindings(bindings: Record<Action, string>): Record<string, Action | "duck"> {
109 const map: Record<string, Action | "duck"> = {};
110 for (const action of ACTIONS) map[bindings[action]] = action;
111 map.ArrowUp = "forward";
112 map.ArrowDown = "back";
113 map.ArrowLeft = "left";
114 map.ArrowRight = "right";
115 map.KeyC = "duck";
116 return map;
117 }
118
119 export function createControls(
120 canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS,
121 zoomRatio = DEFAULT_ZOOM_RATIO,
122 ): Controls {
123 const press = createPress(LATCHED);
124 const input: Input = { ...NO_INPUT };
125 let lookX = 0, lookY = 0;
126 let reverse = reverseBindings(bindings);
127 const controls: Controls = {
128 takeLook() {
129 const out: [number, number] = [lookX, lookY];
130 lookX = 0;
131 lookY = 0;
132 return out;
133 },
134 take: (steps) => press.take(steps),
135 inputAt(frame, step) {
136 const on = (action: string): boolean => downAt(frame, action, step);
137 input.forward = clamp(
138 (frame.held.has("forward") ? 1 : 0) - (frame.held.has("back") ? 1 : 0) + stick.y, -1, 1);
139 input.side = clamp(
140 (frame.held.has("right") ? 1 : 0) - (frame.held.has("left") ? 1 : 0) + stick.x, -1, 1);
141 input.duck = frame.held.has("duck");
142 input.walk = frame.held.has("walk");
143 input.scope = frame.held.has("scope");
144 input.jump = on("jump");
145 input.reload = on("reload");
146 input.use = on("use");
147 input.fire = on("fire");
148 input.switchTo = null;
149 return input;
150 },
151 get scoreboard(): boolean {
152 return press.isDown("scoreboard");
153 },
154 get slotWanted(): 1 | 2 | 3 | 4 | null {
155 return press.isDown("slot4") ? 4 : press.isDown("slot3") ? 3
156 : press.isDown("slot2") ? 2 : press.isDown("slot1") ? 1 : null;
157 },
158 sensitivity,
159 zoomRatio,
160 scoped: false,
161 locked: false,
162 setAction: (action, down) => {
163 if (down) press.down(action);
164 else press.up(action);
165 if (!down) return;
166 // The actions that are a menu rather than a movement. `menu` is how a
167 // touch screen reaches the pause overlay at all: pointer lock is the only
168 // other thing that calls onPause, and a finger never takes the lock.
169 if (action === "buy") controls.onBuy();
170 else if (action === "radio") controls.onRadio();
171 else if (action === "menu") controls.onPause();
172 },
173 setBindings: (next) => {
174 reverse = reverseBindings(next);
175 },
176 clear: () => {
177 press.clear();
178 lookX = 0;
179 lookY = 0;
180 },
181 onBuy: () => {},
182 onRadio: () => {},
183 onPause: () => {},
184 detach: () => {
185 window.removeEventListener("keydown", onKeyDown);
186 window.removeEventListener("keyup", onKeyUp);
187 document.removeEventListener("pointerlockchange", onLockChange);
188 },
189 };
190
191 const stick = { x: 0, y: 0 };
192
193 function onKeyDown(event: KeyboardEvent): void {
194 const action = reverse[event.code];
195 // Under pointer lock every key is the game's, whatever it is bound to:
196 // an unbound Tab still switches focus off the canvas otherwise, and once
197 // it has, a locked mouse is aiming at nothing the player can see move.
198 if (controls.locked) event.preventDefault();
199 if (action === undefined) return;
200 // Whatever is bound to these two actions gets the default the browser
201 // would otherwise take even without a lock: a scoreboard key that also
202 // switches tabs, or a jump key that also scrolls the page.
203 if (action === "scoreboard" || action === "jump") event.preventDefault();
204 if (event.repeat) return;
205 controls.setAction(action, true);
206 }
207 function onKeyUp(event: KeyboardEvent): void {
208 const action = reverse[event.code];
209 if (action !== undefined) controls.setAction(action, false);
210 }
211 function onLockChange(): void {
212 controls.locked = document.pointerLockElement === canvas;
213 if (!controls.locked) {
214 controls.clear();
215 controls.onPause();
216 }
217 }
218
219 window.addEventListener("keydown", onKeyDown);
220 window.addEventListener("keyup", onKeyUp);
221 document.addEventListener("pointerlockchange", onLockChange);
222
223 canvas.addEventListener("mousedown", (event) => {
224 if (!controls.locked) return;
225 event.preventDefault();
226 controls.setAction(event.button === 2 ? "scope" : "fire", true);
227 });
228 window.addEventListener("mouseup", (event) => {
229 controls.setAction(event.button === 2 ? "scope" : "fire", false);
230 });
231 canvas.addEventListener("contextmenu", (event) => event.preventDefault());
232 window.addEventListener("mousemove", (event) => {
233 if (!controls.locked) return;
234 lookX += lookDelta(event.movementX, controls.sensitivity, controls.scoped, controls.zoomRatio);
235 lookY += lookDelta(event.movementY, controls.sensitivity, controls.scoped, controls.zoomRatio);
236 });
237 window.addEventListener("blur", () => {
238 controls.clear();
239 });
240
241 // --- touch: the left third is a stick, the rest of the canvas looks around,
242 // and a second finger on the looking side is the trigger. That is the whole
243 // of firing while aiming: the touch that steers keeps its own target for its
244 // whole life, so a shot never costs the aim the thumb already has. A touch
245 // that fires does not steer — a trigger finger twitches, and the aim should
246 // not follow it.
247 type Role = "stick" | "look" | "fire";
248 type Held = { startX: number; startY: number; x: number; y: number; role: Role };
249 const touches = new Map<number, Held>();
250 const anyRole = (role: Role): boolean => {
251 for (const held of touches.values()) if (held.role === role) return true;
252 return false;
253 };
254 canvas.addEventListener("touchstart", (event) => {
255 for (const touch of Array.from(event.changedTouches)) {
256 const role: Role = touch.clientX < canvas.clientWidth * 0.38
257 ? "stick"
258 : anyRole("look") ? "fire" : "look";
259 touches.set(touch.identifier, {
260 startX: touch.clientX, startY: touch.clientY, x: touch.clientX, y: touch.clientY, role,
261 });
262 if (role === "fire") controls.setAction("fire", true);
263 }
264 event.preventDefault();
265 }, { passive: false });
266 canvas.addEventListener("touchmove", (event) => {
267 for (const touch of Array.from(event.changedTouches)) {
268 const state = touches.get(touch.identifier);
269 if (state === undefined) continue;
270 if (state.role === "stick") {
271 stick.x = clamp((touch.clientX - state.startX) / 60, -1, 1);
272 stick.y = clamp((state.startY - touch.clientY) / 60, -1, 1);
273 } else if (state.role === "look") {
274 // The same scaling a mouse gets: a drag under a scope is the same
275 // problem, and a thumb has less screen to give away than a mouse does.
276 const look = (movement: number): number =>
277 lookDelta(movement, controls.sensitivity, controls.scoped, controls.zoomRatio, TOUCH_DEGREES);
278 lookX += look(touch.clientX - state.x);
279 lookY += look(touch.clientY - state.y);
280 }
281 state.x = touch.clientX;
282 state.y = touch.clientY;
283 }
284 event.preventDefault();
285 }, { passive: false });
286 const endTouch = (event: TouchEvent): void => {
287 for (const touch of Array.from(event.changedTouches)) {
288 const state = touches.get(touch.identifier);
289 touches.delete(touch.identifier);
290 if (state === undefined) continue;
291 if (state.role === "stick") {
292 stick.x = 0;
293 stick.y = 0;
294 }
295 // The last finger off the trigger is the one that releases it: a stuck
296 // trigger on a phone is unrecoverable without reloading the page.
297 if (state.role === "fire" && !anyRole("fire")) controls.setAction("fire", false);
298 }
299 };
300 canvas.addEventListener("touchend", endTouch);
301 canvas.addEventListener("touchcancel", endTouch);
302
303 return controls;
304 }
305
306 /**
307 * The buttons a touch screen needs, wired to the same actions as the keys. A
308 * thumb is quicker than a frame, so the press goes down on pointerdown and up
309 * on pointerup and the latch sees to it that a step counts it.
310 */
311 export function attachButtons(root: ParentNode, controls: Controls): void {
312 for (const element of Array.from(root.querySelectorAll<HTMLElement>("[data-action]"))) {
313 const action = element.dataset.action as string;
314 // preventDefault keeps the browser from making a second event out of a
315 // touch, which also costs the button its :active state — so the class is
316 // the one that says "held", and a held control looks held.
317 const down = (event: Event): void => {
318 event.preventDefault();
319 element.classList.add("on");
320 controls.setAction(action, true);
321 };
322 const up = (event: Event): void => {
323 event.preventDefault();
324 element.classList.remove("on");
325 controls.setAction(action, false);
326 };
327 element.addEventListener("pointerdown", down);
328 element.addEventListener("pointerup", up);
329 element.addEventListener("pointercancel", up);
330 element.addEventListener("pointerleave", up);
331 }
332 }
src/client/interpolate.test.ts deleted −96
@@ -1,96 +0,0 @@
1 /**
2 * The arithmetic of drawing between two ticks, on its own: no world, no GL.
3 * What matters here is the seam in the circle, the two ends of the blend being
4 * exactly the states they are blends of, and a teleport never being a slide.
5 */
6
7 import { expect, test } from "bun:test";
8 import { TICK } from "./loop.ts";
9 import {
10 alphaOf, between, drawnAt, lerpPos, lerpYaw, pose, remember, rememberPos, teleported, TELEPORT,
11 } from "./interpolate.ts";
12 import type { Vec3 } from "./math.ts";
13
14 test("alpha is the sub-tick remainder, clamped, and a nonsense clock draws the newest tick", () => {
15 expect(alphaOf(TICK / 2, TICK)).toBeCloseTo(0.5, 12);
16 expect(alphaOf(0, TICK)).toBe(0);
17 // A backlog would draw a future the world has not simulated yet.
18 expect(alphaOf(TICK * 3, TICK)).toBe(1);
19 expect(alphaOf(-1, TICK)).toBe(0);
20 expect(alphaOf(NaN, TICK)).toBe(1);
21 expect(alphaOf(TICK / 2, 0)).toBe(1);
22 });
23
24 test("a position is lerped, and both ends are exactly the state they came from", () => {
25 const prev: Vec3 = [0.1, -4, 10];
26 const now: Vec3 = [0.2, 4, 12];
27 const out: Vec3 = [0, 0, 0];
28 expect(lerpPos(prev, now, 0.5, out)).toEqual([0.15000000000000002, 0, 11]);
29 // Exact, not "close to": a frame at the end of a tick draws the tick, and a
30 // renderer must not wobble by a float's last bit for interpolating at all.
31 expect(lerpPos(prev, now, 1, out)).toEqual(now);
32 expect(lerpPos(prev, now, 0, out)).toEqual(prev);
33 // It writes into what it was given and returns it — no tuple is allocated.
34 expect(lerpPos(prev, now, 0.5, out)).toBe(out);
35 });
36
37 test("a yaw goes the short way round the circle, not the long way", () => {
38 // The seam: −170° to +170° is 20° through south, and the midpoint is 180°.
39 expect(lerpYaw(-170, 170, 0.5)).toBeCloseTo(-180, 9);
40 expect(lerpYaw(170, -170, 0.5)).toBeCloseTo(180, 9);
41 // A quarter of the way over the seam is five degrees, not eighty-five.
42 expect(lerpYaw(-170, 170, 0.25)).toBeCloseTo(-175, 9);
43 // And the ordinary case is the ordinary answer, both ends exact.
44 expect(lerpYaw(10, 50, 0.5)).toBeCloseTo(30, 9);
45 expect(lerpYaw(10, 50, 1)).toBe(50);
46 expect(lerpYaw(10, 50, 0)).toBe(10);
47 });
48
49 test("a move is interpolated and a teleport snaps", () => {
50 const walked: Vec3 = [0, 0, 0];
51 const step: Vec3 = [4, 0, 0]; // a tick of running, about
52 expect(teleported(walked, step)).toBe(false);
53 // A spawn across the map, a respawn, anything further than a tick of walking.
54 expect(teleported(walked, [0, 2000, 0])).toBe(true);
55 expect(teleported(walked, [TELEPORT + 1, 0, 0])).toBe(true);
56 expect(teleported(walked, [TELEPORT, 0, 0])).toBe(false);
57
58 const out: Vec3 = [0, 0, 0];
59 expect(lerpPos(walked, [0, 2000, 0], 0.5, out)).toEqual([0, 2000, 0]);
60 });
61
62 test("a pose blends both halves, and a teleport takes the newest of both", () => {
63 const prev = pose([0, 0, 0], 0);
64 const now = { pos: [10, 0, 0] as Vec3, yaw: 90 };
65 const out = pose();
66 expect(between(prev, now, 0.5, out)).toBe(out);
67 expect(out.pos).toEqual([5, 0, 0]);
68 expect(out.yaw).toBeCloseTo(45, 9);
69
70 // Teleported: the yaw does not creep round either, because the body that
71 // arrived is facing the way it is facing and was never turning to get there.
72 const far = { pos: [900, 0, 0] as Vec3, yaw: 90 };
73 between(prev, far, 0.5, out);
74 expect(out.pos).toEqual([900, 0, 0]);
75 expect(out.yaw).toBe(90);
76 });
77
78 test("remembering is written into the tuples that are already there", () => {
79 const prev = pose([1, 2, 3], 45);
80 const held = prev.pos;
81 remember(prev, { pos: [9, 8, 7], yaw: -20 });
82 expect(prev.pos).toEqual([9, 8, 7]);
83 expect(prev.yaw).toBe(-20);
84 expect(prev.pos).toBe(held); // the same array: a tick allocates nothing
85
86 const spot: Vec3 = [0, 0, 0];
87 rememberPos(spot, [5, 6, 7]);
88 expect(spot).toEqual([5, 6, 7]);
89 });
90
91 test("the clock a frame is drawn on is behind the simulation, never ahead of it", () => {
92 expect(drawnAt(10, 1, TICK)).toBe(10);
93 expect(drawnAt(10, 0, TICK)).toBeCloseTo(10 - TICK, 12);
94 expect(drawnAt(10, 0.5, TICK)).toBeCloseTo(10 - TICK / 2, 12);
95 expect(drawnAt(10, NaN, TICK)).toBe(10);
96 });
src/client/interpolate.ts deleted −117
@@ -1,117 +0,0 @@
1 /**
2 * What is drawn between two ticks.
3 *
4 * The world steps at a fixed 64 Hz and a screen draws at 60, 120 or whatever
5 * else it feels like, so most frames land partway between two states. This is
6 * the arithmetic that puts them there: `alpha` is the sub-tick remainder the
7 * loop already hands back, 0 being the state the previous tick left and 1 the
8 * state the newest one did, and everything here is exact at both ends — a
9 * frame drawn at `alpha` of 1 is the same numbers as no interpolation at all.
10 *
11 * Two rules it keeps:
12 *
13 * - A yaw goes the short way round the circle. −170° to +170° is 20° across
14 * the seam, not 340° the long way, so a player turning through south does
15 * not spin all the way back.
16 * - A teleport snaps. A round start, a respawn or anything that moves further
17 * in one tick than a body could have walked is not a move to draw halfway
18 * through: past `TELEPORT` the newest state is drawn and nothing slides.
19 *
20 * It is pure — no world, no GL, no clock — and it writes into the tuples it is
21 * given rather than returning new ones, because a renderer calls it once per
22 * player per frame and nothing here may allocate.
23 */
24
25 import { angleDelta, clamp, type Vec3 } from "./math.ts";
26
27 /** Where something was and which way it faced: everything a mesh is placed by. */
28 export type Pose = { pos: Vec3; yaw: number };
29
30 /**
31 * Further apart than one tick of movement can be. A player runs at 250 u/s and
32 * falls no faster than a grenade flies at 780, which is 12 units in a 64 Hz
33 * tick; 64 units a tick is 4096 u/s, which nothing in this game does. Past it,
34 * the two states are two places, not a path between them.
35 */
36 export const TELEPORT = 64;
37
38 /** A fresh pose at the origin. Made once per thing that moves, never per tick. */
39 export const pose = (pos: Vec3 = [0, 0, 0], yaw = 0): Pose => ({ pos: [pos[0], pos[1], pos[2]], yaw });
40
41 /**
42 * How far into the tick this frame is, from the accumulator `advance` returns.
43 * Clamped to [0, 1]: a backlog would draw the future, which is worse than
44 * drawing the past, and anything that is not a number draws the newest state.
45 */
46 export function alphaOf(accumulator: number, tick: number): number {
47 if (!Number.isFinite(accumulator) || !Number.isFinite(tick) || tick <= 0) return 1;
48 return clamp(accumulator / tick, 0, 1);
49 }
50
51 /** Partway between two numbers, and exactly one of them at either end. */
52 const mix = (a: number, b: number, alpha: number): number =>
53 alpha <= 0 ? a : alpha >= 1 ? b : a + (b - a) * alpha;
54
55 /** Is this a step, or a jump? Squared, so a frame never pays for a square root. */
56 export function teleported(a: Vec3, b: Vec3, limit: number = TELEPORT): boolean {
57 const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
58 return dx * dx + dy * dy + dz * dz > limit * limit;
59 }
60
61 /** Partway round the circle from `a` to `b`, the short way, in degrees. */
62 export const lerpYaw = (a: number, b: number, alpha: number): number =>
63 alpha <= 0 ? a : alpha >= 1 ? b : a + angleDelta(a, b) * alpha;
64
65 /**
66 * Partway from `prev` to `now`, written into `out` (which may be `out === now`
67 * for a caller that has nowhere else to put it). A teleport is the newest
68 * state and no blend at all.
69 */
70 export function lerpPos(prev: Vec3, now: Vec3, alpha: number, out: Vec3): Vec3 {
71 if (teleported(prev, now)) {
72 out[0] = now[0];
73 out[1] = now[1];
74 out[2] = now[2];
75 return out;
76 }
77 out[0] = mix(prev[0], now[0], alpha);
78 out[1] = mix(prev[1], now[1], alpha);
79 out[2] = mix(prev[2], now[2], alpha);
80 return out;
81 }
82
83 /**
84 * The pose to draw something at: its position lerped and its yaw turned the
85 * short way, or the newest of both where it teleported. `now` is whatever
86 * carries a `pos` and a `yaw` — a player is one already.
87 */
88 export function between(prev: Pose, now: Pose, alpha: number, out: Pose): Pose {
89 const jumped = teleported(prev.pos, now.pos);
90 lerpPos(prev.pos, now.pos, alpha, out.pos);
91 out.yaw = jumped ? now.yaw : lerpYaw(prev.yaw, now.yaw, alpha);
92 return out;
93 }
94
95 /** Write `now` into the pose kept for it: the one copy a tick is allowed to make. */
96 export function remember(prev: Pose, now: Pose): void {
97 prev.pos[0] = now.pos[0];
98 prev.pos[1] = now.pos[1];
99 prev.pos[2] = now.pos[2];
100 prev.yaw = now.yaw;
101 }
102
103 /** The same for something with a position and no facing: a grenade, the bomb. */
104 export function rememberPos(prev: Vec3, now: Vec3): void {
105 prev[0] = now[0];
106 prev[1] = now[1];
107 prev[2] = now[2];
108 }
109
110 /**
111 * The clock a frame is drawn on: the previous tick's time plus `alpha` of a
112 * tick. Animations measured off `world.time` — a body toppling, say — advance
113 * by the frame rather than by the step when they use this, and it is never
114 * ahead of the simulation, so nothing is drawn before it happened.
115 */
116 export const drawnAt = (time: number, alpha: number, tick: number): number =>
117 time - tick * (1 - clamp(Number.isFinite(alpha) ? alpha : 1, 0, 1));
src/client/layout.test.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * The map digest, held to the three things `replay.test.ts` leans on it for: a
3 * level folds to the same number twice, a single cell somewhere else is a
4 * different number, and the complaint names the band that cell is in.
5 *
6 * Nothing here plays a round — that is next door. This is the instrument, not
7 * the claim: 40 rows of letters, folded, in a millisecond.
8 */
9
10 import { expect, test } from "bun:test";
11 import { generateLevel } from "./generate.ts";
12 import { compare, layout, rowsOf, ROWS_PER_BAND, type MapPin } from "./layout.ts";
13 import { DE_FORGE } from "./map.ts";
14 import type { Cell, Level } from "./level.ts";
15
16 const pinOf = (level: Level): MapPin => {
17 const folded = layout(level);
18 return {
19 label: level.name, constant: "A_MAP", list: "A_MAP_BANDS",
20 digest: folded.digest, bands: folded.bands,
21 };
22 };
23
24 /** The same level with one cell rubbed out, without touching the one it came from. */
25 const withCellAt = (level: Level, col: number, row: number, cell: Cell): Level => {
26 const cells = [...level.grid.cells];
27 cells[row * level.grid.width + col] = cell;
28 return { ...level, grid: { ...level.grid, cells } };
29 };
30
31 test("a level folds to a digest, and the same level folds to the same one", () => {
32 const a = layout(generateLevel(24));
33 const b = layout(generateLevel(24));
34 expect(a.digest).toBe(b.digest);
35 expect(a.bands).toEqual(b.bands);
36 expect(a.rows).toBe(generateLevel(24).grid.height);
37 expect(a.bands.length).toBe(a.rows / ROWS_PER_BAND);
38 expect(compare(a, pinOf(generateLevel(24)))).toBe(null);
39 });
40
41 test("two seeds are two digests, and de_forge is neither", () => {
42 const seeds = [layout(generateLevel(24)).digest, layout(generateLevel(37)).digest, layout(DE_FORGE).digest];
43 expect(new Set(seeds).size).toBe(3);
44 });
45
46 test("one cell moved is a digest moved, and the complaint says which band it is in", () => {
47 const level = generateLevel(24);
48 const pin = pinOf(level);
49 // Row 30 is in the fourth band of ten rows; the cell is floor on this map.
50 const moved = withCellAt(level, 20, 30, "#");
51 const why = compare(layout(moved), pin) as string;
52 expect(why).not.toBe(null);
53 expect(why).toContain("band 4 of 4");
54 expect(why).toContain("src/client/replay.test.ts");
55 expect(why).toContain("const A_MAP = ");
56 expect(why).toContain("const A_MAP_BANDS = ");
57 // The bands the change is not in are the bands they were.
58 expect(layout(moved).bands.slice(0, 3)).toEqual([...pin.bands].slice(0, 3));
59 // And the file the digest is committed in is the caller's to say.
60 expect(compare(layout(moved), pin, "src/client/generate.test.ts")).toContain("src/client/generate.test.ts");
61 });
62
63 test("a page of letters is one string a row, each as wide as the grid", () => {
64 const level = generateLevel(37);
65 const rows = rowsOf(level.grid);
66 expect(rows.length).toBe(level.grid.height);
67 for (const row of rows) expect(row.length).toBe(level.grid.width);
68 expect(rows.join("")).toBe(level.grid.cells.join(""));
69 });
src/client/layout.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * A level folded into a digest: the place, pinned, the way `replay.ts` pins the
3 * play on it.
4 *
5 * A match on a generated map is two claims at once — that this seed still
6 * carves *this* place, and that the simulation on it still runs the way it ran.
7 * A single digest over the round confuses them: the generator gains a crate,
8 * every tick of the round moves, and the failure reads like the physics
9 * changed. So the map gets a number of its own, taken first, and the round's
10 * digest is only ever read on a map that still matches. `replay.test.ts` asks
11 * them in that order and says which of the two moved.
12 *
13 * What is hashed is the letters, one row a line, and nothing else. Everything
14 * else a `Level` carries — the boxes, the spawns, the sites, the walls a bullet
15 * stops in — is `levelOf` reading those letters, so a grid that folds to the
16 * same digest is the same place to play in. What the place is *painted* with is
17 * not in here: a look is the seed's and is pinned beside the mesh in
18 * `generate.test.ts`, and a repaint is not a map that plays differently.
19 *
20 * The arithmetic is the engine's `fold` and `compare`, the same instrument and
21 * the same complaint the rounds are pinned with — rows of letters where they
22 * hand it ticks of a world.
23 */
24
25 import { compare as complain, fold as foldRows, type Grid } from "../engine/index.ts";
26 import type { Level } from "./level.ts";
27
28 /**
29 * How many rows one checkpoint covers: four bands over a generated map, which
30 * is forty rows on a side. The digest is of every row whatever the height; a
31 * map whose height is not a whole number of bands has its last few rows in the
32 * digest and in no band, which is the engine's rule for a part-checkpoint.
33 */
34 export const ROWS_PER_BAND = 10;
35
36 /** A level's cells as a page of letters, one string a row. */
37 export const rowsOf = (grid: Grid<string>): string[] =>
38 Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join(""));
39
40 export type Layout = {
41 /** sha256 of every row of letters, hex. */
42 digest: string;
43 /** One short digest per band of `ROWS_PER_BAND` rows, for finding where two maps part. */
44 bands: string[];
45 rows: number;
46 };
47
48 /** A map digest committed in the repository, and what to call it when it moves. */
49 export type MapPin = {
50 /** What this map is called in the first sentence of a complaint. */
51 label: string;
52 /** The constant the whole digest is committed as. */
53 constant: string;
54 /** The constant the bands are committed as, beside it. */
55 list: string;
56 digest: string;
57 bands: readonly string[];
58 };
59
60 /** Fold a level's cells into a digest, checkpointing every `ROWS_PER_BAND` rows. */
61 export function layout(level: Level): Layout {
62 const rows = rowsOf(level.grid);
63 let at = 0;
64 const run = foldRows({
65 ticks: rows.length,
66 step: (row) => void (at = row),
67 // A row is a fixed number of cells, so the letters need no separator to be
68 // read back apart: what goes in is exactly the page a terminal prints.
69 line: () => rows[at] as string,
70 every: ROWS_PER_BAND,
71 });
72 return { digest: run.digest, bands: run.checkpoints, rows: run.ticks };
73 }
74
75 /**
76 * Why a map and its pin differ, or null if they do not. The engine finds the
77 * band that first went another way and prints the constant to paste; what this
78 * adds is what it cannot know — that a checkpoint here is ten rows of cells and
79 * that what moved is the generator rather than the game.
80 */
81 export function compare(map: Layout, pin: MapPin, file = "src/client/replay.test.ts"): string | null {
82 return complain(
83 { digest: map.digest, checkpoints: map.bands, ticks: map.rows },
84 { label: `map ${pin.label}`, constant: pin.constant, digest: pin.digest, checkpoints: pin.bands },
85 {
86 file,
87 list: pin.list,
88 hashes: "every cell the generator carved, one row of letters a line",
89 every: ROWS_PER_BAND,
90 unit: "band",
91 },
92 );
93 }
src/client/level.test.ts added +192
@@ -0,0 +1,192 @@
1 /**
2 * A level is a value, and this file is the proof: an eleven-by-nine box room,
3 * written out here and nowhere else, is handed to `createWorld` and the game
4 * plays on it. A bot paths from one spawn to the other, walks, and stays inside
5 * walls de_forge has never heard of; a bullet stops in one of them. Nothing
6 * under `src/client/` changed to allow it — the room only has to be a `Level`.
7 *
8 * The invariants every level owes are checked here once, over both levels: a
9 * spawn you cannot stand on, a site off the edge of the grid or a box hanging
10 * outside the floor are bugs in a map, and the test that catches them should
11 * not have to be written again for the second one.
12 */
13
14 import { expect, test } from "bun:test";
15 import { readdirSync, readFileSync } from "node:fs";
16 import { join } from "node:path";
17 import { traceBullet } from "./combat.ts";
18 import { mergeSolid, worldOf as gridWorldOf, type Grid, type Vec3 } from "../engine/index.ts";
19 import {
20 colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level,
21 } from "./level.ts";
22 import { DE_FORGE } from "./map.ts";
23 import { SHORT } from "./match.ts";
24 import { applyInput, createWorld, type Player } from "./world.ts";
25 import { botThink } from "./bots.ts";
26
27 // --- a second level, built here ------------------------------------------
28
29 const CELL = 128;
30 const WALL = 256;
31
32 /**
33 * One room, drawn as it is played: `#` is wall, `t` and `c` are the two spawns,
34 * `A` and `B` are the two bombsites a defusal level has by definition, and a
35 * dot is floor. Eleven cells east by nine south, which is 1,408 by 1,152 units.
36 */
37 const ROWS = [
38 "###########",
39 "#t.......A#",
40 "#.........#",
41 "#.........#",
42 "#.........#",
43 "#.........#",
44 "#.........#",
45 "#c.......B#",
46 "###########",
47 ];
48
49 const GRID: Grid<string> = {
50 width: ROWS[0]!.length,
51 height: ROWS.length,
52 cell: CELL,
53 cells: ROWS.flatMap((row) => [...row]),
54 };
55
56 /** The room's whole alphabet: everything that is not rock can be walked on. */
57 const passable = (cell: string): boolean => cell !== "#";
58
59 const halfX = (GRID.width / 2) * CELL;
60 const halfY = (GRID.height / 2) * CELL;
61
62 /**
63 * Where the one cell carrying `letter` sits in the world. The four places are
64 * read back off the grid rather than written twice, so the room cannot disagree
65 * with the picture above — which is the same trick de_forge plays.
66 */
67 function place(letter: string): Vec3 {
68 const at = GRID.cells.indexOf(letter);
69 return gridWorldOf(GRID, at % GRID.width, Math.floor(at / GRID.width));
70 }
71
72 const ROOM: Level = {
73 name: "de_room",
74 grid: GRID,
75 passable,
76 wallHeight: WALL,
77 look: "room",
78 materials: { FLOOR: 0, WALL: 1 },
79 boxes: [
80 { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: 0 },
81 ...mergeSolid(GRID, (cell) => cell === "#", 0, WALL, 1),
82 ],
83 spawns: { t: [place("t")], ct: [place("c")] },
84 sites: { A: place("A"), B: place("B") },
85 siteCells: { A: "A", B: "B" },
86 };
87
88 // --- what every level owes ------------------------------------------------
89
90 /** Why this level is not one, or nothing at all. Every level answers this. */
91 function problems(level: Level): string[] {
92 const bad: string[] = [];
93 for (const [team, spots] of Object.entries(level.spawns))
94 for (const spot of spots)
95 if (!walkable(level, colOf(level, spot[0]), rowOf(level, spot[1])))
96 bad.push(`${level.name}: a ${team} spawn at ${spot} is not walkable`);
97 for (const [name, site] of Object.entries(level.sites))
98 if (siteAt(level, site) !== name) bad.push(`${level.name}: site ${name} at ${site} is not on ${name}`);
99 const { width, height, cell } = level.grid;
100 const edge: [number, number] = [(width / 2) * cell, (height / 2) * cell];
101 for (const box of level.boxes)
102 // `0 | 1` and not `number`: an axis of a Vec3 is one of two, and naming
103 // them is what makes the reads below numbers rather than maybes.
104 for (const axis of [0, 1] as const)
105 if (box.min[axis] < -edge[axis] || box.max[axis] > edge[axis])
106 bad.push(`${level.name}: a box runs from ${box.min} to ${box.max}, outside the grid`);
107 return bad;
108 }
109
110 test("both levels keep the promises the type makes: spawns, sites, boxes", () => {
111 expect(problems(DE_FORGE)).toEqual([]);
112 expect(problems(ROOM)).toEqual([]);
113 // And the check bites: a spawn in the wall and a box off the edge are named.
114 const broken: Level = {
115 ...ROOM,
116 spawns: { t: [worldOf(ROOM, 0, 0)], ct: ROOM.spawns.ct },
117 boxes: [{ min: [-9999, 0, 0], max: [0, 0, 0], mat: 0 }],
118 };
119 expect(problems(broken).length).toBe(2);
120 });
121
122 test("the room is a grid, a predicate and eleven boxes of geometry", () => {
123 expect(ROOM.grid.cells.length).toBe(11 * 9);
124 expect(walkable(ROOM, 1, 1)).toBe(true);
125 expect(walkable(ROOM, 0, 0)).toBe(false);
126 expect(walkable(ROOM, -1, 4)).toBe(false); // off the edge is not a cell
127 expect(siteAt(ROOM, ROOM.sites.A)).toBe("A");
128 expect(siteAt(ROOM, ROOM.sites.B)).toBe("B");
129 expect(openCells(ROOM).length).toBe(9 * 7);
130 });
131
132 test("only the map and the entry name de_forge: everything else is handed a level", () => {
133 // The whole point of the type. A module that reaches for `DE_FORGE` is a
134 // module that has decided which map the game is on, and only the file that
135 // builds it and the file that starts the game get to decide that.
136 const mine = import.meta.dir;
137 const allowed = new Set(["map.ts", "main.ts"]);
138 const offenders = readdirSync(mine)
139 .filter((file) => file.endsWith(".ts") && !file.includes(".test"))
140 .filter((file) => !allowed.has(file) && readFileSync(join(mine, file), "utf8").includes("DE_FORGE"));
141 expect(offenders).toEqual([]);
142 });
143
144 // --- and the game played on it --------------------------------------------
145
146 test("a path crosses the room, cell by cell, without leaving it", () => {
147 const path = findPath(ROOM, ROOM.spawns.t[0]!, ROOM.spawns.ct[0]!);
148 expect(path.length).toBeGreaterThan(4);
149 for (let i = 1; i < path.length; i++)
150 expect(Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1])).toBeLessThan(CELL * 1.5);
151 for (const step of path) expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true);
152 });
153
154 test("a world on the room is a world on the room, and its bot walks it", () => {
155 const world = createWorld(ROOM, SHORT, "me", "ct", 1, 5);
156 expect(world.level).toBe(ROOM);
157 // The bot spawns on the room's T spawn, not on de_forge's.
158 const bot = world.players.find((p) => p.bot) as Player;
159 expect([bot.pos[0], bot.pos[1]]).toEqual([ROOM.spawns.t[0]![0], ROOM.spawns.t[0]![1]]);
160 expect(siteAt(ROOM, bot.pos)).toBe(null);
161
162 // Three seconds of it thinking and moving, with nobody to shoot at: the round
163 // rules are left out on purpose, because a lone bot ends the round at once.
164 world.match.phase = "live";
165 world.me.alive = false;
166 const from: [number, number] = [bot.pos[0], bot.pos[1]];
167 for (let tick = 0; tick < 64 * 3; tick++) {
168 world.time += 1 / 64;
169 applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64);
170 }
171 // It walked, on a path made of this room's cells, and this room's walls —
172 // not de_forge's — are what kept it inside.
173 expect(Math.hypot(bot.pos[0] - from[0], bot.pos[1] - from[1])).toBeGreaterThan(200);
174 const path = (bot.brain as { path: Vec3[] }).path;
175 expect(path.length).toBeGreaterThan(1);
176 for (const step of path)
177 expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true);
178 expect(Math.abs(bot.pos[0])).toBeLessThan(halfX);
179 expect(Math.abs(bot.pos[1])).toBeLessThan(halfY);
180 });
181
182 test("a bullet fired across the room stops in the room's own wall", () => {
183 const world = createWorld(ROOM, SHORT, "me", "ct", 1, 9);
184 const from: [number, number, number] = [0, 0, 64];
185 // Due east: the east wall's inner face is the west side of column 10, at
186 // (10 - 5.5 + 0.5) * 128 - 64 = 576 units.
187 const hit = traceBullet(from, [8192, 0, 64], world.level.boxes, [], -1);
188 expect(hit.kind).toBe("world");
189 if (hit.kind !== "world") return;
190 expect(hit.point[0]).toBeCloseTo(576, 6);
191 expect(hit.normal).toEqual([-1, 0, 0]);
192 });
src/client/level.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * What a level is, as one value: a grid of lettered cells, what a letter means,
3 * the boxes that grid merges into, where each side spawns, where the two sites
4 * are, and the numbers it was built at. `map.ts` builds de_forge as one of
5 * these and `createWorld` is handed it, so a second map, a second mode or a
6 * four-cell test room is a second value rather than a second module.
7 *
8 * Nothing here knows de_forge's alphabet. A cell is a letter; which letters
9 * exist and which of them a player may stand on is the level's own `passable`,
10 * and the arithmetic and the algorithms under it — cell to world, meshing, A* —
11 * are the engine's `src/engine/grid.ts`, which knows less still.
12 *
13 * Everything below takes the level first and reads it once. These are the
14 * questions the simulation asks every tick, so they stay flat: a lookup here is
15 * a bounds check and a multiply, never a search.
16 */
17
18 import * as g from "../engine/index.ts";
19 import type { Box, Grid, Vec3 } from "../engine/index.ts";
20
21 /** The tracer owns the box; a level hands it out, because that is what a level is made of. */
22 export type { Box } from "../engine/index.ts";
23
24 /** A cell is a letter. What the letters are, and what each one means, is a level's own. */
25 export type Cell = string;
26
27 export type Level = {
28 /** What to call it in a log or a test failure. */
29 readonly name: string;
30 /** The cells, row-major, with the world arithmetic that goes with them. */
31 readonly grid: Grid<Cell>;
32 /** May a player stand on this cell? The whole of what the search knows about the alphabet. */
33 readonly passable: (cell: Cell) => boolean;
34 /** How tall a wall stands, in world units. */
35 readonly wallHeight: number;
36 /**
37 * What this level is dressed in: the name of the set of materials below, out
38 * of `looks.ts`. A level carries it rather than a global holding it, so two
39 * levels in one process — the menu's preview and the match's map — can wear
40 * different ones, and a name that says "gen_14u5j6g looks like this" is the
41 * level's own word rather than a lookup somebody has to repeat.
42 */
43 readonly look: string;
44 /**
45 * The material ids this level's boxes are tagged with, by name: which layer
46 * of the atlas its floor, its walls, its cover and its sites are painted
47 * from. Every box in `boxes` is already tagged with one of these — this is
48 * the reader's index to them, not a second source of truth.
49 */
50 readonly materials: Readonly<Record<string, number>>;
51 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
52 readonly boxes: Box[];
53 /** Where each side starts a round, one point per spawn cell. */
54 readonly spawns: { readonly t: Vec3[]; readonly ct: Vec3[] };
55 /** The middle of each bombsite. */
56 readonly sites: { readonly A: Vec3; readonly B: Vec3 };
57 /** Which letter each site is paved with, so a point can be asked which site it stands in. */
58 readonly siteCells: { readonly A: Cell; readonly B: Cell };
59 };
60
61 /** The cell at (col, row), or `undefined` off the edge — what is out there is the level's business. */
62 export const cellAt = (level: Level, col: number, row: number): Cell | undefined =>
63 g.cellAt(level.grid, col, row);
64
65 /**
66 * Can a player stand in this cell? Off the edge of a level is not a cell and so
67 * is never walkable, whatever the level's border is made of.
68 */
69 export function walkable(level: Level, col: number, row: number): boolean {
70 const { width, height, cells } = level.grid;
71 if (col < 0 || row < 0 || col >= width || row >= height) return false;
72 return level.passable(cells[row * width + col] as Cell);
73 }
74
75 /** Grid coordinates are north-down; the world is Y-north, centred on zero. */
76 export const worldOf = (level: Level, col: number, row: number, z = 0): Vec3 =>
77 g.worldOf(level.grid, col, row, z);
78
79 export const colOf = (level: Level, x: number): number => g.colOf(level.grid, x);
80 export const rowOf = (level: Level, y: number): number => g.rowOf(level.grid, y);
81
82 /** Which bombsite a point stands in, if any. */
83 export function siteAt(level: Level, pos: Vec3): "A" | "B" | null {
84 const cell = cellAt(level, colOf(level, pos[0]), rowOf(level, pos[1]));
85 if (cell === undefined) return null;
86 if (cell === level.siteCells.A) return "A";
87 if (cell === level.siteCells.B) return "B";
88 return null;
89 }
90
91 /**
92 * A* over the cells this level calls walkable, eight ways, no corner-cutting:
93 * the search is the engine's and what a bot may stand on is the level's answer.
94 * de_forge is 1936 cells, and 100 searches between random walkable cells average
95 * 0.10 ms each, so a bot changing its goal mid-tick costs a tenth of what it has.
96 */
97 export const findPath = (level: Level, from: Vec3, to: Vec3): Vec3[] =>
98 g.findPath(level.grid, level.passable, from, to);
99
100 /** Every point a player can stand in, in reading order: what a spawn picker chooses from. */
101 export function openCells(level: Level, z = 0): Vec3[] {
102 const out: Vec3[] = [];
103 for (let row = 0; row < level.grid.height; row++)
104 for (let col = 0; col < level.grid.width; col++)
105 if (walkable(level, col, row)) out.push(worldOf(level, col, row, z));
106 return out;
107 }
src/client/loadout.test.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * The wallet's memory: what a round bought, what the round after buys back, and
3 * the sentence it says about it. No page and no frame — a `Rounds` is a phase
4 * and a round number, and a `Buyer` is the kit and the wallet, the way
5 * `economy.test.ts` and `buy.test.ts` write them.
6 */
7
8 import { expect, test } from "bun:test";
9 import { emptyAmmo, type Buyer } from "./economy.ts";
10 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy, type Rounds } from "./loadout.ts";
11 import { ARMOR_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
12
13 const round = (n: number, phase: "freeze" | "live" = "freeze"): Rounds => ({ match: { phase, round: n } });
14
15 const buyer = (over: Partial<Buyer> = {}): Buyer => ({
16 team: "t", alive: true, money: 5000,
17 armor: 0, helmet: false, defuseKit: false,
18 weapon: "glock", primary: null, secondary: "glock",
19 ammo: { knife: { mag: 0, reserve: 0 }, glock: emptyAmmo("glock") },
20 pocket: { he: 0, flash: 0, smoke: 0 },
21 ...over,
22 });
23
24 test("a round's purchases become the next round's rebuy, in the order they were made", () => {
25 const loadout = createLoadout();
26 atRound(loadout, 1);
27 noteBuy(loadout, "ak47");
28 noteBuy(loadout, "armor");
29 noteBuy(loadout, "smoke");
30 // Nothing to buy back yet: round one is the first round anybody bought in.
31 expect(loadout.last).toEqual([]);
32
33 atRound(loadout, 2);
34 expect(loadout.last).toEqual(["ak47", "armor", "smoke"]);
35 expect(loadout.bought).toEqual([]);
36 });
37
38 test("a round nobody bought anything in is a rebuy of nothing, not of two rounds ago", () => {
39 const loadout = createLoadout();
40 atRound(loadout, 1);
41 noteBuy(loadout, "ak47");
42 atRound(loadout, 2);
43 atRound(loadout, 3);
44 expect(loadout.last).toEqual([]);
45 expect(saidRebuy(rebuy(round(3), buyer(), loadout), "t"))
46 .toBe("Nothing to rebuy: you bought nothing last round.");
47 });
48
49 test("a rebuy buys last round's loadout again, through the shop, and says what it bought", () => {
50 const loadout = createLoadout();
51 atRound(loadout, 1);
52 noteBuy(loadout, "ak47");
53 noteBuy(loadout, "helmet");
54 atRound(loadout, 2);
55
56 const me = buyer({ money: WEAPONS.ak47.price + ARMOR_PRICE + HELMET_PRICE });
57 const done = rebuy(round(2), me, loadout);
58 expect(done.bought).toEqual(["ak47", "helmet"]);
59 expect(done.short).toBeNull();
60 // The kit is the kit the shop hands over, because the shop is what handed it:
61 // the gun in your hands, the vest on, the helmet on, and nothing left.
62 expect(me.primary).toBe("ak47");
63 expect(me.helmet).toBe(true);
64 expect(me.money).toBe(0);
65 expect(saidRebuy(done, "t")).toBe("Rebought AK-47, Kevlar + Helmet.");
66 // And it is this round's loadout now, so the round after buys the same again.
67 expect(loadout.bought).toEqual(["ak47", "helmet"]);
68 });
69
70 test("a rebuy stops where the money runs out, and says what it could not afford", () => {
71 const loadout = createLoadout();
72 atRound(loadout, 1);
73 noteBuy(loadout, "ak47");
74 noteBuy(loadout, "helmet");
75 atRound(loadout, 2);
76
77 const me = buyer({ money: WEAPONS.ak47.price + 100 });
78 const done = rebuy(round(2), me, loadout);
79 expect(done.bought).toEqual(["ak47"]);
80 expect(done.short).toBe("helmet");
81 expect(me.money).toBe(100);
82 expect(me.helmet).toBe(false);
83 expect(saidRebuy(done, "t")).toBe("Rebought AK-47 — not enough for Kevlar + Helmet.");
84
85 // And a wallet that cannot reach the first thing on the list buys none of it.
86 const broke = buyer({ money: 10 });
87 const none = rebuy(round(2), broke, createLoadoutWith(["ak47", "armor"]));
88 expect(none.bought).toEqual([]);
89 expect(broke.money).toBe(10);
90 expect(saidRebuy(none, "t")).toBe("Nothing rebought — not enough for AK-47.");
91 });
92
93 /** A loadout that has already played a round: `last` is this list. */
94 function createLoadoutWith(last: ("ak47" | "armor")[]) {
95 const loadout = createLoadout();
96 atRound(loadout, 1);
97 for (const item of last) noteBuy(loadout, item);
98 atRound(loadout, 2);
99 return loadout;
100 }
101
102 test("a gun you survived the round with is not bought twice", () => {
103 const loadout = createLoadoutWith(["ak47", "armor"]);
104 // Still holding the rifle, still wearing the vest: a rebuy is a no-op that
105 // spends nothing, and it says so rather than pretending it bought something.
106 const me = buyer({ money: 5000, primary: "ak47", armor: 100 });
107 const done = rebuy(round(2), me, loadout);
108 expect(done.bought).toEqual([]);
109 expect(done.short).toBeNull();
110 expect(me.money).toBe(5000);
111 expect(saidRebuy(done, "t")).toBe("Nothing to rebuy: you still have last round's kit.");
112 });
113
114 test("armour you already have is stepped over rather than stopped at", () => {
115 const loadout = createLoadout();
116 atRound(loadout, 1);
117 noteBuy(loadout, "armor");
118 noteBuy(loadout, "ak47");
119 atRound(loadout, 2);
120 // The vest survived the round and the rifle did not: the vest is refused by
121 // the shop, which is not the money running out, so the rifle is still bought.
122 const me = buyer({ money: WEAPONS.ak47.price, armor: 100 });
123 const done = rebuy(round(2), me, loadout);
124 expect(done.bought).toEqual(["ak47"]);
125 expect(me.primary).toBe("ak47");
126 });
127
128 test("a rebuy outside the freeze time, or dead, buys nothing at all", () => {
129 const loadout = createLoadoutWith(["ak47"]);
130 const me = buyer({ money: 5000 });
131 expect(rebuy(round(2, "live"), me, loadout).bought).toEqual([]);
132 expect(me.money).toBe(5000);
133 const dead = buyer({ money: 5000, alive: false });
134 expect(rebuy(round(2), dead, createLoadoutWith(["ak47"])).bought).toEqual([]);
135 expect(dead.money).toBe(5000);
136 });
src/client/loadout.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * What you bought, and buying it again. A round's purchases are written down in
3 * the order they were made; the round after, that list is what one key buys
4 * back — the same items in the same order, as far as the money goes.
5 *
6 * It spends through `buy()` in `economy.ts` and through nothing else: a rebuy is
7 * the same purchases a player would have made by hand, made faster, so every
8 * refusal the shop already has — the phase, the wallet, a pocket that is full, a
9 * kit a T cannot carry — is the refusal a rebuy gets. What is added here is one
10 * rule of its own: a gun you are still holding is not bought twice, because you
11 * survived the round with it and money for a second copy of it is money burnt.
12 *
13 * It knows nothing about a page. The frame tells it which round it is on, the
14 * panel tells it what was pressed, and what comes back is a plain result and a
15 * sentence — which the frame shows in the HUD's message line and the panel
16 * announces, and neither of them composes itself.
17 */
18
19 import { labelOf } from "./buy.ts";
20 import { buy, canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts";
21 import type { Team } from "./match.ts";
22
23 /**
24 * The round a rebuy happens in: whether the shop is open, which is `Market`, and
25 * which round it is, which is what makes last round's list last round's. A
26 * `World` satisfies it, and this file cannot reach back for one.
27 */
28 export type Rounds = Market & { match: { round: number } };
29
30 /** What was bought this round, what was bought the round before, and which round that is. */
31 export type Loadout = {
32 /** The round `bought` is being written for. */
33 round: number;
34 /** This round's purchases, oldest first. */
35 bought: BuyItem[];
36 /** The round before's, which is what a rebuy buys back. */
37 last: BuyItem[];
38 };
39
40 export const createLoadout = (): Loadout => ({ round: 0, bought: [], last: [] });
41
42 /**
43 * A round has begun. What was bought in the one before it becomes the rebuy and
44 * the new round starts with an empty list — called every frame, because a round
45 * where nothing was bought is a rebuy of nothing and a memory that rolled over
46 * only when somebody bought something would hand out a two-round-old loadout.
47 */
48 export function atRound(loadout: Loadout, round: number): void {
49 if (round === loadout.round) return;
50 loadout.last = loadout.bought;
51 loadout.bought = [];
52 loadout.round = round;
53 }
54
55 /** One purchase that went through, written down in the order it was made. */
56 export function noteBuy(loadout: Loadout, item: BuyItem): void {
57 loadout.bought.push(item);
58 }
59
60 /** What a rebuy did: what it bought, what the money ran out on, and what it had to buy. */
61 export type Rebought = {
62 /** The items bought back, in the order they were bought the first time. */
63 bought: BuyItem[];
64 /** The first item the wallet could not reach, which is where it stopped. */
65 short: BuyItem | null;
66 /** How many items last round's loadout held, so "nothing to rebuy" can say so. */
67 had: number;
68 };
69
70 /** Do you already hold this gun? A second one of it is money for nothing. */
71 const holds = (player: Buyer, item: BuyItem): boolean =>
72 player.primary === item || player.secondary === item;
73
74 /**
75 * Buy last round's loadout again, in the order it was bought, and stop at the
76 * first thing the money cannot reach — which is the order that gets the gun
77 * before the vest, because that is the order a player bought them in.
78 *
79 * Anything else the shop refuses — armour already on, a pocket already holding
80 * its grenade — is stepped over rather than stopped at: you have it, which is
81 * not a reason to abandon the rest of the list.
82 */
83 export function rebuy(market: Rounds, player: Buyer, loadout: Loadout): Rebought {
84 atRound(loadout, market.match.round);
85 const done: Rebought = { bought: [], short: null, had: loadout.last.length };
86 if (!canBuy(market, player)) return done;
87 for (const item of loadout.last) {
88 if (holds(player, item)) continue;
89 if (buy(market, player, item)) {
90 loadout.bought.push(item);
91 done.bought.push(item);
92 continue;
93 }
94 // It did not happen. The wallet is why when the price is out of reach, and
95 // that is where a rebuy stops; anything else is a thing you already have.
96 if (priceOf(item, player) > player.money) {
97 done.short = item;
98 break;
99 }
100 }
101 return done;
102 }
103
104 /** The one line a rebuy says: what it bought, or what it could not afford. */
105 export function saidRebuy({ bought, short, had }: Rebought, team: Team): string {
106 const names = bought.map((item) => labelOf(item, team)).join(", ");
107 if (short !== null)
108 return bought.length === 0
109 ? `Nothing rebought — not enough for ${labelOf(short, team)}.`
110 : `Rebought ${names} — not enough for ${labelOf(short, team)}.`;
111 if (bought.length > 0) return `Rebought ${names}.`;
112 return had === 0
113 ? "Nothing to rebuy: you bought nothing last round."
114 : "Nothing to rebuy: you still have last round's kit.";
115 }
src/client/look.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * The zoom compensation, against a hand calculation.
3 *
4 * The numbers below are worked out here rather than recomputed from the source
5 * formula, which would only prove the code equals itself:
6 *
7 * tan(20°) = 0.3639702342662024, tan(45°) = 1 (to a bit: 0.9999999999999999)
8 * scale = tan(20°) / tan(45°) = 0.3639702342662024
9 *
10 * and a player at 2.2° of turn per count who moves the mouse 10 counts asks for
11 * 22.0° of yaw unscoped, so scoped they get
12 *
13 * 22.0 × 0.3639702342662024 = 8.007345153856452°
14 *
15 * which is the same fraction of the screen: 22° is 22/90 = 24.4% of the
16 * unscoped view's width in *angle*, but the screen is a tangent picture, and
17 * what matches at both fields of view is the distance the crosshair moves
18 * across it — checked below by projecting both turns onto their own screen.
19 */
20
21 import { expect, test } from "bun:test";
22 import { FOV, fovFor, lookAt, SCOPED_FOV, zoomScale } from "./look.ts";
23
24 /** Degrees of turn per count, the menu's default and the number in the sums above. */
25 const SENSITIVITY = 2.2;
26
27 test("the default view is the sensitivity the player set, to the bit", () => {
28 expect(zoomScale(FOV, FOV)).toBe(1);
29 for (const delta of [0.123456789, -33.75, 1e-9, 1234.5678, 0]) {
30 expect(lookAt(delta, FOV, FOV)).toBe(delta);
31 expect(lookAt(delta, fovFor(false), FOV)).toBe(delta);
32 }
33 });
34
35 test("the scope scales the turn by tan(fov/2) / tan(fovDefault/2)", () => {
36 expect(zoomScale(SCOPED_FOV, FOV)).toBeCloseTo(0.3639702342662024, 15);
37 // 10 counts × 2.2° each = 22°, and through the scope 8.0073°.
38 const asked = 10 * SENSITIVITY;
39 expect(lookAt(asked, FOV, FOV)).toBe(22);
40 expect(lookAt(asked, SCOPED_FOV, FOV)).toBeCloseTo(8.007345153856452, 12);
41 // The sign is the delta's: a turn left through a scope is still a turn left.
42 expect(lookAt(-asked, SCOPED_FOV, FOV)).toBeCloseTo(-8.007345153856452, 12);
43 // Twitchier, not calmer, was the bug: the scoped turn is under a third of
44 // the way to the unscoped one, not more than twice as far.
45 expect(lookAt(asked, SCOPED_FOV, FOV)).toBeLessThan(asked);
46 });
47
48 test("the same hand movement crosses the same fraction of the screen at either view", () => {
49 // Where a turn of `deg` puts the crosshair on a screen of half-width
50 // tan(fov/2), as a fraction of that half-width: tan(deg) / tan(fov/2).
51 const acrossScreen = (deg: number, fov: number): number =>
52 Math.tan((deg * Math.PI) / 180) / Math.tan((fov * Math.PI) / 360);
53
54 // Small movements, where the tangent is near enough its angle that the ratio
55 // of half-widths is the right scale: a quarter-count to two counts.
56 for (const counts of [0.25, 0.5, 1, 2]) {
57 const asked = counts * SENSITIVITY;
58 const plain = acrossScreen(lookAt(asked, FOV, FOV), FOV);
59 const scoped = acrossScreen(lookAt(asked, SCOPED_FOV, FOV), SCOPED_FOV);
60 expect(scoped).toBeCloseTo(plain, 3);
61 }
62 // Without the scale it is the bug the ratio is for: the same 22° asked for
63 // at 40° of view crosses 2.75 times as much screen as it does at 90°.
64 expect(acrossScreen(22, SCOPED_FOV) / acrossScreen(22, FOV)).toBeCloseTo(2.747, 3);
65 });
66
67 test("it takes a field of view, not a flag, so a second zoom level is one number", () => {
68 // A hypothetical second scope at 15°: nothing here has to change to scale it.
69 // tan(7.5°) / tan(45°) = 0.13165249758739583.
70 expect(zoomScale(15, FOV)).toBeCloseTo(0.13165249758739583, 15);
71 expect(lookAt(22, 15, FOV)).toBeCloseTo(2.8963549469227083, 12);
72 // Narrower is always slower, and the scale rises with the field of view.
73 expect(zoomScale(15, FOV)).toBeLessThan(zoomScale(SCOPED_FOV, FOV));
74 expect(zoomScale(SCOPED_FOV, FOV)).toBeLessThan(zoomScale(FOV, FOV));
75 // And a wider view than the default is faster, which is the same formula
76 // read the other way — a 120° view is tan(60°)/tan(45°) = 1.732.
77 expect(zoomScale(120, FOV)).toBeCloseTo(Math.sqrt(3), 12);
78 });
79
80 test("the fields of view are the ones the camera uses", () => {
81 expect(fovFor(true)).toBe(SCOPED_FOV);
82 expect(fovFor(false)).toBe(FOV);
83 expect([SCOPED_FOV, FOV]).toEqual([40, 90]);
84 });
src/client/look.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The look: the two fields of view this game is seen through, and what a
3 * pointer movement is worth at each of them.
4 *
5 * The engine's controls hand back a turn in degrees, already scaled by the
6 * player's sensitivity. They know nothing about scopes and must not — a scope
7 * is this shooter's tuning, so the compensation lives here, beside the game's
8 * other numbers, and is applied where the look is read.
9 *
10 * Pulling up the scope narrows the view from 90° to 40° across. With the same
11 * degrees per count, the same hand movement then crosses more than twice as
12 * much of the screen as it did unscoped — twitchy at exactly the moment a
13 * player wants the opposite. Counter-Strike scales the sensitivity with the
14 * zoom (`zoom_sensitivity_ratio`, 1.0 by default, which is this); `zoomScale`
15 * below is that scale and `lookAt` is a turn taken through it.
16 */
17
18 /** The field of view the game is played at: degrees, measured across the screen. */
19 export const FOV = 90;
20
21 /**
22 * The one a scope brings on, in degrees across — the same 40 the AWP declares
23 * as its `zoom` in `weapons.ts`, and the one `spectate.ts` eases the camera to.
24 * A second zoom level is one more constant here and nothing else: everything
25 * below takes a field of view, never a "scoped" flag, so a scope that is not
26 * this one is a number passed to the camera and to `lookAt` together.
27 */
28 export const SCOPED_FOV = 40;
29
30 /** Which of the two you are looking through. Degrees out, never a boolean on. */
31 export const fovFor = (scoped: boolean): number => (scoped ? SCOPED_FOV : FOV);
32
33 /**
34 * How much of a turn survives the zoom — unitless, 1 at the default view:
35 *
36 * scale = tan(fov / 2) / tan(fovDefault / 2)
37 *
38 * A half-angle's tangent is the half-width of what the screen shows at one unit
39 * of depth, so the ratio is how much less world the zoomed screen holds. Turn
40 * by that fraction of the degrees and a hand movement crosses the same fraction
41 * of the screen at either field of view, which is the thing a player's muscle
42 * memory is actually measured in. At 40° and 90°: tan20° / tan45° = 0.36397, so
43 * a scoped turn is a little over a third of the unscoped one.
44 *
45 * Both angles are horizontal — the renderer derives the vertical one from the
46 * aspect — so the same scale is right for pitch as for yaw.
47 *
48 * `fov` and `fovDefault` are degrees in (0, 180); the scale is a pure number.
49 */
50 export const zoomScale = (fov: number, fovDefault: number): number =>
51 Math.tan((fov * Math.PI) / 360) / Math.tan((fovDefault * Math.PI) / 360);
52
53 /**
54 * The turn to take this frame: `delta` degrees of look asked for at the
55 * player's sensitivity, taken through `fov` instead of the `fovDefault` that
56 * sensitivity was set at. Degrees in, degrees out.
57 *
58 * At the default view this is the delta itself to the bit — `t / t` is exactly
59 * 1 in IEEE 754 for any finite non-zero `t`, and a multiply by exactly 1 moves
60 * nothing — so a player's sensitivity setting still means what it says when
61 * they are not scoped.
62 */
63 export const lookAt = (delta: number, fov: number, fovDefault: number): number =>
64 delta * zoomScale(fov, fovDefault);
src/client/looks.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * The looks, measured rather than admired.
3 *
4 * A named set of materials is only worth having if a room dressed in it still
5 * reads: cover has to stand off the floor and off the wall from across the
6 * room, a bombsite has to read as painted ground rather than as more floor, and
7 * a player has to be a shape against all three. Nine materials do not combine
8 * freely, so every set in `looks.ts` is held to those distances here — against
9 * the actual shaders in `materials.ts`, sampled, not against a colour somebody
10 * wrote in a comment.
11 *
12 * The distance is a crude perceptual one: each material's mean colour over a
13 * 32×32 sample of its shader, differenced with green weighted heaviest and blue
14 * lightest, which is the order the eye weighs them in. It is not a colour
15 * science; it is enough to tell "sand under wooden crates" (90) from "sand
16 * under a sand crate" (0), which is the whole question being asked.
17 */
18
19 import { expect, test } from "bun:test";
20 import { FORGE, LOOKS, lookFor, lookNamed, SKIN, FATIGUES, GEAR, type Look } from "./looks.ts";
21 import { LAYERS, MATERIALS } from "./materials.ts";
22 import { MAT } from "./cells.ts";
23
24 /** Each material's mean colour, its shader sampled over the square it is painted into. */
25 const MEAN: readonly [number, number, number][] = MATERIALS.map((shader) => {
26 const N = 32;
27 let r = 0, g = 0, b = 0;
28 for (let i = 0; i < N; i++)
29 for (let j = 0; j < N; j++) {
30 const c = shader((i + 0.5) / N, (j + 0.5) / N);
31 r += c[0]; g += c[1]; b += c[2];
32 }
33 return [r / (N * N), g / (N * N), b / (N * N)];
34 });
35
36 /** How far apart two materials look, 0 being the same surface. */
37 function apart(one: number, two: number): number {
38 const a = MEAN[one] as [number, number, number], b = MEAN[two] as [number, number, number];
39 return Math.hypot((a[0] - b[0]) * 0.6, (a[1] - b[1]) * 1.0, (a[2] - b[2]) * 0.4);
40 }
41
42 /**
43 * The bars, and where they come from: de_forge is the map this game was tuned
44 * on, and its own numbers are site/floor 70, cover/floor 90, cover/wall 63 and
45 * floor/wall 31. A look may be quieter than de_forge but not by much — the
46 * floors below are under every one of the eight and over every pairing that
47 * failed to read when it was looked at.
48 */
49 const SITE_OFF_FLOOR = 45;
50 const COVER_OFF_FLOOR = 45;
51 const COVER_OFF_WALL = 45;
52 const FLOOR_OFF_WALL = 30;
53
54 /** What a player is made of: the three materials `models.ts` builds one out of. */
55 const PLAYER = [SKIN, FATIGUES, GEAR];
56
57 test("every look is a set of materials this game has already painted", () => {
58 expect(LOOKS.length).toBeGreaterThan(1);
59 const names = LOOKS.map((look) => look.name);
60 expect(new Set(names).size).toBe(names.length);
61 for (const look of LOOKS) {
62 expect(look.name).toMatch(/^[a-z]+$/);
63 const roles = Object.keys(look.materials).sort();
64 expect(roles).toEqual(["CRATE", "FLOOR", "METAL", "SITE", "WALL"]);
65 for (const [role, mat] of Object.entries(look.materials)) {
66 expect(Number.isInteger(mat)).toBe(true);
67 expect(mat, `${look.name}.${role}`).toBeGreaterThanOrEqual(0);
68 expect(mat, `${look.name}.${role}`).toBeLessThan(LAYERS);
69 }
70 expect(lookNamed(look.name)).toBe(look);
71 }
72 expect(lookNamed("nothing this game paints")).toBeUndefined();
73 });
74
75 test("whatever the look, a site reads as a site and cover reads as cover", () => {
76 for (const look of LOOKS) {
77 const { FLOOR, WALL, CRATE, SITE } = look.materials;
78 const why = (what: string, d: number, bar: number): string =>
79 `${look.name}: ${what} are ${d.toFixed(0)} apart, under the ${bar} a room needs to read`;
80 expect(apart(SITE, FLOOR), why("its site and its floor", apart(SITE, FLOOR), SITE_OFF_FLOOR))
81 .toBeGreaterThanOrEqual(SITE_OFF_FLOOR);
82 expect(apart(CRATE, FLOOR), why("its cover and its floor", apart(CRATE, FLOOR), COVER_OFF_FLOOR))
83 .toBeGreaterThanOrEqual(COVER_OFF_FLOOR);
84 expect(apart(CRATE, WALL), why("its cover and its wall", apart(CRATE, WALL), COVER_OFF_WALL))
85 .toBeGreaterThanOrEqual(COVER_OFF_WALL);
86 expect(apart(FLOOR, WALL), why("its floor and its wall", apart(FLOOR, WALL), FLOOR_OFF_WALL))
87 .toBeGreaterThanOrEqual(FLOOR_OFF_WALL);
88 }
89 });
90
91 test("whatever the look, a player is a shape against it", () => {
92 // A player is skin, fatigues and dark gear. A look may match one of the three
93 // — a dark floor under a dark torso is a real place, and Counter-Strike is
94 // full of them — but never two, or the silhouette a game is played on goes.
95 for (const look of LOOKS)
96 for (const [role, surface] of Object.entries(look.materials)) {
97 if (role === "METAL") continue; // a name, not a surface: nothing is tagged with it
98 const clear = PLAYER.filter((worn) => apart(worn, surface) >= 40);
99 expect(clear.length, `${look.name}: a player is lost against its ${role.toLowerCase()}`)
100 .toBeGreaterThanOrEqual(2);
101 }
102 });
103
104 test("de_forge is dressed in exactly the materials it always was", () => {
105 expect(FORGE.name).toBe("forge");
106 expect(FORGE).toBe(LOOKS[0] as Look);
107 expect(FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 });
108 expect(MAT).toEqual(FORGE.materials);
109 });
110
111 test("a seed is a look, for ever, and a run of seeds is not a run of looks", () => {
112 for (const seed of [0, 1, 7, 4294967295]) expect(lookFor(seed)).toBe(lookFor(seed));
113 // The seed is a uint32 and is treated as one, however it arrives.
114 expect(lookFor(-1)).toBe(lookFor(4294967295));
115 // Ten seeds in a row are not the table in order, which `seed % LOOKS.length`
116 // would be: that is what the stirring in `lookFor` is for.
117 const run = Array.from({ length: LOOKS.length }, (_, i) => lookFor(i).name);
118 expect(run).not.toEqual(LOOKS.map((look) => look.name));
119 });
src/client/looks.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * What a map is dressed in: which of the nine materials in the atlas its floor,
3 * its walls, its cover and its bombsites are painted with, as a handful of
4 * named sets rather than an index per surface.
5 *
6 * The atlas is painted once at boot and never repainted, and a box in a level
7 * mesh carries a layer number. So a generated map can *look* like its own place
8 * for nothing: it picks one of the sets below from the same seed that carved
9 * it, `cells.ts` tags its boxes with that set's numbers, and not one byte more
10 * goes over the wire or into the texture. Nothing downstream learns anything —
11 * the renderer has always drawn whatever layer a vertex names.
12 *
13 * (Issue #151 calls these palettes. The file is `looks.ts` because
14 * `palette.ts` next door is already the colours the 2D is drawn in, and two
15 * palettes in one directory is a file nobody can name.)
16 *
17 * Named, and not random per surface, for one reason: nine materials do not
18 * combine freely. A map wants its cover to stand off its floor from across the
19 * room, its bombsite to read as painted ground rather than as more floor, and
20 * its players to stay legible against all three. `looks.test.ts` measures each
21 * set below against the shaders in `materials.ts` and holds it to those three
22 * distances, so a look added here without looking at it fails there.
23 */
24
25 /** The jobs a level gives a material. `METAL` is a name, not a surface: nothing tags a box with it yet. */
26 export type Role = "FLOOR" | "WALL" | "CRATE" | "SITE" | "METAL";
27
28 /** One dressing of a map: what to call it, and the atlas layer each job is painted from. */
29 export type Look = {
30 /** What this set is called, in a log, a terminal and a pull. */
31 readonly name: string;
32 /** The material id each job is tagged with. The whole of what a look is. */
33 readonly materials: Readonly<Record<Role, number>>;
34 };
35
36 /** The nine materials by name, in the order `materials.ts` paints them. */
37 export const SAND = 0, PLASTER = 1, WOOD = 2, PAINT = 3, METAL = 4;
38 export const SKIN = 5, FATIGUES = 6, GEAR = 7, WARNING = 8;
39
40 const look = (name: string, FLOOR: number, WALL: number, CRATE: number, SITE: number): Look =>
41 ({ name, materials: { FLOOR, WALL, CRATE, SITE, METAL } });
42
43 /**
44 * The looks, written down. Eight of them, each one looked at in the preview and
45 * measured in `looks.test.ts`; `forge` is first because it is de_forge's own,
46 * the numbers this game shipped with, and the default every level gets unless
47 * it asks for another.
48 *
49 * Read them as places: sand under plaster with wooden crates is the desert town
50 * this game started as; metal under dark steel with a red-painted site is a
51 * foundry floor; a timber deck inside plaster walls is a depot. The site is
52 * either the painted ground it always was or the warning red — never the floor
53 * it sits on, which is the one thing a player has to see from a doorway.
54 */
55 export const LOOKS: readonly Look[] = [
56 look("forge", SAND, PLASTER, WOOD, PAINT),
57 look("foundry", METAL, GEAR, WOOD, WARNING),
58 look("quarry", SAND, GEAR, METAL, WARNING),
59 look("depot", WOOD, PLASTER, GEAR, WARNING),
60 look("bunker", GEAR, METAL, WOOD, WARNING),
61 look("kiln", SAND, METAL, GEAR, PAINT),
62 look("plaza", PAINT, PLASTER, GEAR, WARNING),
63 look("hangar", METAL, PLASTER, GEAR, WARNING),
64 ];
65
66 /** de_forge's own, and what a level is dressed in when it does not choose. */
67 export const FORGE = LOOKS[0] as Look;
68
69 /** The look of that name, or `undefined`: what a terminal or a test looks one up with. */
70 export const lookNamed = (name: string): Look | undefined => LOOKS.find((l) => l.name === name);
71
72 /**
73 * A seed's thirty-two bits stirred into thirty-two others. The seed itself is
74 * no good to index with — seeds arrive in runs (`gen_1`, `gen_2`, `gen_3` are a
75 * person trying maps), and `seed % 8` would walk the table in order and make
76 * the look look like a counter. This is murmur3's finaliser, which is one
77 * multiply-xor-shift more than we need and costs nothing.
78 */
79 const stir = (seed: number): number => {
80 let h = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b) >>> 0;
81 h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
82 return (h ^ (h >>> 16)) >>> 0;
83 };
84
85 /**
86 * The look a seed wears. The same seed wears the same one on every machine, for
87 * ever: a map name is a layout and a look together, which is what makes one
88 * worth writing down and handing to somebody.
89 */
90 export const lookFor = (seed: number): Look => LOOKS[stir(seed >>> 0) % LOOKS.length] as Look;
src/client/loop.test.ts deleted −130
@@ -1,130 +0,0 @@
1 import { expect, test } from "bun:test";
2 import { advance, MAX_ELAPSED, MAX_STEPS, TICK, waiting } from "./loop.ts";
3
4 /** Run a sequence of frames, as the loop would, and total the steps. */
5 const run = (frames: number[], paused = false): { steps: number; accumulator: number } => {
6 let accumulator = 0;
7 let steps = 0;
8 for (const elapsed of frames) {
9 const frame = advance(accumulator, elapsed, paused);
10 accumulator = frame.accumulator;
11 steps += frame.steps;
12 }
13 return { steps, accumulator };
14 };
15
16 test("the pause overlay and the match-over card wait; nothing else is asked", () => {
17 expect(waiting(true, false)).toBe(true);
18 expect(waiting(false, true)).toBe(true);
19 expect(waiting(true, true)).toBe(true);
20 // No overlay of its own, and — the point of the pair — the buy panel is not
21 // one of the arguments: it opens in the freeze time and the clock runs on.
22 expect(waiting(false, false)).toBe(false);
23 expect(waiting.length).toBe(2);
24 });
25
26 test("an ordinary frame buys the ticks it paid for and banks the remainder", () => {
27 const frame = advance(0, 1 / 60, false);
28 expect(frame.steps).toBe(1);
29 expect(frame.accumulator).toBeCloseTo(1 / 60 - TICK, 12);
30 expect(frame.elapsed).toBeCloseTo(1 / 60, 12);
31 expect(frame.measured).toBe(true);
32 });
33
34 test("a paused frame runs no steps and banks nothing", () => {
35 const frame = advance(0, 1 / 60, true);
36 expect(frame.steps).toBe(0);
37 expect(frame.accumulator).toBe(0);
38 expect(frame.measured).toBe(false);
39 });
40
41 test("thirty seconds of pause is thirty seconds the world does not run", () => {
42 // Paused at 60 fps with a tick's worth already half banked: the bank stays
43 // where it was and not one step is run, however long the dialog is up.
44 let accumulator = TICK / 2;
45 for (let i = 0; i < 30 * 60; i += 1) {
46 const frame = advance(accumulator, 1 / 60, true);
47 expect(frame.steps).toBe(0);
48 accumulator = frame.accumulator;
49 }
50 expect(accumulator).toBeCloseTo(TICK / 2, 12);
51 // And the first frame back is an ordinary frame, not a flood.
52 const resumed = advance(accumulator, 1 / 60, false);
53 expect(resumed.steps).toBe(1);
54 });
55
56 test("the frame after a long pause runs at most one frame's worth of ticks", () => {
57 // The worst case the caller can hand over: a gap of half a minute, with a
58 // tick nearly banked. It costs the cap and no more, now or next frame.
59 const first = advance(TICK * 0.99, 30, false);
60 expect(first.steps).toBe(MAX_STEPS);
61 expect(first.accumulator).toBeLessThan(TICK);
62 // And the second after it is an ordinary second: about 64 ticks, not the
63 // 8-per-frame flood a banked backlog would have made of it.
64 let accumulator = first.accumulator;
65 let steps = 0;
66 for (let i = 0; i < 60; i += 1) {
67 const frame = advance(accumulator, 1 / 60, false);
68 accumulator = frame.accumulator;
69 steps += frame.steps;
70 }
71 expect(steps).toBeLessThanOrEqual(1 / TICK + 1);
72 });
73
74 test("the eight-step clamp holds under a long frame, and the backlog is dropped", () => {
75 const frame = advance(0, MAX_ELAPSED, false);
76 expect(MAX_ELAPSED / TICK).toBeGreaterThan(MAX_STEPS); // the clamp is doing work
77 expect(frame.steps).toBe(MAX_STEPS);
78 expect(frame.accumulator).toBeLessThan(TICK);
79 expect(frame.elapsed).toBe(MAX_ELAPSED);
80 });
81
82 test("a frame longer than the elapsed clamp is clamped to it", () => {
83 const frame = advance(0, 5, false);
84 expect(frame.elapsed).toBe(MAX_ELAPSED);
85 expect(frame.steps).toBe(MAX_STEPS);
86 expect(frame.measured).toBe(false);
87 });
88
89 test("a run of tiny frames accumulates into exactly one step, at the right moment", () => {
90 const tiny = TICK / 4;
91 let accumulator = 0;
92 for (let i = 0; i < 3; i += 1) {
93 const frame = advance(accumulator, tiny, false);
94 expect(frame.steps).toBe(0);
95 accumulator = frame.accumulator;
96 expect(accumulator).toBeCloseTo(tiny * (i + 1), 12);
97 }
98 const fourth = advance(accumulator, tiny, false);
99 expect(fourth.steps).toBe(1);
100 expect(fourth.accumulator).toBeCloseTo(0, 12);
101 });
102
103 test("a steady 60 fps run spends every second of real time on ticks", () => {
104 const frames = new Array(600).fill(1 / 60);
105 const { steps, accumulator } = run(frames);
106 // Ten seconds is 640 ticks; a tick of float drift either way is the
107 // accumulator's, not a dropped second.
108 expect(steps).toBeGreaterThanOrEqual(10 / TICK - 1);
109 expect(steps).toBeLessThanOrEqual(10 / TICK);
110 expect(accumulator).toBeLessThan(TICK);
111 });
112
113 test("the accumulator never goes negative and never carries a backlog", () => {
114 for (const [banked, elapsed] of [[-5, 1 / 60], [Number.NaN, 1], [0, -1], [TICK, Number.NaN]]) {
115 const frame = advance(banked as number, elapsed as number, false);
116 expect(frame.accumulator).toBeGreaterThanOrEqual(0);
117 expect(frame.accumulator).toBeLessThan(TICK);
118 expect(frame.steps).toBeGreaterThanOrEqual(0);
119 expect(frame.steps).toBeLessThanOrEqual(MAX_STEPS);
120 }
121 });
122
123 test("a frame that is not a measurement says so, so the average is not poisoned", () => {
124 expect(advance(0, 30, false).measured).toBe(false); // clamped: a resume
125 expect(advance(0, 1 / 60, true).measured).toBe(false); // paused: drew nothing
126 expect(advance(0, 0, false).measured).toBe(false); // two frames in one tick of the clock
127 expect(advance(0, Number.NaN, false).measured).toBe(false);
128 expect(advance(0, MAX_ELAPSED, false).measured).toBe(true); // exactly the cap is still real
129 expect(advance(0, 1 / 30, false).measured).toBe(true); // slow, but honestly slow
130 });
src/client/loop.ts deleted −76
@@ -1,76 +0,0 @@
1 /**
2 * Loop: how much simulation one frame is worth.
3 *
4 * The world is stepped at a fixed 64 Hz, and a frame is however long the
5 * browser took to come back, so the leftover time is banked in an accumulator
6 * and spent a whole tick at a time. Three rules keep that honest:
7 *
8 * - A cap on the frame. A tab that was hidden, a machine that hitched or a
9 * laptop that slept comes back with a gap that is not a frame at all; past
10 * `MAX_ELAPSED` the gap is time the simulation will simply not run.
11 * - A cap on the steps. `MAX_STEPS` ticks is the most work a frame may do, and
12 * the backlog beyond it is dropped rather than banked — a banked backlog is
13 * how one long frame becomes a minute of frames each running eight steps,
14 * each of them too slow, which never catches up.
15 * - Paused is paused. A paused frame runs no steps and banks nothing, so a
16 * thirty-second pause costs the world nothing and the resume is an ordinary
17 * frame. The accumulator it keeps is the sub-tick remainder it already had,
18 * which is less than one tick and stays put.
19 *
20 * `measured` is the other half of a pause: a frame that drew nothing, or one
21 * whose clock was clamped, is not a measurement of how fast this machine
22 * draws, so it must not reach the smoothed frame time the render scale is
23 * decided from. Nothing here knows about the DOM, the world or WebGL.
24 */
25
26 /** The fixed step, in seconds. Everything in the simulation is this long. */
27 export const TICK = 1 / 64;
28
29 /** The most ticks one frame may run. Beyond this the backlog is dropped. */
30 export const MAX_STEPS = 8;
31
32 /** The longest a frame may claim to be, in seconds. */
33 export const MAX_ELAPSED = 0.25;
34
35 export type Advance = {
36 /** Fixed steps to run now: never more than `MAX_STEPS`, zero when paused. */
37 steps: number;
38 /** The accumulator to keep for the next frame. Never negative, never a backlog. */
39 accumulator: number;
40 /** The frame's length in seconds, clamped: what animation should use. */
41 elapsed: number;
42 /** Is this frame an honest sample of how long drawing takes? */
43 measured: boolean;
44 };
45
46 /**
47 * Which overlays mean the match is waiting. The pause overlay and the
48 * match-over card stop the world; the buy panel is not an argument here at
49 * all, because it opens in the freeze time and a frozen freeze clock is a
50 * round that never goes live. Two booleans in, one out: the caller looks at
51 * the DOM, this decides what it means.
52 */
53 export const waiting = (pauseUp: boolean, summaryUp: boolean): boolean => pauseUp || summaryUp;
54
55 /** A number that is not one is zero; time never runs backwards. */
56 const sane = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0);
57
58 /**
59 * What a frame is worth, given the time banked so far, the seconds since the
60 * last frame and whether the match is paused.
61 */
62 export function advance(accumulator: number, elapsed: number, paused: boolean): Advance {
63 const banked = sane(accumulator);
64 const raw = sane(elapsed);
65 const dt = Math.min(MAX_ELAPSED, raw);
66 const measured = !paused && raw > 0 && raw <= MAX_ELAPSED;
67 if (paused) return { steps: 0, accumulator: banked, elapsed: dt, measured };
68
69 const total = banked + dt;
70 const wanted = Math.floor(total / TICK);
71 const steps = Math.min(MAX_STEPS, wanted);
72 // Spend what was run; when the cap bit, the rest is time the world will
73 // never run, so only the sub-tick phase survives.
74 const kept = wanted > MAX_STEPS ? total % TICK : total - steps * TICK;
75 return { steps, accumulator: Math.max(0, kept), elapsed: dt, measured };
76 }
src/client/main.test.ts +1709 −72
@@ -1,94 +1,1731 @@
11 /**
2 * main.ts reaches into the page by id forty-odd times, and by a handful of
3 * querySelectors, and casts whatever it finds — nothing ties either list to
4 * the markup gamePage() actually renders, so an id renamed on either side is
5 * a TypeError on the first frame, not a test failure. This reads main.ts's
6 * own source text for every `$("…")` lookup and every querySelector /
7 * querySelectorAll / closest call with a literal selector, and checks each
8 * one against gamePage()'s output — except the handful main.ts writes into
9 * the page itself (the buy menu, the radio panel, the controls panel), which
10 * are named here explicitly rather than inferred, since that is the one
11 * place this test cannot check itself.
2 * The frame itself, booted against the stub page in `stage.test-helper.ts`:
3 * the ids it looks up, the match a click starts, how many fixed steps one frame
4 * is worth, what the buy overlay does to the input, what losing the pointer
5 * means, and what it posts to the leaderboard. Two of those concepts are not in
6 * here any more: `buy.test.ts` holds the shelf and the prices, and
7 * `report.test.ts` holds the round in the page's hand — the retry, the backoff
8 * and the cap. What is left of both below is the wiring — the keys that open
9 * and close the panel, the money a press costs the world, the frame that asks
10 * for a post and the page the answer lands in. Nothing here is a browser and
11 * nothing is timed by a real clock — the test decides when a frame happens and
12 * how long it took.
13 *
14 * Nothing here is seeded by one either, since #170: a match takes its seed from
15 * the id the page hands out, and this page hands out `…-000000000001` and
16 * counts. So every run of this file plays the same world — the same bots on the
17 * same spawns — and the assertions that read where a body stands (the camera in
18 * the spectating test below, and whose eye it is exactly on) are a fixed fact
19 * rather than a die rolled on each check.
20 *
21 * The first frames are the atlas: `boot` paints a layer per frame and builds
22 * the renderer on the last of them, so a test that wants a match steps those
23 * frames first — `painted` below — and two tests are about that window itself.
24 *
25 * The world is not reached for behind `boot`'s back: `boot` hands back the
26 * running frame and that frame says what it is simulating, so a test that wants
27 * a match that is nearly over says so on that world and steps another frame.
28 * What the renderer is handed is a `Scene` now, and `draws` records those.
1229 */
1330
1431 import { expect, test } from "bun:test";
15 import { mkdtempSync, readFileSync } from "node:fs";
16 import { tmpdir } from "node:os";
17 import { join } from "node:path";
18 import { openDatabase } from "../db.ts";
19 import { credit } from "../player.ts";
20 import { gamePage } from "../pages/game.ts";
32 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
33 import type { Level } from "./level.ts";
34 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts";
35 import { NO_PICTURE } from "./support.ts";
36 import { HINT } from "./buy.ts";
37 import { DE_FORGE } from "./map.ts";
38 import { endRound, SHORT } from "./match.ts";
39 import { LAYERS, materialTextures } from "./materials.ts";
40 import { recorder } from "./paint.test-helper.ts";
41 import { drawPreview, previewText } from "./preview.ts";
42 import { FIRST_WAIT, LONGEST_WAIT, RETRYING, SAVED, SENDING } from "./pending.ts";
43 import { headshotShare, resultOf, scoreLine, yourLine } from "./summary.ts";
44 import { digest, snapshot, withoutEntropy } from "./replay.ts";
45 import { WEAPONS } from "./weapons.ts";
46 import { matchOf } from "../matchid.ts";
47 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
48 import { parseResult, type Result } from "../result.ts";
49 import { DRIVER, fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts";
50 import { parseSituation } from "../plan/situation.ts";
51 import { createWorld, type World } from "./world.ts";
2152
22 const mainSource = readFileSync(join(import.meta.dir, "main.ts"), "utf8");
53 /** Every frame booted here, so a stage can be asked what world it is running. */
54 const running = new WeakMap<FakeStage, Frame>();
2355
24 const db = openDatabase(mkdtempSync(join(tmpdir(), "main-dom-")));
25 const player = credit({ role: "anon" }, "1".repeat(32));
26 const html = gamePage(db, {
27 env: "development", pull: undefined, commit: "", script: "/static/app.js", ssh: undefined, player,
56 /** Boot this stage and remember the frame it started. */
57 function start(stage: FakeStage): FakeStage {
58 running.set(stage, boot(stage.host));
59 return stage;
60 }
61
62 /** The world the frame booted on this stage is simulating, if there is one. */
63 const worldOf = (stage: FakeStage): World | null => running.get(stage)?.world() ?? null;
64
65 const TICK = 1 / 64;
66 const FRAME = 1000 / 30;
67
68 /**
69 * Step the frames the atlas is painted over: one layer each, and the renderer
70 * is built on the last of them. Nothing can be played before this is done, so
71 * every test that plays goes through it.
72 */
73 function painted(stage: FakeStage): FakeStage {
74 for (let layer = 0; layer < LAYERS; layer++) stage.step(FRAME);
75 return stage;
76 }
77
78 /** A booted page, its atlas painted, and the match already started on it. */
79 function playing(
80 options: {
81 webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean; planEndpoint?: string;
82 } = {},
83 ): FakeStage {
84 const stage = painted(start(fakeStage(options)));
85 stage.by("play").fire("click");
86 return stage;
87 }
88
89 /** How many 64 Hz steps the world has run, from the time it has simulated. */
90 const stepsRun = (stage: FakeStage): number => Math.round((worldOf(stage)?.time ?? 0) / TICK);
91
92 const button = (stage: FakeStage, action: string) =>
93 stage.buttons.find((b) => b.dataset.action === action) as FakeStage["buttons"][number];
94
95 test("it boots on the page's ids, asks for a frame, and draws nothing until a match starts", () => {
96 const stage = start(fakeStage());
97 expect(stage.frames.length).toBe(1);
98 expect(stage.by("menu").hidden).toBe(false);
99 stage.step(FRAME);
100 // The loop runs, but there is no world yet: nothing is drawn and nothing posted.
101 expect(stage.draws.length).toBe(0);
102 expect(stage.posts.length).toBe(0);
103 expect(stage.frames.length).toBe(1);
28104 });
29105
30 // --- ids, from `$("id")` and `$<Type>("id")` — the only two shapes the file
31 // uses `$` in — plus the one dynamic shape: the crosshair panel, built once
32 // per prefix ("menu", "pause") and looked up with a template literal.
33 const ID_LOOKUP = /\$(?:<[^>]+>)?\("([a-z0-9-]+)"\)/g;
34 const PREFIXED_ID_LOOKUP = /\$(?:<[^>]+>)?\(`\$\{prefix\}([a-z0-9-]*)`\)/g;
35
36 const plainIds = [...mainSource.matchAll(ID_LOOKUP)].map((m) => m[1] as string);
37 const prefixedIds = [...mainSource.matchAll(PREFIXED_ID_LOOKUP)]
38 .flatMap((m) => (["menu", "pause"] as const).map((prefix) => `${prefix}${m[1]}`));
39 const ids = [...plainIds, ...prefixedIds];
40
41 // Markup main.ts writes into the page itself — the buy panel and the radio
42 // panel are empty in gamePage() and filled in once main.ts runs — so these
43 // three ids never appear in gamePage()'s own output. Named here rather than
44 // inferred, since nothing can check this line against itself.
45 const WRITTEN_BY_MAIN_TS = ["buy-close", "buy-money", "buy-clock", "radio-close"];
46
47 test("main.ts still finds ids the way it always has", () => {
48 // A regex that stopped matching `$("…")` at all would make the next test
49 // vacuously pass on an empty list; this is the guard that catches that.
50 expect(ids.length).toBeGreaterThan(30);
106 test("the menu's Motion starts where the system's prefers-reduced-motion is", () => {
107 expect(start(fakeStage()).by("motion").value).toBe("full");
108 expect(start(fakeStage({ reducedMotion: true })).by("motion").value).toBe("reduced");
51109 });
52110
53 test("every id main.ts looks up is on the page, or is markup main.ts wrote itself", () => {
54 for (const id of ids) {
55 if (WRITTEN_BY_MAIN_TS.includes(id)) continue;
56 expect(html).toContain(`id="${id}"`);
111 test("the atlas is painted a layer a frame, and the menu says so while it is", () => {
112 const stage = start(fakeStage());
113 // Nothing was painted inside `boot`: the frame was asked for, the page has it
114 // back, and the menu is up and honest about what it is doing.
115 expect(stage.atlases.length).toBe(0);
116 expect(stage.by("prep").hidden).toBe(false);
117 expect(stage.by("play").disabled).toBe(true);
118 expect(stage.frames.length).toBe(1);
119
120 // Nine layers, nine frames: the renderer is not built until the last one, and
121 // the menu is answering the whole way through — the buy panel's key, the
122 // pointer, the fields all belong to the page between slices.
123 for (let layer = 1; layer < LAYERS; layer++) {
124 stage.step(FRAME);
125 expect(stage.atlases.length).toBe(0);
126 expect(stage.by("prep").hidden).toBe(false);
127 }
128 stage.step(FRAME);
129
130 // One renderer, built once, out of the finished bytes — the same bytes the
131 // one-call painter gives — and before anything has been drawn with it.
132 expect(stage.atlases.length).toBe(1);
133 expect(stage.atlases[0]).toEqual(materialTextures());
134 expect(stage.draws.length).toBe(0);
135 expect(stage.by("prep").hidden).toBe(true);
136 expect(stage.by("play").disabled).toBe(false);
137
138 // And it stays one: the frames keep coming and nothing is painted again.
139 stage.step(FRAME);
140 stage.step(FRAME);
141 expect(stage.atlases.length).toBe(1);
142 });
143
144 test("a match cannot be started before the atlas is finished", () => {
145 const stage = start(fakeStage());
146 // The button is disabled, but a click that arrives anyway is not a match: no
147 // world, no pointer asked for, no id handed out, nothing stored.
148 stage.by("play").fire("click");
149 expect(worldOf(stage)).toBeNull();
150 expect(stage.scene.locks).toBe(0);
151 expect(stage.ids.length).toBe(0);
152 expect(stage.store.size).toBe(0);
153 expect(stage.by("menu").hidden).toBe(false);
154
155 // Eight of the nine layers in: still not a match.
156 for (let layer = 1; layer < LAYERS; layer++) stage.step(FRAME);
157 stage.by("play").fire("click");
158 expect(worldOf(stage)).toBeNull();
159
160 // The ninth lands, and the same click is one.
161 stage.step(FRAME);
162 stage.by("play").fire("click");
163 expect(worldOf(stage)).not.toBeNull();
164 expect(stage.by("menu").hidden).toBe(true);
165 stage.step(FRAME);
166 expect(stage.draws.length).toBe(1);
167 });
168
169 test("the name is remembered between visits, and a blank one is `you`", () => {
170 const first = painted(start(fakeStage()));
171 first.by("name").value = "Vesper";
172 first.by("play").fire("click");
173 expect(first.store.get("cs.name")).toBe("Vesper");
174
175 const again = fakeStage();
176 again.store.set("cs.name", "Vesper");
177 start(again);
178 expect(again.by("name").value).toBe("Vesper");
179
180 const blank = playing();
181 expect(blank.store.get("cs.name")).toBe("you");
182 });
183
184 test("clicking play starts a match: the menu goes, the pointer is asked for, the frame draws", () => {
185 const stage = playing();
186 expect(stage.by("menu").hidden).toBe(true);
187 expect(stage.by("pause").hidden).toBe(true);
188 expect(stage.scene.locks).toBe(1);
189 // Raw counts, asked for by name. Without the dictionary Windows applies its
190 // own pointer speed and "enhance pointer precision" to `movementX` before the
191 // page sees it: the same flick of the same mouse is worth a different angle
192 // depending on how fast it was flicked, and the sensitivity in the menu is
193 // not the sensitivity you get.
194 expect(stage.scene.lockOptions).toEqual([{ unadjustedMovement: true }]);
195 expect(stage.ids.length).toBe(1);
196 expect(stage.sounds).toContain("resume");
197
198 stage.step(FRAME);
199 expect(stage.draws.length).toBe(1);
200 const drawn = stage.draws[0];
201 // Ten players, nine of them drawable: the level, every living other, no me.
202 expect(worldOf(stage)?.players.length).toBe(10);
203 expect(drawn?.scene.instanceCount).toBe(10);
204 expect(drawn?.scene.instances[0]?.mesh).toBe("level");
205 expect(drawn?.scene.camera.fov).toBe(90);
206 // The HUD was sized to its box and painted, and the counter says something.
207 expect([stage.hud.width, stage.hud.height]).toEqual([800, 450]);
208 expect(stage.hud.paint.calls[0]?.[0]).toBe("clearRect");
209 expect(stage.hud.paint.texts.join(" ")).toContain("$800");
210 expect(stage.by("fps").textContent).toContain("ms");
211 });
212
213 test("the HUD's backing store follows the screen's ratio, at 125% as well as at 100%", () => {
214 // Windows at 125% is where #307 came from: the canvas box is still 800×450
215 // CSS pixels and the backing store behind it is a quarter larger, so the HUD
216 // and the scene under it are drawn on the same grid rather than two.
217 const stage = playing();
218 stage.window.devicePixelRatio = 1.25;
219 stage.step(FRAME);
220 expect([stage.hud.width, stage.hud.height]).toEqual([1000, 563]);
221 // Every arm of the crosshair lands on a whole pixel: no smeared cross.
222 const arms = stage.hud.paint.calls.filter((c) => c[0] === "fillRect" && Number(c[3]) < 250);
223 expect(arms.length).toBeGreaterThanOrEqual(4);
224 for (const arm of arms.slice(0, 4))
225 for (const n of arm.slice(1, 5)) expect(Number.isInteger(Number(n))).toBe(true);
226 // And a ratio past the cap is the cap: pixels past two cost and show nothing.
227 stage.window.devicePixelRatio = 4;
228 stage.step(FRAME);
229 expect([stage.hud.width, stage.hud.height]).toEqual([1600, 900]);
230 });
231
232 test("nothing is heard before a match, and then your own boots are heard as your own", () => {
233 const stage = painted(start(fakeStage()));
234 // Nine frames of painting an atlas in front of a menu: the voice has not
235 // been asked for a single thing, so no audio device has been opened.
236 expect(stage.sounds).toEqual([]);
237
238 stage.by("play").fire("click");
239 const world = (stage.step(FRAME), worldOf(stage)) as World;
240 world.match.phase = "live";
241 world.match.timer = 60;
242 stage.lock(true);
243 stage.hold("KeyW", true);
244 // A second and a bit of running: a stride is 85 units and the cap is 250 u/s,
245 // so feet land, and they land as the ears' own — the frame plays every sound
246 // from the eye it is drawing with.
247 for (let frame = 0; frame < 40; frame++) stage.step(FRAME);
248 // Both kinds arrive: the bots walking out of their spawns are somebody
249 // else's feet, and the ones under the camera are marked as the ears' own.
250 expect(stage.sounds).toContain("sound:step:own");
251 expect(stage.sounds).toContain("sound:step");
252 });
253
254 test("every sound is placed from the ear's heading, not just its position", () => {
255 const stage = painted(start(fakeStage()));
256 stage.by("play").fire("click");
257 const world = (stage.step(FRAME), worldOf(stage)) as World;
258 world.match.phase = "live";
259 world.match.timer = 60;
260 stage.lock(true);
261 // A heading nothing else in the game would produce, so a yaw that arrives by
262 // accident — a zero, a bot's, the camera's own — is not this one.
263 world.me.yaw = 137;
264 stage.hold("KeyW", true);
265 for (let frame = 0; frame < 40; frame++) stage.step(FRAME);
266 expect(stage.sounds.length).toBeGreaterThan(0);
267 // Running forward does not turn you, so every noise of that second was heard
268 // from the same facing, and it is the one the player is holding.
269 expect(new Set(stage.headings)).toEqual(new Set([137]));
270 });
271
272 test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest", () => {
273 const stage = playing();
274 stage.step(FRAME);
275 expect(stepsRun(stage)).toBe(2);
276 // 1/30 is 2.13 ticks: the .13 carries, and two frames are still four steps.
277 stage.step(FRAME);
278 expect(stepsRun(stage)).toBe(4);
279 // A frame the clock slept through is clamped: a quarter second, sixteen ticks,
280 // and the loop runs at most eight of them.
281 stage.step(5000);
282 expect(stepsRun(stage)).toBe(12);
283 });
284
285 test("the mouse turns the view, and while scoped it turns it by the zoom's share", () => {
286 const stage = playing();
287 stage.lock(true);
288 const world = (stage.step(FRAME), worldOf(stage)) as World;
289 world.match.phase = "live";
290 world.match.timer = 60;
291 // The one gun in this game with a scope, in this player's hands. Right mouse
292 // is bound to "scope" in `binding.ts`, and the world only listens to it for a
293 // weapon that has a `zoom`.
294 world.me.primary = "awp";
295 world.me.weapon = "awp";
296
297 // 10 counts × 2.2 sensitivity × 0.022° each: 0.484° of yaw, and moving right
298 // turns it down. This is the unscoped path, unchanged by #136.
299 const start = world.me.yaw;
300 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
301 stage.step(FRAME);
302 expect(world.me.scoped).toBe(false);
303 expect(world.me.yaw).toBeCloseTo(start - 0.484, 9);
304
305 // Scoped, by the button a player actually presses: this frame's steps set it.
306 stage.scene.fire("mousedown", { button: 2 });
307 stage.step(FRAME);
308 expect(world.me.scoped).toBe(true);
309
310 // The same hand movement through 40° of view instead of 90°:
311 // 0.484° × tan(20°)/tan(45°) = 0.17616°, so the crosshair crosses the same
312 // fraction of the screen it did unscoped rather than 2.7 times as much.
313 const scopedFrom = world.me.yaw;
314 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
315 stage.step(FRAME);
316 expect(world.me.yaw).toBeCloseTo(scopedFrom - 0.17616159338484194, 9);
317
318 // Letting the button go unscopes on that frame's steps, and the next movement
319 // is the plain sensitivity again — the view itself never jumps, because all
320 // any of this scales is the delta a mouse asked for.
321 stage.window.fire("mouseup", { button: 2 });
322 stage.step(FRAME);
323 expect(world.me.scoped).toBe(false);
324 const plainFrom = world.me.yaw;
325 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
326 stage.step(FRAME);
327 expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9);
328 });
329
330 test("the whole mouse plays the game: the wheel jumps and the thumb buttons do not navigate", () => {
331 const stage = playing();
332 stage.lock(true);
333 const world = (stage.step(FRAME), worldOf(stage)) as World;
334 world.match.phase = "live";
335 world.match.timer = 60;
336 expect(world.me.vel[2]).toBe(0);
337
338 // A notch of the wheel is the jump a lot of people play with.
339 stage.window.fire("wheel", { deltaY: -120 });
340 stage.step(FRAME);
341 expect(world.me.vel[2]).toBeGreaterThan(0);
342
343 // And the two buttons under a thumb are Back and Forward in Edge on Windows:
344 // pressed here they are bound actions and the browser is told to do nothing,
345 // so a five-button mouse mid-round cannot take the page away from the match.
346 let navigated = 0;
347 stage.scene.fire("mousedown", { button: 3, preventDefault: () => void (navigated += 1) });
348 expect(navigated).toBe(1);
349 stage.scene.fire("auxclick", { button: 3, preventDefault: () => void (navigated += 1) });
350 expect(navigated).toBe(2);
351 });
352
353 test("B opens the buy overlay: it is drawn, it keeps the pointer and the shelf takes the focus", () => {
354 const stage = playing();
355 stage.lock(true);
356 stage.press("KeyB");
357 const buy = stage.by("buy");
358 const shelf = stage.by("buyshelf");
359 expect(buy.hidden).toBe(false);
360 // What is in the panel is `buy.test.ts`'s — the shelf, the prices, which tiles
361 // are dead. Here it is that it was drawn at all, that the pointer is still the
362 // game's, and that the focus is on the shelf rather than on a tile: a focused
363 // tile is a tile Space presses, and Space is jump.
364 const grid = shelf.querySelector(".grid");
365 expect(grid?.children.length).toBeGreaterThan(4);
366 expect(grid?.children[0]?.focused).toBe(0);
367 expect(shelf.focused).toBe(1);
368 expect(stage.document.pointerLockElement).toBe(stage.scene);
369 // And the line under it says what opened and what the keys are, in the live
370 // region the page renders: with no cursor to move into the panel, this is what
371 // a screen reader is told instead.
372 const world = (stage.step(FRAME), worldOf(stage));
373 expect(stage.by("buysays").textContent).toContain(`$${world?.me.money}`);
374 expect(stage.by("buysays").textContent).toContain(HINT);
375
376 // A press is wired to the world this frame is simulating: the money goes, the
377 // beep plays, and the panel is drawn again with what is left.
378 const before = world?.me.money ?? 0;
379 grid?.children[0]?.fire("click");
380 expect(world?.me.money).toBeLessThan(before);
381 expect(stage.sounds).toContain("beep");
382 expect(shelf.innerHTML).toContain(`$${world?.me.money}`);
383
384 // Escape closes it, and so does pressing B again — and neither touches the
385 // pointer, which is still on the game it never left.
386 stage.window.fire("keydown", { code: "Escape" });
387 expect(buy.hidden).toBe(true);
388 stage.press("KeyB");
389 expect(buy.hidden).toBe(false);
390 expect(stage.document.pointerLockElement).toBe(stage.scene);
391 expect(stage.scene.locks).toBe(1);
392 });
393
394 test("a number key buys the tile it is printed on, and one the wallet cannot reach buys nothing", () => {
395 const stage = playing();
396 stage.lock(true);
397 stage.step(FRAME);
398 const world = worldOf(stage);
399 if (world === null) throw new Error("the first frame drew nothing");
400 stage.press("KeyB");
401 const shelf = stage.by("buyshelf");
402 const tiles = () => shelf.querySelector(".grid")?.children ?? [];
403 // The fifth tile is the AWP at $4750 and the freeze-time wallet is $800: the
404 // key for it is a key that does nothing, which is what a dead tile is.
405 expect(tiles()[4]?.textContent).toContain("5 · ");
406 const before = world.me.money;
407 stage.press("Digit5");
408 expect(world.me.money).toBe(before);
409 expect(world.me.primary).toBeNull();
410
411 // The first is this side's pistol, and the number buys exactly it: the same
412 // money, the same kit and the same beep a click on that tile costs.
413 const [key, name] = (tiles()[0]?.textContent ?? "").split(" · ");
414 expect(key).toBe("1");
415 stage.press("Digit1");
416 expect(world.me.money).toBeLessThan(before);
417 expect(stage.by("buysays").textContent).toBe(`Bought ${name}. $${world.me.money} left.`);
418 expect(stage.sounds).toContain("beep");
419
420 // And the key is the panel's only while the panel is up: with it closed the
421 // same key is the weapon pocket it has always been, and buys nothing.
422 const spent = world.me.money;
423 stage.press("KeyB");
424 expect(stage.by("buy").hidden).toBe(true);
425 stage.press("Digit1");
426 expect(world.me.money).toBe(spent);
427 });
428
429 test("X buys last round's loadout again, in order, and stops when the money runs out", () => {
430 const stage = playing();
431 stage.lock(true);
432 stage.step(FRAME);
433 const world = worldOf(stage);
434 if (world === null) throw new Error("the first frame drew nothing");
435 const me = world.me;
436 // Round one: a rifle and a vest, bought with the keys, in that order.
437 me.money = 10_000;
438 stage.press("KeyB");
439 const rifle = me.team === "t" ? "ak47" : "m4a4";
440 stage.press("Digit4");
441 stage.press("Digit9");
442 expect(me.primary).toBe(rifle);
443 expect(me.armor).toBeGreaterThan(0);
444 stage.press("KeyB");
445
446 // A round later, with nothing left of it: the same list, in the same order.
447 // The round is scored through the match's own rules and the frames after it
448 // carry the end delay, so the world under test sees a real next freeze time.
449 endRound(world.match, me.team === "t" ? "ct" : "t", "elimination");
450 for (let frame = 0; frame < 60 && world.match.round === 1; frame++) stage.step(250);
451 expect(world.match.round).toBe(2);
452 expect(world.match.phase).toBe("freeze");
453 me.primary = null;
454 me.armor = 0;
455 me.money = WEAPONS[rifle].price + 100;
456 stage.press("KeyX");
457 expect(world.me.primary).toBe(rifle);
458 // The vest was next and the wallet could not reach it, so the line says so —
459 // in the HUD's message line, where a player who never opened the panel is
460 // looking, and in the panel's own line.
461 expect(me.armor).toBe(0);
462 const said = world.messages[world.messages.length - 1]?.text ?? "";
463 expect(said).toContain("Rebought");
464 expect(said).toContain("not enough for Kevlar");
465 expect(stage.by("buysays").textContent).toBe(said);
466 expect(stage.sounds).toContain("beep");
467
468 // And a rebuy outside the freeze time is not a rebuy: `canBuy` is the one
469 // gate, whether the panel is open or the key was pressed on its own.
470 world.match.phase = "live";
471 const left = me.money;
472 stage.press("KeyX");
473 expect(me.money).toBe(left);
474 });
475
476 test("the buy overlay open suppresses fire, but not movement", () => {
477 const stage = playing();
478 stage.step(FRAME);
479 const world = worldOf(stage);
480 if (world === null) throw new Error("the first frame drew nothing");
481 stage.press("KeyB");
482 expect(stage.by("buy").hidden).toBe(false);
483
484 // A thumb on the fire pad and a finger on W, and the round goes live under
485 // both: the freeze time is what the buy panel needs, not what this is about.
486 button(stage, "fire").fire("pointerdown");
487 stage.hold("KeyW", true);
488 world.match.phase = "live";
489 world.match.timer = 60;
490 const mag = world.me.ammo[world.me.weapon]?.mag ?? 0;
491 stage.step(FRAME);
492
493 expect(world.me.ammo[world.me.weapon]?.mag).toBe(mag);
494 expect(Math.hypot(world.me.vel[0], world.me.vel[1])).toBeGreaterThan(0);
495 // And the panel let itself out of the way as soon as buying stopped being legal.
496 expect(stage.by("buy").hidden).toBe(true);
497
498 // The same held input, one frame later, with nothing in front of it.
499 stage.step(FRAME);
500 expect(world.me.ammo[world.me.weapon]?.mag).toBe(mag - 1);
501 expect(stage.sounds.some((s) => s.startsWith("shot:"))).toBe(true);
502 });
503
504 test("losing the pointer pauses a running match, buy panel open or not", async () => {
505 const stage = playing();
506 stage.lock(true);
507 stage.lock(false);
508 expect(stage.by("pause").hidden).toBe(false);
509
510 // Resume asks for the pointer back — and the panel goes when the browser has
511 // answered, which is a promise and so a turn of the loop later.
512 stage.by("resume").fire("click");
513 await settled();
514 expect(stage.by("pause").hidden).toBe(true);
515 expect(stage.scene.locks).toBe(2);
516
517 // The buy menu no longer drops the lock, so a lock that goes while it is open
518 // is a real loss — alt-tab, a browser policy — and the match stops behind the
519 // panel, which closes rather than sitting over the pause.
520 stage.lock(true);
521 stage.press("KeyB");
522 expect(stage.by("buy").hidden).toBe(false);
523 expect(stage.document.pointerLockElement).toBe(stage.scene);
524 stage.lock(false);
525 expect(stage.by("buy").hidden).toBe(true);
526 expect(stage.by("pause").hidden).toBe(false);
527 });
528
529 test("a refused Resume stays paused and says so, rather than a match with a dead mouse", async () => {
530 const stage = playing();
531 stage.lock(true);
532 stage.lock(false);
533 expect(stage.by("pause").hidden).toBe(false);
534
535 // Chromium — Edge included — will not grant a lock for about a second after
536 // one was released, which is exactly how fast a person presses Resume after
537 // Escape. The old code hid the panel on the way out and never read the
538 // answer: the match ran on with no look, no trigger and nothing to click.
539 stage.scene.refuseLock = true;
540 stage.by("resume").fire("click");
541 await settled();
542 expect(stage.by("pause").hidden).toBe(false);
543 expect(stage.by("esc").textContent).toContain("press Resume again");
544 expect(stage.by("resume").focused).toBeGreaterThan(0);
545
546 // Pressed again a moment later, when the browser will give it: back in.
547 stage.scene.refuseLock = false;
548 stage.by("resume").fire("click");
549 await settled();
550 expect(stage.by("pause").hidden).toBe(true);
551 });
552
553 test("Escape pauses and keeps the screen where the browser hands the key over", async () => {
554 const stage = playing();
555 stage.press("KeyF");
556 stage.lock(true);
557 const box = stage.by("stage");
558 expect(stage.document.fullscreenElement).toBe(box);
559 // The keys were asked for on the way into full screen, and granted a microtask
560 // later: from here on Escape is the page's and not the browser's — and so is
561 // every key this game binds, because on Windows the browser's own shortcuts
562 // are Ctrl and a letter, and Ctrl here is crouch: crouch-and-forward is
563 // Ctrl+W, which closes the tab, and crouch-and-reload is Ctrl+R, which
564 // reloads the match away.
565 const locked = stage.keyboard.locked ?? [];
566 expect(locked[0]).toBe("Escape");
567 for (const code of ["KeyW", "KeyR", "KeyD", "Digit1", "Tab"]) expect(locked).toContain(code);
568 // Asked for once each, however many tables name it.
569 expect(new Set(locked).size).toBe(locked.length);
570 await Promise.resolve();
571
572 stage.window.fire("keydown", { code: "Escape" });
573 // Paused, the pointer let go — under the lock the browser does not do that for
574 // us — and the screen still ours, which is the whole point of holding the key.
575 expect(stage.by("pause").hidden).toBe(false);
576 expect(stage.document.pointerLockElement).toBe(null);
577 expect(stage.document.fullscreenElement).toBe(box);
578 expect(stage.by("esc").textContent).toContain("kept the screen");
579 expect(stage.by("resume").focused).toBe(1);
580
581 // And Resume goes back into the match without asking for a screen it already
582 // has: one pointer lock, no second `requestFullscreen`.
583 stage.by("resume").fire("click");
584 await settled();
585 expect(stage.by("pause").hidden).toBe(true);
586 expect(box.fullscreens).toBe(1);
587 expect(stage.scene.locks).toBe(2);
588 });
589
590 test("a browser that keeps Escape for itself pauses just the same, and Resume puts the screen back", () => {
591 // Safari on a Mac: no Keyboard Lock API, so Escape leaves full screen whatever
592 // the page asks — the window drops out of it with the whole animation, which is
593 // what this test is about.
594 const stage = playing({ keyboardLock: false });
595 stage.press("KeyF");
596 stage.lock(true);
597 const box = stage.by("stage");
598 expect(box.fullscreens).toBe(1);
599 expect(stage.keyboard.grabs).toBe(0);
600
601 // Escape, as that browser delivers it: the screen is gone before the page hears
602 // the key, and the pointer goes with it.
603 stage.document.fullscreenElement = null;
604 stage.document.fire("fullscreenchange");
605 stage.lock(false);
606 stage.window.fire("keydown", { code: "Escape" });
607
608 expect(stage.by("pause").hidden).toBe(false);
609 expect(stage.by("esc").textContent).toContain("Resume puts it back");
610 // Resume is a gesture, which is the only thing a browser will give a screen
611 // back to: the stage goes full screen again and the pointer with it.
612 stage.by("resume").fire("click");
613 expect(stage.document.fullscreenElement).toBe(box);
614 expect(box.fullscreens).toBe(2);
615 expect(stage.scene.locks).toBe(2);
616
617 // A player who left full screen on purpose is not dragged back into it.
618 stage.press("KeyF");
619 expect(stage.document.fullscreenElement).toBe(null);
620 stage.lock(false);
621 expect(stage.by("pause").hidden).toBe(false);
622 stage.by("resume").fire("click");
623 expect(stage.document.fullscreenElement).toBe(null);
624 expect(box.fullscreens).toBe(2);
625 });
626
627 test("P pauses too, and a key no browser wants costs no screen", () => {
628 const stage = playing({ keyboardLock: false });
629 stage.press("KeyF");
630 stage.lock(true);
631 stage.press("KeyP");
632 expect(stage.by("pause").hidden).toBe(false);
633 expect(stage.document.fullscreenElement).toBe(stage.by("stage"));
634 expect(stage.document.pointerLockElement).toBe(null);
635 // Nothing was taken, and the line in the panel says as much rather than
636 // promising to put a screen back that never went anywhere.
637 expect(stage.by("esc").textContent).toContain("Esc or P pauses");
638 });
639
640 test("Escape closes the buy menu before it is a pause", () => {
641 const stage = playing();
642 stage.lock(true);
643 stage.press("KeyB");
644 expect(stage.by("buy").hidden).toBe(false);
645 stage.window.fire("keydown", { code: "Escape" });
646 expect(stage.by("buy").hidden).toBe(true);
647 expect(stage.by("pause").hidden).toBe(true);
648 // The next one is the pause, now that there is nothing in front of it.
649 stage.window.fire("keydown", { code: "Escape" });
650 expect(stage.by("pause").hidden).toBe(false);
651 // A pause is not a second pause: the key held down does not stack panels or
652 // steal the focus again.
653 stage.window.fire("keydown", { code: "Escape", repeat: true });
654 expect(stage.by("resume").focused).toBe(1);
655 });
656
657 test("leaving the match puts the menu back and stops the frame", () => {
658 const stage = playing();
659 stage.step(FRAME);
660 expect(stage.draws.length).toBe(1);
661 stage.by("quit").fire("click");
662 expect(stage.by("menu").hidden).toBe(false);
663 expect(stage.by("touch").hidden).toBe(true);
664 stage.step(FRAME);
665 expect(stage.draws.length).toBe(1);
666 });
667
668 // --- a match ends on a scoreboard ------------------------------------------
669
670 /**
671 * Play the match out. The last round is scored through the match's own rules —
672 * `endRound` is what an elimination or a defuse calls — and the frames after it
673 * carry the end delay, which is the clock that turns a won round into
674 * `match_over`. Nothing here writes a phase: the frame under test sees exactly
675 * what a played-out match leaves.
676 *
677 * A quarter of a second a frame is the loop's own cap (eight 64 Hz steps), so
678 * five seconds of end delay is forty frames rather than a hundred and fifty.
679 */
680 function finish(stage: FakeStage, winner: "t" | "ct"): World {
681 const world = worldOf(stage);
682 if (world === null) throw new Error("no match is running");
683 world.match.score[winner] = world.match.rules.winScore - 1;
684 endRound(world.match, winner, "elimination");
685 for (let frame = 0; frame < 60 && world.match.phase !== "match_over"; frame++) stage.step(250);
686 if (world.match.phase !== "match_over") throw new Error("the match never ended");
687 return world;
688 }
689
690 /** A match played to its end, with something on the player's own line to read. */
691 function ended(winner: "t" | "ct" = "ct"): { stage: FakeStage; world: World } {
692 const stage = playing();
693 stage.lock(true);
694 stage.step(FRAME);
695 const world = worldOf(stage);
696 if (world === null) throw new Error("the first frame drew nothing");
697 Object.assign(world.me, { kills: 7, deaths: 3, headshots: 2, damage: 1234.6 });
698 return { stage, world: finish(stage, winner) };
699 }
700
701 test("a match ends on the summary, not the menu: the run stops and the way forward has the focus", () => {
702 const { stage } = ended();
703 expect(stage.by("summary").hidden).toBe(false);
704 expect(stage.by("menu").hidden).toBe(true);
705 expect(stage.by("pause").hidden).toBe(true);
706 expect(stage.by("touch").hidden).toBe(true);
707 // Play again is one press of Enter from wherever the pointer was, and the
708 // pointer itself is given back: the match is over, so the mouse is the page's.
709 expect(stage.by("again").focused).toBe(1);
710 expect(stage.document.pointerLockElement).toBe(null);
711 // The run stopped: the frames keep coming and nothing more is drawn.
712 const drawn = stage.draws.length;
713 stage.step(FRAME);
714 stage.step(FRAME);
715 expect(stage.draws.length).toBe(drawn);
716 });
717
718 /**
719 * Let everything in flight settle and the frames send whatever is still
720 * waiting, then hand back the last body that went to the server. A post is one
721 * attempt at a time and a test's frames are all one synchronous block, so the
722 * result of the last round leaves on the frame after the one before it lands.
723 */
724 async function lastPost(stage: FakeStage): Promise<Result> {
725 for (let attempt = 0; attempt < 4; attempt++) {
726 await settled();
727 stage.step(FRAME);
728 }
729 await settled();
730 return JSON.parse(stage.posts[stage.posts.length - 1]?.body ?? "{}");
731 }
732
733 test("the summary is the final score, your own line and both teams' rows", async () => {
734 const { stage, world } = ended("ct");
735 // Who won and by how much, in the words `summary.ts` writes them, against the
736 // world the match actually ended in.
737 const posted = resultOf(world, "irrelevant", "you", true);
738 expect(stage.by("sumtitle").textContent).toBe("Counter-Terrorists win");
739 expect(stage.by("sumscore").textContent).toContain(`${posted.scoreMine}–${posted.scoreTheirs}`);
740 expect(stage.by("sumscore").textContent).toContain("you won");
741 expect(stage.by("sumscore").textContent).toContain("Counter-Terrorists");
742
743 // Your own line, and it is the numbers the server was posted — the same
744 // object built once, so the screen and the leaderboard cannot disagree.
745 const body = await lastPost(stage);
746 expect(body.finished).toBe(true);
747 expect(parseResult(body)).not.toBeNull();
748 const mine = stage.by("sumyou").textContent ?? "";
749 expect(mine).toContain(`${body.kills} kills`);
750 expect(mine).toContain(`${body.deaths} deaths`);
751 expect(mine).toContain(`${body.damage} damage`);
752 expect(mine).toContain(`${headshotShare(body.headshots, body.kills)} headshots`);
753 expect(mine).toContain(`${body.scoreMine} rounds won`);
754 // Rounded once, in `summary.ts`, so the page and the row carry one number.
755 expect(body.damage).toBe(1235);
756 expect(stage.by("sumyou").textContent).toBe(yourLine({
757 result: resultOf(world, "irrelevant", "you", true), team: world.me.team,
758 winner: world.match.matchWinner, won: true, lines: [],
759 }));
760
761 // And everybody's rows: ten players, both sides, yours marked, in the order
762 // the HUD's board draws them — most kills first, which here is you.
763 const board = stage.by("sumboard").innerHTML;
764 for (const player of world.players) expect(board).toContain(player.name);
765 expect(board).toContain("<b>(you)</b>");
766 const rows = board.slice(board.indexOf("<tbody>")).split("<tr").slice(1);
767 expect(rows.length).toBe(world.players.length);
768 expect(rows[0]).toContain("(you)");
769 expect(board).toContain(`>CT</span>`);
770 expect(board).toContain(`>T</span>`);
771 });
772
773 test("a match lost says so, and the side that won is the side that won", () => {
774 const { stage } = ended("t");
775 expect(stage.by("sumtitle").textContent).toBe("Terrorists win");
776 expect(stage.by("sumscore").textContent).toContain("you lost");
777 });
778
779 test("play again is another match on the same settings, and back to the page is the menu", () => {
780 const { stage } = ended();
781 stage.by("length").value = "long";
782 stage.by("again").fire("click");
783 // A match, not a menu: the summary is gone, the pointer is asked for again,
784 // and it is a new match of its own — a fresh id, so a fresh row.
785 expect(stage.by("summary").hidden).toBe(true);
786 expect(stage.by("menu").hidden).toBe(true);
787 expect(worldOf(stage)?.match.phase).not.toBe("match_over");
788 expect(stage.ids.length).toBe(2);
789 // The settings are the menu's, untouched and unread until Play again asks:
790 // the field says thirteen rounds, so the new match is a thirteen-round one.
791 expect(worldOf(stage)?.match.rules.winScore).toBe(13);
792
793 // And the other way out is the page: the menu is back and nothing is running.
794 const over = finish(stage, "ct");
795 expect(over.match.phase).toBe("match_over");
796 stage.by("leave").fire("click");
797 expect(stage.by("summary").hidden).toBe(true);
798 expect(stage.by("menu").hidden).toBe(false);
799 expect(stage.by("play").focused).toBe(1);
800 const drawn = stage.draws.length;
801 stage.step(FRAME);
802 expect(stage.draws.length).toBe(drawn);
803 });
804
805 test("the summary says whether the match reached the leaderboard, and says it once it has", async () => {
806 const { stage } = ended();
807 // Posted and not yet answered: the screen says it is on its way, never that
808 // it landed. `settled` lets the fetch the last frame made come back.
809 expect(stage.by("sumsaved").textContent).toBe(SENDING);
810 // The round before it settles first — one attempt is ever in flight — and the
811 // line still says the match is not on the board, because it is not.
812 await settled();
813 expect(stage.by("sumsaved").textContent).toBe(SENDING);
814 stage.step(FRAME);
815 await settled();
816 expect(stage.by("sumsaved").textContent).toBe(SAVED);
817 });
818
819 test("dying moves the camera behind a teammate, and fire moves it behind the next", () => {
820 const stage = playing();
821 stage.lock(true);
822 stage.step(FRAME);
823 const world = worldOf(stage);
824 if (world === null) throw new Error("the first frame drew nothing");
825
826 // Alive, the camera is your own eye and every other living body is drawn.
827 const mine = [...(stage.draws[0]?.scene.camera.pos ?? [])];
828 expect(stage.draws[0]?.scene.instanceCount).toBe(10);
829
830 world.me.alive = false;
831 stage.step(FRAME);
832 const watched = [...(stage.draws[1]?.scene.camera.pos ?? [])];
833 // Somewhere else entirely, and one body fewer: the one the camera is inside.
834 expect(watched).not.toEqual(mine);
835 expect(stage.draws[1]?.scene.instanceCount).toBe(9);
836 // And it is a teammate's eye, not a bot from the other side of the map.
837 const mates = world.players.filter((p) => p.alive && p.team === world.me.team);
838 expect(mates.some((p) => p.pos[0] === watched[0] && p.pos[1] === watched[1])).toBe(true);
839
840 // The trigger is the only control a dead player has left: it walks along.
841 button(stage, "fire").fire("pointerdown");
842 stage.step(FRAME);
843 const next = [...(stage.draws[2]?.scene.camera.pos ?? [])];
844 expect(next).not.toEqual(watched);
845 expect(mates.some((p) => p.pos[0] === next[0] && p.pos[1] === next[1])).toBe(true);
846 // The HUD says whose eyes they are.
847 expect(stage.hud.paint.texts.some((t) => t.startsWith("Watching "))).toBe(true);
848 });
849
850 test("a phone gets the touch pads, a mouse does not", () => {
851 expect(playing({ coarse: true }).by("touch").hidden).toBe(false);
852 expect(playing().by("touch").hidden).toBe(true);
853 });
854
855 test("one POST per round scored, in the shape the server accepts", async () => {
856 const stage = playing();
857 stage.step(FRAME);
858 expect(stage.posts.length).toBe(1);
859
860 const first = stage.posts[0];
861 expect(first?.url).toBe("/api/match");
862 expect(first?.method).toBe("POST");
863 expect(first?.headers["content-type"]).toBe("application/json");
864 const body = JSON.parse(first?.body ?? "{}");
865 expect(parseResult(body)).not.toBeNull();
866 expect(body.matchId).toBe(stage.ids[0]);
867 expect(body.rounds).toBe(0);
868 expect(body.finished).toBe(false);
869
870 // The leaderboard the server sent back is written into the page.
871 await Promise.resolve();
872 await Promise.resolve();
873 expect(stage.by("stats").innerHTML).toBe("<h2>Leaderboard</h2>");
874
875 // Three more frames and nothing new to say: still one POST.
876 stage.step(FRAME);
877 stage.step(FRAME);
878 stage.step(FRAME);
879 expect(stage.posts.length).toBe(1);
880
881 // A round goes on the board: exactly one more. The post before it is let
882 // settle first, because there is at most one attempt in flight — in a browser
883 // the answer is milliseconds away and the next round is minutes.
884 const world = worldOf(stage);
885 if (world === null) throw new Error("the first frame drew nothing");
886 world.match.score.ct = 1;
887 stage.step(FRAME);
888 stage.step(FRAME);
889 expect(stage.posts.length).toBe(2);
890 await settled();
891 const second = JSON.parse(stage.posts[1]?.body ?? "{}");
892 expect(parseResult(second)).not.toBeNull();
893 expect(second.rounds).toBe(1);
894 expect(second.finished).toBe(false);
895
896 // The last one says the match is over.
897 world.match.score.ct = 2;
898 world.match.phase = "match_over";
899 stage.step(FRAME);
900 expect(stage.posts.length).toBe(3);
901 expect(JSON.parse(stage.posts[2]?.body ?? "{}").finished).toBe(true);
902 });
903
904 test("a leaderboard that cannot be reached is not a reason to stop playing", async () => {
905 const stage = playing();
906 stage.answer = () => Promise.reject(new Error("offline"));
907 stage.step(FRAME);
908 expect(stage.posts.length).toBe(1);
909 await Promise.resolve();
910 await Promise.resolve();
911 // The page was not overwritten with anything, and the match ran on.
912 expect(stage.by("stats").innerHTML).toBe("");
913 const world = worldOf(stage);
914 if (world === null) throw new Error("the first frame drew nothing");
915
916 // A fetch that throws where it stands is the same kind of nothing.
917 stage.answer = () => {
918 throw new Error("blocked");
919 };
920 world.match.score.t = 1;
921 stage.step(FRAME);
922 stage.step(FRAME);
923 expect(stage.posts.length).toBe(2);
924 expect(stage.draws.length).toBe(3);
925 expect(stepsRun(stage)).toBe(6);
926 expect(stage.by("menu").hidden).toBe(true);
927 });
928
929 // --- a result that did not land says so and is tried again -----------------
930 //
931 // The queue itself is not in here: `report.test.ts` holds what one answer does
932 // to the round in the page's hand — the backoff, the cap, the newest round
933 // winning — against a fetch and a clock of its own. What is below is the
934 // wiring: that the frame is what asks, and that what comes back lands in the
935 // page's live region and in the leaderboard beside it.
936
937 /**
938 * Answer the next posts with these statuses, in order; `"throw"` is a fetch
939 * that never answered, and the last entry answers everything after it.
940 */
941 function answering(stage: FakeStage, ...script: (number | "throw")[]): void {
942 let at = 0;
943 stage.answer = () => {
944 const status = script[Math.min(at, script.length - 1)];
945 at += 1;
946 if (status === "throw" || status === undefined) return Promise.reject(new Error("offline"));
947 return Promise.resolve({
948 ok: status === 200,
949 status,
950 text: () => Promise.resolve("<h2>Leaderboard</h2>"),
951 });
952 };
953 }
954
955 /** Let the fetch behind the last post settle. Nothing here waits on a frame. */
956 const settled = (): Promise<void> => Bun.sleep(0);
957
958 /** What the line by the leaderboard says right now. */
959 const line = (stage: FakeStage): string | null => stage.by("saved").textContent;
960
961 test("the frame is what retries: the wait passes, the same round goes again, the page says so", async () => {
962 const stage = playing();
963 answering(stage, "throw", 200);
964 stage.step(FRAME);
965 expect(stage.posts.length).toBe(1);
966 await settled();
967 // Nothing was written into the page, and the line admits it in plain words.
968 expect(stage.by("stats").innerHTML).toBe("");
969 expect(line(stage)).toBe(RETRYING);
970
971 // Frames keep coming and nothing is retried until the wait is up: a retry in
972 // a game loop is exactly where a flood would be written by accident.
973 stage.step(FRAME);
974 stage.step(FRAME);
975 expect(stage.posts.length).toBe(1);
976
977 // The wait runs out and the same round goes again — once, byte for byte.
978 stage.step(FIRST_WAIT);
979 expect(stage.posts.length).toBe(2);
980 expect(stage.posts[1]?.body).toBe(stage.posts[0]?.body);
981 await settled();
982 expect(line(stage)).toBe(SAVED);
983 expect(stage.by("stats").innerHTML).toBe("<h2>Leaderboard</h2>");
984
985 // And it is done: there is nothing pending, so nothing is posted again.
986 stage.step(LONGEST_WAIT);
987 stage.step(LONGEST_WAIT);
988 expect(stage.posts.length).toBe(2);
989 });
990
991 test("F and the button put the stage on the whole screen, and take it back", () => {
992 const stage = start(fakeStage());
993 const full = stage.by("full");
994 const box = stage.by("stage");
995 // Shown by the entry, not by the server: a control that needs a script is
996 // rendered hidden and revealed by the script that makes it work.
997 expect(full.hidden).toBe(false);
998 expect(full.getAttribute("aria-pressed")).toBe(null);
999
1000 stage.press("KeyF");
1001 expect(stage.document.fullscreenElement).toBe(box);
1002 expect(box.fullscreens).toBe(1);
1003 expect(full.getAttribute("aria-pressed")).toBe("true");
1004 expect(full.getAttribute("aria-label")).toBe("Leave full screen");
1005
1006 stage.press("KeyF");
1007 expect(stage.document.fullscreenElement).toBe(null);
1008 expect(full.getAttribute("aria-pressed")).toBe("false");
1009 expect(full.getAttribute("aria-label")).toBe("Full screen");
1010
1011 // The button says the same thing with a thumb, which is the only way in on a
1012 // phone: there is no keyboard there and the pointer cannot be locked either.
1013 full.fire("click");
1014 expect(stage.document.fullscreenElement).toBe(box);
1015 expect(box.fullscreens).toBe(2);
1016 full.fire("click");
1017 expect(stage.document.fullscreenElement).toBe(null);
1018 });
1019
1020 test("the screen is the browser's: Escape is heard, and a browser that will not is not lied about", () => {
1021 const stage = start(fakeStage());
1022 stage.press("KeyF");
1023 // Escape, which no code of ours ran. The page hears it from the event.
1024 stage.document.fullscreenElement = null;
1025 stage.document.fire("fullscreenchange");
1026 expect(stage.by("full").getAttribute("aria-pressed")).toBe("false");
1027
1028 // And a browser with no fullscreen at all keeps the button hidden rather than
1029 // showing one that does nothing.
1030 const cannot = start(fakeStage({ fullscreen: false }));
1031 expect(cannot.by("full").hidden).toBe(true);
1032 cannot.press("KeyF");
1033 expect(cannot.document.fullscreenElement).toBe(null);
1034 expect(cannot.by("stage").fullscreens).toBe(0);
1035 });
1036
1037 test("no WebGL2 is a sentence on the page, not an exception", () => {
1038 const stage = start(fakeStage({ webgl: false }));
1039 expect(stage.by("menu").innerHTML).toContain("No WebGL2");
1040 expect(stage.by("menu").hidden).toBe(false);
1041 // Nothing was started: no frame was asked for and no listener attached.
1042 expect(stage.frames.length).toBe(0);
1043 expect(stage.window.listening.size).toBe(0);
1044 expect(stage.document.listening.size).toBe(0);
1045 expect(stage.draws.length).toBe(0);
1046 // And it cost no atlas: a browser that cannot draw pays for nothing.
1047 expect(stage.atlases.length).toBe(0);
1048 });
1049
1050 test("a refused shader is a sentence and a line to paste, not a black canvas", () => {
1051 const log = "ERROR: 0:7: 'texture' : no matching overloaded function found";
1052 const stage = painted(start(fakeStage({ refuseShaders: log })));
1053 // The driver took the context and then refused the program, which is what
1054 // ANGLE on Direct3D does. The menu says so in words.
1055 expect(stage.by("menu").innerHTML).toContain(NO_PICTURE);
1056 expect(stage.by("menu").innerHTML).toContain("Nothing else on the page is affected");
1057 // And the line under it carries the browser, the driver and what it said —
1058 // which is the whole of what a bug report from a Windows machine was missing.
1059 const line = stage.by("supportline").textContent ?? "";
1060 expect(line).toContain("Edge 128 on Windows");
1061 expect(line).toContain("Direct3D11");
1062 expect(line).toContain("no matching overloaded function found");
1063 // The page is alive: nothing is drawn, nothing throws on the next frame, and
1064 // the atlas that was paid for is the one that was painted.
1065 expect(stage.draws.length).toBe(0);
1066 stage.step(FRAME);
1067 expect(stage.by("menu").hidden).toBe(false);
1068 });
1069
1070 test("the line says what this browser is, and says so before anything is built", () => {
1071 const stage = start(fakeStage());
1072 const line = stage.by("supportline").textContent ?? "";
1073 expect(line).toBe("Edge 128 on Windows · " + DRIVER + " · 960×540 at 1× dpr");
1074 // A browser that refuses `WEBGL_debug_renderer_info` is named as one.
1075 const quiet = start(fakeStage({ driver: null }));
1076 expect(quiet.by("supportline").textContent).toContain("driver not named");
1077 // Even a browser with no WebGL2 at all has the line: it is the report that
1078 // says which of the two happened.
1079 const none = start(fakeStage({ webgl: false }));
1080 expect(none.by("supportline").textContent).toContain("Edge 128 on Windows");
1081 });
1082
1083 test("the copy control copies the line it is under, and hides where there is no clipboard", () => {
1084 const stage = start(fakeStage());
1085 const button = stage.by("copysupport");
1086 expect(button.hidden).toBe(false);
1087 button.fire("click");
1088 expect(stage.copied).toEqual([stage.by("supportline").textContent ?? ""]);
1089 });
1090
1091 test("every mesh name a scene of this game can place is handed in at construction", () => {
1092 // The renderer keeps what it is given and resolves nothing, so a name this
1093 // table misses is an exception on the frame a player first holds that gun.
1094 const meshes = gameMeshes();
1095 expect(Object.keys(meshes).sort()).toEqual([
1096 "bomb", "cloud", "flash", "grenade", "knife", "level", "pistol", "player", "rifle", "smg",
1097 "smoke", "sniper",
1098 ]);
1099 // Every kind a weapon can be, and the kind `scene.ts` falls back to.
1100 for (const weapon of Object.values(WEAPONS)) expect(meshes[weapon.kind]).toBeDefined();
1101 expect(meshes["rifle"]).toBeDefined();
1102 // And each one is geometry, not an empty buffer.
1103 for (const [name, mesh] of Object.entries(meshes)) {
1104 expect(mesh.index.length).toBeGreaterThan(0);
1105 expect(mesh.position.length).toBe((mesh.index.length / 6) * 4 * 3);
1106 expect(name).toMatch(/^[a-z]+$/);
571107 }
581108 });
591109
60 test("the ids main.ts writes into the page are ones it actually writes, not stray allowances", () => {
61 for (const id of WRITTEN_BY_MAIN_TS) {
62 expect(mainSource).toContain(`id="${id}"`);
63 // Never on the page gamePage() renders — an id in both places would mean
64 // the allow-list is hiding a real duplicate rather than excusing one.
65 expect(html).not.toContain(`id="${id}"`);
1110 test("the map field chooses the map, and a generated one costs one mesh", () => {
1111 const stage = painted(start(fakeStage()));
1112 // de_forge is what the meshes were built from, so nothing is swapped for it.
1113 stage.by("play").fire("click");
1114 expect(worldOf(stage)?.level.name).toBe("de_forge");
1115 expect(stage.replaced).toEqual([]);
1116 expect(stage.by("onmap").textContent).toBe("Playing de_forge.");
1117
1118 // Generated: the level is carved from this match's id, and the renderer is
1119 // handed that level's geometry under the name it already holds — one mesh,
1120 // not a second renderer.
1121 stage.by("quit").fire("click");
1122 (stage.by("map") as unknown as { value: string }).value = "generated";
1123 stage.by("play").fire("click");
1124 const level = worldOf(stage)?.level;
1125 expect(level?.name).toBe(nameOf(seedFrom(stage.ids[stage.ids.length - 1] as string)));
1126 expect(stage.replaced.map((r) => r.name)).toEqual(["level"]);
1127 expect(stage.replaced[0]?.mesh.index.length).toBe(gameMeshes(level as Level).level?.index.length);
1128 expect(stage.by("onmap").textContent).toBe(`Playing ${level?.name}.`);
1129
1130 // And a value nobody rendered is de_forge, like an unknown anything here.
1131 stage.by("quit").fire("click");
1132 (stage.by("map") as unknown as { value: string }).value = "de_moon";
1133 stage.by("play").fire("click");
1134 expect(worldOf(stage)?.level.name).toBe("de_forge");
1135 // The geometry went back to de_forge's with it: two swaps, both of the level.
1136 expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]);
1137 });
1138
1139 // --- a map you can keep ---------------------------------------------------
1140
1141 /** One map's name, base36 of a seed, the way the page hands them out. */
1142 const MAP = "gen_14u5j6g";
1143
1144 test("the map Play will give you is named before Play, and named again as a link", () => {
1145 const stage = painted(start(fakeStage()));
1146 // Nothing touched: de_forge, named and linked, and no id drawn to name it.
1147 expect(stage.by("willplay").textContent).toBe("de_forge");
1148 expect(stage.by("copymap").attributes.href).toBe("/?map=de_forge");
1149 expect(stage.ids.length).toBe(0);
1150
1151 // A new map every match: the id that map is carved from is drawn now, so the
1152 // name is on the screen *before* anything is played — and the link beside it
1153 // is that map by name, not another new one.
1154 stage.by("map").value = "generated";
1155 stage.by("map").fire("change");
1156 expect(stage.ids.length).toBe(1);
1157 const name = nameOf(seedFrom(stage.ids[0] as string));
1158 expect(stage.by("willplay").textContent).toBe(name);
1159 expect(stage.by("copymap").attributes.href).toBe(`/?map=${name}`);
1160
1161 // And it is the map that is played: the promise the menu made is kept, out of
1162 // the id it had already drawn rather than a second one.
1163 stage.by("play").fire("click");
1164 expect(worldOf(stage)?.level.name).toBe(name);
1165 expect(stage.ids.length).toBe(1);
1166 expect(stage.by("mapname").textContent).toBe(name);
1167 expect(stage.by("mapname").attributes.href).toBe(`/?map=${name}`);
1168
1169 // Back at the menu, the line is about the next match again: another map, its
1170 // own name, its own link, and the one just played still named under the stage.
1171 stage.by("quit").fire("click");
1172 expect(stage.ids.length).toBe(2);
1173 const next = nameOf(seedFrom(stage.ids[1] as string));
1174 expect(next).not.toBe(name);
1175 expect(stage.by("willplay").textContent).toBe(next);
1176 expect(stage.by("mapname").textContent).toBe(name);
1177 });
1178
1179 test("a map by name is that map, and a name that is not one is de_forge and says so", () => {
1180 const stage = painted(start(fakeStage()));
1181 stage.by("mapseed").value = MAP;
1182 stage.by("mapseed").fire("input");
1183 expect(stage.by("willplay").textContent).toBe(MAP);
1184 expect(stage.by("copymap").attributes.href).toBe(`/?map=${MAP}`);
1185 // A name needs no id: the map is the seed the name means and nothing else.
1186 expect(stage.ids.length).toBe(0);
1187
1188 stage.by("play").fire("click");
1189 const level = worldOf(stage)?.level;
1190 expect(level?.name).toBe(MAP);
1191 // The same cells `generateLevel` carves from that seed — the field is a seed,
1192 // not a label put on whatever the match would have made anyway.
1193 expect(level?.grid.cells).toEqual(generateLevel(seedOf(MAP) as number).grid.cells);
1194 // The match is still the match: a fresh id seeded the round it was played in.
1195 expect(stage.ids.length).toBe(1);
1196 expect(worldOf(stage)?.level.name).toBe(MAP);
1197
1198 // The name wins over the select, because a person who typed one meant it.
1199 stage.by("quit").fire("click");
1200 stage.by("map").value = "generated";
1201 stage.by("map").fire("change");
1202 expect(stage.by("willplay").textContent).toBe(MAP);
1203 stage.by("play").fire("click");
1204 expect(worldOf(stage)?.level.name).toBe(MAP);
1205
1206 // And junk is de_forge with a line saying so: `gen_0ya` is `gen_ya` written
1207 // badly, which is a typo and not a map. Never an error, never a refusal.
1208 stage.by("quit").fire("click");
1209 stage.by("mapseed").value = "gen_0ya";
1210 stage.by("mapseed").fire("input");
1211 expect(stage.by("onmap").textContent).toBe(NOT_A_MAP);
1212 expect(stage.by("willplay").textContent).toBe("de_forge");
1213 stage.by("play").fire("click");
1214 expect(worldOf(stage)?.level.name).toBe("de_forge");
1215 expect(stage.by("onmap").textContent).toBe(NOT_A_MAP);
1216 expect(stage.by("menu").hidden).toBe(true);
1217 });
1218
1219 test("what is posted says which map it was played on", async () => {
1220 // The leaderboard's rows used to mix layouts silently. The body the page
1221 // sends now names the map, and it is the map the world was carved from —
1222 // whatever the Map control was left on afterwards.
1223 const stage = painted(start(fakeStage()));
1224 stage.by("mapseed").value = MAP;
1225 stage.by("mapseed").fire("input");
1226 stage.by("play").fire("click");
1227 stage.lock(true);
1228 stage.step(FRAME);
1229 finish(stage, "ct");
1230 const named = await lastPost(stage);
1231 expect(named.map).toBe(MAP);
1232 expect(parseResult(named)?.map).toBe(MAP);
1233
1234 // And a match on de_forge says de_forge rather than nothing.
1235 const plain = await lastPost(ended().stage);
1236 expect(plain.map).toBe("de_forge");
1237 expect(parseResult(plain)?.map).toBe("de_forge");
1238 });
1239
1240 test("copy this map is a link first and a clipboard second", async () => {
1241 const LABEL = "Copy this map's link";
1242 const stage = fakeStage();
1243 // The label the page renders, so the control can be put back to it.
1244 stage.by("copymap").textContent = LABEL;
1245 painted(start(stage));
1246 stage.by("mapseed").value = MAP;
1247 stage.by("mapseed").fire("input");
1248
1249 // A click with a clipboard behind it copies the whole URL — origin and all,
1250 // because a link is pasted into a message and not into this page — and the
1251 // navigation is the one thing it takes back.
1252 let prevented = 0;
1253 const click = (): void => stage.by("copymap").fire("click", { preventDefault: () => void (prevented += 1) });
1254 click();
1255 expect(prevented).toBe(1);
1256 expect(stage.copied).toEqual([`${ORIGIN}/?map=${MAP}`]);
1257 await settled();
1258 expect(stage.by("copymap").textContent).toBe(COPIED);
1259
1260 // A browser that refuses the write says so rather than pretending.
1261 stage.refuseCopy = true;
1262 click();
1263 await settled();
1264 expect(stage.by("copymap").textContent).toBe(NOT_COPIED);
1265
1266 // Choosing another map puts the label back: it is a control, not a log.
1267 stage.refuseCopy = false;
1268 stage.by("map").fire("change");
1269 expect(stage.by("copymap").textContent).toBe(LABEL);
1270
1271 // And a browser with no clipboard at all leaves the click alone: nothing is
1272 // prevented, so the anchor navigates, which is this page on that map.
1273 stage.host.copy = undefined;
1274 click();
1275 expect(prevented).toBe(2);
1276 expect(stage.copied.length).toBe(2);
1277 });
1278
1279 // --- the map you can see before you play it -------------------------------
1280
1281 /**
1282 * How long a choice has to stand still before the plan of it is drawn, in
1283 * frames of this test's clock. It is `PLAN_WAIT` in `main.ts` — 200 ms — and it
1284 * is written as a count of frames because that is the only clock a test has.
1285 */
1286 const SETTLE = Math.ceil(200 / FRAME) + 1;
1287
1288 /** The stub canvas's size, which is what a browser reports before any CSS. */
1289 const PLAN_WIDTH = 800;
1290 const PLAN_HEIGHT = 450;
1291
1292 /** Every 2D call the menu's plan canvas has been given, oldest first. */
1293 const planCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.plan.paint.calls;
1294
1295 /** What `drawPreview` puts on a canvas this size, for a level this test names. */
1296 function planFor(level: Level): [string, ...unknown[]][] {
1297 const paint = recorder();
1298 drawPreview(paint, level, PLAN_WIDTH, PLAN_HEIGHT);
1299 return paint.calls.map((call) => [call.op, ...call.args] as [string, ...unknown[]]);
1300 }
1301
1302 test("the menu draws the map Play would start, once the choice has stood still", () => {
1303 const stage = painted(start(fakeStage()));
1304 // Nothing is drawn while the atlas is being painted: the menu is answerable
1305 // and the frames belong to the browser.
1306 expect(planCalls(stage).length).toBe(0);
1307 expect(stage.by("planalt").textContent).toBe("");
1308
1309 // One frame past the wait and the plan of de_forge is on the canvas, at the
1310 // size the browser is giving us of it, with the sentence beside it.
1311 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1312 expect([stage.plan.width, stage.plan.height]).toEqual([PLAN_WIDTH, PLAN_HEIGHT]);
1313 expect(planCalls(stage)).toEqual(planFor(DE_FORGE));
1314 expect(stage.by("planalt").textContent).toBe(previewText(DE_FORGE));
1315
1316 // Once per choice and not once a frame: the frames after it draw nothing.
1317 const drawn = planCalls(stage).length;
1318 for (let frame = 0; frame < 10; frame++) stage.step(FRAME);
1319 expect(planCalls(stage).length).toBe(drawn);
1320
1321 // A new map every match: the plan is the map that id carves, which is the map
1322 // the menu just named and the map Play is about to start.
1323 stage.by("map").value = "generated";
1324 stage.by("map").fire("change");
1325 const level = generateLevel(seedFrom(stage.ids[0] as string));
1326 expect(stage.by("willplay").textContent).toBe(level.name);
1327 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1328 expect(planCalls(stage).slice(drawn)).toEqual(planFor(level));
1329 expect(stage.by("planalt").textContent).toBe(previewText(level));
1330
1331 // And it is that map that is played, with no second id drawn for it.
1332 stage.by("play").fire("click");
1333 expect(worldOf(stage)?.level.name).toBe(level.name);
1334 expect(stage.ids.length).toBe(1);
1335 });
1336
1337 test("a name typed a character at a time costs one map, and none while a match runs", () => {
1338 const stage = painted(start(fakeStage()));
1339 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1340 const drawn = planCalls(stage).length;
1341
1342 // Typing: a keystroke a frame, which is faster than the wait, so no map is
1343 // carved for any of the prefixes — `gen_1` is a map, and not the one meant.
1344 for (const upto of [5, 6, 7, 8, 9, 10, 11]) {
1345 stage.by("mapseed").value = MAP.slice(0, upto);
1346 stage.by("mapseed").fire("input");
1347 stage.step(FRAME);
661348 }
1349 expect(planCalls(stage).length).toBe(drawn);
1350
1351 // Hands off the keyboard, and the map they named is on the screen.
1352 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1353 const named = generateLevel(seedOf(MAP) as number);
1354 expect(planCalls(stage).slice(drawn)).toEqual(planFor(named));
1355
1356 // A name that is not one is de_forge on the plan too, like everywhere else.
1357 const typed = planCalls(stage).length;
1358 stage.by("mapseed").value = "gen_0ya";
1359 stage.by("mapseed").fire("input");
1360 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1361 expect(planCalls(stage).slice(typed)).toEqual(planFor(DE_FORGE));
1362
1363 // Nothing is drawn while a match is running: the menu is not on the screen
1364 // and the frame belongs to the round.
1365 const before = planCalls(stage).length;
1366 stage.by("mapseed").value = MAP;
1367 stage.by("mapseed").fire("input");
1368 stage.by("play").fire("click");
1369 for (let frame = 0; frame < SETTLE + 10; frame++) stage.step(FRAME);
1370 expect(planCalls(stage).length).toBe(before);
1371
1372 // Back at the menu it is drawn again, for the map the next Play would start.
1373 stage.by("quit").fire("click");
1374 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1375 expect(planCalls(stage).slice(before)).toEqual(planFor(named));
671376 });
681377
69 // --- querySelectors, the same way: every call with a literal selector,
70 // against markup main.ts did not write.
71 const SELECTOR_CALL = /\.(?:querySelector|querySelectorAll|closest)(?:<[^>]+>)?\("([^"]+)"\)/g;
72 const selectors = [...new Set([...mainSource.matchAll(SELECTOR_CALL)].map((m) => m[1] as string))];
1378 /**
1379 * A match played from one id: the page hands that id out instead of a fresh
1380 * one, Play is pressed, and `frames` frames are stepped. The id is the only
1381 * thing that differs between two calls, so two worlds that disagree disagree
1382 * because of it — and `withoutEntropy` is what says so, because a frame that
1383 * reached past the seed for `Math.random` or a clock would throw here rather
1384 * than quietly make the comparison below a coin toss.
1385 */
1386 function fromId(id: string, frames: number): World {
1387 return withoutEntropy(() => {
1388 const stage = painted(start(fakeStage()));
1389 stage.host.randomUUID = () => id;
1390 stage.by("play").fire("click");
1391 for (let frame = 0; frame < frames; frame++) stage.step(FRAME);
1392 const world = worldOf(stage);
1393 if (world === null) throw new Error(`the id ${id} started no match`);
1394 return world;
1395 });
1396 }
731397
74 // Selectors main.ts runs against markup it wrote into the page itself: the
75 // controls panel's own buttons, the buy grid it builds, the radio panel's
76 // call buttons. `.grid` inside `#buy` is its own the same way these are.
77 const SELF_WRITTEN_SELECTORS = [
78 "button.bind", ".bind-status", ".reset-bindings", ".grid", "button", ".buy-line", ".buy-why", ".radio-call",
79 ];
1398 test("a match is its id: the same id is the same match, and another id is another", () => {
1399 const ID = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11";
1400 // The world the page started is the world `createWorld` makes from this id —
1401 // not one the wall clock picked. Same roster out of the same draws, same bomb
1402 // in the same hands, and every number `snapshot` hashes identical.
1403 const booted = fromId(ID, 0);
1404 const mirror = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(ID));
1405 expect(booted.players.map((p) => p.name)).toEqual(mirror.players.map((p) => p.name));
1406 expect(booted.players.find((p) => p.hasBomb)?.name).toBe(mirror.players.find((p) => p.hasBomb)?.name);
1407 expect(snapshot(booted)).toBe(snapshot(mirror));
801408
81 test("main.ts still finds selectors the way it always has", () => {
82 expect(selectors.length).toBeGreaterThan(5);
1409 // And it stays that match as it is played: two seconds of frames from one id
1410 // agree tick for tick, and one character of a different id is a different
1411 // match — different spawns, different bots, a different round.
1412 const played = (id: string): string => snapshot(fromId(id, 60));
1413 const other = `${ID.slice(0, -1)}2`;
1414 expect(played(ID)).toBe(played(ID));
1415 expect(played(ID)).not.toBe(played(other));
831416 });
841417
85 test("every selector main.ts runs against the page it did not write matches something there", () => {
86 for (const selector of selectors) {
87 if (SELF_WRITTEN_SELECTORS.includes(selector)) continue;
88 // The only shape left reaching into the page is a bare tag name —
89 // `details` and `summary`, the structure crosshairControls and the
90 // Controls panel both use.
91 expect(selector).toMatch(/^[a-z]+$/);
92 expect(html).toContain(`<${selector}`);
1418 // --- a match you can play again -------------------------------------------
1419
1420 /** A match id shaped like the ones `crypto.randomUUID` hands out. */
1421 const PASTED = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
1422
1423 /**
1424 * A stage with `id` pasted into the menu's field, played from there. The page
1425 * still hands out its own fresh ids — this is the id a *person* supplied, which
1426 * is the whole point — so what the world is seeded with is the field and not
1427 * the uuid behind it.
1428 */
1429 function pasted(id: string, options: { map?: string } = {}): FakeStage {
1430 const stage = painted(start(fakeStage()));
1431 stage.by("matchid").value = id;
1432 if (options.map !== undefined) stage.by("map").value = options.map;
1433 stage.by("play").fire("click");
1434 return stage;
1435 }
1436
1437 test("a pasted id is the match: the same id is the same world, a typo is a fresh one", () => {
1438 const world = worldOf(pasted(PASTED));
1439 const mirror = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(PASTED));
1440 expect(world).not.toBeNull();
1441 // The id came from the field, not from the page's own source of ids: the
1442 // uuid it drew is the row key, and the match is the one that was pasted.
1443 expect(snapshot(world as World)).toBe(snapshot(mirror));
1444
1445 // Junk is not an error and not a refusal to play: a fresh match, and a line
1446 // under the field saying which of the two happened.
1447 const typo = pasted("not a match id");
1448 expect(typo.by("onid").textContent).toBe(NOT_AN_ID);
1449 expect(typo.by("menu").hidden).toBe(true);
1450 expect(snapshot(worldOf(typo) as World))
1451 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(typo.ids[0] as string))));
1452
1453 // Fix the typo and the warning goes: the line under the field is about the
1454 // match that is being played now, not about one that was mistyped earlier.
1455 typo.by("quit").fire("click");
1456 typo.by("matchid").value = PASTED;
1457 typo.by("play").fire("click");
1458 expect(typo.by("onid").textContent).not.toBe(NOT_AN_ID);
1459
1460 // And an empty field is what it always was: the id the page drew, nothing
1461 // said, and the world seeded from it.
1462 const fresh = playing();
1463 expect(fresh.by("onid").textContent).not.toBe(NOT_AN_ID);
1464 expect(snapshot(worldOf(fresh) as World))
1465 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(fresh.ids[0] as string))));
1466 });
1467
1468 test("two worlds from one id fold to the same digest, and another id folds elsewhere", () => {
1469 // The claim the id makes, end to end and through the engine's `digest` door:
1470 // fourteen seconds — the whole freeze and two seconds of a live round, so the
1471 // bots have decided something — of the same fixed script over a world the
1472 // *page* started from a pasted id. Same id, same hash, to the tick; one
1473 // character away, another hash. `withoutEntropy` is what makes that a fact
1474 // rather than a coincidence: a frame that reached for `Math.random` or a
1475 // clock on the way would throw here instead.
1476 const TICKS = 14 * 64;
1477 const run = (id: string): string =>
1478 withoutEntropy(() => {
1479 const world = worldOf(pasted(id));
1480 if (world === null) throw new Error(`the id ${id} started no match`);
1481 return digest(world, TICKS).digest;
1482 });
1483 expect(run(PASTED)).toBe(run(PASTED));
1484 expect(run(PASTED)).not.toBe(run(`${PASTED.slice(0, -1)}5`));
1485 });
1486
1487 test("a rematch is a new row: the id is the match, the posted key is this playing of it", async () => {
1488 const stage = pasted(PASTED);
1489 stage.step(FRAME);
1490 await settled();
1491 const first = JSON.parse(stage.posts[0]?.body ?? "{}");
1492 // The row key is not the id: it is the id with this play's nonce on it, so
1493 // the leaderboard cannot fold two games of one match into one row — and it
1494 // still reads back as the match it was a playing of.
1495 expect(first.matchId).not.toBe(PASTED);
1496 expect(matchOf(first.matchId)).toBe(PASTED);
1497 expect(parseResult(first)).not.toBeNull();
1498
1499 // Play it again from the same menu: the same match, a different row.
1500 stage.by("quit").fire("click");
1501 stage.by("play").fire("click");
1502 // The same match over again — the same world the first click started —
1503 // written to a key of its own.
1504 expect(snapshot(worldOf(stage) as World))
1505 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(PASTED))));
1506 stage.step(FRAME);
1507 const second = JSON.parse(stage.posts[1]?.body ?? "{}");
1508 expect(second.matchId).not.toBe(first.matchId);
1509 expect(matchOf(second.matchId)).toBe(PASTED);
1510
1511 // A match nobody asked for is its own row key, exactly as before today.
1512 const plain = playing();
1513 plain.step(FRAME);
1514 expect(JSON.parse(plain.posts[0]?.body ?? "{}").matchId).toBe(plain.ids[0]);
1515 });
1516
1517 test("the match being played is on the page: the id, the map, the link and the pause line", () => {
1518 const stage = pasted(PASTED, { map: "generated" });
1519 const level = worldOf(stage)?.level;
1520 const mapName = nameOf(seedFrom(PASTED));
1521 expect(level?.name).toBe(mapName);
1522 expect(stage.by("playing").textContent).toBe(PASTED);
1523 expect(stage.by("mapname").textContent).toBe(mapName);
1524 // A real href, to this match on this map: following it is the rematch.
1525 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`);
1526 // The pause panel says the same thing in words, because on a full screen
1527 // there is no page under the stage to read.
1528 expect(stage.by("pauseid").textContent).toBe(`Match ${PASTED} on ${level?.name}.`);
1529
1530 // de_forge is not a map the id carves, and the link says so rather than
1531 // handing a friend a different map.
1532 stage.by("quit").fire("click");
1533 stage.by("map").value = "de_forge";
1534 stage.by("play").fire("click");
1535 expect(stage.by("mapname").textContent).toBe("de_forge");
1536 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`);
1537 });
1538
1539 // --- a pasted link ----------------------------------------------------------
1540
1541 /** Type this into the Match id field, the way a person pastes and the DOM tells us. */
1542 function type(stage: FakeStage, text: string): FakeStage {
1543 stage.by("matchid").value = text;
1544 stage.by("matchid").fire("input");
1545 return stage;
1546 }
1547
1548 test("a pasted link fills both controls, and the map named above Play is the map played", () => {
1549 const stage = painted(start(fakeStage()));
1550 type(stage, `${ORIGIN}/?match=${PASTED}&map=${MAP}`);
1551 // What is left on the screen is what will be played: the bare id in the id
1552 // field and the map in the Map control, both validated again on the way in.
1553 expect(stage.by("matchid").value).toBe(PASTED);
1554 expect(stage.by("mapseed").value).toBe(MAP);
1555 expect(stage.by("map").value).toBe("de_forge");
1556 expect(stage.by("willplay").textContent).toBe(MAP);
1557 expect(stage.by("onid").textContent).not.toBe(NOT_AN_ID);
1558
1559 // And Play gives that: that match's spawns and bots on that layout, not on
1560 // whatever the Map control happened to be left on — which is the whole bug.
1561 stage.by("play").fire("click");
1562 const world = worldOf(stage) as World;
1563 expect(world.level.name).toBe(MAP);
1564 expect(snapshot(world))
1565 .toBe(snapshot(createWorld(generateLevel(seedOf(MAP) as number), SHORT, "you", "ct", 5, seedFrom(PASTED))));
1566 // Copying still gives the complete thing: both halves, as before today.
1567 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=${MAP}`);
1568 expect(stage.by("mapname").attributes.href).toBe(`/?map=${MAP}`);
1569
1570 // A link asking for a map carved from its own id says so in the select, and
1571 // the name above Play is the map that id carves — before Play, as ever.
1572 stage.by("quit").fire("click");
1573 type(stage, `${ORIGIN}/?match=${PASTED}&map=generated`);
1574 expect(stage.by("map").value).toBe("generated");
1575 expect(stage.by("mapseed").value).toBe("");
1576 expect(stage.by("willplay").textContent).toBe(nameOf(seedFrom(PASTED)));
1577 stage.by("play").fire("click");
1578 expect(worldOf(stage)?.level.name).toBe(nameOf(seedFrom(PASTED)));
1579 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`);
1580 });
1581
1582 test("a link pasted without a keystroke is still read when Play is pressed", () => {
1583 // A value set in code — a browser filling the field, a script, this test —
1584 // fires no event, so the link is taken apart at Play as well.
1585 const stage = pasted(`${ORIGIN}/?match=${PASTED}&map=${MAP}`);
1586 expect(worldOf(stage)?.level.name).toBe(MAP);
1587 expect(stage.by("playing").textContent).toBe(PASTED);
1588 expect(stage.by("matchid").value).toBe(PASTED);
1589 });
1590
1591 test("a bare id leaves the Map control exactly where the player left it", () => {
1592 const stage = painted(start(fakeStage()));
1593 stage.by("mapseed").value = MAP;
1594 stage.by("mapseed").fire("input");
1595 type(stage, PASTED);
1596 // An id carries no map, and nothing pretends it does: the field a person set
1597 // is untouched and the line above Play still names their map.
1598 expect(stage.by("mapseed").value).toBe(MAP);
1599 expect(stage.by("willplay").textContent).toBe(MAP);
1600 stage.by("play").fire("click");
1601 expect(worldOf(stage)?.level.name).toBe(MAP);
1602
1603 // And the same for the select: a bare id on `generated` is that id's map.
1604 stage.by("quit").fire("click");
1605 stage.by("mapseed").value = "";
1606 stage.by("mapseed").fire("input");
1607 stage.by("map").value = "generated";
1608 stage.by("map").fire("change");
1609 type(stage, PASTED);
1610 expect(stage.by("map").value).toBe("generated");
1611 expect(stage.by("willplay").textContent).toBe(nameOf(seedFrom(PASTED)));
1612 });
1613
1614 test("a link that is not ours is not followed and not a match: the sentence, and de_forge", () => {
1615 const stage = painted(start(fakeStage()));
1616 const untouched = stage.by("map").value;
1617 for (const text of [
1618 `https://evil.example/?match=${PASTED}&map=${MAP}`,
1619 `javascript:alert(1)?match=${PASTED}&map=${MAP}`,
1620 `${ORIGIN}/?utm=x`,
1621 ]) {
1622 type(stage, text);
1623 // The existing sentence, not an error — and the Map control is untouched,
1624 // because nothing of that URL was believed.
1625 expect(stage.by("onid").textContent).toBe(NOT_AN_ID);
1626 expect(stage.by("mapseed").value).toBe("");
1627 expect(stage.by("map").value).toBe(untouched);
1628 expect(stage.by("matchid").value).toBe(text);
931629 }
1630
1631 // Play is a fresh match on de_forge, which is what a bad id has always been.
1632 stage.by("play").fire("click");
1633 expect(worldOf(stage)?.level.name).toBe("de_forge");
1634 expect(stage.by("onid").textContent).toBe(NOT_AN_ID);
1635 expect(snapshot(worldOf(stage) as World))
1636 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(stage.ids[0] as string))));
1637
1638 // And a link of ours that carried a match nobody could play says the same
1639 // thing, while still keeping the map it did carry.
1640 stage.by("quit").fire("click");
1641 type(stage, `${ORIGIN}/?match=nope&map=${MAP}`);
1642 expect(stage.by("onid").textContent).toBe(NOT_AN_ID);
1643 expect(stage.by("matchid").value).toBe("");
1644 expect(stage.by("willplay").textContent).toBe(MAP);
1645 });
1646
1647 // --- the round's plan -------------------------------------------------------
1648
1649 test("a round is planned in the page, and the bots are a team from the first tick", () => {
1650 const stage = playing();
1651 stage.step(FRAME);
1652 const world = worldOf(stage) as World;
1653 const plan = world.plan;
1654 expect(plan).not.toBeNull();
1655 expect(plan?.source).toBe("local");
1656 expect(plan?.round).toBe(world.match.round);
1657 // The call is on the screen where a round's call belongs.
1658 expect(world.messages[world.messages.length - 1]?.text).toContain(world.me.team.toUpperCase());
1659 // A page that named no endpoint asks nobody: the only post is the result.
1660 expect(stage.posts.filter((post) => post.url === "/api/plan")).toHaveLength(0);
1661 });
1662
1663 test("a page that names a planner asks it, with the situation and nothing else", async () => {
1664 const stage = playing({ planEndpoint: "/api/plan" });
1665 stage.step(FRAME);
1666 const posted = stage.posts.filter((post) => post.url === "/api/plan");
1667 expect(posted).toHaveLength(1);
1668 const sent = JSON.parse(String(posted[0]?.body)) as Record<string, unknown>;
1669 expect(parseSituation(sent)).not.toBeNull();
1670 expect(sent.round).toBe(1);
1671 expect(Object.keys(sent).sort()).toEqual(["lastWinner", "map", "money", "round", "score"]);
1672 expect(JSON.stringify(sent)).not.toContain("matchId");
1673 // The stage answers every post with the leaderboard, which is not a plan: the
1674 // local plan stays in force and the round is played on it.
1675 await Promise.resolve();
1676 stage.step(FRAME);
1677 const world = worldOf(stage) as World;
1678 expect(world.plan?.source).toBe("local");
1679 expect(stepsRun(stage)).toBeGreaterThan(0);
1680 });
1681
1682 // --- the match of the day -------------------------------------------------
1683
1684 test("today's match fills the menu's own controls, and is the match that is played", () => {
1685 const stage = painted(start(fakeStage()));
1686 const daily = dailyOn(stage.wall);
1687 // The line is the browser's own day from the first frame: the server rendered
1688 // the same sentence, and this page derived it again rather than believing it.
1689 expect(stage.by("ontoday").textContent).toBe(saidToday(daily));
1690
1691 // One press fills the three controls that say what a match is — the id, the
1692 // Map control, the length — so what is about to be played is on the screen.
1693 stage.by("today").fire("click");
1694 expect(stage.by("matchid").value).toBe(daily.match);
1695 expect(stage.by("map").value).toBe("generated");
1696 expect(stage.by("mapseed").value).toBe("");
1697 expect(stage.by("length").value).toBe(DAILY_LENGTH);
1698 // And the map is named before Play, out of the id, like every other match on
1699 // `generated` — without drawing an id of its own, because the day is the id.
1700 expect(stage.by("willplay").textContent).toBe(daily.map);
1701 expect(stage.by("copymap").attributes.href).toBe(`/?map=${daily.map}`);
1702 expect(stage.ids.length).toBe(0);
1703
1704 // Play is that match: that id, that map, that length, and the link under the
1705 // stage plays it again.
1706 stage.by("play").fire("click");
1707 expect(worldOf(stage)?.level.name).toBe(daily.map);
1708 expect(worldOf(stage)?.match.rules).toEqual(SHORT);
1709 expect(stage.by("playing").textContent).toBe(daily.match);
1710 expect(stage.by("share").attributes.href).toBe(`/?match=${daily.match}&map=generated`);
1711 // What a round will be posted under is a *playing* of today's match, so a
1712 // second run is a second row and the match inside the key is still today's.
1713 expect(matchOf(String(stage.by("pauseid").textContent).split(" ")[1] ?? "")).toBe(daily.match);
1714 });
1715
1716 test("a browser that crosses midnight picks up the new day's match, not yesterday's", () => {
1717 const stage = painted(start(fakeStage()));
1718 const before = dailyOn(stage.wall);
1719 stage.by("today").fire("click");
1720 expect(stage.by("matchid").value).toBe(before.match);
1721
1722 // The tab was left open. The day is derived from the clock on every press,
1723 // so the next one is the match of the day it is now.
1724 stage.wall += 86_400_000;
1725 const after = dailyOn(stage.wall);
1726 expect(after.match).not.toBe(before.match);
1727 stage.by("today").fire("click");
1728 expect(stage.by("matchid").value).toBe(after.match);
1729 expect(stage.by("ontoday").textContent).toBe(saidToday(after));
1730 expect(stage.by("willplay").textContent).toBe(after.map);
941731 });
src/client/main.ts +1614 −846

Diff not shown — 120,373 bytes of diff (limit 100,000). +1614 −846

View file
src/client/map.test.ts +116 −274
@@ -1,99 +1,39 @@
1 /**
2 * de_forge, pinned. Everything this file asserted when the map was a module of
3 * singletons it asserts now about `DE_FORGE`, the one value `map.ts` exports:
4 * the same cells, the same fifty boxes, the same thirty-three waypoints from T
5 * spawn to A. What a *level* is, and what is true of every one of them, is
6 * `level.test.ts`; this is about this map.
7 */
8
19 import { expect, test } from "bun:test";
2 import {
3 buildMap, CELL, cellAt, colOf, DE_CINDER, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS,
4 placeAt, placeLabel, rowOf, siteAt, walkable, worldOf, type Layout, type MapDef,
5 } from "./map.ts";
10 import { cellAt, colOf, findPath, rowOf, siteAt, walkable, worldOf } from "./level.ts";
11 import { DE_FORGE, GRID_H, GRID_W } from "./map.ts";
12 import { levelMesh } from "./models.ts";
13
14 const { boxes, sites, spawns } = DE_FORGE;
615
716 /**
8 * Every structural claim below holds for every map in the catalogue, not just
9 * the one the game opens on. A layout is a table of numbers typed by hand; the
10 * way it goes wrong is a site inside a crate or a room nothing can walk to,
11 * and neither of those is visible in the table.
17 * Where the Ts start, which half the tests below walk from. The map has more
18 * than four of them — the test above says so — so the first one is there.
1219 */
13 const CATALOGUE: [string, MapDef][] = MAP_NAMES.map((name) => [name, MAPS[name]]);
20 const T_SPAWN = spawns.t[0]!;
1421
15 /** Every cell the flood fill from `start` can reach, four ways, walking. */
16 function reachable(map: MapDef, start: [number, number]): Set<number> {
17 const seen = new Set<number>([start[1] * map.gridW + start[0]]);
18 const queue = [start[1] * map.gridW + start[0]];
19 while (queue.length > 0) {
20 const i = queue.pop() as number;
21 const c = i % map.gridW, r = Math.floor(i / map.gridW);
22 for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) {
23 if (!walkable(map, c + dc, r + dr)) continue;
24 const j = (r + dr) * map.gridW + c + dc;
25 if (seen.has(j)) continue;
26 seen.add(j);
27 queue.push(j);
28 }
29 }
30 return seen;
31 }
32
33 test.each(CATALOGUE)("%s has two sites and two spawns", (_name, map) => {
34 expect(map.spawns.t.length).toBeGreaterThan(4);
35 expect(map.spawns.ct.length).toBeGreaterThan(4);
36 expect(siteAt(map, map.sites.A)).toBe("A");
37 expect(siteAt(map, map.sites.B)).toBe("B");
22 test("the map has two sites and two spawns", () => {
23 expect(spawns.t.length).toBeGreaterThan(4);
24 expect(spawns.ct.length).toBeGreaterThan(4);
25 expect(siteAt(DE_FORGE, sites.A)).toBe("A");
26 expect(siteAt(DE_FORGE, sites.B)).toBe("B");
3827 });
3928
40 // A site's `sites` point is where a bot walks to plant. A crate dropped on the
41 // middle of a site leaves the name in the grid and the point inside the box:
42 // the plant still happens, against a wall, and only a person standing there
43 // ever finds out.
44 test.each(CATALOGUE)("%s plants on floor, not inside a crate", (_name, map) => {
45 for (const site of ["A", "B"] as const) {
46 const at = map.sites[site];
47 expect(walkable(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(true);
48 expect(cellAt(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(site);
29 test("the border is solid all the way round", () => {
30 for (let c = 0; c < GRID_W; c++) {
31 expect(cellAt(DE_FORGE, c, 0)).toBe("#");
32 expect(cellAt(DE_FORGE, c, GRID_H - 1)).toBe("#");
4933 }
50 });
51
52 // A crate is cover: a sightline broken, something to walk round and hold.
53 // One placed on a wall cell builds just as well — the grid takes the mark and
54 // the mesh takes the box — and the only sign is cover nobody can use, because
55 // a table of coordinates typed by hand says nothing about what was underneath.
56 test.each(CATALOGUE)("%s stands every crate on the floor, with room to walk round it", (_name, map) => {
57 let crates = 0;
58 for (let r = 0; r < map.gridH; r++)
59 for (let c = 0; c < map.gridW; c++) {
60 const cell = cellAt(map, c, r);
61 if (cell !== "x" && cell !== "X") continue;
62 crates++;
63 let open = 0;
64 for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][])
65 if (walkable(map, c + dc, r + dr)) open++;
66 // Two open sides, not one: cover you can only reach head-on is a dead
67 // end, and cover with none at all is a box inside a wall.
68 expect(open).toBeGreaterThan(1);
69 }
70 expect(crates).toBeGreaterThan(10); // and the map really is furnished
71 });
72
73 test.each(CATALOGUE)("%s musters each side in its own spawn, on a cell you can walk to", (_name, map) => {
74 for (const team of ["t", "ct"] as const) {
75 const at = map.muster[team];
76 expect(cellAt(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(team === "t" ? "t" : "c");
77 expect(walkable(map, colOf(map, at[0]), rowOf(map, at[1]))).toBe(true);
78 // And it is somewhere a bot can reach from either site, which is what
79 // a counter-terrorist out of clock asks the pathfinder for.
80 for (const site of [map.sites.A, map.sites.B]) {
81 expect(findPath(map, site, at).length).toBeGreaterThan(0);
82 }
83 }
84 // The two halves of the map really are two halves: T at the bottom of the
85 // grid, CT at the top, the sites between them.
86 expect(map.muster.t[1]).toBeLessThan(map.muster.ct[1]);
87 });
88
89 test.each(CATALOGUE)("%s is walled all the way round", (_name, map) => {
90 for (let c = 0; c < map.gridW; c++) {
91 expect(cellAt(map, c, 0)).toBe("#");
92 expect(cellAt(map, c, map.gridH - 1)).toBe("#");
93 }
94 for (let r = 0; r < map.gridH; r++) {
95 expect(cellAt(map, 0, r)).toBe("#");
96 expect(cellAt(map, map.gridW - 1, r)).toBe("#");
34 for (let r = 0; r < GRID_H; r++) {
35 expect(cellAt(DE_FORGE, 0, r)).toBe("#");
36 expect(cellAt(DE_FORGE, GRID_W - 1, r)).toBe("#");
9737 }
9838 });
9939
@@ -105,206 +45,108 @@ test("grid and world coordinates round-trip", () => {
10545 }
10646 });
10747
108 test.each(CATALOGUE)("every walkable cell of %s is reachable from T spawn", (_name, map) => {
48 test("every walkable cell is reachable from T spawn", () => {
10949 // A room nobody can reach is a bug you only find by walking into it.
110 const start = map.spawns.t[0];
111 const seen = reachable(map, [colOf(map, start[0]), rowOf(map, start[1])]);
50 const start = T_SPAWN;
51 const seen = new Set<number>();
52 const queue = [rowOf(DE_FORGE, start[1]) * GRID_W + colOf(DE_FORGE, start[0])];
53 seen.add(queue[0]!);
54 while (queue.length > 0) {
55 const i = queue.pop() as number;
56 const c = i % GRID_W, r = Math.floor(i / GRID_W);
57 for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) {
58 if (!walkable(DE_FORGE, c + dc, r + dr)) continue;
59 const j = (r + dr) * GRID_W + c + dc;
60 if (seen.has(j)) continue;
61 seen.add(j);
62 queue.push(j);
63 }
64 }
11265 let walkables = 0;
113 for (let r = 0; r < map.gridH; r++)
114 for (let c = 0; c < map.gridW; c++) if (walkable(map, c, r)) walkables++;
66 for (let r = 0; r < GRID_H; r++)
67 for (let c = 0; c < GRID_W; c++) if (walkable(DE_FORGE, c, r)) walkables++;
11568 expect(seen.size).toBe(walkables);
11669 });
11770
118 test.each(CATALOGUE)("greedy meshing collapses %s's walls into few boxes", (_name, map) => {
119 expect(map.boxes.length).toBeGreaterThan(10);
120 expect(map.boxes.length).toBeLessThan(300);
71 test("greedy meshing collapses the walls into few boxes", () => {
72 expect(boxes.length).toBeGreaterThan(10);
73 expect(boxes.length).toBeLessThan(300);
12174 });
12275
123 // Not "a path exists" but "a path exists from every cell a round can start on
124 // to both places it can be decided". One spawn cell walled off behind a crate
125 // is one bot in five standing still for a whole match.
126 test.each(CATALOGUE)("on %s every spawn cell reaches both sites", (_name, map) => {
127 for (const team of ["t", "ct"] as const) {
128 for (const spawn of map.spawns[team]) {
129 for (const site of [map.sites.A, map.sites.B]) {
130 const path = findPath(map, spawn, site);
131 expect(path.length).toBeGreaterThan(4);
132 // Consecutive waypoints are neighbouring cells, never a jump across a wall.
133 for (let i = 1; i < path.length - 1; i++) {
134 const step = Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
135 expect(step).toBeLessThan(200);
136 }
137 }
76 test("de_forge is exactly where it was, to the unit", () => {
77 // The whole level comes out of one function, so half a cell of drift in the
78 // arithmetic moves every wall, every spawn and every bot with it and nothing
79 // else in the suite would say so. These numbers are hand-checkable:
80 // 44 cells of 128 units is 5632 across, so the floor runs -2816..2816.
81 expect(boxes.length).toBe(50);
82 expect(boxes[0]).toEqual({ min: [-2816, -2816, -32], max: [2816, 2816, 0], mat: 0 });
83 // The last box is the last uncrossable crate in reading order, at (37, 26):
84 // x = (37 - 22 + 0.5) * 128 = 1984, y = (22 - 26 - 0.5) * 128 = -576, and a
85 // crate is one cell wide and 128 tall.
86 expect(boxes[boxes.length - 1]).toEqual({
87 min: [1920, -640, 0], max: [2048, -512, 128], mat: 2,
88 });
89 });
90
91 test("the route from T spawn to A is the same route it was", () => {
92 // A number a change to the search moves: 33 waypoints from the first T spawn
93 // cell to the centre of A, 4506 units walked against 4142 as the crow flies.
94 const path = findPath(DE_FORGE, T_SPAWN, sites.A);
95 expect(T_SPAWN).toEqual([576, -1984, 0]);
96 expect(path.length).toBe(33);
97 expect(path[0]).toEqual([704, -1984, 0]);
98 expect(path[path.length - 1]).toEqual(sites.A);
99 let walked = 0;
100 for (let i = 1; i < path.length; i++)
101 walked += Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1]);
102 expect(walked).toBeCloseTo(4506.04, 1);
103 const crow = Math.hypot(sites.A[0] - T_SPAWN[0], sites.A[1] - T_SPAWN[1]);
104 expect(crow).toBeCloseTo(4141.87, 1);
105 });
106
107 test("a path from T spawn reaches both sites", () => {
108 for (const site of [sites.A, sites.B]) {
109 const path = findPath(DE_FORGE, T_SPAWN, site);
110 expect(path.length).toBeGreaterThan(4);
111 // Consecutive waypoints are neighbouring cells, never a jump across a wall.
112 for (let i = 1; i < path.length - 1; i++) {
113 const step = Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1]);
114 expect(step).toBeLessThan(200);
138115 }
139116 }
140117 });
141118
142 // Both sites reachable is not the same as both sites *approachable*: a site
143 // with one door is a site the defence holds by standing in the door. Each one
144 // here has two ways in that do not share their last step.
145 test.each(CATALOGUE)("%s lets each site be entered two ways", (_name, map) => {
146 for (const site of ["A", "B"] as const) {
147 // Every walkable cell that is not the site but touches it, grouped by
148 // whether the cells themselves touch: one group is one doorway.
149 const mouth = new Set<number>();
150 for (let r = 0; r < map.gridH; r++)
151 for (let c = 0; c < map.gridW; c++) {
152 if (cellAt(map, c, r) !== site) continue;
153 for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][])
154 if (walkable(map, c + dc, r + dr) && cellAt(map, c + dc, r + dr) !== site)
155 mouth.add((r + dr) * map.gridW + c + dc);
156 }
157 const seen = new Set<number>();
158 let doors = 0;
159 for (const start of mouth) {
160 if (seen.has(start)) continue;
161 doors++;
162 const queue = [start];
163 seen.add(start);
164 while (queue.length > 0) {
165 const i = queue.pop() as number;
166 const c = i % map.gridW, r = Math.floor(i / map.gridW);
167 for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) {
168 const j = (r + dr) * map.gridW + c + dc;
169 if (!mouth.has(j) || seen.has(j)) continue;
170 seen.add(j);
171 queue.push(j);
172 }
173 }
174 }
175 expect(doors).toBeGreaterThan(1);
176 }
177 });
178
179 // One scale, one set of crates, one CELL: the shipped policy reads distances
180 // and the radar reads the grid, and neither is told which map it is on.
181 test("both maps are the same size, in the same cells", () => {
182 expect(DE_CINDER.gridW).toBe(DE_FORGE.gridW);
183 expect(DE_CINDER.gridH).toBe(DE_FORGE.gridH);
184 expect(CELL).toBe(128);
185 });
186
187119 test("a path into solid rock comes back empty rather than lying", () => {
188 expect(findPath(DE_FORGE, DE_FORGE.spawns.t[0], worldOf(DE_FORGE, 0, 0))).toEqual([]);
120 expect(findPath(DE_FORGE, T_SPAWN, worldOf(DE_FORGE, 0, 0))).toEqual([]);
189121 });
190122
191 /** A hand-written layout small enough to check by eye: one room, one crate, two sites. */
192 const TOY: Layout = {
193 name: "toy",
194 gridW: 6,
195 gridH: 6,
196 areas: [
197 { name: "room", rect: [1, 1, 4, 4], fill: "." },
198 { name: "t spawn", rect: [1, 4, 1, 4], fill: "t" },
199 { name: "ct spawn", rect: [4, 1, 4, 1], fill: "c" },
200 { name: "a site", rect: [3, 3, 3, 3], fill: "A" },
201 { name: "b site", rect: [1, 1, 1, 1], fill: "B" },
202 ],
203 crates: [[2, 2, "x"]],
204 };
205
206 test("buildMap turns a layout into the cells, spawns and sites it describes", () => {
207 const toy = buildMap(TOY);
208 expect(toy.name).toBe("toy");
209 expect(toy.gridW).toBe(6);
210 expect(toy.gridH).toBe(6);
211 // The border nobody carved is still solid rock.
212 expect(cellAt(toy, 0, 0)).toBe("#");
213 expect(cellAt(toy, 5, 5)).toBe("#");
214 // The crate landed where it was placed, inside the room.
215 expect(cellAt(toy, 2, 2)).toBe("x");
216 expect(toy.spawns.t).toEqual([worldOf(toy, 1, 4)]);
217 expect(toy.spawns.ct).toEqual([worldOf(toy, 4, 1)]);
218 expect(siteAt(toy, toy.sites.A)).toBe("A");
219 expect(siteAt(toy, toy.sites.B)).toBe("B");
123 test("de_forge carries its own alphabet, its numbers and its material table", () => {
124 // The predicate is the level's: nobody outside this file knows what a crate is.
125 expect(DE_FORGE.name).toBe("de_forge");
126 expect(DE_FORGE.passable(".")).toBe(true);
127 for (const solid of ["#", "x", "X"]) expect(DE_FORGE.passable(solid)).toBe(false);
128 for (const open of ["A", "B", "t", "c"]) expect(DE_FORGE.passable(open)).toBe(true);
129 expect(DE_FORGE.grid.cell).toBe(128);
130 expect(DE_FORGE.grid.cells.length).toBe(GRID_W * GRID_H);
131 expect(DE_FORGE.wallHeight).toBe(384);
132 expect(DE_FORGE.materials).toEqual({ FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 });
133 expect(DE_FORGE.siteCells).toEqual({ A: "A", B: "B" });
134 expect(DE_FORGE.look).toBe("forge");
220135 });
221136
222 test("the catalogue holds both maps, and nothing builds either twice", () => {
223 expect(MAP_NAMES).toEqual(["de_forge", "de_cinder"]);
224 expect(DEFAULT_MAP).toBe("de_forge");
225 expect(MAPS.de_forge).toBe(DE_FORGE);
226 expect(MAPS.de_cinder).toBe(DE_CINDER);
227 expect(DE_CINDER).not.toBe(DE_FORGE);
228 expect(DE_CINDER.grid).not.toEqual(DE_FORGE.grid);
229 });
230
231 // A key and its map's own `name` diverging is a silent 422 on every post — a
232 // second layout with a key that does not match `MapDef.name` would still
233 // build, still show up in the menu, and only fail the moment a match on it
234 // is saved.
235 test("every catalogue key names the map it holds", () => {
236 for (const name of MAP_NAMES) expect(MAPS[name].name).toBe(name);
237 });
238
239 test("mapFor returns the map a valid name asks for", () => {
240 expect(mapFor("de_forge")).toBe(DE_FORGE);
241 expect(mapFor("de_cinder")).toBe(DE_CINDER);
242 });
243
244 test("mapFor falls back to the default map for anything unrecognised", () => {
245 expect(mapFor("nonsense")).toBe(MAPS[DEFAULT_MAP]);
246 expect(mapFor("")).toBe(MAPS[DEFAULT_MAP]);
247 expect(mapFor("DE_FORGE")).toBe(MAPS[DEFAULT_MAP]); // case matters: this is not a stored name
248 });
249
250 test("two maps built from two layouts hold no state in common", () => {
251 const a = buildMap({ ...TOY, name: "a" });
252 const b = buildMap({ ...TOY, gridW: 8, gridH: 8, name: "b", crates: [] });
253 expect(a).not.toBe(b);
254 expect(a.grid).not.toBe(b.grid);
255 expect(a.gridW).toBe(6);
256 expect(b.gridW).toBe(8);
257 expect(cellAt(a, 2, 2)).toBe("x"); // a has the crate
258 expect(cellAt(b, 2, 2)).toBe("."); // b was built with none
259 expect(a).not.toBe(DE_FORGE);
260 expect(a.grid).not.toBe(DE_FORGE.grid);
261 });
262
263 // --- places, the vocabulary a callout is said in --------------------------
264
265 test.each(CATALOGUE)("%s names every place it can stand in, out of its own areas", (_name, map) => {
266 expect(map.places.length).toBeGreaterThan(8);
267 // Every walkable cell is inside some area: a layout that carved a room and
268 // forgot to name it would leave a teammate saying "enemy" and nothing else.
269 for (let r = 0; r < map.gridH; r++) {
270 for (let c = 0; c < map.gridW; c++) {
271 if (!walkable(map, c, r)) continue;
272 expect(placeAt(map, worldOf(map, c, r))).not.toBeNull();
273 }
274 }
275 // And nothing outside the grid is anywhere at all.
276 expect(placeAt(map, [1e6, 1e6, 0])).toBeNull();
277 });
278
279 test.each(CATALOGUE)("%s says the sites and the spawns the way a person would", (_name, map) => {
280 expect(placeAt(map, map.sites.A)).toBe("A site");
281 expect(placeAt(map, map.sites.B)).toBe("B site");
282 expect(placeAt(map, map.muster.t)).toBe("T spawn");
283 expect(placeAt(map, map.muster.ct)).toBe("CT spawn");
284 });
285
286 test("a place is the area actually painted there, not the first one to claim it", () => {
287 // Two rooms, the second painted over the first: a point in the overlap is
288 // standing on the second one's cells, so that is what it is called.
289 const map = buildMap({
290 name: "overlap",
291 gridW: 6,
292 gridH: 6,
293 areas: [
294 { name: "long a", rect: [1, 1, 4, 4], fill: "." },
295 { name: "ct mid", rect: [3, 3, 4, 4], fill: "." },
296 ],
297 crates: [],
298 });
299 expect(placeAt(map, worldOf(map, 1, 1))).toBe("long A");
300 expect(placeAt(map, worldOf(map, 4, 4))).toBe("CT mid");
301 });
302
303 test("placeLabel says letters as letters and leaves every other word alone", () => {
304 expect(placeLabel("a site")).toBe("A site");
305 expect(placeLabel("long a")).toBe("long A");
306 expect(placeLabel("b link")).toBe("B link");
307 expect(placeLabel("ct spawn")).toBe("CT spawn");
308 expect(placeLabel("lower tunnel")).toBe("lower tunnel");
309 expect(placeLabel("catwalk")).toBe("catwalk");
137 /**
138 * #151 lets a map choose which of the nine materials it is painted from. This
139 * is the claim that de_forge did not choose: the same fifty boxes tagged with
140 * the same layers they always were, and the mesh drawn from them the same
141 * 1,200 vertices split the same way — 24 of floor, 600 of wall, 528 of crate
142 * and 48 of site paint. A look applied to this map by accident lands here.
143 */
144 test("de_forge is painted from exactly the layers it always was", () => {
145 expect(boxes.map((box) => box.mat)).toEqual([
146 0, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
147 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
148 ]);
149 const counted: Record<number, number> = {};
150 for (const layer of levelMesh(boxes).layer) counted[layer] = (counted[layer] ?? 0) + 1;
151 expect(counted).toEqual({ 0: 24, 1: 600, 2: 528, 3: 48 });
310152 });
src/client/map.ts +65 −396
@@ -1,414 +1,83 @@
11 /**
2 * The map: a grid of 128-unit cells carved out of solid rock by named areas,
2 * de_forge: a grid of 128-unit cells carved out of solid rock by named areas,
33 * then merged back into as few boxes as possible. One piece of data feeds
44 * everything — what you collide with, what gets drawn, where bots walk, where
55 * the bomb can be planted — so the map can never disagree with itself.
66 *
7 * A map is a value, `MapDef`, built once by `buildMap` from a layout's areas
8 * and crates. Two layouts are in the catalogue below; nothing here reaches for
9 * a module-level grid, so a third is a third table and a third key.
7 * Layout is dust-shaped and original: two sites, a mid, a long right-hand
8 * corridor and a left-hand tunnel, T at the bottom of the grid, CT at the top.
109 *
11 * Both are original, both are 44 cells square, and both put T at the bottom of
12 * the grid and CT at the top with the sites between them — that convention is
13 * what the radar, the spawns and a counter-terrorist walking back to its own
14 * half all read. `de_forge` is dust-shaped: two sites along the top, a mid, a
15 * long right-hand corridor and a left-hand tunnel. `de_cinder` is not: the
16 * sites are diagonally opposite, and the shortest way from one to the other
17 * runs through the courtyard in the middle rather than behind it.
10 * This file is one map and nothing else: the areas, the crates and nothing
11 * about what a letter means. What the letters are and what a page of them
12 * becomes is `src/client/cells.ts`, which `generate.ts` writes its maps in too;
13 * what a level *is* — the grid, the boxes, the spawns, the sites and the
14 * questions asked of them — is `src/client/level.ts`, and `DE_FORGE` below is
15 * one value of that type, built once here and handed to `createWorld`. Nobody
16 * imports this map to find out where they are standing; they read the level off
17 * the world they are in.
18 *
19 * Everything in here is Counter-Strike. The grid itself — the cell arithmetic,
20 * the greedy meshing and the A* — is `src/engine/grid.ts`, which knows nothing
21 * about this alphabet: what counts as solid or walkable goes in as a predicate.
1822 */
1923
20 import type { Vec3 } from "./math.ts";
24 import * as g from "../engine/index.ts";
25 import { CELL, levelOf, type Cell } from "./cells.ts";
26 import type { Level } from "./level.ts";
2127
22 export const CELL = 128;
23 export const WALL_HEIGHT = 384;
24
25 export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c";
28 /** De_forge is 44 cells on a side: 5,632 Source units, a little under dust's. */
29 export const GRID_W = 44;
30 export const GRID_H = 44;
2631
2732 /** [col0, row0, col1, row1] inclusive, and what the cells become. */
28 export type Area = { name: string; rect: [number, number, number, number]; fill: Cell };
33 type Area = { name: string; rect: [number, number, number, number]; fill: Cell };
2934
30 export type Layout = {
31 name: string;
32 gridW: number;
33 gridH: number;
34 areas: Area[];
35 /** Cover, placed by hand: lower-case is jumpable, upper-case is not. */
36 crates: [number, number, Cell][];
37 };
38
39 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
40 export type Box = { min: Vec3; max: Vec3; mat: number };
41
42 /**
43 * One named piece of the map, as a person would say it out loud: the layout's
44 * own area, with its grid rectangle and a label built once when the map is.
45 * This is the whole vocabulary anything in the game has for saying *where* —
46 * there is no second table of places to drift out of step with the areas that
47 * actually carve the rock.
48 */
49 export type Place = { name: string; rect: [number, number, number, number] };
50
51 /** A built map: the grid, its geometry, and the places bots and spawns care about. */
52 export type MapDef = {
53 name: string;
54 gridW: number;
55 gridH: number;
56 grid: Cell[];
57 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
58 boxes: Box[];
59 spawns: { t: Vec3[]; ct: Vec3[] };
60 /**
61 * The middle of each side's spawn: one point to name the half of the map a
62 * side comes from. A site is where the round is decided; this is where the
63 * other side must be when it is not there yet, which is what a bot with a
64 * running-out clock and nothing to shoot at wants to know.
65 *
66 * Both halves, because it is a fact about the map and `spawns` already has
67 * both — the average of a list this file has built either way. Only `t` is
68 * read today, by a counter-terrorist out of clock in `bots.ts`.
69 */
70 muster: { t: Vec3; ct: Vec3 };
71 sites: { A: Vec3; B: Vec3 };
72 /**
73 * The layout's areas, last-painted first, so the first rectangle a point
74 * falls in is the one whose cells it is actually standing on — the same
75 * "a later area wins" rule `buildGrid` paints by. `placeAt` reads it.
76 */
77 places: Place[];
78 };
79
80 const DE_FORGE_LAYOUT: Layout = {
81 name: "de_forge",
82 gridW: 44,
83 gridH: 44,
84 areas: [
85 { name: "ct spawn", rect: [18, 2, 27, 6], fill: "c" },
86 { name: "ct mid", rect: [20, 6, 25, 11], fill: "." },
87 { name: "a link", rect: [27, 3, 32, 7], fill: "." },
88 { name: "a site", rect: [32, 3, 41, 11], fill: "A" },
89 { name: "long a", rect: [35, 11, 39, 31], fill: "." },
90 { name: "doors", rect: [30, 30, 39, 34], fill: "." },
91 { name: "t long", rect: [28, 34, 35, 38], fill: "." },
92 { name: "t spawn", rect: [16, 37, 28, 41], fill: "t" },
93 { name: "t mid", rect: [19, 27, 25, 37], fill: "." },
94 { name: "mid", rect: [19, 11, 25, 27], fill: "." },
95 { name: "catwalk", rect: [25, 14, 32, 17], fill: "." },
96 { name: "a ramp", rect: [30, 8, 33, 14], fill: "." },
97 { name: "b site", rect: [2, 3, 11, 11], fill: "B" },
98 { name: "b link", rect: [11, 5, 18, 9], fill: "." },
99 { name: "tunnels", rect: [4, 11, 8, 30], fill: "." },
100 { name: "lower tunnel", rect: [4, 30, 14, 35], fill: "." },
101 { name: "t tunnel", rect: [14, 33, 18, 38], fill: "." },
102 { name: "short", rect: [8, 20, 19, 23], fill: "." },
103 ],
104 crates: [
105 [34, 5, "X"], [35, 5, "x"], [38, 8, "x"], [39, 4, "X"], [33, 9, "x"],
106 [4, 5, "X"], [5, 5, "x"], [8, 8, "x"], [9, 4, "x"], [3, 9, "X"],
107 [21, 18, "x"], [23, 18, "x"], [22, 24, "X"],
108 [37, 20, "x"], [37, 26, "X"], [6, 18, "x"], [6, 25, "X"],
109 [20, 3, "x"], [25, 3, "x"], [18, 39, "x"], [26, 39, "x"], [28, 15, "x"],
110 ],
111 };
112
113 /**
114 * The second place to play. Where de_forge hangs both sites off the top of the
115 * grid and runs two long lanes down to the terrorists, de_cinder puts them on
116 * a diagonal — A in the north-west, B halfway down the east wall — around a
117 * courtyard the rotation between them has to cross. de_forge's counter-
118 * terrorists swap sites along one corridor behind their own spawn; de_cinder's
119 * walk through the middle, which is the whole difference in how it plays.
120 *
121 * Each site has the long way and the other way. A is the west long, a twenty-
122 * cell climb up the left wall from the terrorist end; or the vents, three
123 * cells tall, out of the courtyard's west side into the middle of that climb.
124 * B is the ramp up from t long on the east; or the kiln door, straight out of
125 * the courtyard into B's north-west shoulder. The counter-terrorists come down
126 * the kiln walk to A and the balcony to B, and both of those cross a route the
127 * terrorists can take, so no approach is anybody's private corridor.
128 *
129 * Areas are painted in order and a later one wins, which is how the doorways
130 * are cut: `west long` takes a bite out of A's south edge, `kiln walk` takes
131 * one out of its east edge. Crates in a spawn are placed in pairs either side
132 * of its middle so `muster` stays where it looks like it is.
133 */
134 const DE_CINDER_LAYOUT: Layout = {
135 name: "de_cinder",
136 gridW: 44,
137 gridH: 44,
138 areas: [
139 { name: "ct spawn", rect: [24, 2, 34, 5], fill: "c" },
140 { name: "ct mid", rect: [20, 6, 25, 13], fill: "." },
141 { name: "b link", rect: [30, 6, 40, 9], fill: "." },
142 { name: "balcony", rect: [36, 9, 40, 16], fill: "." },
143 { name: "b site", rect: [32, 17, 40, 25], fill: "B" },
144 { name: "kiln door", rect: [26, 18, 32, 21], fill: "." },
145 { name: "ramp", rect: [27, 24, 31, 33], fill: "." },
146 { name: "t long", rect: [22, 33, 31, 37], fill: "." },
147 { name: "a site", rect: [3, 4, 11, 11], fill: "A" },
148 { name: "kiln walk", rect: [11, 6, 20, 9], fill: "." },
149 { name: "west long", rect: [3, 11, 7, 30], fill: "." },
150 { name: "vents", rect: [8, 22, 16, 24], fill: "." },
151 { name: "lower west", rect: [3, 30, 12, 35], fill: "." },
152 { name: "t west", rect: [9, 34, 13, 36], fill: "." },
153 { name: "mid", rect: [17, 13, 25, 26], fill: "." },
154 { name: "t mid", rect: [17, 26, 22, 37], fill: "." },
155 { name: "t spawn", rect: [9, 37, 20, 41], fill: "t" },
156 ],
157 crates: [
158 [5, 6, "X"], [6, 6, "x"], [8, 5, "x"], [9, 9, "x"], [4, 10, "X"],
159 [34, 19, "X"], [35, 19, "x"], [39, 18, "X"], [38, 23, "x"], [33, 24, "x"],
160 [19, 17, "x"], [23, 17, "x"], [21, 23, "X"],
161 [5, 16, "x"], [5, 27, "X"], [38, 12, "x"], [29, 29, "x"], [14, 23, "x"],
162 [24, 35, "x"], [30, 7, "x"], [18, 7, "x"], [11, 39, "x"], [18, 39, "x"],
163 [26, 3, "x"], [32, 3, "x"],
164 ],
165 };
166
167 /**
168 * The words a layout writes in lower case that a person says as letters: the
169 * sites, and the two sides. "a site" is A site and "ct mid" is CT mid; every
170 * other word of an area name is already the word you would say.
171 */
172 const SPOKEN: Record<string, string> = { a: "A", b: "B", t: "T", ct: "CT" };
173
174 /** An area name as it is said out loud, built once per map and never per tick. */
175 export const placeLabel = (name: string): string =>
176 name.split(" ").map((word) => SPOKEN[word] ?? word).join(" ");
177
178 /** The layout's areas as places, last-painted first (see `MapDef.places`). */
179 const buildPlaces = (layout: Layout): Place[] =>
180 layout.areas.map((area) => ({ name: placeLabel(area.name), rect: area.rect })).reverse();
181
182 function buildGrid(layout: Layout): Cell[] {
183 const g: Cell[] = new Array(layout.gridW * layout.gridH).fill("#");
184 for (const area of layout.areas) {
185 const [c0, r0, c1, r1] = area.rect;
186 for (let r = r0; r <= r1; r++)
187 for (let c = c0; c <= c1; c++) g[r * layout.gridW + c] = area.fill;
188 }
189 for (const [c, r, cell] of layout.crates) g[r * layout.gridW + c] = cell;
190 return g;
191 }
192
193 export const cellAt = (map: MapDef, col: number, row: number): Cell =>
194 col < 0 || row < 0 || col >= map.gridW || row >= map.gridH
195 ? "#"
196 : (map.grid[row * map.gridW + col] as Cell);
197
198 /** Solid to a walking bot: walls and both kinds of crate. */
199 export const walkable = (map: MapDef, col: number, row: number): boolean => {
200 const c = cellAt(map, col, row);
201 return c !== "#" && c !== "x" && c !== "X";
202 };
203
204 /** Grid coordinates are north-down; the world is Y-north, centred on zero. */
205 export const worldOf = (map: MapDef, col: number, row: number, z = 0): Vec3 => [
206 (col - map.gridW / 2 + 0.5) * CELL,
207 (map.gridH / 2 - row - 0.5) * CELL,
208 z,
35 const AREAS: Area[] = [
36 { name: "ct spawn", rect: [18, 2, 27, 6], fill: "c" },
37 { name: "ct mid", rect: [20, 6, 25, 11], fill: "." },
38 { name: "a link", rect: [27, 3, 32, 7], fill: "." },
39 { name: "a site", rect: [32, 3, 41, 11], fill: "A" },
40 { name: "long a", rect: [35, 11, 39, 31], fill: "." },
41 { name: "doors", rect: [30, 30, 39, 34], fill: "." },
42 { name: "t long", rect: [28, 34, 35, 38], fill: "." },
43 { name: "t spawn", rect: [16, 37, 28, 41], fill: "t" },
44 { name: "t mid", rect: [19, 27, 25, 37], fill: "." },
45 { name: "mid", rect: [19, 11, 25, 27], fill: "." },
46 { name: "catwalk", rect: [25, 14, 32, 17], fill: "." },
47 { name: "a ramp", rect: [30, 8, 33, 14], fill: "." },
48 { name: "b site", rect: [2, 3, 11, 11], fill: "B" },
49 { name: "b link", rect: [11, 5, 18, 9], fill: "." },
50 { name: "tunnels", rect: [4, 11, 8, 30], fill: "." },
51 { name: "lower tunnel", rect: [4, 30, 14, 35], fill: "." },
52 { name: "t tunnel", rect: [14, 33, 18, 38], fill: "." },
53 { name: "short", rect: [8, 20, 19, 23], fill: "." },
20954 ];
21055
211 export const colOf = (map: MapDef, x: number): number => Math.floor(x / CELL + map.gridW / 2);
212 export const rowOf = (map: MapDef, y: number): number => Math.floor(map.gridH / 2 - y / CELL);
56 /** Cover, placed by hand: lower-case is jumpable, upper-case is not. */
57 const CRATES: [number, number, Cell][] = [
58 [34, 5, "X"], [35, 5, "x"], [38, 8, "x"], [39, 4, "X"], [33, 9, "x"],
59 [4, 5, "X"], [5, 5, "x"], [8, 8, "x"], [9, 4, "x"], [3, 9, "X"],
60 [21, 18, "x"], [23, 18, "x"], [22, 24, "X"],
61 [37, 20, "x"], [37, 26, "X"], [6, 18, "x"], [6, 25, "X"],
62 [20, 3, "x"], [25, 3, "x"], [18, 39, "x"], [26, 39, "x"], [28, 15, "x"],
63 ];
21364
214 // --- geometry -------------------------------------------------------------
65 function build(): g.Grid<Cell> {
66 const cells: Cell[] = new Array(GRID_W * GRID_H).fill("#");
67 for (const area of AREAS) {
68 const [c0, r0, c1, r1] = area.rect;
69 for (let r = r0; r <= r1; r++)
70 for (let c = c0; c <= c1; c++) cells[r * GRID_W + c] = area.fill;
71 }
72 for (const [c, r, cell] of CRATES) cells[r * GRID_W + c] = cell;
73 return { width: GRID_W, height: GRID_H, cell: CELL, cells };
74 }
75
76 const grid: g.Grid<Cell> = build();
21577
21678 /**
217 * Greedy meshing: merge runs of identical solid cells across a row, then merge
218 * rows below that match the run exactly. A few hundred boxes instead of a few
219 * thousand, which is the difference between one buffer and a stutter.
79 * de_forge, whole: the map this game was built on, as one value of `Level`.
80 * The letters are `cells.ts`'s and so is the reading of them — this file is the
81 * page they are written on and nothing else.
22082 */
221 function mergeSolid(map: MapDef, match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] {
222 const used = new Uint8Array(map.gridW * map.gridH);
223 const out: Box[] = [];
224 for (let r = 0; r < map.gridH; r++) {
225 for (let c = 0; c < map.gridW; c++) {
226 const i = r * map.gridW + c;
227 if (used[i] === 1 || !match(cellAt(map, c, r))) continue;
228 let w = 1;
229 while (c + w < map.gridW && used[i + w] === 0 && match(cellAt(map, c + w, r))) w++;
230 let h = 1;
231 grow: while (r + h < map.gridH) {
232 for (let k = 0; k < w; k++) {
233 const j = (r + h) * map.gridW + c + k;
234 if (used[j] === 1 || !match(cellAt(map, c + k, r + h))) break grow;
235 }
236 h++;
237 }
238 for (let dr = 0; dr < h; dr++) for (let dc = 0; dc < w; dc++) used[(r + dr) * map.gridW + c + dc] = 1;
239 const a = worldOf(map, c, r);
240 const b = worldOf(map, c + w - 1, r + h - 1);
241 out.push({
242 min: [a[0] - CELL / 2, b[1] - CELL / 2, z0],
243 max: [b[0] + CELL / 2, a[1] + CELL / 2, z1],
244 mat,
245 });
246 }
247 }
248 return out;
249 }
250
251 function siteSlab(map: MapDef, fill: Cell): Box {
252 let c0 = map.gridW, r0 = map.gridH, c1 = 0, r1 = 0;
253 for (let r = 0; r < map.gridH; r++)
254 for (let c = 0; c < map.gridW; c++)
255 if (cellAt(map, c, r) === fill) {
256 c0 = Math.min(c0, c); c1 = Math.max(c1, c);
257 r0 = Math.min(r0, r); r1 = Math.max(r1, r);
258 }
259 const a = worldOf(map, c0, r0), b = worldOf(map, c1, r1);
260 return {
261 min: [a[0] - CELL / 2, b[1] - CELL / 2, 0],
262 max: [b[0] + CELL / 2, a[1] + CELL / 2, 2],
263 mat: MAT.SITE,
264 };
265 }
266
267 function cellsOf(map: MapDef, fill: Cell): Vec3[] {
268 const out: Vec3[] = [];
269 for (let r = 0; r < map.gridH; r++)
270 for (let c = 0; c < map.gridW; c++) if (cellAt(map, c, r) === fill) out.push(worldOf(map, c, r));
271 return out;
272 }
273
274 function centre(list: Vec3[]): Vec3 {
275 const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3);
276 return [s[0] / list.length, s[1] / list.length, 0];
277 }
278
279 const half = (w: number): number => (w / 2) * CELL;
280
281 /** Build one `MapDef` from a layout's areas and crates. */
282 export function buildMap(layout: Layout): MapDef {
283 const map: MapDef = {
284 name: layout.name,
285 gridW: layout.gridW,
286 gridH: layout.gridH,
287 grid: buildGrid(layout),
288 boxes: [],
289 spawns: { t: [], ct: [] },
290 muster: { t: [0, 0, 0], ct: [0, 0, 0] },
291 sites: { A: [0, 0, 0], B: [0, 0, 0] },
292 places: buildPlaces(layout),
293 };
294 const floor: Box = {
295 min: [-half(map.gridW), -half(map.gridH), -32],
296 max: [half(map.gridW), half(map.gridH), 0],
297 mat: MAT.FLOOR,
298 };
299 map.boxes = [
300 floor,
301 siteSlab(map, "A"),
302 siteSlab(map, "B"),
303 ...mergeSolid(map, (c) => c === "#", 0, WALL_HEIGHT, MAT.WALL),
304 ...mergeSolid(map, (c) => c === "x", 0, 64, MAT.CRATE),
305 ...mergeSolid(map, (c) => c === "X", 0, 128, MAT.CRATE),
306 ];
307 map.spawns = { t: cellsOf(map, "t"), ct: cellsOf(map, "c") };
308 map.muster = { t: centre(map.spawns.t), ct: centre(map.spawns.ct) };
309 map.sites = { A: centre(cellsOf(map, "A")), B: centre(cellsOf(map, "B")) };
310 return map;
311 }
312
313 /** The map de_forge plays on, built once from today's tables. */
314 export const DE_FORGE: MapDef = buildMap(DE_FORGE_LAYOUT);
315
316 /** The second map, built the same way from its own tables. */
317 export const DE_CINDER: MapDef = buildMap(DE_CINDER_LAYOUT);
318
319 // --- the catalogue ----------------------------------------------------------
320
321 /** Every map the game can be played on. A further layout is a further key. */
322 export type MapName = "de_forge" | "de_cinder";
323
324 /** Name to built map, each one built exactly once, above. */
325 export const MAPS: Record<MapName, MapDef> = { de_forge: DE_FORGE, de_cinder: DE_CINDER };
326
327 export const MAP_NAMES: readonly MapName[] = Object.keys(MAPS) as MapName[];
328
329 export const DEFAULT_MAP: MapName = "de_forge";
330
331 /** Whatever a select, a stored setting or a request body says, the map it names or the default. */
332 export function mapFor(name: string): MapDef {
333 return Object.hasOwn(MAPS, name) ? MAPS[name as MapName] : MAPS[DEFAULT_MAP];
334 }
335
336 // --- places ---------------------------------------------------------------
337
338 /** Which bombsite a point stands in, if any. */
339 export function siteAt(map: MapDef, pos: Vec3): "A" | "B" | null {
340 const c = cellAt(map, colOf(map, pos[0]), rowOf(map, pos[1]));
341 return c === "A" || c === "B" ? c : null;
342 }
343
344 /**
345 * What a person would call the place `pos` is in, or null for a point inside
346 * the rock no area ever painted. A scan of a couple of dozen rectangles with
347 * nothing allocated — the label itself was built with the map.
348 */
349 export function placeAt(map: MapDef, pos: Vec3): string | null {
350 const col = colOf(map, pos[0]);
351 const row = rowOf(map, pos[1]);
352 for (const place of map.places) {
353 const [c0, r0, c1, r1] = place.rect;
354 if (col >= c0 && col <= c1 && row >= r0 && row <= r1) return place.name;
355 }
356 return null;
357 }
358
359 // --- pathfinding ----------------------------------------------------------
360
361 /**
362 * A* over the walkable cells, eight ways, no corner-cutting. de_forge's grid is
363 * 1936 cells; a full search is well under a millisecond, and bots only ask for
364 * one when their goal changes.
365 */
366 export function findPath(map: MapDef, from: Vec3, to: Vec3): Vec3[] {
367 const start = rowOf(map, from[1]) * map.gridW + colOf(map, from[0]);
368 const goal = rowOf(map, to[1]) * map.gridW + colOf(map, to[0]);
369 if (start === goal) return [to];
370 const n = map.gridW * map.gridH;
371 if (start < 0 || start >= n || goal < 0 || goal >= n) return [];
372 const g = new Float32Array(n).fill(Infinity);
373 const from_ = new Int32Array(n).fill(-1);
374 const open: number[] = [start];
375 const f = new Float32Array(n).fill(Infinity);
376 const h = (i: number): number =>
377 Math.hypot((i % map.gridW) - (goal % map.gridW), Math.floor(i / map.gridW) - Math.floor(goal / map.gridW));
378 g[start] = 0;
379 f[start] = h(start);
380 const seen = new Uint8Array(n);
381 while (open.length > 0) {
382 let bestAt = 0;
383 for (let i = 1; i < open.length; i++)
384 if ((f[open[i] as number] as number) < (f[open[bestAt] as number] as number)) bestAt = i;
385 const cur = open.splice(bestAt, 1)[0] as number;
386 if (cur === goal) break;
387 seen[cur] = 1;
388 const cc = cur % map.gridW, cr = Math.floor(cur / map.gridW);
389 for (let dr = -1; dr <= 1; dr++) {
390 for (let dc = -1; dc <= 1; dc++) {
391 if (dc === 0 && dr === 0) continue;
392 const nc = cc + dc, nr = cr + dr;
393 if (!walkable(map, nc, nr)) continue;
394 if (dc !== 0 && dr !== 0 && (!walkable(map, cc + dc, cr) || !walkable(map, cc, cr + dr))) continue;
395 const ni = nr * map.gridW + nc;
396 if (seen[ni] === 1) continue;
397 const step = dc !== 0 && dr !== 0 ? 1.414 : 1;
398 const cost = (g[cur] as number) + step;
399 if (cost < (g[ni] as number)) {
400 g[ni] = cost;
401 f[ni] = cost + h(ni);
402 from_[ni] = cur;
403 if (!open.includes(ni)) open.push(ni);
404 }
405 }
406 }
407 }
408 if (from_[goal] === -1) return [];
409 const out: Vec3[] = [];
410 for (let i = goal; i !== start && i !== -1; i = from_[i] as number)
411 out.unshift(worldOf(map, i % map.gridW, Math.floor(i / map.gridW)));
412 out.push(to);
413 return out;
414 }
83 export const DE_FORGE: Level = levelOf("de_forge", grid);

255 more files not shown.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs