fuzzyworld

tower / files / test / tour.test.ts

tour.test.ts

10.8 KB · history · blame

1import { readFileSync } from 'node:fs'
2import { describe, expect, it } from 'vitest'
3import { BEAM_RADIUS_M, G0, LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
4import { apparentGravity } from '../src/canon/motion.js'
5import { compileWorld } from '../src/terrain/compile.js'
6import { prepare } from '../src/terrain/field.js'
7import { maxGrade, railLines } from '../src/terrain/rails.js'
8import { shipsAt } from '../src/terrain/ships.js'
9import { transmission } from '../src/tour/director.js'
10import {
11 COLLECTOR_R, COLLECTOR_Z, EXTERIOR_LEGS, HULL_R, SUN_Z, clearOfBeam, planExterior,
12} from '../src/tour/exterior-legs.js'
13import { type V3, dirOf, dot, len, norm, radial, sub, toV } from '../src/tour/geo.js'
14import { INTERIOR_LEGS, planInterior } from '../src/tour/interior-legs.js'
15import { LEG_MAX_S, LEG_MIN_S, deal, legSeconds, tourRng } from '../src/tour/plan.js'
16import { parseWorld } from '../src/world/schema.js'
17import { actualAt } from '../src/world/weather.js'
18
19const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
20const compiled = compileWorld(world)
21const rt = prepare(compiled)
22const ground = (theta: number, z: number): number => {
23 const s = rt.sample(theta, z, 1)
24 return Math.max(s.h, s.water)
25}
26const rails = railLines(world, ground)
27const rng = tourRng(Number(process.env["TOUR_SEED"] ?? 7))
28const legs = planInterior({ world, compiled, ground, rails }, rng, () => legSeconds(rng()))
29const leg = (id: string) => legs.find((l) => l.id === id)!
30const outside = planExterior({ world }, tourRng(Number(process.env["TOUR_SEED"] ?? 3)), () => 15)
31
32const eyeOf = (p: ReturnType<(typeof legs)[number]['pose']>): V3 =>
33 toV({ z: p.z, a: p.theta * RADIUS_M, altM: p.altM })
34const viewOf = (p: ReturnType<(typeof legs)[number]['pose']>): V3 => dirOf(eyeOf(p), p.yaw, p.pitch)
35/** Angle off the middle of the frame, radians. */
36const offCentre = (eye: V3, dir: V3, target: V3): number =>
37 Math.acos(Math.max(-1, Math.min(1, dot(norm(sub(target, eye)), dir))))
38
39describe('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
54describe('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
66describe('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
98describe('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
198describe('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
239it('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