tower / commit
feat(sky): a moon by night, with Earth's phases (D-088); leaving a view ends its tour
README.md | 23 +++++-- decisions.md | 40 ++++++++++++ design.md | 36 +++++++++- docs/screenshots/desktop-interior-night.png | Bin 447830 -> 509940 bytes docs/screenshots/desktop-interior-suncap.png | Bin 462620 -> 524208 bytes docs/screenshots/desktop-tour-far-cap-peek.png | Bin 883591 -> 879017 bytes docs/screenshots/desktop-tour-far-cap.png | Bin 453086 -> 449416 bytes docs/screenshots/desktop-tour-moonlight.png | Bin 0 -> 706100 bytes src/canon/constants.ts | 14 ++++ src/tour/interior-legs.ts | 42 +++++++++++- src/ui/app.ts | 26 +++++++- src/views/exterior.ts | 3 + src/views/interior-model.ts | 87 +++++++++++++++++++++++-- src/views/interior.ts | 15 ++++- src/views/map.ts | 26 ++++++++ src/world/celestial.ts | 20 ++++++ src/world/sky.ts | 36 +++++++++- test/celestial.test.ts | 15 ++++- test/sky.test.ts | 39 ++++++++++- tools/screenshots.mjs | 1 + world-generation.md | 3 +- 21 files changed, 400 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 9d5fdc4..f50c07b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ nothing passive would stay in the beam past mid-afternoon. It is a person. Built: the unrolled surface map, Tower from outside, standing inside it and flying over it, a detail card for every place, Tower's published weather, a sun that crosses -the world each day on Sydney's clock, two railways, a scenic tour that flies the world's +the world each day on Sydney's clock and a moon by night with Earth's phases, two railways, a scenic tour that flies the world's paragliders, aircraft, airships, trains and spacecraft inside and out, permalinks, and the sightline probe. @@ -112,6 +112,7 @@ another machine will show a large one-off divergence rather than a regression. | [tour-climb](docs/screenshots/desktop-tour-climb.png) | scenic tour: a giant airship climbing out of the Capital, the world closing overhead | | [tour-sun-circuit](docs/screenshots/desktop-tour-sun-circuit.png) | scenic tour: a giant airship round the sun, outside the beam | | [tour-far-cap](docs/screenshots/desktop-tour-far-cap.png) | scenic tour: toward the far cap by night | +| [tour-moonlight](docs/screenshots/desktop-tour-moonlight.png) | scenic tour: over The Deep by the light of Tower's moon | | [tour-train-narrows](docs/screenshots/desktop-tour-train-narrows.png) | scenic tour: a train on the Axial Line through The Narrows | | [tour-train-ring](docs/screenshots/desktop-tour-train-ring.png) | scenic tour: a train on the Uplands Ring | | [tour-grand](docs/screenshots/desktop-tour-grand.png) | scenic tour: outside: Tower, its collector and the sun in one frame | @@ -384,8 +385,9 @@ at standing height, and a sea crossed in seconds from ten kilometres up, with no to set in between. **The caps have more than one name.** The **sun cap** is the end the beam comes in at. The -**far cap** is also the **star cap**, since it is the one that shows the stars. More names get -added as they come into use. +**far cap** is also the **star cap**, since it is the one that shows the stars, and the **night +cap**, since the night starts there: the sun goes out at it, and the moon sets out from it. More +names get added as they come into use. **The far cap is a window** — 100 km of it, looking into deep anti-sun space. By day it is simply a disc of blue, because 540 km of air in front of it scatters more @@ -424,9 +426,18 @@ and the date of day 0 are the `calendar` in `content/world.json`. anywhere as seen from under it, so the ground is lit by how high the sun stands, as it is under Earth's. From The Capital, halfway along, the morning sun is low in the sunward sky, it passes overhead at midday, and it sinks away deepward in the afternoon; it never sets, -since 50 km up and 540 km away it is still 5° high. The **beam** glows from the sun cap to -the sun and is dark beyond it, because the shoal takes all of it. Inside and out it is the -same sun: the exterior looks in through the windows at the one the interior stands under. +since 50 km up and 540 km away it is still 5° high. The **beam** glows from cap to cap at +every hour, a little dimmer past the sun, which takes part of it and lets the rest run on to +the far cap's mirror (D-079). Inside and out it is the same sun: the exterior looks in through +the windows at the one the interior stands under. + +**By night Tower has a moon.** A second, smaller gathering of the swarm lights at the far cap +as the sun goes out there, and crosses the world the other way through the night, reaching the +sun cap by sunrise. It is as lit as Earth's Moon is that night — full, gibbous, a crescent lit +toward the far cap while it waxes and toward the sun cap while it wanes, or nothing at new moon +— and it lights the ground by it: a full-moon night is twice as bright as a moonless one, with +a path of light across the water toward it. The Time section says where it is and how lit, and +the map draws a pale line where it is overhead. **The clock opens at the time in Sydney now, and runs.** It starts from Sydney's current date and time and goes forward at whatever speed it was last run at — 900x until you choose diff --git a/decisions.md b/decisions.md index 162bf04..237f638 100644 --- a/decisions.md +++ b/decisions.md @@ -2928,3 +2928,43 @@ live near the axis because only there do they have to. At 44 km up the air is ab twentieth of the floor's density and weighs about a tenth, and what a gasbag lifts per cubic metre depends on the density of the air it displaces, not on gravity — so an airship there needs some twenty times the volume for the same load. Hence giants, 420 m long, and slow. + +## D-088 — Tower has a moon + +**Asked for** (author, 2026-09-28): "a 'moon' will travel from night cap to the sun cap over +the course of the night. The brightness of the moon will be based on the moon phases, so this +will give Tower a moon of sorts." The **night cap** is the far cap: night starts there, when +the sun goes out at it (the cap names are in §1.4). + +**Built.** design.md §1.3.5 has the canon. + +**What it is.** A second gathering of the swarm on the banked beam, by night, the size of the +sun. It lights at the far cap at dusk — `SUN_RAMP_H`, the same quarter hour the sun takes to go +out — runs at a steady pace to the sun cap, and goes out there at sunrise as the sun lights. The +sun crosses the world one way by day and the moon the other way by night, so there is always one +of them on the beam except at new moon. With the beam shut down (the Dark setting) there is no +moon either. + +**Its phase is Earth's Moon's**, worked out for the moment on the clock (`world/celestial.ts` +`moonPhase`, Meeus's low-precision phase angle, checked against the almanac's new, full and +quarter moons). Day 0's night, 23 September 2026, has an 89% waxing moon; it is full on the 27th +and new on 11 October. The disc is drawn with its terminator, lit toward the far cap while it +waxes and toward the sun cap while it wanes — where the sun set and where it will rise, as on +Earth. + +**How bright, and why that.** The brightness had to be chosen, because the honest one would be +invisible: Earth's full moon lights the ground at about 0.25 lux, and Tower's banked night is +already some 150 times that. So Tower's moon is sized to its own night: at full it throws as much +light on the floor as the whole banked rod (`MOON_AT_FULL_OF_NIGHT = 1`), and a moonlit night is +up to 0.64 W/m² against a moonless 0.32. It is lit from where the moon is, not from the whole rod, +so it has a direction: slopes facing it are brighter, and seas carry a path of light toward it. + +**Everywhere the sun is, the moon is.** The panel's time section says where it is and how lit, +the interior's readout gives its height and distance as it does the sun's, the map draws a pale +line where it is overhead, the exterior sees it in through the windows, and the scenic tour's +director will look at it by night. The tour has one new leg for it: a seaplane crossing The Deep by +moonlight, on the first night of the month with the moon at least 60% lit. + +**A canon question it raises, not answered here:** whether the moon takes its light from the +beam as the sun does — the rod dimmer past it — or is fed by Tower lifting the banked beam, as +written above. The second is what is drawn. diff --git a/design.md b/design.md index f8cbe6a..1bfd8c4 100644 --- a/design.md +++ b/design.md @@ -232,6 +232,7 @@ this is a state Tower can survive and not one it can sit in. |---|---|---| | Dark | **0** | the beam shut down. Stars, and nothing else. | | Night | **0.32 W/m²** | the beam banked to a thousandth | +| Moonlit night | up to **0.64 W/m²** | the same, and a full moon's light again (§1.3.5) | | Twilight | 30 W/m² | | | Temperate | 200 W/m² | | | Full | 320 W/m² | Earth clear-sky noon | @@ -296,6 +297,28 @@ says Tower has no latitude; the sun gives it **longitude**, and a direction for is drawn once and seen by both views — from the floor, and in through the windows from outside — with the same haze between it and the eye. +#### 1.3.5 The night: a moon + +*Asked for by the author, 2026-09-28; D-088.* + +**Tower has a moon.** By night the swarm gathers a second, smaller shoal on the banked beam, +the size of the sun — 3.2 km, as Earth's Moon and Sun are one size in Earth's sky. It lights +at the **far cap** as the sun goes out there at dusk, and crosses the world **the other way**, +sunward, reaching the **sun cap** by sunrise, where the sun lights behind it. At the equinox +that is 540 km in 11 h 53 min, some 45 km/h; the long winter nights run it slower. + +**It keeps Earth's Moon's phases**, as the sun keeps Sydney's days. Its disc is lit as much as +the Moon is lit that night, and shaped like it: the lit part lies toward the far cap while it +waxes — the end the sun went out at, as a waxing moon on Earth is lit on the side of the set +sun — and toward the sun cap while it wanes, the end the sun will light at. The dark part is +not drawn; those mirrorlets are simply turned away. At new moon there is no moon. + +**It lights the world.** At full it throws as much light on the floor as the whole banked rod, +so a full-moon night is twice as bright as a moonless one — 0.64 W/m² against 0.32 — and lit +from one direction, with the land facing it brighter. Tower banks the beam that much less to +feed it. This is a choice and not a necessity: Earth's full moon is about a hundred and +fiftieth of Tower's banked night, and would be lost in it. Water shows a path of moonlight. + ### 1.4 Sealed hull, and the cap windows **The cylinder wall is opaque and unbroken.** No slats, no windows, no view out @@ -315,8 +338,10 @@ The only views out are at the **end caps**, and they are peculiar: is horizontal from the floor, it wheels about a point on the **horizon** rather than overhead. - **The caps have more than one name.** The **sun cap** is the end the beam enters by. - The **far cap** is also the **star cap**: it is the one with the stars in it. Both are - used, and more names are added as they come into use (author, 2026-09-27). + The **far cap** is also the **star cap**: it is the one with the stars in it — and the + **night cap**, since night starts there: the sun goes out at it at dusk, and the moon sets + out from it (D-088). All are used, and more names are added as they come into use (author, + 2026-09-27, 2026-09-28). - The **far cap is glazed whole** — a 100 km window looking into deep anti-sun space, not a wall with windows at its foot (D-045). Its structure survives as the window's **mullions**, a lattice of identical equilateral triangles 8.3 km on a side @@ -378,7 +403,9 @@ So the interior sky behaves like this, and the shader must reproduce it: from cap to cap at every hour; by day it is dimmer past the sun (§1.3.1, D-079). - **The sun** is the other thing in the sky that is not land: a blazing disc on the beam, 3.65° across from directly beneath and smaller from afar, with glare round it - (§1.3.4). With the hull sealed, the rod and the sun are all there is. + (§1.3.4). By night there is the **moon** instead, smaller in light and crossing the other + way, as lit as Earth's Moon (§1.3.5). With the hull sealed, the rod, the sun and the moon are + all there is. ### 1.6 Orbit and attitude @@ -1237,3 +1264,6 @@ Things decided to be worth coming back to, not scheduled. rack section. - **The giant airships** (D-087) are a consequence worked out from the thin air near the axis, not canon. Whether Tower's sky has them is the author's call. +- **The moon's light** (D-088). It is drawn as fed by Tower lifting the banked beam, so the rod + is the same either side of it; the sun instead takes its share from the rod and leaves it + dimmer past it (D-079). Canon could have the moon do the same, or settle why it does not. diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png index af438e4..c96000f 100644 Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png index ade0fbf..2e8a0b1 100644 Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ diff --git a/docs/screenshots/desktop-tour-far-cap-peek.png b/docs/screenshots/desktop-tour-far-cap-peek.png index dbcf3b4..8526ccf 100644 Binary files a/docs/screenshots/desktop-tour-far-cap-peek.png and b/docs/screenshots/desktop-tour-far-cap-peek.png differ diff --git a/docs/screenshots/desktop-tour-far-cap.png b/docs/screenshots/desktop-tour-far-cap.png index 2a3a723..d623dc7 100644 Binary files a/docs/screenshots/desktop-tour-far-cap.png and b/docs/screenshots/desktop-tour-far-cap.png differ diff --git a/docs/screenshots/desktop-tour-moonlight.png b/docs/screenshots/desktop-tour-moonlight.png new file mode 100644 index 0000000..2d3faad Binary files /dev/null and b/docs/screenshots/desktop-tour-moonlight.png differ diff --git a/src/canon/constants.ts b/src/canon/constants.ts index c08c20e..36cc815 100644 --- a/src/canon/constants.ts +++ b/src/canon/constants.ts @@ -184,6 +184,20 @@ export const DAY_LENGTH_S = 86_400 * swarm gathering into its shoal at the sun cap, and dispersing at the far cap. D-074. */ export const SUN_RAMP_H = 0.25 + +/** + * Tower's moon (D-088): by night a second, smaller gathering of the swarm on the banked + * beam, the size of the sun, which lights at the far cap as the sun goes out there and + * crosses the world the other way, to the sun cap by sunrise. It keeps Earth's Moon's + * phases, lit as much as the Moon is lit that night. + * + * At full it throws as much light on the floor as the whole banked rod does, so a full-moon + * night is twice as bright as a moonless one; Tower banks the beam that much less to feed + * it. That is a choice, not a physical necessity: Earth's full moon is about a hundred and + * fiftieth of Tower's banked night, and would be lost in it. + */ +export const MOON_AT_FULL_OF_NIGHT = 1 +export const MOON_RADIUS_M = BEAM_RADIUS_M /** The sun's radius on the axis: the beam's own, since it is the beam that is lit up. */ export const SUN_RADIUS_M = BEAM_RADIUS_M diff --git a/src/tour/interior-legs.ts b/src/tour/interior-legs.ts index 6f277f8..ddb953c 100644 --- a/src/tour/interior-legs.ts +++ b/src/tour/interior-legs.ts @@ -148,9 +148,18 @@ const theSun = (w: TourWorld, hours: number, rate: number): Poi => ({ }, }) +/** Tower's moon (D-088), by night: worth a look as it is lit. */ +const theMoon = (w: TourWorld, hours: number, rate: number): Poi => ({ + id: 'moon', name: 'the moon', weight: 1.2, sizeM: 12_000, grounded: false, + at: (t) => { + const sky = skyAt(w.world.calendar, hours + (t * rate) / 3600, 'full') + return sky.moonZ === null || sky.moonLight < 0.05 ? null : v3(0, 0, sky.moonZ) + }, +}) + /** Everything in the world worth turning the camera toward, from `hours` on. */ export function worldPois(w: TourWorld, hours: number, rate: number): Poi[] { - const out: Poi[] = [farCap(), sunCap(), theSun(w, hours, rate)] + const out: Poi[] = [farCap(), sunCap(), theSun(w, hours, rate), theMoon(w, hours, rate)] const R = RADIUS_M for (const f of w.world.features) { if (f.kind === 'mountains') { @@ -233,7 +242,7 @@ function makeLeg(w: TourWorld, d: LegDef): Leg { const level = d.vehicle === 'airship' || d.vehicle === 'giant-airship' || d.vehicle === 'ship' || d.vehicle === 'train' const track = trackOf(d.path, d.durationS, { heading: d.heading, level }) const pois = d.pois === 'sky' - ? [farCap(), sunCap(), theSun(w, d.hours, rate)] + ? [farCap(), sunCap(), theSun(w, d.hours, rate), theMoon(w, d.hours, rate)] : Array.isArray(d.pois) ? d.pois : worldPois(w, d.hours, rate) const g = gaze(track, { pois, subject: d.subject ?? null, subjectBoost: d.subjectBoost, ground: w.ground, @@ -806,6 +815,35 @@ export const INTERIOR_LEGS: LegSpec[] = [ }) }, }, + { + id: 'moonlight', vehicle: 'seaplane', + prepare: (w, o) => { + const capital = feature(w.world, 'the-capital') + const deep = feature(w.world, 'the-deep') + if (!capital || capital.geometry.type !== 'point' || !deep) return null + const { theta, z } = capital.geometry + // The first night of the month with a good moon, at the hour it stands 40 km sunward + // of the Capital — ahead of a seaplane heading sunward over The Deep. + const cal = w.world.calendar + let hours: number | null = null + for (let d = 0; d < 32 && hours === null; d++) { + const dusk = hourWhenSunAt(cal, d, LENGTH_M) + const dawn = hourWhenSunAt(cal, d + 1, 0) + 24 + const h = d * 24 + dusk + (1 - (z - 40_000) / LENGTH_M) * (dawn - dusk) + const sky = skyAt(cal, h, 'full') + if (sky.moonZ !== null && sky.moonPhase.fraction >= 0.6 && sky.moonRamp > 0.99) hours = h + } + if (hours === null) return null + const phase = skyAt(cal, hours, 'full').moonPhase + const a = theta * RADIUS_M + 12_000 + return makeLeg(w, { + id: 'moonlight', vehicle: 'seaplane', hours, durationS: o.durationS, fovDeg: 62, + title: `By moonlight over ${deep.name}: Tower's moon, ${Math.round(phase.fraction * 100)}% lit and ${phase.waxing ? 'waxing' : 'waning'}, crossing the world toward the sun cap`, + path: (t) => ({ z: z + 3000 - 55 * t, a, altM: 260 }), + rig: { kind: 'chase', back: 34, raise: 6, swingRad: 0.6 }, pois: 'sky', subject: theMoon(w, hours, 1), + }) + }, + }, { id: 'far-cap', vehicle: 'airliner', prepare: (w, o) => { diff --git a/src/ui/app.ts b/src/ui/app.ts index 97a7a8f..3dc5f70 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -17,7 +17,7 @@ import { TowerMap } from '../views/map.js' import type { ExteriorView, HullMode } from '../views/exterior.js' import type { InteriorView } from '../views/interior.js' import { clock } from '../world/clock.js' -import { clockText, dateLabel, hoursAt, isDial, skyAt } from '../world/sky.js' +import { type Sky, clockText, dateLabel, hoursAt, isDial, skyAt } from '../world/sky.js' import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' import { EyeGauge } from './eye-gauge.js' @@ -97,6 +97,21 @@ const fmtDuration = (s: number): string => s >= 3600 ? `${+(s / 3600).toFixed(1)} h` : s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s` +/** + * The moon tonight, for the time panel (D-088): how much of it is lit, which way it is going, + * and where it is on its way from the far cap to the sun cap. + */ +function moonText(sky: Sky): string { + const pct = Math.round(sky.moonPhase.fraction * 100) + if (sky.moonZ === null || sky.moonRamp <= 0) return '' + if (pct < 1) return 'The moon is new: no moon tonight.' + const name = pct >= 97 ? 'full' : pct <= 3 ? 'new' : `${pct}% lit and ${sky.moonPhase.waxing ? 'waxing' : 'waning'}` + const dusk = sky.hourOfDay > sky.times.sunsetH ? sky.times.sunsetH : sky.times.sunsetH - 24 + const dawn = sky.hourOfDay > sky.times.sunsetH ? sky.times.sunriseH + 24 : sky.times.sunriseH + const speed = LENGTH_M / 1000 / (dawn - dusk) + return `The moon, ${name}, is ${km(sky.moonZ)} from the sun cap, moving sunward at about ${speed.toFixed(0)} km/h.` +} + export class App { private map: TowerMap private exterior: ExteriorView | null = null @@ -372,6 +387,7 @@ export class App { /** Where the map's sun marker was last drawn, so a running clock redraws it only when it moves. */ private mapSunKm = NaN + private mapMoonKm = NaN /** * Everything that follows the clock: the panel's time, where the sun is, the weather board @@ -387,7 +403,7 @@ export class App { const where = sky.sunZ !== null ? `The sun is ${km(sky.sunZ)} from the sun cap, moving deepward at ${speed.toFixed(0)} km/h.` : clock.dial === 'dark' ? 'The beam is shut down: no sun, and no night light either.' - : 'Night: the sun is out, and the beam banked to a thousandth.' + : `Night: the sun is out, and the beam banked to a thousandth. ${moonText(sky)}` $('sun-now').textContent = `Sunrise ${clockText(sunriseH)}, sunset ${clockText(sunsetH)}. ${where}` const day = $<HTMLInputElement>('f-day') if (document.activeElement !== day && day.value !== String(sky.day)) day.value = String(sky.day) @@ -399,9 +415,13 @@ export class App { if (cycle.checked !== clock.running) cycle.checked = clock.running this.renderWeather() const sunKm = sky.sunZ === null ? NaN : Math.round(sky.sunZ / 500) / 2 - if (!Object.is(sunKm, this.mapSunKm)) { + const moonKm = sky.moonZ === null ? NaN : Math.round(sky.moonZ / 500) / 2 + if (!Object.is(sunKm, this.mapSunKm) || !Object.is(moonKm, this.mapMoonKm)) { this.mapSunKm = sunKm + this.mapMoonKm = moonKm this.map.sunZ = sky.sunZ + this.map.moon = sky.moonZ === null || sky.moonRamp <= 0 ? null + : { z: sky.moonZ, fraction: sky.moonPhase.fraction } if (this.view === 'map') this.map.draw() } if (this.view === 'interior') this.renderStanding() diff --git a/src/views/exterior.ts b/src/views/exterior.ts index b5f3fcc..aa6b174 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -814,6 +814,9 @@ export class ExteriorView { stop(): void { this.running = false + // Leaving the view ends its tour: otherwise its caption stays up over the next view's, + // and a tour started there draws a second one on top of it. + if (this.tour) this.stopTour() } // --------------------------------------------------------------------- tour diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts index 0c469ee..b532175 100644 --- a/src/views/interior-model.ts +++ b/src/views/interior-model.ts @@ -22,7 +22,7 @@ import { BEAM_RADIUS_M, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, - SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K, + MOON_RADIUS_M, SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K, } from '../canon/constants.js' import type { Sky } from '../world/sky.js' import { PALETTE_GLSL } from '../terrain/palette.js' @@ -43,6 +43,9 @@ const R = RADIUS_M / KM /** The sun's radius, and how far out its glow is drawn, km. */ const SUN_R = SUN_RADIUS_M / KM const SUN_GLOW_R = 24 +/** The moon's radius, and how far out its glow is drawn, km (D-088). */ +const MOON_R = MOON_RADIUS_M / KM +const MOON_GLOW_R = 12 const L = LENGTH_M / KM const BEAM_R = BEAM_RADIUS_M / KM /** Wall depth in km. The far cap's window is set this far into its own frame. */ @@ -176,13 +179,17 @@ vec4 windowColour(vec3 face, vec3 solid, float cover) { * so the sun is as bright seen from anywhere as from under it, so a surface is lit by how * squarely it faces the sun — as under Earth's — and each place has its noon when the sun * passes over it. By night the sun is out and the beam is banked, and what light there is - * comes from the whole rod overhead, `uSun.z` of it. + * comes from the whole rod overhead, `uSun.z` of it — and from the moon (D-088), a point on + * the axis `uMoon.x` km from the sun cap throwing `uMoon.y` of the rod's light again. * * Every lit surface in both views goes through `direct`, which is what keeps the sun seen * from the floor and the sun seen in through a window the same sun. */ const SUN_GLSL = /* glsl */` uniform vec3 uSun; +/** The moon: km from the sun cap, its light, how lit it is as it gathers, and its phase — + * the lit share of its disc, positive waxing and negative waning (D-088). */ +uniform vec4 uMoon; /** * How much of the beam runs on past the sun by day, to the far cap (D-079). The shoal takes * the rest; this is what keeps the rod whole from cap to cap, and the far cap's mirror lit. @@ -190,9 +197,12 @@ uniform vec3 uSun; const float BEAM_PAST_SUN = 0.7; vec3 sunPos() { return vec3(0.0, 0.0, uSun.x); } vec3 sunDir(vec3 p) { return normalize(sunPos() - p); } +vec3 moonPos() { return vec3(0.0, 0.0, uMoon.x); } +vec3 moonDir(vec3 p) { return normalize(moonPos() - p); } vec3 upAt(vec3 p) { return -normalize(vec3(p.xy, 0.0)); } float direct(vec3 p, vec3 n) { - return uSun.y * max(dot(n, sunDir(p)), 0.0) + uSun.z * max(dot(n, upAt(p)), 0.0); + return uSun.y * max(dot(n, sunDir(p)), 0.0) + uSun.z * max(dot(n, upAt(p)), 0.0) + + uMoon.y * max(dot(n, moonDir(p)), 0.0); } ` @@ -515,6 +525,8 @@ void main() { float toAxis = max(0.0, dot(normalize(vec3(refl.xy, 0.0) + 1e-6), up)); col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light * uSun.z; col += vec3(1.0, 0.96, 0.88) * pow(max(dot(refl, sunDir(vWorld)), 0.0), 2000.0) * 2.5 * uSun.y * uExposure; + // And the moon, on a moonlit night: a path of light across the sea toward it. + col += vec3(0.85, 0.9, 1.0) * pow(max(dot(refl, moonDir(vWorld)), 0.0), 1500.0) * 1.2 * uMoon.y * uExposure; // Foam: rapids and falls, and the edge where the water is only centimetres deep. float depth = vWater.x; @@ -834,7 +846,8 @@ void main() { #include <logdepthbuf_fragment> vec3 n = normalize(vN); // Both faces of a sail are lit, whichever way it has swung. - float lambert = uSun.y * abs(dot(n, sunDir(vWorld))) + uSun.z * abs(dot(n, upAt(vWorld))); + float lambert = uSun.y * abs(dot(n, sunDir(vWorld))) + uSun.z * abs(dot(n, upAt(vWorld))) + + uMoon.y * abs(dot(n, moonDir(vWorld))); // Three.Color already converts its hex to linear. vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure; vec3 T = towerTransmittance(uCam, vWorld); @@ -1328,6 +1341,47 @@ void main() { } ` +/** + * Tower's moon (D-088): the sun's counterpart by night, a smaller gathering of the swarm on + * the banked beam, drawn the way the sun is. Its disc shows Earth's Moon's phase: the lit + * part is bounded by the terminator's ellipse, and lies toward the far cap while it waxes — + * the end the sun went out at, as a waxing moon on Earth is lit on the side of the set sun — + * and toward the sun cap while it wanes, the end the sun will light at. The dark part is not + * drawn: those mirrorlets are simply turned away. + */ +const MOON_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +varying vec3 vWorld; +${HAZE_GLSL} +${SUN_GLSL} +void main() { + #include <logdepthbuf_fragment> + bool outside = distance(uCam, moonPos()) > ${MOON_GLOW_R.toFixed(1)}; + if (gl_FrontFacing != outside) discard; + vec3 v = normalize(vWorld - uCam); + vec3 oc = moonPos() - uCam; + float along = dot(oc, v); + if (along <= 0.0) discard; + vec3 off = v * along - oc; + float d = length(off); + // Across the disc: x toward the lit limb, along the axis as seen; y across it. + vec3 axis = vec3(0.0, 0.0, 1.0) - v * v.z; + axis = length(axis) > 1e-4 ? normalize(axis) : normalize(cross(v, vec3(1.0, 0.0, 0.0))); + float x = dot(off, axis) / ${MOON_R.toFixed(3)} * sign(uMoon.w); + float y = length(off - axis * dot(off, axis)) / ${MOON_R.toFixed(3)}; + float e = 1.0 - 2.0 * abs(uMoon.w); + float lit = step(e * sqrt(max(0.0, 1.0 - y * y)), x); + float disc = (1.0 - smoothstep(0.9, 1.0, d / ${MOON_R.toFixed(3)})) * lit; + float glare = 0.35 * exp(-max(d - ${MOON_R.toFixed(3)}, 0.0) / 1.2) * abs(uMoon.w); + glare *= 1.0 - smoothstep(${(MOON_GLOW_R * 0.6).toFixed(1)}, ${MOON_GLOW_R.toFixed(1)}, d); + vec3 T = towerTransmittance(uCam, moonPos()); + vec3 col = vec3(0.86, 0.9, 1.0) * (disc * 3.0 + glare * uExposure) * uMoon.z; + gl_FragColor = vec4(col * T, 1.0); + #include <colorspace_fragment> +} +` + // ------------------------------------------------------------------------ model export interface InteriorModelOptions { @@ -1351,6 +1405,8 @@ export class InteriorModel { readonly canopyMat: ShaderMaterial /** The trees near the eye, and the cards beyond them (forest-view.ts). */ readonly forest: ForestView + /** Tower's moon (D-088), moved along the axis by `setSky`, and out by day. */ + readonly moon: Mesh /** Tower's sun, moved along the axis by `setSky`. */ readonly sun: Mesh /** Every waterfall in the world, rebuilt when the world is. */ @@ -1402,8 +1458,9 @@ export class InteriorModel { this.sails = fleet(sailGeometry(), 0xe6ddc8, 'sails') this.jibs = fleet(jibGeometry(), 0xe6ddc8, 'jibs') this.sun = this.buildSun() + this.moon = this.buildMoon() this.rails.name = 'rails' - this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, this.rails, ...this.buildCaps(), this.buildBeam(), this.sun] + this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, this.rails, ...this.buildCaps(), this.buildBeam(), this.sun, this.moon] terrain().onWorld(() => this.buildFalls()) if (terrain().compiled) this.buildFalls() } @@ -1442,6 +1499,8 @@ export class InteriorModel { ;(m.uniforms['uHaze']!.value as Color).copy(donor['uHaze']!.value as Color) ;(m.uniforms['uCam']!.value as Vector3).copy(donor['uCam']!.value as Vector3) ;(m.uniforms['uSun']!.value as Vector3).copy(donor['uSun']!.value as Vector3) + ;(m.uniforms['uMoon']!.value as Vector4).copy(donor['uMoon']!.value as Vector4) + ;(m.uniforms['uMoon']!.value as Vector4).copy(donor['uMoon']!.value as Vector4) ;(m.uniforms['uFrame']!.value as Matrix4).copy(donor['uFrame']!.value as Matrix4) const mesh = new Mesh(g, m) mesh.name = `fall ${f.river}` @@ -1710,6 +1769,8 @@ export class InteriorModel { uSkyFrame: { value: skyMatrix(Date.now(), new Matrix3()) }, // The sun's position (km), how lit it is, and the banked rod's share (SUN_GLSL). uSun: { value: new Vector3(L / 2, 1, 0) }, + // The moon's position (km), its light, its ramp and its signed phase (SUN_GLSL). + uMoon: { value: new Vector4(L / 2, 0, 0, 0) }, } } @@ -1803,6 +1864,17 @@ export class InteriorModel { return sun } + /** The moon, drawn as the sun is: after the beam, before the windows. */ + private buildMoon(): Mesh { + const m = this.material(FLAT_VERT, MOON_FRAG, { side: DoubleSide, additive: true, uniforms: {} }) + const moon = new Mesh(new SphereGeometry(MOON_GLOW_R, 32, 16), m) + moon.name = 'moon' + moon.renderOrder = 3.5 + moon.frustumCulled = false + moon.visible = false + return moon + } + /** * Light the model for a moment of Tower's day (sky.ts). Both views call this with the * same sky, so the sun, the rod and the light on the ground agree inside and out. @@ -1822,6 +1894,8 @@ export class InteriorModel { const zKm = (sky.sunZ ?? 0) / KM // The banked rod lights the night; with the beam shut down there is not even that. const rod = sky.dial === 'dark' ? 0 : 1 - sky.power + const moonKm = (sky.moonZ ?? 0) / KM + const phase = sky.moonPhase.fraction * (sky.moonPhase.waxing ? 1 : -1) const frame = skyMatrix(sky.at, this.skyFrame) for (const m of this.materials) { ;(m.uniforms['uSkyFrame']!.value as Matrix3).copy(frame) @@ -1829,9 +1903,12 @@ export class InteriorModel { m.uniforms['uStarGain']!.value = starGain ;(m.uniforms['uHaze']!.value as Color).copy(haze) ;(m.uniforms['uSun']!.value as Vector3).set(zKm, sky.power, rod) + ;(m.uniforms['uMoon']!.value as Vector4).set(moonKm, sky.moonLight, sky.moonRamp, phase) } this.sun.position.set(0, 0, zKm) this.sun.visible = sky.power > 0 + this.moon.position.set(0, 0, moonKm) + this.moon.visible = sky.moonRamp > 0 && sky.moonPhase.fraction > 0.005 return { exposure, haze } } } diff --git a/src/views/interior.ts b/src/views/interior.ts index 8bd628c..d8505b9 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -412,7 +412,7 @@ export class InteriorView { const cal = terrain().world?.calendar ?? defaultCalendar() if (sky.sunZ === null) { const next = sky.hourOfDay < sky.times.sunriseH ? 'today' : 'tomorrow' - return ` · night, ${cal.city} sunrise ${next}` + return ` · night, ${cal.city} sunrise ${next}${this.moonNote(sky)}` } const along = (sky.sunZ - this.at.z) / KM const up = (RADIUS_M - this.elevationM - this.eyeM) / KM @@ -421,6 +421,16 @@ export class InteriorView { return ` · sun ${elevation.toFixed(0)}° up, ${Math.abs(along).toFixed(0)} km ${along < 0 ? 'sunward' : 'deepward'}` } + /** Where the moon is from here, by night (D-088), in the same terms as the sun. */ + private moonNote(sky: Sky): string { + if (sky.moonZ === null || sky.moonRamp <= 0 || sky.moonPhase.fraction < 0.01) return '' + const along = (sky.moonZ - this.at.z) / KM + const up = (RADIUS_M - this.elevationM - this.eyeM) / KM + const elevation = (Math.atan2(up, Math.abs(along)) * 180) / Math.PI + const pct = Math.round(sky.moonPhase.fraction * 100) + return ` · moon ${pct}% lit, ${elevation.toFixed(0)}° up, ${Math.abs(along).toFixed(0)} km ${along < 0 ? 'sunward' : 'deepward'}` + } + /** Human clock, for the readout: the calendar city's date and time (D-074). */ get clock(): string { const cal = terrain().world?.calendar ?? defaultCalendar() @@ -1006,6 +1016,9 @@ export class InteriorView { stop(): void { this.running = false + // Leaving the view ends its tour: otherwise its caption stays up over the next view's, + // and a tour started there draws a second one on top of it. + if (this.tour) this.stopTour() } // --------------------------------------------------------------------- tour diff --git a/src/views/map.ts b/src/views/map.ts index 7d352f7..20c1201 100644 --- a/src/views/map.ts +++ b/src/views/map.ts @@ -47,6 +47,8 @@ export class TowerMap { * shades the world by the light it throws, with the same curve the 3D views light by. */ sunZ: number | null = null + /** Tower's moon by night (D-088): where it is, and how much of it is lit. */ + moon: { z: number; fraction: number } | null = null private panning: { x: number; y: number; ox: number; oy: number } | null = null private moved = false @@ -383,6 +385,7 @@ export class TowerMap { if (this.sunZ === null) { ctx.fillStyle = 'rgba(4,6,14,0.34)' ctx.fillRect(x0, y0, w, h) + this.drawMoon(y0, h) return } const grad = ctx.createLinearGradient(x0, 0, x0 + w, 0) @@ -416,6 +419,29 @@ export class TowerMap { ctx.textAlign = 'left' } + /** The moon's line, where it is overhead: pale, and as strong as the moon is lit. */ + private drawMoon(y0: number, h: number): void { + const moon = this.moon + if (!moon || moon.fraction < 0.01) return + const ctx = this.ctx + const [mx] = this.worldToScreen(moon.z, 0) + const a = 0.25 + 0.6 * moon.fraction + ctx.lineWidth = 1.5 + ctx.strokeStyle = `rgba(205,220,255,${a.toFixed(2)})` + ctx.setLineDash([6, 5]) + ctx.beginPath() + ctx.moveTo(mx, y0) + ctx.lineTo(mx, y0 + h) + ctx.stroke() + ctx.setLineDash([]) + if (!this.showLabels) return + ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace' + ctx.fillStyle = `rgba(215,228,255,${Math.min(1, a + 0.1).toFixed(2)})` + ctx.textAlign = 'center' + ctx.fillText(`MOON ${Math.round(moon.fraction * 100)}%`, mx, y0 + h + 14) + ctx.textAlign = 'left' + } + private drawCaps(): void { const ctx = this.ctx const [, y0] = this.worldToScreen(0, 0) diff --git a/src/world/celestial.ts b/src/world/celestial.ts index 6e68df3..d5225ff 100644 --- a/src/world/celestial.ts +++ b/src/world/celestial.ts @@ -36,6 +36,26 @@ export function sunLongitude(at: Date): number { return ((lambda % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI) } +/** + * Earth's Moon's phase at an instant: how much of its disc is lit, 0 at new and 1 at full, + * and whether it is waxing. Tower's moon keeps it (D-088), as its sun keeps Sydney's days. + * + * Meeus, *Astronomical Algorithms* ch. 48, low precision: the phase angle from the mean + * elongation and the two mean anomalies, with the six largest periodic terms. Good to about + * a hundredth of the disc, which is finer than an eye can judge a moon. + */ +export function moonPhase(at: Date): { fraction: number; waxing: boolean } { + const T = (at.getTime() - J2000_MS) / DAY_MS / 36525 + const D = (297.8501921 + 445267.1114034 * T) * RAD + const M = (357.5291092 + 35999.0502909 * T) * RAD + const Mp = (134.9633964 + 477198.8675055 * T) * RAD + const i = Math.PI - D + - 6.289 * RAD * Math.sin(Mp) + 2.1 * RAD * Math.sin(M) - 1.274 * RAD * Math.sin(2 * D - Mp) + - 0.658 * RAD * Math.sin(2 * D) - 0.214 * RAD * Math.sin(2 * Mp) - 0.11 * RAD * Math.sin(D) + // Waxing while the moon's elongation from the sun runs from 0° to 180°. + return { fraction: (1 + Math.cos(i)) / 2, waxing: Math.sin(D) > 0 } +} + /** * Scene direction → J2000 equatorial direction at an instant, as a row-major 3×3. * diff --git a/src/world/sky.ts b/src/world/sky.ts index 5f37148..26cabff 100644 --- a/src/world/sky.ts +++ b/src/world/sky.ts @@ -13,8 +13,9 @@ */ import { BRIGHTNESS_FULL_W_M2, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, - BRIGHTNESS_TWILIGHT_W_M2, LENGTH_M, SUN_RAMP_H, + BRIGHTNESS_TWILIGHT_W_M2, LENGTH_M, MOON_AT_FULL_OF_NIGHT, SUN_RAMP_H, } from '../canon/constants.js' +import { moonPhase } from './celestial.js' import type { Calendar } from './schema.js' /** @@ -51,6 +52,21 @@ export interface Sky { /** How lit the sun is, 0..1: it comes up over SUN_RAMP_H after sunrise and goes out * over the same before sunset. */ power: number + /** + * Where the moon is on the axis, metres from the sun cap — or null by day, or with the + * beam shut down. It lights at the far cap at sunset and runs the other way from the sun, + * reaching the sun cap at the next sunrise (D-088). + */ + moonZ: number | null + /** + * How much light the moon throws, as a share of the banked rod's: its lighting and going + * out, times how much of it is lit tonight. 1 at full, 0 at new. + */ + moonLight: number + /** How lit it is as it gathers and disperses, 0..1, whatever its phase: what the disc shows. */ + moonRamp: number + /** Earth's Moon tonight: how much of its disc is lit, and whether it is waxing. */ + moonPhase: { fraction: number; waxing: boolean } /** What the world's light amounts to, W/m² mean, for the eye's adaptation. */ watts: number dial: Dial @@ -153,13 +169,27 @@ export function skyAt(cal: Calendar, hours: number, dial: Dial): Sky { ? smooth((hourOfDay - sunriseH) / SUN_RAMP_H) * smooth((sunsetH - hourOfDay) / SUN_RAMP_H) : 0 const night = dial === 'dark' ? 0 : BRIGHTNESS_NIGHT_W_M2 + const at = instantOf(cal, day, hourOfDay) + + // The night this moment is in: from the last sunset to the next sunrise. + const after = hourOfDay > sunsetH + const dusk = after ? sunsetH : sunTimes(cal, day - 1).sunsetH - 24 + const dawn = after ? sunTimes(cal, day + 1).sunriseH + 24 : sunriseH + const dark = !up && dial !== 'dark' + const moonRamp = dark + ? smooth((hourOfDay - dusk) / SUN_RAMP_H) * smooth((dawn - hourOfDay) / SUN_RAMP_H) + : 0 + const phase = moonPhase(new Date(at)) + const moonLight = moonRamp * phase.fraction * MOON_AT_FULL_OF_NIGHT return { hours, day, hourOfDay, times, sunZ: up ? Math.min(LENGTH_M, Math.max(0, progress * LENGTH_M)) : null, power, - watts: night + (DIALS[dial] - night) * power, + moonZ: dark ? Math.min(LENGTH_M, Math.max(0, (1 - (hourOfDay - dusk) / (dawn - dusk)) * LENGTH_M)) : null, + moonLight, moonRamp, moonPhase: phase, + watts: night * (1 + moonLight) + (DIALS[dial] - night) * power, dial, - at: instantOf(cal, day, hourOfDay), + at, } } diff --git a/test/celestial.test.ts b/test/celestial.test.ts index 312acb3..f1a88cc 100644 --- a/test/celestial.test.ts +++ b/test/celestial.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - STAR_FACE_SIZE, bakeStars, equatorial, skyFrame, sunLongitude, + STAR_FACE_SIZE, bakeStars, equatorial, moonPhase, skyFrame, sunLongitude, } from '../src/world/celestial.js' import catalog from '../src/data/bright-stars.json' @@ -102,3 +102,16 @@ describe('the star bake', () => { expect(lit[0]![3]).toBeCloseTo(-0.1, 6) }) }) + +describe("Earth's Moon's phase", () => { + it('is new, full and half at the moments the almanac says', () => { + expect(moonPhase(new Date('2000-01-06T18:14Z')).fraction).toBeLessThan(0.001) + expect(moonPhase(new Date('2024-04-23T23:49Z')).fraction).toBeGreaterThan(0.999) + const first = moonPhase(new Date('2024-04-15T19:13Z')) + expect(first.fraction).toBeCloseTo(0.5, 1) + expect(first.waxing).toBe(true) + const last = moonPhase(new Date('2024-05-01T11:27Z')) + expect(last.fraction).toBeCloseTo(0.5, 1) + expect(last.waxing).toBe(false) + }) +}) diff --git a/test/sky.test.ts b/test/sky.test.ts index e6ea285..05999f6 100644 --- a/test/sky.test.ts +++ b/test/sky.test.ts @@ -57,7 +57,8 @@ describe('the sun runs the length of the world, sunrise to sunset', () => { const night = skyAt(sydney, day * 24 + 23, 'temperate') expect(night.sunZ).toBeNull() expect(night.power).toBe(0) - expect(night.watts).toBeCloseTo(0.32, 2) + // The rod's 0.32 W/m², and the moon's share of that again, as lit as it is (D-088). + expect(night.watts).toBeCloseTo(0.32 * (1 + night.moonLight), 2) }) it('comes up and goes out over a quarter of an hour, not instantly', () => { @@ -106,3 +107,39 @@ describe('the clock opens at the time in Sydney now', () => { expect(hoursAt(sydney, new Date('2026-01-01T00:00:00Z'))).toBe(0) }) }) + +describe("Tower's moon (D-088)", () => { + const sunset = sunTimes(sydney, 0).sunsetH + const sunrise = sunTimes(sydney, 1).sunriseH + 24 + + it('is out by day, and with the beam shut down', () => { + const noon = skyAt(sydney, 12, 'full') + expect(noon.moonZ).toBeNull() + expect(noon.moonLight).toBe(0) + expect(skyAt(sydney, 23, 'dark').moonZ).toBeNull() + }) + + it('crosses from the far cap at sunset to the sun cap at sunrise', () => { + expect(skyAt(sydney, sunset + 0.01, 'full').moonZ!).toBeGreaterThan(LENGTH_M * 0.99) + expect(skyAt(sydney, (sunset + sunrise) / 2, 'full').moonZ!).toBeCloseTo(LENGTH_M / 2, -3) + expect(skyAt(sydney, sunrise - 0.01, 'full').moonZ!).toBeLessThan(LENGTH_M * 0.01) + // And after midnight it is the same night's moon, still going. + const late = skyAt(sydney, 24 + 2, 'full').moonZ! + expect(late).toBeLessThan(skyAt(sydney, 23, 'full').moonZ!) + }) + + it("is lit as Earth's Moon is lit, and brightens the night by it", () => { + // Full moon 26 Sep 2026 16:49 UTC — early on the 27th in Sydney, world day 4's night. + const full = skyAt(sydney, 3 * 24 + 23, 'full') + expect(full.moonPhase.fraction).toBeGreaterThan(0.97) + // New moon 11 Oct 2026: nothing to light. + const nu = skyAt(sydney, dayOfNew() * 24 + 23, 'full') + expect(nu.moonPhase.fraction).toBeLessThan(0.03) + expect(full.watts).toBeGreaterThan(nu.watts * 1.9) + }) +}) + +/** The world day of Sydney's new moon, 11 Oct 2026. */ +function dayOfNew(): number { + return Math.round((Date.parse('2026-10-11T00:00:00Z') - Date.parse(`${sydney.day0}T00:00:00Z`)) / 86_400_000) +} diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index a9c7473..fc6040e 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -292,6 +292,7 @@ const SHOTS = [ ['climb', 11, 'a giant airship climbing out of the Capital: the world curls up overhead'], ['sun-circuit', 8, 'a giant airship round the sun, outside the beam'], ['far-cap', 9, 'toward the far cap by night, the one sky that turns'], + ['moonlight', 6, 'a seaplane over The Deep by the light of Tower\'s moon'], ['train-narrows', 6, 'a train on the Axial Line through The Narrows, on its viaduct'], ['train-ring', 6, 'a train on the Uplands Ring, round the world'], ].map(([scene, t, caption]) => ({ diff --git a/world-generation.md b/world-generation.md index e78696f..319ee4d 100644 --- a/world-generation.md +++ b/world-generation.md @@ -1288,7 +1288,8 @@ dealt at random, with the camera pointed at whatever is worth seeing. opening on the craft the last one closed on. - **Controls**: *Scenic tour* in the interior's panel, and in the exterior's, starts and stops it; **N** skips to the next leg; **Esc**, a drag, the wheel or a flight key stops it where - it is. + it is, and so does switching to another view — a tour belongs to its view, and one left + running behind another kept its caption on screen under the new one's. - **The screenshot tour photographs the legs**, each held at one moment (`tower.showScene(id, t)` inside, `towerOutside.showScene(id, t)` outside) and planned from a fixed seed, so a leg is the same flight every run. A test checks that every leg the