Split The code is public; the app answers only to the crew.
40 changes from trevato/cs-engine #244
Check · Review · Land · Live — It conflicts with its base.
trevato/cs-engine:main → main 167 commits fork
392 files
changed +69396
−23807
.gitignore +2
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | node_modules/ |
| 2 | dist/ | |
| 3 | static/ | |
| 2 | 4 | *.db |
| 3 | 5 | *.db-wal |
| 4 | 6 | *.db-shm |
AGENTS.md +3 −4
| @@ -9,9 +9,8 @@ preview at its own address, deployed from your branch as you push. | ||
| 9 | 9 | - Work on the branch you were given. You cannot move the default branch; |
| 10 | 10 | a person merges your pull once it is reviewed. |
| 11 | 11 | - 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. | |
| 15 | 14 | - `src/tty.ts` is the app's terminal interface. The exact command that |
| 16 | 15 | reaches it is `FORGE_APP_SSH` at runtime; print that verbatim wherever the |
| 17 | 16 | 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. | ||
| 34 | 33 | come from the environment, never the repository; no `eval`, no shell |
| 35 | 34 | built from user text; a private thing needs a session in an HttpOnly cookie. |
| 36 | 35 | 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 | |
| 38 | 37 | not have and a lie does not honour. `HEAD` answers like `GET`. |
| 39 | 38 | An app with an owner or admin is never claimed by whoever visits first: |
| 40 | 39 | 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 filebun.lock +6 −1
| @@ -3,7 +3,10 @@ | ||
| 3 | 3 | "configVersion": 1, |
| 4 | 4 | "workspaces": { |
| 5 | 5 | "": { |
| 6 | "name": "cs", | |
| 6 | "name": "cs-engine", | |
| 7 | "dependencies": { | |
| 8 | "@typesafe-ai/sdk": "^0.6.0", | |
| 9 | }, | |
| 7 | 10 | "devDependencies": { |
| 8 | 11 | "@types/bun": "^1.4.2", |
| 9 | 12 | "typescript": "^7.0.2", |
| @@ -15,6 +18,8 @@ | ||
| 15 | 18 | |
| 16 | 19 | "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], |
| 17 | 20 | |
| 21 | "@typesafe-ai/sdk": ["@typesafe-ai/[email protected]", "", {}, "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw=="], | |
| 22 | ||
| 18 | 23 | "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], |
| 19 | 24 | |
| 20 | 25 | "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], |
forge.json +4 −1
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | { |
| 2 | "egress": [ | |
| 3 | "api.typesafe.ai" | |
| 4 | ], | |
| 2 | 5 | "app": { |
| 3 | 6 | "run": "bun run src/server.ts", |
| 4 | "test": "bun run check", | |
| 7 | "test": "bun run test", | |
| 5 | 8 | "tty": "bun run src/tty.ts" |
| 6 | 9 | } |
| 7 | 10 | } |
package.json +5 −3
| @@ -1,16 +1,18 @@ | ||
| 1 | 1 | { |
| 2 | "name": "cs", | |
| 2 | "name": "cs-engine", | |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "scripts": { |
| 6 | 6 | "start": "bun run src/server.ts", |
| 7 | 7 | "build": "bun run scripts/build.ts", |
| 8 | "test": "bun test --timeout 20000", | |
| 9 | 8 | "typecheck": "tsc --noEmit", |
| 10 | "check": "tsc --noEmit && bun test --timeout 20000" | |
| 9 | "test": "bun run typecheck && bun test" | |
| 11 | 10 | }, |
| 12 | 11 | "devDependencies": { |
| 13 | 12 | "@types/bun": "^1.4.2", |
| 14 | 13 | "typescript": "^7.0.2" |
| 14 | }, | |
| 15 | "dependencies": { | |
| 16 | "@typesafe-ai/sdk": "^0.6.0" | |
| 15 | 17 | } |
| 16 | 18 | } |
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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 21 | */ |
| 7 | 22 | |
| 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"; | |
| 11 | 26 | |
| 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"); | |
| 13 | 35 | |
| 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 }; | |
| 21 | 77 | } |
| 22 | 78 | |
| 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 | */ | |
| 23 | 343 | 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 | } | |
| 26 | 353 | } |
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 | ||
| 1 | 34 | 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"; | |
| 3 | 37 | 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, | |
| 5 | 55 | } from "./cost.ts"; |
| 6 | 56 | |
| 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"); | |
| 10 | 59 | |
| 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; | |
| 16 | 62 | |
| 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; | |
| 29 | 65 | |
| 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); | |
| 34 | 69 | |
| 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 }; | |
| 76 | 72 | |
| 77 | 73 | /** |
| 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. | |
| 83 | 79 | */ |
| 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([]); | |
| 88 | 281 | }); |
scripts/cost.ts +369 −139
| @@ -1,159 +1,389 @@ | ||
| 1 | 1 | /** |
| 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: | |
| 7 | 3 | * |
| 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 | |
| 12 | 6 | * |
| 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`. | |
| 19 | 36 | */ |
| 20 | 37 | |
| 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"; | |
| 25 | 42 | |
| 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, ".."); | |
| 28 | 44 | |
| 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"); | |
| 73 | 48 | |
| 74 | 49 | /** |
| 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. | |
| 79 | 54 | */ |
| 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[] }; | |
| 82 | 56 | |
| 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 !== "")]); | |
| 88 | 76 | } |
| 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 })); | |
| 103 | 78 | } |
| 104 | 79 | |
| 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(", "); | |
| 108 | 90 | 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, | |
| 113 | 377 | ]; |
| 114 | 378 | } |
| 115 | 379 | |
| 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); | |
| 138 | 388 | } |
| 139 | return millis; | |
| 140 | 389 | } |
| 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 4 | 8 | * |
| 5 | 9 | * bun run scripts/train.ts --bench # how fast the arena steps |
| 6 | 10 | * bun run scripts/train.ts --iters 300 # train and write the weights |
| 7 | 11 | * 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 | |
| 9 | 15 | * |
| 10 | 16 | * Nothing here is imported by the server or the page: this file runs on a |
| 11 | 17 | * machine with a few minutes to spare, and its only output is a table of |
| 12 | 18 | * 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. | |
| 13 | 29 | */ |
| 14 | 30 | |
| 15 | 31 | import { writeFileSync } from "node:fs"; |
| 16 | import { join } from "node:path"; | |
| 32 | import { join, resolve } from "node:path"; | |
| 17 | 33 | 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"; | |
| 21 | 39 | import { OBS_SIZE } from "../src/client/observe.ts"; |
| 40 | import { SHAPE } from "../src/client/packed.ts"; | |
| 22 | 41 | 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, | |
| 29 | 44 | } 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"; | |
| 57 | 49 | |
| 58 | 50 | /** |
| 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. | |
| 65 | 55 | */ |
| 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 }; | |
| 89 | 57 | |
| 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; | |
| 100 | 130 | }; |
| 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; | |
| 101 | 159 | } |
| 102 | 160 | |
| 103 | 161 | // --- the loop ------------------------------------------------------------- |
| 104 | 162 | |
| 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)); | |
| 107 | 167 | const obs = new Float32Array(OBS_SIZE); |
| 108 | 168 | const policy = createPolicy(SHAPE, rng(1)); |
| 109 | 169 | const act = newActivations(SHAPE); |
| @@ -124,138 +184,213 @@ function benchmark(): void { | ||
| 124 | 184 | console.log(`${steps} steps in ${seconds.toFixed(2)}s = ${(steps / seconds / 1000).toFixed(0)}k steps/s (observe + policy + arena)`); |
| 125 | 185 | } |
| 126 | 186 | |
| 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; | |
| 128 | 189 | const hyper: Hyper = { ...DEFAULT_HYPER, minibatch: Math.max(256, (ENVS * STEPS) / 4) }; |
| 129 | 190 | 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); | |
| 139 | 192 | 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())); | |
| 145 | 234 | |
| 146 | 235 | 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`); | |
| 147 | 239 | 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}`, | |
| 153 | 242 | ); |
| 154 | 243 | |
| 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; | |
| 156 | 248 | const started = Bun.nanoseconds(); |
| 157 | 249 | let envSteps = 0; |
| 158 | let sinceLog = noTotals(); | |
| 159 | 250 | |
| 160 | 251 | 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. | |
| 165 | 256 | 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; | |
| 186 | 260 | } |
| 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); | |
| 187 | 301 | |
| 188 | sinceLog = addTotals(sinceLog, t); | |
| 189 | 302 | if (iter % 10 === 0 || iter === 1) { |
| 303 | const mean = returns.length === 0 ? 0 : returns.reduce((a, b) => a + b, 0) / returns.length; | |
| 190 | 304 | 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()); | |
| 193 | 305 | 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)}`, | |
| 202 | 311 | ); |
| 203 | sinceLog = noTotals(); | |
| 312 | returns = []; | |
| 313 | wins = 0; losses = 0; finished = 0; | |
| 314 | aimSum = 0; aimN = 0; fireSum = 0; fireN = 0; | |
| 204 | 315 | } |
| 205 | 316 | } |
| 206 | 317 | |
| 207 | 318 | const seconds = (Bun.nanoseconds() - started) / 1e9; |
| 208 | pool.close(); | |
| 209 | 319 | const packed = pack(policy); |
| 210 | 320 | const quantised = unpack(packed); |
| 211 | 321 | const trained = evaluate(greedyDriver(policy), 240, SEED + 555); |
| 212 | 322 | const shipped = evaluate(greedyDriver(quantised), 240, SEED + 555); |
| 213 | 323 | 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)`); | |
| 218 | 337 | |
| 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({ | |
| 222 | 344 | trainedAt: new Date().toISOString().slice(0, 10), |
| 223 | 345 | decisions: envSteps, |
| 224 | 346 | envSteps: envSteps * SKIP, |
| 225 | 347 | updates: ITERS, |
| 226 | 348 | stepsPerSecond: Math.round((envSteps * SKIP) / seconds), |
| 227 | 349 | minutes: Math.round(seconds / 6) / 10, |
| 228 | workers: pool.shards, | |
| 229 | 350 | 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), | |
| 232 | 358 | })); |
| 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/"); | |
| 234 | 361 | } |
| 235 | 362 | |
| 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. | |
| 252 | 368 | * |
| 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. | |
| 256 | 371 | */ |
| 372 | export const policyDocument = (packed: Packed): string => `${JSON.stringify(packed)}\n`; | |
| 257 | 373 | |
| 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 | */ | |
| 259 | 394 | |
| 260 | 395 | export type Card = { |
| 261 | 396 | trainedAt: string; |
| @@ -264,24 +399,34 @@ export type Card = { | ||
| 264 | 399 | updates: number; |
| 265 | 400 | stepsPerSecond: number; |
| 266 | 401 | minutes: number; |
| 267 | workers: number; | |
| 402 | /** Duels evaluated on de_forge, for \`winRate\` and \`baselineWinRate\`. */ | |
| 268 | 403 | duels: number; |
| 269 | 404 | winRate: number; |
| 270 | 405 | 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; | |
| 271 | 414 | }; |
| 272 | 415 | |
| 273 | 416 | 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 | }; | |
| 281 | 417 | `; |
| 282 | 418 | } |
| 283 | 419 | |
| 284 | 420 | 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); | |
| 287 | 432 | } |
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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 7 | 14 | */ |
| 8 | 15 | |
| 9 | 16 | 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"; | |
| 11 | 24 | |
| 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 | } | |
| 19 | 83 | } |
| 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); | |
| 25 | 86 | }); |
| 26 | 87 | |
| 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 | }); | |
| 29 | 94 | }); |
| 30 | 95 | |
| 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 | }); | |
| 37 | 122 | }); |
| 38 | 123 | |
| 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 | }); | |
| 44 | 135 | }); |
| 45 | 136 | |
| 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); | |
| 64 | 146 | }); |
| 65 | 147 | |
| 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 | }; | |
| 71 | 151 | |
| 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"); | |
| 74 | 170 | }); |
| 75 | 171 | |
| 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 | }); | |
| 90 | 187 | }); |
| 91 | 188 | |
| 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 | } | |
| 94 | 223 | }); |
src/client/action.ts +23 −11
| @@ -13,9 +13,12 @@ | ||
| 13 | 13 | import type { WeaponId } from "./weapons.ts"; |
| 14 | 14 | import type { Input } from "./world.ts"; |
| 15 | 15 | |
| 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]; | |
| 19 | 22 | |
| 20 | 23 | /** The heads, in the order the policy writes them. */ |
| 21 | 24 | 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 = { | ||
| 36 | 39 | yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false, |
| 37 | 40 | }; |
| 38 | 41 | |
| 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]; | |
| 42 | 51 | |
| 43 | 52 | /** Turn one sample from the policy into an intent. */ |
| 44 | 53 | export function decode(actions: Int32Array, at = 0): Intent { |
| 45 | const posture = actions[at + 5] as number; | |
| 54 | const posture = actions[at + 5]; | |
| 46 | 55 | 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]), | |
| 51 | 60 | fire: actions[at + 4] === 1, |
| 52 | 61 | walk: posture === 1, |
| 53 | 62 | duck: posture === 2, |
| @@ -69,6 +78,9 @@ export function toInput(intent: Intent, reload: boolean, switchTo: WeaponId | nu | ||
| 69 | 78 | reload, |
| 70 | 79 | use: false, |
| 71 | 80 | 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, | |
| 72 | 84 | switchTo, |
| 73 | 85 | }; |
| 74 | 86 | } |
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 | ||
| 8 | 1 | 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"; | |
| 12 | 7 | |
| 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 | ]; | |
| 14 | 17 | |
| 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 | : number; | |
| 20 | readonly events: Event[] = []; | |
| 21 | constructor(initial: number) { | |
| 22 | this. = initial; | |
| 23 | } | |
| 24 | get value(): number { | |
| 25 | return this.; | |
| 26 | } | |
| 27 | set value(v: number) { | |
| 28 | this. = v; | |
| 29 | this.events.push({ method: "value", value: v, time: NaN }); | |
| 30 | } | |
| 31 | setValueAtTime(value: number, time: number): FakeParam { | |
| 32 | this. = value; | |
| 33 | this.events.push({ method: "setValueAtTime", value, time }); | |
| 34 | return this; | |
| 35 | } | |
| 36 | exponentialRampToValueAtTime(value: number, time: number): FakeParam { | |
| 37 | this. = 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; | |
| 41 | 33 | } |
| 42 | 34 | |
| 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); | |
| 44 | 46 | |
| 45 | class FakeNode { | |
| 46 | readonly id: number; | |
| 47 | readonly kind: string; | |
| 48 | : (edge: Edge) => void; | |
| 49 | constructor(id: number, kind: string, onConnect: (edge: Edge) => void) { | |
| 50 | this.id = id; | |
| 51 | this.kind = kind; | |
| 52 | this. = onConnect; | |
| 53 | } | |
| 54 | connect(dest: FakeNode): FakeNode { | |
| 55 | this.({ 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); | |
| 57 | 116 | } |
| 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); | |
| 58 | 139 | } |
| 59 | 140 | |
| 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 | |
| 66 | 157 | |
| 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 | ]); | |
| 73 | 166 | |
| 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); | |
| 82 | 172 | |
| 83 | class FakeBufferSource extends FakeNode { | |
| 84 | buffer: AudioBuffer | null = null; | |
| 85 | readonly playbackRate = new FakeParam(1); | |
| 86 | : (id: number) => void; | |
| 87 | : (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; | |
| 91 | this. = onStop; | |
| 92 | } | |
| 93 | start(_time: number): void { | |
| 94 | this.(this.id); | |
| 95 | } | |
| 96 | stop(_time: number): void { | |
| 97 | this.(this.id); | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | class FakeOscillator extends FakeNode { | |
| 102 | type = "sine"; | |
| 103 | readonly frequency = new FakeParam(440); | |
| 104 | : (id: number) => void; | |
| 105 | : (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; | |
| 109 | this. = onStop; | |
| 110 | } | |
| 111 | start(_time: number): void { | |
| 112 | this.(this.id); | |
| 113 | } | |
| 114 | stop(_time: number): void { | |
| 115 | this.(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 | = 0; | |
| 129 | ||
| 130 | constructor() { | |
| 131 | this.destination = new FakeNode(this.(), "destination", (e) => this.edges.push(e)); | |
| 132 | } | |
| 133 | (): number { | |
| 134 | this. += 1; | |
| 135 | return this.; | |
| 136 | } | |
| 137 | <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.(new FakeGain(this.(), (e) => this.edges.push(e))); | |
| 147 | } | |
| 148 | createStereoPanner(): FakeStereoPanner { | |
| 149 | return this.(new FakeStereoPanner(this.(), (e) => this.edges.push(e))); | |
| 150 | } | |
| 151 | createBiquadFilter(): FakeBiquadFilter { | |
| 152 | return this.(new FakeBiquadFilter(this.(), (e) => this.edges.push(e))); | |
| 153 | } | |
| 154 | createBufferSource(): FakeBufferSource { | |
| 155 | return this.( | |
| 156 | new FakeBufferSource(this.(), (e) => this.edges.push(e), (id) => this.started.push(id), (id) => this.stopped.push(id)), | |
| 157 | ); | |
| 158 | } | |
| 159 | createOscillator(): FakeOscillator { | |
| 160 | return this.( | |
| 161 | new FakeOscillator(this.(), (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}`); | |
| 191 | 178 | }); |
| 192 | 179 | |
| 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); | |
| 199 | 193 | }); |
| 200 | 194 | |
| 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"); | |
| 209 | 203 | }); |
| 210 | 204 | |
| 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); | |
| 226 | 218 | }); |
| 227 | 219 | |
| 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); | |
| 236 | 224 | }); |
| 237 | 225 | |
| 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); | |
| 246 | 252 | }); |
| 247 | 253 | |
| 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 | |
| 260 | 263 | }); |
| 261 | 264 | |
| 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); | |
| 268 | 271 | |
| 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)]); | |
| 334 | 280 | } |
| 335 | 281 | }); |
| 336 | 282 | |
| 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); | |
| 347 | 294 | }); |
| 348 | 295 | |
| 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); | |
| 361 | 303 | }); |
src/client/audio.ts +230 −278
| @@ -1,36 +1,53 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 5 | 19 | */ |
| 6 | 20 | |
| 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"; | |
| 9 | 22 | 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"; | |
| 14 | 24 | |
| 15 | 25 | 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; | |
| 21 | 26 | /** |
| 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. | |
| 27 | 30 | */ |
| 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; | |
| 29 | 49 | 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; | |
| 34 | 51 | }; |
| 35 | 52 | |
| 36 | 53 | const TONE: Record<string, { cut: number; thump: number; length: number }> = { |
| @@ -39,278 +56,213 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = { | ||
| 39 | 56 | rifle: { cut: 1500, thump: 110, length: 0.24 }, |
| 40 | 57 | sniper: { cut: 1000, thump: 80, length: 0.5 }, |
| 41 | 58 | 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 }, | |
| 42 | 62 | }; |
| 43 | 63 | |
| 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; | |
| 60 | 67 | |
| 61 | 68 | /** |
| 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. | |
| 66 | 71 | */ |
| 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; | |
| 72 | 74 | |
| 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; | |
| 76 | 86 | |
| 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; | |
| 94 | 94 | |
| 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 { | |
| 95 | 138 | const audio: Audio = { |
| 96 | 139 | get muted() { |
| 97 | return muted; | |
| 140 | return device.muted; | |
| 98 | 141 | }, |
| 99 | setMuted(next) { | |
| 100 | muted = next; | |
| 101 | applyGain(); | |
| 142 | set muted(value: boolean) { | |
| 143 | device.muted = value; | |
| 102 | 144 | }, |
| 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 | }); | |
| 106 | 165 | }, |
| 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 }); | |
| 169 | 260 | }, |
| 170 | 261 | hit(headshot) { |
| 171 | 262 | audio.beep(headshot ? 1400 : 900, 0.08); |
| 172 | 263 | }, |
| 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 | }, | |
| 220 | 264 | 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 }); | |
| 314 | 266 | }, |
| 315 | 267 | }; |
| 316 | 268 | 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { PACKED, useShipped } from "./shipped.ts"; | |
| 3 | import { unpack, type Policy } from "../engine/index.ts"; | |
| 2 | 4 | 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, | |
| 5 | 7 | } 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 | }); | |
| 16 | 39 | |
| 17 | 40 | 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); | |
| 19 | 42 | world.match.phase = "live"; |
| 20 | 43 | const me = world.me; |
| 21 | 44 | 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", () => { | ||
| 64 | 87 | expect(fired).toBeGreaterThan(0); |
| 65 | 88 | }); |
| 66 | 89 | |
| 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 | ||
| 67 | 120 | test("skill is a handicap on the hands, not on the policy", () => { |
| 68 | 121 | const fast = makeBrain(1, 7); |
| 69 | 122 | const slow = makeBrain(0, 7); |
| 70 | 123 | expect(slow.aimSpeed).toBeLessThan(fast.aimSpeed); |
| 71 | 124 | expect(slow.aimError).toBeGreaterThan(fast.aimError); |
| 72 | 125 | 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); | |
| 73 | 131 | |
| 74 | 132 | // The same intent, asked of both: the slow one cannot turn as far. |
| 75 | 133 | const { world, bot } = duel(); |
| @@ -91,51 +149,52 @@ test("a bot that has only just seen you does not shoot yet", () => { | ||
| 91 | 149 | expect(immediate.fire).toBe(false); |
| 92 | 150 | }); |
| 93 | 151 | |
| 94 | // --- sniping ------------------------------------------------------------- | |
| 152 | // --- sniping --------------------------------------------------------------- | |
| 95 | 153 | |
| 96 | 154 | /** |
| 97 | 155 | * A bot with an AWP `range` units down the long open line north of the |
| 98 | 156 | * person, looking straight at them and past its reaction beat. The person |
| 99 | 157 | * stands still: what is measured is the bot's shot, not a moving target. |
| 100 | 158 | */ |
| 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; | |
| 114 | 171 | } |
| 115 | 172 | |
| 116 | 173 | 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); | |
| 121 | 176 | }); |
| 122 | 177 | |
| 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); | |
| 128 | 181 | }); |
| 129 | 182 | |
| 130 | 183 | test("a rifle is never scoped, and an easy bot fires the AWP from the hip", () => { |
| 131 | 184 | const rifle = sniper(1200); |
| 132 | 185 | rifle.bot.weapon = "ak47"; |
| 133 | 186 | rifle.bot.primary = "ak47"; |
| 187 | rifle.bot.ammo = { ...rifle.bot.ammo, ak47: { mag: 30, reserve: 90 } }; | |
| 134 | 188 | expect(botThink(rifle.world, rifle.bot, 1 / 64).scope).toBe(false); |
| 135 | 189 | const easy = sniper(1200, SCOPE_SKILL - 0.05); |
| 136 | 190 | expect(botThink(easy.world, easy.bot, 1 / 64).scope).toBe(false); |
| 137 | 191 | }); |
| 138 | 192 | |
| 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 | ||
| 139 | 198 | test("the scope decision holds across the band between the two ranges", () => { |
| 140 | 199 | const { world, bot } = sniper(1200); |
| 141 | 200 | 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", () => { | ||
| 149 | 208 | for (let t = 0; t < 32; t++) expect(botThink(world, bot, 1 / 64).scope).toBe(false); |
| 150 | 209 | }); |
| 151 | 210 | |
| 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); | |
| 160 | 213 | const dt = 1 / 64; |
| 161 | 214 | const scopedSides: number[] = []; |
| 162 | 215 | const shots: number[] = []; |
| @@ -164,10 +217,10 @@ function snipeFor(learned: boolean): { scopedSides: number[]; shots: number[] } | ||
| 164 | 217 | const input = botThink(world, bot, dt); |
| 165 | 218 | const before = bot.lastShotAt; |
| 166 | 219 | applyInput(world, bot, input, dt); |
| 167 | if (bot.scoped) scopedSides.push(input.side); | |
| 220 | if (bot.scoped) scopedSides.push(input.side, input.forward); | |
| 168 | 221 | if (bot.lastShotAt !== before) { |
| 169 | 222 | 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), | |
| 171 | 224 | inAir: !bot.onGround, |
| 172 | 225 | ducking: bot.duckAmount > 0.5, |
| 173 | 226 | scoped: bot.scoped, |
| @@ -175,19 +228,223 @@ function snipeFor(learned: boolean): { scopedSides: number[]; shots: number[] } | ||
| 175 | 228 | } |
| 176 | 229 | world.time += dt; |
| 177 | 230 | } |
| 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 | }); | |
| 180 | 238 | |
| 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); | |
| 190 | 267 | } |
| 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); | |
| 191 | 448 | }); |
| 192 | 449 | |
| 193 | 450 | // --- reloading -------------------------------------------------------------- |
| @@ -203,7 +460,7 @@ test("a bot alone with a half-empty magazine tops it up", () => { | ||
| 203 | 460 | }); |
| 204 | 461 | |
| 205 | 462 | 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 | |
| 207 | 464 | bot.primary = "ak47"; |
| 208 | 465 | bot.weapon = "ak47"; |
| 209 | 466 | 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", () => { | ||
| 212 | 469 | }); |
| 213 | 470 | |
| 214 | 471 | 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(); | |
| 216 | 473 | bot.primary = "ak47"; |
| 217 | 474 | bot.weapon = "ak47"; |
| 218 | 475 | 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 | ||
| 221 | 478 | expect(input.reload).toBe(false); |
| 222 | 479 | }); |
| 223 | 480 | |
| 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 | ||
| 224 | 492 | test("a bot with a full magazine asks for nothing", () => { |
| 225 | 493 | const { world, me, bot } = duel(); |
| 226 | 494 | me.alive = false; |
| @@ -232,643 +500,169 @@ test("a bot with a full magazine asks for nothing", () => { | ||
| 232 | 500 | expect(input.switchTo).toBeNull(); |
| 233 | 501 | }); |
| 234 | 502 | |
| 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); | |
| 253 | 507 | 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; | |
| 435 | 508 | 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); | |
| 441 | 513 | }); |
| 442 | 514 | |
| 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 }, | |
| 451 | 523 | }); |
| 452 | 524 | |
| 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"; | |
| 682 | 529 | 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 | } | |
| 816 | 530 | }; |
| 817 | 531 | |
| 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 ?? ""); | |
| 823 | 543 | }); |
| 824 | 544 | |
| 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); | |
| 832 | 556 | }); |
| 833 | 557 | |
| 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); | |
| 843 | 565 | }); |
| 844 | 566 | |
| 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; | |
| 867 | 574 | 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); | |
| 874 | 668 | }); |
src/client/bots.ts +606 −427
| @@ -7,68 +7,122 @@ | ||
| 7 | 7 | * The split is deliberate. The round's objective is a plan (go to a site, plant |
| 8 | 8 | * the bomb, defuse it) and A* already solves it. The fight is a reflex, and a |
| 9 | 9 | * 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. | |
| 12 | 35 | */ |
| 13 | 36 | |
| 14 | 37 | 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"; | |
| 20 | 38 | 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"; | |
| 25 | 52 | 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"; | |
| 27 | 55 | |
| 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 | */ | |
| 31 | 61 | 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 | */ | |
| 32 | 77 | export function policy(): Policy { |
| 33 | if (learned === null) learned = unpack(PACKED); | |
| 78 | if (learned === null) throw new Error(NO_POLICY); | |
| 34 | 79 | return learned; |
| 35 | 80 | } |
| 36 | 81 | |
| 37 | /** The policy decides at 16 Hz and holds it, exactly as it was trained. */ | |
| 38 | const SKIP = 4; | |
| 39 | ||
| 40 | 82 | /** |
| 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. | |
| 44 | 92 | */ |
| 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; | |
| 62 | 95 | } |
| 63 | 96 | |
| 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; | |
| 66 | 116 | |
| 67 | 117 | export type BotBrain = { |
| 68 | 118 | skill: number; // 0 = fodder, 1 = uncomfortable |
| 69 | 119 | reaction: number; // seconds between seeing and shooting |
| 70 | 120 | aimSpeed: number; // degrees a second |
| 71 | 121 | 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]; | |
| 72 | 126 | goal: Vec3 | null; |
| 73 | 127 | path: Vec3[]; |
| 74 | 128 | step: number; |
| @@ -80,13 +134,16 @@ export type BotBrain = { | ||
| 80 | 134 | strafe: number; |
| 81 | 135 | strafeUntil: number; |
| 82 | 136 | suspect: Vec3 | null; |
| 83 | /** The world clock the last time this bot listened: what came after it is news. */ | |
| 84 | heardAt: number; | |
| 85 | 137 | 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; | |
| 90 | 147 | reset: boolean; |
| 91 | 148 | random: () => number; |
| 92 | 149 | // What the policy needs to run, one set per bot so nothing is shared. |
| @@ -103,6 +160,15 @@ export type BotBrain = { | ||
| 103 | 160 | scoping: boolean; |
| 104 | 161 | /** False runs the pre-policy fight: the arena's opponent and its baseline. */ |
| 105 | 162 | 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; | |
| 106 | 172 | }; |
| 107 | 173 | |
| 108 | 174 | export function makeBrain(skill: number, seed: number, useLearned = true): BotBrain { |
| @@ -111,13 +177,20 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 111 | 177 | reaction: 0.5 - skill * 0.33, |
| 112 | 178 | aimSpeed: 180 + skill * 620, |
| 113 | 179 | aimError: 4.5 - skill * 3.6, |
| 180 | grip: grip(skill), | |
| 181 | gripRate: gripRate(skill), | |
| 182 | pull: [0, 0], | |
| 114 | 183 | goal: null, path: [], step: 0, repathAt: 0, |
| 115 | 184 | 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, | |
| 118 | 188 | random: rng(seed), |
| 119 | 189 | 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), | |
| 121 | 194 | actions: new Int32Array(HEADS.length), |
| 122 | 195 | memory: { sinceSeen: 9 }, |
| 123 | 196 | intent: { ...STILL }, |
| @@ -127,12 +200,95 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 127 | 200 | jitterUntil: 0, |
| 128 | 201 | scoping: false, |
| 129 | 202 | useLearned, |
| 203 | cover: null, | |
| 204 | coverStep: 0, | |
| 205 | coverFor: -1, | |
| 206 | coverLook: null, | |
| 130 | 207 | }; |
| 131 | 208 | } |
| 132 | 209 | |
| 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 | ||
| 133 | 281 | /** What a bot spends its money on, roughly in the order a person would. */ |
| 134 | 282 | export function botBuy(world: World, bot: Player): void { |
| 135 | 283 | 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 | } | |
| 136 | 292 | const rich = bot.money >= 4000; |
| 137 | 293 | const wants: WeaponId[] = []; |
| 138 | 294 | 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 { | ||
| 144 | 300 | if (bot.team === "ct" && !bot.defuseKit && bot.money >= 400 && brain.random() < 0.6) buy(world, bot, "kit"); |
| 145 | 301 | } |
| 146 | 302 | |
| 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; | |
| 150 | 334 | } |
| 151 | return bot.brain; | |
| 335 | return null; | |
| 152 | 336 | } |
| 153 | 337 | |
| 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 | ||
| 261 | 338 | /** |
| 262 | 339 | * A magazine at this share of capacity or lower is worth topping up between |
| 263 | 340 | * fights. Half a mag is still a whole engagement's worth of bullets — a duel |
| @@ -265,158 +342,169 @@ const HUNT_SPREAD = 700; | ||
| 265 | 342 | * lower would top up mid-fight almost as often as waiting for empty does; any |
| 266 | 343 | * higher and a bot interrupts a walk to reload a gun that did not need it. |
| 267 | 344 | */ |
| 268 | const TOP_UP_SHARE = 0.5; | |
| 345 | export const TOP_UP_SHARE = 0.5; | |
| 269 | 346 | |
| 270 | 347 | /** |
| 271 | 348 | * Ask for a reload while nobody is watching. Only ever called from the branch |
| 272 | 349 | * 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. | |
| 275 | 351 | */ |
| 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]; | |
| 279 | 355 | if (ammo === undefined || w === undefined || w.magazine === 0) return; |
| 280 | 356 | if (ammo.mag <= w.magazine * TOP_UP_SHARE && ammo.reserve > 0) input.reload = true; |
| 281 | 357 | } |
| 282 | 358 | |
| 283 | 359 | /** |
| 284 | 360 | * 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. | |
| 288 | 364 | */ |
| 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]; | |
| 291 | 367 | if (ammo === undefined || ammo.mag > 0) return; |
| 292 | 368 | 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; | |
| 306 | 371 | } |
| 307 | 372 | |
| 308 | 373 | /** |
| 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. | |
| 315 | 377 | */ |
| 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); | |
| 328 | 384 | } |
| 329 | return site; | |
| 385 | return bot.brain; | |
| 330 | 386 | } |
| 331 | 387 | |
| 332 | 388 | /** |
| 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`. | |
| 337 | 398 | */ |
| 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; | |
| 366 | 430 | } |
| 367 | 431 | |
| 368 | 432 | /** Where this bot is trying to be, given what the round is doing. */ |
| 369 | 433 | function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { |
| 370 | 434 | 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]; | |
| 377 | 438 | if (bot.team === "t") { |
| 378 | 439 | if (bomb.state === "planted") { |
| 379 | 440 | // 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); | |
| 381 | 442 | } |
| 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; | |
| 385 | 444 | 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); | |
| 387 | 448 | } |
| 388 | 449 | 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); | |
| 402 | 451 | } |
| 403 | 452 | |
| 404 | const nearby = (world: World, brain: BotBrain, at: Vec3, spread: number): Vec3 => { | |
| 453 | const nearby = (level: Level, brain: BotBrain, at: Vec3, spread: number): Vec3 => { | |
| 405 | 454 | for (let tries = 0; tries < 8; tries++) { |
| 406 | 455 | const x = at[0] + (brain.random() * 2 - 1) * spread; |
| 407 | 456 | 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]]; | |
| 409 | 458 | } |
| 410 | 459 | return at; |
| 411 | 460 | }; |
| 412 | 461 | |
| 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 | ||
| 413 | 498 | function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null { |
| 414 | 499 | let best: Player | null = null; |
| 415 | 500 | 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; | |
| 416 | 504 | for (const other of world.players) { |
| 417 | 505 | if (!other.alive || other.team === bot.team) continue; |
| 418 | 506 | const d = dist(bot.pos, other.pos); |
| 419 | if (d > 4000) continue; | |
| 507 | if (d > sight) continue; | |
| 420 | 508 | if (!canSee(world, bot, other)) continue; |
| 421 | 509 | const aim = aimAt(bot, bodyCentre(other)); |
| 422 | 510 | const off = Math.abs(angleDelta(bot.yaw, aim.yaw)); |
| @@ -427,60 +515,82 @@ function pickTarget(world: World, bot: Player, brain: BotBrain): Player | null { | ||
| 427 | 515 | return best; |
| 428 | 516 | } |
| 429 | 517 | |
| 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 ---------------------------------------------------------------- | |
| 436 | 519 | |
| 437 | 520 | /** |
| 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. | |
| 451 | 525 | */ |
| 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]]; | |
| 481 | 566 | } |
| 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]; | |
| 482 | 593 | } |
| 483 | if (from !== null) brain.suspect = reported(bot.pos, from, world.map, brain.random); | |
| 484 | 594 | } |
| 485 | 595 | |
| 486 | 596 | /** |
| @@ -493,13 +603,15 @@ function listen(world: World, bot: Player, brain: BotBrain): void { | ||
| 493 | 603 | export function scriptedFight( |
| 494 | 604 | world: World, bot: Player, brain: BotBrain, target: Player, input: Input, dt: number, |
| 495 | 605 | ): Input { |
| 606 | const hands = flashed(bot, brain); | |
| 496 | 607 | 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; | |
| 498 | 609 | 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; | |
| 503 | 615 | if (ready && onTarget && world.time > brain.restUntil) { |
| 504 | 616 | input.fire = true; |
| 505 | 617 | if (brain.burstUntil < world.time) brain.burstUntil = world.time + 0.15 + brain.skill * 0.35; |
| @@ -524,11 +636,11 @@ export function scriptedFight( | ||
| 524 | 636 | /** |
| 525 | 637 | * Beyond this range, in map units, a bot holding a scoped weapon zooms in to |
| 526 | 638 | * 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. | |
| 532 | 644 | */ |
| 533 | 645 | export const SCOPE_RANGE = 500; |
| 534 | 646 | |
| @@ -558,15 +670,15 @@ export const SCOPED_MOVE = 0.15; | ||
| 558 | 670 | export const STEADY_SPEED = 60; |
| 559 | 671 | |
| 560 | 672 | /** |
| 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. | |
| 566 | 679 | */ |
| 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) { | |
| 570 | 682 | brain.scoping = false; |
| 571 | 683 | return input; |
| 572 | 684 | } |
| @@ -580,6 +692,24 @@ export function snipe(world: World, bot: Player, brain: BotBrain, target: Player | ||
| 580 | 692 | return input; |
| 581 | 693 | } |
| 582 | 694 | |
| 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 | ||
| 583 | 713 | /** |
| 584 | 714 | * Ask the policy what to do about `target`. It decides once every SKIP ticks |
| 585 | 715 | * 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 | ||
| 608 | 738 | * is a bot you can beat; the same policy with steady hands is not. |
| 609 | 739 | */ |
| 610 | 740 | export function applyIntent(world: World, bot: Player, brain: BotBrain, intent: Intent, dt: number): void { |
| 741 | const hands = flashed(bot, brain); | |
| 611 | 742 | if (world.time > brain.jitterUntil) { |
| 612 | brain.jitter = (brain.random() * 2 - 1) * brain.aimError; | |
| 743 | brain.jitter = (brain.random() * 2 - 1) * hands.aimError; | |
| 613 | 744 | brain.jitterUntil = world.time + 0.25 + brain.random() * 0.35; |
| 614 | 745 | } |
| 615 | const limit = brain.aimSpeed; | |
| 746 | const limit = hands.aimSpeed; | |
| 616 | 747 | bot.yaw += clamp(intent.yawRate, -limit, limit) * dt + (brain.jitter - brain.jitterApplied); |
| 617 | 748 | bot.pitch = clamp( |
| 618 | 749 | 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: | ||
| 630 | 761 | bot.pitch = clamp(bot.pitch + clamp(dp, -maxTurn, maxTurn), -89, 89); |
| 631 | 762 | } |
| 632 | 763 | |
| 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 | ||
| 682 | 764 | /** One bot's decision for this tick, expressed as the same input a person gives. */ |
| 683 | 765 | export function botThink(world: World, bot: Player, dt: number): Input { |
| 684 | 766 | const brain = brainOf(world, bot); |
| 685 | 767 | const input: Input = { ...NO_INPUT }; |
| 686 | 768 | if (brain.reset) { |
| 687 | 769 | 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; | |
| 691 | 777 | brain.goal = null; |
| 692 | 778 | brain.path = []; |
| 693 | 779 | brain.targetId = null; |
| 694 | 780 | brain.suspect = null; |
| 695 | brain.heardAt = world.time; | |
| 696 | 781 | brain.plannedFor = ""; |
| 697 | 782 | brain.memory.sinceSeen = 9; |
| 698 | 783 | brain.hold = 0; |
| 699 | 784 | 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; | |
| 700 | 790 | brain.scoping = false; |
| 701 | 791 | input.switchTo = bot.primary ?? bot.secondary; |
| 702 | 792 | } |
| 793 | const job = jobOf(world, bot, brain); | |
| 703 | 794 | 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; | |
| 706 | 799 | return input; |
| 707 | 800 | } |
| 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); | |
| 712 | 801 | |
| 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); | |
| 713 | 813 | listen(world, bot, brain); |
| 714 | 814 | const target = pickTarget(world, bot, brain); |
| 715 | 815 | if (target !== null) { |
| 716 | 816 | 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); | |
| 724 | 817 | brain.targetId = target.id; |
| 725 | 818 | brain.seenAt = world.time; |
| 726 | 819 | } |
| @@ -728,58 +821,144 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 728 | 821 | brain.targetId = null; |
| 729 | 822 | } |
| 730 | 823 | |
| 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 | ||
| 731 | 831 | // --- fighting, which is the policy's job |
| 732 | 832 | 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)); | |
| 736 | 838 | const intent = think(world, bot, brain, target, dt); |
| 737 | 839 | 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); | |
| 739 | 843 | // Two things the policy does not get to decide. A bot that has only just |
| 740 | 844 | // seen you does not shoot yet — that beat is what skill buys. And the |
| 741 | 845 | // policy trained with a rifle, so it holds the trigger down; on a pistol |
| 742 | 846 | // that is one shot and then nothing, so the trigger is released between |
| 743 | 847 | // 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); | |
| 762 | 857 | return withBomb(world, bot, input); |
| 763 | 858 | } |
| 764 | 859 | brain.scoping = false; |
| 765 | 860 | |
| 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 }; | |
| 772 | 899 | 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); | |
| 773 | 903 | input.walk = brain.suspect !== null && dist(bot.pos, brain.suspect) < 900; |
| 904 | } else { | |
| 905 | brain.goal = null; | |
| 774 | 906 | } |
| 775 | 907 | if (brain.suspect !== null && brain.random() < 0.004) brain.suspect = null; |
| 776 | 908 | return withBomb(world, bot, input); |
| 777 | 909 | } |
| 778 | 910 | |
| 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 | ||
| 779 | 958 | /** Planting and defusing: press use when standing where it matters. */ |
| 780 | 959 | function withBomb(world: World, bot: Player, input: Input): Input { |
| 781 | 960 | 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") { | |
| 783 | 962 | input.use = true; |
| 784 | 963 | input.forward = 0; |
| 785 | 964 | 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 | ||
| 1 | 17 | 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"; | |
| 2 | 23 | 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"; | |
| 6 | 26 | |
| 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" } }; | |
| 9 | 29 | |
| 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, | |
| 16 | 37 | }); |
| 17 | 38 | |
| 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"); | |
| 23 | 100 | }); |
| 24 | 101 | |
| 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); | |
| 31 | 140 | }); |
| 32 | 141 | |
| 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"); | |
| 40 | 152 | }); |
| 41 | 153 | |
| 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); | |
| 50 | 168 | }); |
| 51 | 169 | |
| 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(); | |
| 60 | 261 | }); |
src/client/buy.ts +160 −63
| @@ -1,73 +1,170 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 33 | */ |
| 7 | 34 | |
| 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"; | |
| 10 | 40 | |
| 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 }; | |
| 55 | 43 | |
| 56 | 44 | /** |
| 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. | |
| 61 | 48 | */ |
| 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) => { | |
| 66 | 141 | 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 }); | |
| 71 | 150 | }); |
| 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; | |
| 73 | 170 | } |
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 @@ | ||
| 1 | 1 | 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"; | |
| 7 | 10 | |
| 8 | 11 | const actor = (over: Partial<Actor> = {}): Actor => ({ |
| 9 | 12 | 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", () => { | ||
| 21 | 24 | |
| 22 | 25 | test("a bullet at head height hits the head", () => { |
| 23 | 26 | 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); | |
| 25 | 28 | expect(hit.kind).toBe("actor"); |
| 26 | 29 | if (hit.kind === "actor") expect(hit.group).toBe("head"); |
| 27 | 30 | }); |
| 28 | 31 | |
| 29 | 32 | test("a wall in the way stops the bullet before the body", () => { |
| 30 | 33 | 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); | |
| 32 | 35 | expect(hit.kind).toBe("world"); |
| 33 | 36 | if (hit.kind === "world") expect(hit.point[0]).toBeCloseTo(200, 3); |
| 34 | 37 | }); |
| 35 | 38 | |
| 36 | 39 | test("you cannot shoot yourself", () => { |
| 37 | 40 | 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"); | |
| 39 | 42 | }); |
| 40 | 43 | |
| 41 | 44 | 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"); | |
| 43 | 46 | }); |
| 44 | 47 | |
| 45 | 48 | test("two AK headshots are one too many", () => { |
| @@ -51,6 +54,21 @@ test("two AK headshots are one too many", () => { | ||
| 51 | 54 | expect(target.alive).toBe(false); |
| 52 | 55 | }); |
| 53 | 56 | |
| 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 | ||
| 54 | 72 | test("damage credited never exceeds the health that was there", () => { |
| 55 | 73 | const target = actor({ health: 12 }); |
| 56 | 74 | const hit = applyHit(target, WEAPONS.ak47, "chest", 100); |
| @@ -58,76 +76,76 @@ test("damage credited never exceeds the health that was there", () => { | ||
| 58 | 76 | expect(hit.killed).toBe(true); |
| 59 | 77 | }); |
| 60 | 78 | |
| 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 | */ | |
| 62 | 85 | |
| 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 }); | |
| 79 | 99 | }); |
| 80 | 100 | |
| 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 | } | |
| 88 | 115 | } |
| 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 }); | |
| 89 | 119 | }); |
| 90 | 120 | |
| 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); | |
| 95 | 134 | }); |
| 96 | 135 | |
| 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); | |
| 123 | 141 | }); |
| 124 | 142 | |
| 125 | 143 | // --- the knife's back ----------------------------------------------------- |
| 126 | 144 | |
| 127 | 145 | /** 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); | |
| 131 | 149 | return { target, result }; |
| 132 | 150 | }; |
| 133 | 151 | |
| @@ -140,11 +158,11 @@ test("a stab in the back ends a 100/100 player in one; the same stab in the ches | ||
| 140 | 158 | const front = stab([-40, 0, 64], 180); // same spot, they turned round |
| 141 | 159 | expect(front.result.killed).toBe(false); |
| 142 | 160 | 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 | |
| 144 | 162 | }); |
| 145 | 163 | |
| 146 | 164 | 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 }); | |
| 148 | 166 | expect(applyHit(target, WEAPONS.knife, "chest", 48).killed).toBe(false); |
| 149 | 167 | }); |
| 150 | 168 | |
| @@ -159,22 +177,22 @@ test("a rifle in the back is still a rifle", () => { | ||
| 159 | 177 | // --- a crouch-jumper ------------------------------------------------------- |
| 160 | 178 | |
| 161 | 179 | 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 }; | |
| 164 | 182 | 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); | |
| 167 | 185 | expect(body.duckAmount).toBe(1); |
| 168 | 186 | expect(body.onGround).toBe(false); |
| 169 | 187 | 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); | |
| 171 | 189 | const head = hitboxes(target).find((p) => p.group === "head") as { max: Vec3 }; |
| 172 | 190 | 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); | |
| 176 | 194 | 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); | |
| 179 | 197 | expect(low.kind).toBe("none"); |
| 180 | 198 | }); |
src/client/combat.ts +159 −139
| @@ -2,13 +2,23 @@ | ||
| 2 | 2 | * Hitboxes and what a bullet does when it reaches one. A body is four boxes |
| 3 | 3 | * stacked from the feet, scaled by how far the player is crouched, so a |
| 4 | 4 | * 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. | |
| 5 | 10 | */ |
| 6 | 11 | |
| 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; | |
| 12 | 22 | |
| 13 | 23 | export type Actor = { |
| 14 | 24 | id: number; |
| @@ -33,7 +43,7 @@ const PARTS: [Hitgroup, number, number, number][] = [ | ||
| 33 | 43 | ]; |
| 34 | 44 | |
| 35 | 45 | 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; | |
| 37 | 47 | return PARTS.map(([group, lo, hi, w]) => ({ |
| 38 | 48 | group, |
| 39 | 49 | 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[] { | ||
| 41 | 51 | })); |
| 42 | 52 | } |
| 43 | 53 | |
| 44 | /** The face a bullet went in by, when it came out the other side. */ | |
| 45 | export type Punch = { point: Vec3; normal: Vec3 }; | |
| 46 | ||
| 47 | 54 | 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 }; | |
| 57 | 58 | |
| 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. */ | |
| 137 | 60 | export function traceBullet( |
| 138 | 61 | from: Vec3, |
| 139 | 62 | to: Vec3, |
| 140 | 63 | boxes: Box[], |
| 141 | 64 | actors: Actor[], |
| 142 | 65 | shooterId: number, |
| 143 | weapon: Weapon, | |
| 144 | 66 | ): 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 }; | |
| 172 | 93 | } |
| 173 | 94 | |
| 174 | 95 | /** |
| 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. | |
| 178 | 100 | */ |
| 179 | export function applyHit( | |
| 101 | export function applyDamage( | |
| 180 | 102 | 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 } { | |
| 190 | 106 | const dealt = Math.min(health, target.health); |
| 191 | 107 | target.armor = Math.max(0, target.armor - armor); |
| 192 | 108 | target.health -= health; |
| @@ -195,5 +111,109 @@ export function applyHit( | ||
| 195 | 111 | target.health = 0; |
| 196 | 112 | target.alive = false; |
| 197 | 113 | } |
| 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; | |
| 199 | 219 | } |
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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 20 | */ |
| 7 | 21 | |
| 8 | 22 | 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"; | |
| 13 | 23 | 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"; | |
| 18 | 36 | |
| 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; | |
| 129 | 42 | } |
| 130 | 43 | |
| 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); | |
| 136 | 74 | return world; |
| 137 | }; | |
| 75 | } | |
| 138 | 76 | |
| 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; | |
| 156 | 79 | |
| 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, | |
| 397 | 95 | ); |
| 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; | |
| 495 | 97 | } |
| 496 | 98 | |
| 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); | |
| 500 | 102 | |
| 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])); | |
| 502 | 104 | |
| 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); | |
| 510 | 106 | |
| 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 | } | |
| 516 | 113 | |
| 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 --------------------------------------------------------- | |
| 524 | 115 | |
| 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 | } | |
| 532 | 132 | |
| 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); | |
| 541 | 138 | |
| 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; | |
| 547 | 141 | |
| 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, | |
| 698 | 148 | }); |
| 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); | |
| 703 | 176 | }); |
| 704 | 177 | |
| 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"); | |
| 723 | 980 | }); |
src/client/hud.ts +439 −565
| @@ -1,669 +1,543 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 5 | 7 | * |
| 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. | |
| 9 | 26 | */ |
| 10 | 27 | |
| 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"; | |
| 24 | 28 | 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"; | |
| 31 | 40 | |
| 32 | 41 | /** |
| 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. | |
| 35 | 46 | */ |
| 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 | }; | |
| 37 | 54 | |
| 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; | |
| 40 | 61 | |
| 41 | 62 | /** |
| 42 | 63 | * 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. | |
| 48 | 66 | */ |
| 49 | const NOT_READY_ALPHA = 0.35; | |
| 67 | export const NOT_READY_ALPHA = 0.35; | |
| 50 | 68 | |
| 51 | 69 | /** |
| 52 | 70 | * 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. | |
| 55 | 73 | */ |
| 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]]; | |
| 58 | 79 | |
| 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"; | |
| 64 | 84 | |
| 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}`; | |
| 77 | 103 | } |
| 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; | |
| 79 | 130 | } |
| 80 | 131 | |
| 81 | 132 | /** |
| 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 — | |
| 87 | 135 | * |
| 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) | |
| 267 | 137 | * |
| 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. | |
| 272 | 145 | */ |
| 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); | |
| 298 | 147 | |
| 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; | |
| 331 | 212 | } |
| 332 | 213 | |
| 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 | */ | |
| 333 | 244 | export function drawHud( |
| 334 | ctx: CanvasRenderingContext2D, | |
| 245 | ctx: Paint, | |
| 335 | 246 | world: World, |
| 336 | view: Player, | |
| 337 | camera: Camera, | |
| 338 | 247 | width: number, |
| 339 | 248 | height: number, |
| 340 | 249 | showScoreboard: boolean, |
| 341 | crosshair: CrosshairStyle = DEFAULT_CROSSHAIR, | |
| 342 | alpha = 1, | |
| 250 | view: Player = world.me, | |
| 251 | dpr = 1, | |
| 343 | 252 | motion: Motion = DEFAULT_MOTION, |
| 344 | 253 | ): void { |
| 254 | const ui = toolsFor(ctx, width, height, dpr); | |
| 345 | 255 | ctx.clearRect(0, 0, width, height); |
| 346 | 256 | 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"; | |
| 353 | 257 | 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; | |
| 355 | 263 | |
| 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 | } | |
| 358 | 280 | |
| 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) { | |
| 364 | 283 | const speed = Math.hypot(view.vel[0], view.vel[1]); |
| 365 | 284 | const spread = inaccuracy(weapon, { |
| 366 | moving: clamp(speed / MOVE.maxSpeed, 0, 1), | |
| 285 | moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1), | |
| 367 | 286 | inAir: !view.onGround, |
| 368 | 287 | ducking: view.duckAmount > 0.5, |
| 369 | 288 | scoped: view.scoped, |
| 370 | 289 | }); |
| 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)"; | |
| 382 | 297 | 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"); | |
| 385 | 301 | } |
| 386 | 302 | } |
| 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 | } | |
| 396 | 303 | |
| 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); | |
| 421 | 317 | ctx.beginPath(); |
| 422 | for (const [dx, dy] of [[1, 1], [-1, -1], [1, -1], [-1, 1]]) { | |
| 318 | for (const [dx, dy] of PLUS_ARMS) { | |
| 423 | 319 | ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55); |
| 424 | 320 | ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r); |
| 425 | 321 | } |
| 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); | |
| 435 | 389 | ctx.stroke(); |
| 436 | 390 | } |
| 437 | 391 | } |
| 438 | 392 | |
| 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); | |
| 459 | 397 | |
| 460 | 398 | // --- round clock and score |
| 461 | 399 | 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)); | |
| 465 | 401 | 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 }); | |
| 476 | 411 | |
| 477 | 412 | // --- 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", | |
| 486 | 417 | pad, height - pad - Math.round(30 * scale), |
| 418 | { align: "left", font: small, fill: BLUE }, | |
| 487 | 419 | ); |
| 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 }); | |
| 512 | 427 | |
| 513 | 428 | // --- 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; | |
| 521 | 434 | 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" }); | |
| 535 | 455 | |
| 536 | 456 | // --- who is left |
| 537 | ctx.textAlign = "center"; | |
| 538 | ctx.font = small; | |
| 539 | 457 | const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length; |
| 540 | 458 | 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 }); | |
| 545 | 462 | |
| 546 | 463 | // --- kill feed |
| 547 | ctx.textAlign = "right"; | |
| 548 | ctx.font = small; | |
| 549 | 464 | world.feed.slice(-5).forEach((entry, i) => { |
| 550 | 465 | const age = world.time - entry.at; |
| 551 | 466 | 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; | |
| 555 | 467 | 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 | }); | |
| 558 | 471 | }); |
| 559 | 472 | |
| 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 | |
| 563 | 474 | 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)" }); | |
| 582 | 477 | |
| 583 | 478 | // --- planting or defusing |
| 584 | 479 | const progress = useProgress(world, view); |
| 585 | 480 | if (progress !== null) { |
| 586 | 481 | const w = Math.round(260 * scale), h = Math.round(14 * scale); |
| 587 | 482 | 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" }); | |
| 595 | 486 | } |
| 596 | 487 | |
| 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 }); | |
| 614 | 508 | } |
| 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 | ); | |
| 621 | 509 | } |
| 622 | 510 | |
| 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); | |
| 628 | 512 | } |
| 629 | 513 | |
| 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; | |
| 637 | 516 | const w = Math.min(width - 40, Math.round(760 * scale)); |
| 638 | 517 | const rowH = Math.round(26 * scale); |
| 639 | const h = rowH * (world.players.length + 5); | |
| 518 | const h = rowH * (world.players.length + 4); | |
| 640 | 519 | 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); | |
| 656 | 525 | |
| 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); | |
| 667 | 541 | ctx.globalAlpha = 1; |
| 668 | 542 | }); |
| 669 | 543 | } |
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.