/** * The opening move, held to the two things it must never do. * * Neither of these is a look-at-the-picture question — those were answered with * `scripts/look.mjs` and cannot be asserted — but both are the kind of thing * that would break silently and be noticed months later in a screenshot. * * 1. **The hero seat only ever brings a camera down.** A board's whole-board * shot is authored between 32 and 41 degrees above the ground and is much * better seen from lower; a *room's* viewpoint is often eye height, and * lifting a camera that is standing inside a building to twenty-nine * degrees is a ceiling shot of somebody's desk. `Math.min` is the whole * guard and it is one character from being wrong. * 2. **Neither pose is allowed past the orbit's ceiling.** `setPose` hands the * camera to `OrbitControls`, which clamps to `maxDistance` on its next * update, so a pose beyond it is not a wider shot — it is a move that * starts wherever the clamp happened to land. * * Plus the invariant that makes the move a move: the start stands further off * and higher than the rest, and looks at the same point. */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { Vector3 } from "three"; import { arrivalStart, heroPose } from "../engine/scene.ts"; /** Elevation above the horizontal, in degrees, of a pose about its target. */ function elevationDeg(pose: { position: Vector3; target: Vector3 }): number { const d = pose.position.clone().sub(pose.target); return (Math.asin(d.y / d.length()) * 180) / Math.PI; } function reach(pose: { position: Vector3; target: Vector3 }): number { return pose.position.distanceTo(pose.target); } /** A pose at `elevation` degrees and `distance` units from the origin. */ function pose(elevationDegrees: number, distance: number) { const e = (elevationDegrees * Math.PI) / 180; return { target: new Vector3(10, 4, -6), position: new Vector3( 10 + Math.cos(e) * distance * 0.6, 4 + Math.sin(e) * distance, -6 + Math.cos(e) * distance * 0.8, ), }; } describe("the opening move", () => { it("brings a high board pose down and leaves its target alone", () => { // California's chapter 01: 408 out, 352 up, so 40.8 degrees. const authored = pose(40.8, 538); const hero = heroPose(authored, 1_000); assert.ok(elevationDeg(hero) < elevationDeg(authored) - 8, elevationDeg(hero).toFixed(1)); assert.ok(elevationDeg(hero) > 20, elevationDeg(hero).toFixed(1)); assert.deepEqual(hero.target.toArray(), authored.target.toArray()); }); it("gives the three boards one answer, not three", () => { // 40.8 California, 34.7 San Francisco, 32.1 Southern California. const elevations = [40.8, 34.7, 32.1].map((deg) => elevationDeg(heroPose(pose(deg, 400), 1_000)), ); for (const e of elevations) assert.ok(Math.abs(e - elevations[0]!) < 0.001, String(e)); }); it("never raises a camera that is already low — an office keeps its seat", () => { const eyeLevel = pose(7, 12); const hero = heroPose(eyeLevel, 40); assert.ok( elevationDeg(hero) <= elevationDeg(eyeLevel) + 0.001, `${elevationDeg(hero)} vs ${elevationDeg(eyeLevel)}`, ); }); it("stands further off and higher than the pose it will land on", () => { const rest = heroPose(pose(40.8, 538), 5_000); const start = arrivalStart(rest, 5_000); assert.ok(reach(start) > reach(rest)); assert.ok(elevationDeg(start) > elevationDeg(rest)); assert.deepEqual(start.target.toArray(), rest.target.toArray()); }); it("keeps both poses inside the orbit's own ceiling", () => { const ceiling = 300; const authored = pose(40.8, 538); const rest = heroPose(authored, ceiling); assert.ok(reach(rest) <= ceiling, String(reach(rest))); assert.ok(reach(arrivalStart(rest, ceiling)) <= ceiling, String(reach(arrivalStart(rest, ceiling)))); }); it("is a no-op on a degenerate pose rather than a NaN", () => { const degenerate = { target: new Vector3(1, 2, 3), position: new Vector3(1, 2, 3) }; const hero = heroPose(degenerate, 100); assert.deepEqual(hero.position.toArray(), [1, 2, 3]); assert.deepEqual(hero.target.toArray(), [1, 2, 3]); }); });