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

Merged opened this

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

Merged

torque/cs-engine:torque/118-the-types-are-checked-not-just-written-o → main 2 commits fork

  • Merged. torque/118-the-types-are-checked-not-just-written-o was merged into main as 4126362.

Review

Sign in to review this change.
41 files changed +468 −278
README.md +25
@@ -246,6 +246,31 @@ projections of `src/games.ts` and nobody edited them. The 2D is the point: no
246246 renderer, no atlas, no mesh and no sound ship in that bundle, and
247247 `src/example/bundle.test.ts` reads the committed file to prove it.
248248
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
249274 ## Shape
250275
251276 - `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 @@
11 {
22 "app": {
33 "run": "bun run src/server.ts",
4 "test": "bun test",
4 "test": "bun run test",
55 "tty": "bun run src/tty.ts"
66 }
77 }
package.json +6 −1
@@ -5,6 +5,11 @@
55 "scripts": {
66 "start": "bun run src/server.ts",
77 "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"
914 }
1015 }
scripts/build.ts +4 −1
@@ -39,7 +39,10 @@ export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name,
3939 export const GZIP_LEVEL = 9;
4040
4141 /** 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 {
4346 const bytes = typeof code === "string" ? new TextEncoder().encode(code) : code;
4447 return { raw: bytes.byteLength, gzip: Bun.gzipSync(bytes, { level: GZIP_LEVEL }).byteLength };
4548 }
src/client/action.test.ts +21 −2
@@ -13,9 +13,9 @@ test("actions decode to the bins they name, and to a playable input", () => {
1313 const actions = new Int32Array([0, PITCH_BINS.length - 1, 0, 2, 1, 2]);
1414 const intent = decode(actions);
1515 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]!);
1717 expect(intent.forward).toBe(AXIS_BINS[0]);
18 expect(intent.side).toBe(AXIS_BINS[2]);
18 expect(intent.side).toBe(AXIS_BINS[2]!);
1919 expect(intent.fire).toBe(true);
2020 expect(intent.duck).toBe(true);
2121 expect(intent.walk).toBe(false);
@@ -30,6 +30,25 @@ test("actions decode to the bins they name, and to a playable input", () => {
3030 expect(Object.keys(input)).not.toContain("yawRate");
3131 });
3232
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
3352 test("the middle bin of every axis is standing still", () => {
3453 const middle = new Int32Array([
3554 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 @@
1313 import type { WeaponId } from "./weapons.ts";
1414 import type { Input } from "./world.ts";
1515
16 export const YAW_BINS = [-720, -240, -80, -25, 0, 25, 80, 240, 720];
17 export const PITCH_BINS = [-240, -80, -25, 0, 25, 80, 240];
18 export const AXIS_BINS = [-1, 0, 1];
16 /** A head's bins: at least one, so there is always a bin to fall back on. */
17 export type Bins = readonly [number, ...number[]];
18
19 export const YAW_BINS: Bins = [-720, -240, -80, -25, 0, 25, 80, 240, 720];
20 export const PITCH_BINS: Bins = [-240, -80, -25, 0, 25, 80, 240];
21 export const AXIS_BINS: Bins = [-1, 0, 1];
1922
2023 /** The heads, in the order the policy writes them. */
2124 export const HEADS = [YAW_BINS.length, PITCH_BINS.length, AXIS_BINS.length, AXIS_BINS.length, 2, 3];
@@ -36,14 +39,24 @@ export const STILL: Intent = {
3639 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
3740 };
3841
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
3952 /** Turn one sample from the policy into an intent. */
4053 export function decode(actions: Int32Array, at = 0): Intent {
41 const posture = actions[at + 5] as number;
54 const posture = actions[at + 5];
4255 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]),
4760 fire: actions[at + 4] === 1,
4861 walk: posture === 1,
4962 duck: posture === 2,
src/client/bomb.test.ts +4 −2
@@ -12,7 +12,7 @@
1212 import { expect, test } from "bun:test";
1313 import {
1414 defuseTime, dropBomb, giveBomb, newBomb, updateBomb, useProgress,
15 type Carrier, type Round,
15 type BombState, type Carrier, type Round,
1616 } from "./bomb.ts";
1717 import { DE_FORGE } from "./map.ts";
1818 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
126126 expect(hold(r, ct, SHORT.defuseTime * 0.9)).toBe(null);
127127 expect(r.bomb.state).toBe("planted");
128128 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");
130132 });
131133
132134 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 = {
2727 hasBomb: boolean;
2828 };
2929
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
3033 export type Bomb = {
31 state: "carried" | "dropped" | "planted" | "defused" | "detonated";
34 state: BombState;
3235 pos: Vec3;
3336 site: "A" | "B" | null;
3437 plantProgress: number;
src/client/bots.test.ts +3 −3
@@ -7,7 +7,7 @@ import { grip } from "./spray.ts";
77 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
88 import { dist, type Vec3 } from "../engine/index.ts";
99 import { OBS_SIZE } from "./observe.ts";
10 import { FLASH } from "./weapons.ts";
10 import { FLASH, type WeaponId } from "./weapons.ts";
1111 import { DE_FORGE } from "./map.ts";
1212 import { COMPETITIVE } from "./match.ts";
1313 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", (
286286 // Before this it held the trigger down on an empty rifle for the rest of the
287287 // round: `shoot` refuses without a round in the magazine and `reload` refuses
288288 // 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);
290290 expect(shots).toBeGreaterThan(0);
291291 expect(d.me.health).toBeLessThan(100);
292292 });
@@ -299,7 +299,7 @@ test("a bot with nothing left to fire closes the distance and knifes", () => {
299299 d.bot.ammo[d.bot.secondary] = { mag: 0, reserve: 0 };
300300 const started = dist(d.bot.pos, d.me.pos);
301301 const shots = fight(d, 384);
302 expect(d.bot.weapon).toBe("knife");
302 expect<WeaponId>(d.bot.weapon).toBe("knife");
303303 expect(dist(d.bot.pos, d.me.pos)).toBeLessThan(started); // it walked in
304304 expect(shots).toBeGreaterThan(0);
305305 expect(d.me.health).toBeLessThan(100);
src/client/hud.test.ts +5 −2
@@ -27,8 +27,10 @@ import { SHORT } from "./match.ts";
2727 import { inaccuracy, WEAPONS } from "./weapons.ts";
2828 import {
2929 createWorld, currentWeapon, EFFECT_CAPACITY,
30 type Ammo, type Effect, type Player, type World,
30 type Effect, type Player, type World,
3131 } from "./world.ts";
32 // `Ammo` is economy.ts's; world.ts imports it rather than exporting it.
33 import type { Ammo } from "./economy.ts";
3234 import { createEffects, pushEffect } from "../engine/index.ts";
3335
3436 /** 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
435437
436438 test("the scoreboard ranks by kills, then by damage, marks you, and dims the dead", () => {
437439 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");
439442 me.kills = 2; me.deaths = 1; me.headshots = 1; me.damage = 310.4; me.money = 3200;
440443 second.kills = 5; second.damage = 700;
441444 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(
152152 ctx.lineWidth = 2;
153153 const r = 9 + age * 20;
154154 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) {
156157 ctx.moveTo(width / 2 + dx * r * 0.55, height / 2 + dy * r * 0.55);
157158 ctx.lineTo(width / 2 + dx * r, height / 2 + dy * r);
158159 }
src/client/level.test.ts +8 −6
@@ -47,7 +47,7 @@ const ROWS = [
4747 ];
4848
4949 const GRID: Grid<string> = {
50 width: ROWS[0].length,
50 width: ROWS[0]!.length,
5151 height: ROWS.length,
5252 cell: CELL,
5353 cells: ROWS.flatMap((row) => [...row]),
@@ -96,9 +96,11 @@ function problems(level: Level): string[] {
9696 for (const [name, site] of Object.entries(level.sites))
9797 if (siteAt(level, site) !== name) bad.push(`${level.name}: site ${name} at ${site} is not on ${name}`);
9898 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];
100100 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)
102104 if (box.min[axis] < -edge[axis] || box.max[axis] > edge[axis])
103105 bad.push(`${level.name}: a box runs from ${box.min} to ${box.max}, outside the grid`);
104106 return bad;
@@ -141,10 +143,10 @@ test("only the map and the entry name de_forge: everything else is handed a leve
141143 // --- and the game played on it --------------------------------------------
142144
143145 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]!);
145147 expect(path.length).toBeGreaterThan(4);
146148 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);
148150 for (const step of path) expect(walkable(ROOM, colOf(ROOM, step[0]), rowOf(ROOM, step[1]))).toBe(true);
149151 });
150152
@@ -153,7 +155,7 @@ test("a world on the room is a world on the room, and its bot walks it", () => {
153155 expect(world.level).toBe(ROOM);
154156 // The bot spawns on the room's T spawn, not on de_forge's.
155157 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]]);
157159 expect(siteAt(ROOM, bot.pos)).toBe(null);
158160
159161 // 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 {
185185 // The line in the pause panel that says what Escape just did on this browser.
186186 const escHint = by("esc");
187187
188 const build = host.createRenderer(sceneCanvas);
189 if (build === null) {
188 const maybeBuild = host.createRenderer(sceneCanvas);
189 if (maybeBuild === null) {
190190 menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>";
191191 return { world: () => null };
192192 }
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;
193197 const hudCtx = hudCanvas.getContext("2d") as Paint;
194198 const audio = host.createAudio();
195199 const controls = createControls(
src/client/map.test.ts +16 −10
@@ -12,6 +12,12 @@ import { DE_FORGE, GRID_H, GRID_W } from "./map.ts";
1212
1313 const { boxes, sites, spawns } = DE_FORGE;
1414
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
1521 test("the map has two sites and two spawns", () => {
1622 expect(spawns.t.length).toBeGreaterThan(4);
1723 expect(spawns.ct.length).toBeGreaterThan(4);
@@ -40,14 +46,14 @@ test("grid and world coordinates round-trip", () => {
4046
4147 test("every walkable cell is reachable from T spawn", () => {
4248 // 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;
4450 const seen = new Set<number>();
4551 const queue = [rowOf(DE_FORGE, start[1]) * GRID_W + colOf(DE_FORGE, start[0])];
46 seen.add(queue[0]);
52 seen.add(queue[0]!);
4753 while (queue.length > 0) {
4854 const i = queue.pop() as number;
4955 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][]) {
5157 if (!walkable(DE_FORGE, c + dc, r + dr)) continue;
5258 const j = (r + dr) * GRID_W + c + dc;
5359 if (seen.has(j)) continue;
@@ -84,33 +90,33 @@ test("de_forge is exactly where it was, to the unit", () => {
8490 test("the route from T spawn to A is the same route it was", () => {
8591 // A number a change to the search moves: 33 waypoints from the first T spawn
8692 // 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]);
8995 expect(path.length).toBe(33);
9096 expect(path[0]).toEqual([704, -1984, 0]);
9197 expect(path[path.length - 1]).toEqual(sites.A);
9298 let walked = 0;
9399 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]);
95101 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]);
97103 expect(crow).toBeCloseTo(4141.87, 1);
98104 });
99105
100106 test("a path from T spawn reaches both sites", () => {
101107 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);
103109 expect(path.length).toBeGreaterThan(4);
104110 // Consecutive waypoints are neighbouring cells, never a jump across a wall.
105111 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]);
107113 expect(step).toBeLessThan(200);
108114 }
109115 }
110116 });
111117
112118 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([]);
114120 });
115121
116122 test("de_forge carries its own alphabet, its numbers and its material table", () => {
src/client/match.test.ts +5 −2
@@ -1,6 +1,7 @@
11 import { expect, test } from "bun:test";
22 import {
33 COMPETITIVE, endRound, halfTime, LOSS_BONUS, newMatch, plantBomb, roundReward, SHORT, tick, winReward,
4 type Phase,
45 } from "./match.ts";
56
67 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", () => {
6465 expect(m.matchWinner).toBe("ct");
6566 const events = run(m, SHORT.endDelay + 0.2);
6667 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");
6871 // A finished match stays finished.
6972 expect(tick(m, 10)).toEqual([]);
7073 });
@@ -84,7 +87,7 @@ test("losing pays more the longer you have been losing", () => {
8487 test("a plant pays the losing terrorists, and the bomb pays more to win", () => {
8588 const m = newMatch(COMPETITIVE);
8689 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);
8891 expect(roundReward(m, "ct", "ct", "defused", true)).toBe(3500);
8992 expect(winReward("elimination")).toBe(3250);
9093 });
src/client/stage.test-helper.ts +3
@@ -211,6 +211,9 @@ export function fakeStage(
211211 muted: false,
212212 resume: () => void sounds.push("resume"),
213213 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}`),
214217 hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"),
215218 beep: () => void sounds.push("beep"),
216219 };
src/client/world.test.ts +16 −8
@@ -5,6 +5,7 @@ import { SHORT } from "./match.ts";
55 import { WEAPONS } from "./weapons.ts";
66 import { buy } from "./economy.ts";
77 import { createWorld, currentWeapon, NO_INPUT, shoot, startRound, update, type Input, type World } from "./world.ts";
8 import type { Vec3 } from "../engine/index.ts";
89
910 const TICK = 1 / 64;
1011 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", () => {
2324
2425 test("nobody moves or shoots during the freeze", () => {
2526 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]);
2730 run(world, SHORT.freezeTime - 1, { ...NO_INPUT, forward: 1, fire: true });
2831 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);
3033 });
3134 expect(world.players.every((p) => p.health === 100)).toBe(true);
3235 });
@@ -37,7 +40,9 @@ test("a shot costs a bullet and a reload puts it back", () => {
3740 buy(world, world.me, "m4a4");
3841 run(world, SHORT.freezeTime + 0.5);
3942 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!;
4146 expect(shoot(world, world.me)).toBe(true);
4247 expect(ammo.mag).toBe(WEAPONS.m4a4.magazine - 1);
4348 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", () => {
4853 test("bots leave their spawn once the round goes live", () => {
4954 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 5);
5055 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");
5157 const start = [...bot.pos];
5258 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);
5460 });
5561
5662 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", () =
97103 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 17);
98104 world.me.money = 5000;
99105 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;
103111 startRound(world);
104112 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);
106114 expect(world.me.primary).toBe("m4a4"); // guns you bought stay bought
107115 expect(world.players.filter((p) => p.hasBomb).length).toBe(1);
108116 });
src/client/world.ts +6 −1
@@ -264,7 +264,12 @@ export function startRound(world: World): void {
264264 const spots = { t: [...spawns.t], ct: [...spawns.ct] };
265265 for (const p of world.players) {
266266 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;
268273 p.pos = [at[0], at[1], 2];
269274 p.vel = [0, 0, 0];
270275 p.health = 100;
src/engine/controls.test.ts +11 −4
@@ -1,7 +1,10 @@
11 import { expect, test } from "bun:test";
22 import { attachButtons, createControls, type Host } from "./controls.ts";
33
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;
58 type Registration = { type: string; handler: Handler; options?: { passive?: boolean } };
69
710 /** An event target made of an array: the test is the browser. */
@@ -17,17 +20,21 @@ function stubTarget() {
1720 optionsFor(type: string): { passive?: boolean } | undefined {
1821 return registered.find((r) => r.type === type)?.options;
1922 },
20 addEventListener(type: string, handler: Handler, options?: { passive?: boolean }) {
23 addEventListener<E>(type: string, handler: (event: E) => void, options?: { passive?: boolean }) {
2124 registered.push({ type, handler, options });
2225 },
23 removeEventListener(type: string, handler: Handler) {
26 removeEventListener<E>(type: string, handler: (event: E) => void) {
2427 const at = registered.findIndex((r) => r.type === type && r.handler === handler);
2528 if (at < 0) return;
2629 registered.splice(at, 1);
2730 removes += 1;
2831 },
2932 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);
3138 },
3239 };
3340 }
src/engine/controls.ts +9 −6
@@ -15,13 +15,16 @@
1515 import { clamp } from "./math.ts";
1616
1717 /**
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.
2124 */
2225 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;
2528 };
2629
2730 /** The element the pointer locks to and the fingers land on. */
@@ -105,7 +108,7 @@ export function createControls(host: Host, options: Options): Controls {
105108 const on = (
106109 target: Listens,
107110 type: string,
108 handler: (event: any) => void,
111 handler: (event: never) => void,
109112 passive?: boolean,
110113 ): void => {
111114 target.addEventListener(type, handler, passive === undefined ? undefined : { passive });
src/engine/math.test.ts +16 −4
@@ -1,5 +1,13 @@
11 import { expect, test } from "bun:test";
22 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;
311
412 test("yaw 0 looks east, yaw 90 looks north", () => {
513 const east = angleVectors(0, 0).forward;
@@ -28,8 +36,12 @@ test("the shortest way from 350 to 10 is forwards, not the long way", () => {
2836
2937 test("the view matrix puts a point in front of the eye down -Z", () => {
3038 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 );
3345 expect(eye[0]).toBeCloseTo(0, 4);
3446 expect(eye[1]).toBeCloseTo(0, 4);
3547 expect(eye[2]).toBeCloseTo(-500, 4);
@@ -38,7 +50,7 @@ test("the view matrix puts a point in front of the eye down -Z", () => {
3850 test("perspective maps the near plane to -1 and the far plane to 1", () => {
3951 const p = perspective(90, 1, 8, 8192);
4052 const project = (z: number): number => {
41 const clipZ = p[10] * z + p[14];
53 const clipZ = cell(p, 10) * z + cell(p, 14);
4254 const clipW = -z;
4355 return clipZ / clipW;
4456 };
@@ -49,7 +61,7 @@ test("perspective maps the near plane to -1 and the far plane to 1", () => {
4961 test("multiplying by identity changes nothing", () => {
5062 const a = perspective(70, 1.6, 8, 4096);
5163 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);
5365 });
5466
5567 test("normalize survives a zero vector", () => {
src/engine/physics.ts +5 −2
@@ -78,10 +78,13 @@ export type Wish = {
7878 maxSpeed: number; // the weapon's carry speed
7979 };
8080
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 =>
8285 hull.eyeStand + (hull.eyeDuck - hull.eyeStand) * b.duckAmount;
8386
84 export const hullHeight = (b: Body, hull: Hull): number =>
87 export const hullHeight = (b: Pick<Body, "duckAmount">, hull: Hull): number =>
8588 hull.stand + (hull.duck - hull.stand) * b.duckAmount;
8689
8790 function applyFriction(vel: Vec3, dt: number, tuning: MoveTuning): void {
src/engine/purity.probe.ts +6
@@ -20,6 +20,12 @@
2020 * waits on stdin. `.probe.ts`, like `.test.ts`, is tooling and ships nowhere.
2121 */
2222
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
2329 /** Every global a browser has and a test, a bench or a server does not. */
2430 const WATCHED = [
2531 "window",
src/engine/renderer.test.ts +4 −1
@@ -12,7 +12,10 @@
1212 */
1313
1414 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";
1619 import { addBox, builder, finish, type Mesh } from "./mesh.ts";
1720
1821 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
5151 test("the cursor hands out the pool in order and counts what it handed out", () => {
5252 const scene = createScene({ instances: 3, lineVertices: 2 });
5353 const first = nextInstance(scene);
54 expect(first).toBe(scene.instances[0] as object);
54 expect(first).toBe(scene.instances[0]!);
5555 expect(scene.instanceCount).toBe(1);
56 expect(nextInstance(scene)).toBe(scene.instances[1] as object);
56 expect(nextInstance(scene)).toBe(scene.instances[1]!);
5757 expect(scene.instanceCount).toBe(2);
5858 });
5959
@@ -108,7 +108,7 @@ test("a reset puts the counts back and frees nothing", () => {
108108 expect(scene.lines).toBe(lines);
109109 // Last frame's numbers are still there: nothing reads past a count.
110110 expect([...scene.lines]).toEqual(written);
111 expect(nextInstance(scene)).toBe(held as object);
111 expect(nextInstance(scene)).toBe(held);
112112 expect((held as { mesh: string }).mesh).toBe("anything");
113113 });
114114
src/example/plan.test.ts +21 −16
@@ -20,11 +20,16 @@ function drew(over: Partial<Frame> = {}): FakePaint {
2020 return paint;
2121 }
2222
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])]);
2530
2631 /** 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);
2833
2934 test("a frame is one background, one room, one plate, the aim and the readout", () => {
3035 const paint = drew();
@@ -34,11 +39,11 @@ test("a frame is one background, one room, one plate, the aim and the readout",
3439 expect(all[0]).toEqual([0, 0, WIDTH, HEIGHT]);
3540 expect(paint.calls.some((c) => c[0] === "clearRect")).toBe(false);
3641 // 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)!;
3843 expect(room[2]).toBeGreaterThan(100);
3944 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);
4247 // The readout, and the one line of help.
4348 expect(paint.texts).toEqual(["score 0", "0:30", "press Enter, or Start, for thirty seconds"]);
4449 // 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", () => {
5257 expect(walked.length).toBeGreaterThan(2);
5358 // The world starts facing north, which is up the plan: every dot is higher
5459 // 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]);
5863 });
5964
6065 test("turning turns the aim and nothing else", () => {
@@ -69,11 +74,11 @@ test("turning turns the aim and nothing else", () => {
6974 test("the plate is drawn where the game says it is, and is never thinner than a line", () => {
7075 const world = createWorld(7);
7176 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)!;
7378 expect(plate[3]).toBeGreaterThanOrEqual(2);
7479 // 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);
7782 expect(world.target.max[1]).toBeLessThanOrEqual(ROOM.max[1]);
7883 });
7984
@@ -84,8 +89,8 @@ test("the clock reads the overlay's m:ss, and the last seconds are said differen
8489 // The countdown bar is the fraction of the run that is left.
8590 const full = rects(drew({ clock: 30, fraction: 1 }));
8691 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);
8994 });
9095
9196 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", () => {
99104 drawPlan(paint, 360, 270, { world: createWorld(7), phase: "live", clock: 12, fraction: 0.4 });
100105 for (const r of rects(paint)) {
101106 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);
104109 }
105110 });
src/frames.test.ts +2
@@ -61,6 +61,8 @@ function standing(count: number): Run {
6161 const target: Target = {
6262 id: i + 1,
6363 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,
6466 life: 1, span: 2, points: 100, distance: x,
6567 };
6668 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", () => {
3434 // page owns: full screen is the browser's and the range's game never sees it.
3535 expect(RANGE_ACTIONS).not.toContain("fullscreen");
3636 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);
3939 });
4040
4141 test("nothing held is nothing asked for", () => {
src/range/boot.test.ts +13 −8
@@ -13,7 +13,7 @@
1313
1414 import { expect, test } from "bun:test";
1515 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";
1717
1818 // The entry boots itself when it is imported, so it is imported into a page
1919 // 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", ()
234234 }
235235 });
236236
237 /** A `fillRect`, as the recorder wrote it down: the name, then x, y and width. */
238 type Fill = [string, number, number, number];
239
237240 /** 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[] {
239242 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[];
241244 }
242245
243246 /** 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", () => {
283286 p.step(20);
284287 const plate = platesDrawn(p, before)[0];
285288 expect(plate).toBeDefined();
286 const [, left, top, size] = plate as [string, number, number, number];
289 const [, left, top, size] = plate!;
287290
288291 // The canvas is 800 × 450, so the crosshair is at 400, 225 and a degree is
289292 // five pixels. The angle from it to the plate's centre is the turn that
@@ -354,8 +357,10 @@ const standing = (p: RangePage): number =>
354357 Number(/(\d+) up/.exec([...hudTexts(p)].reverse().find((line) => line.includes(" up")) ?? "")?.[1]);
355358
356359 /** 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.
357362 const glCalls = (p: RangePage): [string, ...unknown[]][] =>
358 (p.scene.gl?.calls ?? []) as [string, ...unknown[]][];
363 (p.scene.gl as GlRecorder | null)?.calls ?? [];
359364
360365 /** What one frame asked the GL context for: the calls made after `from`. */
361366 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", () => {
640645 const where = (): number => {
641646 const before = p.scene.paint?.calls.length ?? 0;
642647 p.step(20);
643 return (platesDrawn(p, before)[0] as [string, number, ...unknown[]])[1];
648 return platesDrawn(p, before)[0]![1];
644649 };
645650 expect(where()).not.toBe(where());
646651 } finally {
@@ -724,14 +729,14 @@ test("walking changes what you can hit: the same aim from two places", () => {
724729 p.window.fire("keyup", { code: "Enter" });
725730 const before = p.scene.paint?.calls.length ?? 0;
726731 p.step(20);
727 const first = platesDrawn(p, before)[0] as [string, number, number, number];
732 const first = platesDrawn(p, before)[0]!;
728733
729734 p.window.fire("keydown", { code: "KeyA" });
730735 for (let i = 0; i < 4; i++) p.step(100);
731736 p.window.fire("keyup", { code: "KeyA" });
732737 const moved = p.scene.paint?.calls.length ?? 0;
733738 p.step(20);
734 const second = platesDrawn(p, moved)[0] as [string, number, number, number];
739 const second = platesDrawn(p, moved)[0]!;
735740
736741 // The same plate, the same view angles, a different place on the screen:
737742 // 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";
1818 import { readFileSync } from "node:fs";
1919 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
2020 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";
2222
2323 afterAll(discardBundleCopies);
2424
@@ -101,7 +101,9 @@ test("the bundled entry draws the room and the plates when the browser has WebGL
101101 const { p, undo } = await booted({ webgl: true });
102102 try {
103103 // 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 ?? [];
105107 expect(calls.find((call) => call[0] === "texImage3D")?.slice(4, 7)).toEqual([64, 64, 3]);
106108 const before = calls.length;
107109 p.step(20);
src/range/main.ts +8 −4
@@ -79,13 +79,16 @@ export function boot(): void {
7979
8080 // A canvas that gives no context is a sentence on the page, never a throw:
8181 // 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) {
8484 note.innerHTML =
8585 "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the readout cannot be" +
8686 " drawn. The rest of the page still works.</p>";
8787 return;
8888 }
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;
8992
9093 // The room, the plates and the tracer, drawn by the engine's renderer out of
9194 // this game's own atlas and its own two meshes. Null is a browser with no
@@ -331,10 +334,11 @@ export function boot(): void {
331334
332335 if (renderer !== null) {
333336 renderer.draw(buildRangeScene(run, view, trail, time, frameScene), elapsed);
334 } else {
337 } else if (sceneCtx !== null) {
335338 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 });
337340 }
341 // No third branch: the guard above returned when both were null.
338342 const [hw, hh] = size(hud);
339343 drawReadout(
340344 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
6363 test("every layer paints something visible, and no two of them are the same", () => {
6464 const swatches = new Set<string>();
6565 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][]) {
6767 const rgb = shade(u, v);
6868 expect(rgb.length).toBe(3);
6969 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", () => {
141141 const plate = (at: [number, number, number], half = 16, left = 1): Target => ({
142142 id: 1,
143143 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,
144147 life: left,
145148 span: 1,
146149 points: 100,
src/range/scene.test.ts +3
@@ -27,6 +27,9 @@ function plate(id: number, at: [number, number, number], half = PLATE_HALF, left
2727 max: [at[0] + half, at[1] + half, at[2] + half],
2828 mat: PLATE_MAT,
2929 },
30 // A plate that stands still: a pop-up's velocity is three zeroes.
31 vel: [0, 0, 0],
32 rate: 0,
3033 life: 2 * left,
3134 span: 2,
3235 points: 100 + id,
static/app.46e58255bb.js deleted −58

Diff not shown — a line 73,131 characters wide (limit 2,000). −58

View file
static/app.6bbbaf11e0.js deleted −58

Diff not shown — a line 72,599 characters wide (limit 2,000). −58

View file
static/app.8342c8f923.js added +58

Diff not shown — a line 73,223 characters wide (limit 2,000). +58

View file
static/range.15d5b4d3e8.js deleted −58

Diff not shown — a line 18,798 characters wide (limit 2,000). −58

View file
static/range.3eb16f5ea0.js added +58

Diff not shown — a line 18,819 characters wide (limit 2,000). +58

View file
tsconfig.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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs