Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The shooter’s recipes are pinned: src/client/models.ts gets the test it never had #90

Merged opened this

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

Merged

vesper/cs-engine:vesper/57-the-shooter-s-recipes-are-pinned-src-cli → main 1 commit fork

  • Merged. vesper/57-the-shooter-s-recipes-are-pinned-src-cli was merged into main as 9a65722.

Review

Sign in to review this change.
3 files changed +200 −3
src/client/models.test.ts added +189
@@ -0,0 +1,189 @@
1 /**
2 * The four recipes, pinned before `models.ts` is handed to an engine renderer
3 * that will not check any of this. What a renderer and the atlas actually
4 * require is asserted here — the attribute lengths agree, every index is inside
5 * the vertex count, every layer is a real layer of the array texture, nothing
6 * is NaN — and what a different way of building a box would change is not:
7 * there is no vertex order in this file and no float pinned to fifteen digits.
8 *
9 * The one geometry a frame depends on is the player's height: `scene.ts`
10 * squashes a ducking player by `hullHeight(p) / HULL.stand` about their feet,
11 * so the mesh has to stand with its feet at z 0 and its head at `HULL.stand`.
12 * It did not: the head reached 73.5, and `models.ts` now takes the top from
13 * `HULL` instead of from a literal.
14 */
15
16 import { expect, test } from "bun:test";
17 import { readFileSync } from "node:fs";
18 import { join } from "node:path";
19 import type { Mesh } from "../engine/index.ts";
20 import { boxes, type Box } from "./map.ts";
21 import { LAYERS } from "./materials.ts";
22 import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts";
23 import { HULL } from "./physics.ts";
24 import { WEAPONS } from "./weapons.ts";
25
26 /**
27 * Everything true of every mesh in the game, asserted once. The counts are the
28 * renderer's contract — three floats of position and normal and two of uv per
29 * vertex, triangles in the index, no index past the end — and the layer range
30 * is the atlas's: a material added to `materials.ts` that a recipe names before
31 * it exists lands here.
32 */
33 function wellFormed(mesh: Mesh): { vertices: number; triangles: number } {
34 const vertices = mesh.position.length / 3;
35 expect(Number.isInteger(vertices)).toBe(true);
36 expect(mesh.normal.length).toBe(vertices * 3);
37 expect(mesh.uv.length).toBe(vertices * 2);
38 expect(mesh.layer.length).toBe(vertices);
39 expect(mesh.index.length % 3).toBe(0);
40 for (const array of [mesh.position, mesh.normal, mesh.uv, mesh.layer]) {
41 for (const value of array) expect(Number.isFinite(value)).toBe(true);
42 }
43 for (const index of mesh.index) {
44 expect(Number.isInteger(index)).toBe(true);
45 expect(index).toBeGreaterThanOrEqual(0);
46 expect(index).toBeLessThan(vertices);
47 }
48 for (const layer of mesh.layer) {
49 expect(Number.isInteger(layer)).toBe(true);
50 expect(layer).toBeGreaterThanOrEqual(0);
51 expect(layer).toBeLessThan(LAYERS);
52 }
53 return { vertices, triangles: mesh.index.length / 3 };
54 }
55
56 /** The axis-aligned box a mesh occupies, which is what a recipe promises. */
57 function bounds(mesh: Mesh): { min: [number, number, number]; max: [number, number, number] } {
58 const min: [number, number, number] = [Infinity, Infinity, Infinity];
59 const max: [number, number, number] = [-Infinity, -Infinity, -Infinity];
60 for (let i = 0; i < mesh.position.length; i += 3) {
61 for (let axis = 0; axis < 3; axis++) {
62 const value = mesh.position[i + axis] as number;
63 min[axis] = Math.min(min[axis] as number, value);
64 max[axis] = Math.max(max[axis] as number, value);
65 }
66 }
67 return { min, max };
68 }
69
70 /** Two meshes hold the same data — for equality and for distinctness alike. */
71 const data = (mesh: Mesh): string =>
72 [mesh.position, mesh.normal, mesh.uv, mesh.layer, mesh.index].map((a) => a.join(",")).join("|");
73
74 const oneBox: Box = { min: [64, -128, 0], max: [192, 0, 384], mat: 1 };
75
76 test("the level is well formed, and a box is twenty-four vertices of it", () => {
77 const mesh = levelMesh([oneBox]);
78 const { vertices, triangles } = wellFormed(mesh);
79 // Six quads, four vertices each: a box's faces do not share vertices, because
80 // they do not share a normal or a texture layer.
81 expect(vertices).toBe(24);
82 expect(triangles).toBe(12);
83 expect(bounds(mesh)).toEqual({ min: oneBox.min, max: oneBox.max });
84 expect(new Set(mesh.layer)).toEqual(new Set([1]));
85 });
86
87 test("the level of no boxes is an empty mesh, not a throw", () => {
88 const mesh = levelMesh([]);
89 wellFormed(mesh);
90 expect(mesh.position.length).toBe(0);
91 expect(mesh.index.length).toBe(0);
92 });
93
94 test("the level boxes grow the mesh and leave it well formed", () => {
95 const two = levelMesh([oneBox, { min: [0, 0, 0], max: [128, 128, 128], mat: 2 }]);
96 expect(wellFormed(two)).toEqual({ vertices: 48, triangles: 24 });
97 const map = levelMesh(boxes);
98 expect(wellFormed(map)).toEqual({ vertices: boxes.length * 24, triangles: boxes.length * 12 });
99 });
100
101 test("the player stands on the floor at exactly the standing hull height", () => {
102 const mesh = playerMesh();
103 wellFormed(mesh);
104 const { min, max } = bounds(mesh);
105 // `scene.ts` scales a ducking player from the floor, so the feet are the
106 // origin and the top is the hull: a duck squash then lands on the duck hull.
107 expect(min[2]).toBe(0);
108 expect(max[2]).toBe(HULL.stand);
109 expect(max[2] * (HULL.duck / HULL.stand)).toBeCloseTo(HULL.duck, 10);
110 // A body, not a tower: it stays inside the hull across, give or take the gun.
111 expect(max[1] - min[1]).toBeLessThanOrEqual(HULL.radius * 2 + 4);
112 });
113
114 test("the bomb sits on the ground and lights up on a layer of its own", () => {
115 const mesh = bombMesh();
116 wellFormed(mesh);
117 const { min, max } = bounds(mesh);
118 expect(min[2]).toBe(0);
119 expect(max[2]).toBeGreaterThan(0);
120 expect(max[2]).toBeLessThan(HULL.duck);
121 // The light on top is the last layer of the atlas, and the case is not it.
122 expect(new Set(mesh.layer).size).toBe(2);
123 expect([...mesh.layer]).toContain(LAYERS - 1);
124 });
125
126 test("every weapon kind has its own view model, in front of the eye", () => {
127 const kinds = [...new Set(Object.values(WEAPONS).map((w) => w.kind))];
128 expect(kinds.length).toBeGreaterThan(1);
129 const seen = new Map<string, string>();
130 for (const kind of kinds) {
131 const mesh = viewModelMesh(kind);
132 const { vertices } = wellFormed(mesh);
133 expect(vertices).toBeGreaterThan(0);
134 // View space: X right, Y up, Z back. The model is down and to the right of
135 // the eye and runs away down -Z, never behind the near plane at +Z.
136 const { min, max } = bounds(mesh);
137 expect(min[2]).toBeLessThan(0);
138 expect(max[2]).toBeLessThan(1);
139 expect(max[1]).toBeLessThan(0);
140 expect(min[0]).toBeGreaterThan(0);
141 expect(seen.get(data(mesh))).toBeUndefined();
142 seen.set(data(mesh), kind);
143 }
144 expect(seen.size).toBe(kinds.length);
145 });
146
147 test("a kind nobody carries falls back to a gun rather than throwing", () => {
148 const fallback = viewModelMesh("no such weapon");
149 expect(wellFormed(fallback).vertices).toBeGreaterThan(0);
150 // One fallback shape, not one per name: the renderer gets a gun either way.
151 expect(data(viewModelMesh("shotgun"))).toBe(data(fallback));
152 expect(data(viewModelMesh(""))).toBe(data(fallback));
153 });
154
155 test("meshFor names the four recipes and nothing else", () => {
156 expect(data(meshFor("level"))).toBe(data(levelMesh(boxes)));
157 expect(data(meshFor("player"))).toBe(data(playerMesh()));
158 expect(data(meshFor("bomb"))).toBe(data(bombMesh()));
159 for (const kind of new Set(Object.values(WEAPONS).map((w) => w.kind))) {
160 expect(data(meshFor(kind))).toBe(data(viewModelMesh(kind)));
161 }
162 });
163
164 test("a recipe called twice gives equal data in a new object every time", () => {
165 const calls: (() => Mesh)[] = [
166 () => levelMesh([oneBox]),
167 () => playerMesh(),
168 () => bombMesh(),
169 () => viewModelMesh("rifle"),
170 ];
171 for (const call of calls) {
172 const first = call();
173 const second = call();
174 expect(data(second)).toBe(data(first));
175 expect(second).not.toBe(first);
176 expect(second.position).not.toBe(first.position);
177 expect(second.index).not.toBe(first.index);
178 // Mutating one mesh leaves the next call alone: no shared buffer, no cache.
179 first.position[0] = 999;
180 expect(data(call())).toBe(data(second));
181 }
182 });
183
184 test("the recipes are arithmetic: no browser, no clock, no randomness", () => {
185 const source = readFileSync(join(import.meta.dir, "models.ts"), "utf8");
186 for (const forbidden of ["document", "window", "canvas", "Date", "performance", "Math.random", "navigator"]) {
187 expect(source).not.toContain(forbidden);
188 }
189 });
src/client/models.ts +9 −1
@@ -6,6 +6,7 @@
66
77 import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts";
88 import { boxes, type Box } from "./map.ts";
9 import { HULL } from "./physics.ts";
910
1011 /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */
1112 export function levelMesh(boxes: Box[]): Mesh {
@@ -20,16 +21,23 @@ const cube = (b: Builder, cx: number, cy: number, cz: number, sx: number, sy: nu
2021 /**
2122 * A player: legs, torso, arms, head, and the gun they are holding, built
2223 * standing at the origin facing +X so a yaw rotation is all a draw needs.
24 *
25 * Feet at z 0 and the crown of the head at `HULL.stand`, both exactly: a
26 * ducking player is drawn by scaling this from the floor by
27 * `hullHeight(p) / HULL.stand`, so a model taller than the hull is a head that
28 * pokes through a vent you could walk under. The head takes its height from
29 * `HULL` rather than a literal, which is why it cannot drift again.
2330 */
2431 export function playerMesh(): Mesh {
2532 const b = builder();
2633 const SKIN = 5, KIT = 6, GEAR = 7;
34 const HEAD = 13;
2735 cube(b, 0, -7, 18, 11, 11, 36, KIT); // right leg
2836 cube(b, 0, 7, 18, 11, 11, 36, KIT); // left leg
2937 cube(b, 0, 0, 48, 16, 26, 26, GEAR); // torso
3038 cube(b, 2, -14, 46, 9, 9, 22, KIT); // right arm
3139 cube(b, 6, 10, 46, 9, 9, 20, KIT); // left arm
32 cube(b, 1, 0, 67, 13, 13, 13, SKIN); // head
40 cube(b, 1, 0, HULL.stand - HEAD / 2, HEAD, HEAD, HEAD, SKIN); // head, crowned at the hull
3341 cube(b, 14, -6, 46, 26, 5, 7, GEAR); // the gun in their hands
3442 return finish(b);
3543 }
static/app.0c26049cd8.js → static/app.bb19cc2fb2.js renamed +2 −2

Diff not shown — 138,068 bytes of diff (limit 100,000). +2 −2

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs