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

Three modules the README calls tested have no test file: `action`, `models` and `textures` #143

Merged opened this

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

Merged

scout/cs:scout/139-three-modules-the-readme-calls-tested-ha → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/139-three-modules-the-readme-calls-tested-ha was merged into main as e1eab3f.

Review

Sign in to review this change.
6 files changed +328 −25
README.md +13 −8
@@ -409,18 +409,23 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
409409 `settings` (mute, volume, sensitivity and difficulty: read, validated, and
410410 never NaN or an unknown name),
411411 `renderer`, `hud`, `input`, `audio`, `main`.
412 Everything except the last five is headless and tested; `renderer` and `hud`
413 are checked against recording contexts, which is how a frame's draws are
414 counted without a GPU — and how a lost context is tested too, since the
415 recorder's canvas takes listeners and a test can fire `webglcontextlost` at
416 it. Every GL resource the renderer owns comes from one `build(gl)`, so the
417 restore is that same function a second time.
412 Everything except the last five and `weights` is headless and tested;
413 `renderer` and `hud` are checked against recording contexts, which is how a
414 frame's draws are counted without a GPU — and how a lost context is tested
415 too, since the recorder's canvas takes listeners and a test can fire
416 `webglcontextlost` at it. Every GL resource the renderer owns comes from one
417 `build(gl)`, so the restore is that same function a second time. `audio`
418 needs a real `AudioContext` and has no test of its own yet — that is #80.
419 `weights` is `scripts/train.ts`'s output, not code anyone writes by hand, so
420 there is nothing in it to assert beyond the numbers it happened to train to.
418421 - `src/rl/` — training, which the game never imports: `arena` is the environment
419422 (a real duel on de_forge with the round and the economy taken out), `ppo` is
420423 the algorithm, with its backward pass checked against finite differences, and
421424 `shard`, `worker`, `pool` and `cores` are the same update spread over the
422 cores this process is allowed. `scripts/train.ts` runs them and writes
423 `src/client/weights.ts`.
425 cores this process is allowed — `shard` has no test file of its own, but
426 `collect` and `gradients` run for real, through `worker.ts`, wherever
427 `src/rl/pool.test.ts` runs a pool. `scripts/train.ts`
428 runs them and writes `src/client/weights.ts`.
424429 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
425430 schema and its queries, `src/result.ts` validates what a browser may claim,
426431 `src/who.ts` verifies the forge's signed identity headers, `src/player.ts`
src/client/action.test.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * `action.ts` decodes the bins the shipped policy was trained against — a
3 * bin renumbered or reordered here does not fail to compile, it just makes
4 * the weights mean something else. So the bin tables are pinned by value,
5 * and `decode`/`toInput` are pinned end to end: an index in, the exact
6 * `Intent`/`Input` it is supposed to mean out.
7 */
8
9 import { expect, test } from "bun:test";
10 import { AXIS_BINS, decode, HEADS, PITCH_BINS, STILL, toInput, YAW_BINS } from "./action.ts";
11
12 test("the bin tables are odd-length, sorted, and centred on zero", () => {
13 for (const bins of [YAW_BINS, PITCH_BINS, AXIS_BINS]) {
14 expect(bins.length % 2).toBe(1); // an odd count so there is a middle bin
15 const middle = (bins.length - 1) / 2;
16 expect(bins[middle]).toBe(0);
17 const sorted = [...bins].sort((a, b) => a - b);
18 expect(bins).toEqual(sorted); // strictly increasing, so index order is bin order
19 }
20 expect(YAW_BINS[0]).toBe(-720);
21 expect(YAW_BINS[YAW_BINS.length - 1]).toBe(720);
22 expect(PITCH_BINS[0]).toBe(-240);
23 expect(PITCH_BINS[PITCH_BINS.length - 1]).toBe(240);
24 expect(AXIS_BINS).toEqual([-1, 0, 1]);
25 });
26
27 test("HEADS lists each head's bin count, in the order decode reads them", () => {
28 expect(HEADS).toEqual([YAW_BINS.length, PITCH_BINS.length, AXIS_BINS.length, AXIS_BINS.length, 2, 3]);
29 });
30
31 test("decoding the middle of every bin head is the still intent", () => {
32 const yawMid = (YAW_BINS.length - 1) / 2;
33 const pitchMid = (PITCH_BINS.length - 1) / 2;
34 const axisMid = (AXIS_BINS.length - 1) / 2;
35 const intent = decode(Int32Array.from([yawMid, pitchMid, axisMid, axisMid, 0, 0]));
36 expect(intent).toEqual(STILL);
37 });
38
39 test("the extreme yaw bins turn at 720 deg/s, one way and the other", () => {
40 const actions = Int32Array.from([0, 3, 1, 1, 0, 0]);
41 expect(decode(actions).yawRate).toBe(-720);
42 actions[0] = YAW_BINS.length - 1;
43 expect(decode(actions).yawRate).toBe(720);
44 });
45
46 test("decode reads fire and posture as the flags they are, at a given offset", () => {
47 const walking = decode(Int32Array.from([4, 3, 1, 1, 1, 1]));
48 expect(walking.fire).toBe(true);
49 expect(walking.walk).toBe(true);
50 expect(walking.duck).toBe(false);
51
52 const ducking = decode(Int32Array.from([4, 3, 1, 1, 0, 2]));
53 expect(ducking.fire).toBe(false);
54 expect(ducking.walk).toBe(false);
55 expect(ducking.duck).toBe(true);
56
57 // and at a non-zero offset, into a batch of several samples
58 const batch = Int32Array.from([0, 0, 0, 0, 0, 0, 4, 3, 2, 0, 1, 1]);
59 const second = decode(batch, 6);
60 expect(second.forward).toBe(1);
61 expect(second.side).toBe(-1);
62 expect(second.fire).toBe(true);
63 expect(second.walk).toBe(true);
64 });
65
66 test("an out-of-range index is clamped, never undefined in an Input", () => {
67 const tooHigh = decode(Int32Array.from([YAW_BINS.length + 5, PITCH_BINS.length + 5, 9, 9, 9, 9]));
68 for (const value of Object.values(tooHigh)) expect(value).not.toBeUndefined();
69 expect(Number.isFinite(tooHigh.yawRate)).toBe(true);
70 expect(Number.isFinite(tooHigh.pitchRate)).toBe(true);
71
72 const negative = decode(Int32Array.from([-1, -1, -1, -1, -1, -1]));
73 for (const value of Object.values(negative)) expect(value).not.toBeUndefined();
74 });
75
76 test("toInput carries the intent's own fields and takes turning from nobody", () => {
77 const intent = decode(Int32Array.from([0, 0, 2, 0, 1, 1]));
78 const input = toInput(intent, true, "ak47");
79 expect(input.forward).toBe(intent.forward);
80 expect(input.side).toBe(intent.side);
81 expect(input.fire).toBe(intent.fire);
82 expect(input.walk).toBe(intent.walk);
83 expect(input.duck).toBe(intent.duck);
84 expect(input.reload).toBe(true);
85 expect(input.switchTo).toBe("ak47");
86 expect(input.jump).toBe(false); // a bot never jumps through this path
87 expect(input.use).toBe(false);
88 expect(input.scope).toBe(false);
89 expect("yawRate" in input).toBe(false); // turning is the caller's mouse, not Input
90 });
91
92 test("toInput defaults to no weapon switch", () => {
93 expect(toInput(STILL, false).switchTo).toBeNull();
94 });
src/client/action.ts +8 −4
@@ -36,14 +36,18 @@ export const STILL: Intent = {
3636 yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false,
3737 };
3838
39 /** An index into a bin table, clamped so a stray sample never reads past it. */
40 const clampBin = (i: number, bins: readonly number[]): number =>
41 bins[Math.max(0, Math.min(bins.length - 1, i))] as number;
42
3943 /** Turn one sample from the policy into an intent. */
4044 export function decode(actions: Int32Array, at = 0): Intent {
4145 const posture = actions[at + 5] as number;
4246 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,
47 yawRate: clampBin(actions[at] as number, YAW_BINS),
48 pitchRate: clampBin(actions[at + 1] as number, PITCH_BINS),
49 forward: clampBin(actions[at + 2] as number, AXIS_BINS),
50 side: clampBin(actions[at + 3] as number, AXIS_BINS),
4751 fire: actions[at + 4] === 1,
4852 walk: posture === 1,
4953 duck: posture === 2,
src/client/models.test.ts +122 −3
@@ -15,10 +15,10 @@ import { expect, test } from "bun:test";
1515 import { readFileSync } from "node:fs";
1616 import { join } from "node:path";
1717 import {
18 bombMesh, builder, finish, grenadeMesh, HIP_Z, levelMesh, PART, pickupMesh, playerMesh,
19 quadMesh, SHOULDER_Z, viewModelMesh, type Mesh,
18 addBox, bombMesh, builder, finish, grenadeMesh, HIP_Z, levelMesh, muzzle, PART, pickupMesh,
19 playerMesh, quadMesh, SHOULDER_Z, viewModelMesh, type Mesh,
2020 } from "./models.ts";
21 import { DE_FORGE } from "./map.ts";
21 import { DE_FORGE, type Box } from "./map.ts";
2222 import { hitboxes } from "./combat.ts";
2323 import type { Vec3 } from "./math.ts";
2424
@@ -148,3 +148,122 @@ test("PART holds the exact numbers the vertex shader's thresholds assume", () =>
148148 expect(shader).toContain("${SHOULDER_Z.toFixed(1)}");
149149 expect(HIP_Z).toBeLessThan(SHOULDER_Z);
150150 });
151
152 /** Every mesh-building function in the module, as a name and a call. */
153 const MESHES: [string, () => Mesh][] = [
154 ["playerMesh", playerMesh],
155 ["bombMesh", bombMesh],
156 ["pickupMesh", pickupMesh],
157 ["grenadeMesh", grenadeMesh],
158 ["quadMesh", quadMesh],
159 ["viewModelMesh(rifle)", () => viewModelMesh("rifle")],
160 ["viewModelMesh(knife)", () => viewModelMesh("knife")],
161 ["viewModelMesh(grenade)", () => viewModelMesh("grenade")],
162 ["levelMesh(DE_FORGE)", () => levelMesh(DE_FORGE.boxes)],
163 ];
164
165 test("every mesh's attribute arrays agree in vertex count, and every index points inside position", () => {
166 for (const [name, build] of MESHES) {
167 const mesh = build();
168 const vertices = mesh.position.length / 3;
169 expect(mesh.normal.length, name).toBe(vertices * 3);
170 expect(mesh.uv.length, name).toBe(vertices * 2);
171 expect(mesh.layer.length, name).toBe(vertices);
172 expect(mesh.part.length, name).toBe(vertices);
173 expect(mesh.index.length % 3, name).toBe(0); // whole triangles only
174 for (const i of mesh.index) {
175 expect(i, name).toBeGreaterThanOrEqual(0);
176 expect(i, name).toBeLessThan(vertices);
177 }
178 }
179 });
180
181 /** The signed area (times two) of a triangle's projection, seen from `normal`. */
182 function windingSign(mesh: Mesh, a: number, b: number, c: number): number {
183 const at = (i: number): Vec3 =>
184 [mesh.position[i * 3] as number, mesh.position[i * 3 + 1] as number, mesh.position[i * 3 + 2] as number];
185 const n: Vec3 = [mesh.normal[a * 3] as number, mesh.normal[a * 3 + 1] as number, mesh.normal[a * 3 + 2] as number];
186 const [pa, pb, pc] = [at(a), at(b), at(c)];
187 const e1: Vec3 = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
188 const e2: Vec3 = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
189 const cross: Vec3 = [
190 e1[1] * e2[2] - e1[2] * e2[1],
191 e1[2] * e2[0] - e1[0] * e2[2],
192 e1[0] * e2[1] - e1[1] * e2[0],
193 ];
194 return cross[0] * n[0] + cross[1] * n[1] + cross[2] * n[2];
195 }
196
197 test("every triangle winds the same way: outward, agreeing with its own face normal", () => {
198 for (const [name, build] of MESHES) {
199 const mesh = build();
200 for (let i = 0; i < mesh.index.length; i += 3) {
201 const a = mesh.index[i] as number, b2 = mesh.index[i + 1] as number, c = mesh.index[i + 2] as number;
202 const sign = windingSign(mesh, a, b2, c);
203 // `quadMesh` is a box flattened to zero depth: its side faces are
204 // genuine zero-area triangles, not a winding to judge.
205 if (sign === 0) continue;
206 expect(sign, `${name} triangle at index ${i}`).toBeGreaterThan(0);
207 }
208 }
209 });
210
211 test("addBox puts a box of exactly the size and position it was asked for", () => {
212 const b = builder();
213 const min: Vec3 = [1, -5, 2];
214 const max: Vec3 = [11, 15, 22]; // a 10x20x20 box, off-centre so no axis is accidentally symmetric
215 addBox(b, min, max, 3);
216 const mesh = finish(b);
217 const xs: number[] = [], ys: number[] = [], zs: number[] = [];
218 for (let i = 0; i < mesh.position.length; i += 3) {
219 xs.push(mesh.position[i] as number);
220 ys.push(mesh.position[i + 1] as number);
221 zs.push(mesh.position[i + 2] as number);
222 }
223 expect(Math.min(...xs)).toBe(min[0]);
224 expect(Math.max(...xs)).toBe(max[0]);
225 expect(Math.min(...ys)).toBe(min[1]);
226 expect(Math.max(...ys)).toBe(max[1]);
227 expect(Math.min(...zs)).toBe(min[2]);
228 expect(Math.max(...zs)).toBe(max[2]);
229 expect(mesh.position.length).toBe(6 * 4 * 3); // six quads of four verts
230 expect(Array.from(new Set(mesh.layer))).toEqual([3]);
231 });
232
233 test("levelMesh([]) is an empty mesh, not a malformed one", () => {
234 const mesh = levelMesh([]);
235 expect(mesh.position.length).toBe(0);
236 expect(mesh.normal.length).toBe(0);
237 expect(mesh.uv.length).toBe(0);
238 expect(mesh.layer.length).toBe(0);
239 expect(mesh.part.length).toBe(0);
240 expect(mesh.index.length).toBe(0);
241 });
242
243 test("levelMesh concatenates one box per input box", () => {
244 const boxes: Box[] = [
245 { min: [0, 0, 0], max: [10, 10, 10], mat: 0 },
246 { min: [20, 0, 0], max: [30, 10, 10], mat: 1 },
247 ];
248 const mesh = levelMesh(boxes);
249 expect(mesh.position.length).toBe(2 * 6 * 4 * 3);
250 expect(mesh.index.length).toBe(2 * 6 * 2 * 3);
251 expect(Array.from(new Set(mesh.layer)).sort()).toEqual([0, 1]);
252 });
253
254 test("muzzle answers null for anything that is not a weapon kind with a flash", () => {
255 expect(muzzle("nonsense")).toBeNull();
256 expect(muzzle("")).toBeNull();
257 expect(muzzle("knife")).toBeNull(); // no muzzle flash to draw
258 expect(muzzle("grenade")).toBeNull();
259 });
260
261 test("muzzle answers a point and a size for every gun, bigger for a rifle than a pistol", () => {
262 const pistol = muzzle("pistol");
263 const rifle = muzzle("rifle");
264 expect(pistol).not.toBeNull();
265 expect(rifle).not.toBeNull();
266 expect(rifle?.size).toBeGreaterThan(pistol?.size as number);
267 // the same frozen object every call: nothing is allocated per frame
268 expect(muzzle("rifle")).toBe(rifle);
269 });
src/client/models.ts +21 −10
@@ -54,21 +54,31 @@ export function finish(b: Builder): Mesh {
5454 };
5555 }
5656
57 const FACES: [Vec3, number, number, number][] = [
58 // normal, axis of the face, then the two axes across it
59 [[1, 0, 0], 0, 1, 2],
60 [[-1, 0, 0], 0, 1, 2],
61 [[0, 1, 0], 1, 0, 2],
62 [[0, -1, 0], 1, 0, 2],
63 [[0, 0, 1], 2, 0, 1],
64 [[0, 0, -1], 2, 0, 1],
57 /**
58 * Normal, axis of the face, the two axes across it for `uv`, and whether
59 * winding needs an extra flip on top of `normal[axis]`'s sign: X×Y is Z and
60 * Y×Z is X, but X×Z is -Y, so with `u, v` listed in the same order on every
61 * row, the Y face's cross product points the opposite way from the other
62 * two. `flip` corrects the winding for that face alone, leaving `uv` — and
63 * so every material's tiling — exactly as every other face computes it. Get
64 * `flip` wrong and the two triangles of a face still agree with each other,
65 * just facing in, which `CULL_FACE` turns into a wall with nothing on the
66 * side it was meant to be seen from.
67 */
68 const FACES: [Vec3, number, number, number, boolean][] = [
outdated

I anchor this here because this one line is the whole pull: it is the only change that alters what a player sees, and the tests around it are what caught it.

Approved. Three test files, two real bug fixes, no new dependency, nothing touching the server, and bun run check green at 5380d5d: 587 pass, 0 fail.

The winding fix is real, and it buys more than the issue asked for. I reproduced the cull test with this repository's own viewMatrix and perspective (both preserve handedness; frontFace is the default CCW; src/client/renderer.ts:295 enables CULL_FACE with cullFace(BACK)). With a camera on the -Y side of a box: on main the near -Y quad is culled and the far +Y quad is drawn from its inside; with this diff the near quad is drawn and the far one culled, and the four other faces are untouched. On de_forge that is 25 of 50 boxes — a perimeter wall 256 units thick has been painting its surface 256 units behind the plane bullets and bodies actually stop at, lit by a normal pointing away from the room. That is shooting feedback, not cosmetics, and this is the right fix.

Verified rather than taken on trust:

  • Reverting action.ts and models.ts to 979c388 and re-running just the two new test files gives exactly 2 failures — the clamp and the winding. Both behaviours are pinned by a test that fails without the change.
  • The server from this head boots and / answers 200 in ~1.6 ms with the floor intact: CSP, nosniff, Referrer-Policy, HttpOnly SameSite=Lax session. The preview host answers not found to plain curl for me right now, but the deploy run is ok and the forge's pictures at this head show / rendering; nothing in this diff can reach the server anyway.
  • The corrected README claim holds: audio, weights and reportPanel are the only untested client modules, and reportPanel is not in the list that sentence covers.

Two notes, neither blocking:

  1. Swapping the Y face's cross axes (1, 0, 2 to 1, 2, 0) fixes the cull but also transposes uv on every ±Y face: material 1's running bond now staggers along Z instead of X, and crate planks run vertically on those faces while the X faces keep them horizontal. The cost is small because the brick grid is 32×32 either way, but reversing the corner order for that face (or a per-face flip flag) would have fixed the winding without touching the texture at all. If the crates read oddly in play, that is the knob.
  2. textures.test.ts's perCall < 500 is a wall-clock assertion; I measured 90 ms per call on this machine, so the margin is about 5.5x. If it ever flakes on a loaded runner, delete it rather than raise the bound — the length, determinism, alpha and not-flat assertions are what actually pin that module.

Unrelated to this pull, but visible in the phone picture at this head: the leaderboard's wins column still runs off 360px. That is #141.

69 [[1, 0, 0], 0, 1, 2, false],
70 [[-1, 0, 0], 0, 1, 2, false],
71 [[0, 1, 0], 1, 0, 2, true],
72 [[0, -1, 0], 1, 0, 2, true],
73 [[0, 0, 1], 2, 0, 1, false],
74 [[0, 0, -1], 2, 0, 1, false],
6575 ];
6676
6777 /** Six quads with the texture tiling every `scale` units in world space. */
6878 export function addBox(
6979 b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128, part: number = PART.STILL,
7080 ): void {
71 for (const [normal, axis, u, v] of FACES) {
81 for (const [normal, axis, u, v, flip] of FACES) {
7282 const base = b.position.length / 3;
7383 const at = (normal[axis] as number) > 0 ? max : min;
7484 for (const [du, dv] of [[0, 0], [1, 0], [1, 1], [0, 1]]) {
@@ -83,7 +93,8 @@ export function addBox(
8393 b.part.push(part);
8494 }
8595 // Wind the two triangles so the outside faces out.
86 if ((normal[axis] as number) > 0) b.index.push(base, base + 1, base + 2, base, base + 2, base + 3);
96 const front = ((normal[axis] as number) > 0) !== flip;
97 if (front) b.index.push(base, base + 1, base + 2, base, base + 2, base + 3);
8798 else b.index.push(base, base + 2, base + 1, base, base + 3, base + 2);
8899 }
89100 }
src/client/textures.test.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * `materialTextures()` is the only asset pipeline this game has: whatever it
3 * writes is what `texSubImage3D` uploads, byte for byte. So the buffer size
4 * and its alpha channel are pinned as the contract the renderer assumes, and
5 * the noise is checked for the one thing a shader can't recover from — a
6 * layer flattened to a single value, which reads as a texture that failed
7 * to generate rather than a material.
8 */
9
10 import { expect, test } from "bun:test";
11 import { LAYERS, materialTextures, TEX_SIZE } from "./textures.ts";
12
13 test("the buffer is exactly TEX_SIZE * TEX_SIZE * LAYERS * 4 bytes", () => {
14 const out = materialTextures();
15 expect(out).toBeInstanceOf(Uint8Array);
16 expect(out.length).toBe(TEX_SIZE * TEX_SIZE * LAYERS * 4);
17 });
18
19 test("two calls are byte-identical: the noise is a pure function of position", () => {
20 const a = materialTextures();
21 const b = materialTextures();
22 expect(a).toEqual(b);
23 });
24
25 test("every layer's alpha channel is fully opaque, as the renderer assumes", () => {
26 const out = materialTextures();
27 const size = TEX_SIZE;
28 for (let layer = 0; layer < LAYERS; layer++) {
29 for (let i = 3; i < size * size * 4; i += 4) {
30 expect(out[layer * size * size * 4 + i]).toBe(255);
31 }
32 }
33 });
34
35 test("no layer is flat: every material varies and uses more than one channel value", () => {
36 const out = materialTextures();
37 const size = TEX_SIZE;
38 for (let layer = 0; layer < LAYERS; layer++) {
39 const base = layer * size * size * 4;
40 const rs = new Set<number>(), gs = new Set<number>(), bs = new Set<number>();
41 let allBlack = true;
42 for (let i = 0; i < size * size; i++) {
43 const r = out[base + i * 4] as number, g = out[base + i * 4 + 1] as number, b = out[base + i * 4 + 2] as number;
44 rs.add(r);
45 gs.add(g);
46 bs.add(b);
47 if (r !== 0 || g !== 0 || b !== 0) allBlack = false;
48 }
49 expect(allBlack).toBe(false); // no layer is entirely black
50 // no layer is entirely one value: at least one channel actually varies
51 expect(rs.size > 1 || gs.size > 1 || bs.size > 1).toBe(true);
52 }
53 });
54
55 test("builds cheaply: nowhere near the cost a per-pixel image decode would be", () => {
56 materialTextures(); // warm the JIT before timing steady-state cost
57 const runs = 5;
58 const start = performance.now();
59 for (let i = 0; i < runs; i++) materialTextures();
60 const perCall = (performance.now() - start) / runs;
61 // The module claims ~1ms on the hardware it ships to; this sandbox's CPU
62 // runs everything roughly two orders of magnitude slower (a tight hash-only
63 // loop of the same call count already costs ~50ms here), so the bound below
64 // is generous rather than the claim itself — loose enough to hold on slow
65 // CI, tight enough to catch an accidental quadratic blowup in the noise.
66 // If it ever flakes on a slower runner, delete this assertion rather than
67 // raise the bound further: the size, determinism, alpha and not-flat
68 // tests above are what actually pin this module's contract.
69 expect(perCall).toBeLessThan(500);
70 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs