Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
The types are checked, not just written: one strict tsconfig and a typecheck that runs where the forge can see it #200
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/118-the-types-are-checked-not-just-written-o → main 2 commits fork
README.md +25
| @@ -246,6 +246,31 @@ projections of `src/games.ts` and nobody edited them. The 2D is the point: no | ||
| 246 | 246 | renderer, no atlas, no mesh and no sound ship in that bundle, and |
| 247 | 247 | `src/example/bundle.test.ts` reads the committed file to prove it. |
| 248 | 248 | |
| 249 | ## The one dependency, and why | |
| 250 | ||
| 251 | Nothing in `src/` imports a package: the app boots, plays and serves with Bun | |
| 252 | and nothing else, and `independence.test.ts` says so by reading every import in | |
| 253 | the tree. The repository does take two **build-time** devDependencies — | |
| 254 | `typescript` and `@types/bun` — because types that nothing checks are comments. | |
| 255 | Every module here is carefully typed and, until this landed, not one of those | |
| 256 | types was verified: a wrong type, a dropped argument or an `undefined` from an | |
| 257 | index on a path no test walks compiled and shipped. | |
| 258 | ||
| 259 | It matters more since the engine started leaving the repository. `scripts/pack.ts` | |
| 260 | writes a package of **TypeScript sources**, and the game that imports it | |
| 261 | typechecks them in its own build — so a type error in here is not a private | |
| 262 | matter, it is an error in somebody else's project, arriving from a package that | |
| 263 | never checked itself. | |
| 264 | ||
| 265 | - `bun run typecheck` runs `tsc --noEmit` over `src/` and `scripts/`, and | |
| 266 | `bun test` runs it first: the forge's check is the typecheck and the tests, | |
| 267 | in that order. | |
| 268 | - `tsconfig.json` is strict and then some: `strict`, | |
| 269 | `noUncheckedIndexedAccess`, `noImplicitOverride`, `noFallthroughCasesInSwitch` | |
| 270 | and `verbatimModuleSyntax`. Nothing is `any`, nothing is `@ts-ignore`. | |
| 271 | - It costs about 0.6 s to typecheck the whole tree and under a second to | |
| 272 | install, once, next to a suite that takes thirteen. | |
| 273 | ||
| 249 | 274 | ## Shape |
| 250 | 275 | |
| 251 | 276 | - `src/engine/` — the part a second game could use, and the rule is that it never |
bun.lock added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | { | |
| 2 | "lockfileVersion": 1, | |
| 3 | "configVersion": 1, | |
| 4 | "workspaces": { | |
| 5 | "": { | |
| 6 | "name": "cs-engine", | |
| 7 | "devDependencies": { | |
| 8 | "@types/bun": "^1.4.2", | |
| 9 | "typescript": "^7.0.2", | |
| 10 | }, | |
| 11 | }, | |
| 12 | }, | |
| 13 | "packages": { | |
| 14 | "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], | |
| 15 | ||
| 16 | "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], | |
| 17 | ||
| 18 | "@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], | |
| 19 | ||
| 20 | "@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], | |
| 21 | ||
| 22 | "@typescript/typescript-darwin-x64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], | |
| 23 | ||
| 24 | "@typescript/typescript-freebsd-arm64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], | |
| 25 | ||
| 26 | "@typescript/typescript-freebsd-x64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], | |
| 27 | ||
| 28 | "@typescript/typescript-linux-arm": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], | |
| 29 | ||
| 30 | "@typescript/typescript-linux-arm64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], | |
| 31 | ||
| 32 | "@typescript/typescript-linux-loong64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], | |
| 33 | ||
| 34 | "@typescript/typescript-linux-mips64el": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], | |
| 35 | ||
| 36 | "@typescript/typescript-linux-ppc64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], | |
| 37 | ||
| 38 | "@typescript/typescript-linux-riscv64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], | |
| 39 | ||
| 40 | "@typescript/typescript-linux-s390x": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], | |
| 41 | ||
| 42 | "@typescript/typescript-linux-x64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], | |
| 43 | ||
| 44 | "@typescript/typescript-netbsd-arm64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], | |
| 45 | ||
| 46 | "@typescript/typescript-netbsd-x64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], | |
| 47 | ||
| 48 | "@typescript/typescript-openbsd-arm64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], | |
| 49 | ||
| 50 | "@typescript/typescript-openbsd-x64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], | |
| 51 | ||
| 52 | "@typescript/typescript-sunos-x64": ["@typescript/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], | |
| 53 | ||
| 54 | "@typescript/typescript-win32-arm64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], | |
| 55 | ||
| 56 | "@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], | |
| 57 | ||
| 58 | "bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], | |
| 59 | ||
| 60 | "typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], | |
| 61 | ||
| 62 | "undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], | |
| 63 | } | |
| 64 | } |
forge.json +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "app": { |
| 3 | 3 | "run": "bun run src/server.ts", |
| 4 | "test": "bun test", | |
| 4 | "test": "bun run test", | |
| 5 | 5 | "tty": "bun run src/tty.ts" |
| 6 | 6 | } |
| 7 | 7 | } |
package.json +6 −1
| @@ -5,6 +5,11 @@ | ||
| 5 | 5 | "scripts": { |
| 6 | 6 | "start": "bun run src/server.ts", |
| 7 | 7 | "build": "bun run scripts/build.ts", |
| 8 | "test": "bun test" | |
| 8 | "typecheck": "tsc --noEmit", | |
| 9 | "test": "bun run typecheck && bun test" | |
| 10 | }, | |
| 11 | "devDependencies": { | |
| 12 | "@types/bun": "^1.4.2", | |
| 13 | "typescript": "^7.0.2" | |
| 9 | 14 | } |
| 10 | 15 | } |
scripts/build.ts +4 −1
| @@ -39,7 +39,10 @@ export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, | ||
| 39 | 39 | export const GZIP_LEVEL = 9; |
| 40 | 40 | |
| 41 | 41 | /** What a bundle weighs: bytes on disk, and bytes after gzip at `GZIP_LEVEL`. */ |
| 42 | export function sizes(code: string | Uint8Array): Ceiling { | |
| 42 | // `Uint8Array<ArrayBuffer>` and not a plain `Uint8Array`: the gzip below will | |
| 43 | // not read a view onto a `SharedArrayBuffer`, and saying so here is how a | |
| 44 | // caller that holds one finds out at the call rather than at the call inside. | |
| 45 | export function sizes(code: string | Uint8Array<ArrayBuffer>): Ceiling { | |
| 43 | 46 | const bytes = typeof code === "string" ? new TextEncoder().encode(code) : code; |
| 44 | 47 | return { raw: bytes.byteLength, gzip: Bun.gzipSync(bytes, { level: GZIP_LEVEL }).byteLength }; |
| 45 | 48 | } |
src/client/action.test.ts +21 −2
| @@ -13,9 +13,9 @@ test("actions decode to the bins they name, and to a playable input", () => { | ||
| 13 | 13 | const actions = new Int32Array([0, PITCH_BINS.length - 1, 0, 2, 1, 2]); |
| 14 | 14 | const intent = decode(actions); |
| 15 | 15 | expect(intent.yawRate).toBe(YAW_BINS[0]); |
| 16 | expect(intent.pitchRate).toBe(PITCH_BINS[PITCH_BINS.length - 1]); | |
| 16 | expect(intent.pitchRate).toBe(PITCH_BINS[PITCH_BINS.length - 1]!); | |
| 17 | 17 | expect(intent.forward).toBe(AXIS_BINS[0]); |
| 18 | expect(intent.side).toBe(AXIS_BINS[2]); | |
| 18 | expect(intent.side).toBe(AXIS_BINS[2]!); | |
| 19 | 19 | expect(intent.fire).toBe(true); |
| 20 | 20 | expect(intent.duck).toBe(true); |
| 21 | 21 | expect(intent.walk).toBe(false); |
| @@ -30,6 +30,25 @@ test("actions decode to the bins they name, and to a playable input", () => { | ||
| 30 | 30 | expect(Object.keys(input)).not.toContain("yawRate"); |
| 31 | 31 | }); |
| 32 | 32 | |
| 33 | test("a bin the head does not have is clamped to the nearest one it does", () => { | |
| 34 | // A sample of the right shape only ever names its own bins. One that is | |
| 35 | // short, or that came from a net trained against different heads, names a bin | |
| 36 | // that is not there — and the nearest bin is a bot that turns too far, while | |
| 37 | // an unchecked read is a view angle of NaN that never comes back. | |
| 38 | const past = decode(new Int32Array([YAW_BINS.length + 3, -2, 99, -1, 0, 0])); | |
| 39 | expect(past.yawRate).toBe(YAW_BINS[YAW_BINS.length - 1]!); | |
| 40 | expect(past.pitchRate).toBe(PITCH_BINS[0]!); | |
| 41 | expect(past.forward).toBe(AXIS_BINS[AXIS_BINS.length - 1]!); | |
| 42 | expect(past.side).toBe(AXIS_BINS[0]!); | |
| 43 | expect(Number.isNaN(past.yawRate)).toBe(false); | |
| 44 | ||
| 45 | // A sample that stops short of the six heads is the same question asked of a | |
| 46 | // missing index, and it answers with the first bin rather than undefined. | |
| 47 | const short = decode(new Int32Array([1])); | |
| 48 | expect(short.pitchRate).toBe(PITCH_BINS[0]!); | |
| 49 | expect(short.duck).toBe(false); | |
| 50 | }); | |
| 51 | ||
| 33 | 52 | test("the middle bin of every axis is standing still", () => { |
| 34 | 53 | const middle = new Int32Array([ |
| 35 | 54 | YAW_BINS.indexOf(0), PITCH_BINS.indexOf(0), AXIS_BINS.indexOf(0), AXIS_BINS.indexOf(0), 0, 0, |
src/client/action.ts +21 −8
| @@ -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,14 +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 | |
| 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]; | |
| 51 | ||
| 39 | 52 | /** Turn one sample from the policy into an intent. */ |
| 40 | 53 | export function decode(actions: Int32Array, at = 0): Intent { |
| 41 | const posture = actions[at + 5] as number; | |
| 54 | const posture = actions[at + 5]; | |
| 42 | 55 | return { |
| 43 | yawRate: YAW_BINS[actions[at] as number] as number, | |
| 44 | pitchRate: PITCH_BINS[actions[at + 1] as number] as number, | |
| 45 | forward: AXIS_BINS[actions[at + 2] as number] as number, | |
| 46 | side: AXIS_BINS[actions[at + 3] as number] as number, | |
| 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]), | |
| 47 | 60 | fire: actions[at + 4] === 1, |
| 48 | 61 | walk: posture === 1, |
| 49 | 62 | duck: posture === 2, |
src/client/bomb.test.ts +4 −2
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | import { expect, test } from "bun:test"; |
| 13 | 13 | import { |
| 14 | 14 | defuseTime, dropBomb, giveBomb, newBomb, updateBomb, useProgress, |
| 15 | type Carrier, type Round, | |
| 15 | type BombState, type Carrier, type Round, | |
| 16 | 16 | } from "./bomb.ts"; |
| 17 | 17 | import { DE_FORGE } from "./map.ts"; |
| 18 | 18 | import { newMatch, PLANT_REWARD, SHORT, type Phase } from "./match.ts"; |
| @@ -126,7 +126,9 @@ test("a defuse interrupted goes back to nothing, and resuming starts the wire ag | ||
| 126 | 126 | expect(hold(r, ct, SHORT.defuseTime * 0.9)).toBe(null); |
| 127 | 127 | expect(r.bomb.state).toBe("planted"); |
| 128 | 128 | expect(hold(r, ct, SHORT.defuseTime * 0.2)).toEqual({ type: "defused" }); |
| 129 | expect(r.bomb.state).toBe("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"); | |
| 130 | 132 | }); |
| 131 | 133 | |
| 132 | 134 | test("a defuse needs to be stood next to, and is the CT's alone", () => { |
src/client/bomb.ts +4 −1
| @@ -27,8 +27,11 @@ export type Carrier = { | ||
| 27 | 27 | hasBomb: boolean; |
| 28 | 28 | }; |
| 29 | 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 | ||
| 30 | 33 | export type Bomb = { |
| 31 | state: "carried" | "dropped" | "planted" | "defused" | "detonated"; | |
| 34 | state: BombState; | |
| 32 | 35 | pos: Vec3; |
| 33 | 36 | site: "A" | "B" | null; |
| 34 | 37 | plantProgress: number; |
src/client/bots.test.ts +3 −3
| @@ -7,7 +7,7 @@ import { grip } from "./spray.ts"; | ||
| 7 | 7 | import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts"; |
| 8 | 8 | import { dist, type Vec3 } from "../engine/index.ts"; |
| 9 | 9 | import { OBS_SIZE } from "./observe.ts"; |
| 10 | import { FLASH } from "./weapons.ts"; | |
| 10 | import { FLASH, type WeaponId } from "./weapons.ts"; | |
| 11 | 11 | import { DE_FORGE } from "./map.ts"; |
| 12 | 12 | import { COMPETITIVE } from "./match.ts"; |
| 13 | 13 | import { aimAt, applyInput, bodyCentre, createWorld, type Player } from "./world.ts"; |
| @@ -286,7 +286,7 @@ test("a bot that has fired its rifle dry draws its pistol and keeps fighting", ( | ||
| 286 | 286 | // Before this it held the trigger down on an empty rifle for the rest of the |
| 287 | 287 | // round: `shoot` refuses without a round in the magazine and `reload` refuses |
| 288 | 288 | // with nothing to reload from, so nobody was ever hit. |
| 289 | expect(d.bot.weapon).toBe(d.bot.secondary); | |
| 289 | expect<WeaponId>(d.bot.weapon).toBe(d.bot.secondary); | |
| 290 | 290 | expect(shots).toBeGreaterThan(0); |
| 291 | 291 | expect(d.me.health).toBeLessThan(100); |
| 292 | 292 | }); |
| @@ -299,7 +299,7 @@ test("a bot with nothing left to fire closes the distance and knifes", () => { | ||
| 299 | 299 | d.bot.ammo[d.bot.secondary] = { mag: 0, reserve: 0 }; |
| 300 | 300 | const started = dist(d.bot.pos, d.me.pos); |
| 301 | 301 | const shots = fight(d, 384); |
| 302 | expect(d.bot.weapon).toBe("knife"); | |
| 302 | expect<WeaponId>(d.bot.weapon).toBe("knife"); | |
| 303 | 303 | expect(dist(d.bot.pos, d.me.pos)).toBeLessThan(started); // it walked in |
| 304 | 304 | expect(shots).toBeGreaterThan(0); |
| 305 | 305 | expect(d.me.health).toBeLessThan(100); |
src/client/hud.test.ts +5 −2
| @@ -27,8 +27,10 @@ import { SHORT } from "./match.ts"; | ||
| 27 | 27 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 28 | 28 | import { |
| 29 | 29 | createWorld, currentWeapon, EFFECT_CAPACITY, |
| 30 | type Ammo, type Effect, type Player, type World, | |
| 30 | type Effect, type Player, type World, | |
| 31 | 31 | } from "./world.ts"; |
| 32 | // `Ammo` is economy.ts's; world.ts imports it rather than exporting it. | |
| 33 | import type { Ammo } from "./economy.ts"; | |
| 32 | 34 | import { createEffects, pushEffect } from "../engine/index.ts"; |
| 33 | 35 | |
| 34 | 36 | /** The one effect this frame is about, in a buffer of its own. */ |
| @@ -435,7 +437,8 @@ test("the numbers under a spectated round are the watched player's, not your cor | ||
| 435 | 437 | |
| 436 | 438 | test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => { |
| 437 | 439 | const world = scene((w) => { |
| 438 | const [me, second, third, fourth] = w.players as Player[]; | |
| 440 | const [me, second, third, fourth] = w.players; | |
| 441 | if (!me || !second || !third || !fourth) throw new Error("this scene wants four players"); | |
| 439 | 442 | me.kills = 2; me.deaths = 1; me.headshots = 1; me.damage = 310.4; me.money = 3200; |
| 440 | 443 | second.kills = 5; second.damage = 700; |
| 441 | 444 | third.kills = 2; third.damage = 480; // same kills as me, more damage: above me |
src/client/hud.ts +2 −1
| @@ -152,7 +152,8 @@ export function drawHud( | ||
| 152 | 152 | ctx.lineWidth = 2; |
| 153 | 153 | const r = 9 + age * 20; |
| 154 | 154 | ctx.beginPath(); |
| 155 | for (const [dx, dy] of [[1, 1], [-1, -1], [1, -1], [-1, 1]]) { | |
| 155 | const arms: [number, number][] = [[1, 1], [-1, -1], [1, -1], [-1, 1]]; | |
| 156 | for (const [dx, dy] of arms) { | |
| 156 | 157 | ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55); |
| 157 | 158 | ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r); |
| 158 | 159 | } |
src/client/level.test.ts +8 −6
| @@ -47,7 +47,7 @@ const ROWS = [ | ||
| 47 | 47 | ]; |
| 48 | 48 | |
| 49 | 49 | const GRID: Grid<string> = { |
| 50 | width: ROWS[0].length, | |
| 50 | width: ROWS[0]!.length, | |
| 51 | 51 | height: ROWS.length, |
| 52 | 52 | cell: CELL, |
| 53 | 53 | cells: ROWS.flatMap((row) => [...row]), |
| @@ -96,9 +96,11 @@ function problems(level: Level): string[] { | ||
| 96 | 96 | for (const [name, site] of Object.entries(level.sites)) |
| 97 | 97 | if (siteAt(level, site) !== name) bad.push(`${level.name}: site ${name} at ${site} is not on ${name}`); |
| 98 | 98 | const { width, height, cell } = level.grid; |
| 99 | const edge = [(width / 2) * cell, (height / 2) * cell]; | |
| 99 | const edge: [number, number] = [(width / 2) * cell, (height / 2) * cell]; | |
| 100 | 100 | for (const box of level.boxes) |
| 101 | for (const axis of [0, 1]) | |
| 101 | // `0 | 1` and not `number`: an axis of a Vec3 is one of two, and naming | |
| 102 | // them is what makes the reads below numbers rather than maybes. | |
| 103 | for (const axis of [0, 1] as const) | |
| 102 | 104 | if (box.min[axis] < -edge[axis] || box.max[axis] > edge[axis]) |
| 103 | 105 | bad.push(`${level.name}: a box runs from ${box.min} to ${box.max}, outside the grid`); |
| 104 | 106 | return bad; |
| @@ -141,10 +143,10 @@ test("only the map and the entry name de_forge: everything else is handed a leve | ||
| 141 | 143 | // --- and the game played on it -------------------------------------------- |
| 142 | 144 | |
| 143 | 145 | test("a path crosses the room, cell by cell, without leaving it", () => { |
| 144 | const path = findPath(ROOM, ROOM.spawns.t[0], ROOM.spawns.ct[0]); | |
| 146 | const path = findPath(ROOM, ROOM.spawns.t[0]!, ROOM.spawns.ct[0]!); | |
| 145 | 147 | expect(path.length).toBeGreaterThan(4); |
| 146 | 148 | for (let i = 1; i < path.length; i++) |
| 147 | expect(Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1])).toBeLessThan(CELL * 1.5); | |
| 149 | expect(Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1])).toBeLessThan(CELL * 1.5); | |
| 148 | 150 | for (const step of path) expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true); |
| 149 | 151 | }); |
| 150 | 152 | |
| @@ -153,7 +155,7 @@ test("a world on the room is a world on the room, and its bot walks it", () => { | ||
| 153 | 155 | expect(world.level).toBe(ROOM); |
| 154 | 156 | // The bot spawns on the room's T spawn, not on de_forge's. |
| 155 | 157 | const bot = world.players.find((p) => p.bot) as Player; |
| 156 | expect([bot.pos[0], bot.pos[1]]).toEqual([ROOM.spawns.t[0][0], ROOM.spawns.t[0][1]]); | |
| 158 | expect([bot.pos[0], bot.pos[1]]).toEqual([ROOM.spawns.t[0]![0], ROOM.spawns.t[0]![1]]); | |
| 157 | 159 | expect(siteAt(ROOM, bot.pos)).toBe(null); |
| 158 | 160 | |
| 159 | 161 | // Three seconds of it thinking and moving, with nobody to shoot at: the round |
src/client/main.ts +6 −2
| @@ -185,11 +185,15 @@ export function boot(host: Host): Frame { | ||
| 185 | 185 | // The line in the pause panel that says what Escape just did on this browser. |
| 186 | 186 | const escHint = by("esc"); |
| 187 | 187 | |
| 188 | const build = host.createRenderer(sceneCanvas); | |
| 189 | if (build === null) { | |
| 188 | const maybeBuild = host.createRenderer(sceneCanvas); | |
| 189 | if (maybeBuild === null) { | |
| 190 | 190 | menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>"; |
| 191 | 191 | return { world: () => null }; |
| 192 | 192 | } |
| 193 | // Re-bound after the guard because `frame` below is a hoisted declaration: | |
| 194 | // the check narrows `maybeBuild` here, not inside a function that could in | |
| 195 | // principle have been called before the check ran. | |
| 196 | const build = maybeBuild; | |
| 193 | 197 | const hudCtx = hudCanvas.getContext("2d") as Paint; |
| 194 | 198 | const audio = host.createAudio(); |
| 195 | 199 | const controls = createControls( |
src/client/map.test.ts +16 −10
| @@ -12,6 +12,12 @@ import { DE_FORGE, GRID_H, GRID_W } from "./map.ts"; | ||
| 12 | 12 | |
| 13 | 13 | const { boxes, sites, spawns } = DE_FORGE; |
| 14 | 14 | |
| 15 | /** | |
| 16 | * Where the Ts start, which half the tests below walk from. The map has more | |
| 17 | * than four of them — the test above says so — so the first one is there. | |
| 18 | */ | |
| 19 | const T_SPAWN = spawns.t[0]!; | |
| 20 | ||
| 15 | 21 | test("the map has two sites and two spawns", () => { |
| 16 | 22 | expect(spawns.t.length).toBeGreaterThan(4); |
| 17 | 23 | expect(spawns.ct.length).toBeGreaterThan(4); |
| @@ -40,14 +46,14 @@ test("grid and world coordinates round-trip", () => { | ||
| 40 | 46 | |
| 41 | 47 | test("every walkable cell is reachable from T spawn", () => { |
| 42 | 48 | // A room nobody can reach is a bug you only find by walking into it. |
| 43 | const start = spawns.t[0]; | |
| 49 | const start = T_SPAWN; | |
| 44 | 50 | const seen = new Set<number>(); |
| 45 | 51 | const queue = [rowOf(DE_FORGE, start[1]) * GRID_W + colOf(DE_FORGE, start[0])]; |
| 46 | seen.add(queue[0]); | |
| 52 | seen.add(queue[0]!); | |
| 47 | 53 | while (queue.length > 0) { |
| 48 | 54 | const i = queue.pop() as number; |
| 49 | 55 | const c = i % GRID_W, r = Math.floor(i / GRID_W); |
| 50 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| 56 | for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as [number, number][]) { | |
| 51 | 57 | if (!walkable(DE_FORGE, c + dc, r + dr)) continue; |
| 52 | 58 | const j = (r + dr) * GRID_W + c + dc; |
| 53 | 59 | if (seen.has(j)) continue; |
| @@ -84,33 +90,33 @@ test("de_forge is exactly where it was, to the unit", () => { | ||
| 84 | 90 | test("the route from T spawn to A is the same route it was", () => { |
| 85 | 91 | // A number a change to the search moves: 33 waypoints from the first T spawn |
| 86 | 92 | // cell to the centre of A, 4506 units walked against 4142 as the crow flies. |
| 87 | const path = findPath(DE_FORGE, spawns.t[0], sites.A); | |
| 88 | expect(spawns.t[0]).toEqual([576, -1984, 0]); | |
| 93 | const path = findPath(DE_FORGE, T_SPAWN, sites.A); | |
| 94 | expect(T_SPAWN).toEqual([576, -1984, 0]); | |
| 89 | 95 | expect(path.length).toBe(33); |
| 90 | 96 | expect(path[0]).toEqual([704, -1984, 0]); |
| 91 | 97 | expect(path[path.length - 1]).toEqual(sites.A); |
| 92 | 98 | let walked = 0; |
| 93 | 99 | for (let i = 1; i < path.length; i++) |
| 94 | walked += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); | |
| 100 | walked += Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1]); | |
| 95 | 101 | expect(walked).toBeCloseTo(4506.04, 1); |
| 96 | const crow = Math.hypot(sites.A[0] - spawns.t[0][0], sites.A[1] - spawns.t[0][1]); | |
| 102 | const crow = Math.hypot(sites.A[0] - T_SPAWN[0], sites.A[1] - T_SPAWN[1]); | |
| 97 | 103 | expect(crow).toBeCloseTo(4141.87, 1); |
| 98 | 104 | }); |
| 99 | 105 | |
| 100 | 106 | test("a path from T spawn reaches both sites", () => { |
| 101 | 107 | for (const site of [sites.A, sites.B]) { |
| 102 | const path = findPath(DE_FORGE, spawns.t[0], site); | |
| 108 | const path = findPath(DE_FORGE, T_SPAWN, site); | |
| 103 | 109 | expect(path.length).toBeGreaterThan(4); |
| 104 | 110 | // Consecutive waypoints are neighbouring cells, never a jump across a wall. |
| 105 | 111 | for (let i = 1; i < path.length - 1; i++) { |
| 106 | const step = Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]); | |
| 112 | const step = Math.hypot(path[i]![0] - path[i - 1]![0], path[i]![1] - path[i - 1]![1]); | |
| 107 | 113 | expect(step).toBeLessThan(200); |
| 108 | 114 | } |
| 109 | 115 | } |
| 110 | 116 | }); |
| 111 | 117 | |
| 112 | 118 | test("a path into solid rock comes back empty rather than lying", () => { |
| 113 | expect(findPath(DE_FORGE, spawns.t[0], worldOf(DE_FORGE, 0, 0))).toEqual([]); | |
| 119 | expect(findPath(DE_FORGE, T_SPAWN, worldOf(DE_FORGE, 0, 0))).toEqual([]); | |
| 114 | 120 | }); |
| 115 | 121 | |
| 116 | 122 | test("de_forge carries its own alphabet, its numbers and its material table", () => { |
src/client/match.test.ts +5 −2
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | 3 | COMPETITIVE, endRound, halfTime, LOSS_BONUS, newMatch, plantBomb, roundReward, SHORT, tick, winReward, |
| 4 | type Phase, | |
| 4 | 5 | } from "./match.ts"; |
| 5 | 6 | |
| 6 | 7 | const run = (state: ReturnType<typeof newMatch>, seconds: number): ReturnType<typeof tick> => { |
| @@ -64,7 +65,9 @@ test("the match ends when a side reaches the win score", () => { | ||
| 64 | 65 | expect(m.matchWinner).toBe("ct"); |
| 65 | 66 | const events = run(m, SHORT.endDelay + 0.2); |
| 66 | 67 | expect(events.some((e) => e.type === "match_end")).toBe(true); |
| 67 | expect(m.phase).toBe("match_over"); | |
| 68 | // Asked as a `Phase`: the line above set it to "live" and the checker does | |
| 69 | // not know that `run` moved it on. | |
| 70 | expect<Phase>(m.phase).toBe("match_over"); | |
| 68 | 71 | // A finished match stays finished. |
| 69 | 72 | expect(tick(m, 10)).toEqual([]); |
| 70 | 73 | }); |
| @@ -84,7 +87,7 @@ test("losing pays more the longer you have been losing", () => { | ||
| 84 | 87 | test("a plant pays the losing terrorists, and the bomb pays more to win", () => { |
| 85 | 88 | const m = newMatch(COMPETITIVE); |
| 86 | 89 | endRound(m, "ct", "defused"); |
| 87 | expect(roundReward(m, "t", "ct", "defused", true)).toBe(LOSS_BONUS[0] + 800); | |
| 90 | expect(roundReward(m, "t", "ct", "defused", true)).toBe(LOSS_BONUS[0]! + 800); | |
| 88 | 91 | expect(roundReward(m, "ct", "ct", "defused", true)).toBe(3500); |
| 89 | 92 | expect(winReward("elimination")).toBe(3250); |
| 90 | 93 | }); |
src/client/stage.test-helper.ts +3
| @@ -211,6 +211,9 @@ export function fakeStage( | ||
| 211 | 211 | muted: false, |
| 212 | 212 | resume: () => void sounds.push("resume"), |
| 213 | 213 | shot: (weapon) => void sounds.push(`shot:${weapon}`), |
| 214 | // The stub was missing this one: a stage that boots the game without it is | |
| 215 | // a stage where the first grenade throws `audio.grenade is not a function`. | |
| 216 | grenade: (sound) => void sounds.push(`grenade:${sound}`), | |
| 214 | 217 | hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"), |
| 215 | 218 | beep: () => void sounds.push("beep"), |
| 216 | 219 | }; |
src/client/world.test.ts +16 −8
| @@ -5,6 +5,7 @@ import { SHORT } from "./match.ts"; | ||
| 5 | 5 | import { WEAPONS } from "./weapons.ts"; |
| 6 | 6 | import { buy } from "./economy.ts"; |
| 7 | 7 | import { createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts"; |
| 8 | import type { Vec3 } from "../engine/index.ts"; | |
| 8 | 9 | |
| 9 | 10 | const TICK = 1 / 64; |
| 10 | 11 | const run = (world: World, seconds: number, input: Input = NO_INPUT): void => { |
| @@ -23,10 +24,12 @@ test("a world starts ten players, one bomb and a freeze time", () => { | ||
| 23 | 24 | |
| 24 | 25 | test("nobody moves or shoots during the freeze", () => { |
| 25 | 26 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 2); |
| 26 | const start = world.players.map((p) => [...p.pos]); | |
| 27 | // Copied as a `Vec3` and not a `number[]`: a position has three numbers, | |
| 28 | // and saying so is how the arithmetic below stays about numbers. | |
| 29 | const start = world.players.map((p): Vec3 => [...p.pos]); | |
| 27 | 30 | run(world, SHORT.freezeTime - 1, { ...NO_INPUT, forward: 1, fire: true }); |
| 28 | 31 | world.players.forEach((p, i) => { |
| 29 | expect(Math.hypot(p.pos[0] - start[i][0], p.pos[1] - start[i][1])).toBeLessThan(1); | |
| 32 | expect(Math.hypot(p.pos[0] - start[i]![0], p.pos[1] - start[i]![1])).toBeLessThan(1); | |
| 30 | 33 | }); |
| 31 | 34 | expect(world.players.every((p) => p.health === 100)).toBe(true); |
| 32 | 35 | }); |
| @@ -37,7 +40,9 @@ test("a shot costs a bullet and a reload puts it back", () => { | ||
| 37 | 40 | buy(world, world.me, "m4a4"); |
| 38 | 41 | run(world, SHORT.freezeTime + 0.5); |
| 39 | 42 | expect(currentWeapon(world.me).id).toBe("m4a4"); |
| 40 | const ammo = world.me.ammo.m4a4; | |
| 43 | // The rifle was just bought, so it has a magazine: `ammo` is keyed by every | |
| 44 | // weapon id and a gun you do not own has none. | |
| 45 | const ammo = world.me.ammo.m4a4!; | |
| 41 | 46 | expect(shoot(world, world.me)).toBe(true); |
| 42 | 47 | expect(ammo.mag).toBe(WEAPONS.m4a4.magazine - 1); |
| 43 | 48 | run(world, WEAPONS.m4a4.reloadTime + 0.2, { ...NO_INPUT, reload: true }); |
| @@ -48,9 +53,10 @@ test("a shot costs a bullet and a reload puts it back", () => { | ||
| 48 | 53 | test("bots leave their spawn once the round goes live", () => { |
| 49 | 54 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 5); |
| 50 | 55 | const bot = world.players.find((p) => p.bot && p.team === "t"); |
| 56 | if (bot === undefined) throw new Error("five a side puts T bots on the server"); | |
| 51 | 57 | const start = [...bot.pos]; |
| 52 | 58 | run(world, SHORT.freezeTime + 6); |
| 53 | expect(Math.hypot(bot.pos[0] - start[0], bot.pos[1] - start[1])).toBeGreaterThan(200); | |
| 59 | expect(Math.hypot(bot.pos[0] - start[0]!, bot.pos[1] - start[1]!)).toBeGreaterThan(200); | |
| 54 | 60 | }); |
| 55 | 61 | |
| 56 | 62 | test("a round is played out: bots meet, shoot, and one side wins it", () => { |
| @@ -97,12 +103,14 @@ test("a new round puts everyone back with full health and a full magazine", () = | ||
| 97 | 103 | const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 17); |
| 98 | 104 | world.me.money = 5000; |
| 99 | 105 | buy(world, world.me, "m4a4"); |
| 100 | world.me.ammo.m4a4.mag = 3; | |
| 101 | world.players[3].alive = false; | |
| 102 | world.players[3].health = 0; | |
| 106 | // Bought above, so the magazine is there; the fourth player is on a server | |
| 107 | // of ten, so they are there too. | |
| 108 | world.me.ammo.m4a4!.mag = 3; | |
| 109 | world.players[3]!.alive = false; | |
| 110 | world.players[3]!.health = 0; | |
| 103 | 111 | startRound(world); |
| 104 | 112 | expect(world.players.every((p) => p.alive && p.health === 100)).toBe(true); |
| 105 | expect(world.me.ammo.m4a4.mag).toBe(WEAPONS.m4a4.magazine); | |
| 113 | expect(world.me.ammo.m4a4!.mag).toBe(WEAPONS.m4a4.magazine); | |
| 106 | 114 | expect(world.me.primary).toBe("m4a4"); // guns you bought stay bought |
| 107 | 115 | expect(world.players.filter((p) => p.hasBomb).length).toBe(1); |
| 108 | 116 | }); |
src/client/world.ts +6 −1
| @@ -264,7 +264,12 @@ export function startRound(world: World): void { | ||
| 264 | 264 | const spots = { t: [...spawns.t], ct: [...spawns.ct] }; |
| 265 | 265 | for (const p of world.players) { |
| 266 | 266 | const list = spots[p.team]; |
| 267 | const at = list.splice(Math.floor(world.random() * list.length), 1)[0] ?? spawns[p.team][0]; | |
| 267 | // The list is drained so no two players start on the same spot; when it | |
| 268 | // runs out the side's first spawn is reused, and a level that gives a side | |
| 269 | // no spawn at all leaves the player where they stand rather than at an | |
| 270 | // invented origin outside the map. | |
| 271 | const at = | |
| 272 | list.splice(Math.floor(world.random() * list.length), 1)[0] ?? spawns[p.team][0] ?? p.pos; | |
| 268 | 273 | p.pos = [at[0], at[1], 2]; |
| 269 | 274 | p.vel = [0, 0, 0]; |
| 270 | 275 | p.health = 100; |
src/engine/controls.test.ts +11 −4
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { attachButtons, createControls, type Host } from "./controls.ts"; |
| 3 | 3 | |
| 4 | type Handler = (event: unknown) => void; | |
| 4 | // `(event: never)` is the one type every listener can be stored as: a handler | |
| 5 | // written for a key event and one written for a touch both fit here, which is | |
| 6 | // what lets a single array stand in for a real target's listener list. | |
| 7 | type Handler = (event: never) => void; | |
| 5 | 8 | type Registration = { type: string; handler: Handler; options?: { passive?: boolean } }; |
| 6 | 9 | |
| 7 | 10 | /** An event target made of an array: the test is the browser. */ |
| @@ -17,17 +20,21 @@ function stubTarget() { | ||
| 17 | 20 | optionsFor(type: string): { passive?: boolean } | undefined { |
| 18 | 21 | return registered.find((r) => r.type === type)?.options; |
| 19 | 22 | }, |
| 20 | addEventListener(type: string, handler: Handler, options?: { passive?: boolean }) { | |
| 23 | addEventListener<E>(type: string, handler: (event: E) => void, options?: { passive?: boolean }) { | |
| 21 | 24 | registered.push({ type, handler, options }); |
| 22 | 25 | }, |
| 23 | removeEventListener(type: string, handler: Handler) { | |
| 26 | removeEventListener<E>(type: string, handler: (event: E) => void) { | |
| 24 | 27 | const at = registered.findIndex((r) => r.type === type && r.handler === handler); |
| 25 | 28 | if (at < 0) return; |
| 26 | 29 | registered.splice(at, 1); |
| 27 | 30 | removes += 1; |
| 28 | 31 | }, |
| 29 | 32 | emit(type: string, event: unknown = {}) { |
| 30 | for (const r of registered.filter((one) => one.type === type)) r.handler(event); | |
| 33 | // The browser's own seam, and the only one in this file: a target holds | |
| 34 | // listeners for events of every shape and hands each the event it was | |
| 35 | // dispatched with. Dispatching is where the shape is asserted. | |
| 36 | for (const r of registered.filter((one) => one.type === type)) | |
| 37 | (r.handler as (event: unknown) => void)(event); | |
| 31 | 38 | }, |
| 32 | 39 | }; |
| 33 | 40 | } |
src/engine/controls.ts +9 −6
| @@ -15,13 +15,16 @@ | ||
| 15 | 15 | import { clamp } from "./math.ts"; |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | * Anything you can listen to. The handler takes `any` because one target | |
| 19 | * carries a keyboard event and the next a touch: the events below are the | |
| 20 | * shapes this module actually reads. | |
| 18 | * Anything you can listen to. The event is a type parameter rather than `any` | |
| 19 | * because one target carries a keyboard event and the next a touch: each | |
| 20 | * listener below names the shape it actually reads, a real `window`, | |
| 21 | * `document` or canvas satisfies this, and a handler stored for `detach` is | |
| 22 | * remembered as `(event: never) => void` — the one handler type every one of | |
| 23 | * them can be put back as. | |
| 21 | 24 | */ |
| 22 | 25 | export type Listens = { |
| 23 | addEventListener(type: string, handler: (event: any) => void, options?: { passive?: boolean }): void; | |
| 24 | removeEventListener(type: string, handler: (event: any) => void): void; | |
| 26 | addEventListener<E>(type: string, handler: (event: E) => void, options?: { passive?: boolean }): void; | |
| 27 | removeEventListener<E>(type: string, handler: (event: E) => void): void; | |
| 25 | 28 | }; |
| 26 | 29 | |
| 27 | 30 | /** The element the pointer locks to and the fingers land on. */ |
| @@ -105,7 +108,7 @@ export function createControls(host: Host, options: Options): Controls { | ||
| 105 | 108 | const on = ( |
| 106 | 109 | target: Listens, |
| 107 | 110 | type: string, |
| 108 | handler: (event: any) => void, | |
| 111 | handler: (event: never) => void, | |
| 109 | 112 | passive?: boolean, |
| 110 | 113 | ): void => { |
| 111 | 114 | target.addEventListener(type, handler, passive === undefined ? undefined : { passive }); |
src/engine/math.test.ts +16 −4
| @@ -1,5 +1,13 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { angleDelta, angleVectors, cross, dot, mat4, multiply, normalize, perspective, rng, viewMatrix } from "./math.ts"; |
| 3 | import type { Mat4 } from "./math.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * One cell of a matrix. A `Mat4` is sixteen floats and every index below is | |
| 7 | * one of them; `NaN` for anything else, so a read off the end fails the | |
| 8 | * comparison it was written for rather than passing as `undefined`. | |
| 9 | */ | |
| 10 | const cell = (m: Mat4, i: number): number => m[i] ?? NaN; | |
| 3 | 11 | |
| 4 | 12 | test("yaw 0 looks east, yaw 90 looks north", () => { |
| 5 | 13 | const east = angleVectors(0, 0).forward; |
| @@ -28,8 +36,12 @@ test("the shortest way from 350 to 10 is forwards, not the long way", () => { | ||
| 28 | 36 | |
| 29 | 37 | test("the view matrix puts a point in front of the eye down -Z", () => { |
| 30 | 38 | const view = viewMatrix([0, 0, 64], 0, 0); |
| 31 | const world = [500, 0, 64, 1]; | |
| 32 | const eye = [0, 1, 2].map((r) => view[r] * world[0] + view[4 + r] * world[1] + view[8 + r] * world[2] + view[12 + r]); | |
| 39 | const world: [number, number, number, number] = [500, 0, 64, 1]; | |
| 40 | const eye = [0, 1, 2].map( | |
| 41 | (r) => | |
| 42 | cell(view, r) * world[0] + cell(view, 4 + r) * world[1] + | |
| 43 | cell(view, 8 + r) * world[2] + cell(view, 12 + r), | |
| 44 | ); | |
| 33 | 45 | expect(eye[0]).toBeCloseTo(0, 4); |
| 34 | 46 | expect(eye[1]).toBeCloseTo(0, 4); |
| 35 | 47 | expect(eye[2]).toBeCloseTo(-500, 4); |
| @@ -38,7 +50,7 @@ test("the view matrix puts a point in front of the eye down -Z", () => { | ||
| 38 | 50 | test("perspective maps the near plane to -1 and the far plane to 1", () => { |
| 39 | 51 | const p = perspective(90, 1, 8, 8192); |
| 40 | 52 | const project = (z: number): number => { |
| 41 | const clipZ = p[10] * z + p[14]; | |
| 53 | const clipZ = cell(p, 10) * z + cell(p, 14); | |
| 42 | 54 | const clipW = -z; |
| 43 | 55 | return clipZ / clipW; |
| 44 | 56 | }; |
| @@ -49,7 +61,7 @@ test("perspective maps the near plane to -1 and the far plane to 1", () => { | ||
| 49 | 61 | test("multiplying by identity changes nothing", () => { |
| 50 | 62 | const a = perspective(70, 1.6, 8, 4096); |
| 51 | 63 | const m = multiply(a, mat4()); |
| 52 | for (let k = 0; k < 16; k++) expect(m[k]).toBeCloseTo(a[k], 5); | |
| 64 | for (let k = 0; k < 16; k++) expect(cell(m, k)).toBeCloseTo(cell(a, k), 5); | |
| 53 | 65 | }); |
| 54 | 66 | |
| 55 | 67 | test("normalize survives a zero vector", () => { |
src/engine/physics.ts +5 −2
| @@ -78,10 +78,13 @@ export type Wish = { | ||
| 78 | 78 | maxSpeed: number; // the weapon's carry speed |
| 79 | 79 | }; |
| 80 | 80 | |
| 81 | export const eyeHeight = (b: Body, hull: Hull): number => | |
| 81 | // Both heights read one field, so they ask for one field: a camera, a flash or | |
| 82 | // a hitbox has a duck amount without having a velocity, and a caller that holds | |
| 83 | // only that should not have to invent the rest of a `Body` to measure it. | |
| 84 | export const eyeHeight = (b: Pick<Body, "duckAmount">, hull: Hull): number => | |
| 82 | 85 | hull.eyeStand + (hull.eyeDuck - hull.eyeStand) * b.duckAmount; |
| 83 | 86 | |
| 84 | export const hullHeight = (b: Body, hull: Hull): number => | |
| 87 | export const hullHeight = (b: Pick<Body, "duckAmount">, hull: Hull): number => | |
| 85 | 88 | hull.stand + (hull.duck - hull.stand) * b.duckAmount; |
| 86 | 89 | |
| 87 | 90 | function applyFriction(vel: Vec3, dt: number, tuning: MoveTuning): void { |
src/engine/purity.probe.ts +6
| @@ -20,6 +20,12 @@ | ||
| 20 | 20 | * waits on stdin. `.probe.ts`, like `.test.ts`, is tooling and ships nowhere. |
| 21 | 21 | */ |
| 22 | 22 | |
| 23 | // The empty export is the file's module marker, not an interface: without it a | |
| 24 | // file with no import and no export is a global script, its top-level `await` | |
| 25 | // is an error, and its `Touch` and `Report` collide with the DOM globals of the | |
| 26 | // same name. It runs as an ES module either way; this says so. | |
| 27 | export {}; | |
| 28 | ||
| 23 | 29 | /** Every global a browser has and a test, a bench or a server does not. */ |
| 24 | 30 | const WATCHED = [ |
| 25 | 31 | "window", |
src/engine/renderer.test.ts +4 −1
| @@ -12,7 +12,10 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | import { createRenderer, type Scene } from "./renderer.ts"; | |
| 15 | import { createRenderer } from "./renderer.ts"; | |
| 16 | // `Scene` is scene.ts's, and renderer.ts only imports it: a type a module | |
| 17 | // imports is not a type it exports. | |
| 18 | import type { Scene } from "./scene.ts"; | |
| 16 | 19 | import { addBox, builder, finish, type Mesh } from "./mesh.ts"; |
| 17 | 20 | |
| 18 | 21 | type Call = [string, ...unknown[]]; |
src/engine/scene.test.ts +3 −3
| @@ -51,9 +51,9 @@ test("a capacity that is not a whole number above zero is refused with the numbe | ||
| 51 | 51 | test("the cursor hands out the pool in order and counts what it handed out", () => { |
| 52 | 52 | const scene = createScene({ instances: 3, lineVertices: 2 }); |
| 53 | 53 | const first = nextInstance(scene); |
| 54 | expect(first).toBe(scene.instances[0] as object); | |
| 54 | expect(first).toBe(scene.instances[0]!); | |
| 55 | 55 | expect(scene.instanceCount).toBe(1); |
| 56 | expect(nextInstance(scene)).toBe(scene.instances[1] as object); | |
| 56 | expect(nextInstance(scene)).toBe(scene.instances[1]!); | |
| 57 | 57 | expect(scene.instanceCount).toBe(2); |
| 58 | 58 | }); |
| 59 | 59 | |
| @@ -108,7 +108,7 @@ test("a reset puts the counts back and frees nothing", () => { | ||
| 108 | 108 | expect(scene.lines).toBe(lines); |
| 109 | 109 | // Last frame's numbers are still there: nothing reads past a count. |
| 110 | 110 | expect([...scene.lines]).toEqual(written); |
| 111 | expect(nextInstance(scene)).toBe(held as object); | |
| 111 | expect(nextInstance(scene)).toBe(held); | |
| 112 | 112 | expect((held as { mesh: string }).mesh).toBe("anything"); |
| 113 | 113 | }); |
| 114 | 114 |
src/example/plan.test.ts +21 −16
| @@ -20,11 +20,16 @@ function drew(over: Partial<Frame> = {}): FakePaint { | ||
| 20 | 20 | return paint; |
| 21 | 21 | } |
| 22 | 22 | |
| 23 | const rects = (paint: FakePaint): number[][] => | |
| 24 | paint.calls.filter((c) => c[0] === "fillRect").map((c) => c.slice(1).map(Number)); | |
| 23 | /** A `fillRect`, as the four numbers it was called with: x, y, width, height. */ | |
| 24 | type Rect = [number, number, number, number]; | |
| 25 | ||
| 26 | const rects = (paint: FakePaint): Rect[] => | |
| 27 | paint.calls | |
| 28 | .filter((c) => c[0] === "fillRect") | |
| 29 | .map((c): Rect => [Number(c[1]), Number(c[2]), Number(c[3]), Number(c[4])]); | |
| 25 | 30 | |
| 26 | 31 | /** The aim dots: the only 2×2 rectangles on the canvas, oldest first. */ |
| 27 | const dots = (paint: FakePaint): number[][] => rects(paint).filter((r) => r[2] === 2 && r[3] === 2); | |
| 32 | const dots = (paint: FakePaint): Rect[] => rects(paint).filter((r) => r[2] === 2 && r[3] === 2); | |
| 28 | 33 | |
| 29 | 34 | test("a frame is one background, one room, one plate, the aim and the readout", () => { |
| 30 | 35 | const paint = drew(); |
| @@ -34,11 +39,11 @@ test("a frame is one background, one room, one plate, the aim and the readout", | ||
| 34 | 39 | expect(all[0]).toEqual([0, 0, WIDTH, HEIGHT]); |
| 35 | 40 | expect(paint.calls.some((c) => c[0] === "clearRect")).toBe(false); |
| 36 | 41 | // The room is square on the canvas, and fits inside it. |
| 37 | const room = all.find((r) => r[2] === r[3] && r[2] > 100) as number[]; | |
| 42 | const room = all.find((r) => r[2] === r[3] && r[2] > 100)!; | |
| 38 | 43 | expect(room[2]).toBeGreaterThan(100); |
| 39 | 44 | expect(room[0]).toBeGreaterThanOrEqual(0); |
| 40 | expect((room[0] as number) + (room[2] as number)).toBeLessThanOrEqual(WIDTH); | |
| 41 | expect((room[1] as number) + (room[3] as number)).toBeLessThanOrEqual(HEIGHT); | |
| 45 | expect(room[0] + room[2]).toBeLessThanOrEqual(WIDTH); | |
| 46 | expect(room[1] + room[3]).toBeLessThanOrEqual(HEIGHT); | |
| 42 | 47 | // The readout, and the one line of help. |
| 43 | 48 | expect(paint.texts).toEqual(["score 0", "0:30", "press Enter, or Start, for thirty seconds"]); |
| 44 | 49 | // The player is a crosshair: four arms, one path, one stroke. |
| @@ -52,9 +57,9 @@ test("the aim walks out of the eye and stops at the wall", () => { | ||
| 52 | 57 | expect(walked.length).toBeGreaterThan(2); |
| 53 | 58 | // The world starts facing north, which is up the plan: every dot is higher |
| 54 | 59 | // than the one before it, and none of them leaves the room. |
| 55 | for (let i = 1; i < walked.length; i++) expect(walked[i]?.[1]).toBeLessThan(walked[i - 1]?.[1] as number); | |
| 56 | const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100) as number[]; | |
| 57 | for (const dot of walked) expect(dot[1]).toBeGreaterThanOrEqual(room[1] as number); | |
| 60 | for (let i = 1; i < walked.length; i++) expect(walked[i]![1]).toBeLessThan(walked[i - 1]![1]); | |
| 61 | const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100)!; | |
| 62 | for (const dot of walked) expect(dot[1]).toBeGreaterThanOrEqual(room[1]); | |
| 58 | 63 | }); |
| 59 | 64 | |
| 60 | 65 | test("turning turns the aim and nothing else", () => { |
| @@ -69,11 +74,11 @@ test("turning turns the aim and nothing else", () => { | ||
| 69 | 74 | test("the plate is drawn where the game says it is, and is never thinner than a line", () => { |
| 70 | 75 | const world = createWorld(7); |
| 71 | 76 | const paint = drew({ world }); |
| 72 | const plate = rects(paint).find((r) => r[2] > 2 && r[2] < 100) as number[]; | |
| 77 | const plate = rects(paint).find((r) => r[2] > 2 && r[2] < 100)!; | |
| 73 | 78 | expect(plate[3]).toBeGreaterThanOrEqual(2); |
| 74 | 79 | // It is against the far wall, so it is drawn above the middle of the plan. |
| 75 | const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100) as number[]; | |
| 76 | expect(plate[1]).toBeLessThan((room[1] as number) + (room[3] as number) / 2); | |
| 80 | const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100)!; | |
| 81 | expect(plate[1]).toBeLessThan(room[1] + room[3] / 2); | |
| 77 | 82 | expect(world.target.max[1]).toBeLessThanOrEqual(ROOM.max[1]); |
| 78 | 83 | }); |
| 79 | 84 | |
| @@ -84,8 +89,8 @@ test("the clock reads the overlay's m:ss, and the last seconds are said differen | ||
| 84 | 89 | // The countdown bar is the fraction of the run that is left. |
| 85 | 90 | const full = rects(drew({ clock: 30, fraction: 1 })); |
| 86 | 91 | const half = rects(drew({ clock: 15, fraction: 0.5 })); |
| 87 | const bar = (r: number[][]): number[] => r.filter((x) => x[3] === 3)[0] as number[]; | |
| 88 | expect(bar(half)[2]).toBeCloseTo((bar(full)[2] as number) / 2, 6); | |
| 92 | const bar = (r: Rect[]): Rect => r.filter((x) => x[3] === 3)[0]!; | |
| 93 | expect(bar(half)[2]).toBeCloseTo(bar(full)[2] / 2, 6); | |
| 89 | 94 | }); |
| 90 | 95 | |
| 91 | 96 | test("each phase says its own line and no other", () => { |
| @@ -99,7 +104,7 @@ test("a narrow phone is the same frame, laid out inside it", () => { | ||
| 99 | 104 | drawPlan(paint, 360, 270, { world: createWorld(7), phase: "live", clock: 12, fraction: 0.4 }); |
| 100 | 105 | for (const r of rects(paint)) { |
| 101 | 106 | expect(r[0]).toBeGreaterThanOrEqual(0); |
| 102 | expect((r[0] as number) + (r[2] as number)).toBeLessThanOrEqual(360 + 1); | |
| 103 | expect((r[1] as number) + (r[3] as number)).toBeLessThanOrEqual(270 + 1); | |
| 107 | expect(r[0] + r[2]).toBeLessThanOrEqual(360 + 1); | |
| 108 | expect(r[1] + r[3]).toBeLessThanOrEqual(270 + 1); | |
| 104 | 109 | } |
| 105 | 110 | }); |
src/frames.test.ts +2
| @@ -61,6 +61,8 @@ function standing(count: number): Run { | ||
| 61 | 61 | const target: Target = { |
| 62 | 62 | id: i + 1, |
| 63 | 63 | box: { min: [x - half, y - half, z - half], max: [x + half, y + half, z + half], mat: PLATE_MAT }, |
| 64 | // A plate that stands: zero velocity is what `step` adds per tick. | |
| 65 | vel: [0, 0, 0], rate: 0, | |
| 64 | 66 | life: 1, span: 2, points: 100, distance: x, |
| 65 | 67 | }; |
| 66 | 68 | run.targets.push(target); |
src/range/binding.test.ts +2 −2
| @@ -34,8 +34,8 @@ test("the range's bindings are its own, not the shooter's", () => { | ||
| 34 | 34 | // page owns: full screen is the browser's and the range's game never sees it. |
| 35 | 35 | expect(RANGE_ACTIONS).not.toContain("fullscreen"); |
| 36 | 36 | for (const action of Object.values(RANGE_KEYS)) |
| 37 | if (action !== "fullscreen") expect(RANGE_ACTIONS).toContain(action); | |
| 38 | for (const action of Object.values(RANGE_MOUSE)) expect(RANGE_ACTIONS).toContain(action); | |
| 37 | if (action !== "fullscreen") expect<readonly string[]>(RANGE_ACTIONS).toContain(action); | |
| 38 | for (const action of Object.values(RANGE_MOUSE)) expect<readonly string[]>(RANGE_ACTIONS).toContain(action); | |
| 39 | 39 | }); |
| 40 | 40 | |
| 41 | 41 | test("nothing held is nothing asked for", () => { |
src/range/boot.test.ts +13 −8
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | 15 | import { HIT, POP, TICK } from "./audio.ts"; |
| 16 | import { page, type RangePage, type Voice } from "./stage.test-helper.ts"; | |
| 16 | import { page, type GlRecorder, type RangePage, type Voice } from "./stage.test-helper.ts"; | |
| 17 | 17 | |
| 18 | 18 | // The entry boots itself when it is imported, so it is imported into a page |
| 19 | 19 | // that is still loading: then every test below boots a fresh one on purpose. |
| @@ -234,10 +234,13 @@ test("a canvas with no context is a sentence on the page, not an exception", () | ||
| 234 | 234 | } |
| 235 | 235 | }); |
| 236 | 236 | |
| 237 | /** A `fillRect`, as the recorder wrote it down: the name, then x, y and width. */ | |
| 238 | type Fill = [string, number, number, number]; | |
| 239 | ||
| 237 | 240 | /** The fillRects of the last frame, less the sky and the ground: the plates. */ |
| 238 | function platesDrawn(p: RangePage, from: number): [number, ...unknown[]][] { | |
| 241 | function platesDrawn(p: RangePage, from: number): Fill[] { | |
| 239 | 242 | const calls = (p.scene.paint?.calls ?? []).slice(from); |
| 240 | return calls.filter((c) => c[0] === "fillRect").slice(2) as [number, ...unknown[]][]; | |
| 243 | return calls.filter((c) => c[0] === "fillRect").slice(2) as Fill[]; | |
| 241 | 244 | } |
| 242 | 245 | |
| 243 | 246 | /** The most recent line the panel drew starting with `prefix`: this frame's. */ |
| @@ -283,7 +286,7 @@ test("a mouse plays it: turn onto a plate, click, and the score moves", () => { | ||
| 283 | 286 | p.step(20); |
| 284 | 287 | const plate = platesDrawn(p, before)[0]; |
| 285 | 288 | expect(plate).toBeDefined(); |
| 286 | const [, left, top, size] = plate as [string, number, number, number]; | |
| 289 | const [, left, top, size] = plate!; | |
| 287 | 290 | |
| 288 | 291 | // The canvas is 800 × 450, so the crosshair is at 400, 225 and a degree is |
| 289 | 292 | // five pixels. The angle from it to the plate's centre is the turn that |
| @@ -354,8 +357,10 @@ const standing = (p: RangePage): number => | ||
| 354 | 357 | Number(/(\d+) up/.exec([...hudTexts(p)].reverse().find((line) => line.includes(" up")) ?? "")?.[1]); |
| 355 | 358 | |
| 356 | 359 | /** Every GL call the scene canvas was given, in order. */ |
| 360 | // The engine keeps `gl` as `unknown` — it does not know what a recorder is — | |
| 361 | // and this page is what put one there. | |
| 357 | 362 | const glCalls = (p: RangePage): [string, ...unknown[]][] => |
| 358 | (p.scene.gl?.calls ?? []) as [string, ...unknown[]][]; | |
| 363 | (p.scene.gl as GlRecorder | null)?.calls ?? []; | |
| 359 | 364 | |
| 360 | 365 | /** What one frame asked the GL context for: the calls made after `from`. */ |
| 361 | 366 | const since = (p: RangePage, from: number): [string, ...unknown[]][] => glCalls(p).slice(from); |
| @@ -640,7 +645,7 @@ test("T chooses the drill between runs, and does nothing during one", () => { | ||
| 640 | 645 | const where = (): number => { |
| 641 | 646 | const before = p.scene.paint?.calls.length ?? 0; |
| 642 | 647 | p.step(20); |
| 643 | return (platesDrawn(p, before)[0] as [string, number, ...unknown[]])[1]; | |
| 648 | return platesDrawn(p, before)[0]![1]; | |
| 644 | 649 | }; |
| 645 | 650 | expect(where()).not.toBe(where()); |
| 646 | 651 | } finally { |
| @@ -724,14 +729,14 @@ test("walking changes what you can hit: the same aim from two places", () => { | ||
| 724 | 729 | p.window.fire("keyup", { code: "Enter" }); |
| 725 | 730 | const before = p.scene.paint?.calls.length ?? 0; |
| 726 | 731 | p.step(20); |
| 727 | const first = platesDrawn(p, before)[0] as [string, number, number, number]; | |
| 732 | const first = platesDrawn(p, before)[0]!; | |
| 728 | 733 | |
| 729 | 734 | p.window.fire("keydown", { code: "KeyA" }); |
| 730 | 735 | for (let i = 0; i < 4; i++) p.step(100); |
| 731 | 736 | p.window.fire("keyup", { code: "KeyA" }); |
| 732 | 737 | const moved = p.scene.paint?.calls.length ?? 0; |
| 733 | 738 | p.step(20); |
| 734 | const second = platesDrawn(p, moved)[0] as [string, number, number, number]; | |
| 739 | const second = platesDrawn(p, moved)[0]!; | |
| 735 | 740 | |
| 736 | 741 | // The same plate, the same view angles, a different place on the screen: |
| 737 | 742 | // the horizon is drawn from the eye the body has, not from a constant. |
src/range/bundle.test.ts +4 −2
| @@ -18,7 +18,7 @@ import { afterAll, expect, test } from "bun:test"; | ||
| 18 | 18 | import { readFileSync } from "node:fs"; |
| 19 | 19 | import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts"; |
| 20 | 20 | import { RANGE_MOVE } from "./movement.ts"; |
| 21 | import { page, type RangePage } from "./stage.test-helper.ts"; | |
| 21 | import { page, type GlRecorder, type RangePage } from "./stage.test-helper.ts"; | |
| 22 | 22 | |
| 23 | 23 | afterAll(discardBundleCopies); |
| 24 | 24 | |
| @@ -101,7 +101,9 @@ test("the bundled entry draws the room and the plates when the browser has WebGL | ||
| 101 | 101 | const { p, undo } = await booted({ webgl: true }); |
| 102 | 102 | try { |
| 103 | 103 | // The range's own atlas, uploaded once by the minified file: three 64² layers. |
| 104 | const calls = (p.scene.gl?.calls ?? []) as [string, ...unknown[]][]; | |
| 104 | // The engine keeps `gl` as `unknown` — it does not know what a recorder | |
| 105 | // is — and this page is what put one there. | |
| 106 | const calls = (p.scene.gl as GlRecorder | null)?.calls ?? []; | |
| 105 | 107 | expect(calls.find((call) => call[0] === "texImage3D")?.slice(4, 7)).toEqual([64, 64, 3]); |
| 106 | 108 | const before = calls.length; |
| 107 | 109 | p.step(20); |
src/range/main.ts +8 −4
| @@ -79,13 +79,16 @@ export function boot(): void { | ||
| 79 | 79 | |
| 80 | 80 | // A canvas that gives no context is a sentence on the page, never a throw: |
| 81 | 81 | // everything else here — the text, the links, the terminal line — still works. |
| 82 | const hudCtx = hud.getContext("2d") as Paint | null; | |
| 83 | if (hudCtx === null) { | |
| 82 | const maybeHudCtx = hud.getContext("2d") as Paint | null; | |
| 83 | if (maybeHudCtx === null) { | |
| 84 | 84 | note.innerHTML = |
| 85 | 85 | "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the readout cannot be" + |
| 86 | 86 | " drawn. The rest of the page still works.</p>"; |
| 87 | 87 | return; |
| 88 | 88 | } |
| 89 | // Re-bound after the guard because `frame` below is a hoisted declaration, | |
| 90 | // and a check outside one does not narrow a name inside it. | |
| 91 | const hudCtx = maybeHudCtx; | |
| 89 | 92 | |
| 90 | 93 | // The room, the plates and the tracer, drawn by the engine's renderer out of |
| 91 | 94 | // this game's own atlas and its own two meshes. Null is a browser with no |
| @@ -331,10 +334,11 @@ export function boot(): void { | ||
| 331 | 334 | |
| 332 | 335 | if (renderer !== null) { |
| 333 | 336 | renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed); |
| 334 | } else { | |
| 337 | } else if (sceneCtx !== null) { | |
| 335 | 338 | const [sw, sh] = size(scene); |
| 336 | drawHorizon(sceneCtx as Paint, view, sw, sh, { eye: view.eye, targets: run.targets }); | |
| 339 | drawHorizon(sceneCtx, view, sw, sh, { eye: view.eye, targets: run.targets }); | |
| 337 | 340 | } |
| 341 | // No third branch: the guard above returned when both were null. | |
| 338 | 342 | const [hw, hh] = size(hud); |
| 339 | 343 | drawReadout( |
| 340 | 344 | hudCtx, |
src/range/paint.test.ts +1 −1
| @@ -63,7 +63,7 @@ test("the range's atlas is small on purpose, and smaller than the shooter's nine | ||
| 63 | 63 | test("every layer paints something visible, and no two of them are the same", () => { |
| 64 | 64 | const swatches = new Set<string>(); |
| 65 | 65 | for (const shade of RANGE_SHADERS) { |
| 66 | for (const [u, v] of [[0.2, 0.2], [0.5, 0.5], [0.8, 0.45]]) { | |
| 66 | for (const [u, v] of [[0.2, 0.2], [0.5, 0.5], [0.8, 0.45]] as [number, number][]) { | |
| 67 | 67 | const rgb = shade(u, v); |
| 68 | 68 | expect(rgb.length).toBe(3); |
| 69 | 69 | for (const channel of rgb) { |
src/range/readout.test.ts +3
| @@ -141,6 +141,9 @@ test("the horizon follows the pitch and the compass follows the yaw", () => { | ||
| 141 | 141 | const plate = (at: [number, number, number], half = 16, left = 1): Target => ({ |
| 142 | 142 | id: 1, |
| 143 | 143 | box: { min: [at[0] - half, at[1] - half, at[2] - half], max: [at[0] + half, at[1] + half, at[2] + half], mat: 0 }, |
| 144 | // A plate that stands still: a pop-up's velocity is three zeroes. | |
| 145 | vel: [0, 0, 0], | |
| 146 | rate: 0, | |
| 144 | 147 | life: left, |
| 145 | 148 | span: 1, |
| 146 | 149 | points: 100, |
src/range/scene.test.ts +3
| @@ -27,6 +27,9 @@ function plate(id: number, at: [number, number, number], half = PLATE_HALF, left | ||
| 27 | 27 | max: [at[0] + half, at[1] + half, at[2] + half], |
| 28 | 28 | mat: PLATE_MAT, |
| 29 | 29 | }, |
| 30 | // A plate that stands still: a pop-up's velocity is three zeroes. | |
| 31 | vel: [0, 0, 0], | |
| 32 | rate: 0, | |
| 30 | 33 | life: 2 * left, |
| 31 | 34 | span: 2, |
| 32 | 35 | points: 100 + id, |
static/app.46e58255bb.js deleted −58
Diff not shown — a line 73,131 characters wide (limit 2,000). −58
View filestatic/app.6bbbaf11e0.js deleted −58
Diff not shown — a line 72,599 characters wide (limit 2,000). −58
View filestatic/app.8342c8f923.js added +58
Diff not shown — a line 73,223 characters wide (limit 2,000). +58
View filestatic/range.15d5b4d3e8.js deleted −58
Diff not shown — a line 18,798 characters wide (limit 2,000). −58
View filestatic/range.3eb16f5ea0.js added +58
Diff not shown — a line 18,819 characters wide (limit 2,000). +58
View filetsconfig.json added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | { | |
| 2 | "compilerOptions": { | |
| 3 | "lib": ["ESNext", "DOM", "DOM.Iterable"], | |
| 4 | "target": "ESNext", | |
| 5 | "module": "ESNext", | |
| 6 | "moduleResolution": "bundler", | |
| 7 | "types": ["bun"], | |
| 8 | ||
| 9 | "allowImportingTsExtensions": true, | |
| 10 | "verbatimModuleSyntax": true, | |
| 11 | "noEmit": true, | |
| 12 | ||
| 13 | "strict": true, | |
| 14 | "noUncheckedIndexedAccess": true, | |
| 15 | "noImplicitOverride": true, | |
| 16 | "noFallthroughCasesInSwitch": true, | |
| 17 | ||
| 18 | "skipLibCheck": true | |
| 19 | }, | |
| 20 | "include": ["src", "scripts"] | |
| 21 | } |