tower / files / test / tour.test.ts
tour.test.ts
99a2821b 2026-09-29 Pavel Safronov | 1 | import { readFileSync } from 'node:fs' |
| 2 | import { describe, expect, it } from 'vitest' | |
| 3 | import { BEAM_RADIUS_M, G0, LENGTH_M, RADIUS_M } from '../src/canon/constants.js' | |
| 4 | import { apparentGravity } from '../src/canon/motion.js' | |
| 5 | import { compileWorld } from '../src/terrain/compile.js' | |
| 6 | import { prepare } from '../src/terrain/field.js' | |
| 7 | import { maxGrade, railLines } from '../src/terrain/rails.js' | |
| 8 | import { shipsAt } from '../src/terrain/ships.js' | |
| 9 | import { transmission } from '../src/tour/director.js' | |
| 10 | import { | |
| 11 | COLLECTOR_R, COLLECTOR_Z, EXTERIOR_LEGS, HULL_R, SUN_Z, clearOfBeam, planExterior, | |
| 12 | } from '../src/tour/exterior-legs.js' | |
| 13 | import { type V3, dirOf, dot, len, norm, radial, sub, toV } from '../src/tour/geo.js' | |
| 14 | import { INTERIOR_LEGS, planInterior } from '../src/tour/interior-legs.js' | |
| 15 | import { LEG_MAX_S, LEG_MIN_S, deal, legSeconds, tourRng } from '../src/tour/plan.js' | |
| 16 | import { parseWorld } from '../src/world/schema.js' | |
| 17 | import { actualAt } from '../src/world/weather.js' | |
| 18 | ||
| 19 | const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))) | |
| 20 | const compiled = compileWorld(world) | |
| 21 | const rt = prepare(compiled) | |
| 22 | const ground = (theta: number, z: number): number => { | |
| 23 | const s = rt.sample(theta, z, 1) | |
| 24 | return Math.max(s.h, s.water) | |
| 25 | } | |
| 26 | const rails = railLines(world, ground) | |
| 27 | const rng = tourRng(Number(process.env["TOUR_SEED"] ?? 7)) | |
| 28 | const legs = planInterior({ world, compiled, ground, rails }, rng, () => legSeconds(rng())) | |
| 29 | const leg = (id: string) => legs.find((l) => l.id === id)! | |
| 30 | const outside = planExterior({ world }, tourRng(Number(process.env["TOUR_SEED"] ?? 3)), () => 15) | |
| 31 | ||
| 32 | const eyeOf = (p: ReturnType<(typeof legs)[number]['pose']>): V3 => | |
| 33 | toV({ z: p.z, a: p.theta * RADIUS_M, altM: p.altM }) | |
| 34 | const viewOf = (p: ReturnType<(typeof legs)[number]['pose']>): V3 => dirOf(eyeOf(p), p.yaw, p.pitch) | |
| 35 | /** Angle off the middle of the frame, radians. */ | |
| 36 | const offCentre = (eye: V3, dir: V3, target: V3): number => | |
| 37 | Math.acos(Math.max(-1, Math.min(1, dot(norm(sub(target, eye)), dir)))) | |
| 38 | ||
| 39 | describe('the railways', () => { | |
| 40 | it('lays both lines, one of them a loop round the world', () => { | |
| 41 | expect(rails.map((r) => r.id).sort()).toEqual(['the-axial-line', 'the-uplands-ring']) | |
| 42 | expect(rails.find((r) => r.id === 'the-uplands-ring')!.loop).toBe(true) | |
| 43 | expect(rails.find((r) => r.id === 'the-axial-line')!.loop).toBe(false) | |
| 44 | }) | |
| 45 | ||
| 46 | it('never runs the deck below the ground, and never steeper than a mountain railway', () => { | |
| 47 | for (const r of rails) { | |
| 48 | for (let i = 0; i < r.deckM.length; i++) expect(r.deckM[i]!).toBeGreaterThanOrEqual(r.groundM[i]!) | |
| 49 | expect(maxGrade(r), r.id).toBeLessThan(0.08) | |
| 50 | } | |
| 51 | }) | |
| 52 | }) | |
| 53 | ||
| 54 | describe('weight on the move', () => { | |
| 55 | it('is the floor\'s 1 g standing still, and nothing at all doing the rim speed antispinward', () => { | |
| 56 | expect(apparentGravity(RADIUS_M, 0) / G0).toBeCloseTo(1, 6) | |
| 57 | expect(apparentGravity(RADIUS_M, -Math.sqrt(G0 * RADIUS_M))).toBeCloseTo(0, 6) | |
| 58 | }) | |
| 59 | ||
| 60 | it('puts a 100 m/s train 31% up one way and 27% down the other', () => { | |
| 61 | expect(apparentGravity(RADIUS_M, 100) / G0).toBeCloseTo(1.306, 2) | |
| 62 | expect(apparentGravity(RADIUS_M, -100) / G0).toBeCloseTo(0.735, 2) | |
| 63 | }) | |
| 64 | }) | |
| 65 | ||
| 66 | describe('the order of the legs', () => { | |
| 67 | it('deals every leg once, and never the same craft twice running', () => { | |
| 68 | const items = legs.map((l) => ({ id: l.id, kind: l.vehicle })) | |
| 69 | for (let seed = 1; seed < 40; seed++) { | |
| 70 | const order = deal(items, (x) => x.kind, tourRng(seed), 'train') | |
| 71 | expect(order.map((x) => x.id).sort()).toEqual(items.map((x) => x.id).sort()) | |
| 72 | expect(order[0]!.kind).not.toBe('train') | |
| 73 | for (let i = 1; i < order.length; i++) expect(order[i]!.kind, `seed ${seed}`).not.toBe(order[i - 1]!.kind) | |
| 74 | } | |
| 75 | }) | |
| 76 | ||
| 77 | it('keeps two legs about the same thing apart, nearly always', () => { | |
| 78 | // Where a craft has to be laid to keep two of a kind apart, the subject can lose out. | |
| 79 | let repeats = 0 | |
| 80 | for (let seed = 1; seed <= 100; seed++) { | |
| 81 | const order = deal(legs, (l) => l.vehicle, tourRng(seed), undefined, (l) => l.subject?.id ?? l.id) | |
| 82 | for (let i = 1; i < order.length; i++) { | |
| 83 | const a = order[i]!.subject?.id, b = order[i - 1]!.subject?.id | |
| 84 | if (a && a === b) repeats++ | |
| 85 | } | |
| 86 | } | |
| 87 | expect(repeats).toBeLessThan(10) | |
| 88 | }) | |
| 89 | ||
| 90 | it('gives each leg twelve to eighteen seconds', () => { | |
| 91 | for (const l of legs) { | |
| 92 | expect(l.durationS).toBeGreaterThanOrEqual(LEG_MIN_S) | |
| 93 | expect(l.durationS).toBeLessThanOrEqual(LEG_MAX_S) | |
| 94 | } | |
| 95 | }) | |
| 96 | }) | |
| 97 | ||
| 98 | describe('the tour inside', () => { | |
| 99 | it('finds every leg in the world as authored', () => { | |
| 100 | expect(legs.map((l) => l.id)).toEqual(INTERIOR_LEGS.map((s) => s.id)) | |
| 101 | }) | |
| 102 | ||
| 103 | it('keeps every camera in the world, above the ground, out of the beam, and moving smoothly', () => { | |
| 104 | for (const l of legs) { | |
| 105 | let prev: ReturnType<typeof l.pose> | null = null | |
| 106 | for (let t = 0; t <= l.durationS; t += 0.5) { | |
| 107 | const q = l.pose(t) | |
| 108 | for (const v of Object.values(q)) expect(Number.isFinite(v), `${l.id} at ${t}s`).toBe(true) | |
| 109 | expect(q.z).toBeGreaterThanOrEqual(0) | |
| 110 | expect(q.z).toBeLessThanOrEqual(LENGTH_M) | |
| 111 | expect(q.altM - ground(q.theta, q.z), `${l.id} clearance at ${t}s`).toBeGreaterThan(8) | |
| 112 | expect(RADIUS_M - q.altM, `${l.id} beam at ${t}s`).toBeGreaterThan(BEAM_RADIUS_M + 2000) | |
| 113 | expect(Math.abs(q.roll), `${l.id} roll at ${t}s`).toBeLessThan(1) | |
| 114 | if (prev) { | |
| 115 | const moved = len(sub(eyeOf(q), eyeOf(prev))) | |
| 116 | // Real time, a train or a ship is tens of metres a second; a time-lapse is more. | |
| 117 | expect(moved, `${l.id} speed at ${t}s`).toBeLessThan(l.rate > 1 ? 3000 : 400) | |
| 118 | const turn = Math.acos(Math.min(1, dot(viewOf(q), viewOf(prev)))) | |
| 119 | expect(turn, `${l.id} turn at ${t}s`).toBeLessThan(0.45) | |
| 120 | } | |
| 121 | prev = q | |
| 122 | } | |
| 123 | } | |
| 124 | }) | |
| 125 | ||
| 126 | it('keeps every craft out of the beam and off the ground', () => { | |
| 127 | for (const l of legs) { | |
| 128 | for (let t = 0; t <= l.durationS; t += 0.5) { | |
| 129 | const c = l.at(t) | |
| 130 | expect(radial(c.pos), `${l.id} at ${t}s`).toBeGreaterThan(BEAM_RADIUS_M + 3000) | |
| 131 | const at = { theta: Math.atan2(c.pos.y, c.pos.x), z: c.pos.z } | |
| 132 | expect(RADIUS_M - radial(c.pos) - ground(at.theta, at.z), `${l.id} at ${t}s`).toBeGreaterThan(1) | |
| 133 | } | |
| 134 | } | |
| 135 | }) | |
| 136 | ||
| 137 | it('keeps a chased craft in the frame', () => { | |
| 138 | for (const l of legs.filter((x) => x.rig === 'chase')) { | |
| 139 | for (let t = 0; t <= l.durationS; t += 1) { | |
| 140 | const q = l.pose(t) | |
| 141 | const off = offCentre(eyeOf(q), viewOf(q), l.at(t).pos) | |
| 142 | expect(off, `${l.id} at ${t}s`).toBeLessThan((q.fovDeg / 2) * (Math.PI / 180)) | |
| 143 | } | |
| 144 | } | |
| 145 | }) | |
| 146 | ||
| 147 | it('shows every leg its subject', () => { | |
| 148 | for (const l of legs.filter((x) => x.subject)) { | |
| 149 | let best = Infinity | |
| 150 | for (let t = 0; t <= l.durationS; t += 0.5) { | |
| 151 | const q = l.pose(t) | |
| 152 | const s = l.subject!.at(t) | |
| 153 | if (s) best = Math.min(best, offCentre(eyeOf(q), viewOf(q), s)) | |
| 154 | } | |
| 155 | expect(best, l.id).toBeLessThan(0.35) | |
| 156 | } | |
| 157 | }) | |
| 158 | ||
| 159 | it('never turns the camera toward something the haze has eaten', () => { | |
| 160 | for (const l of legs) { | |
| 161 | for (let t = 0; t <= l.durationS; t += 1) { | |
| 162 | const name = l.lookingAt(t) | |
| 163 | expect(name, l.id).toBeTruthy() | |
| 164 | } | |
| 165 | } | |
| 166 | // The haze model itself: the far side overhead is clear, a hundred kilometres along the floor is not. | |
| 167 | const here = toV({ z: 270_000, a: 0, altM: 2 }) | |
| 168 | expect(transmission(here, toV({ z: 270_000, a: Math.PI * RADIUS_M, altM: 2 }))).toBeGreaterThan(0.7) | |
| 169 | expect(transmission(here, toV({ z: 370_000, a: 0, altM: 2 }))).toBeLessThan(0.4) | |
| 170 | }) | |
| 171 | ||
| 172 | it('catches a ship out in weather Tower gave no warning of', () => { | |
| 173 | const l = leg('squall') | |
| 174 | const sea = world.weather.events.find((e) => l.title.includes(e.kind) && e.id.includes('squall'))! | |
| 175 | const wind = actualAt(world.weather, sea.target, { directionDeg: 0, speedMS: 0 }, l.hours).wind.speedMS | |
| 176 | expect(wind).toBeGreaterThanOrEqual(17) | |
| 177 | expect(shipsAt(world, l.hours).some((s) => s.lane.sea === sea.target)).toBe(true) | |
| 178 | }) | |
| 179 | ||
| 180 | it('shows a published storm with the sea empty of ships', () => { | |
| 181 | const l = leg('storm') | |
| 182 | expect(shipsAt(world, l.hours).filter((s) => l.title.includes('Sunward') && s.lane.sea === 'sunward-sea')).toHaveLength(0) | |
| 183 | }) | |
| 184 | ||
| 185 | it('flies round the sun by day, and to the caps by night', () => { | |
| 186 | expect(leg('sun-circuit').subject!.at(0)).not.toBeNull() | |
| 187 | expect(leg('far-cap').subject!.id).toBe('far-cap') | |
| 188 | }) | |
| 189 | ||
| 190 | it('draws the whole train, every carriage on the track', () => { | |
| 191 | const t = leg('train-ring').craft(5) | |
| 192 | expect(t[0]!.kind).toBe('locomotive') | |
| 193 | expect(t.filter((c) => c.kind === 'carriage')).toHaveLength(8) | |
| 194 | for (let i = 1; i < t.length; i++) expect(len(sub(t[i]!.pos, t[i - 1]!.pos))).toBeLessThan(30) | |
| 195 | }) | |
| 196 | }) | |
| 197 | ||
| 198 | describe('the tour outside', () => { | |
| 199 | it('flies every leg', () => { | |
| 200 | expect(outside.map((l) => l.id)).toEqual(EXTERIOR_LEGS.map((s) => s.id)) | |
| 201 | }) | |
| 202 | ||
| 203 | it('keeps every camera and craft out of the beam, off the hull, and moving smoothly', () => { | |
| 204 | for (const l of outside) { | |
| 205 | let prev: ReturnType<typeof l.pose> | null = null | |
| 206 | for (let t = 0; t <= l.durationS; t += 0.5) { | |
| 207 | const p = l.pose(t) | |
| 208 | for (const v of [p.eye, p.look, p.up]) { | |
| 209 | for (const x of [v.x, v.y, v.z]) expect(Number.isFinite(x), `${l.id} at ${t}s`).toBe(true) | |
| 210 | } | |
| 211 | const things = [p.eye, ...l.craft(t).map((c) => c.pos)] | |
| 212 | for (const q of things) { | |
| 213 | expect(clearOfBeam(q, 3), `${l.id} beam at ${t}s`).toBe(true) | |
| 214 | if (q.z > -1 && q.z < 541) expect(radial(q), `${l.id} hull at ${t}s`).toBeGreaterThan(HULL_R + 0.05) | |
| 215 | } | |
| 216 | if (prev) { | |
| 217 | const d0 = norm(sub(prev.look, prev.eye)), d1 = norm(sub(p.look, p.eye)) | |
| 218 | expect(Math.acos(Math.min(1, dot(d0, d1))), `${l.id} turn at ${t}s`).toBeLessThan(0.3) | |
| 219 | } | |
| 220 | prev = p | |
| 221 | } | |
| 222 | } | |
| 223 | }) | |
| 224 | ||
| 225 | it('frames the sun clear of the collector in the grand shot', () => { | |
| 226 | const l = outside.find((x) => x.id === 'grand')! | |
| 227 | for (const t of [0, 7, 14]) { | |
| 228 | const { eye, look, fovDeg } = l.pose(t) | |
| 229 | const toSun = norm(sub({ x: 0, y: 0, z: SUN_Z }, eye)) | |
| 230 | // Where the line to the sun crosses the collector's plane, it is outside the disc. | |
| 231 | const k = (COLLECTOR_Z - eye.z) / toSun.z | |
| 232 | expect(Math.hypot(eye.x + toSun.x * k, eye.y + toSun.y * k)).toBeGreaterThan(COLLECTOR_R + 10) | |
| 233 | expect(offCentre(eye, norm(sub(look, eye)), { x: 0, y: 0, z: SUN_Z })).toBeLessThan((fovDeg / 2) * (Math.PI / 180)) | |
| 234 | expect(offCentre(eye, norm(sub(look, eye)), { x: 0, y: 0, z: 540 })).toBeLessThan((fovDeg / 2) * (Math.PI / 180)) | |
| 235 | } | |
| 236 | }) | |
| 237 | }) | |
| 238 | ||
| 239 | it('photographs only legs that exist', () => { | |
| 240 | const tool = readFileSync('tools/screenshots.mjs', 'utf8') | |
| 241 | const ids = [...tool.matchAll(/\['([a-z-]+)', \d+, '/g)].map((m) => m[1]) | |
| 242 | expect(ids.length).toBeGreaterThan(15) | |
| 243 | for (const id of ids) expect([...INTERIOR_LEGS, ...EXTERIOR_LEGS].map((s) => s.id)).toContain(id) | |
| 244 | }) | |
| 245 |