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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/57-the-shooter-s-recipes-are-pinned-src-cli → main 1 commit fork
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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts"; |
| 8 | 8 | import { boxes, type Box } from "./map.ts"; |
| 9 | import { HULL } from "./physics.ts"; | |
| 9 | 10 | |
| 10 | 11 | /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */ |
| 11 | 12 | export function levelMesh(boxes: Box[]): Mesh { |
| @@ -20,16 +21,23 @@ const cube = (b: Builder, cx: number, cy: number, cz: number, sx: number, sy: nu | ||
| 20 | 21 | /** |
| 21 | 22 | * A player: legs, torso, arms, head, and the gun they are holding, built |
| 22 | 23 | * 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. | |
| 23 | 30 | */ |
| 24 | 31 | export function playerMesh(): Mesh { |
| 25 | 32 | const b = builder(); |
| 26 | 33 | const SKIN = 5, KIT = 6, GEAR = 7; |
| 34 | const HEAD = 13; | |
| 27 | 35 | cube(b, 0, -7, 18, 11, 11, 36, KIT); // right leg |
| 28 | 36 | cube(b, 0, 7, 18, 11, 11, 36, KIT); // left leg |
| 29 | 37 | cube(b, 0, 0, 48, 16, 26, 26, GEAR); // torso |
| 30 | 38 | cube(b, 2, -14, 46, 9, 9, 22, KIT); // right arm |
| 31 | 39 | 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 | |
| 33 | 41 | cube(b, 14, -6, 46, 26, 5, 7, GEAR); // the gun in their hands |
| 34 | 42 | return finish(b); |
| 35 | 43 | } |
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
new above
offline