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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/139-three-modules-the-readme-calls-tested-ha → main 2 commits fork 1 thread · 1 unresolved
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. | ||
| 409 | 409 | `settings` (mute, volume, sensitivity and difficulty: read, validated, and |
| 410 | 410 | never NaN or an unknown name), |
| 411 | 411 | `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. | |
| 418 | 421 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 419 | 422 | (a real duel on de_forge with the round and the economy taken out), `ppo` is |
| 420 | 423 | the algorithm, with its backward pass checked against finite differences, and |
| 421 | 424 | `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`. | |
| 424 | 429 | - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the |
| 425 | 430 | schema and its queries, `src/result.ts` validates what a browser may claim, |
| 426 | 431 | `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 = { | ||
| 36 | 36 | yawRate: 0, pitchRate: 0, forward: 0, side: 0, fire: false, walk: false, duck: false, |
| 37 | 37 | }; |
| 38 | 38 | |
| 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 | ||
| 39 | 43 | /** Turn one sample from the policy into an intent. */ |
| 40 | 44 | export function decode(actions: Int32Array, at = 0): Intent { |
| 41 | 45 | const posture = actions[at + 5] as number; |
| 42 | 46 | 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), | |
| 47 | 51 | fire: actions[at + 4] === 1, |
| 48 | 52 | walk: posture === 1, |
| 49 | 53 | duck: posture === 2, |
src/client/models.test.ts +122 −3
| @@ -15,10 +15,10 @@ import { expect, test } from "bun:test"; | ||
| 15 | 15 | import { readFileSync } from "node:fs"; |
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | 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, | |
| 20 | 20 | } from "./models.ts"; |
| 21 | import { DE_FORGE } from "./map.ts"; | |
| 21 | import { DE_FORGE, type Box } from "./map.ts"; | |
| 22 | 22 | import { hitboxes } from "./combat.ts"; |
| 23 | 23 | import type { Vec3 } from "./math.ts"; |
| 24 | 24 | |
| @@ -148,3 +148,122 @@ test("PART holds the exact numbers the vertex shader's thresholds assume", () => | ||
| 148 | 148 | expect(shader).toContain("${SHOULDER_Z.toFixed(1)}"); |
| 149 | 149 | expect(HIP_Z).toBeLessThan(SHOULDER_Z); |
| 150 | 150 | }); |
| 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 { | ||
| 54 | 54 | }; |
| 55 | 55 | } |
| 56 | 56 | |
| 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][] = [ | |
| 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], | |
| 65 | 75 | ]; |
| 66 | 76 | |
| 67 | 77 | /** Six quads with the texture tiling every `scale` units in world space. */ |
| 68 | 78 | export function addBox( |
| 69 | 79 | b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128, part: number = PART.STILL, |
| 70 | 80 | ): void { |
| 71 | for (const [normal, axis, u, v] of FACES) { | |
| 81 | for (const [normal, axis, u, v, flip] of FACES) { | |
| 72 | 82 | const base = b.position.length / 3; |
| 73 | 83 | const at = (normal[axis] as number) > 0 ? max : min; |
| 74 | 84 | for (const [du, dv] of [[0, 0], [1, 0], [1, 1], [0, 1]]) { |
| @@ -83,7 +93,8 @@ export function addBox( | ||
| 83 | 93 | b.part.push(part); |
| 84 | 94 | } |
| 85 | 95 | // 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); | |
| 87 | 98 | else b.index.push(base, base + 2, base + 1, base, base + 3, base + 2); |
| 88 | 99 | } |
| 89 | 100 | } |
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 | }); |
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 checkgreen 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
viewMatrixandperspective(both preserve handedness;frontFaceis the default CCW;src/client/renderer.ts:295enablesCULL_FACEwithcullFace(BACK)). With a camera on the -Y side of a box: onmainthe 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:
action.tsandmodels.tsto 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./answers 200 in ~1.6 ms with the floor intact: CSP,nosniff,Referrer-Policy, HttpOnlySameSite=Laxsession. The preview host answersnot foundto plain curl for me right now, but the deploy run isokand the forge's pictures at this head show/rendering; nothing in this diff can reach the server anyway.audio,weightsandreportPanelare the only untested client modules, andreportPanelis not in the list that sentence covers.Two notes, neither blocking:
1, 0, 2to1, 2, 0) fixes the cull but also transposesuvon 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.textures.test.ts'sperCall < 500is 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
winscolumn still runs off 360px. That is #141.