Split The code is public; the app answers only to the crew.

40 changes from trevato/cs-engine #244

opened this

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

trevato/cs-engine:main → main 167 commits fork

  • Can't merge. Conflicts in 8 files: README.md, src/client/main.ts, src/db.test.ts…. Merge main into the branch and push again.

    Conflicts in 8 filesdeploy ae07c2d

    Website spec: 10 findings (report only)

Review

Sign in to review this change.
392 files changed +69396 −23807
.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 +1958 −940

Diff not shown — 206,920 bytes of diff (limit 100,000). +1958 −940

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 +4 −1
@@ -1,7 +1,10 @@
11 {
2 "egress": [
3 "api.typesafe.ai"
4 ],
25 "app": {
36 "run": "bun run src/server.ts",
4 "test": "bun run check",
7 "test": "bun run test",
58 "tty": "bun run src/tty.ts"
69 }
710 }
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/alone.probe.ts added +15
@@ -0,0 +1,15 @@
1 /**
2 * The child half of `scripts/alone.test.ts`: import one module as the first
3 * thing this process loads, and exit non-zero if loading it throws. The path
4 * arrives as an argument and is imported, never evaluated; the parent only
5 * ever sends a path its own walk found.
6 */
7
8 export {};
9
10 const file = process.argv[2];
11 if (file === undefined || !file.endsWith(".ts")) {
12 console.error("usage: bun run scripts/alone.probe.ts <module.ts>");
13 process.exit(2);
14 }
15 await import(file);
scripts/alone.test.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * Every module loads as the first thing a process imports — the load-order
3 * half of `scripts/alone.ts`, cheap enough for every push.
4 *
5 * Inside `bun test` every module is cached after its first load, so a ring
6 * entered at a harmless end once hides that it throws entered at another:
7 * `observe.ts` → `world.ts` → `bots.ts` → `packed.ts` → `observe.ts` threw
8 * `Cannot access 'OBS_SIZE' before initialization` entered at `observe.ts` or
9 * at `scripts/train.ts`, and only the order the suite loaded its files kept
10 * that quiet (#248). Importing each module alone in a fresh process enters
11 * every ring from every end, so a cycle that reads a value at load time fails
12 * here whichever file the suite happens to sort first.
13 */
14
15 import { expect, test } from "bun:test";
16 import { existsSync, mkdtempSync } from "node:fs";
17 import { tmpdir } from "node:os";
18 import { join } from "node:path";
19 import { each, ENTRIES, modules, parseJobs, ROOT, testFiles } from "./alone.ts";
20
21 const PROBE = join(import.meta.dir, "alone.probe.ts");
22
23 test("the walk finds the modules, both ends of the #248 ring among them, and leaves the entries out", () => {
24 const found = modules();
25 for (const file of ["src/client/observe.ts", "src/client/packed.ts", "src/client/obs-size.ts", "scripts/train.ts"])
26 expect(found).toContain(file);
27 for (const file of Object.keys(ENTRIES)) {
28 expect(existsSync(join(ROOT, file))).toBe(true); // an entry that is gone is a stale exemption
29 expect(found).not.toContain(file);
30 }
31 expect(found.some((file) => file.endsWith(".test.ts") || file.endsWith(".probe.ts"))).toBe(false);
32 expect(testFiles()).toContain("scripts/train.test.ts");
33 });
34
35 test("every module loads on its own, as the first thing a fresh process imports", async () => {
36 const data = mkdtempSync(join(tmpdir(), "alone-"));
37 const outcomes = await each(modules(), (file) => ["run", PROBE, join(ROOT, file)], 8, { FORGE_APP_DATA: data });
38 const failed = outcomes.filter((o) => !o.ok).map((o) => `${o.file}\n${o.output.trim().split("\n").slice(-6).join("\n")}`);
39 expect(failed).toEqual([]);
40 }, 120_000);
41
42 test("the probe refuses anything that is not a module path", async () => {
43 const [outcome] = await each(["x"], () => ["run", PROBE, "not-a-module"], 1);
44 expect(outcome!.ok).toBe(false);
45 expect(outcome!.output).toContain("usage:");
46 });
47
48 test("--jobs takes a whole number from 1 to 64 and nothing else", () => {
49 expect(parseJobs([])).toBe(8);
50 expect(parseJobs(["--jobs", "4"])).toBe(4);
51 expect(parseJobs(["--jobs", "64"])).toBe(64);
52 for (const bad of [["--jobs", "0"], ["--jobs", "65"], ["--jobs", "1.5"], ["--jobs"], ["--jobs", "-2"], ["-j", "4"]])
53 expect(parseJobs(bad)).toBeNull();
54 });
scripts/alone.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * Every file on its own: a result that depends on what loaded first is not a
3 * result. `bun test` loads every test file into one process, so a module that
4 * reads another's value while that module is still initialising (#248: the
5 * `packed.ts` → `observe.ts` → `world.ts` → `bots.ts` ring) or a test that
6 * leans on state another file set up (a bot thinking before anyone called
7 * `useShipped`) passes or fails depending on which file bun happened to load
8 * first. Run alone, each of them fails every time.
9 *
10 * bun run scripts/alone.ts # every test file, each in a process of its own
11 * bun run scripts/alone.ts --jobs 4 # four at a time rather than eight
12 *
13 * It is not part of `bun test`: every file run alone is the whole suite again,
14 * about half a minute at eight at a time, most of it `src/client/main.test.ts`.
15 * `scripts/alone.test.ts` is the part that is: it loads every module in the
16 * tree as the first thing a fresh process imports, which is the load-order
17 * half of this and costs a few seconds.
18 *
19 * Each file gets a minute a test rather than bun's five seconds: eight suites
20 * at once are slower than one, and this checks what loaded first, not how fast.
21 *
22 * The walk is here, shared by both, so a file added tomorrow is covered the
23 * day it lands. Every spawn is this bun and a path the walk found; no text from
24 * anywhere else reaches a command line.
25 */
26
27 import { Glob } from "bun";
28 import { join, resolve } from "node:path";
29
30 /** The repository root: every path below is relative to it. */
31 export const ROOT = resolve(import.meta.dir, "..");
32
33 /** Where the code lives. `node_modules/`, `dist/` and the rest are not ours. */
34 const TREES = ["src", "scripts"];
35
36 /**
37 * Files that do their work when they are loaded, and so are run rather than
38 * imported: loading one alone is starting it, not checking it. Each says why.
39 */
40 export const ENTRIES: Record<string, string> = {
41 "src/server.ts": "starts the server and opens the database",
42 "src/example/main.ts": "the example page's boot: draws into `window` on load",
43 "src/range/main.ts": "the range page's boot: draws into `window` on load",
44 "scripts/mutate-result.ts": "breaks src/result.ts on load and runs the tests against it",
45 };
46
47 /** Every `.ts` file under the trees, repository-relative with `/`, sorted. */
48 function walk(): string[] {
49 const files: string[] = [];
50 for (const tree of TREES)
51 for (const file of new Glob("**/*.ts").scanSync({ cwd: join(ROOT, tree) })) files.push(`${tree}/${file.replaceAll("\\", "/")}`);
52 return files.sort();
53 }
54
55 /** Every test file the suite runs. */
56 export const testFiles = (): string[] => walk().filter((file) => file.endsWith(".test.ts"));
57
58 /**
59 * Every module that is imported rather than run: not a test, not a test's
60 * helper, not a `.probe.ts` (tooling that runs in a child), not a declaration
61 * file and not one of the `ENTRIES`.
62 */
63 export const modules = (): string[] =>
64 walk().filter(
65 (file) =>
66 !file.endsWith(".test.ts") &&
67 !file.endsWith(".test-helper.ts") &&
68 !file.endsWith(".probe.ts") &&
69 !file.endsWith(".d.ts") &&
70 !(file in ENTRIES),
71 );
72
73 /** What one child said, and whether it exited clean. */
74 export type Outcome = { file: string; ok: boolean; output: string };
75
76 /** Run `args` for each file in a fresh bun, `jobs` at a time, in the files' order. */
77 export async function each(
78 files: string[],
79 args: (file: string) => string[],
80 jobs: number,
81 env: Record<string, string> = {},
82 ): Promise<Outcome[]> {
83 const outcomes: Outcome[] = new Array(files.length);
84 let next = 0;
85 const worker = async (): Promise<void> => {
86 while (next < files.length) {
87 const i = next++;
88 const file = files[i]!;
89 const child = Bun.spawn([process.execPath, ...args(file)], {
90 cwd: ROOT,
91 env: { ...process.env, ...env },
92 stdin: "ignore",
93 stdout: "pipe",
94 stderr: "pipe",
95 });
96 const [out, err, code] = await Promise.all([
97 new Response(child.stdout).text(),
98 new Response(child.stderr).text(),
99 child.exited,
100 ]);
101 outcomes[i] = { file, ok: code === 0, output: out + err };
102 }
103 };
104 await Promise.all(Array.from({ length: Math.max(1, Math.min(jobs, files.length)) }, worker));
105 return outcomes;
106 }
107
108 /** `--jobs N`, a whole number from 1 to 64; anything else is the usage. */
109 export function parseJobs(argv: string[]): number | null {
110 if (argv.length === 0) return 8;
111 if (argv.length !== 2 || argv[0] !== "--jobs" || !/^[0-9]{1,2}$/.test(argv[1]!)) return null;
112 const jobs = Number(argv[1]);
113 return jobs >= 1 && jobs <= 64 ? jobs : null;
114 }
115
116 if (import.meta.main) {
117 const jobs = parseJobs(process.argv.slice(2));
118 if (jobs === null) {
119 console.error("usage: bun run scripts/alone.ts [--jobs N] (N from 1 to 64, default 8)");
120 process.exit(2);
121 }
122 const files = testFiles();
123 const started = performance.now();
124 const outcomes = await each(files, (file) => ["test", "--timeout", "60000", `./${file}`], jobs);
125 const failed = outcomes.filter((o) => !o.ok);
126 for (const o of failed) console.log(`\n── ${o.file} fails on its own ──\n${o.output.trimEnd()}`);
127 const seconds = ((performance.now() - started) / 1000).toFixed(1);
128 console.log(`\n${files.length - failed.length} of ${files.length} test files pass on their own (${seconds}s, ${jobs} at a time)`);
129 process.exit(failed.length === 0 ? 0 : 1);
130 }
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: 147_600, gzip: 57_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 +158
@@ -0,0 +1,158 @@
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 runs when a person runs it, and once when the server starts —
30 * `src/archive.ts` packs into `FORGE_APP_DATA/engine` for the download `/engine`
31 * serves — and never at request time. Either way it writes only under the
32 * directory it was given, which it creates if it is missing and otherwise
33 * leaves alone; nothing here names `FORGE_APP_DATA` itself. Anything already in the target that this pack does
34 * not write is left where it is; the copy is the files below, not the
35 * directory's whole contents.
36 */
37
38 import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
39 import { dirname, join, relative, resolve } from "node:path";
40 import { VERSION } from "./surface.ts";
41
42 const ROOT = join(import.meta.dir, "..");
43
44 /** Where the package is taken from: the engine, and only the engine. */
45 export const ENGINE = join(ROOT, "src", "engine");
46
47 /** Where it lands when nobody says: under `dist/`, which `.gitignore` covers. */
48 export const DEFAULT_OUT = join(ROOT, "dist", "engine");
49
50 /** The package's name. It is the repository's, because it is its point. */
51 export const NAME = "cs-engine";
52
53 /**
54 * The version written into the generated `package.json`. It means the engine's
55 * surface — the names `index.ts` offers — and it moves when one of them
56 * arrives, leaves or is renamed. This repository keeps no other version and
57 * does not otherwise number its releases: the commit is the real identity of a
58 * pack, and this is what a reader of the package can compare against the guide.
59 *
60 * It is not a constant here, because a number that means a surface and sits
61 * nowhere near one goes stale in the first pull that widens the door. It lives
62 * in `src/engine/surface.json`, committed *with* the sorted list of names it
63 * belongs to; `scripts/surface.ts` reads that record, `scripts/surface.test.ts`
64 * holds today's door against it and says which part of this number to move, and
65 * `src/engine/GUIDE.md` says what a move of it promises a game that packed an
66 * older copy.
67 */
68 export { VERSION };
69
70 /** The guide, which ships: a package whose how lives elsewhere is not one. */
71 export const GUIDE = "GUIDE.md";
72
73 /** The sample, which ships beside the package rather than as a module of it. */
74 export const SAMPLE = "example.ts";
75
76 /** Every `.ts` under `dir`, recursively, as paths relative to it, sorted. */
77 export function sources(dir: string, from = dir): string[] {
78 const out: string[] = [];
79 for (const entry of readdirSync(dir, { withFileTypes: true })) {
80 const path = join(dir, entry.name);
81 if (entry.isDirectory()) out.push(...sources(path, from));
82 else if (entry.name.endsWith(".ts")) out.push(relative(from, path));
83 }
84 return out.sort();
85 }
86
87 /**
88 * Is this file a module of the package? The same three exceptions
89 * `src/engine/boundary.test.ts` knows about, read from this side: a test and a
90 * probe are not code that ships, and the sample is a game written *on* the
91 * package rather than a part of it.
92 */
93 export function isModule(file: string): boolean {
94 return (
95 file.endsWith(".ts") &&
96 !file.endsWith(".test.ts") &&
97 !file.endsWith(".probe.ts") &&
98 file !== SAMPLE
99 );
100 }
101
102 /** The generated `package.json`, as plain data: no dependencies key, none to name. */
103 export function manifest(): Record<string, unknown> {
104 return {
105 name: NAME,
106 version: VERSION,
107 type: "module",
108 exports: { ".": "./index.ts" },
109 };
110 }
111
112 /** Where a pack goes: the one argument, resolved against the repository root. */
113 export function target(arg: string | undefined): string {
114 if (arg === undefined) return DEFAULT_OUT;
115 if (arg.trim() === "") throw new Error("the directory to pack into cannot be empty");
116 return resolve(ROOT, arg);
117 }
118
119 /** What one pack wrote: the directory, the modules in it, and every file in it. */
120 export type Packed = { dir: string; modules: string[]; files: string[] };
121
122 /**
123 * Write the package into `dir`, making it if it is missing, and hand back what
124 * was written. Every path is joined onto `dir`; nothing above it is touched.
125 */
126 export function pack(dir: string = DEFAULT_OUT, from: string = ENGINE): Packed {
127 const modules = sources(from).filter(isModule);
128 if (!modules.includes("index.ts")) throw new Error(`${from} has no index.ts; there is no door to pack`);
129
130 const files: string[] = [];
131 const put = (name: string, text: string): void => {
132 const path = join(dir, name);
133 mkdirSync(dirname(path), { recursive: true });
134 writeFileSync(path, text);
135 files.push(name);
136 };
137
138 mkdirSync(dir, { recursive: true });
139 for (const module of modules) put(module, readFileSync(join(from, module), "utf8"));
140 put(GUIDE, readFileSync(join(from, GUIDE), "utf8"));
141 put(SAMPLE, readFileSync(join(from, SAMPLE), "utf8"));
142 put("package.json", `${JSON.stringify(manifest(), null, 2)}\n`);
143 return { dir, modules, files };
144 }
145
146 /** `cs-engine 0.1.0 → dist/engine (21 modules, the guide, the sample)`. */
147 export function line({ dir, modules }: Packed): string {
148 const where = relative(ROOT, dir) === "" ? dir : relative(ROOT, dir);
149 return `${NAME} ${VERSION} → ${where} (${modules.length} modules, the guide, the sample)`;
150 }
151
152 if (import.meta.main) {
153 const args = process.argv.slice(2);
154 if (args.length > 1) throw new Error("usage: bun run scripts/pack.ts [directory]");
155 const packed = pack(target(args[0]));
156 console.log(line(packed));
157 console.log(` bun build ${join(relative(ROOT, packed.dir), SAMPLE)} # the sample, against the packed door`);
158 }
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 +153
@@ -0,0 +1,153 @@
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
16 import { expect, test } from "bun:test";
17 import { mkdtempSync, writeFileSync } from "node:fs";
18 import { tmpdir } from "node:os";
19 import { join, resolve } from "node:path";
20 import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts";
21 import { parsePacked } from "../src/client/packed.ts";
22 import { PACKED } from "../src/client/shipped.ts";
23 import { CARD } from "../src/client/weights.ts";
24
25 const SOURCE = join(import.meta.dir, "..", "src", "client", "weights.ts");
26
27 /** What `parseOptions` gives back for these flags, as a difference from the defaults. */
28 const options = (...argv: string[]): Options => parseOptions(argv);
29
30 test("no flags is the run the README documents, writing the file the game ships", () => {
31 expect(options()).toEqual(DEFAULTS);
32 expect(DEFAULTS.out).toBe(WEIGHTS);
33 expect(WEIGHTS).toBe(SOURCE);
34 // The defaults are a real run, not placeholders.
35 expect(DEFAULTS.iters).toBeGreaterThan(0);
36 expect(DEFAULTS.envs).toBeGreaterThan(0);
37 expect(DEFAULTS.maps).toBeGreaterThan(1);
38 expect(DEFAULTS.holdout).toBeGreaterThan(0);
39 expect(DEFAULTS.foe).toBe("mixed");
40 expect(DEFAULTS.bench).toBe(false);
41 expect(DEFAULTS.dry).toBe(false);
42 });
43
44 test("every flag the trainer takes lands on the option it names", () => {
45 expect(options("--bench")).toEqual({ ...DEFAULTS, bench: true });
46 expect(options("--dry")).toEqual({ ...DEFAULTS, dry: true });
47 expect(options("--iters", "300")).toEqual({ ...DEFAULTS, iters: 300 });
48 expect(options("--envs", "8", "--steps", "4")).toEqual({ ...DEFAULTS, envs: 8, steps: 4 });
49 expect(options("--maps", "1")).toEqual({ ...DEFAULTS, maps: 1 });
50 expect(options("--holdout", "3")).toEqual({ ...DEFAULTS, holdout: 3 });
51 expect(options("--foe", "scripted")).toEqual({ ...DEFAULTS, foe: "scripted" });
52 expect(options("--foe", "still")).toEqual({ ...DEFAULTS, foe: "still" });
53 // A seed is a label, not a size, so zero is one.
54 expect(options("--seed", "0")).toEqual({ ...DEFAULTS, seed: 0 });
55 // The order they are written in is not a meaning, and the last one wins.
56 expect(options("--dry", "--iters", "7")).toEqual(options("--iters", "7", "--dry"));
57 expect(options("--iters", "7", "--iters", "9").iters).toBe(9);
58 });
59
60 test("a count that is not a count is a sentence and not a twelve-minute run on NaN", () => {
61 expect(() => options("--iters", "abc")).toThrow(`--iters wants a whole number greater than 0, and got "abc"`);
62 expect(() => options("--iters", "NaN")).toThrow("--iters wants a whole number");
63 expect(() => options("--envs", "-4")).toThrow("--envs wants a whole number");
64 expect(() => options("--envs", "0")).toThrow("--envs wants a whole number");
65 expect(() => options("--steps", "2.5")).toThrow("--steps wants a whole number");
66 expect(() => options("--maps", "")).toThrow("--maps wants a whole number");
67 expect(() => options("--maps", "1e400")).toThrow("--maps wants a whole number");
68 // A seed of 0 is fine; a seed below it is not a seed.
69 expect(() => options("--seed", "-1")).toThrow("--seed wants a whole number of 0 or more");
70 });
71
72 test("a flag with nothing after it is refused rather than swallowing the next flag", () => {
73 expect(() => options("--iters")).toThrow("--iters needs a value");
74 expect(() => options("--iters", "--dry")).toThrow("--iters needs a value");
75 expect(() => options("--foe")).toThrow("--foe needs a value");
76 expect(() => options("--out")).toThrow("--out needs a value");
77 expect(() => options("--out", " ")).toThrow("--out cannot be empty");
78 });
79
80 test("a flag nobody knows, and a word that is not a flag, stop the run and say so", () => {
81 const unknown = (): Options => options("--iter", "300");
82 expect(unknown).toThrow("unknown flag --iter");
83 // The complaint lists what there is, so the typo is one line from its fix.
84 expect(unknown).toThrow("--iters");
85 expect(() => options("300")).toThrow(`"300" is not a flag`);
86 expect(() => options("--foe", "friendly")).toThrow(`--foe is one of mixed, still, scripted, and got "friendly"`);
87 expect(USAGE).toContain("bun run scripts/train.ts");
88 for (const flag of ["--bench", "--dry", "--envs", "--foe", "--holdout", "--iters", "--maps", "--out", "--seed", "--steps"])
89 expect(USAGE).toContain(flag);
90 });
91
92 test("--out and --policy are the two paths a run writes, resolved against where it was run", () => {
93 expect(options("--out", "/tmp/weights.ts").out).toBe("/tmp/weights.ts");
94 expect(options("--out", "elsewhere/w.ts").out).toBe(resolve("elsewhere/w.ts"));
95 expect(options("--out", "/tmp/w.ts").out).not.toBe(WEIGHTS);
96 // Since #214 the weights are their own file: the card is one write and the
97 // packed policy the build copies into static/ is the other.
98 expect(DEFAULTS.policy).toBe(POLICY);
99 expect(POLICY.endsWith("src/client/weights.json")).toBe(true);
100 expect(options("--policy", "/tmp/w.json").policy).toBe("/tmp/w.json");
101 expect(options("--policy", "elsewhere/w.json").policy).toBe(resolve("elsewhere/w.json"));
102 expect(options("--policy", "/tmp/w.json").out).toBe(WEIGHTS);
103 });
104
105 test("the policy document it writes is what the page's reader accepts, and is the file in the tree", async () => {
106 const text = policyDocument(PACKED);
107 expect(text.endsWith("\n")).toBe(true);
108 // One line of JSON: these bytes are copied into static/ verbatim and are
109 // what a browser downloads, so `parsePacked` is the other end of this.
110 expect(parsePacked(text)).toEqual(PACKED);
111 expect(policyDocument(PACKED)).toBe(await Bun.file(POLICY).text());
112 });
113
114 test("the generated module says not to edit it, and says it first", () => {
115 const text = cardModule(CARD);
116 expect(text.startsWith("/**")).toBe(true);
117 expect(text).toContain("scripts/train.ts. Do not edit by hand.");
118 expect(text.indexOf("Do not edit by hand")).toBeLessThan(text.indexOf("export const CARD"));
119 expect(text.endsWith("\n")).toBe(true);
120 });
121
122 test("the same card and the same weights write the same text, every time", () => {
123 expect(cardModule(CARD)).toBe(cardModule(CARD));
124 // And a different card is a different file in exactly one place: its numbers.
125 const other = cardModule({ ...CARD, winRate: 3 });
126 expect(other).not.toBe(cardModule(CARD));
127 expect(other).toContain(`"winRate": 3`);
128 expect(other.replace(`"winRate": 3`, `"winRate": ${CARD.winRate}`)).toBe(cardModule(CARD));
129 });
130
131 test("what it emits parses, and exports what src/client/weights.ts exports today", async () => {
132 // Transpiled rather than imported as TypeScript: the generated module has no
133 // imports at all, so what is left is a module with nothing of this repository
134 // in it.
135 const js = new Bun.Transpiler({ loader: "ts" }).transformSync(cardModule(CARD));
136 const file = join(mkdtempSync(join(tmpdir(), "train-")), "weights.js");
137 writeFileSync(file, js);
138 const generated = (await import(file)) as { CARD: typeof CARD };
139 const shipped = await import("../src/client/weights.ts");
140
141 expect(Object.keys(generated).sort()).toEqual(Object.keys(shipped).sort());
142 expect(generated.CARD).toEqual(CARD);
143 // And the weights are not in it any more: they are the JSON beside it.
144 expect(Object.keys(generated)).not.toContain("PACKED");
145 });
146
147 test("the file in the tree is this generator's output, character for character", async () => {
148 // If this fails, either somebody edited `src/client/weights.ts` by hand or
149 // the generator's formatting moved under it. Both are worth a failure: the
150 // page reads the file in the tree, and every other test here reads the
151 // generator.
152 expect(cardModule(CARD)).toBe(await Bun.file(SOURCE).text());
153 });
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/archive.test.ts added +70
@@ -0,0 +1,70 @@
1 import { expect, test } from "bun:test";
2 import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { GUIDE, manifest, NAME, SAMPLE, VERSION } from "../scripts/pack.ts";
6 import { archiveDir, baseName, buildArchive } from "./archive.ts";
7 import { untar } from "./tar.ts";
8
9 /** A data directory of this test's own, removed however the body ends. */
10 function inTemp<T>(body: (dir: string) => T): T {
11 const dir = mkdtempSync(join(tmpdir(), "archive-"));
12 try {
13 return body(dir);
14 } finally {
15 rmSync(dir, { recursive: true, force: true });
16 }
17 }
18
19 test("the archive lives under the data directory, never the repository", () => {
20 expect(archiveDir("/srv/data")).toBe(join("/srv/data", "engine"));
21 expect(baseName("1.2.3")).toBe(`${NAME}-1.2.3`);
22 });
23
24 test("the tarball unpacks to one directory whose package.json names the engine's version", () => {
25 inTemp((data) => {
26 const archive = buildArchive(archiveDir(data));
27 // The pack, and the archive beside it, and nothing else.
28 expect(readdirSync(archiveDir(data)).sort()).toEqual([baseName(), `${baseName()}.tar.gz`].sort());
29 expect(archive.file).toBe(`${NAME}-${VERSION}.tar.gz`);
30
31 const read = untar(Bun.gunzipSync(archive.bytes));
32 const top = `${NAME}-${VERSION}/`;
33 for (const { name } of read) expect(name.startsWith(top)).toBe(true);
34 const at = (name: string) => read.find((entry) => entry.name === top + name);
35
36 const pkg = JSON.parse(new TextDecoder().decode(at("package.json")?.data));
37 expect(pkg).toEqual(manifest());
38 expect(pkg.version).toBe(VERSION);
39 expect(pkg.dependencies).toBeUndefined();
40
41 // The door, the guide and the sample are in it, and they are this tree's.
42 const engine = join(import.meta.dir, "engine");
43 for (const name of ["index.ts", GUIDE, SAMPLE])
44 expect(new TextDecoder().decode(at(name)?.data)).toBe(readFileSync(join(engine, name), "utf8"));
45 // Every module the pack counted, and no test.
46 for (const module of archive.modules) expect(at(module)).toBeDefined();
47 expect(read.some(({ name }) => name.endsWith(".test.ts"))).toBe(false);
48 expect(read.map(({ name }) => name)).toEqual(archive.files);
49 expect(archive.unpacked).toBe(read.reduce((sum, { size }) => sum + size, 0));
50 });
51 });
52
53 test("the bytes held are the file written, and the href carries their hash", () => {
54 inTemp((data) => {
55 const archive = buildArchive(archiveDir(data));
56 expect(readFileSync(join(archiveDir(data), archive.file))).toEqual(Buffer.from(archive.bytes));
57 const hex = new Bun.CryptoHasher("sha256").update(archive.bytes).digest("hex");
58 expect(archive.sha256).toBe(hex);
59 expect(archive.href).toBe(`/engine/${NAME}-${VERSION}.${hex.slice(0, 10)}.tar.gz`);
60 });
61 });
62
63 test("the same tree packs to the same bytes, so a hash in the URL is honest", () => {
64 inTemp((data) => {
65 const once = buildArchive(join(data, "a"));
66 const twice = buildArchive(join(data, "b"));
67 expect(twice.sha256).toBe(once.sha256);
68 expect(twice.bytes).toEqual(once.bytes);
69 });
70 });
src/archive.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * The engine as one file a stranger can take: `scripts/pack.ts` run once at
3 * start-up into this deployment's data directory, tarred by `./tar.ts`, gzipped
4 * by Bun, written beside it, and read back.
5 *
6 * FORGE_APP_DATA/engine/cs-engine-<version>/ ← the pack, as files
7 * FORGE_APP_DATA/engine/cs-engine-<version>.tar.gz ← the same files, as one
8 *
9 * Nothing here writes into the repository and nothing here runs per request:
10 * `src/server.ts` calls `buildArchive` once before it listens and serves the
11 * bytes it hands back. What those bytes are is the pack's own list — every
12 * shipping module, the guide, the sample and a generated `package.json` — under
13 * one top-level directory named for the version, which is what `tar -xzf`
14 * leaves behind.
15 *
16 * The version is `VERSION` from `scripts/pack.ts`, which is the one in
17 * `src/engine/surface.json` that `scripts/surface.test.ts` holds the door
18 * against (#191). There is no second copy of it here.
19 *
20 * The archive is a function of the files: every mtime is 0, owner and group
21 * are 0, and Bun's gzip writes no timestamp. So its sha256 changes exactly when
22 * a byte of the package does — which is what lets the URL carry a slice of that
23 * hash and be cached for a year, when the version alone would not: behaviour
24 * moves under a version that stands still (see the guide's "The version, and
25 * what it promises").
26 */
27
28 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
29 import { join } from "node:path";
30 import { NAME, pack, VERSION } from "../scripts/pack.ts";
31 import { tar } from "./tar.ts";
32
33 /** The level the archive is gzipped at: it is built once, so it is built small. */
34 export const ARCHIVE_LEVEL = 9;
35
36 /** The directory the pack and its archive go in, under this deployment's data. */
37 export const archiveDir = (data = process.env.FORGE_APP_DATA ?? "./data"): string => join(data, "engine");
38
39 /** The name a download is saved under, and the directory it unpacks to. */
40 export const baseName = (version = VERSION): string => `${NAME}-${version}`;
41
42 /** What start-up made, as the page and the route need it. Read once, held. */
43 export type Archive = {
44 /** The package's name and version, as its `package.json` says them. */
45 name: string;
46 version: string;
47 /** What a browser saves it as: `cs-engine-0.3.0.tar.gz`. */
48 file: string;
49 /** Where it answers: the file name with ten hex digits of its sha256 in it. */
50 href: string;
51 /** The archive, exactly the bytes on disk. */
52 bytes: Uint8Array<ArrayBuffer>;
53 /** Hex sha256 of `bytes`: what a person checks a download against. */
54 sha256: string;
55 /** Modules of the package, as the pack counted them — `index.ts` included. */
56 modules: string[];
57 /** Every path in the archive, top directory and all, in archive order. */
58 files: string[];
59 /** Bytes of the files before tar and gzip. */
60 unpacked: number;
61 };
62
63 /**
64 * Pack into `dir`, tar and gzip what was packed, write the archive, and read it
65 * back. The entries are the files `pack` says it wrote, read from where it wrote
66 * them — so the archive is the pack on disk, not a second opinion of it.
67 */
68 export function buildArchive(dir = archiveDir()): Archive {
69 const base = baseName();
70 mkdirSync(dir, { recursive: true });
71 const packed = pack(join(dir, base));
72 const names = [...packed.files].sort();
73 const entries = names.map((name) => ({
74 name: `${base}/${name}`,
75 data: new Uint8Array(readFileSync(join(packed.dir, name))),
76 }));
77 const file = `${base}.tar.gz`;
78 const path = join(dir, file);
79 writeFileSync(path, Bun.gzipSync(tar(entries), { level: ARCHIVE_LEVEL }));
80 const bytes = new Uint8Array(readFileSync(path));
81 const sha256 = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
82 return {
83 name: NAME,
84 version: VERSION,
85 file,
86 href: `/engine/${base}.${sha256.slice(0, 10)}.tar.gz`,
87 bytes,
88 sha256,
89 modules: packed.modules,
90 files: entries.map(({ name }) => name),
91 unpacked: entries.reduce((sum, { data }) => sum + data.length, 0),
92 };
93 }
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 +511
@@ -0,0 +1,511 @@
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; and again with all ten players
31 * dead, each one a body lying on the floor it fell to (#296).
32 * - `buildRangeScene` on a live run: the room, the plates standing in it and a
33 * tracer still fading. The range's frame is a thirtieth of the shooter's and
34 * is measured anyway, because a game with no ceiling is where the cost goes.
35 * - A snapshot reconciled in a room: your own body put back on the server's
36 * word and twenty ticks of your unanswered inputs replayed through the mover
37 * — a 250 ms round trip and a snapshot's wait — which a member's page does
38 * sixteen times a second on top of its frame.
39 * - And a frame deliberately made dearer, which must fail the same ceiling: a
40 * guard nothing can trip is not a guard, and the last test here proves this
41 * one trips.
42 *
43 * ## Why the ceilings are where they are
44 *
45 * Measured on the machine that wrote this, idle: a tick **0.078 ms**, the
46 * shooter's hungry frame **0.0096 ms**, the range's **0.0003 ms**. The ceilings
47 * below are not those numbers — a test pinned to today's number fails the first
48 * time CI is busy — they are **1.0 ms**, **0.15 ms** and **0.02 ms**: thirteen,
49 * sixteen and sixty-six times today's cost, and each still a small slice of the
50 * 15.625 ms a frame has. A radar, a gait, a grenade trail, footsteps or a
51 * damage indicator that made one of these an order of magnitude dearer trips
52 * the ceiling. A box a few times slower than this one does not: with all eighty
53 * cores of this machine saturated by other work the three measured 0.27 ms,
54 * 0.022 ms and 0.0010 ms — still under their ceilings by four, seven and twenty
55 * times over.
56 *
57 * The room-second is the same argument at a different scale, and it is worth
58 * saying exactly which second it is: the **first** of a live round, which is
59 * ten bots walking out of spawn, and which reads about **4.6 ms** here. A round
60 * gets dearer as it meets itself — `bun run scripts/room.ts` measures an
61 * eight-second window on the forge's box, eighty cores at a load average of
62 * twenty, and reads 5.4–8.1 ms an average second with 10–24 ms in the dearest
63 * one — so the ceiling is set against the bench's numbers and not against this
64 * test's own. It is **40 ms**: roughly five times an average second of play,
65 * four per cent of the second a room has to be stepped inside, and still above
66 * the dearest second this box has produced. What it catches is a change that
67 * makes the simulation *several* times dearer, not one that makes it a fifth
68 * dearer — a tighter ceiling on a shared box is a red build about somebody
69 * else's job, which nobody would read twice.
70 *
71 * The multiple is deliberately larger where the number is smaller. Clock
72 * resolution and scheduler jitter are absolute, not proportional: ten times
73 * 0.3 µs is inside the noise of any machine, so the range's ceiling is sixty
74 * times its cost and still a seven-hundredth of a frame, which is what makes it
75 * worth asserting at all rather than not asserting it.
76 *
77 * ## Why it does not flake
78 *
79 * Three things, and the file is worth little without all of them.
80 *
81 * Every measurement **warms up first** — two batches thrown away, so the JIT
82 * has compiled and the shapes have settled — and then takes the **median of
83 * seven batches** rather than the mean of one, so a batch that lands on a GC
84 * pause is one of seven and the median steps over it.
85 *
86 * And every batch is timed by **two clocks**: the wall clock, and this
87 * process's own CPU time (`process.cpuUsage`). Both are upper bounds on the
88 * work a frame does, and interference only ever pushes one of them up — a
89 * descheduled process reads a long wall clock, and Bun's GC and JIT threads
90 * burning a second core read a long CPU clock — so the measurement is the
91 * **smaller of the two medians**, which is the tightest bound the pair can
92 * honestly support. It matters: measured against 160 spinning processes on 80
93 * cores, the wall clock put a tick at 7.8 ms — a hundred times its real cost,
94 * and past any ceiling worth having — while the CPU clock put it at 0.43 ms and
95 * every ceiling here held. That is the difference between a test that fails
96 * when the code got slower and one that fails when CI got busy.
97 *
98 * The whole file costs about six tenths of a second — a thirty-fifth of the
99 * twenty-second suite it joined — which is the price of batches big enough for
100 * a clock to resolve.
101 */
102
103 import { expect, test } from "bun:test";
104 import { pushEffect, resetEffects, type Camera, type Vec3 } from "./engine/index.ts";
105 import { DE_FORGE } from "./client/map.ts";
106 import { SHORT } from "./client/match.ts";
107 import { buildScene, createScene } from "./client/scene.ts";
108 import { useShipped } from "./client/shipped.ts";
109 import { NO_INPUT, createWorld, update, type Effect, type World } from "./client/world.ts";
110 import { buildRangeScene, createRangeScene } from "./range/scene.ts";
111 import { EYE, createRun, start, step } from "./range/targets.ts";
112 import { createRoom, stepRoom, type Room } from "./rooms/room.ts";
113 import { createPrediction, keep, reconcile, TICK as PREDICT_TICK } from "./client/predict.ts";
114
115 /** The fixed simulation step, and the wall clock a frame of it has to fit in. */
116 const TICK = 1 / 64;
117 const FRAME_BUDGET = 1000 * TICK;
118
119 /** One tick of ten bodies, nine of them thinking. Today: 0.078 ms. */
120 const TICK_CEILING = 1.0;
121 /** The shooter's scene, a hungry frame of it. Today: 0.0096 ms. */
122 const SCENE_CEILING = 0.15;
123 /** The range's scene. Today: 0.0003 ms — see the note on small numbers above. */
124 const RANGE_SCENE_CEILING = 0.02;
125 /**
126 * One second of play in one room, stepped here. Today: 4.6 ms for the second
127 * this test takes, 5.4–8.1 ms for an average one on the forge's box under load
128 * — see the note above, and `bun run scripts/room.ts` for where the numbers
129 * come from and what a deployment does with them.
130 */
131 const ROOM_SECOND_CEILING = 40;
132
133 /**
134 * A room member's own body replayed over a 250 ms round trip and the snapshot
135 * interval on top of it — twenty ticks of the mover — which is what every
136 * snapshot that lands costs a page sixteen times a second. Today: 0.063 ms, so
137 * the ceiling is eight times that and still a thirtieth of a frame.
138 */
139 const REPLAY_CEILING = 0.5;
140 /** How many ticks that replay is: 250 ms of 64 Hz, plus one 62.5 ms snapshot wait. */
141 const REPLAY_TICKS = Math.round((250 + 62.5) / (1000 * PREDICT_TICK));
142
143 /** Batches thrown away before any are kept, so nothing measured is cold. */
144 const WARM = 2;
145 /** Batches kept, of which the median is the answer. Odd, so there is one. */
146 const BATCHES = 7;
147
148 /**
149 * Milliseconds of CPU this process has burned, user and system together. The
150 * clock every batch below is timed by, for the reason in the header: it counts
151 * work done and not time spent waiting for a core.
152 */
153 const cpuMs = (): number => {
154 const used = process.cpuUsage();
155 return (used.user + used.system) / 1000;
156 };
157
158 const median = (xs: number[]): number =>
159 [...xs].sort((a, b) => a - b)[(xs.length - 1) >> 1] as number;
160
161 /** One batch of operations, in milliseconds each, read off both clocks. */
162 type Batch = { wall: number; cpu: number };
163
164 /** `runs` operations of `work`, timed by both clocks and divided by `runs`. */
165 function timed(runs: number, work: () => void): Batch {
166 const wall = performance.now();
167 const cpu = cpuMs();
168 work();
169 return { wall: (performance.now() - wall) / runs, cpu: (cpuMs() - cpu) / runs };
170 }
171
172 /**
173 * Long enough that a machine under real contention finishes rather than fails:
174 * the measurement itself is immune to load, but the wall clock the runner holds
175 * a test to is not, and a timeout is a flake by another name.
176 */
177 const PATIENCE = 60_000;
178
179 /**
180 * What a ceiling here is a slice of. A frame has 15.625 ms of a person's
181 * patience; a room-second has a second of a core, which is the same sentence
182 * about a different clock, and the printed line says which one it meant.
183 */
184 const FRAME = { ms: FRAME_BUDGET, name: "frame" };
185 const ROOM_SECOND = { ms: 1000, name: "second a room has" };
186
187 /**
188 * Run `batch` — which times some number of operations and hands back the
189 * milliseconds one of them cost — until it is warm, then seven more times, and
190 * return the median. The number is printed, because a green run should still
191 * tell a person what a frame costs today.
192 */
193 function measure(label: string, ceiling: number, batch: () => Batch, within = FRAME): number {
194 for (let i = 0; i < WARM; i++) batch();
195 const kept: Batch[] = [];
196 for (let i = 0; i < BATCHES; i++) kept.push(batch());
197 const wall = median(kept.map((b) => b.wall));
198 const cpu = median(kept.map((b) => b.cpu));
199 const cost = Math.min(wall, cpu);
200 console.log(
201 ` ${label}: ${cost.toFixed(4)} ms` +
202 ` (median of ${BATCHES}: wall ${wall.toFixed(4)}, cpu ${cpu.toFixed(4)};` +
203 ` ceiling ${ceiling} ms of a ${within.ms.toFixed(3)} ms ${within.name})`,
204 );
205 return cost;
206 }
207
208 /**
209 * A round of the shooter already under way: ten bodies on de_forge, the freeze
210 * over. The phase is set rather than played through, because eight seconds of
211 * freeze is eight seconds of the same tick and this file has a second to spend.
212 */
213 function liveRound(seed: number): World {
214 const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seed);
215 world.match.phase = "live";
216 world.match.timer = SHORT.roundTime;
217 return world;
218 }
219
220 /** How many ticks one batch of the simulation is: a second of play. */
221 const TICKS = 64;
222
223 test("a 64 Hz tick with ten bodies stays well inside the frame budget", () => {
224 useShipped(); // a bot with no policy does not think, and would not be measured
225 let alive = 0;
226 const each = measure("a 64 Hz tick, ten bodies, nine of them thinking", TICK_CEILING, () => {
227 // A fresh round per batch: a world played for thousands of ticks is a world
228 // where people have died, and a dead bot does not think — the measurement
229 // would drift downwards into meaninglessness.
230 const world = liveRound(7);
231 const batch = timed(TICKS, () => {
232 for (let i = 0; i < TICKS; i++) update(world, TICK, NO_INPUT);
233 });
234 alive = world.players.filter((p) => p.alive).length;
235 return batch;
236 });
237
238 expect(each).toBeLessThan(TICK_CEILING);
239 // And the ticks that were measured really did have ten bodies in them: if the
240 // round had emptied out, the number above would be a number about nothing.
241 expect(alive).toBe(10);
242 }, PATIENCE);
243
244 /**
245 * The same tick with smoke standing in the round: ten clouds, one per body,
246 * which is the most this game can have at once and far more than a round ever
247 * does. It is measured against the *same* ceiling as the plain tick, because
248 * what #165 promised is that a cloud does not buy a sight test a second price:
249 * `canSee` traces the level's fifty-odd boxes first, as it always has, and then
250 * runs `segmentSphere` over the clouds — a dot product and at most one square
251 * root each, with no allocation — and in a round nobody smoked the loop is a
252 * length check on an empty array and costs nothing measurable at all.
253 *
254 * Measured on the machine that wrote this, idle: a plain tick 0.102 ms, this
255 * one 0.107 ms. Ten clouds cost about **0.005 ms a tick** — three hundredths of
256 * one per cent of the 15.625 ms a frame has, and a twentieth of what a tick
257 * already cost — and both are nearly ten times under the 1.0 ms ceiling, which
258 * did not move for any of this.
259 */
260 test("a tick with ten clouds of smoke standing is still well inside the budget", () => {
261 useShipped();
262 let clouds = 0;
263 const each = measure("a 64 Hz tick with ten clouds standing", TICK_CEILING, () => {
264 const world = liveRound(7);
265 // One cloud on each body, opened a moment ago so every one of them is at
266 // its full radius for the whole batch rather than growing out of nothing.
267 for (const p of world.players)
268 world.clouds.push({ pos: [p.pos[0], p.pos[1], p.pos[2] + 58], born: world.time - 2, owner: p.id });
269 const batch = timed(TICKS, () => {
270 for (let i = 0; i < TICKS; i++) update(world, TICK, NO_INPUT);
271 });
272 clouds = world.clouds.length;
273 return batch;
274 });
275
276 expect(each).toBeLessThan(TICK_CEILING);
277 // The ticks that were measured really did have ten clouds standing in them.
278 expect(clouds).toBe(10);
279 }, PATIENCE);
280
281 /**
282 * A room's own round, whose freeze is set aside the way `liveRound` sets it
283 * aside and for the same reason. Every one of the ten bodies is a bot here —
284 * `createRoom` makes `world.me` one too, because a room has seats and not a
285 * person — so this is ten policies thinking and not nine.
286 */
287 function liveRoom(seed: number): Room {
288 const room = createRoom("BUDGET", { map: "de_forge", seed, length: "short", now: 0 });
289 room.world.match.phase = "live";
290 room.world.match.timer = room.rules.roundTime;
291 return room;
292 }
293
294 /**
295 * What this process pays to *be* one room for one second of play. It is the
296 * number `ROOM_CAP` is divided out of, so the day the simulation gets several
297 * times dearer this is where it is read — before twenty rooms full of people
298 * find out for themselves.
299 *
300 * The snapshots are not in here: what they weigh is pinned in
301 * `src/rooms/wire.test.ts` and what they cost is printed by
302 * `bun run scripts/room.ts`, and this holds the one number that is the room
303 * whether anybody is watching it or not.
304 */
305 test("a second of play in one room costs a small fraction of the second it had", () => {
306 useShipped(); // a bot with no policy does not think, and would not be measured
307 let alive = 0;
308 const each = measure(
309 "a room-second: 64 steps of ten bodies, all ten thinking",
310 ROOM_SECOND_CEILING,
311 () => {
312 // A fresh round per batch, for the reason the tick above takes one.
313 const room = liveRoom(7);
314 const batch = timed(1, () => {
315 for (let i = 0; i < TICKS; i++) stepRoom(room);
316 });
317 alive = room.world.players.filter((p) => p.alive).length;
318 return batch;
319 },
320 ROOM_SECOND,
321 );
322
323 expect(each).toBeLessThan(ROOM_SECOND_CEILING);
324 // And the second that was measured really was ten bodies thinking: a room
325 // that had emptied out would be a cheap number about nothing.
326 expect(alive).toBe(10);
327 }, PATIENCE);
328
329 /**
330 * And the room's ceiling bites too, for the reason the frame's does. Sixteen
331 * seconds of play charged as one is a simulation sixteen times dearer — nothing
332 * about the room changed, it is simply asked for sixteen times the work inside
333 * the second it had — and the measurement notices and the ceiling refuses it.
334 * Sixteen and not two, for the same reason the frame's proof is written at
335 * sixty-four substeps and not at ten: the margin between the ceiling and
336 * today's cost is what keeps a busy box from turning this file red, and a proof
337 * has to clear that margin on a machine slower than this one as well.
338 */
339 test("a room-second that got several times dearer is past the ceiling", () => {
340 useShipped();
341 const dearer = 16;
342 const each = measure(
343 `a room-second asked to step ${dearer} of them, to prove the ceiling bites`,
344 ROOM_SECOND_CEILING,
345 () => {
346 const room = liveRoom(7);
347 return timed(1, () => {
348 for (let i = 0; i < TICKS * dearer; i++) stepRoom(room);
349 });
350 },
351 ROOM_SECOND,
352 );
353
354 expect(each).toBeGreaterThan(ROOM_SECOND_CEILING);
355 }, PATIENCE);
356
357 /**
358 * The guard's own guard. A ceiling nothing can reach is a green light wired to
359 * nothing: a loop the JIT lifted out, a clock read twice in a row, a batch that
360 * timed an empty function would all pass every assertion above and say so
361 * cheerfully forever. So here is a frame that really did get dearer — the same
362 * 1/64 s of play, stepped in sixty-four substeps instead of one — and the
363 * measurement notices it and the ceiling refuses it.
364 *
365 * Sixty-four substeps is not sixty-four times the cost: some of a tick is
366 * per-call work that does not multiply, and it measures about 35× a plain one,
367 * 2.6–3.8 ms against a 1.0 ms ceiling. That margin is the point of the number.
368 * A tenfold regression would land just under the ceiling on *this* machine and
369 * over it on any machine slower — which is the price of a ceiling that never
370 * flakes, and the reason this proof is not written at ten.
371 */
372 test("a frame that got an order of magnitude dearer is past the ceiling", () => {
373 useShipped();
374 const dearer = 64;
375 const ops = 4;
376 const each = measure(`a 64 Hz tick cut into ${dearer} substeps, to prove the ceiling bites`, TICK_CEILING, () => {
377 const world = liveRound(7);
378 return timed(ops, () => {
379 for (let i = 0; i < ops; i++)
380 for (let j = 0; j < dearer; j++) update(world, TICK / dearer, NO_INPUT);
381 });
382 });
383
384 expect(each).toBeGreaterThan(TICK_CEILING);
385 }, PATIENCE);
386
387 test("the shooter's scene is built in a fraction of what a tick costs", () => {
388 useShipped();
389 const world = liveRound(11);
390 for (let i = 0; i < 64; i++) update(world, TICK, NO_INPUT);
391
392 // A hungry frame: the bomb down and a firefight's worth of effects still in
393 // the ring, so the line buffer is being filled and not just the instances.
394 resetEffects(world.effects);
395 world.bomb.state = "planted";
396 world.bomb.pos = [500, 600, 20];
397 for (let i = 0; i < 64; i++) {
398 const at = world.time - 0.02;
399 const effect: Effect =
400 i % 3 === 0
401 ? { kind: "tracer", from: [i, 0, 40], to: [i, 200, 45], at }
402 : i % 3 === 1
403 ? { kind: "impact", at, pos: [i, 2, 3], normal: [0, 0, 1] }
404 : { kind: "blood", at, pos: [i, 2, 3] };
405 pushEffect(world.effects, effect, effect.at);
406 }
407
408 const camera: Camera = {
409 pos: [world.me.pos[0], world.me.pos[1], world.me.pos[2] + 64],
410 yaw: world.me.yaw, pitch: world.me.pitch, fov: 90,
411 };
412 const scene = createScene();
413 const runs = 1000;
414 const each = measure("buildScene, a firefight frame", SCENE_CEILING, () =>
415 timed(runs, () => {
416 for (let i = 0; i < runs; i++) buildScene(world, camera, scene);
417 }),
418 );
419
420 expect(each).toBeLessThan(SCENE_CEILING);
421 // The frame that was timed was the hungry one it claims to be: bodies drawn
422 // and a few hundred line vertices, not an empty scene measured very fast.
423 expect(scene.instanceCount).toBeGreaterThan(5);
424 expect(scene.lineCount).toBeGreaterThan(100);
425 }, PATIENCE);
426
427 test("the shooter's scene holds the same ceiling with ten bodies on the floor", () => {
428 useShipped();
429 const world = liveRound(11);
430 for (let i = 0; i < 64; i++) update(world, TICK, NO_INPUT);
431 // The end of a round nobody walked away from: all ten down and the bomb
432 // planted. Every body is one more instance on the player mesh and one trace
433 // for the floor under it, which is the cost this holds.
434 for (const p of world.players) p.alive = false;
435 world.bomb.state = "planted";
436 world.bomb.pos = [500, 600, 20];
437 const camera: Camera = {
438 pos: [world.me.pos[0], world.me.pos[1], world.me.pos[2] + 120],
439 yaw: world.me.yaw, pitch: 40, fov: 90,
440 };
441 const scene = createScene();
442 buildScene(world, camera, scene);
443 world.time += 1;
444 const runs = 1000;
445 const each = measure("buildScene, ten bodies down", SCENE_CEILING, () =>
446 timed(runs, () => {
447 for (let i = 0; i < runs; i++) buildScene(world, camera, scene);
448 }),
449 );
450
451 expect(each).toBeLessThan(SCENE_CEILING);
452 // All ten were laid, inside the pool, and none of them standing.
453 const bodies = scene.instances.slice(0, scene.instanceCount).filter((i) => (i.tip ?? 0) > 0);
454 expect(bodies.length).toBe(10);
455 expect(scene.instanceCount).toBe(12);
456 }, PATIENCE);
457
458 test("the range's scene is built in a fraction of what a tick costs", () => {
459 const run = createRun(1);
460 start(run);
461 // Five seconds in, so the lane is as full as this drill ever makes it.
462 for (let i = 0; i < 64 * 5; i++) step(run, TICK);
463
464 const scene = createRangeScene();
465 const view = { eye: EYE, yaw: 3, pitch: -1 };
466 const trail = { from: EYE, to: [900, 0, 60] as [number, number, number], at: 0 };
467 const runs = 10000;
468 const each = measure("buildRangeScene, plates and a tracer", RANGE_SCENE_CEILING, () =>
469 timed(runs, () => {
470 for (let i = 0; i < runs; i++) buildRangeScene(run, view, trail, 0.02, scene);
471 }),
472 );
473
474 expect(each).toBeLessThan(RANGE_SCENE_CEILING);
475 // The room, the plates standing in it, and the tracer still fading — which is
476 // one line, and a line is two vertices, which is what `lineCount` counts.
477 expect(run.targets.length).toBeGreaterThan(0);
478 expect(scene.instanceCount).toBe(1 + run.targets.length);
479 expect(scene.lineCount).toBe(2);
480 }, PATIENCE);
481
482 /**
483 * What prediction costs a page in a room: a snapshot lands, your body goes back
484 * to the server's word, and every input it has not answered is played through
485 * the mover again. Twenty of them is a 250 ms round trip with a snapshot's wait
486 * on top, on de_forge's boxes, strafing and turning so the walls are met. It is
487 * measured here so a slower mover, or a history that forgot its cap, cannot
488 * quietly become the frame's problem.
489 */
490 test("replaying a 250 ms round trip of your own inputs is a small slice of a frame", () => {
491 const world = liveRound(7);
492 const me = world.me;
493 const own = { pos: [...me.pos] as Vec3, vel: [0, 0, 0] as Vec3 };
494 const runs = 200;
495 let replayed = 0;
496 const each = measure(`a snapshot reconciled: ${REPLAY_TICKS} ticks of your own inputs replayed`, REPLAY_CEILING, () => {
497 const p = createPrediction();
498 reconcile(p, own, 0, world, me, 0);
499 for (let t = 1; t <= REPLAY_TICKS; t++)
500 keep(p, { tick: t, input: { ...NO_INPUT, forward: 1, side: t % 2 ? 1 : -1 }, yaw: t * 9, pitch: 0, switchCode: 0, buy: null }, 1, world, me);
501 return timed(runs, () => {
502 for (let i = 0; i < runs; i++) reconcile(p, own, 0, world, me, i);
503 replayed = p.kept.length;
504 });
505 });
506
507 expect(each).toBeLessThan(REPLAY_CEILING);
508 // And it really was twenty ticks each time: nothing acknowledged, nothing dropped.
509 expect(replayed).toBe(REPLAY_TICKS);
510 expect(REPLAY_TICKS).toBe(20);
511 }, 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 +270 −328
@@ -1,361 +1,303 @@
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 in the world, which is the whole of `Sound`
10 * but the radio: the five a 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 /** The one noise that is not in the world: a call, heard in the ear at one volume. */
19 const RADIO: Sound = "radio";
20
21 /** A device that plays nothing and writes down everything. */
22 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } {
23 const device = {
24 muted: false,
25 bursts: [] as Burst[],
26 tones: [] as Tone[],
27 resumes: 0,
28 resume() { device.resumes += 1; },
29 noiseBurst(burst: Burst) { if (!device.muted) device.bursts.push(burst); },
30 tone(tone: Tone) { if (!device.muted) device.tones.push(tone); },
31 };
32 return device;
4133 }
4234
43 type Edge = { from: number; to: number };
35 test("a shot is a burst plus a thump, coloured by the weapon", () => {
36 const device = recorder();
37 const audio = createAudio(device);
38 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
39 expect(device.bursts.length).toBe(1);
40 expect(device.tones.length).toBe(1);
41 const burst = device.bursts[0] as Burst;
42 const thump = device.tones[0] as Tone;
43 expect(burst.frequency).toBe(shotTone("ak47").cut);
44 expect(burst.length).toBe(shotTone("ak47").length);
45 expect(thump.slideTo).toBeLessThan(thump.frequency);
4446
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;
47 audio.shot("awp", [0, 0, 0], [0, 0, 0]);
48 const sniper = device.bursts[1] as Burst;
49 // The sniper is lower and rings longer than the rifle.
50 expect(sniper.frequency).toBeLessThan(burst.frequency);
51 expect(sniper.length).toBeGreaterThan(burst.length);
52 });
53
54 test("a shot across the map is quieter than one at your feet", () => {
55 const device = recorder();
56 const audio = createAudio(device);
57 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
58 audio.shot("ak47", [4000, 0, 0], [0, 0, 0]);
59 const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst;
60 expect(far.gain).toBeLessThan(near.gain / 2);
61 expect(near.gain).toBeCloseTo(0.35, 6);
62 expect(loudness([100, 0, 0], [0, 0, 0])).toBe(1);
63 expect(loudness([4000, 0, 0], [0, 0, 0])).toBeLessThan(0.3);
64 });
65
66 /**
67 * The gain the recipe asks for at a distance, by hand: `attenuate` is
68 * `min(1, earshot / distance)`, so a footstep's 256-unit earshot is full gain
69 * at 256 units and a quarter of it at 1,024 — four times the distance, a
70 * quarter the volume. The numbers on the right are written out rather than
71 * computed from the constants, because a test that recomputes the code proves
72 * nothing about what a player hears.
73 */
74 test("a footstep is full gain at 256 Source units and a quarter of that at 1,024", () => {
75 const device = recorder();
76 const audio = createAudio(device);
77 audio.play("step", [0, 0, 0], [256, 0, 0]);
78 audio.play("step", [1024, 0, 0], [0, 0, 0]);
79 const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst;
80 expect(near.gain).toBeCloseTo(0.3, 10); // 0.3 × min(1, 256/256)
81 expect(far.gain).toBeCloseTo(0.075, 10); // 0.3 × 256/1024
82 // Past a site and a half it is under a tenth of what it was: an enemy across
83 // the map is not information, and this is the distance that says so.
84 audio.play("step", [2560, 0, 0], [0, 0, 0]);
85 expect((device.bursts[2] as Burst).gain).toBeCloseTo(0.03, 10); // 0.3 × 256/2560
86 });
87
88 test("your own boots are quieter than somebody else's taken in the same place", () => {
89 const device = recorder();
90 const audio = createAudio(device);
91 audio.play("step", [0, 0, 0], [0, 0, 0], true);
92 audio.play("step", [0, 0, 0], [0, 0, 0], false);
93 const mine = device.bursts[0] as Burst, theirs = device.bursts[1] as Burst;
94 expect(mine.gain).toBeCloseTo(0.105, 10); // 0.3 × 0.35, and no distance at all
95 expect(theirs.gain).toBeCloseTo(0.3, 10);
96 expect(mine.gain).toBeLessThan(theirs.gain / 2);
97 });
98
99 test("every noise a round makes has a recipe, and a far one is quieter than a near one", () => {
100 for (const name of SOUNDS) {
101 const device = recorder();
102 const audio = createAudio(device);
103 audio.play(name, [0, 0, 0], [0, 0, 0]);
104 const bursts = [...device.bursts], tones = [...device.tones];
105 expect(bursts.length + tones.length).toBeGreaterThan(0);
106 expect([...bursts, ...tones].every((node) => node.gain > 0)).toBe(true);
107
108 // The same noise three thousand units away, node for node: every one of
109 // them is quieter, and none of them went missing on the way.
110 audio.play(name, [3000, 0, 0], [0, 0, 0]);
111 const far = [...device.bursts.slice(bursts.length), ...device.tones.slice(tones.length)];
112 const near = [...bursts, ...tones];
113 expect(far.length).toBe(near.length);
114 for (let i = 0; i < far.length; i++)
115 expect((far[i] as Burst).gain).toBeLessThan((near[i] as Burst).gain);
57116 }
117 });
118
119 test("a radio call has a recipe, and it is as loud across the map as beside you", () => {
120 const device = recorder();
121 const audio = createAudio(device);
122 audio.play(RADIO, [0, 0, 0], [0, 0, 0]);
123 audio.play(RADIO, [3000, 0, 0], [0, 0, 0]);
124 expect(device.bursts.length).toBe(2);
125 expect(device.tones.length).toBe(2);
126 expect((device.bursts[1] as Burst).gain).toBe((device.bursts[0] as Burst).gain);
127 expect((device.tones[1] as Tone).gain).toBe((device.tones[0] as Tone).gain);
128 // Not placed: dead centre, whatever the ear's heading.
129 expect((device.tones[1] as Tone).pan).toBeUndefined();
130 });
131
132 // --- the budget -----------------------------------------------------------
133
134 /** What one recipe asks for at zero distance: the peak gain of every voice in it. */
135 function peak(play: (audio: Audio) => void): number {
136 const device = recorder();
137 play(createAudio(device));
138 return [...device.bursts, ...device.tones].reduce((sum, voice) => sum + voice.gain, 0);
58139 }
59140
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 }
141 /**
142 * The headroom, measured rather than asserted. This adds the recipes up from
143 * the recipes — not from a table of numbers copied beside them — and holds the
144 * total against `LOUDEST_INSTANT`, which is the number `src/engine/sound.ts`
145 * sized the master bus and its limiter from. A recipe that grows, or a new loud
146 * name, fails here and is read there.
147 *
148 * The instant is the issue's own: five guns in one room, and the three loudest
149 * other noises a tick can carry in with them. Which three is not written down —
150 * it is whichever three are loudest today — so a new noise louder than a body
151 * falling joins the sum on its own rather than waiting for somebody to notice.
152 */
153 test("the recipes fit the headroom the engine's bus was sized for", () => {
154 const here: Vec3 = [0, 0, 0];
155 const shot = peak((audio) => audio.shot("ak47", here, here));
156 expect(shot).toBeCloseTo(0.65, 9); // 0.35 of noise, 0.30 of thump
66157
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 }
158 const others = SOUNDS
159 .map((name) => ({ name, gain: peak((audio) => audio.play(name, here, here)) }))
160 .sort((a, b) => b.gain - a.gain);
161 expect(others.slice(0, 3)).toEqual([
162 { name: "blast", gain: expect.closeTo(1.25, 9) },
163 { name: "flash", gain: expect.closeTo(0.85, 9) },
164 { name: "death", gain: expect.closeTo(0.58, 9) },
165 ]);
73166
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 }
167 const worst = 5 * shot + others.slice(0, 3).reduce((sum, one) => sum + one.gain, 0);
168 expect(worst).toBeCloseTo(5.93, 9);
169 // A nanogain of slack, because this is a sum of floats and the budget is a
170 // statement about a firefight rather than about the last bit of a double.
171 expect(worst).toBeLessThanOrEqual(LOUDEST_INSTANT + 1e-9);
82172
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);
173 // And the same line of addition is the reason the bus multiplies by what it
174 // multiplies by, so the docstring that does the arithmetic has to hold this
175 // number: read out of the file rather than trusted to stay true.
176 const device = readFileSync(join(import.meta.dir, "..", "engine", "sound.ts"), "utf8");
177 expect(device).toContain(`= ${LOUDEST_INSTANT}`);
191178 });
192179
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");
180 test("the bomb counting is a square tone, and the plant climbs where the defuse falls", () => {
181 const device = recorder();
182 const audio = createAudio(device);
183 audio.play("beep", [0, 0, 0], [0, 0, 0]);
184 const beep = device.tones[0] as Tone;
185 expect(beep.type).toBe("square");
186 expect(device.bursts.length).toBe(0); // nothing under it: it has to cut through
187
188 audio.play("plant", [0, 0, 0], [0, 0, 0]);
189 audio.play("defuse", [0, 0, 0], [0, 0, 0]);
190 const plant = device.tones[1] as Tone, defuse = device.tones[2] as Tone;
191 expect(plant.slideTo as number).toBeGreaterThan(plant.frequency);
192 expect(defuse.slideTo as number).toBeLessThan(defuse.frequency);
199193 });
200194
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);
195 test("a headshot beeps higher than a body hit", () => {
196 const device = recorder();
197 const audio = createAudio(device);
198 audio.hit(true);
199 audio.hit(false);
200 const head = device.tones[0] as Tone, body = device.tones[1] as Tone;
201 expect(head.frequency).toBeGreaterThan(body.frequency);
202 expect(head.type).toBe("square");
209203 });
210204
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);
205 test("muting the voice mutes the device under it", () => {
206 const device = recorder();
207 const audio = createAudio(device);
208 audio.muted = true;
209 expect(device.muted).toBe(true);
210 audio.shot("ak47", [0, 0, 0], [0, 0, 0]);
211 audio.play("step", [0, 0, 0], [0, 0, 0]);
212 audio.beep(700);
213 expect(device.bursts).toEqual([]);
214 expect(device.tones).toEqual([]);
215 audio.muted = false;
216 audio.beep(700);
217 expect(device.tones.length).toBe(1);
226218 });
227219
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);
220 test("resuming the voice resumes the device", () => {
221 const device = recorder();
222 createAudio(device).resume();
223 expect(device.resumes).toBe(1);
236224 });
237225
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);
226 // --- which side it came from ----------------------------------------------
227
228 /**
229 * North is +Y, so an ear facing north is at yaw 90, and from there west (−X) is
230 * its left and east (+X) is its right. 300 units is well past the engine's
231 * softening radius, so these are the full swing: a step that far to one side
232 * comes out of that speaker and nowhere near the other.
233 */
234 const NORTH = 90;
235
236 test("a footstep to the left of a facing ear is negative, to the right positive, ahead zero", () => {
237 const device = recorder();
238 const audio = createAudio(device);
239 audio.play("step", [-300, 0, 0], [0, 0, 0], false, NORTH);
240 audio.play("step", [300, 0, 0], [0, 0, 0], false, NORTH);
241 audio.play("step", [0, 300, 0], [0, 0, 0], false, NORTH);
242 const [left, right, ahead] = device.bursts as [Burst, Burst, Burst];
243 expect(left.pan).toBeLessThan(0);
244 expect(right.pan).toBeGreaterThan(0);
245 expect(ahead.pan).toBeCloseTo(0, 12);
246 // Hard over, both of them, and mirrored: 300 units is a long way past a head.
247 expect(left.pan).toBeCloseTo(-1, 12);
248 expect(right.pan).toBeCloseTo(1, 12);
249 // The thump under the step is placed with it, or the two halves of one
250 // footstep come out of different speakers.
251 expect((device.tones[0] as Tone).pan).toBe(left.pan);
246252 });
247253
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);
254 test("turning to face the sound brings it to the middle, and turning past it swaps sides", () => {
255 const device = recorder();
256 const audio = createAudio(device);
257 // The same step due east of the ear, heard while facing east, north and south.
258 for (const yaw of [0, 90, -90]) audio.play("step", [300, 0, 0], [0, 0, 0], false, yaw);
259 const [facing, backTo, turnedAway] = device.bursts as [Burst, Burst, Burst];
260 expect(facing.pan).toBeCloseTo(0, 12); // straight at it
261 expect(backTo.pan).toBeCloseTo(1, 12); // right shoulder
262 expect(turnedAway.pan).toBeCloseTo(-1, 12); // left shoulder
260263 });
261264
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];
265 test("a shot is placed the same way, and every node of a noise carries one pan", () => {
266 const device = recorder();
267 const audio = createAudio(device);
268 audio.shot("ak47", [-800, 0, 0], [0, 0, 0], NORTH);
269 expect((device.bursts[0] as Burst).pan).toBeCloseTo(-1, 12);
270 expect((device.tones[0] as Tone).pan).toBeCloseTo(-1, 12);
268271
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();
272 // Every recipe places every voice it builds: a name that forgot would leave
273 // half a noise in the middle while the other half is off to one side.
274 for (const name of SOUNDS) {
275 const one = recorder();
276 createAudio(one).play(name, [900, 0, 0], [0, 0, 0], false, NORTH);
277 const voices = [...one.bursts, ...one.tones];
278 expect(voices.length).toBeGreaterThan(0);
279 for (const voice of voices) expect([name, voice.pan]).toEqual([name, expect.closeTo(1, 12)]);
334280 }
335281 });
336282
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");
283 test("your own boots and your own gun stay in the middle", () => {
284 const device = recorder();
285 const audio = createAudio(device);
286 // A noise you made is passed no pan at all: there is no side for it.
287 audio.play("step", [0, 0, 0], [0, 0, 0], true, NORTH);
288 expect((device.bursts[0] as Burst).pan).toBeUndefined();
289 expect((device.tones[0] as Tone).pan).toBeUndefined();
290 // Your own gun fires from the ear it is heard with, so it works out centred
291 // without a special case.
292 audio.shot("ak47", [0, 0, 0], [0, 0, 0], NORTH);
293 expect((device.bursts[1] as Burst).pan).toBe(0);
347294 });
348295
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));
296 test("a caller that names no heading is the game as it was: every sound ahead-and-centre of due east", () => {
297 const device = recorder();
298 const audio = createAudio(device);
299 // No yaw given is yaw 0 — facing +X — which is the old behaviour for anything
300 // directly in front, and still a real pan for anything off to a side.
301 audio.play("blast", [1000, 0, 0], [0, 0, 0]);
302 expect((device.bursts[0] as Burst).pan).toBeCloseTo(0, 12);
361303 });
src/client/audio.ts +230 −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,213 @@ 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 === "radio") {
172 // A call on the radio: a crackle of static and a short square blip that
173 // drops a step as it goes. It is not placed and does not carry — it comes
174 // out of the same earpiece for everyone who hears it at all, and who
175 // that is (the caller's side) is the frame's question, not this one's.
176 device.noiseBurst({ frequency: 3000, gain: 0.08, length: 0.06, q: 0.7, rate: 1 });
177 device.tone({ frequency: 1250, slideTo: 950, gain: 0.1, length: 0.16, type: "square", floor: 0.0005, tail: 0.01 });
178 return;
179 }
180 if (sound === "step") {
181 // A foot landing: a scuff of grit through a wide filter with a soft
182 // thump under it. The rate is jittered so a body at a dead run is a
183 // walk and not one sample played twelve times.
184 const volume = attenuate(far, STEP_EARSHOT, STEP_CLOSE) * (own ? OWN_STEP : 1);
185 device.noiseBurst({ frequency: 520, gain: STEP_GAIN * volume, length: 0.1, q: 1.1, rate: 0.85 + Math.random() * 0.3, pan });
186 device.tone({ frequency: 95, slideTo: 55, gain: 0.16 * volume, length: 0.09, pan });
187 return;
188 }
189 if (sound === "beep") {
190 // The bomb, counting. A square tone and nothing under it: it has to cut
191 // through a firefight, and it is the one sound here that repeats.
192 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
193 device.tone({ frequency: 2200, gain: 0.2 * volume, length: 0.07, type: "square", floor: 0.0005, tail: 0.01, pan });
194 return;
195 }
196 if (sound === "plant") {
197 // The bomb going down: the clunk of it landing, and a tone that climbs
198 // — the round just turned over, and it turned the terrorists' way.
199 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
200 device.noiseBurst({ frequency: 1400, gain: 0.3 * volume, length: 0.09, q: 5, rate: 1, pan });
201 device.tone({ frequency: 300, slideTo: 900, gain: 0.26 * volume, length: 0.3, pan });
202 return;
203 }
204 if (sound === "defuse") {
205 // The wire, cut: a bright snip and a tone that falls away under it. It
206 // is the plant's noise backwards on purpose — the same event, undone.
207 const volume = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
208 device.noiseBurst({ frequency: 2600, gain: 0.26 * volume, length: 0.07, q: 6, rate: 1, pan });
209 device.tone({ frequency: 900, slideTo: 300, gain: 0.26 * volume, length: 0.3, pan });
210 return;
211 }
212 if (sound === "death") {
213 // A body hitting the floor: low, wide, short, with a thump that falls
214 // away. No cry — the game has no voices, and a fall carries further.
215 const volume = attenuate(far, DEATH_EARSHOT, DEATH_CLOSE);
216 device.noiseBurst({ frequency: 240, gain: 0.34 * volume, length: 0.35, q: 0.8, rate: 0.8, pan });
217 device.tone({ frequency: 110, slideTo: 50, gain: 0.24 * volume, length: 0.3, pan });
218 return;
219 }
220 if (sound === "throw") {
221 // A short upward whoosh of air: quiet, and only near you.
222 device.noiseBurst({ frequency: 2400, gain: 0.14 * loudness(at, listener), length: 0.16, q: 0.7, rate: 1.4, pan });
223 return;
224 }
225 if (sound === "bounce") {
226 // A hard tap: the shell against concrete, tight and short.
227 const volume = loudness(at, listener);
228 device.noiseBurst({ frequency: 1200, gain: 0.22 * volume, length: 0.07, q: 4, rate: 1, pan });
229 device.tone({ frequency: 260, slideTo: 150, gain: 0.16 * volume, length: 0.08, pan });
230 return;
231 }
232 if (sound === "smoke") {
233 // The canister opening: no bang at all. A long, soft, wide hiss that
234 // fades away rather than cracks — it is the one grenade noise in this
235 // game that is a *process* and not an event, and it is quiet, because
236 // what a smoke costs you is what you can see and never what you hear.
237 // It carries like a footstep does and not like a blast: a cloud across
238 // the map is not information anybody should get for free.
239 const soft = attenuate(far, BOMB_EARSHOT, BOMB_CLOSE);
240 device.noiseBurst({ frequency: 3200, gain: 0.2 * soft, length: 1.4, q: 0.5, rate: 0.9, pan });
241 device.tone({ frequency: 420, slideTo: 210, gain: 0.1 * soft, length: 0.9, pan });
242 return;
243 }
244 if (sound === "flash") {
245 // The flash: the opposite noise to the blast in every way. A bright,
246 // tight crack with no body under it and a ring above it that climbs
247 // rather than falls — what a very loud, very short bang leaves in your
248 // ears. It carries as far as the blast does, and it matters over a
249 // wider circle than one, because so does the light.
250 const bright = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
251 device.noiseBurst({ frequency: 5200, gain: 0.55 * bright, length: 0.28, q: 1.2, rate: 1.5, pan });
252 device.tone({ frequency: 2600, slideTo: 4200, gain: 0.3 * bright, length: 0.5, pan });
253 return;
254 }
255 // The blast: a wide, low body of noise with a thump under it that falls
256 // an octave and a half as it goes.
257 const volume = attenuate(far, BLAST_EARSHOT, BLAST_CLOSE);
258 device.noiseBurst({ frequency: 360, gain: 0.7 * volume, length: 1.1, q: 0.35, rate: 0.6, pan });
259 device.tone({ frequency: 130, slideTo: 42, gain: 0.55 * volume, length: 0.85, pan });
169260 },
170261 hit(headshot) {
171262 audio.beep(headshot ? 1400 : 900, 0.08);
172263 },
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 },
220264 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);
265 device.tone({ frequency: pitch, gain, length: 0.09, type: "square", floor: 0.0005, tail: 0.01 });
314266 },
315267 };
316268 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 +152
@@ -0,0 +1,152 @@
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 import { callOf, RADIO_ACTIONS } from "./radio.ts";
4 import { CALLS } from "../plan/call.ts";
5
6 const NONE = { x: 0, y: 0 };
7 const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick);
8
9 test("the bindings are the ones the keyboard has always had", () => {
10 expect(CS_KEYS.KeyW).toBe("forward");
11 expect(CS_KEYS.KeyS).toBe("back");
12 expect(CS_KEYS.KeyA).toBe("left");
13 expect(CS_KEYS.KeyD).toBe("right");
14 expect(CS_KEYS.ArrowUp).toBe("forward");
15 expect(CS_KEYS.Space).toBe("jump");
16 expect(CS_KEYS.ControlLeft).toBe("duck");
17 expect(CS_KEYS.KeyC).toBe("duck");
18 expect(CS_KEYS.ShiftLeft).toBe("walk");
19 expect(CS_KEYS.KeyR).toBe("reload");
20 expect(CS_KEYS.KeyE).toBe("use");
21 expect(CS_KEYS.KeyB).toBe("buy");
22 expect(CS_KEYS.KeyG).toBe("grenade");
23 expect(CS_KEYS.KeyT).toBe("flash");
24 expect(CS_KEYS.KeyV).toBe("smoke");
25 expect(CS_KEYS.Tab).toBe("scoreboard");
26 expect(CS_KEYS.Digit1).toBe("slot1");
27 expect(CS_KEYS.Digit3).toBe("slot3");
28 expect(CS_MOUSE[0]).toBe("fire");
29 expect(CS_MOUSE[2]).toBe("scope");
30 // The whole mouse, because an unbound button is one the browser keeps: 3 and
31 // 4 are Back and Forward on Windows, and the wheel pressed is the quick knife.
32 expect(CS_MOUSE[1]).toBe("knife");
33 expect(CS_MOUSE[3]).toBe("use");
34 expect(CS_MOUSE[4]).toBe("grenade");
35 // The wheel jumps whichever way it is turned, which is how half of this
36 // game's players jump at all.
37 expect(CS_WHEEL.up).toBe("jump");
38 expect(CS_WHEEL.down).toBe("jump");
39 expect(intent(["jump"]).input.jump).toBe(true);
40 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke", "goA", "goB", "hold"]);
41 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
42 // the browser for the screen, and nothing about an `Input` changes for it.
43 expect(CS_KEYS.KeyF).toBe("fullscreen");
44 expect(intent(["fullscreen"])).toEqual(intent([]));
45 // P is the page's in the same way: it pauses, which is what Escape means too,
46 // and no browser has a meaning of its own for it to take the window back with.
47 expect(CS_KEYS.KeyP).toBe("pause");
48 expect(intent(["pause"])).toEqual(intent([]));
49 // X is the page's too: it buys last round's loadout again, which is money and
50 // not movement, so nothing about an `Input` changes for it either.
51 expect(CS_KEYS.KeyX).toBe("rebuy");
52 expect(intent(["rebuy"])).toEqual(intent([]));
53 });
54
55 test("the number row is one row of keys, bound in the order it is printed", () => {
56 // Eleven keys, left to right, and the code of each is what a browser calls
57 // that key: the buy panel prints them on its tiles in this order.
58 expect([...NUMBER_ROW]).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"]);
59 const codes = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus"];
60 expect(codes.map((code) => CS_KEYS[code])).toEqual(NUMBER_ROW.map((_, i) => slotAction(i)));
61 // And the way back, which is how `main.ts` turns a press into the tile under
62 // it: every one of them, and nothing else.
63 expect(codes.map((code) => slotIndex(CS_KEYS[code] ?? ""))).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
64 expect(slotIndex("forward")).toBeNull();
65 expect(slotIndex("slot12")).toBeNull();
66 expect(slotIndex("slot")).toBeNull();
67 });
68
69 test("only the first three of the row are pockets: the rest are the panel's and the world has never heard of them", () => {
70 expect(intent(["slot1"]).slotWanted).toBe(1);
71 expect(intent(["slot2"]).slotWanted).toBe(2);
72 expect(intent(["slot3"]).slotWanted).toBe(3);
73 for (const action of ["slot4", "slot9", "slot10", "slot11"]) {
74 expect(intent([action]).slotWanted).toBeNull();
75 expect(intent([action])).toEqual(intent([]));
76 }
77 });
78
79 test("the wheel pressed is the knife pocket, and it is not a tile of the buy panel", () => {
80 // The same pocket `3` is, by a name no tile is printed with: with the shop
81 // open a key of the number row buys, and a thumb on the wheel means the knife
82 // rather than whatever the third tile happens to be.
83 expect(intent(["knife"]).slotWanted).toBe(3);
84 expect(slotIndex("knife")).toBeNull();
85 expect(intent(["knife"]).input).toEqual(intent([]).input);
86 });
87
88 test("nothing held is nothing asked for", () => {
89 const { input, scoreboard, slotWanted } = intent([]);
90 expect(input.forward).toBe(0);
91 expect(input.side).toBe(0);
92 expect(input.jump).toBe(false);
93 expect(input.fire).toBe(false);
94 expect(input.switchTo).toBeNull();
95 expect(scoreboard).toBe(false);
96 expect(slotWanted).toBeNull();
97 });
98
99 test("the movement keys sum and opposites cancel", () => {
100 expect(intent(["forward"]).input.forward).toBe(1);
101 expect(intent(["back"]).input.forward).toBe(-1);
102 expect(intent(["forward", "back"]).input.forward).toBe(0);
103 expect(intent(["right"]).input.side).toBe(1);
104 expect(intent(["left"]).input.side).toBe(-1);
105 expect(intent(["left", "right"]).input.side).toBe(0);
106 });
107
108 test("the stick adds to the keys and the sum clamps to ±1", () => {
109 expect(intent([], { x: -0.5, y: 0.25 }).input.side).toBeCloseTo(-0.5, 10);
110 expect(intent([], { x: -0.5, y: 0.25 }).input.forward).toBeCloseTo(0.25, 10);
111 expect(intent(["forward"], { x: 0, y: 0.9 }).input.forward).toBe(1);
112 expect(intent(["left"], { x: -0.9, y: 0 }).input.side).toBe(-1);
113 });
114
115 test("every held action lands on its field", () => {
116 const { input } = intent(["jump", "duck", "walk", "reload", "use", "fire", "scope"]);
117 expect(input.jump).toBe(true);
118 expect(input.duck).toBe(true);
119 expect(input.walk).toBe(true);
120 expect(input.reload).toBe(true);
121 expect(input.use).toBe(true);
122 expect(input.fire).toBe(true);
123 expect(input.scope).toBe(true);
124 });
125
126 test("the scoreboard is not an input to the world", () => {
127 expect(intent(["scoreboard"]).scoreboard).toBe(true);
128 expect(intent(["scoreboard"]).input).toEqual(intent([]).input);
129 });
130
131 test("three beats two beats one", () => {
132 expect(intent(["slot1"]).slotWanted).toBe(1);
133 expect(intent(["slot2"]).slotWanted).toBe(2);
134 expect(intent(["slot3"]).slotWanted).toBe(3);
135 expect(intent(["slot1", "slot2"]).slotWanted).toBe(2);
136 expect(intent(["slot1", "slot2", "slot3"]).slotWanted).toBe(3);
137 });
138
139 test("an action the game does not know is not an input", () => {
140 expect(intent(["buy", "nonsense"]).input).toEqual(intent([]).input);
141 });
142
143 test("the radio is three keys, each a call, and none of them moves the body", () => {
144 expect(CS_KEYS.KeyZ).toBe("goA");
145 expect(CS_KEYS.KeyQ).toBe("goB");
146 expect(CS_KEYS.KeyH).toBe("hold");
147 // Every action the radio makes is bound, and every bound call is one the radio knows.
148 const bound = Object.values(CS_KEYS).filter((action) => callOf(action) !== null);
149 expect(bound.sort()).toEqual(Object.keys(RADIO_ACTIONS).sort());
150 expect(Object.values(RADIO_ACTIONS)).toEqual([...CALLS]);
151 expect(intent(["goA", "goB", "hold"])).toEqual(intent([]));
152 });
src/client/binding.ts added +135
@@ -0,0 +1,135 @@
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 // The radio: three calls to your own side, which `main.ts` hears on
61 // `onAction` and `src/client/radio.ts` makes. None is an `Input` — a call
62 // moves the bots, not the body that made it.
63 KeyZ: "goA",
64 KeyQ: "goB",
65 KeyH: "hold",
66 };
67
68 /**
69 * Left fires, right scopes, and the three buttons a five-button mouse has as
70 * well — all of them, because a bound button is a button the engine takes the
71 * browser's meaning away from and an unbound one is left alone.
72 *
73 * That matters most for the two under the thumb: to a browser on Windows they
74 * are Back and Forward, so a thumb that finds one mid-round in Edge leaves the
75 * page and takes the match with it. Bound here, they are the game's. Which
76 * actions they get is the pair a thumb reaches for: plant-or-defuse, and the HE.
77 *
78 * The wheel pressed is the quick knife every other shooter puts there — it is
79 * `knife` rather than `slot3` on purpose, because the number row is also the
80 * buy panel's and a middle click with the shop open would otherwise spend the
81 * money on whatever tile is printed with a 3.
82 */
83 export const CS_MOUSE: Record<number, string> = {
84 0: "fire", 1: "knife", 2: "scope", 3: "use", 4: "grenade",
85 };
86
87 /** The wheel jumps, both ways, which is the bind half this game plays with. */
88 export const CS_WHEEL: Record<string, string> = { up: "jump", down: "jump" };
89
90 /** On-screen buttons that are a tap, not a hold. */
91 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke", "goA", "goB", "hold"] as const;
92
93 export type Intent = {
94 input: Input;
95 scoreboard: boolean;
96 /** 1 primary, 2 pistol, 3 knife — `main` knows which weapon each is. */
97 slotWanted: 1 | 2 | 3 | null;
98 };
99
100 /**
101 * What the held actions and the thumb stick add up to this frame. `switchTo`
102 * stays null: only the world knows what is in slot 1, so `main` fills it from
103 * `slotWanted`.
104 */
105 export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: number }): Intent {
106 const down = (action: string): boolean => held.has(action);
107 const input: Input = {
108 ...NO_INPUT,
109 forward: clamp((down("forward") ? 1 : 0) - (down("back") ? 1 : 0) + stick.y, -1, 1),
110 side: clamp((down("right") ? 1 : 0) - (down("left") ? 1 : 0) + stick.x, -1, 1),
111 jump: down("jump"),
112 duck: down("duck"),
113 walk: down("walk"),
114 reload: down("reload"),
115 use: down("use"),
116 fire: down("fire"),
117 scope: down("scope"),
118 // Three pockets, three keys, read newest-reach-first: the one nearest the
119 // front of this list wins while more than one is held, because the one you
120 // just reached for is the one you meant, and releasing it throws whichever
121 // of the others is still down.
122 throwing: down("smoke") ? "smoke" : down("flash") ? "flash" : down("grenade") ? "he" : null,
123 switchTo: null,
124 };
125 return {
126 input,
127 scoreboard: down("scoreboard"),
128 // Three pockets, and only three: the rest of the number row belongs to the
129 // buy panel, and a world that has never heard of `slot4` cannot be steered
130 // by the key that buys the fourth tile. `knife` is the third pocket by
131 // another name — the wheel pressed, which no tile is printed with.
132 slotWanted:
133 down("slot3") || down("knife") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null,
134 };
135 }
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 +476 −682
@@ -1,21 +1,44 @@
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, SCOPE_RANGE,
4 SCOPE_SKILL, SCOPED_MOVE, skillBand, think, UNSCOPE_RANGE,
5 applyIntent, blindness, botBuy, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy,
6 ride, roundPlan, SCOPE_RANGE, SCOPE_SKILL, SCOPED_MOVE, think, UNSCOPE_RANGE, usePlan, usePolicy,
57 } from "./bots.ts";
6 import { DE_FORGE, placeAt, worldOf } from "./map.ts";
7 import { dist2d } from "./math.ts";
8 import { MOVE } from "./physics.ts";
9 import { inaccuracy, WEAPONS } from "./weapons.ts";
10 import { COMPETITIVE, SHORT } from "./match.ts";
11 import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts";
12 import {
13 aimAt, applyInput, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
14 type Player, type World,
15 } from "./world.ts";
8 import { radio } from "./radio.ts";
9 import { CALL_LIFE } from "../plan/call.ts";
10 import type { RoundPlan, TeamPlan } from "../plan/plan.ts";
11 import { grip } from "./spray.ts";
12 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
13 import { dist, type Vec3 } from "../engine/index.ts";
14 import { OBS_SIZE } from "./observe.ts";
15 import { FLASH, inaccuracy, WEAPONS, type WeaponId } from "./weapons.ts";
16 import { CS_MOVE } from "./movement.ts";
17 import { DE_FORGE } from "./map.ts";
18 import { COMPETITIVE } from "./match.ts";
19 import { aimAt, applyInput, bodyCentre, createWorld, deployTicks, type Player } from "./world.ts";
20
21 // The bots in here think with the policy this repository ships, handed in the
22 // way a browser hands in the one it downloaded.
23 useShipped();
24
25 test("a bot with no policy handed to it says so, and one handed a policy uses that one", () => {
26 // The seam #214 opened: the weights are data now, so `usePolicy` is the only
27 // way a bot gets any — a browser hands it what it downloaded, Bun hands it
28 // what `shipped.ts` read. Nobody having handed one in is loud, not a bot
29 // quietly aiming at nothing.
30 usePolicy(null as unknown as Policy);
31 expect(() => policy()).toThrow(NO_POLICY);
32 const net = useShipped();
33 expect(policy()).toBe(net);
34 const other = unpack(PACKED);
35 usePolicy(other);
36 expect(policy()).toBe(other);
37 useShipped();
38 });
1639
1740 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
18 const world = createWorld(COMPETITIVE, "you", "ct", 1, seed);
41 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, seed);
1942 world.match.phase = "live";
2043 const me = world.me;
2144 const bot = world.players[1] as Player;
@@ -64,12 +87,47 @@ test("a bot turns towards a target it can see, and shoots at it", () => {
6487 expect(fired).toBeGreaterThan(0);
6588 });
6689
90 test("the hands hold the gun down through a spray and give the whole pull back", () => {
91 const { world, bot } = duel();
92 const brain = bot.brain!;
93 bot.weapon = "ak47";
94 bot.pitch = 0;
95 bot.yaw = 180;
96 // Ten rounds into a spray: the pattern has climbed and the hands are on it.
97 bot.shotIndex = 10;
98 for (let t = 0; t < 32; t++) ride(bot, brain, brain.grip, 1 / 64);
99 expect(bot.pitch).toBeGreaterThan(1); // pointing below where it was aiming
100 expect(brain.pull[1]).toBeCloseTo(bot.pitch, 9);
101 expect(brain.pull[0]).toBeCloseTo(bot.yaw - 180, 9);
102 // The world puts shotIndex back a third of a second after the trigger comes
103 // up, and the pull unwinds with it: nothing of it outlives the burst.
104 bot.shotIndex = 0;
105 for (let t = 0; t < 64; t++) ride(bot, brain, brain.grip, 1 / 64);
106 expect(bot.pitch).toBeCloseTo(0, 2);
107 expect(bot.yaw).toBeCloseTo(180, 2);
108 expect(Math.hypot(brain.pull[0], brain.pull[1])).toBeLessThan(0.01);
109
110 // And the scripted brain never rides at all: it is the baseline the policy is
111 // measured against, and a baseline that moves measures nothing.
112 const scripted = duel();
113 scripted.bot.brain = makeBrain(1, 99, false);
114 scripted.bot.weapon = "ak47";
115 scripted.bot.shotIndex = 10;
116 for (let t = 0; t < 32; t++) botThink(scripted.world, scripted.bot, 1 / 64);
117 expect(scripted.bot.brain.pull).toEqual([0, 0]);
118 });
119
67120 test("skill is a handicap on the hands, not on the policy", () => {
68121 const fast = makeBrain(1, 7);
69122 const slow = makeBrain(0, 7);
70123 expect(slow.aimSpeed).toBeLessThan(fast.aimSpeed);
71124 expect(slow.aimError).toBeGreaterThan(fast.aimError);
72125 expect(slow.reaction).toBeGreaterThan(fast.reaction);
126 // Spray control is on the same ladder: fodder holds a fifth of the pattern
127 // and chases it slowly, the best hands in the game hold nine tenths.
128 expect(slow.grip).toBeLessThan(fast.grip);
129 expect(fast.grip).toBeCloseTo(grip(1), 12);
130 expect(slow.gripRate).toBeLessThan(fast.gripRate);
73131
74132 // The same intent, asked of both: the slow one cannot turn as far.
75133 const { world, bot } = duel();
@@ -91,51 +149,52 @@ test("a bot that has only just seen you does not shoot yet", () => {
91149 expect(immediate.fire).toBe(false);
92150 });
93151
94 // --- sniping -------------------------------------------------------------
152 // --- sniping ---------------------------------------------------------------
95153
96154 /**
97155 * A bot with an AWP `range` units down the long open line north of the
98156 * person, looking straight at them and past its reaction beat. The person
99157 * stands still: what is measured is the bot's shot, not a moving target.
100158 */
101 function sniper(range: number, skill = 1, learned = true): { world: World; me: Player; bot: Player } {
102 const { world, me, bot } = duel();
103 world.match.timer = COMPETITIVE.roundTime; // a full clock: nobody is running for a site
104 bot.pos = [0, range, 2];
105 bot.brain = makeBrain(skill, 99, learned);
106 bot.brain.reset = false;
107 bot.brain.seenAt = -10;
108 bot.primary = "awp";
109 bot.weapon = "awp";
110 bot.ammo = { ...bot.ammo, awp: { mag: 10, reserve: 30 } };
111 bot.nextShotAt = 0;
112 bot.yaw = aimAt(bot, bodyCentre(me)).yaw;
113 return { world, me, bot };
159 function sniper(range: number, skill = 1, learned = true): ReturnType<typeof duel> {
160 const d = duel();
161 d.bot.pos = [0, range, 2];
162 d.bot.brain = makeBrain(skill, 99, learned);
163 d.bot.brain.reset = false;
164 d.bot.brain.seenAt = -10;
165 d.bot.primary = "awp";
166 d.bot.weapon = "awp";
167 d.bot.ammo = { ...d.bot.ammo, awp: { mag: 10, reserve: 30 } };
168 d.bot.nextShotAt = 0;
169 d.bot.yaw = aimAt(d.bot, bodyCentre(d.me)).yaw;
170 return d;
114171 }
115172
116173 test("a bot with an AWP and an enemy at long range asks for the scope", () => {
117 for (const learned of [true, false]) {
118 const { world, bot } = sniper(1200, 1, learned);
119 expect(botThink(world, bot, 1 / 64).scope).toBe(true);
120 }
174 const { world, bot } = sniper(1200);
175 expect(botThink(world, bot, 1 / 64).scope).toBe(true);
121176 });
122177
123 test("the same bot with the enemy at knife range does not", () => {
124 for (const learned of [true, false]) {
125 const { world, bot } = sniper(200, 1, learned);
126 expect(botThink(world, bot, 1 / 64).scope).toBe(false);
127 }
178 test("the same bot with the enemy close does not", () => {
179 const { world, bot } = sniper(200);
180 expect(botThink(world, bot, 1 / 64).scope).toBe(false);
128181 });
129182
130183 test("a rifle is never scoped, and an easy bot fires the AWP from the hip", () => {
131184 const rifle = sniper(1200);
132185 rifle.bot.weapon = "ak47";
133186 rifle.bot.primary = "ak47";
187 rifle.bot.ammo = { ...rifle.bot.ammo, ak47: { mag: 30, reserve: 90 } };
134188 expect(botThink(rifle.world, rifle.bot, 1 / 64).scope).toBe(false);
135189 const easy = sniper(1200, SCOPE_SKILL - 0.05);
136190 expect(botThink(easy.world, easy.bot, 1 / 64).scope).toBe(false);
137191 });
138192
193 test("the scripted baseline is not taught to scope: it does not move", () => {
194 const { world, bot } = sniper(1200, 1, false);
195 expect(botThink(world, bot, 1 / 64).scope).toBe(false);
196 });
197
139198 test("the scope decision holds across the band between the two ranges", () => {
140199 const { world, bot } = sniper(1200);
141200 expect(botThink(world, bot, 1 / 64).scope).toBe(true);
@@ -149,14 +208,8 @@ test("the scope decision holds across the band between the two ranges", () => {
149208 for (let t = 0; t < 32; t++) expect(botThink(world, bot, 1 / 64).scope).toBe(false);
150209 });
151210
152 /**
153 * Play the sniper's side of the fight tick by tick for up to three seconds —
154 * the bot decides, `applyInput` carries it out, the clock moves — and hand
155 * back what it asked of its feet on every tick it was zoomed, and the
156 * inaccuracy of the first shot it actually took.
157 */
158 function snipeFor(learned: boolean): { scopedSides: number[]; shots: number[] } {
159 const { world, bot } = sniper(1200, 1, learned);
211 test("a scoped bot stops dancing and takes a steady shot", () => {
212 const { world, bot } = sniper(1200);
160213 const dt = 1 / 64;
161214 const scopedSides: number[] = [];
162215 const shots: number[] = [];
@@ -164,10 +217,10 @@ function snipeFor(learned: boolean): { scopedSides: number[]; shots: number[] }
164217 const input = botThink(world, bot, dt);
165218 const before = bot.lastShotAt;
166219 applyInput(world, bot, input, dt);
167 if (bot.scoped) scopedSides.push(input.side);
220 if (bot.scoped) scopedSides.push(input.side, input.forward);
168221 if (bot.lastShotAt !== before) {
169222 shots.push(inaccuracy(WEAPONS.awp, {
170 moving: Math.min(1, Math.hypot(bot.vel[0], bot.vel[1]) / MOVE.maxSpeed),
223 moving: Math.min(1, Math.hypot(bot.vel[0], bot.vel[1]) / CS_MOVE.maxSpeed),
171224 inAir: !bot.onGround,
172225 ducking: bot.duckAmount > 0.5,
173226 scoped: bot.scoped,
@@ -175,19 +228,223 @@ function snipeFor(learned: boolean): { scopedSides: number[]; shots: number[] }
175228 }
176229 world.time += dt;
177230 }
178 return { scopedSides, shots };
179 }
231 expect(scopedSides.length).toBeGreaterThan(0);
232 for (const stick of scopedSides) expect(Math.abs(stick)).toBeLessThanOrEqual(SCOPED_MOVE);
233 // Hip-fired on the move this is 7.7°; scoped at a walk, 0.49°. A shot from
234 // a near stop is a couple of units wide at 1000.
235 expect(shots.length).toBe(1);
236 expect(shots[0]!).toBeLessThan(0.15);
237 });
180238
181 test("a scoped bot stops dancing and takes a steady shot", () => {
182 for (const learned of [true, false]) {
183 const { scopedSides, shots } = snipeFor(learned);
184 expect(scopedSides.length).toBeGreaterThan(0);
185 for (const side of scopedSides) expect(Math.abs(side)).toBeLessThanOrEqual(SCOPED_MOVE);
186 // Hip-fired on the move this is 7.7°; scoped at a walk, 0.49°. A shot
187 // from a near stop is a couple of units wide at 1000.
188 expect(shots.length).toBe(1);
189 expect(shots[0]!).toBeLessThan(0.15);
239 test("a goal nothing can walk to is nudged to a cell something can", () => {
240 // A crate is solid to the search and standing on it is not: a bomb dropped
241 // up there is a point A* answers nothing about.
242 const crate = worldOf(DE_FORGE, 21, 18, 0); // one of de_forge's jumpable crates
243 expect(walkable(DE_FORGE, colOf(DE_FORGE, crate[0]), rowOf(DE_FORGE, crate[1]))).toBe(false);
244 const from = DE_FORGE.spawns.t[0] as Vec3;
245 expect(findPath(DE_FORGE, from, crate)).toHaveLength(0);
246
247 const path = pathTo(DE_FORGE, from, crate);
248 expect(path.length).toBeGreaterThan(0);
249 const end = path[path.length - 1] as Vec3;
250 expect(Math.hypot(end[0] - crate[0], end[1] - crate[1])).toBeLessThan(3 * 128 * 1.5);
251 });
252
253 test("a bot whose objective sits on a crate walks to it rather than standing still", () => {
254 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 1, 4242);
255 world.match.phase = "live";
256 const bot = world.players[1] as Player;
257 bot.team = "t";
258 bot.hasBomb = false;
259 world.me.alive = false; // nothing to fight: this is about the walking
260 // The bomb, dropped on top of a crate in mid.
261 world.bomb.state = "dropped";
262 world.bomb.pos = worldOf(DE_FORGE, 21, 18, 0);
263 const before = dist(bot.pos, world.bomb.pos);
264 for (let tick = 0; tick < 64 * 3; tick++) {
265 world.time += 1 / 64;
266 applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64);
190267 }
268 expect(dist(bot.pos, world.bomb.pos)).toBeLessThan(before - 200);
269 });
270
271
272 test("a flashed bot keeps its policy and loses its hands: slower, wilder, later, blinder", () => {
273 const { world, me, bot } = duel();
274 const brain = bot.brain!;
275 const clear = flashed(bot, brain);
276 // Unflashed it is exactly the skill it always was — the same numbers, to the
277 // bit, which is why every pinned run in this repository is untouched by this.
278 expect(blindness(bot)).toBe(0);
279 expect(clear.aimSpeed).toBe(brain.aimSpeed);
280 expect(clear.aimError).toBe(brain.aimError);
281 expect(clear.reaction).toBe(brain.reaction);
282 expect(clear.grip).toBe(brain.grip);
283
284 bot.blind = FLASH.flash.maxBlind;
285 const hit = flashed(bot, brain);
286 expect(blindness(bot)).toBe(1);
287 expect(hit.aimSpeed).toBeCloseTo(brain.aimSpeed * 0.2, 6);
288 expect(hit.aimError).toBeCloseTo(brain.aimError * 7, 6);
289 expect(hit.reaction).toBeCloseTo(brain.reaction * 4, 6);
290 expect(hit.sight).toBeLessThan(clear.sight);
291 // And it holds none of its spray: a pair of hands that cannot see the gun
292 // cannot ride the pattern either.
293 expect(hit.grip).toBe(0);
294 // Half way through the flash it is half of each: the number in the world is
295 // the only thing that decides, and it is the same one the HUD paints.
296 bot.blind = FLASH.flash.maxBlind / 2;
297 expect(flashed(bot, brain).aimSpeed).toBeCloseTo(brain.aimSpeed * 0.6, 6);
298
299 // Its head is untouched: the observation, the heads and the weights know
300 // nothing about any of this.
301 expect(OBS_SIZE).toBe(33);
302 expect(brain.obs.length).toBe(OBS_SIZE);
303 });
304
305 test("a flashed bot turns slower and shoots later than the same bot unflashed", () => {
306 const turn = (blind: number): number => {
307 const { world, me, bot } = duel();
308 bot.blind = blind;
309 bot.yaw = 90; // ninety degrees off the target
310 let fired = 0;
311 for (let t = 0; t < 64; t++) {
312 const input = botThink(world, bot, 1 / 64);
313 world.time += 1 / 64;
314 if (input.fire) fired += 1;
315 }
316 return Math.abs(bot.yaw - 90) - fired * 1e-6;
317 };
318 // A second of the same duel, from the same seed, with and without a flash on
319 // it: the blinded one has come nowhere near as far round towards the shot.
320 expect(turn(FLASH.flash.maxBlind)).toBeLessThan(turn(0));
321
322 // And a bot blinded at the far end of the map does not pick a fight it cannot
323 // see: its sight shrinks with the blindness.
324 const blinded = duel();
325 blinded.bot.blind = FLASH.flash.maxBlind;
326 botThink(blinded.world, blinded.bot, 1 / 64);
327 expect(flashed(blinded.bot, blinded.bot.brain!).sight).toBeLessThan(500);
328 expect(blinded.bot.brain!.targetId).toBe(null);
329 // The same bot, the same room, the same enemy five hundred units away, with
330 // its eyes working: it picks the fight it just could not see.
331 const seeing = duel();
332 botThink(seeing.world, seeing.bot, 1 / 64);
333 expect(seeing.bot.brain!.targetId).toBe(seeing.me.id);
334 });
335
336 /**
337 * A duel where both of them are standing somewhere de_forge actually has a
338 * floor, five hundred units apart down a clear line. The tests below step the
339 * movement as well as the thinking, so where they stand has to be real.
340 */
341 const standoff = (): ReturnType<typeof duel> => {
342 const d = duel();
343 d.me.pos = [-448, 2496, 2];
344 d.bot.pos = [64, 2496, 2];
345 d.bot.yaw = aimAt(d.bot, bodyCentre(d.me)).yaw;
346 return d;
347 };
348
349 /** Step a duel for `ticks`, counting the shots the bot actually got out. */
350 const fight = (d: ReturnType<typeof duel>, ticks: number): number => {
351 let shots = 0;
352 for (let t = 0; t < ticks; t++) {
353 const input = botThink(d.world, d.bot, 1 / 64);
354 const before = d.bot.lastShotAt;
355 applyInput(d.world, d.bot, input, 1 / 64);
356 if (d.bot.lastShotAt !== before) shots += 1;
357 d.world.time += 1 / 64;
358 }
359 return shots;
360 };
361
362 test("a gun is dry only with nothing behind it, and a knife never is", () => {
363 const { bot } = duel();
364 bot.primary = "ak47";
365 bot.weapon = "ak47";
366 bot.ammo.ak47 = { mag: 30, reserve: 90 };
367 expect(dry(bot, "ak47")).toBe(false);
368 expect(nextWeapon(bot)).toBe(null);
369 // An empty magazine with a spare behind it is a reload, not a swap.
370 bot.ammo.ak47 = { mag: 0, reserve: 90 };
371 expect(dry(bot, "ak47")).toBe(false);
372 expect(nextWeapon(bot)).toBe(null);
373 // Nothing anywhere: the pistol, then the knife, and then nothing more.
374 bot.ammo.ak47 = { mag: 0, reserve: 0 };
375 expect(dry(bot, "ak47")).toBe(true);
376 expect(nextWeapon(bot)).toBe(bot.secondary);
377 bot.ammo[bot.secondary] = { mag: 0, reserve: 0 };
378 expect(nextWeapon(bot)).toBe("knife");
379 bot.weapon = "knife";
380 expect(dry(bot, "knife")).toBe(false);
381 expect(nextWeapon(bot)).toBe(null);
382 });
383
384 test("a bot that has fired its rifle dry draws its pistol and keeps fighting", () => {
385 const d = standoff();
386 d.bot.primary = "ak47";
387 d.bot.weapon = "ak47";
388 d.bot.ammo.ak47 = { mag: 0, reserve: 0 }; // it spent the round's ammunition
389 const shots = fight(d, 192);
390 // Before this it held the trigger down on an empty rifle for the rest of the
391 // round: `shoot` refuses without a round in the magazine and `reload` refuses
392 // with nothing to reload from, so nobody was ever hit.
393 expect<WeaponId>(d.bot.weapon).toBe(d.bot.secondary);
394 expect(shots).toBeGreaterThan(0);
395 expect(d.me.health).toBeLessThan(100);
396 });
397
398 test("a bot with nothing left to fire closes the distance and knifes", () => {
399 const d = standoff();
400 d.bot.primary = "ak47";
401 d.bot.weapon = "ak47";
402 d.bot.ammo.ak47 = { mag: 0, reserve: 0 };
403 d.bot.ammo[d.bot.secondary] = { mag: 0, reserve: 0 };
404 const started = dist(d.bot.pos, d.me.pos);
405 const shots = fight(d, 384);
406 expect<WeaponId>(d.bot.weapon).toBe("knife");
407 expect(dist(d.bot.pos, d.me.pos)).toBeLessThan(started); // it walked in
408 expect(shots).toBeGreaterThan(0);
409 expect(d.me.health).toBeLessThan(100);
410 });
411
412 test("a bot pays the deploy time too: no bot bypasses the timer", () => {
413 const d = standoff();
414 d.bot.primary = "ak47";
415 d.bot.weapon = "ak47";
416 d.bot.ammo.ak47 = { mag: 0, reserve: 0 };
417 // A bot switches the way a person does — `input.switchTo` into `applyInput`
418 // — so it goes through exactly the same clock. Its pistol is a Glock (it is
419 // a T here) and CS's 0.7 s is 45 whole ticks at 64 Hz.
420 let swappedAt = -1;
421 let firstShot = -1;
422 for (let t = 0; t < 192; t++) {
423 const input = botThink(d.world, d.bot, 1 / 64);
424 const holding: WeaponId = d.bot.weapon;
425 const before = d.bot.lastShotAt;
426 applyInput(d.world, d.bot, input, 1 / 64);
427 if (swappedAt < 0 && d.bot.weapon !== holding) swappedAt = t;
428 if (firstShot < 0 && d.bot.lastShotAt !== before) firstShot = t;
429 d.world.time += 1 / 64;
430 }
431 expect<WeaponId>(d.bot.weapon).toBe(d.bot.secondary);
432 expect(swappedAt).toBeGreaterThanOrEqual(0);
433 expect(firstShot).toBeGreaterThan(swappedAt);
434 expect(deployTicks(WEAPONS[d.bot.secondary], 1 / 64)).toBe(45);
435 // The empty rifle's trigger sent nothing before the swap either, so the first
436 // shot of the round is the pistol's, and it is not one tick earlier than 45.
437 expect(firstShot - swappedAt).toBeGreaterThanOrEqual(45);
438 });
439
440 test("a bot with bullets left plays exactly as it did: no swap, no knife", () => {
441 const d = standoff();
442 d.bot.primary = "ak47";
443 d.bot.weapon = "ak47";
444 d.bot.ammo.ak47 = { mag: 30, reserve: 90 };
445 const shots = fight(d, 192);
446 expect(d.bot.weapon).toBe("ak47");
447 expect(shots).toBeGreaterThan(0);
191448 });
192449
193450 // --- reloading --------------------------------------------------------------
@@ -203,7 +460,7 @@ test("a bot alone with a half-empty magazine tops it up", () => {
203460 });
204461
205462 test("the same bot does not start that reload with an enemy in sight", () => {
206 const { world, bot } = duel(); // me is alive and visible, unlike above
463 const { world, bot } = standoff(); // me is alive and visible, unlike above
207464 bot.primary = "ak47";
208465 bot.weapon = "ak47";
209466 bot.ammo.ak47 = { mag: 10, reserve: 80 };
@@ -212,7 +469,7 @@ test("the same bot does not start that reload with an enemy in sight", () => {
212469 });
213470
214471 test("a bot that runs dry in a fight draws its loaded sidearm instead of reloading", () => {
215 const { world, bot } = duel();
472 const { world, bot } = standoff();
216473 bot.primary = "ak47";
217474 bot.weapon = "ak47";
218475 bot.ammo.ak47 = { mag: 0, reserve: 80 };
@@ -221,6 +478,17 @@ test("a bot that runs dry in a fight draws its loaded sidearm instead of reloadi
221478 expect(input.reload).toBe(false);
222479 });
223480
481 test("with the sidearm empty too, a bot that runs dry reloads what it holds", () => {
482 const { world, bot } = standoff();
483 bot.primary = "ak47";
484 bot.weapon = "ak47";
485 bot.ammo.ak47 = { mag: 0, reserve: 80 };
486 bot.ammo[bot.secondary] = { mag: 0, reserve: 40 };
487 const input = botThink(world, bot, 1 / 64);
488 expect(input.switchTo).toBeNull();
489 expect(input.reload).toBe(true);
490 });
491
224492 test("a bot with a full magazine asks for nothing", () => {
225493 const { world, me, bot } = duel();
226494 me.alive = false;
@@ -232,643 +500,169 @@ test("a bot with a full magazine asks for nothing", () => {
232500 expect(input.switchTo).toBeNull();
233501 });
234502
235 // --- hearing --------------------------------------------------------------
236
237 /**
238 * A bot standing in ct mid with its back to b link, and the person 512 units
239 * away through the wall between the two. The person moves for `seconds` —
240 * running, walking or ducking — and what comes back is what the bot made of
241 * the noise. Nothing here is pushed by hand: the footsteps are the ones
242 * `world.ts` pushes for a person who is actually moving.
243 */
244 function behindTheWall(seconds: number, how: Partial<typeof NO_INPUT>): Player {
245 const world = createWorld(COMPETITIVE, "you", "ct", 1, 4242);
246 world.match.phase = "live";
247 const bot = world.players[1] as Player;
248 world.me.pos = [-448, 1856, 2]; // b link
249 world.me.yaw = 90; // running up the room, not into the wall
250 bot.pos = [64, 1856, 2]; // ct mid, one wall away
251 bot.yaw = 0; // looking the other way entirely
252 bot.brain = makeBrain(1, 99);
503 test("the scripted baseline is not taught to top up: it does not move", () => {
504 const { world, me, bot } = duel();
505 me.alive = false;
506 bot.brain = makeBrain(1, 99, false);
253507 bot.brain.reset = false;
254 bot.brain.heardAt = world.time;
255 for (let t = 0; t < 64 * seconds; t++) update(world, 1 / 64, { ...NO_INPUT, forward: 1, ...how });
256 return bot;
257 }
258
259 test("a bot turns towards a running enemy it cannot see", () => {
260 const bot = behindTheWall(1, {});
261 const suspect = bot.brain!.suspect;
262 expect(suspect).not.toBeNull();
263 // It believes the noise is roughly where it was made: west of it, in b link.
264 expect(suspect![0]).toBeLessThan(bot.pos[0]);
265 const towards = aimAt(bot, suspect!).yaw;
266 expect(Math.abs(((towards - bot.yaw + 540) % 360) - 180)).toBeLessThan(45);
267 expect(Math.abs(bot.yaw)).toBeGreaterThan(45); // it started out looking the other way
268 });
269
270 test("the same enemy walking or ducking is not heard at all", () => {
271 expect(behindTheWall(1, { walk: true }).brain!.suspect).toBeNull();
272 expect(behindTheWall(1, { duck: true }).brain!.suspect).toBeNull();
273 });
274
275 test("a bot never hears itself walking about", () => {
276 const world = createWorld(RULES, "you", "ct", 1, 606);
277 world.match.phase = "live";
278 const bot = world.players[1] as Player;
279 // The person stands still and silent at the far end of the map; the only
280 // footsteps in this round are the bot's own.
281 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
282 let steps = 0;
283 for (let t = 0; t < 64 * 3; t++) {
284 update(world, 1 / 64, NO_INPUT);
285 steps += world.effects.filter((e) => e.kind === "sound" && e.name === "step" && e.by === bot.id).length;
286 expect(bot.brain?.suspect ?? null).toBeNull();
287 }
288 expect(steps).toBeGreaterThan(0); // it really was making a noise
289 });
290
291 // --- difficulty -----------------------------------------------------------
292
293 test("easy hands are strictly slower and shakier than hard's", () => {
294 const easy = createWorld(SHORT, "you", "ct", 5, 999, "easy");
295 const hard = createWorld(SHORT, "you", "ct", 5, 999, "hard");
296 for (const easyBot of easy.players) {
297 if (!easyBot.bot) continue;
298 const hardBot = hard.players.find((p) => p.id === easyBot.id)!;
299 const easyBrain = easyBot.brain!;
300 const hardBrain = hardBot.brain!;
301 expect(easyBrain.aimSpeed).toBeLessThan(hardBrain.aimSpeed);
302 expect(easyBrain.aimError).toBeGreaterThan(hardBrain.aimError);
303 expect(easyBrain.reaction).toBeGreaterThan(hardBrain.reaction);
304 }
305 });
306
307 test("createWorld gives every bot a skill inside its difficulty's band", () => {
308 for (const difficulty of DIFFICULTIES) {
309 const { lo, hi } = skillBand(difficulty);
310 const world = createWorld(SHORT, "you", "ct", 5, 555, difficulty);
311 const bots = world.players.filter((p) => p.bot);
312 expect(bots.length).toBeGreaterThan(0);
313 for (const bot of bots) {
314 expect(bot.brain).toBeDefined();
315 expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo);
316 expect(bot.brain!.skill).toBeLessThanOrEqual(hi);
317 }
318 }
319 });
320
321 test("a difficulty change waits for the next startRound", () => {
322 const world = createWorld(SHORT, "you", "ct", 5, 777, "normal");
323 const bot = world.players.find((p) => p.bot)!;
324 const before = bot.brain!.skill;
325 setDifficulty(world, "hard");
326 // Queued, not applied: the round already running keeps the hands it started with.
327 expect(world.difficulty).toBe("normal");
328 expect(bot.brain!.skill).toBe(before);
329 startRound(world);
330 expect(world.difficulty).toBe("hard");
331 const { lo, hi } = skillBand("hard");
332 expect(bot.brain!.skill).toBeGreaterThanOrEqual(lo);
333 expect(bot.brain!.skill).toBeLessThanOrEqual(hi);
334 });
335
336 test("restyleBrain changes the hands but keeps the plan it already made", () => {
337 const brain = makeBrain(0.9, 5);
338 brain.path = [[100, 200, 0]];
339 brain.step = 1;
340 brain.site = "B";
341 brain.targetId = 4;
342 restyleBrain(brain, 3, "easy");
343 const { lo, hi } = skillBand("easy");
344 expect(brain.skill).toBeGreaterThanOrEqual(lo);
345 expect(brain.skill).toBeLessThanOrEqual(hi);
346 expect(brain.path).toEqual([[100, 200, 0]]);
347 expect(brain.step).toBe(1);
348 expect(brain.site).toBe("B");
349 expect(brain.targetId).toBe(4);
350 });
351
352 test("the card describes a real training run", () => {
353 expect(CARD.envSteps).toBeGreaterThan(1_000_000);
354 expect(CARD.updates).toBeGreaterThan(0);
355 expect(CARD.duels).toBeGreaterThan(0);
356 expect(CARD.winRate).toBeGreaterThanOrEqual(0);
357 expect(CARD.winRate).toBeLessThanOrEqual(100);
358 expect(CARD.trainedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
359 });
360
361 // --- the round's plan -----------------------------------------------------
362
363 /**
364 * A round played out headlessly: every player, the person's slot included, is
365 * driven by the same bot code, so this is a real round with nobody watching.
366 * Three a side and a half-minute clock: the whole suite is a few seconds, and
367 * a full 1:55 round with ten players in it is most of one on its own.
368 */
369 const RULES = { ...SHORT, freezeTime: 0.5, roundTime: 30 };
370
371 function playRound(seed: number, teamSize = 3): { planted: boolean; left: number } {
372 const world = createWorld(RULES, "you", "ct", teamSize, seed);
373 for (let t = 0; t < 64 * 70; t++) {
374 update(world, 1 / 64, botThink(world, world.me, 1 / 64));
375 if (world.bomb.state === "planted") return { planted: true, left: world.match.timer };
376 if (world.match.phase === "over" || world.match.phase === "match_over") break;
377 }
378 return { planted: false, left: 0 };
379 }
380
381 test("a side walks in together: every terrorist reads the round's own site", () => {
382 const world = createWorld(SHORT, "you", "ct", 5, 20240);
383 for (let t = 0; t < 4; t++) for (const p of world.players) if (p.bot) botThink(world, p, 1 / 64);
384 const ts = world.players.filter((p) => p.team === "t" && p.bot);
385 expect(ts.length).toBe(5);
386 for (const t of ts) expect(t.brain?.site).toBe(world.plan.push);
387 // And the defence covers both sites rather than all standing on one.
388 const cts = world.players.filter((p) => p.team === "ct" && p.bot).map((p) => p.brain?.site);
389 expect(new Set(cts).size).toBe(2);
390 });
391
392 test("the carrier gets the bomb down inside the round clock", () => {
393 const seeds = [11, 23, 37, 51, 67, 83, 97, 113, 131, 149];
394 const planted = seeds.filter((seed) => playRound(seed).planted);
395 // Not every round: a carrier that loses the fight on the way is a round the
396 // terrorists lost, and that is allowed to happen. Half of them, though —
397 // where the code this replaced planted in none of these, because a bot that
398 // rolled its own site never arrived anywhere with anybody. Ten seeds rather
399 // than five because the rate this is asking about is only about 54% (that is
400 // `scripts/measure.ts` over 177 rounds, before and after the bots could
401 // hear), and five rounds cannot tell 54% from a bad afternoon.
402 expect(planted.length).toBeGreaterThanOrEqual(5);
403 }, 10_000); // ten rounds played out headlessly, twice the default timeout for a slower box
404
405 test("a bomb on the floor is still picked up by a living terrorist", () => {
406 const world = createWorld(RULES, "you", "ct", 1, 4242);
407 world.match.phase = "live";
408 const bot = world.players.find((p) => p.team === "t") as Player;
409 world.me.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2]; // out of the way, and out of sight
410 bot.hasBomb = false;
411 world.bomb = { ...world.bomb, state: "dropped", pos: [bot.pos[0] + 240, bot.pos[1], bot.pos[2] + 4] };
412 for (let t = 0; t < 64 * 6 && world.bomb.state === "dropped"; t++) update(world, 1 / 64, NO_INPUT);
413 expect(world.bomb.state).toBe("carried");
414 expect(bot.hasBomb).toBe(true);
415 });
416
417 // --- picking a gun up off the floor ----------------------------------------
418
419 test("a bot with an empty primary slot detours to a rifle a few steps off its path", () => {
420 const world = createWorld(RULES, "you", "t", 1, 4242);
421 world.match.phase = "live";
422 const bot = world.players.find((p) => p.team === "ct") as Player;
423 world.me.alive = false; // nothing for it to fight, nothing to flee
424 expect(bot.primary).toBeNull(); // an eco round: too little money to have bought one
425 world.pickups.push({
426 weapon: "m4a4", pos: [bot.pos[0] + 80, bot.pos[1], bot.pos[2] + 4], ammo: { mag: 30, reserve: 90 },
427 });
428 for (let t = 0; t < 64 * 6 && bot.primary === null; t++) update(world, 1 / 64, NO_INPUT);
429 expect(bot.primary).toBe("m4a4");
430 });
431
432 test("a bot already holding a primary never diverts for one on the floor", () => {
433 const world = createWorld(SHORT, "you", "ct", 1, 1);
434 const bot = world.players[1] as Player;
435508 bot.primary = "ak47";
436 world.pickups.push({
437 weapon: "m4a4", pos: [bot.pos[0] + 10, bot.pos[1], bot.pos[2]], ammo: { mag: 30, reserve: 90 },
438 });
439 const goal: [number, number, number] = [123, 456, 0];
440 expect(gunGoal(world, bot, goal)).toBe(goal);
509 bot.weapon = "ak47";
510 bot.ammo.ak47 = { mag: 10, reserve: 80 };
511 const input = botThink(world, bot, 1 / 64);
512 expect(input.reload).toBe(false);
441513 });
442514
443 test("a rifle across the map is not worth crossing for", () => {
444 const world = createWorld(SHORT, "you", "ct", 1, 1);
445 const bot = world.players[1] as Player;
446 bot.primary = null;
447 bot.pos = [0, 0, 2];
448 world.pickups.push({ weapon: "ak47", pos: [4000, 4000, 2], ammo: { mag: 30, reserve: 90 } });
449 const goal: [number, number, number] = [100, 0, 2];
450 expect(gunGoal(world, bot, goal)).toBe(goal);
515 // --- the round's plan -------------------------------------------------------
516
517 const planned = (over: Partial<TeamPlan> = {}): RoundPlan => ({
518 round: 1,
519 source: "typesafe",
520 model: "jev-1",
521 t: { site: "A", shape: "stack", tempo: 2, save: false, ...over },
522 ct: { site: "A", shape: "stack", tempo: 2, save: false, ...over },
451523 });
452524
453 test("an empty pickups list leaves the goal it was given untouched", () => {
454 const world = createWorld(SHORT, "you", "ct", 1, 1);
455 const bot = world.players[1] as Player;
456 bot.primary = null;
457 const goal: [number, number, number] = [123, 456, 0];
458 expect(world.pickups.length).toBe(0);
459 expect(gunGoal(world, bot, goal)).toBe(goal); // same reference: nothing was computed
460 });
461
462 // --- the radio ------------------------------------------------------------
463
464 /**
465 * A side thinking with nobody to shoot at: the enemy is taken out of the world
466 * so every bot takes the walking branch, which is the one that reads the plan.
467 * The person is a terrorist here, so the terrorists are the side that hears.
468 */
469 function quietSide(seed: number): { world: ReturnType<typeof createWorld>; carrier: Player } {
470 const world = createWorld(SHORT, "you", "t", 5, seed);
471 world.match.phase = "live";
472 for (const p of world.players) {
473 if (p.team === "ct") p.alive = false;
474 p.hasBomb = false;
475 }
476 const carrier = world.players.find((p) => p.team === "t" && p.bot) as Player;
477 carrier.hasBomb = true;
478 return { world, carrier };
479 }
480
481 const thinkAll = (world: ReturnType<typeof createWorld>): void => {
482 for (const p of world.players) if (p.bot && p.alive) botThink(world, p, 1 / 64);
483 };
484
485 test("a bot carrying the bomb obeys a call exactly as it obeys the plan", () => {
486 const { world, carrier } = quietSide(20240);
487 thinkAll(world);
488 expect(carrier.brain?.site).toBe(world.plan.push);
489 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[world.plan.push]);
490
491 const called = otherSite(world.plan.push);
492 world.time += 1;
493 radioCall(world, called);
494 thinkAll(world); // the next tick, and the bomb is already walking the other way
495 expect(carrier.brain?.site).toBe(called);
496 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[called]);
497 });
498
499 test("the four teammates turn round on the call, and the other side never hears it", () => {
500 const { world } = quietSide(4242);
501 thinkAll(world);
502 const called = otherSite(world.plan.push);
503 world.time += 1;
504 radioCall(world, called);
505 thinkAll(world);
506 const ts = world.players.filter((p) => p.team === "t" && p.bot);
507 expect(ts.length).toBe(4);
508 for (const t of ts) expect(t.brain?.site).toBe(called);
509 // The counter-terrorists were never told anything: their brains still read
510 // the roll, split across the two sites exactly as they were.
511 for (const ct of world.players.filter((p) => p.team === "ct")) ct.alive = true;
512 thinkAll(world);
513 const cts = world.players.filter((p) => p.team === "ct").map((p) => p.brain?.site);
514 expect(new Set(cts).size).toBe(2);
515 });
516
517 test("a call expires and the side goes back to the plan it rolled", () => {
518 const { world, carrier } = quietSide(1234);
519 const called = otherSite(world.plan.push);
520 radioCall(world, called);
521 thinkAll(world);
522 expect(carrier.brain?.site).toBe(called);
523 world.time += CALL_LIFE + 1;
524 thinkAll(world);
525 expect(carrier.brain?.site).toBe(world.plan.push);
526 });
527
528 test("hold stops the push and leaves nobody standing still in the open", () => {
529 const { world, carrier } = quietSide(777);
530 thinkAll(world);
531 const site = DE_FORGE.sites[world.plan.push];
532 expect(dist2d(carrier.pos, carrier.brain?.goal ?? [0, 0, 0])).toBeGreaterThan(600);
533
534 world.time += 1;
535 radioCall(world, "hold");
536 thinkAll(world);
537 for (const t of world.players.filter((p) => p.team === "t" && p.bot)) {
538 const goal = t.brain?.goal as [number, number, number];
539 // Where it stands, not where the plan was taking it — and a goal it can
540 // still walk to, so a held bot repositions rather than freezing.
541 expect(t.brain?.site).toBe(world.plan.push); // hold does not move the site
542 expect(dist2d(t.pos, goal)).toBeLessThanOrEqual(360);
543 expect(dist2d(goal, site)).toBeGreaterThan(600);
544 expect(t.brain?.path.length).toBeGreaterThan(0);
545 }
546 });
547
548 test("a call outranks the rotation a counter-terrorist would have made on its own", () => {
549 // The person is a counter-terrorist — the side the menu picks by default —
550 // and one of their teammates has just died at B. That is the contact the
551 // defence rotates to on its own, and it is exactly what a person who has
552 // seen something at A is calling against.
553 const world = createWorld(SHORT, "you", "ct", 5, 31);
554 world.match.phase = "live";
555 for (const p of world.players) if (p.team === "t") p.alive = false;
556 const cts = world.players.filter((p) => p.team === "ct" && p.bot);
557 const fallen = cts[0] as Player;
558 fallen.alive = false;
559 fallen.pos = [...DE_FORGE.sites.B] as [number, number, number];
560 world.feed.push({
561 at: world.time, killer: "them", killerId: 9, victim: fallen.name, victimId: fallen.id,
562 weapon: "ak47", headshot: false, killerTeam: "t", killerHealth: 100,
563 });
564
565 const standing = cts.filter((p) => p.alive);
566 const near = (p: Player, site: "A" | "B"): number =>
567 dist2d(p.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[site]);
568 thinkAll(world);
569 // Without a word said, every one of them is on its way to the contact.
570 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
571
572 world.time += 1;
573 radioCall(world, "A");
574 thinkAll(world);
575 for (const ct of standing) {
576 expect(ct.brain?.site).toBe("A");
577 expect(near(ct, "A")).toBeLessThan(400);
578 }
579
580 // And when the call runs out, the side reads the round for itself again.
581 world.time += CALL_LIFE + 1;
582 thinkAll(world);
583 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
584 });
585
586 // --- the clock ------------------------------------------------------------
587
588 /**
589 * The terrorists walking in with the bomb and the clock wherever the test puts
590 * it, with one counter-terrorist standing in front of the carrier so that the
591 * branch every terrorist is in is the fight — the one that trades ground for a
592 * duel. Nothing is planted, and the person is a terrorist so that their side
593 * is the one the radio reaches.
594 */
595 function underFire(secondsLeft: number, seed = 20240): {
596 world: ReturnType<typeof createWorld>; carrier: Player; enemy: Player;
597 } {
598 const world = createWorld(SHORT, "you", "t", 3, seed);
599 world.match.phase = "live";
600 world.match.timer = secondsLeft;
601 const carrier = world.players.find((p) => p.team === "t" && p.bot && p.hasBomb) as Player;
602 const enemy = world.players.find((p) => p.team === "ct") as Player;
603 for (const p of world.players) if (p.team === "ct" && p.id !== enemy.id) p.alive = false;
604 // Close enough to be unmissable, and the carrier already looking at it.
605 enemy.pos = [carrier.pos[0] + 160, carrier.pos[1], carrier.pos[2]];
606 carrier.yaw = aimAt(carrier, bodyCentre(enemy)).yaw;
607 return { world, carrier, enemy };
608 }
609
610 test("with the clock low and no plant, a terrorist's feet go to the site", () => {
611 const { world, carrier } = underFire(25);
612 botThink(world, carrier, 1 / 64);
613 expect(carrier.brain?.targetId).not.toBeNull(); // it really is in a fight
614 // The policy still has the gun; the plan has taken the feet, and they are
615 // walking to the site the round rolled.
616 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[carrier.brain!.site]);
617 expect(carrier.brain?.path.length).toBeGreaterThan(0);
618 });
619
620 test("with the clock high the same fight is the policy's to move", () => {
621 const { world, carrier } = underFire(110);
622 botThink(world, carrier, 1 / 64);
623 expect(carrier.brain?.targetId).not.toBeNull(); // the same fight, one number apart
624 expect(carrier.brain?.goal).toBeNull(); // nothing asked the plan for a path
625 });
626
627 test("the escort feels the clock too, not just the bot holding the bomb", () => {
628 const { world, carrier } = underFire(25);
629 const escort = world.players.find((p) => p.team === "t" && p.bot && p.id !== carrier.id) as Player;
630 escort.pos = [...carrier.pos] as [number, number, number];
631 escort.yaw = carrier.yaw;
632 botThink(world, escort, 1 / 64);
633 expect(escort.brain?.targetId).not.toBeNull();
634 expect(dist2d(escort.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[escort.brain!.site])).toBeLessThan(400);
635 });
636
637 test("a call is answered on a hard push: the push goes where the person said", () => {
638 const { world, carrier } = underFire(25);
639 const called = otherSite(world.plan.push);
640 world.time += 1;
641 radioCall(world, called);
642 botThink(world, carrier, 1 / 64);
643 expect(carrier.brain?.site).toBe(called);
644 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[called]);
645 });
646
647 test("hold outranks the clock: a bot told to stand still is not dragged off", () => {
648 const { world, carrier } = underFire(25);
649 world.time += 1;
650 radioCall(world, "hold");
651 botThink(world, carrier, 1 / 64);
652 expect(carrier.brain?.holding).toBe(true);
653 // It fights where it was told to stand: nothing asked the plan for a path
654 // to the site, which is what the same clock does to a bot nobody called.
655 expect(carrier.brain?.goal).toBeNull();
656 });
657
658 test("once the bomb is down the clock stops moving the terrorists' feet", () => {
659 const { world, carrier } = underFire(25);
660 // The same round one moment later: the bomb is on the floor at the site and
661 // the carrier is in the fight it was already in.
662 world.bomb = {
663 ...world.bomb,
664 state: "planted",
665 pos: [...DE_FORGE.sites[carrier.brain?.site ?? "A"]] as [number, number, number],
666 };
667 carrier.hasBomb = false;
668 botThink(world, carrier, 1 / 64);
669 expect(carrier.brain?.targetId).not.toBeNull();
670 // There is nothing left to be late for. Time pressure is off, so nothing
671 // walks a terrorist at the bomb in the middle of a duel it is having —
672 // holding near a planted bomb is not the same thing as standing on it.
673 expect(carrier.brain?.goal).toBeNull();
674 });
675
676 /** The defence with nothing to shoot at and nothing on the floor to defend. */
677 function quietDefence(secondsLeft: number, seed = 31): ReturnType<typeof createWorld> {
678 const world = createWorld(SHORT, "you", "ct", 3, seed);
679 world.match.phase = "live";
680 world.match.timer = secondsLeft;
681 for (const p of world.players) if (p.team === "t") p.alive = false;
525 /** A full ten-bot round, frozen on the first tick, with every brain made. */
526 const round = (seed = 77): ReturnType<typeof createWorld> => {
527 const world = createWorld(DE_FORGE, COMPETITIVE, "you", "ct", 5, seed);
528 world.match.phase = "freeze";
682529 return world;
683 }
684
685 const defenders = (world: ReturnType<typeof createWorld>): Player[] =>
686 world.players.filter((p) => p.team === "ct" && p.bot);
687
688 test("with the clock low and no plant, the defence stops holding and goes looking", () => {
689 const late = quietDefence(HUNT_AT - 1);
690 thinkAll(late);
691 for (const ct of defenders(late)) {
692 const goal = ct.brain?.goal ?? [0, 0, 0];
693 // Nobody has come to the corner it was sitting in, so it walks at the half
694 // of the map the terrorists came from — and it walks, rather than teleports.
695 expect(dist2d(goal, DE_FORGE.muster.t)).toBeLessThan(1000);
696 expect(dist2d(goal, DE_FORGE.sites[ct.brain!.site])).toBeGreaterThan(2000);
697 expect(ct.brain?.path.length).toBeGreaterThan(0);
698 }
699
700 const early = quietDefence(HUNT_AT + 1);
701 thinkAll(early);
702 for (const ct of defenders(early)) {
703 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[ct.brain!.site])).toBeLessThan(400);
704 }
705 });
706
707 test("the defence leaves its angle the tick the clock says so, not at the next repath", () => {
708 const world = quietDefence(HUNT_AT + 10);
709 thinkAll(world);
710 const holding = defenders(world).map((ct) => {
711 const brain = ct.brain as NonNullable<Player["brain"]>;
712 // A path it is part-way through and a repath it is nowhere near due for:
713 // nothing about this bot is stale, which is the whole point of the test.
714 expect(dist2d(brain.goal ?? [0, 0, 0], DE_FORGE.sites[brain.site])).toBeLessThan(400);
715 expect(brain.path.length).toBeGreaterThan(0);
716 expect(brain.step).toBeLessThan(brain.path.length);
717 expect(world.time).toBeLessThan(brain.repathAt);
718 return { ct, brain, plannedFor: brain.plannedFor };
719 });
720 expect(holding.length).toBeGreaterThan(0);
721
722 // Eleven seconds of clock and not a tick of anything else. The objective it
723 // planned for has to say it is hunting, or the defence sits on its angle for
724 // the rest of its repath window with the round running out.
725 world.match.timer = HUNT_AT - 1;
726 thinkAll(world);
727 for (const { brain, plannedFor } of holding) {
728 expect(brain.plannedFor).not.toBe(plannedFor);
729 expect(dist2d(brain.goal ?? [0, 0, 0], DE_FORGE.muster.t)).toBeLessThan(1000);
730 }
731 });
732
733 test("a call turns a rotating defender round inside its repath window", () => {
734 // The person is a counter-terrorist, a teammate has just died at B, and the
735 // side is rotating there on its own. The person calls A — which is the site
736 // this bot was rolled onto anyway, so nothing about its orders changes
737 // except that a person said them.
738 const world = quietDefence(SHORT.roundTime); // nothing hunting at this clock
739 const fallen = defenders(world)[0] as Player;
740 fallen.alive = false;
741 fallen.pos = [...DE_FORGE.sites.B] as [number, number, number];
742 world.feed.push({
743 at: world.time, killer: "them", killerId: 9, victim: fallen.name, victimId: fallen.id,
744 weapon: "ak47", headshot: false, killerTeam: "t", killerHealth: 100,
745 });
746 thinkAll(world);
747 const rotated = defenders(world).filter((p) => p.alive && p.brain?.site === "A");
748 expect(rotated.length).toBeGreaterThan(0);
749 for (const ct of rotated) {
750 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.B)).toBeLessThan(400);
751 expect(world.time).toBeLessThan(ct.brain!.repathAt); // the path is fresh
752 }
753
754 radioCall(world, "A");
755 thinkAll(world);
756 for (const ct of rotated) {
757 expect(ct.brain?.site).toBe("A"); // the call named the site it already had
758 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.A)).toBeLessThan(400);
759 }
760 });
761
762 test("a bomb on the floor is what the defence is for: no hunt once it is planted", () => {
763 const world = quietDefence(HUNT_AT - 1);
764 world.bomb = { ...world.bomb, state: "planted", pos: [...DE_FORGE.sites.A] as [number, number, number] };
765 thinkAll(world);
766 for (const ct of defenders(world)) {
767 expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.A)).toBeLessThan(200);
768 }
769 });
770
771 test("a defender told to hold keeps holding however low the clock is", () => {
772 const world = quietDefence(HUNT_AT - 1);
773 world.time += 1;
774 radioCall(world, "hold");
775 thinkAll(world);
776 for (const ct of defenders(world)) {
777 expect(ct.brain?.holding).toBe(true);
778 expect(dist2d(ct.pos, ct.brain?.goal ?? [0, 0, 0])).toBeLessThanOrEqual(360);
779 }
780 });
781
782 // --- callouts -------------------------------------------------------------
783
784 /**
785 * One teammate and one enemy, six cells apart down mid with nothing between
786 * them, and two more players parked out of the way at the far end of the map.
787 * The teammate is looking straight at the enemy, so `pickTarget`'s own trace
788 * is what decides whether it can see anything — nothing here is handed to it.
789 */
790 function squad(seed = 4114): { world: World; mate: Player; foe: Player } {
791 const world = createWorld(COMPETITIVE, "you", "ct", 2, seed);
792 world.match.phase = "live";
793 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
794 const foe = world.players.find((p) => p.team === "t") as Player;
795 for (const p of world.players) {
796 if (p === mate || p === foe) continue;
797 p.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
798 }
799 mate.pos = [...worldOf(DE_FORGE, 22, 14, 2)];
800 foe.pos = [...worldOf(DE_FORGE, 22, 20, 2)];
801 mate.brain = makeBrain(1, 99);
802 mate.brain.reset = false;
803 mate.yaw = aimAt(mate, bodyCentre(foe)).yaw;
804 return { world, mate, foe };
805 }
806
807 const callouts = (world: World): string[] =>
808 world.messages.filter((m) => m.by !== undefined).map((m) => m.text);
809
810 /** Let `bot` think for `seconds` of ticks, the way a round would. */
811 const thinkFor = (world: World, bot: Player, seconds: number): void => {
812 for (let t = 0; t < 64 * seconds; t++) {
813 botThink(world, bot, 1 / 64);
814 world.time += 1 / 64;
815 }
816530 };
817531
818 test("a teammate that can see an enemy says where it is", () => {
819 const { world, mate, foe } = squad();
820 botThink(world, mate, 1 / 64);
821 expect(callouts(world)).toEqual([`${mate.name}: enemy — ${placeAt(DE_FORGE, foe.pos)}`]);
822 expect(world.messages[world.messages.length - 1]?.by).toBe(mate.id);
532 const bots = (world: ReturnType<typeof createWorld>, team: "t" | "ct"): Player[] =>
533 world.players.filter((p) => p.team === team && p !== world.me);
534
535 test("with no plan a bot is what it always was: its own coin flip, no wait", () => {
536 const world = round();
537 const bot = bots(world, "t")[0] as Player;
538 botThink(world, bot, 1 / 64);
539 expect(roundPlan(world)).toBeNull();
540 expect(bot.brain?.job.role).toBe("support");
541 expect(bot.brain?.job.delay).toBe(0);
542 expect(["A", "B"]).toContain(bot.brain?.job.site ?? "");
823543 });
824544
825 test("a teammate with nothing in view says nothing", () => {
826 const { world, mate, foe } = squad();
827 // The same two players, with the whole map between them: b site cannot be
828 // seen from mid, and `pickTarget` is the thing that finds that out.
829 foe.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
830 thinkFor(world, mate, 3);
831 expect(callouts(world)).toEqual([]);
545 test("a plan makes a team a team: one site between them, and one of them first", () => {
546 const world = round();
547 usePlan(world, planned({ site: "B" }));
548 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
549 const attackers = bots(world, "t");
550 expect(attackers.every((bot) => bot.brain?.job.site === "B")).toBe(true);
551 expect(attackers.filter((bot) => bot.brain?.job.role === "entry")).toHaveLength(1);
552 // The entry goes now and the rest give it its beat: that is the coordination.
553 const entry = attackers.find((bot) => bot.brain?.job.role === "entry") as Player;
554 expect(entry.brain?.job.delay).toBe(0);
555 expect(attackers.some((bot) => (bot.brain?.job.delay ?? 0) > 0)).toBe(true);
832556 });
833557
834 test("a teammate does not say the same thing twice in a row", () => {
835 const { world, mate } = squad();
836 botThink(world, mate, 1 / 64);
837 expect(callouts(world).length).toBe(1);
838 // Half a minute of staring down the same corridor at the same enemy: the
839 // sighting was news once, and the rate limit and the contact edge between
840 // them keep it at once.
841 thinkFor(world, mate, 30);
842 expect(callouts(world).length).toBe(1);
558 test("a split sends the attackers to both sites at once", () => {
559 const world = round();
560 usePlan(world, planned({ site: "A", shape: "split" }));
561 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
562 const sites = bots(world, "t").map((bot) => bot.brain?.job.site);
563 expect(sites.filter((site) => site === "A").length).toBeGreaterThan(0);
564 expect(sites.filter((site) => site === "B").length).toBeGreaterThan(0);
843565 });
844566
845 test("nothing the other side's bots know ever reaches the person's feed", () => {
846 const { world, mate, foe } = squad();
847 // The enemy looking straight back down the same corridor, for as long as
848 // the teammate looked down it.
849 foe.brain = makeBrain(1, 77);
850 foe.brain.reset = false;
851 foe.yaw = aimAt(foe, bodyCentre(mate)).yaw;
852 mate.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2]; // and the teammate out of it
853 thinkFor(world, foe, 30);
854 expect(callouts(world)).toEqual([]);
855 });
856
857 test("a round restart clears the callouts and leaves the round's own line", () => {
858 const { world, mate, foe } = squad();
859 botThink(world, mate, 1 / 64);
860 expect(callouts(world).length).toBe(1);
861 world.messages.push({ at: world.time, text: "Terrorists win — detonated the bomb" });
862 startRound(world);
863 expect(callouts(world)).toEqual([]);
864 expect(world.messages.map((m) => m.text)).toEqual(["Terrorists win — detonated the bomb"]);
865 // And the side starts the round with nothing already said, so the first
866 // sighting of it is news again.
567 test("a bot waiting for its beat stands still, and goes when the beat comes", () => {
568 const world = round();
569 usePlan(world, planned());
570 world.match.phase = "freeze";
571 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
572 const waiting = bots(world, "t").find((bot) => (bot.brain?.job.delay ?? 0) > 0) as Player;
573 waiting.hasBomb = false;
867574 world.match.phase = "live";
868 mate.pos = [...worldOf(DE_FORGE, 22, 14, 2)];
869 foe.pos = [...worldOf(DE_FORGE, 22, 20, 2)];
870 mate.yaw = aimAt(mate, bodyCentre(foe)).yaw;
871 botThink(world, mate, 1 / 64); // `reset` and all: this is the round's first tick
872
873 expect(callouts(world).length).toBe(1);
575 world.time += 0.5;
576 const held = botThink(world, waiting, 1 / 64);
577 expect(held.forward).toBe(0);
578 expect(held.side).toBe(0);
579 // Past the wait it moves off towards the site like any other bot.
580 world.time = (waiting.brain?.pushAt ?? 0) + 0.1;
581 const moving = botThink(world, waiting, 1 / 64);
582 expect(Math.abs(moving.forward) + Math.abs(moving.side)).toBeGreaterThan(0);
583 });
584
585 test("a plan that says save is a round nothing is spent on a rifle", () => {
586 const world = round();
587 const bot = bots(world, "t")[0] as Player;
588 bot.money = 2400;
589 usePlan(world, planned({ save: true }));
590 botBuy(world, bot);
591 expect(bot.primary).toBeNull();
592 expect(bot.money).toBe(2400);
593
594 // The same money with the same plan, on a side that is not saving: it buys.
595 const spending = round(78);
596 const other = bots(spending, "t")[0] as Player;
597 other.money = 2400;
598 usePlan(spending, planned({ save: false }));
599 botBuy(spending, other);
600 expect(other.money).toBeLessThan(2400);
601 });
602
603 test("a plan that lands mid-round re-tasks the team it was cut for", () => {
604 const world = round();
605 usePlan(world, planned({ site: "A" }));
606 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
607 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "A")).toBe(true);
608 usePlan(world, planned({ site: "B" }));
609 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
610 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "B")).toBe(true);
611 expect(roundPlan(world)?.t.site).toBe("B");
612 });
613
614 // --- the radio --------------------------------------------------------------
615
616 test("a call puts the caller's side on the site it named, and leaves the other side alone", () => {
617 const world = round();
618 usePlan(world, planned({ site: "A", shape: "fake" }));
619 world.match.phase = "live";
620 const made = radio(world, world.me, "B");
621 expect(made?.side).toBe("ct");
622 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
623 // All four defenders, the fake's lurker too: a call is everyone.
624 expect(bots(world, "ct").map((bot) => bot.brain?.job.site)).toEqual(["B", "B", "B", "B"]);
625 // The attackers never heard it, and play the plan they were handed.
626 expect(bots(world, "t").filter((bot) => bot.brain?.job.site === "A").length).toBeGreaterThan(2);
627 expect(bots(world, "t").some((bot) => bot.brain?.job.site === "B")).toBe(true); // the fake's lurker
628 });
629
630 test("a call expires back to the roll, and a hold keeps a side where it stands until then", () => {
631 const world = round();
632 usePlan(world, planned({ site: "A" }));
633 world.match.phase = "live";
634 world.me.team = "t";
635 radio(world, world.me, "B");
636 const think = (): void => {
637 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
638 };
639 think();
640 const attackers = bots(world, "t");
641 expect(attackers.every((bot) => bot.brain?.job.site === "B")).toBe(true);
642 // One tick past its life, the plan has the side back.
643 world.time += CALL_LIFE + 0.1;
644 think();
645 expect(attackers.every((bot) => bot.brain?.job.site === "A")).toBe(true);
646
647 // A hold: nobody but the bomb moves off before the call runs out.
648 radio(world, world.me, "hold");
649 think();
650 const end = world.time + CALL_LIFE;
651 for (const bot of attackers) {
652 if (bot.hasBomb) continue;
653 expect(bot.brain?.pushAt ?? 0).toBeCloseTo(end, 6);
654 const held = botThink(world, bot, 1 / 64);
655 if (bot.brain?.targetId === null) expect([held.forward, held.side]).toEqual([0, 0]);
656 }
657 });
658
659 test("a call from last round is not this round's call", () => {
660 const world = round();
661 usePlan(world, planned({ site: "A" }));
662 world.match.phase = "live";
663 radio(world, world.me, "B");
664 world.match.round += 1;
665 usePlan(world, { ...planned({ site: "A" }), round: world.match.round });
666 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
667 expect(bots(world, "ct").every((bot) => bot.brain?.job.site === "A")).toBe(true);
874668 });
src/client/bots.ts +606 −427
@@ -7,68 +7,122 @@
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, dist2d, 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, type TeamPlan } from "../plan/plan.ts";
43 import { heed, liveCall, steer, type RadioCall } from "../plan/call.ts";
44 import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
45 import { CS_HULL } from "./movement.ts";
46 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
47 import { SHAPE } from "./packed.ts";
48 import { ease, grip, gripRate, sprayPull } from "./spray.ts";
49 import { cloudRadius } from "./grenade.ts";
50 import { FLASH, WEAPONS, type WeaponId } from "./weapons.ts";
51 import { buy } from "./economy.ts";
2552 import type { Input, Player, World } from "./world.ts";
26 import { aimAt, bodyCentre, buy, callout, canSee, NO_INPUT, pickupSlot } from "./world.ts";
53 import { aimAt, bodyCentre, canSee, NO_INPUT } from "./world.ts";
54 import { coverFrom } from "./cover.ts";
2755
28 export { CARD };
29
30 /** The weights are unpacked once, the first time a bot needs to think. */
56 /**
57 * The policy every bot in this process thinks with, put here once by whoever
58 * got hold of one. There is one because there is one game: a bot's own skill
59 * is what makes it easy or hard, not a network of its own.
60 */
3161 let learned: Policy | null = null;
62
63 /** What `policy()` says when nobody handed one in. */
64 export const NO_POLICY = "no policy: nothing has called usePolicy, so a bot has nothing to think with";
65
66 /** Hand the bots the policy they think with. `main.ts` at boot, `shipped.ts` in Bun. */
67 export function usePolicy(net: Policy): void {
68 learned = net;
69 }
70
71 /**
72 * The policy the bots think with. It throws rather than falling back to the
73 * scripted fight: a round played against bots that quietly stopped being the
74 * trained ones is the failure nobody would notice, and the browser never
75 * reaches here anyway — Play is not pressable until the weights have arrived.
76 */
3277 export function policy(): Policy {
33 if (learned === null) learned = unpack(PACKED);
78 if (learned === null) throw new Error(NO_POLICY);
3479 return learned;
3580 }
3681
37 /** The policy decides at 16 Hz and holds it, exactly as it was trained. */
38 const SKIP = 4;
39
4082 /**
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.
83 * Hand one world's bots the round they are playing. `main.ts` does this every
84 * round: the local plan the moment the round starts, and the planned one if a
85 * better one arrives while it is still that round.
86 *
87 * It is the world's and not this module's, unlike the policy above, because a
88 * plan is one match's round and a policy is the whole game's: a plan in a
89 * module would be one match's orders reaching the next match's bots, and the
90 * arena's duels and the trainer's rounds — worlds nobody plans for — would be
91 * playing whatever the last page happened to ask for.
4492 */
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);
93 export function usePlan(world: World, plan: RoundPlan | null): void {
94 world.plan = plan;
6295 }
6396
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];
97 /**
98 * Hand a world's bots a plan that came from somewhere slower than arithmetic —
99 * the server, and a model behind it. It is held, not played: `updateWith` in
100 * `world.ts` puts it in force on the tick this round's freeze ends, so two
101 * playings handed the same plan play the same round however long either
102 * answer took. A plan for another round, or one that arrives once the round is
103 * live, is refused and the round keeps the plan it has. True when it was taken.
104 */
105 export function offerPlan(world: World, plan: RoundPlan): boolean {
106 if (world.match.phase !== "freeze" || plan.round !== world.match.round) return false;
107 world.planned = plan;
108 return true;
109 }
110
111 /** The round this world's bots are playing, or null when nobody planned one. */
112 export const roundPlan = (world: World): RoundPlan | null => world.plan;
113
114 /** The policy decides at 16 Hz and holds it, exactly as it was trained. */
115 const SKIP = 4;
66116
67117 export type BotBrain = {
68118 skill: number; // 0 = fodder, 1 = uncomfortable
69119 reaction: number; // seconds between seeing and shooting
70120 aimSpeed: number; // degrees a second
71121 aimError: number; // degrees of standing error
122 grip: number; // 0..1, the share of the spray pattern these hands pull back
123 gripRate: number; // how fast that pull catches up, e-foldings a second
124 /** Where the pull is holding the view right now: [yaw, pitch] degrees. */
125 pull: [number, number];
72126 goal: Vec3 | null;
73127 path: Vec3[];
74128 step: number;
@@ -80,13 +134,16 @@ export type BotBrain = {
80134 strafe: number;
81135 strafeUntil: number;
82136 suspect: Vec3 | null;
83 /** The world clock the last time this bot listened: what came after it is news. */
84 heardAt: number;
85137 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
138 /** This bot's share of the round: a site, a role, a beat to wait, a spread. */
139 job: Job;
140 /** The plan the job was cut from, and the round it was cut for. */
141 jobPlan: RoundPlan | null;
142 jobRound: number;
143 /** The radio call the job was cut under, or null for the plan on its own. */
144 jobCall: RadioCall | null;
145 /** The clock the job's wait is up at. Set while the round is still frozen. */
146 pushAt: number;
90147 reset: boolean;
91148 random: () => number;
92149 // What the policy needs to run, one set per bot so nothing is shared.
@@ -103,6 +160,15 @@ export type BotBrain = {
103160 scoping: boolean;
104161 /** False runs the pre-policy fight: the arena's opponent and its baseline. */
105162 useLearned: boolean;
163 /**
164 * Where this bot is going to reload out of sight: the path from `coverFrom`,
165 * null when there was nowhere to go, cut once per reload — `coverFor` is the
166 * `reloadUntil` it was cut for — and `coverLook` is where the threat was.
167 */
168 cover: Vec3[] | null;
169 coverStep: number;
170 coverFor: number;
171 coverLook: Vec3 | null;
106172 };
107173
108174 export function makeBrain(skill: number, seed: number, useLearned = true): BotBrain {
@@ -111,13 +177,20 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
111177 reaction: 0.5 - skill * 0.33,
112178 aimSpeed: 180 + skill * 620,
113179 aimError: 4.5 - skill * 3.6,
180 grip: grip(skill),
181 gripRate: gripRate(skill),
182 pull: [0, 0],
114183 goal: null, path: [], step: 0, repathAt: 0,
115184 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
116 strafe: 0, strafeUntil: 0, suspect: null, heardAt: 0, plannedFor: "",
117 site: "A", slot: 0, holding: false, ordered: false, reset: true,
185 strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "",
186 job: DEFAULT_JOB, jobPlan: null, jobRound: -1, jobCall: null, pushAt: 0,
187 reset: true,
118188 random: rng(seed),
119189 obs: new Float32Array(OBS_SIZE),
120 activations: newActivations(PACKED.shape),
190 // Sized from the shape the game asks for rather than from a policy: a
191 // brain is made before any weights have arrived, and the two agree because
192 // `parsePacked` refuses a document of any other shape.
193 activations: newActivations(SHAPE),
121194 actions: new Int32Array(HEADS.length),
122195 memory: { sinceSeen: 9 },
123196 intent: { ...STILL },
@@ -127,12 +200,95 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
127200 jitterUntil: 0,
128201 scoping: false,
129202 useLearned,
203 cover: null,
204 coverStep: 0,
205 coverFor: -1,
206 coverLook: null,
130207 };
131208 }
132209
210 // --- blinded --------------------------------------------------------------
211
212 /**
213 * How badly this bot is flashed, 0 to 1: the seconds of blindness it has left
214 * over the ceiling a flash can hand out. A bot does not see a white screen —
215 * there is no screen — so what a flash costs it is written here, in the same
216 * skill layer its reaction and its shaky hands already live in.
217 *
218 * Not flashed, this is exactly 0 and every number below is multiplied by
219 * exactly 1, which is why an unflashed bot plays the same game to the bit and
220 * the training arena's digest does not move for any of this.
221 */
222 export const blindness = (bot: Player): number => clamp(bot.blind / FLASH.flash.maxBlind, 0, 1);
223
224 /**
225 * What a flash leaves a bot with. Fully blind it turns at a fifth of its usual
226 * speed, its hands wander seven times as far off centre, it takes four times as
227 * long to decide to shoot at all, it holds none of its spray pattern, and it
228 * only notices an enemy inside a twelfth of its usual sight — which is roughly
229 * what a person flashed in the face does, and is the whole of the disadvantage.
230 * Its policy is untouched: `OBS_SIZE`, the heads and the shipped weights know
231 * nothing about this.
232 */
233 export function flashed(
234 bot: Player,
235 brain: BotBrain,
236 ): { aimSpeed: number; aimError: number; reaction: number; sight: number; grip: number } {
237 const b = blindness(bot);
238 return {
239 aimSpeed: brain.aimSpeed * (1 - 0.8 * b),
240 aimError: brain.aimError * (1 + 6 * b),
241 reaction: brain.reaction * (1 + 3 * b),
242 sight: SIGHT * (1 - 0.92 * b),
243 grip: brain.grip * (1 - b),
244 };
245 }
246
247 /**
248 * One tick of the bot riding its own recoil: the pull eases towards what would
249 * cancel the shot it is about to take, and the *change* in it is what moves the
250 * view — exactly the way the wobble below is applied, and for the same reason.
251 * The view carries the pull, nothing else does, so the bullets that leave the
252 * barrel are the ones a person pulling down would have sent.
253 *
254 * `bot.shotIndex` is where the next round lands in the pattern, and the world
255 * puts it back to zero a third of a second after the trigger comes up — so a
256 * bot that stops spraying eases back up to where it was aiming, with the whole
257 * of the pull given back. Nothing here is a net offset that outlives a burst.
258 *
259 * The policy sees the pulled view, because the arena it learned in pulls the
260 * same way (`src/rl/arena.ts`) and its own grip is one of the 33 numbers it is
261 * handed. A policy told nothing about its hands would aim for a recoil nobody
262 * was about to make.
263 */
264 export function ride(bot: Player, brain: BotBrain, grip: number, dt: number): void {
265 const want = sprayPull(bot.weapon, bot.shotIndex, grip);
266 const next = ease(brain.pull, want, brain.gripRate, dt);
267 bot.yaw += next[0] - brain.pull[0];
268 bot.pitch = clamp(bot.pitch + next[1] - brain.pull[1], -89, 89);
269 brain.pull = next;
270 }
271
272 /** How far a bot notices an enemy at all, in units, with its eyes working. */
273 const SIGHT = 4000;
274
275 /**
276 * A bot rich enough that a save costs it nothing buys anyway, which is what a
277 * person does with a round's worth of money already in the bank.
278 */
279 const FORCE = 5000;
280
133281 /** What a bot spends its money on, roughly in the order a person would. */
134282 export function botBuy(world: World, bot: Player): void {
135283 const brain = brainOf(world, bot);
284 const job = jobOf(world, bot, brain);
285 // The economy half of a plan: a round the side is saving is a round nothing
286 // leaves the bank, so the next one is a full buy for five rather than a rifle
287 // for two. Armour is still worth it to whoever can spare the change.
288 if (job.save && bot.money < FORCE) {
289 if (bot.armor < 100 && bot.money >= 2000) buy(world, bot, "armor");
290 return;
291 }
136292 const rich = bot.money >= 4000;
137293 const wants: WeaponId[] = [];
138294 if (rich && brain.random() < 0.15 && bot.money >= WEAPONS.awp.price + 1000) wants.push("awp");
@@ -144,120 +300,41 @@ export function botBuy(world: World, bot: Player): void {
144300 if (bot.team === "ct" && !bot.defuseKit && bot.money >= 400 && brain.random() < 0.6) buy(world, bot, "kit");
145301 }
146302
147 function brainOf(world: World, bot: Player): BotBrain {
148 if (bot.brain === undefined) {
149 bot.brain = makeBrain(skillFor(bot.id, world.difficulty), 0x1234 + bot.id * 2654435761);
303 // --- what is left to shoot with -------------------------------------------
304
305 /**
306 * Is this weapon out of bullets — none in the magazine and none behind it?
307 * A weapon with no magazine at all is the knife, and a knife is never dry:
308 * that is what makes it the last answer below and why a bot always has one.
309 */
310 export function dry(bot: Player, id: WeaponId): boolean {
311 const weapon = WEAPONS[id];
312 if (weapon === undefined) return true;
313 if (weapon.magazine === 0) return weapon.kind !== "knife";
314 const ammo = bot.ammo[id];
315 return ammo === undefined || (ammo.mag <= 0 && ammo.reserve <= 0);
316 }
317
318 /**
319 * What this bot should have in its hands instead of what it is holding, or
320 * null to keep holding it. A gun it can still fire it keeps; an empty one it
321 * puts away for the rifle, then the pistol, then the knife.
322 *
323 * A bot cannot buy mid-round and there is nothing on the floor to pick up, so
324 * an empty gun stays empty until the round ends. Before this it kept pulling
325 * the trigger on it: `shoot` refuses with no round in the magazine and `reload`
326 * refuses with nothing to reload from, so the bot stood in the open holding
327 * fire at an enemy it could see and never hurt it — which is what a weird spot
328 * where two bots will not shoot each other looks like from the inside.
329 */
330 export function nextWeapon(bot: Player): WeaponId | null {
331 if (!dry(bot, bot.weapon)) return null;
332 for (const id of [bot.primary, bot.secondary, "knife" as WeaponId]) {
333 if (id !== null && id !== bot.weapon && !dry(bot, id)) return id;
150334 }
151 return bot.brain;
335 return null;
152336 }
153337
154 /**
155 * Re-skill a brain for a new difficulty in place: the hands change, but the
156 * path, the plan and the memory it already built up this match do not. Called
157 * once a round, from `startRound`, so a change queued mid-fight cannot hand a
158 * bot new hands between one shot and the next.
159 */
160 export function restyleBrain(brain: BotBrain, id: number, difficulty: Difficulty): void {
161 const skill = skillFor(id, difficulty);
162 brain.skill = skill;
163 brain.reaction = 0.5 - skill * 0.33;
164 brain.aimSpeed = 180 + skill * 620;
165 brain.aimError = 4.5 - skill * 3.6;
166 }
167
168 /** This bot's place in its side's list, so the plan can hand out two sites. */
169 function slotOf(world: World, bot: Player): number {
170 let slot = 0;
171 for (const p of world.players) if (p.team === bot.team && p.id < bot.id) slot += 1;
172 return slot;
173 }
174
175 /**
176 * Read this side's orders onto the brain: where to be, and whether to push
177 * onto it. `plan.ts` answers with the round's roll unless somebody on this
178 * side has called something else — which is all this file knows about it. A
179 * site that changed under a bot already walking is a new objective, and
180 * `repath` notices that on the next tick by itself.
181 */
182 function readOrders(world: World, bot: Player, brain: BotBrain): void {
183 const orders = ordersFor(world.plan, world.radio, bot.team, brain.slot, world.time);
184 brain.site = orders.site;
185 brain.holding = orders.hold;
186 brain.ordered = orders.called;
187 }
188
189 /** What is left of the walk this bot has already planned, in map units. */
190 function pathLeft(brain: BotBrain, from: Vec3): number {
191 let total = 0;
192 let prev = from;
193 for (let i = brain.step; i < brain.path.length; i++) {
194 const at = brain.path[i] as Vec3;
195 total += dist2d(prev, at);
196 prev = at;
197 }
198 return total;
199 }
200
201 /** A crow flight underestimates a walk around a wall by about this much. */
202 const DETOUR = 1.3;
203
204 /**
205 * How far this bot still has to walk to where the round wants it, in seconds
206 * of clock: the longer of the crow flight around a wall and what is left of
207 * the path it has already planned.
208 */
209 function walkLeft(world: World, bot: Player, brain: BotBrain): number {
210 const crow = dist2d(bot.pos, world.map.sites[brain.site]) * DETOUR;
211 return Math.max(crow, pathLeft(brain, bot.pos));
212 }
213
214 /**
215 * Has a person told this bot to stand still? "Hold" is the one call the clock
216 * does not overrule: a teammate that walked off mid-sentence because the timer
217 * said so is a teammate you cannot use. The other two calls need no exception
218 * — a bot under time pressure walks to `brain.site`, and a call is what
219 * `brain.site` says — so somebody who calls A at 0:20 gets the push, at A.
220 */
221 const heldInPlace = (brain: BotBrain): boolean => brain.holding;
222
223 /**
224 * Time pressure on the terrorists. What is left of the round is no more than
225 * the walk to the site, the plant, and `PUSH_WINDOW` seconds of fighting — so
226 * a duel on the way is a round lost on the timer. The whole side reads it, not
227 * just the carrier: an escort still trading angles in mid is the carrier
228 * walking in alone. A bot that reads true keeps its gun — the policy still
229 * points it and still fires it — but its feet stop negotiating and take it to
230 * the site.
231 */
232 function pushing(world: World, bot: Player, brain: BotBrain): boolean {
233 if (bot.team !== "t" || world.match.phase !== "live" || heldInPlace(brain)) return false;
234 if (world.bomb.state === "planted") return false;
235 return mustPush(world.match.timer, walkLeft(world, bot, brain), world.match.rules.plantTime);
236 }
237
238 /**
239 * Time pressure on the counter-terrorists, which is the mirror of it: with the
240 * clock this low and no bomb on the floor, the terrorists are not coming to
241 * the corner this bot is sitting in — they are somewhere else, running it out.
242 * So it stops holding and goes to look, and `chooseGoal` says where.
243 */
244 function hunting(world: World, bot: Player, brain: BotBrain): boolean {
245 if (bot.team !== "ct" || world.match.phase !== "live") return false;
246 // Both calls outrank this one, and for the same reason: a hunt is a guess
247 // about which room the terrorists are in, and a person who has just said
248 // "go A" or "hold" has made that guess already, out loud.
249 if (heldInPlace(brain) || brain.ordered) return false;
250 if (world.bomb.state === "planted") return false;
251 return mustHunt(world.match.timer);
252 }
253
254 /**
255 * How wide a hunt fans out around the terrorists' own half. Enough that five
256 * counter-terrorists sweep it rather than queue down one corridor into the
257 * same angle, and not so much that they stop covering each other.
258 */
259 const HUNT_SPREAD = 700;
260
261338 /**
262339 * A magazine at this share of capacity or lower is worth topping up between
263340 * fights. Half a mag is still a whole engagement's worth of bullets — a duel
@@ -265,158 +342,169 @@ const HUNT_SPREAD = 700;
265342 * lower would top up mid-fight almost as often as waiting for empty does; any
266343 * higher and a bot interrupts a walk to reload a gun that did not need it.
267344 */
268 const TOP_UP_SHARE = 0.5;
345 export const TOP_UP_SHARE = 0.5;
269346
270347 /**
271348 * Ask for a reload while nobody is watching. Only ever called from the branch
272349 * where `pickTarget` already found no enemy in sight, so this can never be
273 * the thing that starts a reload in front of someone — the one way this fix
274 * could have made a bot easier to kill rather than harder.
350 * the thing that starts a reload in front of someone.
275351 */
276 function topUp(bot: Player, input: Input): void {
277 const w = WEAPONS[bot.weapon];
278 const ammo = bot.ammo[bot.weapon];
352 function topUp(bot: Player, holding: WeaponId, input: Input): void {
353 const w = WEAPONS[holding];
354 const ammo = bot.ammo[holding];
279355 if (ammo === undefined || w === undefined || w.magazine === 0) return;
280356 if (ammo.mag <= w.magazine * TOP_UP_SHARE && ammo.reserve > 0) input.reload = true;
281357 }
282358
283359 /**
284360 * A magazine that hit zero mid-fight: draw the sidearm instead of standing
285 * still through a rifle's reload, if there is a loaded one to draw. Reloading
286 * is still what happens once the sidearm itself is the gun that ran dry —
287 * there is nothing left in the bot's hands to switch to.
361 * through a rifle's reload, if there is a loaded one to draw. Reloading — and
362 * the walk to cover that goes with it — is still what happens once the
363 * sidearm itself is the gun that ran dry, or there is no loaded one.
288364 */
289 function outOfAmmo(bot: Player, input: Input): void {
290 const ammo = bot.ammo[bot.weapon];
365 function outOfAmmo(bot: Player, holding: WeaponId, input: Input): void {
366 const ammo = bot.ammo[holding];
291367 if (ammo === undefined || ammo.mag > 0) return;
292368 const sidearm = bot.ammo[bot.secondary];
293 if (bot.weapon !== bot.secondary && sidearm !== undefined && sidearm.mag > 0) input.switchTo = bot.secondary;
294 else input.reload = true;
295 }
296
297 /** A death this far from a site centre is a fight over that site. */
298 const CONTACT_RANGE = 1600;
299
300 /** Which site a fight at `pos` is a fight over, if it is near enough to be one. */
301 function contactSite(world: World, pos: Vec3): Site | null {
302 const a = dist2d(pos, world.map.sites.A);
303 const b = dist2d(pos, world.map.sites.B);
304 if (Math.min(a, b) > CONTACT_RANGE) return null;
305 return a <= b ? "A" : "B";
369 if (holding !== bot.secondary && sidearm !== undefined && sidearm.mag > 0) input.switchTo = bot.secondary;
370 else if (ammo.reserve > 0) input.reload = true;
306371 }
307372
308373 /**
309 * Where the round is being fought, as far as this side can tell: the site the
310 * last of them to die fell at. It is only ever this side's own information —
311 * who is dead and where they were, which is the kill feed and the radar — and
312 * it is the answer to a side that commits to one site together. The first CT
313 * down at A is what turns the B players around; without it, five together
314 * beats three and two nearly every round.
374 * How close a knife has to be, centre to centre, to land: `world.ts` traces a
375 * knife 64 units from the eye, and the body it is swung at is a hull's radius
376 * wide, so the two overlap from here in.
315377 */
316 function rotateTo(world: World, bot: Player): Site | null {
317 let site: Site | null = null;
318 let freshest = -Infinity;
319 for (const entry of world.feed) {
320 if (entry.killerTeam === bot.team || entry.at < freshest) continue;
321 for (const p of world.players) {
322 if (p.id !== entry.victimId || p.team !== bot.team) continue;
323 const where = contactSite(world, p.pos);
324 if (where === null) continue;
325 freshest = entry.at;
326 site = where;
327 }
378 const KNIFE_RANGE = 64 + CS_HULL.radius;
379
380 function brainOf(world: World, bot: Player): BotBrain {
381 if (bot.brain === undefined) {
382 const skill = clamp(0.25 + ((bot.id * 37) % 100) / 130, 0, 1);
383 bot.brain = makeBrain(skill, 0x1234 + bot.id * 2654435761);
328384 }
329 return site;
385 return bot.brain;
330386 }
331387
332388 /**
333 * `goal`, or a dropped primary if this bot has an empty primary slot and one
334 * is close enough to be worth the walk. `worthTheDetour` (plan.ts) is the
335 * whole arithmetic; this only finds the candidates and picks the cheapest.
336 * The empty floor — every tick of the training arena — costs one comparison.
389 * This bot's job, cut from the round's plan the first time anyone asks for it
390 * after that plan arrived. A job belongs to a team and not to a bot — an entry
391 * only means anything if somebody else is the support — so asking for one bot's
392 * re-tasks its whole side at once, in one `assign`, and the other four find
393 * theirs already waiting.
394 *
395 * With no plan handed in this is whatever `botThink` put there at the start of
396 * the round, which is the coin flip these bots have always made. Nothing about
397 * the arena or the trainer changes until somebody calls `usePlan`.
337398 */
338 export function gunGoal(world: World, bot: Player, goal: Vec3): Vec3 {
339 if (world.pickups.length === 0 || bot.primary !== null) return goal;
340 const toGoal = dist2d(bot.pos, goal);
341 let best: Vec3 | null = null;
342 let bestDetour = Infinity;
343 for (const pickup of world.pickups) {
344 if (pickupSlot(pickup.weapon) !== "primary") continue;
345 const toGun = dist2d(bot.pos, pickup.pos);
346 const gunToGoal = dist2d(pickup.pos, goal);
347 if (!worthTheDetour(toGun, gunToGoal, toGoal)) continue;
348 const detour = toGun + gunToGoal - toGoal;
349 if (detour < bestDetour) {
350 bestDetour = detour;
351 best = pickup.pos;
352 }
353 }
354 return best ?? goal;
355 }
356
357 /**
358 * How many dropped primaries are on the floor right now — the whole gun
359 * signature `repath` needs in its objective string: a rifle appearing or
360 * getting taken is a floor that changed, whatever else this bot is doing.
361 */
362 function primaryPickupCount(world: World): number {
363 let count = 0;
364 for (const pickup of world.pickups) if (pickupSlot(pickup.weapon) === "primary") count += 1;
365 return count;
399 function jobOf(world: World, bot: Player, brain: BotBrain): Job {
400 const plan = world.plan;
401 // A call from this side stands in front of the plan while it is live, and
402 // is null again the tick it expires — which is what re-cuts the jobs from
403 // the plan alone: the side falls back to the roll without anybody clearing it.
404 const call = liveCall(world.radio, bot.team, world.time, world.match.round);
405 if (plan === null && call === null) return brain.job;
406 // The plan and the call are the stamp: a new one is a new object, and a bot
407 // holding a job cut from another one is a bot with yesterday's orders.
408 if (brain.jobPlan === plan && brain.jobCall === call && brain.jobRound === world.match.round) return brain.job;
409 const mates = world.players.filter((p) => p.team === bot.team);
410 const brains = mates.map((mate) => brainOf(world, mate));
411 // Nobody planned this world but somebody called: the side goes on the call
412 // from the site this bot had already flipped for.
413 const team: TeamPlan = plan?.[bot.team] ?? { site: brain.job.site, shape: "stack", tempo: 1, save: false };
414 const jobs = heed(assign(steer(team, call?.call ?? null), bot.team, mates.map((mate, at) => ({
415 id: mate.id,
416 skill: brains[at]?.skill ?? 0,
417 }))), call, world.time);
418 brains.forEach((mate, at) => {
419 mate.job = jobs[at] ?? DEFAULT_JOB;
420 mate.jobPlan = plan;
421 mate.jobCall = call;
422 mate.jobRound = world.match.round;
423 // The wait runs from the moment the round goes live, and the freeze time is
424 // still running: `botThink` pushes this forward on every frozen tick.
425 mate.pushAt = world.time + mate.job.delay;
426 // A goal cut for the site this bot is no longer going to is not a goal.
427 mate.plannedFor = "";
428 });
429 return brain.job;
366430 }
367431
368432 /** Where this bot is trying to be, given what the round is doing. */
369433 function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
370434 const bomb = world.bomb;
371 // Told to hold: this is where the round is being fought from now on. The
372 // goal is a walkable spot a stride away rather than the bot's own feet —
373 // a bot with no path stands still wherever it happened to be, and standing
374 // still in the open is how a hold gets a side killed. Once the bomb is on
375 // the floor or in the ground it outranks anything anyone said.
376 if (brain.holding && bomb.state === "carried") return nearby(world, brain, bot.pos, 250);
435 const level = world.level;
436 const job = brain.job;
437 const site = level.sites[job.site];
377438 if (bot.team === "t") {
378439 if (bomb.state === "planted") {
379440 // Hold near the bomb rather than walk onto it.
380 return nearby(world, brain, bomb.pos, 300);
441 return nearby(level, brain, bomb.pos, 300);
381442 }
382 // The bomb carrier does not stop for a gun: walking in alone with a rifle
383 // costs the round more than walking in with a pistol on time.
384 if (bot.hasBomb) return world.map.sites[brain.site];
443 if (bot.hasBomb) return site;
385444 if (bomb.state === "dropped") return bomb.pos;
386 return gunGoal(world, bot, nearby(world, brain, world.map.sites[brain.site], 200));
445 // An entry walks onto the site; a lurker is happy five hundred units off
446 // it, watching the way somebody would come. That is what a role is worth.
447 return nearby(level, brain, site, job.spread * 0.8);
387448 }
388449 if (bomb.state === "planted") return bomb.pos;
389 // The contact rotation is this side reading the round for itself — and it
390 // gives way to a person who has just said where to be. Somebody dying at B
391 // is why you called A; it is not an argument against the call.
392 const contact = brain.ordered ? null : rotateTo(world, bot);
393 // Nothing has happened and the clock is nearly out: whatever the terrorists
394 // are doing they are not doing it here, or this bot would have seen them by
395 // now. So it walks at the half of the map they came from and takes what it
396 // meets on the way. A death somewhere is better information than a guess,
397 // so a contact still wins.
398 if (contact === null && hunting(world, bot, brain)) {
399 return gunGoal(world, bot, nearby(world, brain, world.map.muster.t, HUNT_SPREAD));
400 }
401 return gunGoal(world, bot, nearby(world, brain, world.map.sites[contact ?? brain.site], 250));
450 return nearby(level, brain, site, job.spread);
402451 }
403452
404 const nearby = (world: World, brain: BotBrain, at: Vec3, spread: number): Vec3 => {
453 const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 => {
405454 for (let tries = 0; tries < 8; tries++) {
406455 const x = at[0] + (brain.random() * 2 - 1) * spread;
407456 const y = at[1] + (brain.random() * 2 - 1) * spread;
408 if (walkable(world.map, colOf(world.map, x), rowOf(world.map, y))) return [x, y, at[2]];
457 if (walkable(level, colOf(level, x), rowOf(level, y))) return [x, y, at[2]];
409458 }
410459 return at;
411460 };
412461
462 /** How many cells out a goal nothing walks to may be nudged, and how many tries. */
463 const NUDGE_RINGS = 3;
464 const NUDGE_TRIES = 8;
465
466 /**
467 * A path to `goal`, or to the nearest cell something can walk to.
468 *
469 * A* aims at a cell, and not every point a round hands a bot is one: a bomb
470 * dropped on top of a jumpable crate stands on a cell the search calls solid,
471 * and so does a body that fell into a corner of a map carved from a seed. The
472 * search then comes back with nothing, and a bot with no path stands exactly
473 * where it is until it asks again three seconds later — the standing about
474 * that looks, from the other end of a corridor, like a bot that has forgotten
475 * what it was doing. So a goal nothing walks to is nudged outwards in rings
476 * until a cell answers, at most `NUDGE_TRIES` searches deep, and only a goal
477 * walled off entirely leaves the bot without a plan.
478 */
479 export function pathTo(level: Level, from: Vec3, goal: Vec3): Vec3[] {
480 const direct = findPath(level, from, goal);
481 if (direct.length > 0) return direct;
482 const col = colOf(level, goal[0]);
483 const row = rowOf(level, goal[1]);
484 let tries = 0;
485 for (let ring = 1; ring <= NUDGE_RINGS; ring++) {
486 for (let dr = -ring; dr <= ring; dr++)
487 for (let dc = -ring; dc <= ring; dc++) {
488 if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring) continue;
489 if (!walkable(level, col + dc, row + dr)) continue;
490 if (tries++ >= NUDGE_TRIES) return [];
491 const path = findPath(level, from, worldOf(level, col + dc, row + dr, goal[2]));
492 if (path.length > 0) return path;
493 }
494 }
495 return [];
496 }
497
413498 function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
414499 let best: Player | null = null;
415500 let bestScore = Infinity;
501 // Flashed, a bot picks its fights out of a much smaller room: this is the
502 // "choice" half of what a flash costs it, and the aim below is the other.
503 const sight = flashed(bot, brain).sight;
416504 for (const other of world.players) {
417505 if (!other.alive || other.team === bot.team) continue;
418506 const d = dist(bot.pos, other.pos);
419 if (d > 4000) continue;
507 if (d > sight) continue;
420508 if (!canSee(world, bot, other)) continue;
421509 const aim = aimAt(bot, bodyCentre(other));
422510 const off = Math.abs(angleDelta(bot.yaw, aim.yaw));
@@ -427,60 +515,82 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null {
427515 return best;
428516 }
429517
430 /** The player an effect's `by` names, if it names one. Ids are the slot they sit in. */
431 function makerOf(world: World, by: number | undefined): Maker {
432 if (by === undefined) return undefined;
433 const at = world.players[by];
434 return at !== undefined && at.id === by ? at : world.players.find((p) => p.id === by);
435 }
518 // --- smoke ----------------------------------------------------------------
436519
437520 /**
438 * Hearing. Every noise the world made since this bot last listened is looked
439 * at once — no list built and nothing allocated until something is actually
440 * heard — and the loudest of them, if it arrives above `AUDIBLE`, becomes the
441 * place the bot thinks someone is.
442 *
443 * It is the sounds themselves, not the players making them: a gunshot through
444 * two walls is quieter than one down the corridor, a sprint carries and a walk
445 * makes no sound at all, and where the bot ends up looking is off by more the
446 * further away it was. `hearing.ts` owns all of that arithmetic.
447 *
448 * The bomb's own beep is skipped on purpose. It repeats every half-second from
449 * a place both sides already know, so acting on it would drown out every
450 * footstep near a planted site — what a CT should do about it is #46's retake.
521 * How far outside a cloud a bot aims to walk, in units. A hull's width, so it
522 * skirts the fringe of one rather than clipping through it — and so that a
523 * diversion is a visible step around something and not a shuffle along its
524 * edge.
451525 */
452 function listen(world: World, bot: Player, brain: BotBrain): void {
453 const since = brain.heardAt;
454 brain.heardAt = world.time;
455 let loudest = 0;
456 let from: Vec3 | null = null;
457 // Backwards, and out at the first stale one: `world.effects` is only ever
458 // appended to and only ever filtered, so what is new is the tail of it. The
459 // list is two seconds deep and a firefight fills it with tracers, blood and
460 // impacts; what this bot has not heard yet is the handful on the end.
461 for (let i = world.effects.length - 1; i >= 0; i--) {
462 const effect = world.effects[i];
463 if (effect === undefined || effect.at < since) break;
464 let name: HeardName;
465 let pos: Vec3;
466 let by: number | undefined;
467 if (effect.kind === "shot") {
468 name = effect.weapon;
469 pos = effect.pos;
470 by = effect.shooter;
471 } else if (effect.kind === "sound" && effect.name !== "beep") {
472 name = effect.name;
473 pos = effect.pos;
474 by = effect.by;
475 } else continue;
476 if (!fromOther(bot, makerOf(world, by))) continue;
477 const heard = loudness(bot.pos, pos, name, world.map);
478 if (heard > loudest) {
479 loudest = heard;
480 from = pos;
526 const SMOKE_CLEARANCE = CS_HULL.radius * 2;
527
528 /**
529 * Where this bot should walk instead of straight at `step`, given the clouds
530 * standing in the round.
531 *
532 * A cloud is not a wall — a body walks through one exactly as it walks through
533 * air, and it should, because walking through smoke is half of what smoke is
534 * for — but it *is* a wall to this bot's eyes: `canSee` refuses a line through
535 * one, so a bot that strolls into a cloud arrives blind at whatever is waiting
536 * on the other side and has given away the fight it was walking to. So the
537 * scripted layer treats it as something to go around, and the policy is told
538 * nothing: the shipped weights, `OBS_SIZE` and every head are untouched by all
539 * of this.
540 *
541 * Two cases and no third. Standing in one, the shortest way out is straight
542 * away from the middle, and that is where the bot walks. Heading into one, the
543 * step is pushed sideways to whichever edge is nearer — the tangent, plus the
544 * clearance — so the path bends around the cloud and rejoins itself. A cloud
545 * behind the bot, beside the walk, or past the end of this step is left alone,
546 * which is every cloud on most ticks; a round nobody smoked never reaches here
547 * at all, because the caller checks the list is empty first.
548 *
549 * The step comes back unchanged when there is nothing to do, so the common case
550 * allocates nothing.
551 */
552 export function skirt(world: World, bot: Player, step: Vec3): Vec3 {
553 for (const cloud of world.clouds) {
554 const radius = cloudRadius(cloud, world.time);
555 if (radius <= 0) continue;
556 const keep = radius + SMOKE_CLEARANCE;
557 const cx = cloud.pos[0] - bot.pos[0];
558 const cy = cloud.pos[1] - bot.pos[1];
559 const here = Math.hypot(cx, cy);
560 if (here < keep) {
561 // Inside it, or in its fringe: out, by the shortest line there is. Dead
562 // in the middle there is no such line, so the bot keeps walking rather
563 // than picking a direction out of nothing.
564 if (here < 1e-6) continue;
565 return [bot.pos[0] - (cx / here) * keep, bot.pos[1] - (cy / here) * keep, step[2]];
481566 }
567 const dx = step[0] - bot.pos[0];
568 const dy = step[1] - bot.pos[1];
569 const len = Math.hypot(dx, dy);
570 if (len < 1e-6) continue;
571 const ux = dx / len, uy = dy / len;
572 // How far along the walk the cloud sits, and how far off to one side.
573 const along = cx * ux + cy * uy;
574 if (along <= 0 || along > len + keep) continue;
575 const off = cx * -uy + cy * ux;
576 if (Math.abs(off) >= keep) continue;
577 // Around the near edge: the perpendicular, away from the side the cloud is
578 // on, by just enough to put the walk on the tangent.
579 const side = off >= 0 ? -1 : 1;
580 const push = (keep - Math.abs(off)) * side;
581 return [step[0] + -uy * push, step[1] + ux * push, step[2]];
582 }
583 return step;
584 }
585
586 /** An enemy shooting nearby is a direction to look, even through a wall. */
587 function listen(world: World, bot: Player, brain: BotBrain): void {
588 for (const other of world.players) {
589 if (!other.alive || other.team === bot.team) continue;
590 if (world.time - other.lastShotAt > 0.25) continue;
591 if (dist(bot.pos, other.pos) > 2200) continue;
592 brain.suspect = [other.pos[0], other.pos[1], other.pos[2] + 48];
482593 }
483 if (from !== null) brain.suspect = reported(bot.pos, from, world.map, brain.random);
484594 }
485595
486596 /**
@@ -493,13 +603,15 @@ function listen(world: World, bot: Player, brain: BotBrain): void {
493603 export function scriptedFight(
494604 world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number,
495605 ): Input {
606 const hands = flashed(bot, brain);
496607 const spraying = bot.shotIndex > 0 && world.time - bot.lastShotAt < 0.3;
497 const wobble = brain.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
608 const wobble = hands.aimError * (spraying ? 1.6 : 1) * (brain.random() * 2 - 1) * 0.15;
498609 const aim = aimAt(bot, bodyCentre(target));
499 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, brain.aimSpeed, dt);
500 const ready = world.time - brain.seenAt > brain.reaction;
501 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + brain.aimError;
502 outOfAmmo(bot, input);
610 turnTowards(bot, { yaw: aim.yaw + wobble, pitch: aim.pitch + wobble * 0.4 }, hands.aimSpeed, dt);
611 const ready = world.time - brain.seenAt > hands.reaction;
612 const onTarget = Math.abs(angleDelta(bot.yaw, aim.yaw)) < 3 + hands.aimError;
613 const ammo = bot.ammo[bot.weapon];
614 if (ammo !== undefined && ammo.mag === 0) input.reload = true;
503615 if (ready && onTarget && world.time > brain.restUntil) {
504616 input.fire = true;
505617 if (brain.burstUntil < world.time) brain.burstUntil = world.time + 0.15 + brain.skill * 0.35;
@@ -524,11 +636,11 @@ export function scriptedFight(
524636 /**
525637 * Beyond this range, in map units, a bot holding a scoped weapon zooms in to
526638 * take the fight. Hip-firing the AWP on the move is a cone of 7.7° — four
527 * bodies wide at 1000 units — and the scope takes 85% of it off, so wherever
528 * a target is more than a body or two wide in that cone the scope is worth
529 * its price. The price is the feet: a scoped player walks, and a walker is a
530 * slow target. Closer than this the hip cone is already about a body wide, and
531 * a bot keeps its speed instead.
639 * bodies wide at 1000 units — and the scope takes 85% of it off (`spreadOf` in
640 * `weapons.ts`), so wherever a target is more than a body or two wide in that
641 * cone the scope is worth its price. The price is the feet: a scoped player
642 * walks, and a walker is a slow target. Closer than this the hip cone is
643 * already about a body wide, and a bot keeps its speed instead.
532644 */
533645 export const SCOPE_RANGE = 500;
534646
@@ -558,15 +670,15 @@ export const SCOPED_MOVE = 0.15;
558670 export const STEADY_SPEED = 60;
559671
560672 /**
561 * Sniper discipline, on top of whichever fight asked for the rest: zoom in on
562 * a target past `SCOPE_RANGE`, stop moving once zoomed, and wait for the feet
563 * to settle before the shot. It only ever reads and trims an `Input` — the
564 * policy's action space has no scope in it and its observation is unchanged,
565 * so the shipped weights still mean what they were trained on.
673 * Sniper discipline, on top of what the policy asked for: zoom in on a target
674 * past `SCOPE_RANGE`, stop moving once zoomed, and wait for the feet to settle
675 * before the shot. It only ever reads and trims an `Input` — the policy's
676 * action space has no scope in it and its observation is unchanged, so the
677 * shipped weights still mean what they were trained on. `holding` is the gun
678 * the input is about, which is the one a swap this tick is handing over.
566679 */
567 export function snipe(world: World, bot: Player, brain: BotBrain, target: Player, input: Input): Input {
568 const weapon = WEAPONS[bot.weapon];
569 if (weapon?.zoom === undefined || brain.skill < SCOPE_SKILL) {
680 export function snipe(bot: Player, brain: BotBrain, holding: WeaponId, target: Player, input: Input): Input {
681 if (WEAPONS[holding]?.zoom === undefined || brain.skill < SCOPE_SKILL) {
570682 brain.scoping = false;
571683 return input;
572684 }
@@ -580,6 +692,24 @@ export function snipe(world: World, bot: Player, brain: BotBrain, target: Player
580692 return input;
581693 }
582694
695 /**
696 * A bot with nothing left to fire, which on this map means a bot down to its
697 * knife. The policy is not asked: it learned a rifle duel and is content to
698 * hold a distance a knife cannot cross, and the two things a knife needs — be
699 * pointed at them, be next to them — are not a reflex worth a network. So the
700 * bot turns onto the enemy with its own hands, at its own skill's turn rate,
701 * walks straight in and swings once the swing can land.
702 */
703 export function knifeRush(
704 bot: Player, brain: BotBrain, target: Player, input: Input, dt: number,
705 ): Input {
706 const hands = flashed(bot, brain);
707 turnTowards(bot, aimAt(bot, bodyCentre(target)), hands.aimSpeed, dt);
708 input.forward = 1;
709 input.fire = dist(bot.pos, target.pos) < KNIFE_RANGE;
710 return input;
711 }
712
583713 /**
584714 * Ask the policy what to do about `target`. It decides once every SKIP ticks
585715 * and the answer is held in between, which is how it was trained and what a
@@ -608,11 +738,12 @@ export function think(world: World, bot: Player, brain: BotBrain, target: Player
608738 * is a bot you can beat; the same policy with steady hands is not.
609739 */
610740 export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void {
741 const hands = flashed(bot, brain);
611742 if (world.time > brain.jitterUntil) {
612 brain.jitter = (brain.random() * 2 - 1) * brain.aimError;
743 brain.jitter = (brain.random() * 2 - 1) * hands.aimError;
613744 brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35;
614745 }
615 const limit = brain.aimSpeed;
746 const limit = hands.aimSpeed;
616747 bot.yaw += clamp(intent.yawRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied);
617748 bot.pitch = clamp(
618749 bot.pitch + clamp(intent.pitchRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied) * 0.4,
@@ -630,97 +761,59 @@ function turnTowards(bot: Player, target: { yaw: number; pitch: number }, speed:
630761 bot.pitch = clamp(bot.pitch + clamp(dp, -maxTurn, maxTurn), -89, 89);
631762 }
632763
633 /**
634 * Ask for a path when there is none, when it runs out, when it goes stale, or
635 * when the objective itself changed under the bot's feet. A* is only ever run
636 * here, which is a few times a round and never per tick.
637 */
638 function repath(world: World, bot: Player, brain: BotBrain): void {
639 // `ordered` is part of the objective and not just a tie-break inside
640 // `chooseGoal`: a counter-terrorist that has rotated off its own site is
641 // walking to a goal its site no longer names, so a call that happens to
642 // agree with the roll still has to be a new objective to turn it round.
643 // The hunt is part of it too: a counter-terrorist that has just run out of
644 // clock is walking to a goal its site does not name, and nothing else in
645 // this string would have changed to send it there. The gun count only
646 // matters to a bot with an empty primary slot — one that already has a
647 // rifle never diverts, so the floor changing under it is not its news.
648 const objective =
649 `${world.bomb.state}:${brain.site}${brain.holding ? ":hold" : ""}` +
650 `${brain.ordered ? ":called" : ""}${hunting(world, bot, brain) ? ":hunt" : ""}` +
651 `${bot.primary === null ? `:guns${primaryPickupCount(world)}` : ""}`;
652 const stale =
653 brain.path.length === 0 || brain.step >= brain.path.length ||
654 world.time > brain.repathAt || objective !== brain.plannedFor;
655 if (!stale) return;
656 brain.goal = chooseGoal(world, bot, brain);
657 brain.path = findPath(world.map, bot.pos, brain.goal);
658 brain.step = 0;
659 brain.plannedFor = objective;
660 brain.repathAt = world.time + 3 + brain.random() * 3;
661 }
662
663 /**
664 * Point the feet at the next waypoint and return how far off the view is from
665 * it, in degrees, or null when the path has run out. The movement is in the
666 * bot's own frame, so this works whether the view is following the path or
667 * looking down the barrel of something else.
668 */
669 function follow(bot: Player, brain: BotBrain, input: Input): number | null {
670 const next = brain.path[brain.step];
671 if (next === undefined) {
672 brain.goal = null;
673 return null;
674 }
675 if (dist2d(bot.pos, next) < 48) brain.step += 1;
676 const off = angleDelta(bot.yaw, aimAt(bot, [next[0], next[1], bot.pos[2] + 48]).yaw);
677 input.forward = Math.cos((off * Math.PI) / 180);
678 input.side = -Math.sin((off * Math.PI) / 180);
679 return off;
680 }
681
682764 /** One bot's decision for this tick, expressed as the same input a person gives. */
683765 export function botThink(world: World, bot: Player, dt: number): Input {
684766 const brain = brainOf(world, bot);
685767 const input: Input = { ...NO_INPUT };
686768 if (brain.reset) {
687769 brain.reset = false;
688 // The round's plan, not a coin of this bot's own: the side arrives together.
689 brain.slot = slotOf(world, bot);
690 readOrders(world, bot, brain);
770 // The job a bot has when no round was planned for it: its own coin flip for
771 // a site, no wait and the spread these bots have always used. A plan, if one
772 // arrived, replaces this on the next line and for the rest of the round.
773 brain.job = { ...DEFAULT_JOB, site: brain.random() < 0.5 ? "A" : "B" };
774 brain.jobPlan = null;
775 brain.jobCall = null;
776 brain.pushAt = 0;
691777 brain.goal = null;
692778 brain.path = [];
693779 brain.targetId = null;
694780 brain.suspect = null;
695 brain.heardAt = world.time;
696781 brain.plannedFor = "";
697782 brain.memory.sinceSeen = 9;
698783 brain.hold = 0;
699784 brain.intent = { ...STILL };
785 // A fresh round hands the body a fresh view; the pull is not owed back.
786 brain.pull = [0, 0];
787 brain.cover = null;
788 brain.coverFor = -1;
789 brain.coverLook = null;
700790 brain.scoping = false;
701791 input.switchTo = bot.primary ?? bot.secondary;
702792 }
793 const job = jobOf(world, bot, brain);
703794 if (world.match.phase === "freeze") {
704 // Nothing that happens behind the barrier is worth turning round for.
705 brain.heardAt = world.time;
795 // Nobody moves in the freeze time, so a job's wait cannot start running
796 // inside it: it is pushed along in front of the clock and starts the moment
797 // the round goes live, which is the beat an entry is ahead by.
798 brain.pushAt = world.time + job.delay;
706799 return input;
707800 }
708 // Orders can change under a bot mid-round — but only ever because somebody
709 // said something, so a world with a silent radio does not pay for the ones
710 // that do not: the round's roll is already on the brain from `reset`.
711 if (world.radio !== null) readOrders(world, bot, brain);
712801
802 // An empty gun is not a weapon: swap to whatever can still fire before
803 // deciding anything else, so the decision below is made about the gun the
804 // bot will actually be holding when the input is applied.
805 const swap = nextWeapon(bot);
806 if (swap !== null) input.switchTo = swap;
807
808 // The hands ride the pattern whatever the head is doing, so a bot that stops
809 // fighting — or stops firing — gives the pull back rather than walking off
810 // aiming at the floor. Not on the scripted brain: that one is the baseline
811 // the policy is measured against, and a baseline that moves measures nothing.
812 if (brain.useLearned) ride(bot, brain, flashed(bot, brain).grip, dt);
713813 listen(world, bot, brain);
714814 const target = pickTarget(world, bot, brain);
715815 if (target !== null) {
716816 if (brain.targetId !== target.id) {
717 // Contact, and only contact: a bot that had nobody in its sights now
718 // has somebody, and `pickTarget` has already run the trace that proves
719 // it can see them. Swapping from one enemy to another is the same fight
720 // and says nothing; so is the next tick of the same sighting, which is
721 // why a standoff down one corridor is one line and not ninety seconds
722 // of them.
723 if (brain.targetId === null) callout(world, bot, target.pos);
724817 brain.targetId = target.id;
725818 brain.seenAt = world.time;
726819 }
@@ -728,58 +821,144 @@ export function botThink(world: World, bot: Player, dt: number): Input {
728821 brain.targetId = null;
729822 }
730823
824 // --- reloading, which is not a fight. A bot with nothing in the magazine
825 // gets out of sight until there is something in it, then the policy has it
826 // back. Not the scripted brain: it is the baseline, and it does not move.
827 if (brain.useLearned && world.time < bot.reloadUntil && takeCover(world, bot, brain, target, input, dt)) {
828 return withBomb(world, bot, input);
829 }
830
731831 // --- fighting, which is the policy's job
732832 if (target !== null) {
733 if (!brain.useLearned) {
734 return withBomb(world, bot, snipe(world, bot, brain, target, scriptedFight(world, bot, brain, target, input, dt)));
735 }
833 // The gun this input is about is the one the swap above is handing it, not
834 // the empty one still in its hands for the rest of this tick.
835 const holding = swap ?? bot.weapon;
836 if (holding === "knife") return withBomb(world, bot, knifeRush(bot, brain, target, input, dt));
837 if (!brain.useLearned) return withBomb(world, bot, scriptedFight(world, bot, brain, target, input, dt));
736838 const intent = think(world, bot, brain, target, dt);
737839 applyIntent(world, bot, brain, intent, dt);
738 outOfAmmo(bot, input);
840 // Reloading is worth asking for only when there is something to reload
841 // from; an empty reserve is what the swap just answered.
842 outOfAmmo(bot, holding, input);
739843 // Two things the policy does not get to decide. A bot that has only just
740844 // seen you does not shoot yet — that beat is what skill buys. And the
741845 // policy trained with a rifle, so it holds the trigger down; on a pistol
742846 // that is one shot and then nothing, so the trigger is released between
743847 // decisions and each decision is worth one pull.
744 const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1;
745 input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction;
746 if (pushing(world, bot, brain)) {
747 // Out of clock: the policy keeps the gun, the plan takes the feet. It
748 // runs to the site shooting at whatever it is looking at rather than
749 // trading ground for a duel it cannot afford to win slowly.
750 repath(world, bot, brain);
751 follow(bot, brain, input);
752 input.walk = false;
753 input.duck = false;
754 brain.scoping = false;
755 } else {
756 input.forward = intent.forward;
757 input.side = intent.side;
758 input.walk = intent.walk;
759 input.duck = intent.duck;
760 snipe(world, bot, brain, target, input);
761 }
848 const held = WEAPONS[holding]?.automatic !== false || brain.hold === SKIP - 1;
849 input.fire = intent.fire && held && world.time - brain.seenAt > flashed(bot, brain).reaction;
850 input.forward = intent.forward;
851 input.side = intent.side;
852 input.walk = intent.walk;
853 input.duck = intent.duck;
854 // The scope is not the policy's either: it trained without one. Not the
855 // scripted brain — it is the baseline, and it does not move.
856 snipe(bot, brain, holding, target, input);
762857 return withBomb(world, bot, input);
763858 }
764859 brain.scoping = false;
765860
766 // --- moving
767 repath(world, bot, brain);
768 topUp(bot, input);
769 const off = follow(bot, brain, input);
770 if (off !== null) {
771 const look = brain.suspect !== null ? aimAt(bot, brain.suspect) : { yaw: bot.yaw + off, pitch: 0 };
861 // --- topping up, with nobody in sight. Not the scripted brain: it is the
862 // baseline, and it does not move.
863 if (brain.useLearned && swap === null) topUp(bot, bot.weapon, input);
864
865 // --- waiting. A support does not walk out with the entry; it gives it its
866 // beat and follows. The bomb never waits — a round where the bomb stands at
867 // spawn for two seconds is two seconds off the clock — and neither does a bot
868 // with something in front of it, because the fight above returned already.
869 if (world.time < brain.pushAt && !bot.hasBomb && world.bomb.state === "carried") {
870 if (brain.suspect !== null) {
871 const look = aimAt(bot, brain.suspect);
872 turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt);
873 }
874 return withBomb(world, bot, input);
875 }
876
877 // --- moving. A path is asked for when there is none, when it runs out, when
878 // it goes stale, or when the objective itself changed under the bot's feet.
879 const objective = `${world.bomb.state}:${job.site}`;
880 const stale =
881 brain.path.length === 0 || brain.step >= brain.path.length ||
882 world.time > brain.repathAt || objective !== brain.plannedFor;
883 if (stale) {
884 brain.goal = chooseGoal(world, bot, brain);
885 brain.path = pathTo(world.level, bot.pos, brain.goal);
886 brain.step = 0;
887 brain.plannedFor = objective;
888 brain.repathAt = world.time + 3 + brain.random() * 3;
889 }
890 const next = brain.path[brain.step];
891 if (next !== undefined) {
892 if (dist([bot.pos[0], bot.pos[1], 0], [next[0], next[1], 0]) < 48) brain.step += 1;
893 // The path is A*'s and it knows nothing about smoke, which is right: the
894 // cells are still walkable and a cloud is gone in fifteen seconds. What
895 // bends is the *step*, here and only while there is a cloud to bend it.
896 const walk = world.clouds.length === 0 ? next : skirt(world, bot, next);
897 const aim = aimAt(bot, [walk[0], walk[1], bot.pos[2] + 48]);
898 const look = brain.suspect !== null ? aimAt(bot, brain.suspect) : { yaw: aim.yaw, pitch: 0 };
772899 turnTowards(bot, { yaw: look.yaw, pitch: clamp(look.pitch, -20, 20) }, brain.aimSpeed * 0.5, dt);
900 const off = angleDelta(bot.yaw, aim.yaw);
901 input.forward = Math.cos((off * Math.PI) / 180);
902 input.side = -Math.sin((off * Math.PI) / 180);
773903 input.walk = brain.suspect !== null && dist(bot.pos, brain.suspect) < 900;
904 } else {
905 brain.goal = null;
774906 }
775907 if (brain.suspect !== null && brain.random() < 0.004) brain.suspect = null;
776908 return withBomb(world, bot, input);
777909 }
778910
911 /**
912 * One tick of reloading behind something: walk the path `coverFrom` cut, at a
913 * run, with the view on where the threat was, and stand still once there. The
914 * path is cut the first tick of a reload that has an enemy in view — a reload
915 * started out of sight is already where it should be — and never recut: a bot
916 * that changes its mind about cover twice in two seconds reaches none of it.
917 *
918 * False leaves the tick to the fight below, which is what happens when there
919 * is no threat or nowhere within reach to hide from it: the policy strafes, as
920 * it always did, and that is better than walking into the open.
921 */
922 export function takeCover(
923 world: World, bot: Player, brain: BotBrain, target: Player | null, input: Input, dt: number,
924 ): boolean {
925 if (brain.coverFor !== bot.reloadUntil) {
926 brain.coverFor = bot.reloadUntil;
927 brain.cover = null;
928 brain.coverStep = 0;
929 brain.coverLook = null;
930 if (target !== null) {
931 brain.cover = coverFrom(world, bot, target);
932 brain.coverLook = bodyCentre(target);
933 }
934 }
935 if (brain.cover === null) return false;
936 if (target !== null) brain.coverLook = bodyCentre(target);
937 brain.memory.sinceSeen = target !== null ? 0 : brain.memory.sinceSeen + dt;
938 // Whatever the policy decided before the reload, it decides afresh after.
939 brain.hold = 0;
940 if (brain.coverLook !== null) {
941 const look = aimAt(bot, brain.coverLook);
942 turnTowards(bot, look, brain.aimSpeed * 0.5, dt);
943 }
944 let next = brain.cover[brain.coverStep];
945 if (next !== undefined && dist([bot.pos[0], bot.pos[1], 0], [next[0], next[1], 0]) < 24) {
946 brain.coverStep += 1;
947 next = brain.cover[brain.coverStep];
948 }
949 if (next !== undefined) {
950 const aim = aimAt(bot, [next[0], next[1], bot.pos[2] + 48]);
951 const off = angleDelta(bot.yaw, aim.yaw);
952 input.forward = Math.cos((off * Math.PI) / 180);
953 input.side = -Math.sin((off * Math.PI) / 180);
954 }
955 return true;
956 }
957
779958 /** Planting and defusing: press use when standing where it matters. */
780959 function withBomb(world: World, bot: Player, input: Input): Input {
781960 const bomb = world.bomb;
782 if (bot.team === "t" && bot.hasBomb && siteAt(world.map, bot.pos) !== null && world.match.phase === "live") {
961 if (bot.team === "t" && bot.hasBomb && siteAt(world.level, bot.pos) !== null && world.match.phase === "live") {
783962 input.use = true;
784963 input.forward = 0;
785964 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 +246 −45
@@ -1,60 +1,261 @@
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 { controlAt, 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("Point and click");
232 expect(HINT).toContain("press the number printed on it");
233 expect(HINT).toContain("X");
234 expect(HINT).toContain("B or Escape closes");
235 // The sentence a rebuy says is built out of these, so they are the shelf's
236 // names and not a second table of them.
237 expect(labelOf("ak47", "t")).toBe("AK-47");
238 expect(labelOf("helmet", "ct")).toBe("Kevlar + Helmet");
239 expect(labelOf("kit", "ct")).toBe("Defuse kit");
240 });
241
242 test("every control drawn comes back with its press, and a press at a point is only a live one's", () => {
243 const it = shop();
244 const controls = drawBuy(it.counter, freeze, buyer({ money: 800 }), ["ak47"]);
245 // Every tile and then the rebuy control, in the order they were drawn.
246 expect(controls.map((control) => control.tile)).toEqual([...it.tiles(), it.again()]);
247
248 // What the panel's pointer lands on is pressed exactly as a click on it is.
249 const pistol = it.tiles()[0];
250 controlAt(controls, pistol)?.act();
251 expect(it.pressed).toEqual([offers("ct")[0]?.item as BuyItem]);
252 controlAt(controls, it.again())?.act();
253 expect(it.rebuys()).toBe(1);
254
255 // A dead tile is as dead to the pointer as to a click, and a point over
256 // anything that is not a control — or over nothing — presses nothing.
257 expect(it.tile("AWP").disabled).toBe(true);
258 expect(controlAt(controls, it.tile("AWP"))).toBeNull();
259 expect(controlAt(controls, it.panel)).toBeNull();
260 expect(controlAt(controls, null)).toBeNull();
60261 });
src/client/buy.ts +160 −63
@@ -1,73 +1,170 @@
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. The mouse still buys too: the panel draws its own
16 * pointer inside the lock (`cursor.ts`), and `drawBuy` hands back every control
17 * with what pressing it means so the trigger over a tile is that tile's click.
18 *
19 * It is not a browser and it is not the frame. It takes the element to draw
20 * into, the two things a price is worked out from — the round and the player —
21 * and the calls it cannot make itself: one that makes a `<button>`, one that
22 * says which item was pressed, and one that says a rebuy was asked for. `main.ts` is the only file that knows where
23 * that element came from; nothing here reaches for a document, a host or a
24 * world. A press does not buy: it says what was wanted and hands it back, so
25 * the wallet is still spent in one place — the frame, on the world it is
26 * simulating — and a tile left over from a match that has ended cannot reach
27 * into a match that has not begun.
28 *
29 * Nothing rendered here comes from outside the program: every label is a
30 * constant of `weapons.ts` or of this file, and the only value interpolated is
31 * a number off the player. No name, no field, nothing a person typed is in the
32 * markup, which is why there is no escaping in it to read.
633 */
734
8 import { buyable, WEAPONS } from "./weapons.ts";
9 import { canBuy, grenadeFull, priceOf, type BuyItem, type Player, type World } from "./world.ts";
35 import { NUMBER_ROW } from "./binding.ts";
36 import type { Listens } from "../engine/index.ts";
37 import { canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts";
38 import type { Team } from "./match.ts";
39 import { buyable } from "./weapons.ts";
1040
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 }
41 /** One thing on the shelf: what a player reads, and what a press of it buys. */
42 export type Offer = { item: BuyItem; label: string };
5543
5644 /**
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.
45 * The element the panel is drawn into, as far as this file is concerned: the
46 * markup it is given, and the grid it just wrote into that markup.
47 * `unknown` because a browser's own `querySelector` is wider than any subset.
6148 */
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]) => {
49 export type Shelf = {
50 innerHTML: string;
51 querySelector(selectors: string): unknown;
52 appendChild(child: unknown): void;
53 };
54
55 /** One tile in the grid: a button, its label, whether it can be pressed. */
56 export type Tile = Listens & { type: string; textContent: string | null; disabled: boolean };
57
58 /**
59 * A control the panel drew and what pressing it means. `drawBuy` hands the
60 * whole list back so a press that is not a browser's click — the trigger with
61 * the panel's own pointer over a tile (`cursor.ts`) — is the same call a click
62 * makes, and a dead tile is as dead to it.
63 */
64 export type Control = { tile: Tile; act(): void };
65
66 /**
67 * What the page has to lend the panel: the element, a way to make a button, and
68 * what a press means. `press` is the whole of what this module hands back —
69 * the item wanted, once per press — and the caller decides what it costs.
70 * `again` is the same thing for the one control that is not an item: last
71 * round's loadout, which the caller buys back through the same `buy()`.
72 */
73 export type Counter = {
74 panel: Shelf;
75 /** A fresh `<button>`, unattached. `document.createElement("button")` is one. */
76 button(): Tile;
77 press(item: BuyItem): void;
78 again(): void;
79 };
80
81 /**
82 * What the panel says under the grid: the keys, then the one rule. It reads the
83 * way the menu's hint line reads, and it says the number row rather than naming
84 * eleven keys, because each tile is printed with the key that presses it.
85 */
86 export const HINT =
87 "Point and click a tile, or press the number printed on it · X buys last round's loadout again · " +
88 "B or Escape closes. You can only buy in the freeze time.";
89
90 /** The control under the grid, and the key it answers to. */
91 export const REBUY_LABEL = "X · Rebuy last round";
92
93 /** The key printed on the nth tile, or "" for a shelf longer than the row. */
94 export const keyFor = (index: number): string => NUMBER_ROW[index] ?? "";
95
96 /** What a tile says: the key that presses it, its name, and what this body pays. */
97 export const tileLabel = (index: number, label: string, price: number): string => {
98 const key = keyFor(index);
99 return `${key === "" ? "" : `${key} · `}${label} · $${price}`;
100 };
101
102 /**
103 * The shelf, in the order it is drawn: this side's guns and grenades as
104 * `weapons.ts` lists them, then the armour, then the defuse kit — which is on
105 * the shelf for a CT and not on it at all for a T, because a T cannot carry one
106 * and a tile that is never pressable is a tile that should not be there.
107 */
108 export function offers(team: Team): Offer[] {
109 const guns: Offer[] = buyable(team).map((weapon) => ({ item: weapon.id, label: weapon.name }));
110 const kit: Offer[] = team === "ct" ? [{ item: "kit", label: "Defuse kit" }] : [];
111 return [...guns, { item: "armor", label: "Kevlar" }, { item: "helmet", label: "Kevlar + Helmet" }, ...kit];
112 }
113
114 /** The name this shelf gives an item, which is the name a sentence about it uses. */
115 export const labelOf = (item: BuyItem, team: Team): string =>
116 offers(team).find((offer) => offer.item === item)?.label ?? item;
117
118 /**
119 * Draw the panel: the heading with what is left to spend, one tile per offer —
120 * numbered with the key that presses it — the rebuy control, and the hint. A
121 * tile is dead when the wallet cannot reach the price or when the round is not
122 * one a buy is allowed in at all — the panel shows the prices either way rather
123 * than emptying itself, because what the next freeze time costs is worth
124 * reading.
125 *
126 * `last` is what was bought the round before, and the whole of what this module
127 * wants to know about it is whether there is any of it: a rebuy with nothing to
128 * buy back is a dead control rather than a live one that does nothing.
129 */
130 export function drawBuy(counter: Counter, market: Market, player: Buyer, last: readonly BuyItem[] = []): Control[] {
131 const open = canBuy(market, player);
132 const controls: Control[] = [];
133 counter.panel.innerHTML =
134 `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` +
135 `<div class="row"></div><p class="hint">${HINT}</p>`;
136 const grid = counter.panel.querySelector(".grid") as Shelf;
137 // The index is the tile's place on the screen and the key that presses it,
138 // read out of one list: `main.ts` turns the key back into this same index of
139 // this same `offers`, so the number on a tile is the item it buys.
140 offers(player.team).forEach(({ item, label }, index) => {
66141 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: "" };
142 const tile = counter.button();
143 tile.type = "button";
144 tile.textContent = tileLabel(index, label, price);
145 tile.disabled = price > player.money || !open;
146 const act = (): void => counter.press(item);
147 tile.addEventListener("click", act);
148 grid.appendChild(tile);
149 controls.push({ tile, act });
71150 });
72 return { rows, money: player.money, secondsLeft: Math.max(0, world.match.timer) };
151 const again = counter.button();
152 again.type = "button";
153 again.textContent = REBUY_LABEL;
154 again.disabled = !open || last.length === 0;
155 const rebuyAct = (): void => counter.again();
156 again.addEventListener("click", rebuyAct);
157 (counter.panel.querySelector(".row") as Shelf).appendChild(again);
158 controls.push({ tile: again, act: rebuyAct });
159 return controls;
160 }
161
162 /**
163 * The control a press at some point lands on, given what is at that point — a
164 * browser's `elementFromPoint` — or null when it is not one of this panel's, or
165 * is one a click would not press either.
166 */
167 export function controlAt(controls: readonly Control[], hit: unknown): Control | null {
168 const found = controls.find((control) => control.tile === hit);
169 return found === undefined || found.tile.disabled ? null : found;
73170 }
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/chooser.test.ts added +125
@@ -0,0 +1,125 @@
1 import { expect, test } from "bun:test";
2 import {
3 badName, chosenMap, levelOf, nameMap, nextMatch, NOT_A_MAP, PLAN_WAIT, planDue, planLevel, playedLink, readId,
4 showMap, type Controls,
5 } from "./chooser.ts";
6 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
7 import { DE_FORGE } from "./map.ts";
8
9 const ORIGIN = "https://cs.example";
10 const MAP = "gen_14u5j6g";
11 const ID = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
12 const FRESH = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11";
13
14 /** The menu as a page first paints it: de_forge, no name, no id. */
15 const menu = (over: Partial<Controls> = {}): Controls => ({ map: "de_forge", name: "", id: "", ...over });
16
17 /** A source of ids that counts what it handed out, so laziness can be asserted. */
18 function ids(): { draw: () => string; count: () => number } {
19 let n = 0;
20 return { draw: () => (n++, FRESH), count: () => n };
21 }
22
23 test("the wait is 200 ms, and a plan is due once it has passed and never during a match", () => {
24 expect(PLAN_WAIT).toBe(200);
25 expect(nameMap(menu(), 1000, ids().draw).planAt).toBe(1200);
26 expect(planDue(null, 5000, false)).toBe(false);
27 expect(planDue(1200, 1199, false)).toBe(false);
28 expect(planDue(1200, 1200, false)).toBe(true);
29 expect(planDue(1200, 9999, true)).toBe(false);
30 });
31
32 test("a name wins over the select, an empty one leaves the select, and junk is de_forge", () => {
33 expect(chosenMap(menu())).toBe("de_forge");
34 expect(chosenMap(menu({ map: "generated" }))).toBe("generated");
35 // A value nobody rendered is de_forge, like an unknown anything here.
36 expect(chosenMap(menu({ map: "de_moon" }))).toBe("de_forge");
37 expect(chosenMap(menu({ map: "generated", name: ` ${MAP} ` }))).toBe(MAP);
38 expect(badName(menu({ name: MAP }))).toBe(false);
39 // `gen_0ya` is `gen_ya` written badly: a typo, not a map.
40 expect(chosenMap(menu({ map: "generated", name: "gen_0ya" }))).toBe("de_forge");
41 expect(badName(menu({ name: "gen_0ya" }))).toBe(true);
42 expect(badName(menu({ name: " " }))).toBe(false);
43 });
44
45 test("the map is named before Play, and only `generated` draws an id to name it", () => {
46 const source = ids();
47 const plain = nameMap(menu(), 0, source.draw);
48 expect([plain.name, plain.link, plain.bad]).toEqual(["de_forge", "/?map=de_forge", false]);
49 const typed = nameMap(menu({ name: MAP }), 0, source.draw);
50 expect([typed.name, typed.link]).toEqual([MAP, `/?map=${MAP}`]);
51 expect(source.count()).toBe(0);
52
53 // A new map every match: the name is the map the drawn id carves, and the
54 // link is that map by name, not `generated`.
55 const fresh = nameMap(menu({ map: "generated" }), 0, source.draw);
56 expect(fresh.choice).toBe("generated");
57 expect(fresh.name).toBe(nameOf(seedFrom(FRESH)));
58 expect(fresh.link).toBe(`/?map=${fresh.name}`);
59 expect(source.count()).toBe(1);
60
61 // An id in the field is the match, so it names the map and nothing is drawn.
62 const pasted = nameMap(menu({ map: "generated", id: ` ${ID} ` }), 0, source.draw);
63 expect(pasted.name).toBe(nameOf(seedFrom(ID)));
64 expect(source.count()).toBe(1);
65 expect(nextMatch(menu({ id: "nope" }), source.draw)).toBe(FRESH);
66
67 // Junk is de_forge, with the sentence the line under the control prints.
68 const junk = nameMap(menu({ name: "gen_0ya" }), 0, source.draw);
69 expect([junk.name, junk.bad]).toEqual(["de_forge", true]);
70 expect(NOT_A_MAP).toContain("de_forge");
71 });
72
73 test("the level is the one the name means: the same cells, whichever way it was asked", () => {
74 expect(levelOf("de_forge", 7)).toBe(DE_FORGE);
75 expect(levelOf("generated", 7).grid.cells).toEqual(generateLevel(7).grid.cells);
76 expect(levelOf(MAP, 7).grid.cells).toEqual(generateLevel(seedOf(MAP) as number).grid.cells);
77 expect(levelOf(MAP, 7).name).toBe(MAP);
78
79 // The plan is of that level too, and draws an id only for `generated`.
80 const source = ids();
81 expect(planLevel(menu(), source.draw)).toBe(DE_FORGE);
82 expect(planLevel(menu({ name: MAP }), source.draw).name).toBe(MAP);
83 expect(planLevel(menu({ name: "gen_0ya" }), source.draw)).toBe(DE_FORGE);
84 expect(source.count()).toBe(0);
85 expect(planLevel(menu({ map: "generated" }), source.draw).name).toBe(nameOf(seedFrom(FRESH)));
86 expect(source.count()).toBe(1);
87 });
88
89 test("the played map's name links to that map and nothing else of the match", () => {
90 expect(playedLink(DE_FORGE)).toBe("/?map=de_forge");
91 expect(playedLink(generateLevel(seedOf(MAP) as number))).toBe(`/?map=${MAP}`);
92 });
93
94 test("the Map control is written the way the server renders the same ?map=", () => {
95 expect(showMap("de_forge")).toEqual({ map: "de_forge", name: "" });
96 expect(showMap("generated")).toEqual({ map: "generated", name: "" });
97 expect(showMap(MAP)).toEqual({ map: "de_forge", name: MAP });
98 });
99
100 test("an id is read as it is, and a typo says so without rewriting anything", () => {
101 expect(readId(menu({ id: ` ${ID} ` }), ORIGIN)).toEqual({ controls: null, match: ID, bad: false });
102 expect(readId(menu(), ORIGIN)).toEqual({ controls: null, match: null, bad: false });
103 expect(readId(menu({ id: "nope" }), ORIGIN)).toEqual({ controls: null, match: null, bad: true });
104 });
105
106 test("a link of ours becomes the controls it names; one that is not ours is left alone", () => {
107 // Both halves: the bare id in the id field and the map in the Map control.
108 const both = readId(menu({ map: "generated", id: `${ORIGIN}/?match=${ID}&map=${MAP}` }), ORIGIN);
109 expect(both).toEqual({ controls: { map: "de_forge", name: MAP, id: ID }, match: ID, bad: false });
110 const carved = readId(menu({ name: MAP, id: `${ORIGIN}/?match=${ID}&map=generated` }), ORIGIN);
111 expect(carved.controls).toEqual({ map: "generated", name: "", id: ID });
112 expect(chosenMap(carved.controls as Controls)).toBe("generated");
113
114 // Our link with a match nobody could play keeps its map and says NOT_AN_ID.
115 expect(readId(menu({ id: `${ORIGIN}/?match=nope&map=${MAP}` }), ORIGIN))
116 .toEqual({ controls: { map: "de_forge", name: MAP, id: "" }, match: null, bad: true });
117
118 // Another origin, a script, a URL with neither parameter: not believed, not
119 // rewritten, and not a match.
120 for (const text of [
121 `https://evil.example/?match=${ID}&map=${MAP}`,
122 `javascript:alert(1)?match=${ID}&map=${MAP}`,
123 `${ORIGIN}/?utm=x`,
124 ]) expect(readId(menu({ name: "", id: text }), ORIGIN)).toEqual({ controls: null, match: null, bad: true });
125 });
src/client/chooser.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * Which map the menu is asking for, and what it is called. Three controls say
3 * it — the Map select, the Map name field and the Match id field — and this
4 * module is the one place their values are read into a map: the choice, the
5 * name the line above Play prints, the link that keeps that map, and when the
6 * plan of it is due to be drawn.
7 *
8 * Two fields, one value: a name in the Map name field is one exact map and
9 * wins over the select, because a person who typed a name meant that map; an
10 * empty field leaves the select's two words; and a name that is not one is
11 * de_forge with a sentence, like an unknown anything here. The id field is the
12 * one that can rewrite the other two: a whole link pasted into it is taken
13 * apart into the controls it names (`src/matchlink.ts`, which reads it and never
14 * follows it) before anything else is read.
15 *
16 * It is not a browser and it is not the frame. What comes in is the controls'
17 * values as strings, a clock reading and a source of ids; what goes out is
18 * plain data. `src/mapname.ts` stays the only grammar of a map's name and
19 * `preview.ts` the only drawing — this names the level, `main.ts` holds the
20 * canvas and draws it.
21 */
22
23 import { mapFrom, mapLink, nameOf, seedFrom, seedOf, type MapChoice } from "../mapname.ts";
24 import { matchFrom } from "../matchid.ts";
25 import { pastedLink } from "../matchlink.ts";
26 import { generateLevel } from "./generate.ts";
27 import type { Level } from "./level.ts";
28 import { levelNamed } from "./map.ts";
29
30 /**
31 * How still the Map controls have to be before the map they name is carved and
32 * drawn, in milliseconds. It is a debounce rather than a blur handler because
33 * the choice is three controls and a person moves between them — a blur is not
34 * the moment a choice was made. 200 ms is longer than the gap between two
35 * keystrokes of a name being typed and shorter than a person noticing, so
36 * `gen_14u5j6g` costs one map and the picture is there when they look up.
37 */
38 export const PLAN_WAIT = 200;
39
40 /**
41 * What the menu says when the field holds something that is not a match id.
42 * A typo is not an error page and not a refusal to play: the match starts, it
43 * is a fresh one, and the line under the field says which of the two happened.
44 */
45 export const NOT_AN_ID = "That is not a match id — playing a fresh match instead.";
46
47 /** And what it says when the Map name field holds something that is not one. */
48 export const NOT_A_MAP = "That is not a map name — playing de_forge instead.";
49
50 /** The three controls, as the page holds them: untrimmed, unvalidated text. */
51 export type Controls = {
52 /** The Map select: `de_forge` or `generated`, or whatever a page sent. */
53 readonly map: string;
54 /** The Map name field: a `gen_…` name, nothing, or a typo. */
55 readonly name: string;
56 /** The Match id field: an id, a pasted link, nothing, or a typo. */
57 readonly id: string;
58 };
59
60 /** What the Map control is asking for, through the same door `src/server.ts` validated `?map=` with. */
61 export function chosenMap(controls: Controls): MapChoice {
62 const typed = controls.name.trim();
63 if (typed !== "") return mapFrom(typed);
64 return controls.map === "generated" ? "generated" : "de_forge";
65 }
66
67 /** Whether the Map name field holds something that is not a map name. */
68 export function badName(controls: Controls): boolean {
69 const typed = controls.name.trim();
70 return typed !== "" && seedOf(typed) === null;
71 }
72
73 /**
74 * The Map control, set to one map: the select and the name field written the
75 * way the server renders them for the same `?map=`, so a link opened and a link
76 * pasted leave the menu looking the same.
77 */
78 export const showMap = (choice: MapChoice): Pick<Controls, "map" | "name"> => ({
79 map: choice === "generated" ? "generated" : "de_forge",
80 name: choice === "generated" || choice === "de_forge" ? "" : choice,
81 });
82
83 /**
84 * The Match id field, read. `controls` is what the page should now show, or
85 * null when nothing needs rewriting — a link of ours becomes the bare id in the
86 * id field and its map in the Map control, so what is on the screen afterwards
87 * is what will be played; anything else is left exactly as typed, for the id's
88 * own door to judge. `match` is the match to play, or null for a fresh one, and
89 * `bad` is whether the line under the field should say `NOT_AN_ID` — which a
90 * link naming a match that is not an id also earns, since the field cannot say
91 * so once the link is out of it.
92 */
93 export type IdRead = { controls: Controls | null; match: string | null; bad: boolean };
94
95 export function readId(controls: Controls, origin: string): IdRead {
96 const link = pastedLink(controls.id.trim(), origin);
97 const after = link === null ? null : { ...showMap(link.map), id: link.match ?? "" };
98 const typed = (after ?? controls).id.trim();
99 const match = matchFrom(typed);
100 return { controls: after, match, bad: (link?.badMatch ?? false) || (typed !== "" && match === null) };
101 }
102
103 /**
104 * The match the next Play will be: the id in the field, or the fresh one `draw`
105 * hands out — asked only when it is needed, so a page nobody has touched hands
106 * out no ids. It is what `generated` carves its map from.
107 */
108 export const nextMatch = (controls: Controls, draw: () => string): string =>
109 matchFrom(controls.id.trim()) ?? draw();
110
111 /**
112 * The level a choice names. `generated` is this match's own seed and nothing
113 * else; a name is the server's own builder (`levelNamed`), because `mapFrom`
114 * only ever hands out a name that has a seed.
115 */
116 export const levelOf = (choice: MapChoice, seed: number): Level =>
117 choice === "generated" ? generateLevel(seed) : levelNamed(choice);
118
119 /**
120 * The map Play is about to give you, before Play: its name, the link that is
121 * that map by name, whether the line under the Map control says `NOT_A_MAP`,
122 * and when its plan is due — `PLAN_WAIT` after `now`, because naming a map is a
123 * string and drawing one is a level, and this is asked on every keystroke. For
124 * `generated` the name is the map this match's id carves, so the id is drawn
125 * now rather than at Play and the link plays *this* layout, not another.
126 */
127 export type Named = { choice: MapChoice; name: MapChoice; link: string; bad: boolean; planAt: number };
128
129 export function nameMap(controls: Controls, now: number, draw: () => string): Named {
130 const choice = chosenMap(controls);
131 const name = choice === "generated" ? nameOf(seedFrom(nextMatch(controls, draw))) : choice;
132 return { choice, name, link: mapLink(name), bad: badName(controls), planAt: now + PLAN_WAIT };
133 }
134
135 /**
136 * The level the plan beside that name is of, chosen exactly the way Play
137 * chooses it — same controls, same seed, same `levelOf` — so the picture is the
138 * map, not a map like it. Only `generated` asks `draw` for an id.
139 */
140 export function planLevel(controls: Controls, draw: () => string): Level {
141 const choice = chosenMap(controls);
142 return levelOf(choice, choice === "generated" ? seedFrom(nextMatch(controls, draw)) : 0);
143 }
144
145 /** Whether a plan scheduled for `at` is to be drawn now: never while a match runs. */
146 export const planDue = (at: number | null, now: number, running: boolean): boolean =>
147 at !== null && !running && now >= at;
148
149 /** The played map's own name as a link: that layout and nothing else of the match. */
150 export const playedLink = (level: Level): string => mapLink(mapFrom(level.name));
src/client/combat.test.ts +96 −78
@@ -1,9 +1,12 @@
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 { HULL, hullHeight, move, type Body, type Wish } from "./physics.ts";
6 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 { hullHeight, move, type Body, type Vec3, type Wish } from "../engine/index.ts";
8 import { CS_HULL, CS_MOVE } from "./movement.ts";
9 import { BULLET_RANGE, KNIFE_RANGE, WEAPONS } from "./weapons.ts";
710
811 const actor = (over: Partial<Actor> = {}): Actor => ({
912 id: 1, team: "t", pos: [500, 0, 0], yaw: 180, duckAmount: 0,
@@ -21,25 +24,25 @@ test("a crouching head sits where a crouching head looks", () => {
2124
2225 test("a bullet at head height hits the head", () => {
2326 const target = actor();
24 const hit = traceBullet([0, 0, 66], [1000, 0, 66], [], [target], 0, WEAPONS.ak47);
27 const hit = traceBullet([0, 0, 66], [1000, 0, 66], [], [target], 0);
2528 expect(hit.kind).toBe("actor");
2629 if (hit.kind === "actor") expect(hit.group).toBe("head");
2730 });
2831
2932 test("a wall in the way stops the bullet before the body", () => {
3033 const wall: Box = { min: [200, -100, 0], max: [216, 100, 384], mat: 1 };
31 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [wall], [actor()], 0, WEAPONS.ak47);
34 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [wall], [actor()], 0);
3235 expect(hit.kind).toBe("world");
3336 if (hit.kind === "world") expect(hit.point[0]).toBeCloseTo(200, 3);
3437 });
3538
3639 test("you cannot shoot yourself", () => {
3740 const me = actor({ id: 7, pos: [10, 0, 0] });
38 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [me], 7, WEAPONS.ak47).kind).toBe("none");
41 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [me], 7).kind).toBe("none");
3942 });
4043
4144 test("a dead body does not stop bullets", () => {
42 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [actor({ alive: false })], 0, WEAPONS.ak47).kind).toBe("none");
45 expect(traceBullet([0, 0, 40], [1000, 0, 40], [], [actor({ alive: false })], 0).kind).toBe("none");
4346 });
4447
4548 test("two AK headshots are one too many", () => {
@@ -51,6 +54,21 @@ test("two AK headshots are one too many", () => {
5154 expect(target.alive).toBe(false);
5255 });
5356
57 test("a Deagle one-taps a helmeted head from the far corner of the map", () => {
58 // A bullet fired the whole length one can be fired, at a head with a helmet
59 // and a full vest: the trace says head, the hit says killed, and the body is
60 // down in one. This is the end of the wire `weapons.test.ts` measures.
61 const far = actor({ pos: [BULLET_RANGE - 500, 0, 0], armor: 100, helmet: true });
62 const hit = traceBullet([0, 0, 66], [BULLET_RANGE, 0, 66], [], [far], 0);
63 expect(hit.kind).toBe("actor");
64 if (hit.kind !== "actor") return;
65 expect(hit.group).toBe("head");
66 expect(hit.distance).toBeGreaterThan(7000);
67 const result = applyHit(far, WEAPONS.deagle, hit.group, hit.distance);
68 expect(result).toEqual({ damage: 100, killed: true, headshot: true });
69 expect(far.alive).toBe(false);
70 });
71
5472 test("damage credited never exceeds the health that was there", () => {
5573 const target = actor({ health: 12 });
5674 const hit = applyHit(target, WEAPONS.ak47, "chest", 100);
@@ -58,76 +76,76 @@ test("damage credited never exceeds the health that was there", () => {
5876 expect(hit.killed).toBe(true);
5977 });
6078
61 // --- penetration ----------------------------------------------------------
79 /**
80 * `shotCost` and `killRange` are what a page of weapon numbers is allowed to
81 * ask, so every figure below is one a person can do on paper and check against
82 * the same arithmetic the round runs on. If one of these moves, a number
83 * printed somewhere moved with it, which is the point of both.
84 */
6285
63 const crate = (x0: number, x1: number, mat: number = MAT.CRATE): Box =>
64 ({ min: [x0, -100, 0], max: [x1, 100, 384], mat });
65
66 /** One shot down the x axis at a body at 500, and what it took off them. */
67 const shootThrough = (weapon: Weapon, boxes: Box[]): number => {
68 const target = actor();
69 const hit = traceBullet([0, 0, 40], [1000, 0, 40], boxes, [target], 0, weapon);
70 if (hit.kind !== "actor") return 0;
71 return applyHit(target, weapon, hit.group, hit.distance, hit.damageScale).damage;
72 };
73
74 test("a rifle crosses a thin crate and still hurts, for less than the clear shot", () => {
75 const through = shootThrough(WEAPONS.ak47, [crate(200, 280)]);
76 const clear = shootThrough(WEAPONS.ak47, []);
77 expect(through).toBeGreaterThan(0);
78 expect(through).toBeLessThan(clear);
86 test("what one shot costs is the fight's own sum, to the point", () => {
87 // 36 × 4 (head) = 144, nothing in the way of it.
88 expect(shotCost(WEAPONS.ak47, "head", 0)).toEqual({ health: 144, armor: 0, shots: 1 });
89 // 36 through 77.5% penetration is 27.9 → 28 health, and the vest takes half
90 // of the 8.1 it stopped → 4. Four of those is 112, which is more than a body.
91 expect(shotCost(WEAPONS.ak47, "chest", 0, FULL_ARMOR, true)).toEqual({ health: 28, armor: 4, shots: 4 });
92 // A leg is never covered, so the vest changes nothing about it: 115 × 0.75.
93 expect(shotCost(WEAPONS.awp, "leg", 0, FULL_ARMOR, true).health).toBe(86);
94 // The first shot's cost is uncapped by what is left, which is how a table
95 // shows the headroom a gun has: 63 × 4 × 0.955^(8192/500) × 0.925 = 110.
96 expect(shotCost(WEAPONS.deagle, "head", BULLET_RANGE, FULL_ARMOR, true).health).toBe(110);
97 // And a grenade has no bullet at all: nothing off, and nobody falls.
98 expect(shotCost(WEAPONS.he, "chest", 0)).toEqual({ health: 0, armor: 0, shots: null });
7999 });
80100
81 test("a pistol's round stays in the crate a rifle's crosses", () => {
82 expect(shootThrough(WEAPONS.glock, [crate(200, 280)])).toBe(0);
83 });
84
85 test("no round crosses a wall, a floor, a site plate or metal, however thin", () => {
86 for (const mat of [MAT.WALL, MAT.FLOOR, MAT.SITE, MAT.METAL]) {
87 expect(shootThrough(WEAPONS.awp, [crate(200, 216, mat)])).toBe(0);
101 test("the shots to kill are the shots the fight needs", () => {
102 // Counted by firing them, not by dividing: the same loop a round runs, into
103 // the same body, so the count on a page and the count in a fight are one
104 // number. Checked here against a body shot the long way round.
105 for (const weapon of [WEAPONS.glock, WEAPONS.ak47, WEAPONS.awp, WEAPONS.knife]) {
106 for (const group of ["head", "chest", "leg"] as const) {
107 const target = actor({ armor: FULL_ARMOR, helmet: true });
108 let fired = 0;
109 while (target.alive && fired < 64) {
110 applyHit(target, weapon, group, 0);
111 fired += 1;
112 }
113 expect(shotCost(weapon, group, 0, FULL_ARMOR, true).shots).toBe(fired);
114 }
88115 }
116 // And the arithmetic of one of them, on paper: a Glock's 30 to a vested chest
117 // is 14 health and 8 armour a shot, so it takes eight of them.
118 expect(shotCost(WEAPONS.glock, "chest", 0, FULL_ARMOR, true)).toEqual({ health: 14, armor: 8, shots: 8 });
89119 });
90120
91 test("a crate thicker than the round's limit stops it", () => {
92 // 200 units of crate: the AWP's 230 goes through, the AK's 175 does not.
93 expect(shootThrough(WEAPONS.ak47, [crate(200, 400)])).toBe(0);
94 expect(shootThrough(WEAPONS.awp, [crate(200, 400)])).toBeGreaterThan(0);
121 test("how far one shot to the head still kills is searched to the unit", () => {
122 // A Glock's 30 falls at 0.75 per 500 units: 30 × 4 × 0.75^(325/500) = 99.54,
123 // which rounds to 100 and is exactly a body. One unit further it is 99.48,
124 // rounds to 99, and the head gets up.
125 expect(killRange(WEAPONS.glock, "head")).toBe(325);
126 expect(shotCost(WEAPONS.glock, "head", 325).health).toBe(100);
127 expect(shotCost(WEAPONS.glock, "head", 326).health).toBe(99);
128 // The Deagle's promise, as a number: every unit a bullet can travel.
129 expect(killRange(WEAPONS.deagle, "head", FULL_ARMOR, true)).toBe(BULLET_RANGE);
130 // The M4A4's 33 × 4 through a helmet is 92, which is not a body at any range.
131 expect(killRange(WEAPONS.m4a4, "head", FULL_ARMOR, true)).toBe(null);
132 // A knife's answer is its own reach and never a bullet's.
133 expect(killRange(WEAPONS.knife, "head")).toBe(KNIFE_RANGE);
95134 });
96135
97 test("two crates in a line stop the bullet: one surface a shot", () => {
98 expect(shootThrough(WEAPONS.ak47, [crate(200, 280), crate(320, 400)])).toBe(0);
99 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 280), crate(320, 400)], [actor()], 0, WEAPONS.ak47);
100 expect(hit.kind).toBe("world");
101 if (hit.kind === "world") expect(hit.point[0]).toBeCloseTo(320, 3);
102 });
103
104 test("a punched crate reports the face it was punched at, and the tracer ends past it", () => {
105 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 280)], [actor()], 0, WEAPONS.ak47);
106 expect(hit.kind).toBe("actor");
107 expect(hit.through?.point[0]).toBeCloseTo(200, 3);
108 expect(hit.through?.normal).toEqual([-1, 0, 0]);
109 expect(hit.point[0]).toBeGreaterThan(280);
110 });
111
112 test("a body in front of the crate is hit at full damage, nothing punched", () => {
113 const target = actor({ pos: [100, 0, 0] });
114 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 280)], [target], 0, WEAPONS.ak47);
115 expect(hit.kind).toBe("actor");
116 if (hit.kind === "actor") expect(hit.damageScale).toBe(1);
117 expect(hit.through).toBeNull();
118 });
119
120 test("a knife has no penetration: a crate is a crate", () => {
121 const hit = traceBullet([0, 0, 40], [1000, 0, 40], [crate(200, 216)], [actor()], 0, WEAPONS.knife);
122 expect(hit.kind).toBe("world");
136 test("a whole body is the body the world spawns and the shop dresses", () => {
137 const body = wholeBody(FULL_ARMOR, true);
138 expect(body.health).toBe(FULL_HEALTH);
139 expect(body.armor).toBe(FULL_ARMOR);
140 expect(body.alive).toBe(true);
123141 });
124142
125143 // --- the knife's back -----------------------------------------------------
126144
127145 /** One stab at a full-health, fully armoured target facing `yaw`, from `from`. */
128 const stab = (from: Vec3, yaw: number, weapon: Weapon = WEAPONS.knife) => {
129 const target = actor({ pos: [0, 0, 0], yaw, armor: 100, helmet: true });
130 const result = applyHit(target, weapon, "chest", 48, 1, from);
146 const stab = (from: Vec3, yaw: number, weapon = WEAPONS.knife) => {
147 const target = actor({ pos: [0, 0, 0], yaw, armor: FULL_ARMOR, helmet: true });
148 const result = applyHit(target, weapon, "chest", 48, from);
131149 return { target, result };
132150 };
133151
@@ -140,11 +158,11 @@ test("a stab in the back ends a 100/100 player in one; the same stab in the ches
140158 const front = stab([-40, 0, 64], 180); // same spot, they turned round
141159 expect(front.result.killed).toBe(false);
142160 expect(front.target.health).toBeGreaterThan(50);
143 expect(front.target.armor).toBeLessThan(100); // armour still took its share
161 expect(front.target.armor).toBeLessThan(FULL_ARMOR); // armour still took its share
144162 });
145163
146164 test("a hit with nowhere to have come from is never a back", () => {
147 const target = actor({ pos: [0, 0, 0], yaw: 0, armor: 100, helmet: true });
165 const target = actor({ pos: [0, 0, 0], yaw: 0, armor: FULL_ARMOR, helmet: true });
148166 expect(applyHit(target, WEAPONS.knife, "chest", 48).killed).toBe(false);
149167 });
150168
@@ -159,22 +177,22 @@ test("a rifle in the back is still a rifle", () => {
159177 // --- a crouch-jumper -------------------------------------------------------
160178
161179 test("a crouch-jumper's head is where a bullet finds it: the hitboxes lift with the feet", () => {
162 const floor: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: MAT.FLOOR };
163 const body: Body = { pos: [500, 0, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0, landSpeed: 0 };
180 const floor: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 };
181 const body: Body = { pos: [500, 0, 0], vel: [0, 0, 0], onGround: true, ducking: false, duckAmount: 0 };
164182 const still: Wish = { forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: 250 };
165 move(body, { ...still, jump: true }, 1 / 64, [floor]);
166 for (let i = 0; i < 20; i++) move(body, { ...still, duck: true }, 1 / 64, [floor]);
183 move(body, { ...still, jump: true }, 1 / 64, [floor], CS_MOVE, CS_HULL);
184 for (let i = 0; i < 20; i++) move(body, { ...still, duck: true }, 1 / 64, [floor], CS_MOVE, CS_HULL);
167185 expect(body.duckAmount).toBe(1);
168186 expect(body.onGround).toBe(false);
169187 const target = actor({ pos: [...body.pos] as Vec3, duckAmount: body.duckAmount });
170 const top = body.pos[2] + hullHeight(body);
188 const top = body.pos[2] + hullHeight(body, CS_HULL);
171189 const head = hitboxes(target).find((p) => p.group === "head") as { max: Vec3 };
172190 expect(head.max[2]).toBeCloseTo(top, 6);
173 // A shot a hand under the top of the hull is a headshot; one where the head
174 // of a crouched body standing on the floor would be goes under the tucked feet.
175 const high = traceBullet([0, 0, top - 3], [1000, 0, top - 3], [], [target], 0, WEAPONS.ak47);
191 // A hand under the top of the hull is a headshot; where the head of a body
192 // crouched on the floor would be goes under the tucked feet.
193 const high = traceBullet([0, 0, top - 3], [1000, 0, top - 3], [], [target], 0);
176194 expect(high.kind === "actor" && high.group).toBe("head");
177 const low = traceBullet([0, 0, HULL.duck - 3], [1000, 0, HULL.duck - 3], [], [target], 0, WEAPONS.ak47);
178 expect(body.pos[2]).toBeGreaterThan(HULL.duck);
195 const low = traceBullet([0, 0, CS_HULL.duck - 3], [1000, 0, CS_HULL.duck - 3], [], [target], 0);
196 expect(body.pos[2]).toBeGreaterThan(CS_HULL.duck);
179197 expect(low.kind).toBe("none");
180198 });
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 +123
@@ -0,0 +1,123 @@
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 // A loaded sidearm is drawn instead of reloading (`outOfAmmo`), so the
72 // reload under fire this plays out is the one with the pistol empty too.
73 s.bot.ammo[s.bot.secondary] = { mag: 0, reserve: 40 };
74 s.bot.yaw = Math.atan2(s.me.pos[1] - s.bot.pos[1], s.me.pos[0] - s.bot.pos[0]) * (180 / Math.PI);
75 let started = false;
76 for (let t = 0; t < Math.ceil((WEAPONS.ak47.reloadTime + 0.2) / dt); t++) {
77 const input = botThink(s.world, s.bot, dt);
78 applyInput(s.world, s.bot, input, dt);
79 s.world.time += dt;
80 if (s.bot.reloadUntil > 0) started = true;
81 // Judge it the last tick the magazine is still going in.
82 if (started && s.bot.reloadUntil > 0 && s.world.time + dt >= s.bot.reloadUntil) break;
83 }
84 expect(started).toBe(true);
85 return {
86 hiddenAtEnd: !canSee(s.world, s.bot, s.me),
87 at: [...s.bot.pos],
88 };
89 }
90
91 test("a bot that runs dry in view of an enemy reloads behind cover", () => {
92 const s = scene();
93 const path = standoff(s);
94 const end = path[path.length - 1] as Vec3;
95 const out = reloadUnderFire(s, true);
96 // It went where the search said, and not wherever its strafing took it.
97 expect(Math.hypot(out.at[0] - end[0], out.at[1] - end[1])).toBeLessThan(48);
98 expect(out.hiddenAtEnd).toBe(true);
99 });
100
101 test("a bot takes cover once per reload, and the policy has it back when the magazine is in", () => {
102 const s = scene();
103 standoff(s);
104 reloadUnderFire(s, true);
105 const brain = s.bot.brain!;
106 const cutFor = brain.coverFor;
107 expect(brain.cover).not.toBeNull();
108 // The reload finishes; the next tick is the fight's again, and a cover path
109 // cut for a reload that is over is never walked.
110 s.world.time = s.bot.reloadUntil + 0.01;
111 applyInput(s.world, s.bot, { ...botThink(s.world, s.bot, 1 / 64) }, 1 / 64);
112 expect(s.bot.reloadUntil).toBe(0);
113 expect(brain.coverFor).toBe(cutFor);
114 expect(s.bot.ammo.ak47?.mag).toBe(30);
115 });
116
117 test("the scripted baseline does not take cover: it is what the policy is measured against", () => {
118 const s = scene();
119 standoff(s);
120 reloadUnderFire(s, false);
121 expect(s.bot.brain!.cover).toBeNull();
122 expect(s.bot.brain!.coverFor).toBe(-1);
123 });
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/cursor.test.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * The buy panel's own pointer, as data: where it starts, how a measured turn
3 * becomes pixels, and the edge it stops at. `main.test.ts` holds the wiring —
4 * the mouse moving it, the trigger pressing what is under it.
5 */
6
7 import { expect, test } from "bun:test";
8 import { centre, DEGREES_PER_COUNT, moveCursor, toPixels } from "./cursor.ts";
9
10 const panel = { width: 400, height: 300 };
11
12 test("the pointer starts in the middle of the panel", () => {
13 expect(centre(panel)).toEqual({ x: 200, y: 150 });
14 });
15
16 test("one count of the mouse is one pixel, whatever the sensitivity", () => {
17 for (const sens of [0.5, 2.2, 7]) expect(toPixels(10 * sens * DEGREES_PER_COUNT, sens)).toBeCloseTo(10, 9);
18 expect(toPixels(-3 * DEGREES_PER_COUNT, 1)).toBeCloseTo(-3, 9);
19 // A turn that is not a number, or a sensitivity that is no scale, moves nothing.
20 expect(toPixels(Number.NaN, 2)).toBe(0);
21 expect(toPixels(1, 0)).toBe(0);
22 expect(toPixels(1, -2)).toBe(0);
23 });
24
25 test("the pointer moves, and stops at the panel's edge rather than leaving it", () => {
26 expect(moveCursor({ x: 200, y: 150 }, 50, -30, panel)).toEqual({ x: 250, y: 120 });
27 expect(moveCursor({ x: 390, y: 5 }, 100, -100, panel)).toEqual({ x: 400, y: 0 });
28 expect(moveCursor({ x: 10, y: 290 }, -100, 100, panel)).toEqual({ x: 0, y: 300 });
29 // Nothing that is not a number moves it, and a panel with no size holds it at its corner.
30 expect(moveCursor({ x: 20, y: 30 }, Number.NaN, Infinity, panel)).toEqual({ x: 20, y: 30 });
31 expect(moveCursor({ x: 20, y: 30 }, 5, 5, { width: 0, height: Number.NaN })).toEqual({ x: 0, y: 0 });
32 });
33
34 test("the pointer stops at each edge on its own, and the way back starts from the edge", () => {
35 const start = centre(panel);
36 expect(moveCursor(start, -1000, 0, panel)).toEqual({ x: 0, y: 150 });
37 expect(moveCursor(start, 1000, 0, panel)).toEqual({ x: 400, y: 150 });
38 expect(moveCursor(start, 0, -1000, panel)).toEqual({ x: 200, y: 0 });
39 expect(moveCursor(start, 0, 1000, panel)).toEqual({ x: 200, y: 300 });
40 // A hand that kept going past the edge is not owed that distance on the way back.
41 expect(moveCursor(moveCursor(start, 1000, 0, panel), -10, 0, panel)).toEqual({ x: 390, y: 150 });
42 });
src/client/cursor.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * The buy panel's own mouse pointer: a point inside the panel that the mouse
3 * moves while the game still holds the pointer lock (#355).
4 *
5 * The panel keeps the lock so a round is not two trips to the desktop (#274),
6 * and a locked pointer is one the browser does not draw and does not click
7 * with. So the game draws its own: while the panel is open the mouse's
8 * movement moves this point instead of turning the view, and the trigger
9 * presses the tile under it. It is plain data — a point, and the box it is
10 * kept inside — and `main.ts` is the only file that knows it is drawn.
11 */
12
13 /** Where the pointer is, in CSS pixels from the panel's top-left corner. */
14 export type Cursor = { x: number; y: number };
15
16 /** The box the pointer is kept inside: the panel's size, in CSS pixels. */
17 export type Area = { width: number; height: number };
18
19 /**
20 * The degrees of turn one count of the mouse is worth at a sensitivity of 1,
21 * which is `createControls`'s default `lookScale` and CS's `m_yaw`. The frame
22 * is handed the turn in degrees; this is how it gets the counts back.
23 */
24 export const DEGREES_PER_COUNT = 0.022;
25
26 /** Where the pointer starts when the panel opens: the middle of it. */
27 export const centre = (area: Area): Cursor => ({ x: area.width / 2, y: area.height / 2 });
28
29 /**
30 * A turn the controls measured, in degrees, as pixels of pointer travel: one
31 * count of the mouse is one pixel, whatever the sensitivity, the way a
32 * desktop pointer does not speed up because the game's aim did.
33 */
34 export function toPixels(degrees: number, sensitivity: number): number {
35 const scale = sensitivity * DEGREES_PER_COUNT;
36 if (!Number.isFinite(degrees) || !(scale > 0)) return 0;
37 return degrees / scale;
38 }
39
40 /** Keep a value inside [0, max], where a max that is not a size is zero. */
41 const within = (value: number, max: number): number =>
42 Math.min(Math.max(value, 0), Number.isFinite(max) && max > 0 ? max : 0);
43
44 /**
45 * The pointer moved by `dx`, `dy` pixels and kept inside the panel: a hand that
46 * keeps going at the edge leaves it at the edge, the way a desktop pointer
47 * stops at the side of the screen. A move that is not a number moves nothing.
48 */
49 export function moveCursor(cursor: Cursor, dx: number, dy: number, area: Area): Cursor {
50 const x = cursor.x + (Number.isFinite(dx) ? dx : 0);
51 const y = cursor.y + (Number.isFinite(dy) ? dy : 0);
52 return { x: within(x, area.width), y: within(y, area.height) };
53 }
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 −92
@@ -1,92 +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 });
88
89 test("a round a teammate stopped says so on the cue, so it is never played as a hit", () => {
90 const blocked: Effect = { kind: "hitmark", at: 1, headshot: false, killed: false, friendly: true };
91 expect(cuesHeard([blocked], 0, HERE, 1)).toEqual([{ kind: "hit", headshot: false, killed: false, friendly: true }]);
92 });
src/client/earshot.ts deleted −73
@@ -1,73 +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 // `friendly` — a teammate stopped the round; the hit tone is not for it.
42 | { kind: "hit"; headshot: boolean; killed: boolean; friendly?: boolean }
43 | { kind: "hurt"; from: Vec3; amount: number }
44 | { kind: "step"; pos: Vec3 }
45 | { kind: "world"; name: WorldSoundName; pos: Vec3; pitch?: number };
46
47 /**
48 * The cues a listener at `listener` picks up out of `effects`, having
49 * already heard everything at or before `since`. `localId` is whose pain
50 * counts: a `hurt` effect is only ever the local player's, whoever the
51 * camera is watching while they are dead.
52 */
53 export function cuesHeard(effects: Effect[], since: number, listener: Vec3, localId: number): Cue[] {
54 const cues: Cue[] = [];
55 for (const effect of effects) {
56 if (effect.at <= since) continue;
57 if (effect.kind === "shot") cues.push({ kind: "shot", pos: effect.pos, weapon: effect.weapon });
58 else if (effect.kind === "hitmark")
59 cues.push({ kind: "hit", headshot: effect.headshot, killed: effect.killed, friendly: effect.friendly });
60 else if (effect.kind === "hurt") {
61 if (effect.victim === localId) cues.push({ kind: "hurt", from: effect.from, amount: effect.amount });
62 } else if (effect.kind === "sound") {
63 const limit = EARSHOT[effect.name];
64 if (limit !== undefined && dist(effect.pos, listener) > limit) continue;
65 cues.push(
66 effect.name === "step"
67 ? { kind: "step", pos: effect.pos }
68 : { kind: "world", name: effect.name, pos: effect.pos, pitch: effect.pitch },
69 );
70 }
71 }
72 return cues;
73 }
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 +559
@@ -0,0 +1,559 @@
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 import { useShipped } from "./shipped.ts";
36
37 useShipped(); // rounds here have bots in them, and a bot thinks with the shipped policy
38
39 /** Did anything the world remembers look like this? The buffer is read by index. */
40 function heard(world: World, is: (e: Effect) => boolean): boolean {
41 for (let i = 0; i < world.effects.count; i++) {
42 const effect = effectAt(world.effects, i);
43 if (effect !== undefined && is(effect)) return true;
44 }
45 return false;
46 }
47
48 const FLOOR: Box = { min: [-4000, -4000, -64], max: [4000, 4000, 0], mat: 0 };
49 const EAST_WALL: Box = { min: [300, -4000, 0], max: [364, 4000, 400], mat: 0 };
50
51 /** A body at `pos`, made the way a test can read. */
52 const actor = (pos: Vec3, over: Partial<Actor> = {}): Actor => ({
53 id: 9, team: "t", pos, yaw: 0, duckAmount: 0, health: 100, armor: 0, helmet: false, alive: true,
54 ...over,
55 });
56
57 const EAST: Vec3 = [1, 0, 0];
58
59 test("the grenade is in the buy menu with CS's numbers, and carries a blast", () => {
60 expect(WEAPONS.he).toBe(HE);
61 expect(HE.price).toBe(300);
62 expect(HE.blast).toEqual({ damage: 98, radius: 350, fuse: 1.5, armorPenetration: 0.5 });
63 // Its own tuning, and not the player's: 0.4 of CS's gravity, a bounce, and
64 // no step — a grenade does not climb an eighteen-unit ledge like a person.
65 expect(NADE_MOVE.gravity).toBe(320);
66 expect(NADE_MOVE.restitution).toBe(0.45);
67 expect(NADE_MOVE.stepSize).toBe(0);
68 expect(NADE_HULL.stand).toBe(4);
69 });
70
71 test("a grenade thrown level flies where the arithmetic says", () => {
72 const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]);
73 // It leaves the hand eighteen units ahead of the eye, at 750 u/s.
74 expect(g.pos).toEqual([18, 0, 64]);
75 expect(g.vel).toEqual([THROW_SPEED, 0, 0]);
76 const gravity = NADE_MOVE.gravity;
77 for (let n = 1; n <= 16; n++) {
78 stepGrenade(g, TICK, [FLOOR]);
79 expect(g.pos[0]).toBeCloseTo(18 + THROW_SPEED * n * TICK, 6);
80 expect(g.pos[2]).toBeCloseTo(64 - gravity * TICK * TICK * ((n * (n + 1)) / 2), 6);
81 expect(g.fuse).toBeCloseTo(HE.blast.fuse - n * TICK, 10);
82 }
83 // A quarter of a second in: 187.5 units along, and 10.2 units of drop.
84 const quarter = 16;
85 expect(g.pos[0] - 18).toBeCloseTo(THROW_SPEED * quarter * TICK, 6);
86 expect(64 - g.pos[2]).toBeCloseTo(gravity * TICK * TICK * ((quarter * (quarter + 1)) / 2), 6);
87 });
88
89 test("a grenade thrown at a wall comes back with 0.45 of what it arrived with", () => {
90 const g = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR, EAST_WALL]);
91 let bounces = 0;
92 let after = 0;
93 for (let n = 0; n < 64 && bounces === 0; n++) {
94 if (stepGrenade(g, TICK, [FLOOR, EAST_WALL])) {
95 bounces += 1;
96 after = g.vel[0];
97 }
98 }
99 expect(bounces).toBe(1);
100 // 750 out, 0.45 of it back: −337.5 u/s, and it is against the wall's face,
101 // its own two-unit radius short of it.
102 expect(after).toBeCloseTo(-THROW_SPEED * NADE_MOVE.restitution, 6);
103 expect(g.pos[0]).toBeCloseTo(EAST_WALL.min[0] - NADE_HULL.radius, 6);
104 });
105
106 test("a grenade dropped on the floor bounces, then comes to rest on it", () => {
107 const g = throwGrenade(0, "ct", "he", [0, 0, 200], [0, 0, -1], [FLOOR]);
108 let bounces = 0;
109 for (let n = 0; n < 64 * 6; n++) if (stepGrenade(g, TICK, [FLOOR])) bounces += 1;
110 expect(bounces).toBeGreaterThan(0);
111 expect(g.onGround).toBe(true);
112 expect(g.pos[2]).toBe(0);
113 expect(Math.hypot(g.vel[0], g.vel[1], g.vel[2])).toBeLessThan(1);
114 });
115
116 test("the blast falls off in a straight line to nothing at the radius", () => {
117 const blast = HE.blast;
118 expect(blastDamage(blast, 0)).toBeCloseTo(98, 10);
119 expect(blastDamage(blast, blast.radius / 2)).toBeCloseTo(49, 10);
120 expect(blastDamage(blast, blast.radius)).toBe(0);
121 expect(blastDamage(blast, blast.radius + 1)).toBe(0);
122 expect(blastDamage(blast, 87.5)).toBeCloseTo(73.5, 10);
123 });
124
125 test("the blast is 98 at the centre, 49 at half the radius, and armour halves it", () => {
126 // The grenade at the feet of a body standing at the origin: the centre of
127 // the blast is 2 units up and the middle of the chest is 54, so 52 units is
128 // as close as a standing body gets — 98 · (1 − 52/350) = 83.4.
129 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
130 const at = (x: number, over: Partial<Actor> = {}): { health: number; armor: number } => {
131 const hits = blastHits(g, [actor([x, 0, 0], over)], []);
132 return { health: hits[0]?.health ?? 0, armor: hits[0]?.armor ?? 0 };
133 };
134 const centre = blastCentre(g);
135 const chest = 0.75 * 72; // the middle of the chest box, 62%..88% of a 72-unit hull
136 const near = Math.hypot(0, chest - centre[2]);
137 expect(at(0).health).toBe(Math.round(blastDamage(HE.blast, near)));
138 expect(at(0).health).toBe(83);
139
140 // Half the radius away along the floor: √(175² + 52²) = 182.6 units, which
141 // is 98 · (1 − 182.6/350) = 46.9.
142 const half = Math.hypot(175, chest - centre[2]);
143 expect(at(175).health).toBe(Math.round(blastDamage(HE.blast, half)));
144 expect(at(175).health).toBe(47);
145
146 // Past it, nothing at all — no entry, not a zero.
147 expect(blastHits(g, [actor([400, 0, 0])], [])).toEqual([]);
148
149 // Armour takes half of what it stops, like every other source of damage:
150 // 83.44 becomes 42 through the vest, and the vest wears 21 down.
151 expect(at(0, { armor: 100 })).toEqual({ health: 42, armor: 21 });
152 });
153
154 test("a grenade behind a crate hurts nobody on the far side", () => {
155 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
156 const behind = actor([200, 0, 0]);
157 // In the open, 200 units is well inside the radius and it hurts.
158 expect(blastHits(g, [behind], [FLOOR])[0]?.health).toBeGreaterThan(0);
159 // A crate between the two takes all of it: `traceWorld` decides, not distance.
160 const crate: Box = { min: [100, -80, 0], max: [140, 80, 90], mat: 0 };
161 expect(blastHits(g, [behind], [FLOOR, crate])).toEqual([]);
162 // And the man standing beside the crate, with nothing in the way, still dies.
163 expect(blastHits(g, [actor([200, 200, 0])], [FLOOR, crate])[0]?.health).toBeGreaterThan(0);
164 });
165
166 test("the blast does not reach the thrower's own side", () => {
167 const g = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
168 expect(blastHits(g, [actor([0, 0, 0], { team: "ct" })], [FLOOR])).toEqual([]);
169 expect(blastHits(g, [actor([0, 0, 0], { team: "t" })], [FLOOR]).length).toBe(1);
170 });
171
172 // --- in a round -----------------------------------------------------------
173
174 const TICKS_PER_SECOND = 64;
175
176 /** A world past the freeze, with `me` holding one grenade and nothing else to do. */
177 function ready(seed: number, teamSize = 5): World {
178 const world = createWorld(DE_FORGE, SHORT, "me", "ct", teamSize, seed);
179 world.me.money = 5000;
180 expect(buy(world, world.me, "he")).toBe(true);
181 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
182 expect(world.match.phase).toBe("live");
183 return world;
184 }
185
186 test("a grenade is bought like anything else, one at a time, and goes in a pocket", () => {
187 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 11);
188 world.me.money = 5000;
189 const before = world.me.weapon;
190 expect(buy(world, world.me, "he")).toBe(true);
191 expect(world.me.pocket.he).toBe(CARRY.he);
192 expect(world.me.money).toBe(5000 - HE.price);
193 // It did not become the gun in your hands and it did not displace one.
194 expect(world.me.weapon).toBe(before);
195 expect(world.me.primary).toBe(null);
196 // One a round, CS's rule: the second is refused and costs nothing.
197 expect(buy(world, world.me, "he")).toBe(false);
198 expect(world.me.money).toBe(5000 - HE.price);
199 });
200
201 test("the throw key throws one, once a press, and never during the freeze", () => {
202 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 12);
203 world.me.money = 5000;
204 buy(world, world.me, "he");
205 const held: Input = { ...NO_INPUT, throwing: "he" };
206 // Frozen: the key does nothing and the grenade stays in the pocket.
207 update(world, TICK, held);
208 expect(world.grenades.length).toBe(0);
209 expect(world.me.pocket.he).toBe(1);
210
211 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
212 // Live, and held down for half a second: exactly one leaves the hand.
213 for (let t = 0; t < 32; t++) update(world, TICK, held);
214 expect(world.grenades.length).toBe(1);
215 expect(world.me.pocket.he).toBe(0);
216 expect(world.grenades[0]?.owner).toBe(world.me.id);
217 // And the throw was heard where the hand was.
218 expect(heard(world, (e) => e.kind === "sound" && e.name === "throw")).toBe(true);
219 });
220
221 test("the fuse is the same number of ticks whatever the frame rate", () => {
222 /** Step `world` in frames of `perFrame` ticks; return the tick it went off on. */
223 const fuseTicks = (perFrame: number): number => {
224 const world = ready(21);
225 world.me.pitch = THROW_LIFT; // aim down by the lift: a dead level throw
226 expect(throwOne(world, world.me)).toBe(true);
227 let ticks = 0;
228 while (world.grenades.length > 0 && ticks < 64 * 5) {
229 for (let f = 0; f < perFrame; f++) {
230 update(world, TICK, NO_INPUT);
231 ticks += 1;
232 }
233 }
234 return ticks;
235 };
236 // 1.5 seconds of 64 Hz ticks is 96 of them, and a frame that batches four of
237 // them at a time — a sixteen-frame-a-second phone — detonates on the same one.
238 expect(fuseTicks(1)).toBe(Math.ceil(HE.blast.fuse * TICKS_PER_SECOND));
239 expect(fuseTicks(4)).toBe(fuseTicks(1));
240 });
241
242 test("a blast that hurts someone marks the hit, and one that kills names the grenade", () => {
243 // One a side, so the only thing that can hurt anybody here is the grenade:
244 // nobody on this player's team exists to shoot, and this player never fires.
245 const world = ready(31, 1);
246 const victim = world.players.find((p) => p.team === "t") as Player;
247 const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]];
248 victim.armor = 0;
249 const kills = world.me.kills;
250 expect(throwOne(world, world.me)).toBe(true);
251 let healthBefore = victim.health;
252 let damageBefore = world.me.damage;
253 for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
254 // Hold the victim still and the grenade at their feet: what is under test
255 // is the blast, not whether a thrown one can be aimed at a moving bot.
256 victim.pos = [where[0], where[1], where[2]];
257 for (const g of world.grenades) {
258 g.pos = [where[0], where[1], where[2]];
259 g.vel = [0, 0, 0];
260 }
261 healthBefore = victim.health;
262 damageBefore = world.me.damage;
263 update(world, TICK, NO_INPUT);
264 }
265
266 expect(victim.health).toBeLessThan(healthBefore);
267 expect(world.me.damage).toBeGreaterThan(damageBefore);
268 expect(heard(world, (e) => e.kind === "hitmark")).toBe(true);
269 // The mark says whether the blast finished them, as a bullet's does.
270 expect(heard(world, (e) => e.kind === "hitmark" && e.killed === !victim.alive)).toBe(true);
271 expect(heard(world, (e) => e.kind === "blast")).toBe(true);
272 expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(true);
273 // 83 hit points at the feet of a body with no vest is not a kill on its own,
274 // so a kill is only asserted when the blast actually finished them.
275 if (!victim.alive) {
276 expect(world.me.kills).toBe(kills + 1);
277 const entry = world.feed[world.feed.length - 1];
278 expect(entry?.weapon).toBe("he");
279 expect(causeName(entry?.weapon ?? "knife")).toBe("HE Grenade");
280 }
281 });
282
283 test("a blast that finishes a hurt man puts the grenade in the feed", () => {
284 const world = ready(33, 1);
285 const victim = world.players.find((p) => p.team === "t") as Player;
286 victim.health = 20; // one blast away, whatever the falloff says
287 const where: Vec3 = [victim.pos[0], victim.pos[1], victim.pos[2]];
288 expect(throwOne(world, world.me)).toBe(true);
289 for (let t = 0; t < 2 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
290 victim.pos = [where[0], where[1], where[2]];
291 for (const g of world.grenades) {
292 g.pos = [where[0], where[1], where[2]];
293 g.vel = [0, 0, 0];
294 }
295 update(world, TICK, NO_INPUT);
296 }
297 expect(victim.alive).toBe(false);
298 expect(world.me.kills).toBe(1);
299 const entry = world.feed[world.feed.length - 1];
300 expect(entry?.weapon).toBe("he");
301 expect(entry?.killer).toBe(world.me.name);
302 expect(WEAPONS.he.name).toBe("HE Grenade");
303 });
304
305 test("a grenade is drawn where the body is, and forgotten when it goes off", () => {
306 const world = ready(41);
307 expect(throwOne(world, world.me)).toBe(true);
308 const g = world.grenades[0];
309 if (g === undefined) throw new Error("the throw made no grenade");
310 const start: Vec3 = [g.pos[0], g.pos[1], g.pos[2]];
311 for (let t = 0; t < 16; t++) update(world, TICK, NO_INPUT);
312 expect(world.grenades.length).toBe(1);
313 expect(g.pos).not.toEqual(start);
314 for (let t = 0; t < 2 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
315 expect(world.grenades.length).toBe(0);
316 });
317
318 test("a round with a grenade in it is still bit-for-bit reproducible", () => {
319 const TICKS = 8 * TICKS_PER_SECOND;
320 // A script that buys one in the freeze and throws it the moment it is live.
321 const script = [
322 { atTick: 0, input: {}, buy: "he" as const },
323 { atTick: 5 * TICKS_PER_SECOND, input: { throwing: "he" as const } },
324 { atTick: 5 * TICKS_PER_SECOND + 8, input: {} },
325 ];
326 const run = (): ReturnType<typeof digest> =>
327 withoutEntropy(() => {
328 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
329 world.me.money = 5000;
330 return digest(world, TICKS, script);
331 });
332 const quiet = withoutEntropy(() => digest(createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7), TICKS));
333 const a = run();
334 const b = run();
335 expect(a.digest).toBe(b.digest);
336 expect(a.seconds).toEqual(b.seconds);
337 // And it is a different run from the one where nobody threw anything: the
338 // grenade is in the hash, so a thrown one cannot hide.
339 expect(a.digest).not.toBe(quiet.digest);
340 });
341
342 test("an input that never sets the throw plays exactly as it did before", () => {
343 // The whole of what a bot had to learn for this feature: nothing.
344 expect(NO_INPUT.throwing).toBe(null);
345 const world = ready(51);
346 world.me.pocket.he = 1;
347 const before = eyePos(world.me);
348 for (let t = 0; t < 64; t++) update(world, TICK, NO_INPUT);
349 expect(world.grenades.length).toBe(0);
350 expect(world.me.pocket.he).toBe(1);
351 expect(before.length).toBe(3);
352 });
353
354 // --- the flash ------------------------------------------------------------
355
356 /** A body at `pos` looking along `yaw`/`pitch`: what a flash needs to see. */
357 const looker = (pos: Vec3, over: Partial<Looker> = {}): Looker => ({
358 ...actor(pos), pitch: 0, ...over,
359 });
360
361 /** The height a standing body's eye is at: what a flash is measured to. */
362 const EYE = eyeHeight({ duckAmount: 0 }, CS_HULL);
363
364 /**
365 * A flashbang lying still with its centre at `where`, out of nobody's hand.
366 * The tests below put it at eye height so the distances are the round numbers
367 * they are written as rather than a hypotenuse nobody can check by hand.
368 */
369 const lit = (where: Vec3): ReturnType<typeof throwGrenade> =>
370 throwGrenade(0, "ct", "flash", [where[0], where[1], where[2] - NADE_HULL.stand / 2], [0, 0, 0], [FLOOR]);
371
372 test("a flashbang is a grenade like the other one: same flight, its own fuse", () => {
373 const he = throwGrenade(0, "ct", "he", [0, 0, 64], EAST, [FLOOR]);
374 const flash = throwGrenade(0, "ct", "flash", [0, 0, 64], EAST, [FLOOR]);
375 expect(flash.weapon).toBe("flash");
376 expect(flash.fuse).toBe(FLASH.flash.fuse);
377 expect(he.fuse).toBe(HE.blast.fuse);
378 // The same body through the same solver: sixteen ticks apart they are still
379 // in exactly the same place, because nothing about the flight is the kind's.
380 for (let n = 0; n < 16; n++) {
381 stepGrenade(he, TICK, [FLOOR]);
382 stepGrenade(flash, TICK, [FLOOR]);
383 }
384 expect(flash.pos).toEqual(he.pos);
385 expect(flash.vel).toEqual(he.vel);
386 // And it does no damage to anybody, however close they stand.
387 expect(blastHits(flash, [actor([0, 0, 0])], [FLOOR])).toEqual([]);
388 // While the HE blinds nobody: the two ends are the only thing that differs.
389 expect(flashHits(he, [looker([0, 0, 0])], [FLOOR])).toEqual([]);
390 });
391
392 test("a flash blinds by how much of it you could see, and not at all with no line", () => {
393 const g = lit([0, 0, EYE]);
394 const seconds = (l: Looker, boxes: Box[] = [FLOOR]): number =>
395 flashHits(g, [l], boxes)[0]?.seconds ?? 0;
396 const max = FLASH.flash.maxBlind;
397
398 // Standing 300 units east, looking west straight at it: 1.6 · 1 · (1 − 300/1200).
399 const staring = looker([300, 0, 0], { yaw: 180 });
400 expect(seconds(staring)).toBeCloseTo(max * 0.75, 6);
401
402 // Looking away works: the same body turned round sees none of it.
403 expect(seconds(looker([300, 0, 0], { yaw: 0 }))).toBe(0);
404 // And half turned away — 90° off, which is 45/105 of the way from 30 to 135.
405 expect(seconds(looker([300, 0, 0], { yaw: 90 }))).toBeCloseTo(max * (45 / 105) * 0.75, 6);
406
407 // A wall works: `traceWorld` decides whether there was a line at all, and a
408 // crate between the two takes every bit of it however squarely they looked.
409 const crate: Box = { min: [100, -80, 0], max: [140, 80, 120], mat: 0 };
410 expect(seconds(staring, [FLOOR, crate])).toBe(0);
411 // The man beside the crate, with nothing in the way, is blinded as before.
412 expect(seconds(looker([300, 300, 0], { yaw: 225 }), [FLOOR, crate])).toBeGreaterThan(0);
413
414 // Being behind it works: a flash at your back, with your view down the far
415 // way, is over your shoulder and costs nothing however close it went off.
416 expect(seconds(looker([-300, 0, 0], { yaw: 180 }))).toBe(0);
417 expect(seconds(looker([-20, 0, 0], { yaw: 180 }))).toBe(0);
418
419 // Being behind *someone* is not cover: a body is not a wall, and the trace
420 // is against the world's boxes exactly like the blast's is.
421 const shield = looker([150, 0, 0], { yaw: 180 });
422 expect(flashHits(g, [shield, staring], [FLOOR]).length).toBe(2);
423
424 // Past the radius it is a light in another room.
425 expect(seconds(looker([1300, 0, 0], { yaw: 180 }))).toBe(0);
426 // And a flicker is not a blinding: anything under MIN_BLIND is no entry.
427 expect(blindSeconds(FLASH.flash, 0, 1150)).toBeLessThan(MIN_BLIND);
428 expect(flashHits(g, [looker([1150, 0, 0], { yaw: 180 })], [FLOOR])).toEqual([]);
429 });
430
431 test("a flash does not care whose side you are on, and the dead keep their sight", () => {
432 const g = lit([0, 0, EYE]);
433 const mine = looker([200, 0, 0], { yaw: 180, team: "ct" }); // the thrower's own side
434 const theirs = looker([200, 0, 0], { yaw: 180, team: "t" });
435 const dead = looker([200, 0, 0], { yaw: 180, team: "t", alive: false });
436 expect(flashHits(g, [mine], [FLOOR]).length).toBe(1);
437 expect(flashHits(g, [theirs], [FLOOR]).length).toBe(1);
438 expect(flashHits(g, [dead], [FLOOR])).toEqual([]);
439 // The same two under the HE: it spares the thrower's team and hurts the other.
440 const he = throwGrenade(0, "ct", "he", [0, 0, 0], [0, 0, 0], [FLOOR]);
441 expect(blastHits(he, [mine], [FLOOR])).toEqual([]);
442 expect(blastHits(he, [theirs], [FLOOR]).length).toBe(1);
443 });
444
445 test("the angle a flash is judged at is the whole view, not the yaw", () => {
446 const overhead: Vec3 = [0, 0, EYE + 200];
447 const g = lit(overhead);
448 const below = looker([0, 0, 0]);
449 // It went off straight overhead: looking up is looking at it, and looking
450 // level is ninety degrees off it whichever way the body is turned.
451 expect(viewAngle({ ...below, pitch: -90 }, overhead)).toBeCloseTo(0, 6);
452 expect(viewAngle({ ...below, pitch: 0 }, overhead)).toBeCloseTo(90, 6);
453 expect(viewAngle({ ...below, pitch: 0, yaw: 90 }, overhead)).toBeCloseTo(90, 6);
454 const up = flashHits(g, [{ ...below, pitch: -90 }], [FLOOR])[0]?.seconds ?? 0;
455 const level = flashHits(g, [{ ...below, pitch: 0 }], [FLOOR])[0]?.seconds ?? 0;
456 expect(up).toBeGreaterThan(level);
457 expect(up).toBeCloseTo(FLASH.flash.maxBlind * (1 - 200 / FLASH.flash.radius), 6);
458 });
459
460 test("a flash is bought alongside the HE, one of each, and thrown on its own key", () => {
461 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 13);
462 world.me.money = 5000;
463 expect(buy(world, world.me, "he")).toBe(true);
464 expect(buy(world, world.me, "flash")).toBe(true);
465 // Carried alongside, not instead of: both are in the pocket at once.
466 expect(world.me.pocket).toEqual({ he: CARRY.he, flash: CARRY.flash, smoke: 0 });
467 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
468 // One of each a round: the second of either is refused and costs nothing.
469 expect(buy(world, world.me, "flash")).toBe(false);
470 expect(buy(world, world.me, "he")).toBe(false);
471 expect(world.me.money).toBe(5000 - HE.price - FLASH.price);
472 // And neither went into your hands.
473 expect(world.me.weapon).toBe(world.me.secondary);
474
475 for (let t = 0; t < (SHORT.freezeTime + 0.5) * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
476 // The flash key throws the flash and leaves the HE where it was.
477 for (let t = 0; t < 32; t++) update(world, TICK, { ...NO_INPUT, throwing: "flash" });
478 expect(world.grenades.length).toBe(1);
479 expect(world.grenades[0]?.weapon).toBe("flash");
480 expect(world.me.pocket).toEqual({ he: 1, flash: 0, smoke: 0 });
481 });
482
483 /** Everyone but `p` put out of the round, so only the flash can change anything. */
484 const alone = (world: World, p: Player): void => {
485 for (const other of world.players) if (other !== p) other.alive = false;
486 };
487
488 test("a flash that goes off blinds everyone looking at it, and the clock burns it down", () => {
489 // One a side, and both bodies held still, alive and whole for the second and
490 // a half the fuse burns: what is under test is who the flash reaches.
491 const world = ready(37, 1);
492 const me = world.me;
493 const victim = world.players.find((p) => p.team === "t" && p.alive) as Player;
494 // Standing next to each other in the open, looking at one another, with the
495 // flash between them: what is under test is the blinding, not the throw.
496 victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]];
497 victim.yaw = 180;
498 me.yaw = 0;
499 const where: Vec3 = [me.pos[0] + 32, me.pos[1], me.pos[2] + EYE];
500 me.pocket.flash = 1;
501 expect(throwOne(world, me, "flash")).toBe(true);
502 for (let t = 0; t < 3 * TICKS_PER_SECOND && world.grenades.length > 0; t++) {
503 for (const g of world.grenades) {
504 g.pos = [where[0], where[1], where[2]];
505 g.vel = [0, 0, 0];
506 }
507 victim.pos = [me.pos[0] + 64, me.pos[1], me.pos[2]];
508 victim.yaw = 180;
509 victim.health = 100;
510 me.health = 100;
511 me.yaw = 0;
512 update(world, TICK, NO_INPUT);
513 }
514 // Both of them were looking at it, so both are blinded: light is not on a side.
515 expect(victim.blind).toBeGreaterThan(0);
516 expect(me.blind).toBeGreaterThan(0);
517 expect(me.blind).toBeLessThanOrEqual(FLASH.flash.maxBlind);
518 // It was heard as a flash, not as a blast, and it drew its own pop.
519 expect(heard(world, (e) => e.kind === "sound" && e.name === "flash")).toBe(true);
520 expect(heard(world, (e) => e.kind === "sound" && e.name === "blast")).toBe(false);
521 expect(heard(world, (e) => e.kind === "blast" && e.weapon === "flash")).toBe(true);
522 // Nobody was hurt and nothing went in the feed: a flash is a chance, not a kill.
523 expect(victim.health).toBe(100);
524 expect(world.feed.length).toBe(0);
525
526 // And it burns down on the simulation's clock, a tick at a time, to nothing.
527 const left = me.blind;
528 update(world, TICK, NO_INPUT);
529 expect(me.blind).toBeCloseTo(left - TICK, 10);
530 for (let t = 0; t < 3 * TICKS_PER_SECOND; t++) update(world, TICK, NO_INPUT);
531 expect(me.blind).toBe(0);
532 expect(world.match.phase === "live" || world.match.phase === "over").toBe(true);
533 });
534
535 test("two flashes in the face are not twice the blindness: neither passes the ceiling", () => {
536 const world = ready(39, 1);
537 const me = world.me;
538 alone(world, me);
539 me.yaw = 0; // looking straight at where each of them goes off, forty units out
540 const max = FLASH.flash.maxBlind;
541 /** Go off at the eye, and step until it is gone. */
542 const pop = (): void => {
543 const g = throwGrenade(
544 me.id, me.team, "flash", [me.pos[0] + 40, me.pos[1], me.pos[2] + EYE - NADE_HULL.stand / 2], [0, 0, 0], [],
545 );
546 g.fuse = TICK;
547 world.grenades.push(g);
548 while (world.grenades.length > 0) update(world, TICK, NO_INPUT);
549 };
550 pop();
551 const first = me.blind;
552 expect(first).toBeGreaterThan(max * 0.9);
553 expect(first).toBeLessThanOrEqual(max);
554 // A second one on top of the first: the longer of the two, never the sum.
555 pop();
556 expect(me.blind).toBeLessThanOrEqual(max);
557 expect(me.blind).toBeGreaterThan(max * 0.9);
558 expect(me.blind).toBeLessThan(first * 2);
559 });
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 +954 −697
@@ -1,723 +1,980 @@
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 Call, recorder } from "./recorder.test-helper.ts";
15 import type { Camera } from "./renderer.ts";
16 import { WEAPONS } from "./weapons.ts";
17 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";
1836
19 const W = 1280, H = 720;
20
21 /** The camera `main.ts` builds for the player being drawn, punch and all aside. */
22 const cameraFor = (view: Player): Camera =>
23 ({ pos: eyePos(view), yaw: view.yaw, pitch: view.pitch, fov: view.scoped ? 40 : 90 });
24
25 /** One frame of the HUD, drawing `view` — the person unless a spectator is watching. */
26 const draw = (into: { ctx: CanvasRenderingContext2D }, world: World, view: Player = world.me,
27 scoreboard = false, crosshair?: CrosshairStyle, alpha = 1): void =>
28 drawHud(into.ctx, world, view, cameraFor(view), W, H, scoreboard, crosshair, alpha);
29
30 /** Where the middle of a stroked wedge lands on the screen. */
31 const wedgePoint = (call: Call): { x: number; y: number } => {
32 const [x, y, r, start, end] = call.args as [number, number, number, number, number];
33 const middle = (start + end) / 2;
34 return { x: x + Math.cos(middle) * r, y: y + Math.sin(middle) * r };
35 };
36
37 const hurtWorld = (yaw: number, from: [number, number, number], victim: number): World => {
38 const world = createWorld(SHORT, "me", "ct", 5, 71);
39 world.me.pos = [0, 0, 0];
40 world.me.yaw = yaw;
41 world.effects.length = 0;
42 world.effects.push({ kind: "hurt", at: world.time, victim, from, amount: 30 });
43 return world;
44 };
45
46 // The damage wedges are arcs centred on the crosshair; the radar draws its
47 // own arcs too, off in the corner, so a plain count of "arc" calls is no
48 // longer enough to isolate them.
49 const wedgeArcs = (calls: Call[]): Call[] => calls.filter((c) => c.name === "arc" && c.args[0] === W / 2);
50
51 test("a hit from behind puts the wedge below the crosshair, one from the left on its left", () => {
52 const behind = recorder();
53 draw(behind, hurtWorld(0, [-500, 0, 40], 0));
54 const back = wedgeArcs(behind.calls).map(wedgePoint);
55 expect(back.length).toBe(1);
56 expect(back[0].y).toBeGreaterThan(H / 2 + 20);
57 expect(Math.abs(back[0].x - W / 2)).toBeLessThan(20);
58
59 const left = recorder();
60 draw(left, hurtWorld(0, [0, 500, 40], 0));
61 const side = wedgeArcs(left.calls).map(wedgePoint);
62 expect(side.length).toBe(1);
63 expect(side[0].x).toBeLessThan(W / 2 - 20);
64 expect(Math.abs(side[0].y - H / 2)).toBeLessThan(20);
65 });
66
67 test("a fall hurts from straight below: no wedge, since there is no way it came from", () => {
68 const frame = recorder();
69 draw(frame, hurtWorld(0, [0, 0, 0], 0));
70 expect(wedgeArcs(frame.calls).length).toBe(0);
71 });
72
73 test("hits stack, and damage to anyone but the player being drawn is not drawn", () => {
74 const two = recorder();
75 const world = hurtWorld(0, [-500, 0, 40], 0);
76 world.effects.push({ kind: "hurt", at: world.time, victim: 0, from: [0, -500, 40], amount: 18 });
77 draw(two, world);
78 expect(wedgeArcs(two.calls).length).toBe(2);
79
80 const bot = recorder();
81 draw(bot, hurtWorld(0, [-500, 0, 40], 4));
82 expect(wedgeArcs(bot.calls).length).toBe(0);
83 });
84
85 test("an old hit has faded out by the time the effects list is trimmed", () => {
86 const world = hurtWorld(0, [-500, 0, 40], 0);
87 world.time += 1.5; // still in the list, which update keeps for two seconds
88 const frame = recorder();
89 draw(frame, world);
90 expect(wedgeArcs(frame.calls).length).toBe(0);
91 });
92
93 test("the hurt vignette follows health and its gradient is built once, not per frame", () => {
94 const healthy = recorder();
95 const world = createWorld(SHORT, "me", "ct", 5, 73);
96 draw(healthy, world);
97 const full = (calls: Call[]): Call[] =>
98 calls.filter((c) => c.name === "fillRect" && c.args[0] === 0 && c.args[1] === 0 && c.args[2] === W);
99 expect(full(healthy.calls).length).toBe(0);
100
101 world.me.health = 20;
102 const hurt = recorder();
103 draw(hurt, world);
104 expect(full(hurt.calls).length).toBe(1);
105
106 // The second frame at the same size reuses the gradient the first one made.
107 const again = recorder();
108 draw(again, world);
109 expect(full(again.calls).length).toBe(1);
110 expect(again.calls.filter((c) => c.name === "createRadialGradient").length).toBe(0);
111 });
112
113 /**
114 * The crosshair's arms as the context was told to draw them: each `moveTo`
115 * paired with the `lineTo` that follows it, kept only when it starts at the
116 * middle of the screen. Nothing else in a quiet frame starts there — the
117 * radar's own lines are off in the corner and the hit markers need an effect.
118 */
119 function armsDrawn(calls: Call[]): { from: number[]; to: number[]; stroke: string; lineWidth: number }[] {
120 const arms: { from: number[]; to: number[]; stroke: string; lineWidth: number }[] = [];
121 for (let i = 0; i < calls.length - 1; i++) {
122 const move = calls[i], line = calls[i + 1];
123 if (move.name !== "moveTo" || line.name !== "lineTo") continue;
124 const along = (move.args[0] === W / 2) !== (move.args[1] === H / 2);
125 if (!along) continue;
126 arms.push({ from: move.args, to: line.args, stroke: move.stroke, lineWidth: move.lineWidth });
127 }
128 return arms;
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;
12942 }
13043
131 /** A quiet frame: alive, still, and holding the knife, which has no spread. */
132 const quietWorld = (): World => {
133 const world = createWorld(SHORT, "me", "ct", 5, 67);
134 world.me.weapon = "knife";
135 world.me.vel = [0, 0, 0];
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);
46
47 // --- the world this suite draws --------------------------------------------
48
49 /**
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.
54 */
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);
13674 return world;
137 };
75 }
13876
139 test("the default crosshair is the one this game has always drawn", () => {
140 const frame = recorder();
141 draw(frame, quietWorld());
142 // Four green arms, starting 4px out and running 7px, stroked 2px wide —
143 // right, left, down, up, exactly as the hard-coded loop drew them.
144 expect(armsDrawn(frame.calls).map((a) => [...a.from, ...a.to])).toEqual([
145 [W / 2 + 4, H / 2, W / 2 + 11, H / 2],
146 [W / 2 - 4, H / 2, W / 2 - 11, H / 2],
147 [W / 2, H / 2 + 4, W / 2, H / 2 + 11],
148 [W / 2, H / 2 - 4, W / 2, H / 2 - 11],
149 ]);
150 for (const arm of armsDrawn(frame.calls)) {
151 expect(arm.stroke).toBe("rgba(120, 255, 140, 0.9)");
152 expect(arm.lineWidth).toBe(2);
153 }
154 // No dot by default: nothing is filled in the middle of the screen.
155 expect(frame.calls.some((c) => c.name === "arc" && c.args[0] === W / 2 && c.args[1] === H / 2)).toBe(false);
77 const WIDTH = 1280;
78 const HEIGHT = 720;
15679
157 // And the default is what a frame drawn with no style at all gets, so the
158 // HUD and the settings agree about what "today's crosshair" means.
159 const explicit = recorder();
160 draw(explicit, quietWorld(), undefined, false, DEFAULT_CROSSHAIR);
161 expect(armsDrawn(explicit.calls)).toEqual(armsDrawn(frame.calls));
162 });
163
164 test("the style is what gets drawn: colour, gap, size, thickness and the dot", () => {
165 const style: CrosshairStyle =
166 { colour: "cyan", size: 16, gap: 10, thickness: 4, dot: true, dynamic: false };
167 const frame = recorder();
168 draw(frame, quietWorld(), undefined, false, style);
169 const arms = armsDrawn(frame.calls);
170 expect(arms.map((a) => [...a.from, ...a.to])).toEqual([
171 [W / 2 + 10, H / 2, W / 2 + 26, H / 2],
172 [W / 2 - 10, H / 2, W / 2 - 26, H / 2],
173 [W / 2, H / 2 + 10, W / 2, H / 2 + 26],
174 [W / 2, H / 2 - 10, W / 2, H / 2 - 26],
175 ]);
176 expect(arms[0].stroke).toBe("rgba(96, 226, 255, 0.9)");
177 expect(arms[0].lineWidth).toBe(4);
178 // The dot: a filled circle in the middle, in the same colour as the arms.
179 const dot = frame.calls.find((c) => c.name === "arc" && c.args[0] === W / 2 && c.args[1] === H / 2);
180 expect(dot).toBeDefined();
181 expect((dot as Call).args[2]).toBe(2);
182 expect((dot as Call).fill).toBe("rgba(96, 226, 255, 0.9)");
183 });
184
185 test("a dynamic crosshair opens as the gun loses accuracy; a static one holds still", () => {
186 const running = (crosshair?: CrosshairStyle): number => {
187 const world = quietWorld();
188 world.me.weapon = "ak47";
189 world.me.vel = [250, 0, 0];
190 world.me.onGround = false; // in the air, where a rifle is at its worst
191 const frame = recorder();
192 draw(frame, world, undefined, false, crosshair);
193 return armsDrawn(frame.calls)[0].from[0] - W / 2;
194 };
195 const still = (): number => {
196 const frame = recorder();
197 draw(frame, quietWorld(), undefined, false);
198 return armsDrawn(frame.calls)[0].from[0] - W / 2;
199 };
200
201 expect(running()).toBeGreaterThan(still() + 10);
202 // The same jump with the same rifle, and the arms do not move at all.
203 expect(running({ ...DEFAULT_CROSSHAIR, dynamic: false })).toBe(still());
204 });
205
206 /** A world where the person is dead and one named teammate is still up. */
207 const spectatedWorld = (): World => {
208 const world = createWorld(SHORT, "me", "ct", 5, 79);
209 const mates = world.players.filter((p) => p.team === "ct" && p !== world.me);
210 for (const p of mates.slice(1)) p.alive = false;
211 world.me.alive = false;
212 world.me.health = 0;
213 world.feed.push({
214 at: world.time, killer: "Krait", killerId: 9, victim: world.me.name, victimId: world.me.id,
215 weapon: "ak47", headshot: true, killerTeam: "t", killerHealth: 23,
216 });
217 return world;
218 };
219
220 test("dead, the HUD is the watched player's: their numbers, no crosshair, and who killed you", () => {
221 const world = spectatedWorld();
222 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
223 mate.health = 64;
224 mate.money = 4321;
225 const frame = recorder();
226 draw(frame, world, mate);
227 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text);
228
229 expect(texts).toContain("64"); // the watched player's health, not the corpse's zero
230 expect(texts).toContain("$4321");
231 expect(texts.join("|")).toContain(`Watching ${mate.name}`);
232 expect(texts.join("|")).toContain("Krait killed you with AK-47");
233 expect(texts.join("|")).toContain("headshot");
234 expect(texts.join("|")).toContain("23 hp left"); // how close you came
235 // The crosshair is four strokes out from the middle; a spectator gets none.
236 expect(frame.calls.filter((c) => c.name === "moveTo" && c.args[1] === H / 2).length).toBe(0);
237 });
238
239 test("a fall that killed you says so, rather than naming you as your own killer", () => {
240 const world = spectatedWorld();
241 world.feed.push({
242 at: world.time, killer: world.me.name, killerId: world.me.id, victim: world.me.name, victimId: world.me.id,
243 weapon: "fall", headshot: false, killerTeam: "ct", killerHealth: null,
244 });
245 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
246 const frame = recorder();
247 draw(frame, world, mate);
248 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text).join("|");
249 expect(texts).toContain("You fell too far");
250 expect(texts).toContain(`${world.me.name} Fall ${world.me.name}`);
251 });
252
253 test("a self-kill says nothing about health: there is no one else's hp to report", () => {
254 const world = spectatedWorld();
255 world.feed.push({
256 at: world.time, killer: world.me.name, killerId: world.me.id, victim: world.me.name, victimId: world.me.id,
257 weapon: "bomb", headshot: false, killerTeam: "ct", killerHealth: null,
258 });
259 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
260 const frame = recorder();
261 draw(frame, world, mate);
262 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text).join("|");
263 expect(texts).not.toContain("hp left");
264 });
265
266 test("a spectator does not inherit the dead player's hit markers", () => {
267 const world = spectatedWorld();
268 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
269 world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed: false });
270 const watching = recorder();
271 draw(watching, world, mate);
272 // The radar draws its own moveTo near the corner for the self arrow; the
273 // hit marker's crosses are the ones centred on the crosshair.
274 const centred = watching.calls.filter((c) => c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50);
275 expect(centred.length).toBe(0);
276
277 // Alive, the same effect is the marker it was drawn for.
278 const alive = createWorld(SHORT, "me", "ct", 5, 79);
279 alive.effects.push({ kind: "hitmark", at: alive.time, headshot: false, killed: false });
280 const own = recorder();
281 draw(own, alive);
282 expect(own.calls.filter((c) => c.name === "moveTo").length).toBeGreaterThan(4);
283 });
284
285 test("the mark for a kill is a different shape from the one for a hit, and lasts longer", () => {
286 /** The strokes a frame put in the middle of the screen: the crosshair's and the marker's. */
287 const centred = (killed: boolean, age = 0, headshot = false): Call[] => {
288 const world = createWorld(SHORT, "me", "ct", 5, 81);
289 world.effects.push({ kind: "hitmark", at: world.time - age, headshot, killed });
290 const frame = recorder();
291 draw(frame, world);
292 return frame.calls.filter((c) =>
293 c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50 && Math.abs(c.args[1] - H / 2) < 50);
294 };
295 // The crosshair alone, with the marker long expired: the baseline to subtract.
296 const crosshairOnly = centred(false, 10).length;
297 expect(centred(false, 10, false).length).toBe(crosshairOnly);
298
299 expect(centred(false).length - crosshairOnly).toBe(4); // four ticks out of the corners
300 expect(centred(true).length - crosshairOnly).toBe(6); // and an X through the middle
301 expect(centred(true).some((c) => c.lineWidth === 3)).toBe(true); // heavier
302 expect(centred(false).every((c) => c.lineWidth !== 3)).toBe(true);
303
304 // A third of a second on, the plain mark is gone and the kill is still up.
305 expect(centred(false, 0.35).length).toBe(crosshairOnly);
306 expect(centred(true, 0.35).length - crosshairOnly).toBe(6);
307
308 // A headshot kill is still the headshot's red, X and all.
309 expect(centred(true, 0, true).some((c) => c.stroke === "rgba(255,90,90,0.95)")).toBe(true);
310 });
311
312 test("a gun under your feet that you would have to ask for says so", () => {
313 const world = createWorld(SHORT, "me", "ct", 5, 89);
314 world.me.primary = "m4a4";
315 world.pickups.push({
316 weapon: "ak47",
317 pos: [world.me.pos[0], world.me.pos[1], world.me.pos[2] + 4],
318 ammo: { mag: 12, reserve: 90 },
319 });
320 const carrying = recorder();
321 draw(carrying, world);
322 expect(carrying.calls.some((c) => c.text === "E — take AK-47")).toBe(true);
323
324 // With no primary it is taken by walking over it, so there is nothing to ask.
325 world.me.primary = null;
326 const empty = recorder();
327 draw(empty, world);
328 expect(empty.calls.some((c) => c.text.includes("take AK-47"))).toBe(false);
329
330 // And a gun across the map is not under anyone's feet.
331 world.me.primary = "m4a4";
332 world.pickups[0].pos = [world.me.pos[0] + 400, world.me.pos[1], world.me.pos[2]];
333 const far = recorder();
334 draw(far, world);
335 expect(far.calls.some((c) => c.text.includes("take AK-47"))).toBe(false);
336 });
337
338 test("the round result stays up through the end delay and the freeze that follows", () => {
339 const world = createWorld(SHORT, "me", "ct", 5, 83);
340 world.match.phase = "over";
341 world.messages.push({ at: world.time, text: "Counter-Terrorists win — defused the bomb" });
342 world.time += SHORT.endDelay + SHORT.freezeTime - 1; // well past the four-second fade
343 const ending = recorder();
344 draw(ending, world);
345 expect(ending.calls.some((c) => c.text.includes("defused the bomb"))).toBe(true);
346
347 world.match.phase = "freeze";
348 const freezing = recorder();
349 draw(freezing, world);
350 expect(freezing.calls.some((c) => c.text.includes("defused the bomb"))).toBe(true);
351
352 // Live again, an old line is gone: it is the round's result, not wallpaper.
353 world.match.phase = "live";
354 const live = recorder();
355 draw(live, world);
356 expect(live.calls.some((c) => c.text.includes("defused the bomb"))).toBe(false);
357 });
358
359 test("the damage report is drawn while the round is over, and not while it is live", () => {
360 const world = createWorld(SHORT, "me", "ct", 5, 103);
361 const enemy = world.players.find((p) => p.team === "t") as Player;
362 world.damageLog[world.me.id][enemy.id] = { damage: 42, hits: 2, killed: true };
363
364 world.match.phase = "over";
365 const over = recorder();
366 draw(over, world);
367 const overTexts = over.calls.filter((c) => c.name === "fillText").map((c) => c.text);
368 expect(overTexts.some((t) => t.includes(`${enemy.name} — 42 dmg`))).toBe(true);
369 expect(overTexts.some((t) => t.startsWith("Dealt"))).toBe(true);
370
371 world.match.phase = "live";
372 const live = recorder();
373 draw(live, world);
374 const liveTexts = live.calls.filter((c) => c.name === "fillText").map((c) => c.text);
375 expect(liveTexts.some((t) => t.includes(`${enemy.name} — 42 dmg`))).toBe(false);
376
377 // The scoreboard covers the same pixels, so a held Tab wins over the report.
378 world.match.phase = "over";
379 const withScoreboard = recorder();
380 draw(withScoreboard, world, world.me, true);
381 const scoreboardTexts = withScoreboard.calls.filter((c) => c.name === "fillText").map((c) => c.text);
382 expect(scoreboardTexts.some((t) => t.startsWith("Dealt"))).toBe(false);
383 });
384
385 test("the radar sits in the bottom-left corner and labels both sites", () => {
386 const world = createWorld(SHORT, "me", "ct", 5, 91);
387 const frame = recorder();
388 draw(frame, world);
389 const texts = frame.calls.filter((c) => c.name === "fillText").map((c) => c.text);
390 expect(texts).toContain("A");
391 expect(texts).toContain("B");
392
393 // The radar's border is a full circle, away from the money line, the kill
394 // feed and the clock, all of which live in the top half of the screen.
395 const border = frame.calls.find(
396 (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,
39795 );
398 expect(border).toBeDefined();
399 });
400
401 test("the ammo counter dims while the gun is coming up, and lifts as it comes level", () => {
402 const world = createWorld(SHORT, "me", "ct", 5, 208);
403 world.me.primary = "awp";
404 world.me.weapon = "awp";
405 world.me.ammo.awp = { mag: WEAPONS.awp.magazine, reserve: WEAPONS.awp.reserve };
406 world.time = 30;
407 // A round start hands the gun over already up: nothing to say about it.
408 const ammoOf = (r: { calls: Call[] }): Call =>
409 r.calls.find((c) => c.name === "fillText" && c.text.startsWith(`${WEAPONS.awp.magazine} /`)) as Call;
410 const up = recorder();
411 draw(up, world);
412 expect(ammoOf(up).alpha).toBe(1);
413
414 // Drawn off the knife: 1.25 s in which the trigger does nothing, said in the
415 // one place on the HUD that claims the gun is ready to use.
416 applyInput(world, world.me, { ...NO_INPUT, switchTo: "knife" }, TICK);
417 world.time += TICK;
418 applyInput(world, world.me, { ...NO_INPUT, switchTo: "awp" }, TICK);
419 const coming = recorder();
420 draw(coming, world);
421 expect(ammoOf(coming).alpha).toBeLessThan(1);
422 // The weapon's name goes with it, and nothing above them does.
423 expect((coming.calls.find((c) => c.text === "AWP") as Call).alpha).toBeLessThan(1);
424 expect((coming.calls.find((c) => c.text === "$800") as Call).alpha).toBe(1);
425
426 // Still dim a whole second in, and full strength the moment the shot clock
427 // frees the trigger — the frame the view model is level.
428 world.time += 1;
429 const nearly = recorder();
430 draw(nearly, world);
431 expect(ammoOf(nearly).alpha).toBeLessThan(1);
432 world.time = world.me.nextShotAt;
433 const ready = recorder();
434 draw(ready, world);
435 expect(ammoOf(ready).alpha).toBe(1);
436 });
437
438 test("the cooldown between two shots is not a deploy, so the ammo counter holds", () => {
439 const world = createWorld(SHORT, "me", "ct", 5, 209);
440 world.time = 30;
441 const ammoOf = (r: { calls: Call[] }): Call =>
442 r.calls.find((c) => c.name === "fillText" && c.text.includes(" / ")) as Call;
443 // The USP mid-cooldown: the gun is up and working, not arriving.
444 world.me.lastShotAt = world.time;
445 world.me.nextShotAt = world.time + 60 / WEAPONS.usp.rpm;
446 const firing = recorder();
447 draw(firing, world);
448 expect(ammoOf(firing).alpha).toBe(1);
449 });
450
451 test("the HUD says what is in the pocket, and counts it where the ammo goes", () => {
452 const world = createWorld(SHORT, "me", "ct", 5, 97);
453 world.me.grenades = { he: 1 };
454 const carrying = recorder();
455 draw(carrying, world);
456 const texts = (r: { calls: Call[] }): string[] =>
457 r.calls.filter((c) => c.name === "fillText").map((c) => c.text);
458 expect(texts(carrying).some((t) => t.startsWith("HE ×1"))).toBe(true);
459
460 // Held, the count is what the magazine would have said, under its name.
461 world.me.weapon = "he";
462 const held = recorder();
463 draw(held, world);
464 expect(texts(held)).toContain("×1");
465 expect(texts(held)).toContain("HE Grenade");
466
467 // With none, neither line is there to read.
468 world.me.grenades = {};
469 world.me.weapon = world.me.secondary;
470 const without = recorder();
471 draw(without, world);
472 expect(without.calls.some((c) => c.text.includes("HE"))).toBe(false);
473 });
474
475 /**
476 * A world with nothing solid in it, the person at the origin looking east, and
477 * one live teammate and one live enemy to put wherever a test needs them. The
478 * boxes are replaced rather than emptied in place: `world.boxes` starts out as
479 * the shared map's own list, which no test may edit.
480 */
481 function plateWorld(): { world: World; me: Player; mate: Player; enemy: Player } {
482 const world = createWorld(SHORT, "me", "ct", 5, 61);
483 const me = world.me;
484 me.pos = [0, 0, 0];
485 me.yaw = 0;
486 me.pitch = 0;
487 const mates = world.players.filter((p) => p.team === "ct" && p !== me);
488 const enemies = world.players.filter((p) => p.team === "t");
489 for (const p of [...mates.slice(1), ...enemies.slice(1)]) p.alive = false;
490 const mate = mates[0] as Player, enemy = enemies[0] as Player;
491 mate.pos = [600, 60, 0];
492 enemy.pos = [600, -60, 0];
493 world.boxes = [];
494 return { world, me, mate, enemy };
96 return paint;
49597 }
49698
497 /** A wall across the view at `x`, tall enough to hide anyone standing behind it. */
498 const wallAt = (x: number): { min: [number, number, number]; max: [number, number, number]; mat: number } =>
499 ({ 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);
500102
501 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]));
502104
503 test("a name over the player in front of you, with health when they are yours", () => {
504 const { world, mate, enemy } = plateWorld();
505 mate.health = 64;
506 enemy.health = 37;
507 const frame = recorder();
508 draw(frame, world);
509 const texts = written(frame.calls);
105 const says = (paint: Recorder, text: string): boolean => words(paint).includes(text);
510106
511 expect(texts).toContain(mate.name);
512 expect(texts).toContain(enemy.name);
513 expect(texts).toContain("64"); // the teammate's health, under their name
514 expect(texts).not.toContain("37"); // an enemy's health is nobody's business
515 });
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 }
516113
517 test("no wallhack: a player behind a box has no plate, and gets one again in the open", () => {
518 const { world, mate, enemy } = plateWorld();
519 world.boxes = [wallAt(300)];
520 const hidden = recorder();
521 draw(hidden, world);
522 expect(written(hidden.calls)).not.toContain(mate.name);
523 expect(written(hidden.calls)).not.toContain(enemy.name);
114 // --- the crosshair ---------------------------------------------------------
524115
525 // Same frame, same players, with the wall gone.
526 world.boxes = [];
527 const open = recorder();
528 draw(open, world);
529 expect(written(open.calls)).toContain(mate.name);
530 expect(written(open.calls)).toContain(enemy.name);
531 });
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 }
532132
533 test("a plate needs a live player in front of the eye, and no scoreboard over it", () => {
534 const { world, mate, enemy } = plateWorld();
535 enemy.alive = false; // a body is not a player
536 mate.pos = [-600, 0, 0]; // behind the camera, which looks east
537 const away = recorder();
538 draw(away, world);
539 expect(written(away.calls)).not.toContain(mate.name);
540 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);
541138
542 // In front but off to the side past the 90° frustum: still nothing.
543 mate.pos = [600, 1400, 0];
544 const wide = recorder();
545 draw(wide, world);
546 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;
547141
548 // In front, alive, and visible — but a held Tab is showing the scoreboard,
549 // which names everyone anyway, so the plates stay out of it.
550 mate.pos = [600, 0, 0];
551 const held = recorder();
552 draw(held, world, world.me, true);
553 expect(written(held.calls).filter((t) => t === mate.name).length).toBe(0);
554 const plain = recorder();
555 draw(plain, world);
556 expect(written(plain.calls)).toContain(mate.name);
557 });
558
559 test("the plates are the watched player's while spectating, not the corpse's", () => {
560 const { world, me, mate, enemy } = plateWorld();
561 me.alive = false;
562 me.health = 0;
563 mate.pos = [600, 0, 0];
564 mate.yaw = 0;
565 enemy.pos = [1200, 0, 0]; // clear of the teammate, blocked from the corpse
566 world.boxes = [wallAt(300)];
567
568 const watching = recorder();
569 draw(watching, world, mate);
570 expect(written(watching.calls)).toContain(enemy.name);
571 expect(written(watching.calls)).not.toContain(mate.name); // never the player you are
572
573 // From the corpse's own eye the wall is in the way, which is the difference.
574 const corpse = recorder();
575 draw(corpse, world, me);
576 expect(written(corpse.calls)).not.toContain(enemy.name);
577 });
578
579 test("a plate across the map is a hint: fainter, and clamped small", () => {
580 const { world, mate, enemy } = plateWorld();
581 mate.alive = false; // one plate in the frame, and no health bar under it
582 enemy.pos = [500, 0, 0];
583 const near = recorder();
584 draw(near, world);
585 enemy.pos = [4000, 0, 0];
586 const far = recorder();
587 draw(far, world);
588
589 const plate = (calls: Call[]): Call => calls.find((c) => c.name === "fillText" && c.text === enemy.name) as Call;
590 // The backing behind the name is the only small fillRect in the frame; its
591 // height is the font size the plate was drawn at.
592 const backing = (calls: Call[]): Call => calls.find((c) => c.name === "fillRect" && c.args[2] < 30) as Call;
593
594 expect(plate(near.calls)).toBeDefined();
595 expect(plate(far.calls)).toBeDefined();
596 expect(plate(far.calls).alpha).toBeLessThan(plate(near.calls).alpha * 0.5);
597 expect(plate(far.calls).alpha).toBeGreaterThan(0.1); // faint, never invisible
598 expect(backing(far.calls).args[3]).toBeLessThan(backing(near.calls).args[3]);
599 // Clamped at both ends: a player in your face is not twice the size either.
600 enemy.pos = [120, 0, 0];
601 const close = recorder();
602 draw(close, world);
603 expect(backing(close.calls).args[3]).toBeCloseTo(backing(near.calls).args[3], 6);
604 });
605
606 test("a name plate sits over the body the renderer drew, not the tick it left", () => {
607 // The scene is drawn between two ticks; a plate taken from the newest one
608 // would swim above the head it belongs to. `alpha` is the same number both.
609 const { world, mate, enemy } = plateWorld();
610 mate.alive = false;
611 enemy.prev.pos = [600, -20, 0];
612 enemy.pos = [600, 20, 0];
613
614 const plateX = (alpha: number): number => {
615 const frame = recorder();
616 draw(frame, world, world.me, false, undefined, alpha);
617 const call = frame.calls.find((c) => c.name === "fillText" && c.text === enemy.name) as Call;
618 return call.args[0] as number;
619 };
620
621 const oldest = plateX(0), half = plateX(0.5), newest = plateX(1);
622 // Looking east with the player crossing from right to left of the screen.
623 expect(oldest).not.toBeCloseTo(newest, 0);
624 expect(half).toBeCloseTo((oldest + newest) / 2, 0);
625 });
626
627 // --- motion: what reduced motion is allowed to turn down, and what it is not.
628
629 /** One frame drawn at a motion setting, and the calls it made. */
630 const drawAt = (world: World, motion: Motion, view: Player = world.me): Call[] => {
631 const into = recorder();
632 drawHud(into.ctx, world, view, cameraFor(view), W, H, false, DEFAULT_CROSSHAIR, 1, motion);
633 return into.calls;
634 };
635
636 test("reduced motion holds a damage wedge steady where full motion fades it out", () => {
637 const fresh = hurtWorld(0, [-500, 0, 40], 0);
638 const older = hurtWorld(0, [-500, 0, 40], 0);
639 older.time += 0.8; // most of the way through HURT_FADE, and still in the list
640
641 const wedge = (world: World, motion: Motion): Call => wedgeArcs(drawAt(world, motion))[0] as Call;
642 // Full motion: the same wedge, most of a second later, is most of the way gone.
643 expect(wedge(older, "full").alpha).toBeLessThan(wedge(fresh, "full").alpha * 0.5);
644 // Reduced: it is the same strength for its whole life. Nothing slides.
645 expect(wedge(older, "reduced").alpha).toBeCloseTo(wedge(fresh, "reduced").alpha, 10);
646
647 // And it still says everything it said: where the hit came from, and how
648 // hard — a 60-damage hit is a stronger wedge than a 10-damage one, reduced
649 // motion or not, and it is in the same place either way.
650 const soft = hurtWorld(0, [-500, 0, 40], 0);
651 (soft.effects[0] as { amount: number }).amount = 10;
652 expect(wedge(soft, "reduced").alpha).toBeLessThan(wedge(fresh, "reduced").alpha);
653 expect(wedgePoint(wedge(fresh, "reduced"))).toEqual(wedgePoint(wedge(fresh, "full")));
654
655 // It ends where it always ended: a wedge past HURT_FADE is not drawn at all,
656 // so "steady" is a strength held and never a wedge that outstays its hit.
657 const gone = hurtWorld(0, [-500, 0, 40], 0);
658 gone.time += 1.5;
659 expect(wedgeArcs(drawAt(gone, "reduced")).length).toBe(0);
660 });
661
662 test("the hurt vignette is the same steady tint under either motion, and no frame of it reads a clock", () => {
663 const world = createWorld(SHORT, "me", "ct", 5, 79);
664 world.me.health = 20;
665 const vignette = (motion: Motion): Call =>
666 drawAt(world, motion).filter(
667 (c) => c.name === "fillRect" && c.args[0] === 0 && c.args[1] === 0 && c.args[2] === W,
668 )[0] as Call;
669
670 // There is no time term in it — only `view.health` — so it is already the
671 // steady tint reduced motion would ask for, and three seconds of world time
672 // change nothing about it on either setting.
673 const before = vignette("full").alpha;
674 expect(vignette("reduced").alpha).toBe(before);
675 world.time += 3;
676 expect(vignette("full").alpha).toBe(before);
677 expect(vignette("reduced").alpha).toBe(before);
678
679 // What it does say is how close to dead you are, and reduced motion is not
680 // allowed to take that away: nearly dead is a deeper tint than nearly hurt.
681 world.me.health = 5;
682 expect(vignette("reduced").alpha).toBeGreaterThan(before);
683 // And a healthy player has no tint at all, either way.
684 world.me.health = 100;
685 expect(vignette("reduced")).toBeUndefined();
686 expect(vignette("full")).toBeUndefined();
687 });
688
689 test("reduced motion leaves the hit marker, the kill feed and the crosshair exactly where they were", () => {
690 const world = createWorld(SHORT, "me", "ct", 5, 83);
691 world.me.weapon = "ak47";
692 world.me.vel = [180, 0, 0]; // moving, so the dynamic crosshair is open
693 world.effects.length = 0;
694 world.effects.push({ kind: "hitmark", at: world.time - 0.1, headshot: true, killed: true });
695 world.feed.push({
696 at: world.time, killer: "me", killerId: world.me.id, killerTeam: "ct", victim: "them",
697 weapon: "ak47", headshot: true, victimId: 5, killerHealth: 80,
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,
698148 });
699 // Every call, argument for argument and alpha for alpha: the whole of the
700 // rest of the HUD is information, and a setting that moved any of it would
701 // be a setting that changes the game.
702 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);
703176 });
704177
705 test("a round a teammate stopped is marked in your side's colour, never as a hit", () => {
706 const centred = (friendly: boolean): Call[] => {
707 const world = createWorld(SHORT, "me", "ct", 5, 83);
708 world.effects.push({ kind: "hitmark", at: world.time, headshot: false, killed: false, friendly });
709 const frame = recorder();
710 draw(frame, world);
711 return frame.calls.filter((c) =>
712 c.name === "moveTo" && Math.abs(c.args[0] - W / 2) < 50 && Math.abs(c.args[1] - H / 2) < 50);
713 };
714 const blocked = centred(true);
715 const hit = centred(false);
716 // The enemy mark's white is nowhere in it, and the side's blue is.
717 expect(blocked.some((c) => c.stroke === "rgba(255,255,255,0.9)")).toBe(false);
718 expect(hit.some((c) => c.stroke === "rgba(255,255,255,0.9)")).toBe(true);
719 const own = blocked.filter((c) => c.stroke === "#6fa8e8");
720 expect(own.length).toBe(4);
721 // Ticks on the axes — a plus — where a hit's sit on the diagonals.
722 expect(own.every((c) => c.args[0] === W / 2 || c.args[1] === H / 2)).toBe(true);
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 test("a round a teammate stopped is a plus in your side's colour, never a hit", () => {
272 const mark = (friendly: boolean, team: "t" | "ct" = "ct"): Call[] => draw(scene((w) => {
273 w.time = 10;
274 w.me.team = team;
275 w.effects = only({ kind: "hitmark", at: 9.9, headshot: false, killed: false, friendly });
276 })).calls;
277 const strokes = (calls: Call[]): Call[] => calls.filter((c) => c.op === "stroke");
278 // One stroke, like a hit's, but in the side's colour and not the hit's white.
279 expect(strokes(mark(false)).map((c) => c.stroke)).toEqual(["rgba(255,255,255,0.9)"]);
280 expect(strokes(mark(true)).map((c) => c.stroke)).toEqual(["#6fa8e8"]);
281 expect(strokes(mark(true, "t")).map((c) => c.stroke)).toEqual(["#e2b04a"]);
282 // Four ticks on the axes — a plus — where a hit's sit on the diagonals.
283 const centred = (calls: Call[]): number[][] => calls
284 .filter((c) => c.op === "lineTo")
285 .map((c) => c.args as number[])
286 .filter(([x, y]) => Math.abs((x as number) - WIDTH / 2) < 50 && Math.abs((y as number) - HEIGHT / 2) < 50);
287 const ends = centred(mark(true));
288 expect(ends.length).toBe(4);
289 for (const [x, y] of ends) expect(x === WIDTH / 2 || y === HEIGHT / 2).toBe(true);
290 const hits = centred(mark(false));
291 expect(hits.length).toBe(4);
292 for (const [x, y] of hits) expect(x !== WIDTH / 2 && y !== HEIGHT / 2).toBe(true);
293 });
294
295 // --- health, money and armour ----------------------------------------------
296
297 test("health reads white while you are standing and red when you are nearly gone", () => {
298 expect(line(draw(scene()), "100").fill).toBe("#f3f5f8");
299 expect(line(draw(scene((w) => { w.me.health = 31; })), "31").fill).toBe("#f3f5f8");
300 // Thirty itself is already the red one: the HUD asks `health > 30`.
301 expect(line(draw(scene((w) => { w.me.health = 30; })), "30").fill).toBe("#ff6b5b");
302 expect(line(draw(scene((w) => { w.me.health = 9; })), "9").fill).toBe("#ff6b5b");
303
304 // It is a whole number, and never a negative one.
305 expect(says(draw(scene((w) => { w.me.health = 45.6; })), "46")).toBe(true);
306 expect(says(draw(scene((w) => { w.me.health = -12; })), "0")).toBe(true);
307 });
308
309 test("the wallet, the armour and the kit say what you bought", () => {
310 const poor = draw(scene());
311 expect(says(poor, "$800")).toBe(true);
312 expect(says(poor, "no armour")).toBe(true);
313 expect(says(poor, "defuse kit")).toBe(false);
314
315 const kitted = draw(scene((w) => {
316 w.me.money = 4250;
317 w.me.armor = 100;
318 w.me.helmet = true;
319 w.me.defuseKit = true;
320 }));
321 expect(says(kitted, "$4250")).toBe(true);
322 expect(says(kitted, "armour 100 + helmet")).toBe(true);
323 expect(says(kitted, "defuse kit")).toBe(true);
324
325 // A terrorist with a kit in the inventory is still a terrorist: no kit line.
326 const t = draw(scene((w) => { w.me.team = "t"; w.me.defuseKit = true; }));
327 expect(says(t, "defuse kit")).toBe(false);
328 });
329
330 // --- ammo ------------------------------------------------------------------
331
332 test("ammo reads mag / reserve, the knife reads a dash, and reloading says so", () => {
333 const loaded = draw(scene((w) => {
334 w.me.weapon = "usp";
335 w.me.ammo.usp = { mag: 5, reserve: 20 };
336 }));
337 expect(says(loaded, "5 / 20")).toBe(true);
338 expect(says(loaded, WEAPONS.usp.name)).toBe(true);
339 expect(says(loaded, "reloading")).toBe(false);
340
341 const knife = draw(scene((w) => { w.me.weapon = "knife"; }));
342 expect(says(knife, "—")).toBe(true);
343 expect(says(knife, WEAPONS.knife.name)).toBe(true);
344
345 // Mid-reload the magazine still reads empty, and the word is on the screen.
346 const reloading = draw(scene((w) => {
347 w.time = 10;
348 w.me.reloadUntil = 10.5;
349 w.me.ammo.usp = { mag: 0, reserve: 24 };
350 }));
351 expect(says(reloading, "0 / 24")).toBe(true);
352 expect(line(reloading, "reloading").fill).toBe("#f0c060");
353
354 // The instant it finishes the word goes.
355 expect(says(draw(scene((w) => { w.time = 10; w.me.reloadUntil = 10; })), "reloading")).toBe(false);
356
357 // Mid-switch the same line says so instead: the weapon is in the hands but
358 // not up yet, and the word goes the instant it is.
359 const switching = draw(scene((w) => { w.time = 10; w.me.deployUntil = 10.5; }));
360 expect(line(switching, "switching").fill).toBe("#f0c060");
361 expect(says(switching, "reloading")).toBe(false);
362 expect(says(draw(scene((w) => { w.time = 10; w.me.deployUntil = 10; })), "switching")).toBe(false);
363 // And a switch that cut a reload short reads as the switch it is.
364 const both = draw(scene((w) => { w.time = 10; w.me.deployUntil = 10.5; w.me.reloadUntil = 12; }));
365 expect(says(both, "switching")).toBe(true);
366 expect(says(both, "reloading")).toBe(false);
367
368 // While it is on its way up the counter and the name are dimmed, and the
369 // word that explains it is not; the tick it is up they are whole again.
370 const drawing = (until: number): Recorder => draw(scene((w) => {
371 w.time = 10;
372 w.me.weapon = "usp";
373 w.me.ammo.usp = { mag: 12, reserve: 24 };
374 w.me.deployUntil = until;
375 }));
376 const rising = drawing(10.5);
377 expect(line(rising, "12 / 24").alpha).toBeCloseTo(NOT_READY_ALPHA, 10);
378 expect(line(rising, WEAPONS.usp.name).alpha).toBeCloseTo(NOT_READY_ALPHA, 10);
379 expect(line(rising, "switching").alpha).toBe(1);
380 const up = drawing(10);
381 expect(line(up, "12 / 24").alpha).toBe(1);
382 expect(line(up, WEAPONS.usp.name).alpha).toBe(1);
383
384 // Carrying the bomb is written beside the gun's name.
385 const carrier = draw(scene((w) => { w.me.team = "t"; w.me.hasBomb = true; }));
386 expect(says(carrier, `${WEAPONS.usp.name} · bomb`)).toBe(true);
387 });
388
389 // --- the clock and the score -----------------------------------------------
390
391 test("the clock counts the phase down, goes red when the bomb is down, and stops between rounds", () => {
392 const freeze = draw(scene((w) => { w.match.phase = "freeze"; w.match.timer = 8; }));
393 expect(line(freeze, "0:08").fill).toBe("#f3f5f8");
394
395 const live = draw(scene((w) => { w.match.phase = "live"; w.match.timer = 95; }));
396 expect(line(live, "1:35").fill).toBe("#f3f5f8");
397
398 const planted = draw(scene((w) => { w.match.phase = "planted"; w.match.timer = 40; }));
399 expect(line(planted, "0:40").fill).toBe("#ff6b5b");
400
401 // Nothing is being counted between rounds or after the match: no clock to read.
402 for (const phase of ["over", "match_over"] as const) {
403 const paint = draw(scene((w) => { w.match.phase = phase; w.match.timer = 5; }));
404 expect(says(paint, "--:--")).toBe(true);
405 expect(says(paint, "0:05")).toBe(false);
406 }
407 });
408
409 test("the score sits either side of the clock, each in its team's colour, and so do the survivors", () => {
410 const paint = draw(scene((w) => {
411 w.match.score = { t: 7, ct: 11 };
412 for (const p of w.players) if (p.team === "t" && p !== w.me) p.alive = false;
413 }));
414 expect(line(paint, "7").fill).toBe("#e2b04a");
415 expect(line(paint, "11").fill).toBe("#6fa8e8");
416 // Five terrorists, all of them down; five counter-terrorists, all still up.
417 expect(line(paint, "T 0").fill).toBe("#e2b04a");
418 expect(line(paint, "CT 5").fill).toBe("#6fa8e8");
419 });
420
421 // --- the kill feed ---------------------------------------------------------
422
423 const kill = (at: number, over: Partial<World["feed"][number]> = {}): World["feed"][number] => ({
424 at, killer: "Kestrel", victim: "Pike", weapon: "ak47", headshot: false, killerTeam: "t", killerHealth: 100, ...over,
425 });
426
427 test("a kill is named with the gun that made it, fades for eight seconds and is then gone", () => {
428 const fresh = draw(scene((w) => { w.time = 100; w.feed = [kill(100)]; }));
429 const entry = line(fresh, `Kestrel ${WEAPONS.ak47.name} Pike`);
430 expect(entry.alpha).toBe(1);
431 expect(entry.fill).toBe("#e2b04a");
432
433 // Halfway out it is half there, and a CT's kill is the other colour.
434 const fading = draw(scene((w) => {
435 w.time = 100;
436 w.feed = [kill(92.5, { killerTeam: "ct", headshot: true })];
437 }));
438 const faded = line(fading, `Kestrel ${WEAPONS.ak47.name} ⌖ Pike`);
439 expect(faded.alpha).toBeCloseTo(0.5, 10);
440 expect(faded.fill).toBe("#6fa8e8");
441
442 // Eight seconds old is the end of it: nothing readable, and nothing at all after.
443 const spent = draw(scene((w) => { w.time = 100; w.feed = [kill(92)]; }));
444 expect(readable(spent).some((c) => String(c.args[0]).includes("Kestrel"))).toBe(false);
445 const gone = draw(scene((w) => { w.time = 100; w.feed = [kill(91.9)]; }));
446 expect(gone.calls.some((c) => c.op === "fillText" && String(c.args[0]).includes("Kestrel"))).toBe(false);
447
448 // A fall is its own cause: the victim on both sides and the word, not a gun.
449 const fell = draw(scene((w) => { w.time = 100; w.feed = [kill(100, { killer: "Pike", weapon: "fall" })]; }));
450 expect(line(fell, "Pike Fall Pike").alpha).toBe(1);
451
452 // The feed shows the last five kills, newest last, oldest dropped.
453 const busy = draw(scene((w) => {
454 w.time = 100;
455 w.feed = [1, 2, 3, 4, 5, 6].map((n) => kill(99, { victim: `Down ${n}` }));
456 }));
457 expect(words(busy).filter((s) => s.includes("Down")).length).toBe(5);
458 expect(words(busy).some((s) => s.includes("Down 1"))).toBe(false);
459 expect(words(busy).some((s) => s.includes("Down 6"))).toBe(true);
460 });
461
462 test("the round's last word is on the screen for four seconds", () => {
463 const said = draw(scene((w) => {
464 w.time = 100;
465 w.messages = [{ at: 99.9, text: "The bomb has been planted at A." }];
466 }));
467 expect(says(said, "The bomb has been planted at A.")).toBe(true);
468
469 const stale = draw(scene((w) => {
470 w.time = 100;
471 w.messages = [{ at: 96, text: "The bomb has been planted at A." }];
472 }));
473 expect(says(stale, "The bomb has been planted at A.")).toBe(false);
474 });
475
476 // --- planting and defusing -------------------------------------------------
477
478 /** The filled fraction of the use bar: the bar's width over the panel behind it. */
479 function useBar(paint: Recorder): { fraction: number; fill: string } {
480 const rects = paint.calls.filter((c) => c.op === "fillRect");
481 const bar = rects[rects.length - 1] as Call;
482 const panel = rects[rects.length - 2] as Call;
483 return { fraction: Number(bar.args[2]) / (Number(panel.args[2]) - 4), fill: bar.fill };
484 }
485
486 test("the use bar fills as far as the plant or the defuse has got", () => {
487 const nothing = draw(scene());
488 expect(says(nothing, "Planting")).toBe(false);
489
490 const planting = draw(scene((w) => {
491 w.me.team = "t";
492 w.me.hasBomb = true;
493 w.bomb.plantProgress = SHORT.plantTime / 2;
494 }));
495 expect(says(planting, "Planting")).toBe(true);
496 expect(useBar(planting).fraction).toBeCloseTo(0.5, 10);
497 expect(useBar(planting).fill).toBe("#e2b04a");
498
499 const defusing = draw(scene((w) => {
500 w.bomb.state = "planted";
501 w.bomb.defuser = w.me;
502 w.bomb.defuseProgress = SHORT.defuseTime * 0.25;
503 }));
504 expect(says(defusing, "Defusing")).toBe(true);
505 expect(useBar(defusing).fraction).toBeCloseTo(0.25, 10);
506 expect(useBar(defusing).fill).toBe("#6fa8e8");
507
508 // A kit is faster, and says so, and the bar is full when the wire is cut.
509 const kit = draw(scene((w) => {
510 w.me.defuseKit = true;
511 w.bomb.state = "planted";
512 w.bomb.defuser = w.me;
513 w.bomb.defuseProgress = SHORT.defuseTimeWithKit;
514 }));
515 expect(says(kit, "Defusing (kit)")).toBe(true);
516 expect(useBar(kit).fraction).toBeCloseTo(1, 10);
517 });
518
519 // --- dead, and the scoreboard ----------------------------------------------
520
521 const ALONE = "You are dead — watching the round out";
522 const SWITCH = "fire — next player · aim — the one before";
523
524 /** A teammate to watch, and the world with you dead in it. */
525 function spectating(change: (world: World) => void = () => {}): { world: World; mate: Player } {
526 const world = scene((w) => {
527 w.me.alive = false;
528 change(w);
529 });
530 const mate = world.players.find((p) => p !== world.me && p.team === world.me.team) as Player;
531 return { world, mate };
532 }
533
534 test("a dead player watching nobody is told so, until the match itself is over", () => {
535 // The whole side down and the round still running — a planted bomb — is the
536 // only frame left where the camera is your own body and there is no name.
537 expect(says(draw(scene()), ALONE)).toBe(false);
538 expect(says(draw(scene((w) => { w.me.alive = false; })), ALONE)).toBe(true);
539 expect(says(draw(scene((w) => {
540 w.me.alive = false;
541 w.match.phase = "match_over";
542 })), ALONE)).toBe(false);
543 });
544
545 test("watching someone says whose eyes these are, in their team's colour, and how to change them", () => {
546 const { world, mate } = spectating();
547 const paint = draw(world, { view: mate });
548 expect(says(paint, `Watching ${mate.name}`)).toBe(true);
549 expect(says(paint, SWITCH)).toBe(true);
550 expect(says(paint, ALONE)).toBe(false);
551 expect(line(paint, `Watching ${mate.name}`).fill).toBe("#6fa8e8"); // this side is CT
552
553 // Alive, there is nothing to say: no banner, no keys.
554 const playing = draw(scene());
555 expect(words(playing).some((w) => w.startsWith("Watching"))).toBe(false);
556 expect(says(playing, SWITCH)).toBe(false);
557 });
558
559 test("dead, you are told who killed you, with what, and how much health they had left", () => {
560 const { world, mate } = spectating((w) => {
561 w.feed = [
562 kill(w.time, { killer: "Krait", victim: w.me.name, headshot: true, killerHealth: 23 }),
563 kill(w.time, { killer: "Kestrel", victim: "Pike" }), // somebody else's death is not yours
564 ];
565 });
566 const notice = `Krait killed you with ${WEAPONS.ak47.name} — headshot, 23 hp left`;
567 const watching = draw(world, { view: mate });
568 expect(says(watching, notice)).toBe(true);
569 expect(line(watching, notice).fill).toBe("#ff8f86");
570 // Over the panel, not on the name in it.
571 expect(line(watching, notice).args[2]).toBeLessThan(line(watching, `Watching ${mate.name}`).args[2] as number);
572 // With nobody left to watch it sits under the line that says so.
573 expect(says(draw(world), notice)).toBe(true);
574 // Alive, or with the match over, there is no notice.
575 world.me.alive = true;
576 expect(words(draw(world, { view: mate })).some((w) => w.includes("killed you"))).toBe(false);
577 world.me.alive = false;
578 world.match.phase = "match_over";
579 expect(words(draw(world, { view: mate })).some((w) => w.includes("killed you"))).toBe(false);
580 });
581
582 test("a death nobody else caused says how, and names no one's health", () => {
583 const fell = spectating((w) => {
584 w.feed = [kill(w.time, { killer: w.me.name, victim: w.me.name, weapon: "fall", killerTeam: "ct", killerHealth: null })];
585 });
586 const fallen = draw(fell.world, { view: fell.mate });
587 expect(says(fallen, "You fell too far")).toBe(true);
588 expect(words(fallen).some((w) => w.includes("hp left"))).toBe(false);
589
590 const bombed = spectating((w) => {
591 w.feed = [kill(w.time, { killer: w.me.name, victim: w.me.name, weapon: "he", killerTeam: "ct", killerHealth: null })];
592 });
593 const bomb = draw(bombed.world, { view: bombed.mate });
594 expect(says(bomb, `You killed yourself with ${WEAPONS.he.name}`)).toBe(true);
595 expect(words(bomb).some((w) => w.includes("hp left"))).toBe(false);
596
597 // No line with you in it, no notice at all.
598 const quiet = spectating();
599 expect(words(draw(quiet.world, { view: quiet.mate })).some((w) => w.includes("killed you"))).toBe(false);
600 });
601
602 test("the numbers under a spectated round are the watched player's, not your corpse's", () => {
603 const { world, mate } = spectating((w) => {
604 w.me.health = 89; // a corpse's last number, which is nobody's business now
605 w.me.money = 1234;
606 });
607 mate.health = 57;
608 mate.money = 4300;
609 mate.weapon = "ak47";
610 mate.ammo["ak47"] = { mag: 21, reserve: 60 };
611 const paint = draw(world, { view: mate });
612 expect(says(paint, "57")).toBe(true);
613 expect(says(paint, "$4300")).toBe(true);
614 expect(says(paint, "21 / 60")).toBe(true);
615 expect(says(paint, WEAPONS.ak47.name)).toBe(true);
616 // Nothing of yours is on the screen: not your health, not your wallet.
617 expect(says(paint, "89")).toBe(false);
618 expect(says(paint, "$1234")).toBe(false);
619
620 // And their crosshair is drawn, because *they* are aiming: a dead player's
621 // own view had none, which is what made the old spectate look broken.
622 expect(paint.calls.some((c) => c.op === "moveTo")).toBe(true);
623 });
624
625 test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => {
626 const world = scene((w) => {
627 const [me, second, third, fourth] = w.players;
628 if (!me || !second || !third || !fourth) throw new Error("this scene wants four players");
629 me.kills = 2; me.deaths = 1; me.headshots = 1; me.damage = 310.4; me.money = 3200;
630 second.kills = 5; second.damage = 700;
631 third.kills = 2; third.damage = 480; // same kills as me, more damage: above me
632 fourth.kills = 0; fourth.alive = false;
633 });
634 const paint = draw(world, { scoreboard: true });
635 const rows = readable(paint).filter((c) => c.align === "left" && String(c.args[0]).includes(" "));
636
637 const names = rows.map((c) => String(c.args[0]));
638 expect(names[0]).toContain(world.players[1]?.name);
639 expect(names[1]).toContain(world.players[2]?.name);
640 expect(names[2]).toContain("you");
641
642 // Your own row is the white one, and it is the only one that says so.
643 const mine = rows[2] as Call;
644 expect(mine.fill).toBe("#ffffff");
645 expect(names.filter((n) => n.includes("(you)")).length).toBe(1);
646 expect(rows.filter((r) => r.fill === "#ffffff").length).toBe(1);
647
648 // Every other row wears its team's colour, and a dead one is dimmed.
649 expect(rows.filter((r) => r.fill === "#e2b04a" || r.fill === "#6fa8e8").length).toBe(9);
650 const down = rows.find((r) => String(r.args[0]).includes(world.players[3]?.name ?? "")) as Call;
651 expect(down.alpha).toBeCloseTo(0.45, 10);
652 expect(mine.alpha).toBe(1);
653
654 // The columns are there, and your figures are under them, rounded.
655 expect(words(paint)).toContain("kills");
656 expect(words(paint)).toContain("damage");
657 const figures = readable(paint).filter((c) => c.align === "right");
658 expect(figures.map((c) => String(c.args[0]))).toContain("310");
659 expect(figures.map((c) => String(c.args[0]))).toContain("$3200");
660
661 // It is drawn over everything: its scrim comes after the health it covers.
662 const scrim = paint.calls.findIndex((c) => c.fill === "rgba(10, 12, 16, 0.88)");
663 const health = paint.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100");
664 expect(scrim).toBeGreaterThan(health);
665
666 // And nothing of it is on the screen when it is not being held open.
667 expect(says(draw(world), "kills")).toBe(false);
668 });
669
670 // --- both ends of the screen -----------------------------------------------
671
672 /** A frame with every panel on it at once, so a layout test has something to lay out. */
673 const busyWorld = (): World => scene((w) => {
674 w.time = 100;
675 w.match.phase = "planted";
676 w.match.timer = 37;
677 w.match.score = { t: 9, ct: 12 };
678 w.me.health = 24;
679 w.me.armor = 100;
680 w.me.helmet = true;
681 w.me.defuseKit = true;
682 w.me.money = 12450;
683 w.me.reloadUntil = 101;
684 w.bomb.state = "planted";
685 w.bomb.defuser = w.me;
686 w.bomb.defuseProgress = 1;
687 w.effects = these(
688 { kind: "hitmark", at: 99.95, headshot: true, killed: false },
689 // Somebody is shooting back, which is a wash and an arc on the same frame.
690 { kind: "hurt", at: 99.9, from: [w.me.pos[0] - 400, w.me.pos[1] + 250, 0], damage: 27 },
691 );
692 w.feed = [kill(99), kill(97, { killerTeam: "ct" }), kill(94, { headshot: true })];
693 w.messages = [{ at: 99.5, text: "The bomb has been planted at B." }];
694 for (const p of w.players) p.kills = p.id;
695 (w.players[4] as Player).alive = false;
696 });
697
698 /** Every call that put something over the edge of the canvas. */
699 function offCanvas(paint: Recorder, width: number, height: number): Call[] {
700 const on = (x: number, y: number): boolean => x >= 0 && y >= 0 && x <= width && y <= height;
701 return paint.calls.filter((c) => {
702 const [a, b, c3, d] = c.args.map(Number);
703 if (c.op === "fillText") return !on(Number(c.args[1]), Number(c.args[2]));
704 if (c.op === "moveTo" || c.op === "lineTo") return !on(a as number, b as number);
705 if (c.op === "arc") return !on((a as number) - (c3 as number), (b as number) - (c3 as number)) ||
706 !on((a as number) + (c3 as number), (b as number) + (c3 as number));
707 if (c.op === "fillRect" || c.op === "rect" || c.op === "clearRect")
708 return !on(a as number, b as number) || !on((a as number) + (c3 as number), (b as number) + (d as number));
709 return false;
710 });
711 }
712
713 const fontsUsed = (paint: Recorder): string[] =>
714 [...new Set(paint.calls.filter((c) => c.op === "fillText").map((c) => c.font))].sort();
715
716 test("the whole readout fits a phone and a desk, and says the same amount at both", () => {
717 const world = busyWorld();
718 const phone = draw(world, { width: 360, height: 640, scoreboard: true });
719 const desk = draw(world, { width: 1920, height: 1080, scoreboard: true });
720
721 expect(offCanvas(phone, 360, 640)).toEqual([]);
722 expect(offCanvas(desk, 1920, 1080)).toEqual([]);
723
724 // The scale is `width / 1280`, clamped to 0.7 .. 1.4: 21/13/11 px on a phone,
725 // 42/27/21 on a desk, and no other size on either.
726 expect(fontsUsed(phone)).toEqual([
727 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
728 ]);
729 expect(fontsUsed(desk)).toEqual([
730 "21px system-ui, sans-serif", "27px system-ui, sans-serif", "42px system-ui, sans-serif",
731 ]);
732
733 // The same world reads the same on both: every line of it, in the same order.
734 expect(words(phone)).toEqual(words(desk));
735 expect(phone.calls.length).toBe(desk.calls.length);
736 expect(phone.calls.map((c) => c.op)).toEqual(desk.calls.map((c) => c.op));
737
738 // And the frame starts by clearing the one it is drawn on, whatever its size.
739 expect(phone.calls[0]?.op).toBe("clearRect");
740 expect(phone.calls[0]?.args).toEqual([0, 0, 360, 640]);
741 });
742
743 test("the same canvas turned sideways is drawn at the new size, not the one before", () => {
744 // The engine's tools are built for one surface at one size and kept between
745 // frames, because a frame that rebuilt them would allocate for no new number.
746 // A phone rotating is the frame where a cache that ignored the size would
747 // draw the old one: same canvas, same world, different width.
748 const world = busyWorld();
749 const paint = recorder();
750 drawHud(paint, world, 1280, 720, false, world.me);
751 const before = paint.calls.length;
752 drawHud(paint, world, 360, 640, false, world.me);
753 const after = paint.calls.slice(before);
754
755 expect(after[0]?.args).toEqual([0, 0, 360, 640]);
756 expect([...new Set(after.filter((c) => c.op === "fillText").map((c) => c.font))].sort()).toEqual([
757 "11px system-ui, sans-serif", "13px system-ui, sans-serif", "21px system-ui, sans-serif",
758 ]);
759 // The clock bar is 240 units wide at the scale the *new* width says. It is
760 // the first filled rectangle that is not a full-screen wash — this world has
761 // somebody shooting back in it, and the pain flash goes on before anything.
762 // (The four narrow rectangles before it are the crosshair's arms.)
763 const bar = after.find((c) =>
764 c.op === "fillRect" && c.args[2] !== 360 && Number(c.args[2]) > 100) as Call;
765 expect(bar.args[2]).toBe(Math.round(240 * 0.7));
766 });
767
768 // --- flashed ---------------------------------------------------------------
769
770 /** Every full-screen wash this frame drew, with the brush that drew it. */
771 const washes = (paint: Recorder, width = WIDTH, height = HEIGHT): Call[] =>
772 paint.calls.filter((c) => c.op === "fillRect" && c.args[2] === width && c.args[3] === height);
773
774 /** The opacity of an `rgba(...)` fill, or 0 for anything else. */
775 const opacity = (fill: string): number => Number(/rgba\([^)]*,([^,)]+)\)$/.exec(fill)?.[1] ?? 0);
776
777 test("a flashed player gets white over the world, and the crosshair back before it ends", () => {
778 const max = 1.6;
779 // Unflashed there is no wash at all: this costs a frame nothing.
780 expect(washes(draw(scene()))).toEqual([]);
781
782 // The white is read off the simulation's own number, in seconds.
783 const full = washes(draw(scene((w) => { (w.me as Player).blind = max; })));
784 expect(full.length).toBe(1);
785 expect(opacity(full[0]?.fill ?? "")).toBeCloseTo(whiteout(max), 6);
786 // It never quite reaches one: the world is always a ghost behind it.
787 expect(whiteout(max)).toBeLessThan(1);
788 expect(whiteout(max)).toBeGreaterThan(0.85);
789
790 // It fades, and it is gone with the blindness.
791 expect(whiteout(0.45)).toBeLessThan(whiteout(0.9));
792 expect(whiteout(0.9)).toBe(whiteout(max)); // held at full while there is time left
793 expect(washes(draw(scene((w) => { (w.me as Player).blind = 0; })))).toEqual([]);
794
795 // The crosshair is drawn *under* the wash and is readable again for the last
796 // half second of any flash: at 0.45 s left the white is under a half.
797 expect(whiteout(0.45)).toBeLessThan(0.5);
798 const half = draw(scene((w) => { (w.me as Player).blind = 0.45; }));
799 const wash = washes(half)[0] as Call;
800 const crosshair = half.calls.findIndex((c) =>
801 c.op === "fillRect" && Number(c.args[0]) > WIDTH / 2 &&
802 Number(c.args[1]) <= HEIGHT / 2 && Number(c.args[1]) + Number(c.args[3]) >= HEIGHT / 2);
803 expect(crosshair).toBeGreaterThanOrEqual(0);
804 expect(half.calls.indexOf(wash)).toBeGreaterThan(crosshair);
805
806 // And the numbers go on top of it: a flashed player still reads their health.
807 const flashed = draw(scene((w) => { (w.me as Player).blind = max; }));
808 const health = flashed.calls.findIndex((c) => c.op === "fillText" && c.args[0] === "100");
809 expect(health).toBeGreaterThan(flashed.calls.indexOf(washes(flashed)[0] as Call));
810 });
811
812 // --- hurt: where it came from, and how hard ---------------------------------
813
814 /**
815 * Every damage arc this frame drew, as the angle each sits at *on the screen*:
816 * zero is straight ahead, ninety is off to your right and a hundred and eighty
817 * is behind you. The recorded call is a canvas angle — zero to the right of the
818 * middle, growing clockwise — so this is that, turned a quarter turn.
819 *
820 * Only the arcs: the scope's sight and the radar's dots go through `arc` too
821 * and are *filled*, so what is taken here is an arc in the hurt red that was
822 * stroked — the brush and the call that closed the path, both of which the
823 * recorder wrote down.
824 */
825 const arcs = (paint: Recorder): { at: number; alpha: number; radius: number }[] =>
826 paint.calls
827 .filter((c, i) =>
828 c.op === "arc" && c.stroke.startsWith("rgba(255,70,60") && paint.calls[i + 1]?.op === "stroke")
829 .map((c) => ({
830 at: turn(((Number(c.args[3]) + Number(c.args[4])) / 2) * (180 / Math.PI) + 90),
831 alpha: Number(/,([^,)]+)\)$/.exec(c.stroke)?.[1] ?? 0),
832 radius: Number(c.args[2]),
833 }));
834
835 /** An angle in degrees, folded into -180 .. 180. */
836 const turn = (degrees: number): number => ((((degrees + 180) % 360) + 360) % 360) - 180;
837
838 /** How far from straight ahead an arc is, whichever way round it went. */
839 const behindness = (at: number): number => Math.abs(at);
840
841 /** A world in which somebody at `from` has just shot you, while you face `yaw`. */
842 const shotAt = (from: [number, number], yaw: number, damage = 27, ago = 0.1): World =>
843 scene((w) => {
844 w.time = 10;
845 w.me.pos = [0, 0, 0];
846 w.me.yaw = yaw;
847 w.effects = only({ kind: "hurt", at: 10 - ago, from: [from[0], from[1], 0], damage });
848 });
849
850 test("the arc points at whoever hit you, and turning takes it with you", () => {
851 // Facing east. North is your left, so an attacker due north is drawn left.
852 const left = arcs(draw(shotAt([0, 500], 0)));
853 expect(left.length).toBe(1);
854 expect(left[0]?.at).toBeCloseTo(-90, 6);
855
856 // The same attacker, you turned ninety degrees to face them: dead ahead.
857 expect(arcs(draw(shotAt([0, 500], 90)))[0]?.at).toBeCloseTo(0, 6);
858 // And ninety the other way puts them behind you, which is the same ninety
859 // degrees of arc travel in the other direction.
860 expect(behindness(arcs(draw(shotAt([0, 500], -90)))[0]?.at ?? 0)).toBeCloseTo(180, 6);
861
862 // Shot in the back, facing east: an attacker due west is at the bottom.
863 expect(behindness(arcs(draw(shotAt([-500, 0], 0)))[0]?.at ?? 0)).toBeCloseTo(180, 6);
864 // Turn ninety degrees to your left and the arc walks exactly ninety degrees
865 // round the ring the other way: what was behind you is now off your left.
866 const turned = arcs(draw(shotAt([-500, 0], 90)))[0]?.at ?? 0;
867 expect(turned).toBeCloseTo(-90, 6);
868 expect(behindness(turn(180 - turned))).toBeCloseTo(90, 6);
869
870 // To your right, and nowhere near the crosshair: the ring is 28% of the
871 // shorter side out, which on this canvas is 201 pixels from the middle.
872 const right = arcs(draw(shotAt([0, -500], 0)));
873 expect(right[0]?.at).toBeCloseTo(90, 6);
874 expect(right[0]?.radius).toBeCloseTo(HEIGHT * 0.28, 6);
875 });
876
877 test("under reduced motion an arc holds its strength for its life and then goes", () => {
878 const steady = (ago: number) =>
879 arcs(draw(shotAt([-500, 0], 0, 27, ago), { motion: "reduced" }));
880 expect(steady(0)[0]?.alpha).toBeCloseTo(0.85, 3);
881 // Where the full setting has faded to half, this one has not moved.
882 expect(steady(HURT_LIFE / 2)[0]?.alpha).toBeCloseTo(0.85, 3);
883 expect(steady(HURT_LIFE - 0.01)[0]?.alpha).toBeCloseTo(0.85, 3);
884 // Its life is the same life: gone on the same tick.
885 expect(steady(HURT_LIFE)).toEqual([]);
886 // And the bearing is the bearing — only the fade was decoration.
887 expect(behindness(steady(0.1)[0]?.at ?? 0)).toBeCloseTo(180, 6);
888 });
889
890 test("an arc fades over its life and is gone at the end of it, and two attackers are two arcs", () => {
891 const fresh = arcs(draw(shotAt([-500, 0], 0, 27, 0)));
892 expect(fresh[0]?.alpha).toBeCloseTo(0.85, 3);
893
894 // Half way through its life it is half there.
895 const half = arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE / 2)));
896 expect(half[0]?.alpha).toBeCloseTo(0.425, 3);
897
898 // A hair inside its life is still drawn; the life itself is the end of it.
899 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE - 0.01))).length).toBe(1);
900 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE)))).toEqual([]);
901 expect(arcs(draw(shotAt([-500, 0], 0, 27, HURT_LIFE + 1)))).toEqual([]);
902
903 // Two people shooting you from two sides is two arcs, each at its own bearing.
904 const crossfire = arcs(draw(scene((w) => {
905 w.time = 10;
906 w.me.pos = [0, 0, 0];
907 w.me.yaw = 0;
908 w.effects = these(
909 { kind: "hurt", at: 9.9, from: [0, 500, 0], damage: 27 },
910 { kind: "hurt", at: 9.9, from: [0, -500, 0], damage: 27 },
911 );
912 })));
913 expect(crossfire.length).toBe(2);
914 expect(crossfire.map((a) => Math.round(a.at)).sort((a, b) => a - b)).toEqual([-90, 90]);
915
916 // Untouched, there is no arc at all: this costs a quiet frame nothing.
917 expect(arcs(draw(scene()))).toEqual([]);
918 });
919
920 test("a fall hurts from straight below: the pain flash says it, and there is no arc since it came from no side", () => {
921 // `shotAt` from where you stand is exactly what `land` records: your own feet.
922 const fell = draw(shotAt([0, 0], 0, 12, 0));
923 expect(arcs(fell)).toEqual([]);
924 expect(opacity(washes(fell)[0]?.fill ?? "")).toBeCloseTo(painAlpha(12, 0), 3);
925 });
926
927 test("the pain flash is as hard as the hit was, is gone in a quarter second, and is under the crosshair", () => {
928 // Nothing taken, nothing washed.
929 expect(washes(draw(scene()))).toEqual([]);
930
931 // An AK round to an armoured chest: a sixth of a red screen at its worst.
932 const round = draw(shotAt([-500, 0], 0, 27, 0));
933 expect(opacity(washes(round)[0]?.fill ?? "")).toBeCloseTo(painAlpha(27, 0), 3);
934 expect(painAlpha(27, 0)).toBeCloseTo(0.162, 3);
935
936 // A body-shot AWP is the whole of it, and half a body is already the cap: a
937 // hit that takes more than fifty does not wash the screen out any further.
938 expect(painAlpha(100, 0)).toBeCloseTo(0.3, 6);
939 expect(painAlpha(50, 0)).toBe(painAlpha(100, 0));
940 // It never comes near what a flashbang is allowed to do.
941 expect(painAlpha(100, 0)).toBeLessThan(whiteout(1.6) / 3);
942
943 // It fades, and a third of a second is the end of it.
944 expect(painAlpha(100, 0.125)).toBeCloseTo(0.15, 6);
945 expect(painAlpha(100, 0.25)).toBe(0);
946 expect(washes(draw(shotAt([-500, 0], 0, 100, 0.25)))).toEqual([]);
947 // The arc outlives it by a long way: the wash says *ouch* and the arc says
948 // where, and only one of those is worth having over your sights.
949 expect(arcs(draw(shotAt([-500, 0], 0, 100, 0.25))).length).toBe(1);
950
951 // And the crosshair goes on *after* it, so the shot you take back is aimed
952 // at exactly what it was aimed at before — the opposite way round from the
953 // white-out, which is meant to blind you.
954 const wash = washes(round)[0] as Call;
955 expect(round.calls.indexOf(wash)).toBeLessThan(round.calls.findIndex((c) => c.op === "stroke"));
956
957 // A spectator is not the one being shot at: a dead player watching a mate
958 // gets neither the wash nor the arc, whatever your corpse last took.
959 const { world, mate } = spectating((w) => {
960 w.time = 10;
961 w.effects = only({ kind: "hurt", at: 9.95, from: [-500, 0, 0], damage: 100 });
962 });
963 const watching = draw(world, { view: mate });
964 expect(washes(watching)).toEqual([]);
965 expect(arcs(watching)).toEqual([]);
966 });
967
968 test("the pocket line names each grenade you are carrying, and both at once", () => {
969 const none = draw(scene());
970 expect(words(none).some((t) => t.includes("HE"))).toBe(false);
971 const both = draw(scene((w) => {
972 (w.me as Player).pocket = { he: 1, flash: 1, smoke: 0 };
973 }));
974 const named = words(both).find((t) => t.includes("HE")) ?? "";
975 expect(named).toContain("HE");
976 expect(named).toContain("flash");
977 const only = draw(scene((w) => { (w.me as Player).pocket = { he: 0, flash: 1, smoke: 0 }; }));
978 const line = words(only).find((t) => t.includes("flash")) ?? "";
979 expect(line).not.toContain("HE");
723980 });
src/client/hud.ts +439 −565
@@ -1,669 +1,543 @@
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 { rankPlayers } from "./summary.ts";
23 import { inaccuracy, WEAPONS } from "./weapons.ts";
2428 import {
25 canSee, causeName, currentWeapon, deploying, eyePos, grenadeCount, grenadeKinds, offeredPickup, useProgress,
26 type Player, type World,
27 } from "./world.ts";
28
29 const T_COLOR = "#e2b04a";
30 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";
3140
3241 /**
33 * How long a damage indicator lives. Well inside the two seconds `update`
34 * 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.
3546 */
36 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 };
3754
38 /** How long a round message stays up when the clock is not holding it there. */
39 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;
4061
4162 /**
4263 * What is left of the ammo counter and the weapon's name while the gun is
43 * still coming up. The gun rising in the middle of the screen is the cue; this
44 * is the one that says what it means, because a person who clicks and gets
45 * nothing for the 1.25 s an AWP takes should not have to guess why. It lifts
46 * on the frame `nextShotAt` passes — the frame the gun is level and the
47 * 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.
4866 */
49 const NOT_READY_ALPHA = 0.35;
67 export const NOT_READY_ALPHA = 0.35;
5068
5169 /**
5270 * How long a hit marker lives, and how much longer the one for a kill does:
53 * long enough to register as its own thing at a glance, still well inside the
54 * 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.
5573 */
56 const HIT_MARK_LIFE = 0.25;
57 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]];
77 /** A blocked round's mark: the hit marker's four ticks turned onto the axes. */
78 const PLUS_ARMS: [number, number][] = [[1, 0], [-1, 0], [0, 1], [0, -1]];
5879
59 // The vignette is one radial gradient per canvas size, kept between frames:
60 // building it costs an allocation and drawHud runs sixty times a second, so it
61 // is built with its colours at full strength and faded with globalAlpha.
62 let vignette: CanvasGradient | null = null;
63 let vignetteSize = "";
80 /** What a dead player is told, in the two cases there are. */
81 const DEAD = "You are dead — watching the round out";
82 const WATCHING = "Watching";
83 const SWITCH = "fire — next player · aim — the one before";
6484
65 function hurtVignette(ctx: CanvasRenderingContext2D, width: number, height: number): CanvasGradient {
66 const size = `${width}x${height}`;
67 if (vignette === null || vignetteSize !== size) {
68 const gradient = ctx.createRadialGradient(
69 width / 2, height / 2, Math.min(width, height) * 0.22,
70 width / 2, height / 2, Math.max(width, height) * 0.62,
71 );
72 gradient.addColorStop(0, "rgba(140, 0, 0, 0)");
73 gradient.addColorStop(0.55, "rgba(160, 6, 6, 0.4)");
74 gradient.addColorStop(1, "rgba(190, 14, 14, 0.95)");
75 vignette = gradient;
76 vignetteSize = size;
85 /** The colour of the line that says who killed you. */
86 const NOTICE = "#ff8f86";
87
88 /**
89 * The line a dead player is shown about their own death: who, with what, and
90 * how much health the killer had left then, so a lost duel says whether you
91 * were outplayed or one bullet away. It is the newest line in the feed with you
92 * as the victim — the feed is names, and a room keeps its names unique — and
93 * null when there is none. A death nobody else caused names no one's health.
94 */
95 export function deathLine(world: World, me: Player): string | null {
96 for (let i = world.feed.length - 1; i >= 0; i--) {
97 const entry = world.feed[i];
98 if (entry === undefined || entry.victim !== me.name) continue;
99 if (entry.weapon === "fall") return "You fell too far";
100 const by = entry.killer === me.name ? "You killed yourself" : `${entry.killer} killed you`;
101 const hp = entry.killerHealth === null ? "" : `, ${Math.round(clamp(entry.killerHealth, 0, 100))} hp left`;
102 return `${by} with ${causeName(entry.weapon)}${entry.headshot ? " — headshot" : ""}${hp}`;
77103 }
78 return vignette;
104 return null;
105 }
106
107 /** The scoreboard's heading row, and the empty other half of each `columns` call. */
108 const FIGURES = ["kills", "deaths", "hs", "damage", "money"] as const;
109 const NOTHING: readonly string[] = [];
110
111 /**
112 * The tools, kept from frame to frame. `overlay` is built for one surface at
113 * one size — a resize, not a frame — and a frame that rebuilt it would allocate
114 * an object and ten closures sixty times a second for no new number. The cache
115 * is one entry deep and keyed on what it was built from, so a test drawing onto
116 * a fresh recorder gets fresh tools and never another test's.
117 */
118 let tools: Overlay | null = null;
119 let builtOn: Paint | null = null;
120
121 function toolsFor(ctx: Paint, width: number, height: number, dpr: number): Overlay {
122 if (
123 tools === null || builtOn !== ctx ||
124 tools.width !== width || tools.height !== height || tools.ratio !== ratioOf(dpr)
125 ) {
126 tools = overlay(ctx, width, height, dpr);
127 builtOn = ctx;
128 }
129 return tools;
79130 }
80131
81132 /**
82 * The wedges around the crosshair pointing at whatever just hurt `view`, in its
83 * own yaw frame, and the red vignette that deepens as its health drops. Several
84 * hits stack: each is its own wedge, fading on its own clock. A spectator sees
85 * neither: `hurt` effects are only ever pushed for the person, so a teammate's
86 * id matches nothing here.
133 * The white-out, as opacity: how much white is over the world with `blind`
134 * seconds of a flash left to run —
87135 *
88 * `steady` is reduced motion, decided once by `drawHud` and passed down rather
89 * than asked per wedge. It takes the fade off a wedge and nothing else: where
90 * it came from and how hard it hit are what a wedge says, and a strength that
91 * slides to nothing over a second is only how it leaves. The vignette is the
92 * same picture on both settings by construction — there is no clock anywhere
93 * in it, only `view.health`, so it is already the steady tint reduced motion
94 * would ask for, and deepening as you are shot is information rather than
95 * decoration.
96 */
97 function drawDamage(
98 ctx: CanvasRenderingContext2D,
99 world: World,
100 view: Player,
101 width: number,
102 height: number,
103 steady: boolean,
104 ): void {
105 const low = clamp((55 - view.health) / 55, 0, 1);
106 if (view.alive && low > 0) {
107 ctx.globalAlpha = 0.65 * low * low;
108 ctx.fillStyle = hurtVignette(ctx, width, height);
109 ctx.fillRect(0, 0, width, height);
110 ctx.globalAlpha = 1;
111 }
112
113 const eye = eyePos(view);
114 const radius = Math.min(width, height) * 0.14;
115 for (const effect of world.effects) {
116 if (effect.kind !== "hurt" || effect.victim !== view.id) continue;
117 const age = world.time - effect.at;
118 if (age < 0 || age > HURT_FADE) continue;
119 // A fall hurts from straight below: no bearing, so no wedge — the
120 // vignette and the flinch are the whole of it.
121 if (Math.hypot(effect.from[0] - eye[0], effect.from[1] - eye[1]) < 1) continue;
122 const bearing = (Math.atan2(effect.from[1] - eye[1], effect.from[0] - eye[0]) * 180) / Math.PI;
123 // Yaw grows anticlockwise, so a source to the left is a positive angle off
124 // the view; the canvas turns clockwise from +x, so the wedge sits at minus
125 // that angle, a quarter turn back from straight up.
126 const middle = rad(-angleDelta(view.yaw, bearing)) - Math.PI / 2;
127 const half = rad(14 + clamp(effect.amount, 0, 60) * 0.2);
128 const fade = steady ? 1 : clamp(1 - age / HURT_FADE, 0, 1);
129 ctx.globalAlpha = fade * clamp(0.35 + effect.amount / 50, 0, 0.9);
130 ctx.strokeStyle = "#ff4238";
131 ctx.lineWidth = Math.max(3, Math.round(radius * 0.13));
132 ctx.beginPath();
133 ctx.arc(width / 2, height / 2, radius, middle - half, middle + half);
134 ctx.stroke();
135 }
136 ctx.globalAlpha = 1;
137 }
138
139 const clock = (seconds: number): string => {
140 const s = Math.max(0, Math.ceil(seconds));
141 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
142 };
143
144 // The radar's map is a few hundred walkable cells, the same every frame: baked
145 // once into an offscreen canvas at the on-screen size and rotated by the
146 // canvas transform as the view turns, instead of walked and re-rotated by
147 // hand sixty times a second. Rebuilt only when the size or the map changes,
148 // same as the vignette above — keyed by the map itself, so a switch to a
149 // second layout can never draw the first one's walls.
150 let radarMap: OffscreenCanvas | HTMLCanvasElement | null = null;
151 let radarMapSize = -1;
152 let radarMapFor: MapDef | null = null;
153
154 function radarMapImage(map: MapDef, size: number): CanvasImageSource | null {
155 if (radarMap === null || radarMapSize !== size || radarMapFor !== map) {
156 if (typeof OffscreenCanvas !== "undefined") radarMap = new OffscreenCanvas(size, size);
157 else if (typeof document !== "undefined") {
158 const canvas = document.createElement("canvas");
159 canvas.width = size;
160 canvas.height = size;
161 radarMap = canvas;
162 } else return null; // no canvas to build one on, e.g. a headless test
163 const rctx = radarMap.getContext("2d") as CanvasRenderingContext2D;
164 const cw = size / map.gridW, ch = size / map.gridH;
165 rctx.fillStyle = "rgba(190, 198, 212, 0.55)";
166 for (let r = 0; r < map.gridH; r++)
167 for (let c = 0; c < map.gridW; c++)
168 if (walkable(map, c, r)) rctx.fillRect(c * cw, r * ch, cw + 0.5, ch + 0.5);
169 radarMapSize = size;
170 radarMapFor = map;
171 }
172 return radarMap;
173 }
174
175 /**
176 * The radar: `view`'s map, rotated so its yaw points up, in a circle at
177 * `(x, y)` of `size` pixels across. Only ever `view`'s team, the bomb once
178 * it is planted, and the two sites — the data `buildRadar` hands back, with
179 * no enemy in it to draw even by mistake.
180 */
181 function drawRadar(
182 ctx: CanvasRenderingContext2D,
183 world: World,
184 view: Player,
185 x: number,
186 y: number,
187 size: number,
188 ): void {
189 const radius = size / 2;
190 const cx = x + radius, cy = y + radius;
191 ctx.fillStyle = "rgba(8, 10, 14, 0.6)";
192 ctx.beginPath();
193 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
194 ctx.fill();
195
196 ctx.save();
197 ctx.beginPath();
198 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
199 ctx.clip();
200 const image = radarMapImage(world.map, Math.max(1, Math.round(size)));
201 if (image !== null) {
202 // The image is baked with north up; turning it to match the view's yaw
203 // is one canvas rotation, not a re-walk of the grid.
204 ctx.save();
205 ctx.translate(cx, cy);
206 ctx.rotate(rad(view.yaw - 90));
207 ctx.drawImage(image, -radius, -radius, size, size);
208 ctx.restore();
209 }
210
211 const teamColor = view.team === "t" ? T_COLOR : CT_COLOR;
212 ctx.textAlign = "center";
213 for (const blip of buildRadar(world, view)) {
214 const px = cx + blip.x * radius, py = cy + blip.y * radius;
215 if (blip.kind === "site") {
216 ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
217 ctx.beginPath();
218 ctx.arc(px, py, Math.max(3, radius * 0.05), 0, Math.PI * 2);
219 ctx.fill();
220 ctx.font = `${Math.round(radius * 0.16)}px system-ui, sans-serif`;
221 ctx.fillText(blip.label, px, py - radius * 0.09);
222 } else if (blip.kind === "bomb") {
223 ctx.fillStyle = "#ff6b5b";
224 ctx.beginPath();
225 ctx.arc(px, py, Math.max(3, radius * 0.06), 0, Math.PI * 2);
226 ctx.fill();
227 } else if (blip.kind === "mate") {
228 ctx.fillStyle = teamColor;
229 ctx.beginPath();
230 ctx.arc(px, py, Math.max(2, radius * 0.045), 0, Math.PI * 2);
231 ctx.fill();
232 } else {
233 // self: an arrow, not a dot, pointing at `facing` — always straight up,
234 // since the whole radar is already turned to match the view's yaw.
235 const r = Math.max(4, radius * 0.08);
236 const dir = blip.facing - Math.PI / 2;
237 ctx.fillStyle = "#ffffff";
238 ctx.beginPath();
239 ctx.moveTo(px + Math.cos(dir) * r, py + Math.sin(dir) * r);
240 ctx.lineTo(px + Math.cos(dir + 2.4) * r * 0.6, py + Math.sin(dir + 2.4) * r * 0.6);
241 ctx.lineTo(px + Math.cos(dir - 2.4) * r * 0.6, py + Math.sin(dir - 2.4) * r * 0.6);
242 ctx.closePath();
243 ctx.fill();
244 }
245 }
246 ctx.restore();
247
248 ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
249 ctx.lineWidth = 2;
250 ctx.beginPath();
251 ctx.arc(cx, cy, radius, 0, Math.PI * 2);
252 ctx.stroke();
253 }
254
255 /** A plate is full strength this close and at its faintest by here. */
256 const PLATE_NEAR = 400;
257 const PLATE_FAR = 3000;
258 /** How far above the top of the hull the plate floats, in world units. */
259 const PLATE_LIFT = 14;
260
261 /**
262 * A name over every player the eye can actually see: in front of it, inside
263 * the frustum, traced clear of the level's boxes, and alive. That list is the
264 * whole point — a name drawn through a wall is a wallhack — so the test that
265 * puts a box in the way is the one that matters, and the trace is `canSee`,
266 * the same eye-to-eye geometry the bots shoot by.
136 * alpha(b) = PEAK · min(1, b / WHITEOUT_FADE)
267137 *
268 * Teammates carry their health, in your side's colour; an enemy is a name and
269 * nothing else. Both fade with distance and are clamped in size, so the far
270 * side of the map is a hint rather than a billboard. The geometry is
271 * `project.ts`'s: this function only decides what a plate looks like.
138 * Two deliberate limits, because a full-screen white-out is the easiest thing
139 * in this game to make unplayable. It never reaches 1, so the world is always a
140 * ghost behind it rather than gone. And it falls to nothing over the last
141 * `WHITEOUT_FADE` seconds, so the crosshair under it is readable again once
142 * alpha drops under about a half — at `b = 0.49 s` — whatever the flash was
143 * worth. The longest a flash can be is `FLASH.maxBlind`, 1.6 seconds, of which
144 * at most the first 0.7 is full white.
272145 */
273 function drawPlates(
274 ctx: CanvasRenderingContext2D,
275 world: World,
276 view: Player,
277 camera: Camera,
278 width: number,
279 height: number,
280 scale: number,
281 alpha: number,
282 ): void {
283 const plates: { player: Player; at: Projected }[] = [];
284 const blend: Vec3 = [0, 0, 0];
285 for (const p of world.players) {
286 if (p === view || !p.alive) continue;
287 if (!canSee(world, view, p)) continue; // one trace per player per frame
288 // Over the head the renderer drew, not the head the last tick left: a
289 // plate that sat on the newest tick would swim above a body drawn between
290 // two of them. `alpha` is the same number the scene was drawn with.
291 lerpPos(p.prev.pos, p.pos, alpha, blend);
292 const head: [number, number, number] = [blend[0], blend[1], blend[2] + hullHeight(p) + PLATE_LIFT];
293 const at = project(head, camera, width, height);
294 if (at !== null) plates.push({ player: p, at });
295 }
296 // Furthest first, so the near one is the one you read where two overlap.
297 plates.sort((a, b) => b.at.depth - a.at.depth);
146 export const whiteout = (blind: number): number => PEAK * Math.min(1, blind / WHITEOUT_FADE);
298147
299 ctx.textAlign = "center";
300 const teamColor = view.team === "t" ? T_COLOR : CT_COLOR;
301 for (const { player, at } of plates) {
302 // 15px at 900 units, shrinking with the perspective divide the way the
303 // player under it does — but clamped both ways, so a plate is never a
304 // headline in your face nor a line of pixels you cannot read.
305 const size = Math.round(clamp((15 * 900 * scale) / at.depth, 9 * scale, 17 * scale));
306 const mate = player.team === view.team;
307 ctx.globalAlpha = clamp(1 - (at.depth - PLATE_NEAR) / (PLATE_FAR - PLATE_NEAR), 0.16, 1);
308 ctx.font = `${size}px system-ui, sans-serif`;
309 const nameWidth = ctx.measureText(player.name).width;
310 ctx.fillStyle = "rgba(10, 12, 16, 0.42)";
311 ctx.fillRect(at.x - nameWidth / 2 - size * 0.3, at.y - size, nameWidth + size * 0.6, size * 1.25);
312 ctx.fillStyle = mate ? teamColor : "#eef0f5";
313 ctx.fillText(player.name, at.x, at.y);
314 if (mate) {
315 // Health as a bar under the name: a number to read and a length to
316 // glance at, which is what "is this one worth trading for" asks.
317 const fraction = clamp(player.health / 100, 0, 1);
318 const barW = Math.round(size * 3), barH = Math.max(2, Math.round(size * 0.18));
319 const barY = at.y + size * 0.4;
320 ctx.fillStyle = "rgba(10, 12, 16, 0.5)";
321 ctx.fillRect(at.x - barW / 2, barY, barW, barH);
322 ctx.fillStyle = fraction > 0.3 ? "#7ddc8a" : "#ff6b5b";
323 ctx.fillRect(at.x - barW / 2, barY, barW * fraction, barH);
324 // The number keeps the bar's colour: one glance says how much and
325 // whether it is the kind of much you go and help with.
326 ctx.font = `${Math.max(8, Math.round(size * 0.78))}px system-ui, sans-serif`;
327 ctx.fillText(String(Math.max(0, Math.round(player.health))), at.x, barY + barH + size * 0.78);
328 }
329 }
330 ctx.globalAlpha = 1;
148 /** The most white there ever is, and the seconds it takes to clear. */
149 const PEAK = 0.92;
150 const WHITEOUT_FADE = 0.9;
151
152 /**
153 * Taking damage, on the screen, is two things with two clocks.
154 *
155 * **The arc** says where it came from: a stroke on a ring around the middle of
156 * the view, in the direction of whoever hit you *relative to where you are
157 * facing now*, so it swings as you turn and sits still if you do not. It lives
158 * `HURT_LIFE` seconds and fades linearly to nothing over them — long enough to
159 * read while you are still turning, short enough that it is never telling you
160 * about a fight that is over. Every live hit draws its own, so two people
161 * shooting you from two sides is two arcs and not an average of them.
162 *
163 * **The flash** says how hard: a red wash whose opacity is the damage over
164 * `PAIN_FULL` — half a body — capped, and gone `PAIN_LIFE` seconds later.
165 *
166 * The numbers are meant to be argued with, so here they are out loud. An AK
167 * round to an armoured chest at 500 units is 27 damage, which peaks at
168 * `0.3 · 27/50 ≈ 0.16` of red; an AWP body shot is 111 and peaks at the full
169 * 0.3. A quarter of a second at a third of a colour is a hit you cannot miss
170 * and nothing you have to wait out: an M4 fires every 0.09 s and an AWP every
171 * 1.46, so the flash is over before your next shot either way, and the worst of
172 * it is a third of what `whiteout` above is allowed to do — a flashbang is
173 * meant to take the round off you and a bullet is not.
174 *
175 * And it draws *under* the crosshair, which is the one rule a full-screen wash
176 * on an aiming game has to keep: the crosshair goes on after it, at its own
177 * colour, so the shot you take back is aimed at exactly what it was aimed at
178 * before. `whiteout` is the deliberate other way round.
179 */
180 export const HURT_LIFE = 1.25;
181 const PAIN_LIFE = 0.25;
182 const PAIN_FULL = 50;
183 const PAIN_PEAK = 0.3;
184
185 /** How much red is over the world from one hit of `damage`, `age` seconds on. */
186 export const painAlpha = (damage: number, age: number): number =>
187 age < 0 || age >= PAIN_LIFE ? 0 : PAIN_PEAK * Math.min(1, damage / PAIN_FULL) * (1 - age / PAIN_LIFE);
188
189 /** The red it washes in, and the red the arcs are stroked in. */
190 const PAIN = "190,30,26";
191 const HURT = "255,70,60";
192
193 /** How far out the arcs sit as a fraction of the shorter side, and how wide each is. */
194 const HURT_RADIUS = 0.28;
195 const HURT_HALF = 0.34; // radians: an arc about 39° across
196 const HURT_ALPHA = 0.85;
197
198 /**
199 * Where a hit at `from` sits on that ring, for a body at `at` facing `yaw`, as
200 * the angle `ctx.arc` wants: zero to the right of the middle of the screen,
201 * growing clockwise. Straight ahead is the top of the ring and behind you is
202 * the bottom, so turning ninety degrees walks the arc ninety degrees round it.
203 *
204 * Height is dropped on purpose: this is a bearing you turn by, and a shot from
205 * a floor above is still a shot from over there.
206 */
207 export function hurtAngle(yaw: number, from: Vec3, at: Vec3): number {
208 const bearing = (Math.atan2(from[1] - at[1], from[0] - at[0]) * 180) / Math.PI;
209 // `angleDelta` counts counter-clockwise, which on the ground is to your left;
210 // the screen's angles run the other way, and that is the minus sign.
211 return -Math.PI / 2 - (angleDelta(yaw, bearing) * Math.PI) / 180;
331212 }
332213
214 /**
215 * How much grey is over the world when the eye is inside a cloud of smoke.
216 *
217 * It is a constant and not a ramp, on purpose: the simulation's answer inside a
218 * cloud is a flat *no* — `canSee` refuses a line that meets the ball at all, so
219 * a bot one unit inside one is as blind as a bot in the middle — and a wash
220 * that faded with depth would be the screen disagreeing with the round. Like
221 * the white-out above it stops short of 1, so the crosshair and the world are a
222 * ghost behind it rather than gone; unlike the white-out it does not fade,
223 * because a cloud does not fade while you are standing in it.
224 *
225 * From outside, a cloud is a ball `scene.ts` places and this draws nothing at
226 * all. The two are the same fact from the two sides of one surface.
227 */
228 export const FOG = 0.9;
229
230 /**
231 * Paint the HUD for this frame. `view` is whose eyes the camera is in — you, or
232 * the player you are watching once you are dead — and it is what every number
233 * about a body is read off: the crosshair that opens as far as *their* gun is
234 * inaccurate, their health, their armour, their magazine, their defuse bar. The
235 * round, the feed and the scoreboard belong to the match and are the same from
236 * anybody's eyes. Left out, the view is you, which is every frame of a round
237 * you are alive in.
238 *
239 * `width` and `height` are the canvas's backing store — device pixels, not CSS
240 * pixels — and `dpr` is how many of those go to a CSS pixel, so that the
241 * crosshair is the same size on the glass and exact on the grid at 125% and
242 * 150% as well as at 100%. Left out it is 1, which is what every test draws at.
243 */
333244 export function drawHud(
334 ctx: CanvasRenderingContext2D,
245 ctx: Paint,
335246 world: World,
336 view: Player,
337 camera: Camera,
338247 width: number,
339248 height: number,
340249 showScoreboard: boolean,
341 crosshair: CrosshairStyle = DEFAULT_CROSSHAIR,
342 alpha = 1,
250 view: Player = world.me,
251 dpr = 1,
343252 motion: Motion = DEFAULT_MOTION,
344253 ): void {
254 const ui = toolsFor(ctx, width, height, dpr);
345255 ctx.clearRect(0, 0, width, height);
346256 ctx.textBaseline = "alphabetic";
347 // One value, read once a frame, the way the view and the crosshair are:
348 // what it turns down is decided here and handed on, never re-decided per
349 // element further down.
350 const steady = motion === "reduced";
351 const spectating = view !== world.me;
352 const over = world.match.phase === "match_over";
353257 const weapon = currentWeapon(view);
354 const pad = Math.max(12, Math.round(width * 0.02));
258 const pad = ui.pad;
259 // Damage is something a body takes, so the indicator is drawn only from the
260 // eyes of the body that took it: yours, while you are standing. A spectator
261 // is not being shot at, and a corpse is not turning to face anything.
262 const hurting = view === world.me && view.alive;
355263
356 // --- being shot: where it came from, and how close to dead it left us
357 drawDamage(ctx, world, view, width, height, steady);
264 // --- the pain flash, first of everything and under the crosshair. The
265 // strongest hit still on the clock decides it rather than the sum of them:
266 // two rifle rounds in one tick is one hit's worth of red and not two, which
267 // is what keeps a burst from washing the screen out.
268 if (hurting) {
269 let pain = 0;
270 for (let i = 0; i < world.effects.count; i++) {
271 const effect = effectAt(world.effects, i);
272 if (effect === undefined || effect.kind !== "hurt") continue;
273 pain = Math.max(pain, painAlpha(effect.damage, world.time - effect.at));
274 }
275 if (pain > 0) {
276 ctx.fillStyle = `rgba(${PAIN},${pain.toFixed(3)})`;
277 ctx.fillRect(0, 0, width, height);
278 }
279 }
358280
359 // --- crosshair, opening up exactly as much as the gun is inaccurate — as
360 // far as the player's own style lets it: what the arms look like is
361 // crosshair.ts's, and the only thing decided here is the spread they answer
362 // to. A colour is a name that module resolves, never a string from storage.
363 if (view.alive && !spectating && !over) {
281 // --- crosshair, opening up exactly as much as the gun is inaccurate
282 if (view.alive) {
364283 const speed = Math.hypot(view.vel[0], view.vel[1]);
365284 const spread = inaccuracy(weapon, {
366 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
285 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
367286 inAir: !view.onGround,
368287 ducking: view.duckAmount > 0.5,
369288 scoped: view.scoped,
370289 });
371 const shape = crosshairShape(crosshair, spread, width / 2, height / 2);
372 ctx.strokeStyle = shape.colour;
373 ctx.lineWidth = shape.thickness;
374 ctx.beginPath();
375 for (const segment of shape.segments) {
376 ctx.moveTo(segment.x1, segment.y1);
377 ctx.lineTo(segment.x2, segment.y2);
378 }
379 ctx.stroke();
380 if (shape.dot !== null) {
381 ctx.fillStyle = shape.colour;
290 // The gap and the arm in CSS pixels, turned into whole device pixels here:
291 // the cross is the same size on the glass at 100%, 125% and 200%, and every
292 // edge of it is a pixel's edge. `px` never rounds down to nothing, so the
293 // tightest scoped crosshair is still a cross.
294 ui.crosshair(width / 2, height / 2, ui.px(4 + spread * 14), ui.px(7), CROSSHAIR);
295 if (view.scoped) {
296 ctx.fillStyle = "rgba(0,0,0,0.55)";
382297 ctx.beginPath();
383 ctx.arc(shape.dot.x, shape.dot.y, shape.dot.radius, 0, Math.PI * 2);
384 ctx.fill();
298 ctx.rect(0, 0, width, height);
299 ctx.arc(width / 2, height / 2, Math.min(width, height) * 0.36, 0, Math.PI * 2, true);
300 ctx.fill("evenodd");
385301 }
386302 }
387 // The scope mask belongs to the camera, not to the crosshair: a spectator
388 // watching a scoped teammate is looking down that scope's field of view.
389 if (view.alive && view.scoped) {
390 ctx.fillStyle = "rgba(0,0,0,0.55)";
391 ctx.beginPath();
392 ctx.rect(0, 0, width, height);
393 ctx.arc(width / 2, height / 2, Math.min(width, height) * 0.36, 0, Math.PI * 2, true);
394 ctx.fill("evenodd");
395 }
396303
397 // --- hit markers, which are the person's own hits and nobody else's: a
398 // spectator never inherits the ones the corpse left behind.
399 if (!spectating && view.alive) {
400 for (const effect of world.effects) {
401 if (effect.kind !== "hitmark") continue;
402 const age = world.time - effect.at;
403 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
404 // A round a teammate stopped is not a hit, and does not look like one:
405 // four ticks on the axes, a plus rather than an X, in your side's colour.
406 if (effect.friendly === true) {
407 ctx.strokeStyle = world.me.team === "t" ? T_COLOR : CT_COLOR;
408 ctx.lineWidth = 2;
409 const r = 9 + age * 20;
410 ctx.beginPath();
411 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
412 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
413 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
414 }
415 ctx.stroke();
416 continue;
417 }
418 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
419 ctx.lineWidth = effect.killed ? 3 : 2;
420 const r = 9 + age * 20;
304 // --- hit markers. Four arms like the crosshair's, but on the diagonals and
305 // growing as the mark ages, which is not a shape the engine hands out.
306 for (let i = 0; i < world.effects.count; i++) {
307 const effect = effectAt(world.effects, i);
308 if (effect === undefined || effect.kind !== "hitmark") continue;
309 const age = world.time - effect.at;
310 if (age > (effect.killed ? KILL_MARK_LIFE : HIT_MARK_LIFE)) continue;
311 // A round a teammate stopped is not a hit and does not look like one: four
312 // ticks on the axes, a plus rather than an X, in your side's colour.
313 if (effect.friendly === true) {
314 ctx.strokeStyle = world.me.team === "t" ? T_COLOR : CT_COLOR;
315 ctx.lineWidth = ui.px(2);
316 const r = ui.px(9 + age * 20);
421317 ctx.beginPath();
422 for (const [dx, dy] of [[1, 1], [-1, -1], [1, -1], [-1, 1]]) {
318 for (const [dx, dy] of PLUS_ARMS) {
423319 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
424320 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
425321 }
426 // The one that finished them closes the four ticks into an X through the
427 // middle — two more strokes on the same path, heavier and held longer,
428 // so a kill is a different shape and not a shade of the same one. A
429 // headshot kill is that X in the headshot's red.
430 if (effect.killed)
431 for (const [dx, dy] of [[1, 1], [1, -1]]) {
432 ctx.moveTo(width / 2 - dx * r * 0.45, height / 2 - dy * r * 0.45);
433 ctx.lineTo(width / 2 + dx * r * 0.45, height / 2 + dy * r * 0.45);
434 }
322 ctx.stroke();
323 continue;
324 }
325 ctx.strokeStyle = effect.headshot ? "rgba(255,90,90,0.95)" : "rgba(255,255,255,0.9)";
326 // On the diagonals, so there is no pixel grid to land on and no point
327 // pretending there is — but it is still the same size on the glass at every
328 // ratio, which is the half of this a dense screen used to lose.
329 ctx.lineWidth = ui.px(effect.killed ? 3 : 2);
330 const r = ui.px(9 + age * 20);
331 ctx.beginPath();
332 const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]];
333 for (const [dx, dy] of arms) {
334 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
335 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
336 }
337 // The hit that finished them closes the four ticks into an X through the
338 // middle: a different shape, not a shade of the same one.
339 if (effect.killed)
340 for (const [dx, dy] of KILL_STROKES) {
341 ctx.moveTo(width / 2 - dx * r * 0.45, height / 2 - dy * r * 0.45);
342 ctx.lineTo(width / 2 + dx * r * 0.45, height / 2 + dy * r * 0.45);
343 }
344 ctx.stroke();
345 }
346
347 // --- flashed: white over the world, and nothing else. The blindness itself
348 // is the simulation's — `view.blind`, in seconds, burned down at 64 Hz — so
349 // this is only how it looks and a bot flashed by the same grenade suffers the
350 // same number. It goes on after the crosshair and before the numbers: it
351 // dims the crosshair as it should and never hides the clock or your health.
352 if (view.blind > 0) {
353 ctx.fillStyle = `rgba(255,255,255,${whiteout(view.blind).toFixed(3)})`;
354 ctx.fillRect(0, 0, width, height);
355 }
356
357 // --- and in the smoke: the same idea from inside a cloud instead of outside
358 // one. The ball `scene.ts` draws is opaque and its far faces are culled, so
359 // an eye within it would otherwise see straight through the thing that is
360 // blocking every sightline in the simulation. Which eye is the view's, so a
361 // spectator sees the fog the body they are watching is standing in.
362 if (view.alive && insideSmoke(world, eyePos(view))) {
363 ctx.fillStyle = `rgba(196,200,208,${FOG})`;
364 ctx.fillRect(0, 0, width, height);
365 }
366
367 // --- and where it came from: one arc per hit, over the washes for the same
368 // reason your health is drawn over them — being blinded or standing in smoke
369 // is exactly when knowing which way to turn is worth most.
370 if (hurting) {
371 const radius = Math.min(width, height) * HURT_RADIUS;
372 ctx.lineWidth = Math.max(3, Math.round(5 * ui.scale));
373 for (let i = 0; i < world.effects.count; i++) {
374 const effect = effectAt(world.effects, i);
375 if (effect === undefined || effect.kind !== "hurt") continue;
376 const age = world.time - effect.at;
377 if (age < 0 || age >= HURT_LIFE) continue;
378 // A fall hurts from straight below: no bearing, so no arc — the pain
379 // flash above is the whole of it.
380 if (Math.hypot(effect.from[0] - view.pos[0], effect.from[1] - view.pos[1]) < 1) continue;
381 const mid = hurtAngle(view.yaw, effect.from, view.pos);
382 // Under reduced motion the arc holds its strength and then goes: where
383 // it came from is what it says, and a slide to nothing is only how it
384 // leaves. The pain flash above is left alone — how hard is information.
385 const fade = motion === "reduced" ? 1 : 1 - age / HURT_LIFE;
386 ctx.strokeStyle = `rgba(${HURT},${(HURT_ALPHA * fade).toFixed(3)})`;
387 ctx.beginPath();
388 ctx.arc(width / 2, height / 2, radius, mid - HURT_HALF, mid + HURT_HALF);
435389 ctx.stroke();
436390 }
437391 }
438392
439 const scale = clamp(width / 1280, 0.7, 1.4);
440 const big = `${Math.round(30 * scale)}px system-ui, sans-serif`;
441 const mid = `${Math.round(19 * scale)}px system-ui, sans-serif`;
442 const small = `${Math.round(15 * scale)}px system-ui, sans-serif`;
443
444 const panel = (x: number, y: number, w: number, h: number): void => {
445 ctx.fillStyle = "rgba(12, 14, 18, 0.55)";
446 ctx.fillRect(x, y, w, h);
447 };
448
449 // --- who is that: names over the players the eye can see, drawn under the
450 // rest of the HUD so a plate never covers the clock or the ammo, and not at
451 // all while the scoreboard — which says the same thing better — is up.
452 if (!showScoreboard) drawPlates(ctx, world, view, camera, width, height, scale, alpha);
453
454 // --- radar: bottom-left, clear of the money line above and the health and
455 // armour numbers below, at 360px of a 1280px-wide HUD — about 28% of the
456 // width, the same share at any size since it scales with everything else.
457 const radarSize = Math.round(360 * scale);
458 drawRadar(ctx, world, view, pad, height - pad - Math.round(78 * scale) - radarSize, radarSize);
393 const scale = ui.scale;
394 const big = ui.font(30);
395 const mid = ui.font(19);
396 const small = ui.font(15);
459397
460398 // --- round clock and score
461399 const barW = Math.round(240 * scale);
462 panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
463 ctx.textAlign = "center";
464 ctx.font = big;
400 ui.panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
465401 const phase = world.match.phase;
466 const time = phase === "freeze" || phase === "live" || phase === "planted" ? clock(world.match.timer) : "--:--";
467 ctx.fillStyle = phase === "planted" ? "#ff6b5b" : "#f3f5f8";
468 ctx.fillText(time, width / 2, Math.round(32 * scale));
469 ctx.font = mid;
470 ctx.fillStyle = T_COLOR;
471 ctx.textAlign = "right";
472 ctx.fillText(String(world.match.score.t), width / 2 - barW / 2 - 8 * scale, Math.round(32 * scale));
473 ctx.fillStyle = CT_COLOR;
474 ctx.textAlign = "left";
475 ctx.fillText(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, Math.round(32 * scale));
402 const timed = phase === "freeze" || phase === "live" || phase === "planted";
403 const clockY = Math.round(32 * scale);
404 ui.text(timed ? ui.clock(world.match.timer) : "--:--", width / 2, clockY, {
405 align: "center", font: big, fill: phase === "planted" ? RED : WHITE,
406 });
407 ui.text(String(world.match.score.t), width / 2 - barW / 2 - 8 * scale, clockY,
408 { align: "right", font: mid, fill: T_COLOR });
409 ui.text(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, clockY,
410 { align: "left", font: mid, fill: CT_COLOR });
476411
477412 // --- health, armour, money
478 ctx.textAlign = "left";
479 ctx.font = big;
480 ctx.fillStyle = view.health > 30 ? "#f3f5f8" : "#ff6b5b";
481 ctx.fillText(String(Math.max(0, Math.round(view.health))), pad, height - pad);
482 ctx.font = small;
483 ctx.fillStyle = "#9fd0ff";
484 ctx.fillText(
485 `${view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour"}`,
413 ui.text(String(Math.max(0, Math.round(view.health))), pad, height - pad,
414 { align: "left", font: big, fill: view.health > 30 ? WHITE : RED });
415 ui.text(
416 view.armor > 0 ? `armour ${view.armor}${view.helmet ? " + helmet" : ""}` : "no armour",
486417 pad, height - pad - Math.round(30 * scale),
418 { align: "left", font: small, fill: BLUE },
487419 );
488 ctx.fillStyle = "#7ddc8a";
489 ctx.font = mid;
490 ctx.fillText(`$${view.money}`, pad, pad + Math.round(18 * scale));
491 ctx.font = small;
492 let carried = pad + Math.round(38 * scale);
493 if (view.team === "ct" && view.defuseKit) {
494 ctx.fillStyle = "#9fd0ff";
495 ctx.fillText("defuse kit", pad, carried);
496 carried += Math.round(20 * scale);
497 }
498 // What is in the pocket, whether or not it is in the hands: a grenade you
499 // forgot you bought is a grenade you never threw. Every kind carried reads
500 // on the one line — with one kind, as today; empty draws nothing, which is
501 // the whole of how this avoids a layout shift when the pocket runs out.
502 // Untestable until a second real kind exists to carry: check this line's
503 // width at 360px once one does, since it grows sideways with each kind.
504 const pocket = grenadeKinds(view);
505 if (pocket.length > 0) {
506 ctx.fillStyle = "#c8e6a0";
507 const label = pocket
508 .map((id) => `${WEAPONS[id].name.replace(" Grenade", "")} ×${grenadeCount(view, id)}`)
509 .join(" ");
510 ctx.fillText(`${label} · 4`, pad, carried);
511 }
420 // --- the radar, top left, and the money under it rather than over it
421 const radar = Math.min(Math.round(RADAR * scale), Math.round(Math.min(width, height) * 0.3));
422 drawMinimap(ctx, ui, world, view, pad, pad, radar);
423 const moneyY = pad + radar + Math.round(22 * scale);
424 ui.text(`$${view.money}`, pad, moneyY, { align: "left", font: mid, fill: "#7ddc8a" });
425 if (view.team === "ct" && view.defuseKit)
426 ui.text("defuse kit", pad, moneyY + Math.round(20 * scale), { align: "left", font: small, fill: BLUE });
512427
513428 // --- ammo and weapon, dimmed while the gun is still on its way up: the
514 // simulation refuses the trigger until `nextShotAt` passes, and an AWP owes
515 // 1.25 s of that. The reload line below keeps its own full strength — a
516 // reload says what it is in words, and the two never overlap anyway.
517 ctx.textAlign = "right";
518 ctx.globalAlpha = deploying(world, view) ? NOT_READY_ALPHA : 1;
519 ctx.font = big;
520 ctx.fillStyle = "#f3f5f8";
429 // simulation refuses the trigger until `deployUntil`, and the gun rising in
430 // the middle of the screen ends on that same tick, so the two cues end
431 // together. The busy line below keeps its full strength — it says in words
432 // what the dimming means.
433 const ready = world.time < view.deployUntil ? NOT_READY_ALPHA : 1;
521434 const ammo = view.ammo[weapon.id];
522 // A grenade counts what is left in the pocket where a gun counts its magazine.
523 const ammoText = weapon.kind === "grenade"
524 ? `×${grenadeCount(view, weapon.id)}`
525 : weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
526 ctx.fillText(ammoText, width - pad, height - pad);
527 ctx.font = small;
528 ctx.fillStyle = "#c8ccd4";
529 ctx.fillText(weapon.name + (view.hasBomb ? " · bomb" : ""), width - pad, height - pad - Math.round(30 * scale));
530 ctx.globalAlpha = 1;
531 if (world.time < view.reloadUntil) {
532 ctx.fillStyle = "#f0c060";
533 ctx.fillText("reloading", width - pad, height - pad - Math.round(50 * scale));
534 }
435 const ammoText = weapon.magazine === 0 ? "—" : `${ammo?.mag ?? 0} / ${ammo?.reserve ?? 0}`;
436 ui.text(ammoText, width - pad, height - pad, { align: "right", font: big, fill: WHITE, alpha: ready });
437 // What is in your hands, and then what is in your pockets: the grenades you
438 // bought and the bomb, each named only while you are carrying it.
439 ui.text(
440 weapon.name +
441 (view.pocket.he > 0 ? " · HE" : "") +
442 (view.pocket.flash > 0 ? " · flash" : "") +
443 (view.pocket.smoke > 0 ? " · smoke" : "") +
444 (view.hasBomb ? " · bomb" : ""),
445 width - pad, height - pad - Math.round(30 * scale),
446 { align: "right", font: small, fill: GREY, alpha: ready },
447 );
448 // What the gun is busy doing, in the one line there is room for. The two
449 // cannot both be true — a switch cancels the reload it interrupted and a
450 // reload cannot start until the new weapon is up — so this is an order of
451 // reading and not a choice between two things happening at once.
452 const busy = world.time < view.deployUntil ? "switching" : world.time < view.reloadUntil ? "reloading" : null;
453 if (busy !== null)
454 ui.text(busy, width - pad, height - pad - Math.round(50 * scale), { align: "right", font: small, fill: "#f0c060" });
535455
536456 // --- who is left
537 ctx.textAlign = "center";
538 ctx.font = small;
539457 const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length;
540458 const aliveCT = world.players.filter((p) => p.team === "ct" && p.alive).length;
541 ctx.fillStyle = T_COLOR;
542 ctx.fillText(`T ${aliveT}`, width / 2 - Math.round(60 * scale), Math.round(62 * scale));
543 ctx.fillStyle = CT_COLOR;
544 ctx.fillText(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), Math.round(62 * scale));
459 const leftY = Math.round(62 * scale);
460 ui.text(`T ${aliveT}`, width / 2 - Math.round(60 * scale), leftY, { align: "center", font: small, fill: T_COLOR });
461 ui.text(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), leftY, { align: "center", font: small, fill: CT_COLOR });
545462
546463 // --- kill feed
547 ctx.textAlign = "right";
548 ctx.font = small;
549464 world.feed.slice(-5).forEach((entry, i) => {
550465 const age = world.time - entry.at;
551466 if (age > 8) return;
552 const y = pad + Math.round((20 + i * 22) * scale);
553 ctx.globalAlpha = clamp(8 - age, 0, 1);
554 ctx.fillStyle = entry.killerTeam === "t" ? T_COLOR : CT_COLOR;
555467 const text = `${entry.killer} ${causeName(entry.weapon)}${entry.headshot ? " ⌖" : ""} ${entry.victim}`;
556 ctx.fillText(text, width - pad, y);
557 ctx.globalAlpha = 1;
468 ui.text(text, width - pad, pad + Math.round((20 + i * 22) * scale), {
469 align: "right", font: small, fill: teamColor(entry.killerTeam), alpha: 8 - age,
470 });
558471 });
559472
560 // --- what the round is saying. The line stays up for four seconds, and a
561 // round result stays until the next round goes live: the end delay plus the
562 // freeze is long enough to read who won and why, which is the point of it.
473 // --- what the round is saying
563474 const message = world.messages[world.messages.length - 1];
564 const holding = phase === "over" || phase === "freeze" || phase === "match_over";
565 if (message !== undefined && (holding || world.time - message.at < MESSAGE_FADE)) {
566 ctx.textAlign = "center";
567 ctx.font = mid;
568 ctx.fillStyle = "rgba(255,255,255,0.92)";
569 ctx.fillText(message.text, width / 2, height * 0.22);
570 }
571
572 // --- a gun under your feet that you would have to ask for. A gun you have
573 // no slot for is taken by walking over it and says nothing, because by the
574 // time it could be read it is already in your hands.
575 const offered = !spectating && view.alive ? offeredPickup(world, view) : null;
576 if (offered !== null) {
577 ctx.textAlign = "center";
578 ctx.font = mid;
579 ctx.fillStyle = "rgba(255,255,255,0.92)";
580 ctx.fillText(`E — take ${WEAPONS[offered.weapon].name}`, width / 2, height * 0.58);
581 }
475 if (message !== undefined && world.time - message.at < 4)
476 ui.text(message.text, width / 2, height * 0.22, { align: "center", font: mid, fill: "rgba(255,255,255,0.92)" });
582477
583478 // --- planting or defusing
584479 const progress = useProgress(world, view);
585480 if (progress !== null) {
586481 const w = Math.round(260 * scale), h = Math.round(14 * scale);
587482 const x = width / 2 - w / 2, y = height * 0.62;
588 panel(x - 2, y - 2, w + 4, h + 4);
589 ctx.fillStyle = progress.label.startsWith("Def") ? "#6fa8e8" : "#e2b04a";
590 ctx.fillRect(x, y, w * progress.fraction, h);
591 ctx.font = small;
592 ctx.fillStyle = "#fff";
593 ctx.textAlign = "center";
594 ctx.fillText(progress.label, width / 2, y - 8 * scale);
483 ui.panel(x - 2, y - 2, w + 4, h + 4);
484 ui.bar(x, y, w, h, progress.fraction, progress.label.startsWith("Def") ? CT_COLOR : T_COLOR);
485 ui.text(progress.label, width / 2, y - 8 * scale, { align: "center", font: small, fill: "#fff" });
595486 }
596487
597 // --- dead: who killed you, and whose eyes you are borrowing until the round
598 // is over. The kill feed is where the killer comes from; the bomb credits
599 // nobody, so it names you as your own killer there and this reads that way.
600 if (!world.me.alive && !over) {
601 const top = height * 0.38;
602 ctx.textAlign = "center";
603 ctx.font = big;
604 ctx.fillStyle = "rgba(255,255,255,0.88)";
605 ctx.fillText("You are dead", width / 2, top);
606 const notice = deathNotice(world, world.me);
607 if (notice !== null) {
608 ctx.font = mid;
609 ctx.fillStyle = "#ff8f86";
610 const hp = notice.killerHealth === null ? "" : `, ${Math.round(clamp(notice.killerHealth, 0, 100))} hp left`;
611 const how = notice.weapon === "fall" ? "You fell too far" :
612 `${notice.killer} killed you with ${causeName(notice.weapon)}` + (notice.headshot ? " — headshot" : "") + hp;
613 ctx.fillText(how, width / 2, top + Math.round(30 * scale));
488 // --- dead: whose eyes this is, and how to move to the next pair
489 if (!world.me.alive && world.match.phase !== "match_over") {
490 const notice = deathLine(world, world.me);
491 if (view === world.me) {
492 // Nobody left to watch: your own body is the only camera in the round.
493 ui.text(DEAD, width / 2, height * 0.4, { align: "center", font: big, fill: "rgba(255,255,255,0.85)" });
494 if (notice !== null)
495 ui.text(notice, width / 2, height * 0.4 + Math.round(34 * scale), { align: "center", font: mid, fill: NOTICE });
496 } else {
497 // Above the bottom row, so it never lands on the health or the ammo —
498 // at 360 pixels wide those are a thumb's width from the middle.
499 const w = Math.round(420 * scale), h = Math.round(34 * scale);
500 const y = height - pad - Math.round(64 * scale);
501 ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
502 ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale),
503 { align: "center", font: mid, fill: teamColor(view.team) });
504 ui.text(SWITCH, width / 2, y, { align: "center", font: small, fill: GREY });
505 // Over the panel rather than in it, so the panel keeps its two lines.
506 if (notice !== null)
507 ui.text(notice, width / 2, y - h - Math.round(4 * scale), { align: "center", font: mid, fill: NOTICE });
614508 }
615 ctx.font = small;
616 ctx.fillStyle = "#c8ccd4";
617 ctx.fillText(
618 spectating ? `Watching ${view.name} — fire or click for the next` : "Nobody left to watch",
619 width / 2, top + Math.round(56 * scale),
620 );
621509 }
622510
623 // --- the damage report: only during the round-over delay, and never under
624 // the scoreboard, which a held Tab can ask for at the same moment.
625 if (!showScoreboard) drawReport(ctx, world, view, width, height, scale);
626
627 if (showScoreboard) drawScoreboard(ctx, world, width, height, scale);
511 if (showScoreboard) drawScoreboard(ctx, ui, world);
628512 }
629513
630 function drawScoreboard(
631 ctx: CanvasRenderingContext2D,
632 world: World,
633 width: number,
634 height: number,
635 scale: number,
636 ): void {
514 function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void {
515 const { width, height, scale } = ui;
637516 const w = Math.min(width - 40, Math.round(760 * scale));
638517 const rowH = Math.round(26 * scale);
639 const h = rowH * (world.players.length + 5);
518 const h = rowH * (world.players.length + 4);
640519 const x = width / 2 - w / 2, y = height / 2 - h / 2;
641 ctx.fillStyle = "rgba(10, 12, 16, 0.88)";
642 ctx.fillRect(x, y, w, h);
643 ctx.font = `${Math.round(15 * scale)}px system-ui, sans-serif`;
644 // Which band the fight is being played at, so a screenshot of the scoreline
645 // still means something once the round it was taken in is long over.
646 ctx.textAlign = "center";
647 ctx.fillStyle = "#9aa4b4";
648 const difficulty = world.difficulty.charAt(0).toUpperCase() + world.difficulty.slice(1);
649 ctx.fillText(`Difficulty: ${difficulty}`, x + w / 2, y + rowH);
650 ctx.textAlign = "left";
651 ctx.fillStyle = "#c8ccd4";
652 ctx.fillText("player", x + 16, y + rowH * 2);
653 ctx.textAlign = "right";
654 for (const [i, label] of ["kills", "deaths", "hs", "damage", "money"].entries())
655 ctx.fillText(label, x + w - 16 - i * Math.round(90 * scale), y + rowH * 2);
520 const right = x + w - 16, stride = Math.round(90 * scale);
521 ui.panel(x, y, w, h, "rgba(10, 12, 16, 0.88)");
522 ui.text("player", x + 16, y + rowH, { align: "left", font: ui.font(15), fill: GREY });
523 // The headings sit on the row they head, so there is no lead over a value.
524 ui.columns(FIGURES, NOTHING, { x: right, y: y + rowH, lead: 0 }, stride);
656525
657 const order = rankPlayers(world.players);
658 order.forEach((p, row) => {
659 const ty = y + rowH * (row + 3.4);
660 ctx.textAlign = "left";
661 ctx.fillStyle = p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR;
662 ctx.globalAlpha = p.alive ? 1 : 0.45;
663 ctx.fillText(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty);
664 ctx.textAlign = "right";
665 for (const [i, value] of [p.kills, p.deaths, p.headshots, Math.round(p.damage), `$${p.money}`].entries())
666 ctx.fillText(String(value), x + w - 16 - i * Math.round(90 * scale), ty);
526 // The rows, and the order they are in, are `summary.ts`'s: the screen at the
527 // end of the match draws the same ten lines out of the same function, so what
528 // you read holding Tab and what you read afterwards cannot be in two orders.
529 scoreLines(world).forEach((p, row) => {
530 const ty = y + rowH * (row + 2.4);
531 // A dead player's whole row is dimmed — the name and the figures both — so
532 // the fade is set around them rather than passed to each line.
533 const dim = p.alive ? 1 : 0.45;
534 ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.you ? " (you)" : ""}`, x + 16, ty, {
535 align: "left", fill: p.you ? "#ffffff" : teamColor(p.team), alpha: dim,
536 });
537 ctx.globalAlpha = dim;
538 ui.columns(NOTHING, [
539 String(p.kills), String(p.deaths), String(p.headshots), String(p.damage), `$${p.money}`,
540 ], { x: right, y: ty }, stride);
667541 ctx.globalAlpha = 1;
668542 });
669543 }
src/client/input.test.ts deleted −445
@@ -1,445 +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 // --- the thumb's own sensitivity: a touch drag never reads `sensitivity`,
175 // the mouse's number, and changing one never moves the other.
176
177 test("the same drag turns by different degrees at two touch sensitivities", () => {
178 const { canvas, controls } = harness();
179 controls.touchSensitivity = 1;
180 touch(canvas, "touchstart", at(1, 700));
181 touch(canvas, "touchmove", at(1, 740));
182 const slow = controls.takeLook()[0];
183 touch(canvas, "touchend", at(1, 740));
184
185 controls.touchSensitivity = 5;
186 touch(canvas, "touchstart", at(2, 700));
187 touch(canvas, "touchmove", at(2, 740));
188 const fast = controls.takeLook()[0];
189
190 expect(slow).toBeCloseTo(40 * 1 * TOUCH_DEGREES, 10);
191 expect(fast).toBeCloseTo(40 * 5 * TOUCH_DEGREES, 10);
192 expect(fast).toBeGreaterThan(slow);
193 });
194
195 test("changing the touch sensitivity leaves the mouse's alone, and a mouse move ignores it entirely", () => {
196 const { canvas, controls } = harness();
197 const window = globals.window as Canvas;
198 controls.locked = true;
199
200 controls.touchSensitivity = 9;
201 window.fire("mousemove", { movementX: 50, movementY: 0 });
202 const [turned] = controls.takeLook();
203 expect(turned).toBeCloseTo(50 * controls.sensitivity * MOUSE_DEGREES, 10);
204 expect(controls.sensitivity).toBe(2.2); // untouched by the thumb's field
205
206 touch(canvas, "touchstart", at(1, 700));
207 touch(canvas, "touchmove", at(1, 740));
208 expect(controls.takeLook()[0]).toBeCloseTo(40 * 9 * TOUCH_DEGREES, 10);
209 });
210
211 test("createControls takes the thumb's sensitivity as its own argument, separate from the mouse's", () => {
212 const canvas = target({ clientWidth: 1000, clientHeight: 600 });
213 globals.window = target();
214 globals.document = target({ pointerLockElement: null });
215 const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2, DEFAULT_BINDINGS, DEFAULT_ZOOM_RATIO, 7);
216 expect(controls.touchSensitivity).toBe(7);
217 expect(controls.sensitivity).toBe(2.2);
218 });
219
220 test("a drag while scoped is scaled too — the pad is not left with the old behaviour", () => {
221 const { canvas, controls } = harness();
222 touch(canvas, "touchstart", at(1, 700));
223 touch(canvas, "touchmove", at(1, 740));
224 const [hip] = controls.takeLook();
225 expect(hip).toBeCloseTo(40 * 2.2 * TOUCH_DEGREES, 10);
226
227 controls.scoped = true;
228 touch(canvas, "touchmove", at(1, 780));
229 expect(controls.takeLook()[0]).toBeCloseTo(hip * DEFAULT_ZOOM_RATIO, 10);
230 });
231
232 // --- the buttons on the pad, which are the same action names as the keys
233
234 /** A fake `[data-action]` button, and a root that hands it to attachButtons. */
235 function pad(...actions: string[]): {
236 root: ParentNode;
237 press(action: string): void;
238 release(action: string): void;
239 classes(action: string): string[];
240 } {
241 const buttons = new Map<string, Canvas>();
242 for (const action of actions) {
243 const classes: string[] = [];
244 buttons.set(action, target({
245 dataset: { action },
246 classList: {
247 add: (name: string) => { if (!classes.includes(name)) classes.push(name); },
248 remove: (name: string) => {
249 const at = classes.indexOf(name);
250 if (at >= 0) classes.splice(at, 1);
251 },
252 names: classes,
253 },
254 }));
255 }
256 const root = { querySelectorAll: () => Array.from(buttons.values()) };
257 return {
258 root: root as unknown as ParentNode,
259 press: (action) => buttons.get(action)?.fire("pointerdown"),
260 release: (action) => buttons.get(action)?.fire("pointerup"),
261 classes: (action) => ((buttons.get(action)?.classList as { names: string[] }).names),
262 };
263 }
264
265 test("the menu button pauses without pointer lock", () => {
266 const { controls } = harness();
267 const buttons = pad("menu");
268 attachButtons(buttons.root, controls);
269 let paused = 0;
270 controls.onPause = () => { paused += 1; };
271 expect(controls.locked).toBe(false);
272 buttons.press("menu");
273 expect(paused).toBe(1);
274 buttons.release("menu");
275 expect(paused).toBe(1); // the release is not a second pause
276 });
277
278 test("clear forgets the presses and the look a paused match never took", () => {
279 // What a pause does with what arrived under the overlay: the Space that
280 // pressed Resume is not a jump on the first frame back, and a drag the game
281 // did not watch is not a flick of the aim.
282 const { canvas, controls } = harness();
283 const buttons = pad("jump");
284 attachButtons(buttons.root, controls);
285 buttons.press("jump");
286 touch(canvas, "touchstart", at(1, 600));
287 touch(canvas, "touchmove", at(1, 700));
288 expect(controls.takeLook()[0]).not.toBe(0);
289
290 buttons.press("jump");
291 touch(canvas, "touchmove", at(1, 800));
292 controls.clear();
293 expect(controls.takeLook()).toEqual([0, 0]);
294 expect(firstStep(controls, "jump")).toBe(false);
295 });
296
297 test("the pad's slot, scoreboard and buy buttons are the keyboard's actions", () => {
298 const { controls } = harness();
299 const buttons = pad("slot1", "slot2", "slot3", "slot4", "scoreboard", "buy");
300 attachButtons(buttons.root, controls);
301 let buys = 0;
302 controls.onBuy = () => { buys += 1; };
303
304 expect(controls.slotWanted).toBe(null);
305 for (const [action, slot] of [["slot1", 1], ["slot2", 2], ["slot3", 3], ["slot4", 4]] as const) {
306 buttons.press(action);
307 expect(controls.slotWanted).toBe(slot);
308 buttons.release(action);
309 }
310 expect(controls.slotWanted).toBe(null);
311
312 expect(controls.scoreboard).toBe(false);
313 buttons.press("scoreboard");
314 expect(controls.scoreboard).toBe(true); // held, not a toggle
315 expect(buttons.classes("scoreboard")).toEqual(["on"]); // and it looks held
316 buttons.release("scoreboard");
317 expect(controls.scoreboard).toBe(false);
318 expect(buttons.classes("scoreboard")).toEqual([]);
319
320 buttons.press("buy");
321 expect(buys).toBe(1);
322 });
323
324 test("scope is a touch button, held like the mouse's right button", () => {
325 const { controls } = harness();
326 const buttons = pad("scope");
327 attachButtons(buttons.root, controls);
328
329 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
330 buttons.press("scope");
331 expect(controls.inputAt(controls.take(1), 0).scope).toBe(true); // held, not a toggle
332 expect(buttons.classes("scope")).toEqual(["on"]);
333 buttons.release("scope");
334 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
335 expect(buttons.classes("scope")).toEqual([]);
336 });
337
338 test("Q scopes from the keyboard the same way", () => {
339 const { controls } = harness();
340 const window = globals.window as Canvas;
341 window.fire("keydown", { code: "KeyQ" });
342 expect(controls.inputAt(controls.take(1), 0).scope).toBe(true);
343 window.fire("keyup", { code: "KeyQ" });
344 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
345 });
346
347 test("the radio is one key and one pad button, and opens on the press", () => {
348 const { controls } = harness();
349 const buttons = pad("radio");
350 attachButtons(buttons.root, controls);
351 const window = globals.window as Canvas;
352 let opened = 0;
353 controls.onRadio = () => { opened += 1; };
354
355 window.fire("keydown", { code: "KeyZ" });
356 expect(opened).toBe(1);
357 // The release is not a second opening, and neither is the key repeating.
358 window.fire("keyup", { code: "KeyZ" });
359 window.fire("keydown", { code: "KeyZ", repeat: true });
360 expect(opened).toBe(1);
361
362 buttons.press("radio");
363 expect(opened).toBe(2);
364 buttons.release("radio");
365 expect(opened).toBe(2);
366 });
367
368 test("the radio is a binding like any other: rebound, it answers to the new key", () => {
369 const { controls } = harness({ ...DEFAULT_BINDINGS, radio: "KeyG" });
370 const window = globals.window as Canvas;
371 let opened = 0;
372 controls.onRadio = () => { opened += 1; };
373
374 window.fire("keydown", { code: "KeyZ" });
375 expect(opened).toBe(0);
376 window.fire("keydown", { code: "KeyG" });
377 expect(opened).toBe(1);
378 });
379
380 // --- bindings: a non-default map moves the game, not the printed letters
381
382 test("a non-default map moves on the bound key and does nothing on KeyW", () => {
383 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyG" });
384 const window = globals.window as Canvas;
385
386 window.fire("keydown", { code: "KeyW" });
387 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
388
389 window.fire("keydown", { code: "KeyG" });
390 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
391 });
392
393 test("a rebind takes effect on the very next key event, with no detach or re-attach", () => {
394 const { controls } = harness();
395 const window = globals.window as Canvas;
396
397 window.fire("keydown", { code: "KeyW" });
398 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
399 window.fire("keyup", { code: "KeyW" });
400
401 controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyG" });
402
403 window.fire("keydown", { code: "KeyW" });
404 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
405 window.fire("keydown", { code: "KeyG" });
406 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
407 });
408
409 test("the arrow keys and C still move and crouch whatever forward is bound to", () => {
410 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyG", duck: "KeyU" });
411 const window = globals.window as Canvas;
412
413 window.fire("keydown", { code: "ArrowUp" });
414 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
415 window.fire("keydown", { code: "KeyC" });
416 expect(controls.inputAt(controls.take(1), 0).duck).toBe(true);
417 });
418
419 test("under pointer lock, an unbound key is still kept off the browser — Tab cannot walk focus off the canvas", () => {
420 const { controls } = harness();
421 const window = globals.window as Canvas;
422 let prevented = 0;
423 const press = (code: string): void => window.fire("keydown", { code, preventDefault: () => { prevented += 1; } });
424
425 press("Backquote"); // unbound, and not locked yet
426 expect(prevented).toBe(0);
427
428 controls.locked = true;
429 press("Backquote"); // unbound, but the lock claims every key
430 expect(prevented).toBe(1);
431 });
432
433 test("preventDefault follows the action, not the code: a jump rebound off Space no longer eats it", () => {
434 const { controls } = harness({ ...DEFAULT_BINDINGS, jump: "KeyJ" });
435 const window = globals.window as Canvas;
436 let prevented = 0;
437
438 window.fire("keydown", { code: "Space", preventDefault: () => { prevented += 1; } });
439 expect(prevented).toBe(0);
440 expect(controls.inputAt(controls.take(1), 0).jump).toBe(false);
441
442 window.fire("keydown", { code: "KeyJ", preventDefault: () => { prevented += 1; } });
443 expect(prevented).toBe(1);
444 expect(controls.inputAt(controls.take(1), 0).jump).toBe(true);
445 });
src/client/input.ts deleted −336
@@ -1,336 +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 /** A thumb's own sensitivity, apart from the mouse's — touches whose role is `look` only. */
56 touchSensitivity: number;
57 /** The scope's share of that sensitivity while it is up — see zoom.ts. */
58 zoomRatio: number;
59 /**
60 * Whether the person is looking down a scope right now. The loop sets it from
61 * the world once a frame; the events in between scale their look by it, so the
62 * worst a scope going up or down costs is the one frame it happened on.
63 */
64 scoped: boolean;
65 locked: boolean;
66 setAction(action: string, down: boolean): void;
67 /** A rebind takes hold on the very next key event — no detach, no reload. */
68 setBindings(bindings: Record<Action, string>): void;
69 /**
70 * Forget every intent not yet taken — held controls, latched presses and the
71 * look that has piled up. What the focus, the pointer lock and the pause
72 * overlay all want: nothing a player did while the game was not listening
73 * should arrive on the first frame it listens again.
74 */
75 clear(): void;
76 onBuy: () => void;
77 onRadio: () => void;
78 onPause: () => void;
79 detach(): void;
80 };
81
82 /**
83 * Every action a key can drive, and precisely today's map — the default a
84 * corrupt or missing setting falls back to. `settings.ts` validates a stored
85 * map against this same list; nothing else should invent an action name.
86 */
87 export const ACTIONS = [
88 "forward", "back", "left", "right", "jump", "duck", "walk",
89 "reload", "use", "buy", "radio", "scoreboard", "scope",
90 "slot1", "slot2", "slot3", "slot4",
91 ] as const;
92 export type Action = typeof ACTIONS[number];
93
94 export const DEFAULT_BINDINGS: Record<Action, string> = {
95 forward: "KeyW", back: "KeyS", left: "KeyA", right: "KeyD",
96 jump: "Space", duck: "ControlLeft", walk: "ShiftLeft",
97 reload: "KeyR", use: "KeyE", buy: "KeyB", radio: "KeyZ", scoreboard: "Tab", scope: "KeyQ",
98 slot1: "Digit1", slot2: "Digit2", slot3: "Digit3", slot4: "Digit4",
99 };
100
101 /**
102 * Codes no binding can claim: the arrow keys and `C` are a fixed movement and
103 * crouch fallback, wired below regardless of what a player has bound, so a
104 * rebind gone wrong is never a player who cannot move.
105 */
106 export const RESERVED_CODES: ReadonlySet<string> =
107 new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]);
108
109 /** Code to action, the fixed fallback last so nothing bound can ever shadow it. */
110 function reverseBindings(bindings: Record<Action, string>): Record<string, Action | "duck"> {
111 const map: Record<string, Action | "duck"> = {};
112 for (const action of ACTIONS) map[bindings[action]] = action;
113 map.ArrowUp = "forward";
114 map.ArrowDown = "back";
115 map.ArrowLeft = "left";
116 map.ArrowRight = "right";
117 map.KeyC = "duck";
118 return map;
119 }
120
121 export function createControls(
122 canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS,
123 zoomRatio = DEFAULT_ZOOM_RATIO, touchSensitivity = 2.2,
124 ): Controls {
125 const press = createPress(LATCHED);
126 const input: Input = { ...NO_INPUT };
127 let lookX = 0, lookY = 0;
128 let reverse = reverseBindings(bindings);
129 const controls: Controls = {
130 takeLook() {
131 const out: [number, number] = [lookX, lookY];
132 lookX = 0;
133 lookY = 0;
134 return out;
135 },
136 take: (steps) => press.take(steps),
137 inputAt(frame, step) {
138 const on = (action: string): boolean => downAt(frame, action, step);
139 input.forward = clamp(
140 (frame.held.has("forward") ? 1 : 0) - (frame.held.has("back") ? 1 : 0) + stick.y, -1, 1);
141 input.side = clamp(
142 (frame.held.has("right") ? 1 : 0) - (frame.held.has("left") ? 1 : 0) + stick.x, -1, 1);
143 input.duck = frame.held.has("duck");
144 input.walk = frame.held.has("walk");
145 input.scope = frame.held.has("scope");
146 input.jump = on("jump");
147 input.reload = on("reload");
148 input.use = on("use");
149 input.fire = on("fire");
150 input.switchTo = null;
151 return input;
152 },
153 get scoreboard(): boolean {
154 return press.isDown("scoreboard");
155 },
156 get slotWanted(): 1 | 2 | 3 | 4 | null {
157 return press.isDown("slot4") ? 4 : press.isDown("slot3") ? 3
158 : press.isDown("slot2") ? 2 : press.isDown("slot1") ? 1 : null;
159 },
160 sensitivity,
161 touchSensitivity,
162 zoomRatio,
163 scoped: false,
164 locked: false,
165 setAction: (action, down) => {
166 if (down) press.down(action);
167 else press.up(action);
168 if (!down) return;
169 // The actions that are a menu rather than a movement. `menu` is how a
170 // touch screen reaches the pause overlay at all: pointer lock is the only
171 // other thing that calls onPause, and a finger never takes the lock.
172 if (action === "buy") controls.onBuy();
173 else if (action === "radio") controls.onRadio();
174 else if (action === "menu") controls.onPause();
175 },
176 setBindings: (next) => {
177 reverse = reverseBindings(next);
178 },
179 clear: () => {
180 press.clear();
181 lookX = 0;
182 lookY = 0;
183 },
184 onBuy: () => {},
185 onRadio: () => {},
186 onPause: () => {},
187 detach: () => {
188 window.removeEventListener("keydown", onKeyDown);
189 window.removeEventListener("keyup", onKeyUp);
190 document.removeEventListener("pointerlockchange", onLockChange);
191 },
192 };
193
194 const stick = { x: 0, y: 0 };
195
196 function onKeyDown(event: KeyboardEvent): void {
197 const action = reverse[event.code];
198 // Under pointer lock every key is the game's, whatever it is bound to:
199 // an unbound Tab still switches focus off the canvas otherwise, and once
200 // it has, a locked mouse is aiming at nothing the player can see move.
201 if (controls.locked) event.preventDefault();
202 if (action === undefined) return;
203 // Whatever is bound to these two actions gets the default the browser
204 // would otherwise take even without a lock: a scoreboard key that also
205 // switches tabs, or a jump key that also scrolls the page.
206 if (action === "scoreboard" || action === "jump") event.preventDefault();
207 if (event.repeat) return;
208 controls.setAction(action, true);
209 }
210 function onKeyUp(event: KeyboardEvent): void {
211 const action = reverse[event.code];
212 if (action !== undefined) controls.setAction(action, false);
213 }
214 function onLockChange(): void {
215 controls.locked = document.pointerLockElement === canvas;
216 if (!controls.locked) {
217 controls.clear();
218 controls.onPause();
219 }
220 }
221
222 window.addEventListener("keydown", onKeyDown);
223 window.addEventListener("keyup", onKeyUp);
224 document.addEventListener("pointerlockchange", onLockChange);
225
226 canvas.addEventListener("mousedown", (event) => {
227 if (!controls.locked) return;
228 event.preventDefault();
229 controls.setAction(event.button === 2 ? "scope" : "fire", true);
230 });
231 window.addEventListener("mouseup", (event) => {
232 controls.setAction(event.button === 2 ? "scope" : "fire", false);
233 });
234 canvas.addEventListener("contextmenu", (event) => event.preventDefault());
235 window.addEventListener("mousemove", (event) => {
236 if (!controls.locked) return;
237 lookX += lookDelta(event.movementX, controls.sensitivity, controls.scoped, controls.zoomRatio);
238 lookY += lookDelta(event.movementY, controls.sensitivity, controls.scoped, controls.zoomRatio);
239 });
240 window.addEventListener("blur", () => {
241 controls.clear();
242 });
243
244 // --- touch: the left third is a stick, the rest of the canvas looks around,
245 // and a second finger on the looking side is the trigger. That is the whole
246 // of firing while aiming: the touch that steers keeps its own target for its
247 // whole life, so a shot never costs the aim the thumb already has. A touch
248 // that fires does not steer — a trigger finger twitches, and the aim should
249 // not follow it.
250 type Role = "stick" | "look" | "fire";
251 type Held = { startX: number; startY: number; x: number; y: number; role: Role };
252 const touches = new Map<number, Held>();
253 const anyRole = (role: Role): boolean => {
254 for (const held of touches.values()) if (held.role === role) return true;
255 return false;
256 };
257 canvas.addEventListener("touchstart", (event) => {
258 for (const touch of Array.from(event.changedTouches)) {
259 const role: Role = touch.clientX < canvas.clientWidth * 0.38
260 ? "stick"
261 : anyRole("look") ? "fire" : "look";
262 touches.set(touch.identifier, {
263 startX: touch.clientX, startY: touch.clientY, x: touch.clientX, y: touch.clientY, role,
264 });
265 if (role === "fire") controls.setAction("fire", true);
266 }
267 event.preventDefault();
268 }, { passive: false });
269 canvas.addEventListener("touchmove", (event) => {
270 for (const touch of Array.from(event.changedTouches)) {
271 const state = touches.get(touch.identifier);
272 if (state === undefined) continue;
273 if (state.role === "stick") {
274 stick.x = clamp((touch.clientX - state.startX) / 60, -1, 1);
275 stick.y = clamp((state.startY - touch.clientY) / 60, -1, 1);
276 } else if (state.role === "look") {
277 // The thumb's own sensitivity, not the mouse's: a drag under a scope
278 // is the same problem, and a thumb has less screen to give away than
279 // a mouse does.
280 const look = (movement: number): number =>
281 lookDelta(movement, controls.touchSensitivity, controls.scoped, controls.zoomRatio, TOUCH_DEGREES);
282 lookX += look(touch.clientX - state.x);
283 lookY += look(touch.clientY - state.y);
284 }
285 state.x = touch.clientX;
286 state.y = touch.clientY;
287 }
288 event.preventDefault();
289 }, { passive: false });
290 const endTouch = (event: TouchEvent): void => {
291 for (const touch of Array.from(event.changedTouches)) {
292 const state = touches.get(touch.identifier);
293 touches.delete(touch.identifier);
294 if (state === undefined) continue;
295 if (state.role === "stick") {
296 stick.x = 0;
297 stick.y = 0;
298 }
299 // The last finger off the trigger is the one that releases it: a stuck
300 // trigger on a phone is unrecoverable without reloading the page.
301 if (state.role === "fire" && !anyRole("fire")) controls.setAction("fire", false);
302 }
303 };
304 canvas.addEventListener("touchend", endTouch);
305 canvas.addEventListener("touchcancel", endTouch);
306
307 return controls;
308 }
309
310 /**
311 * The buttons a touch screen needs, wired to the same actions as the keys. A
312 * thumb is quicker than a frame, so the press goes down on pointerdown and up
313 * on pointerup and the latch sees to it that a step counts it.
314 */
315 export function attachButtons(root: ParentNode, controls: Controls): void {
316 for (const element of Array.from(root.querySelectorAll<HTMLElement>("[data-action]"))) {
317 const action = element.dataset.action as string;
318 // preventDefault keeps the browser from making a second event out of a
319 // touch, which also costs the button its :active state — so the class is
320 // the one that says "held", and a held control looks held.
321 const down = (event: Event): void => {
322 event.preventDefault();
323 element.classList.add("on");
324 controls.setAction(action, true);
325 };
326 const up = (event: Event): void => {
327 event.preventDefault();
328 element.classList.remove("on");
329 controls.setAction(action, false);
330 };
331 element.addEventListener("pointerdown", down);
332 element.addEventListener("pointerup", up);
333 element.addEventListener("pointercancel", up);
334 element.addEventListener("pointerleave", up);
335 }
336 }
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 });

292 more files not shown.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs