tower / commit
feat(clock): open at the time in Sydney now, and run
README.md | 6 ++++++ decisions.md | 5 +++++ design.md | 3 ++- src/ui/app.ts | 13 ++++++++++-- src/world/clock.ts | 55 ++++++++++++++++++++++++++------------------------- src/world/sky.ts | 16 +++++++++++++++ test/sky.test.ts | 14 ++++++++++++- tools/screenshots.mjs | 3 ++- 8 files changed, 83 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e6c02e8..1a7c88b 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,12 @@ since 50 km up and 540 km away it is still 5° high. The **beam** glows from the 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. +**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 +another, and 1x keeps it in step with Sydney. Add **`?clock=<hours>`** to the address to +hold it at a moment instead, stopped: `?clock=12` is noon on day 0. That is how the +screenshot tour gets the same sky every run. + **Time**, in the panel, works in every view: the **Day** (with its date in Sydney), the **Time** of day, **Run the clock** and its rate, and the **Light** — how bright Tower runs its day. It says when the sun rises and sets and where it is now, and the map marks it. diff --git a/decisions.md b/decisions.md index bd1a4b9..5adf437 100644 --- a/decisions.md +++ b/decisions.md @@ -2481,3 +2481,8 @@ morning where they are, the sun 60 km sunward and about 40° up. **The caps have more than one name.** The sun cap; the far cap, also the star cap. The author may use any of them, and more are added as they come into use. + +**The clock opens at the time in Sydney now** (author, 2026-09-27). It starts from Sydney's +current date and time, counted from day 0, and runs forward at the speed it was last run at; +the speed and the Light are remembered, the time is not. `?clock=<hours since day 0>` holds +it at a moment, stopped, which is what the screenshot tour uses to stay reproducible. diff --git a/design.md b/design.md index d5c2f86..20a2c22 100644 --- a/design.md +++ b/design.md @@ -272,7 +272,8 @@ physical offers one, since the spin is 7.5 minutes and has nothing to do with li **Tower keeps an Earth city's days: Sydney's.** Its clock is Sydney's, daylight saving included, and its sunrise and sunset are Sydney's, so the days lengthen and shorten with Sydney's seasons. Day 0 is the southern spring equinox, 23 September 2026. The -city and that date are the `calendar` in the world file, and can be changed there. +city and that date are the `calendar` in the world file, and can be changed there. The +app's clock opens at Sydney's time now and runs on at the speed it was last run at. **The sun crosses the world once a day.** It lights at the **sun cap** at sunrise, travels at a steady speed down the axis, and reaches the **far cap** at sunset, where diff --git a/src/ui/app.ts b/src/ui/app.ts index 039f5c3..5380163 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -17,7 +17,7 @@ import { TowerMap } from '../views/map.js' import type { ExteriorView } from '../views/exterior.js' import type { InteriorView } from '../views/interior.js' import { clock } from '../world/clock.js' -import { clockText, dateLabel, isDial, skyAt } from '../world/sky.js' +import { 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 { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js' @@ -92,7 +92,8 @@ const fmtCount = (n: number): string => { } const fmtDuration = (s: number): string => - s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s` + s >= 3600 ? `${+(s / 3600).toFixed(1)} h` + : s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s` export class App { private map: TowerMap @@ -122,6 +123,14 @@ export class App { onPick: (p, hit, shift) => this.onPick(p, hit, shift), onHover: (p) => this.onHover(p), }) + /** + * The clock opens at the calendar city's time now, and runs on at the speed it was last + * run at (D-074) — unless the page names a moment with `?clock=<hours since day 0>`, + * which holds it there, stopped. That is how the screenshot tour gets the same sky twice. + */ + const pinned = new URLSearchParams(location.search).get('clock') + const at = pinned !== null && pinned.trim() !== '' && Number.isFinite(Number(pinned)) ? Number(pinned) : null + clock.start(at ?? hoursAt(world.calendar, new Date()), at === null) this.buildViews() this.wireView() this.wirePanel() diff --git a/src/world/clock.ts b/src/world/clock.ts index 4b3a857..1711905 100644 --- a/src/world/clock.ts +++ b/src/world/clock.ts @@ -3,15 +3,14 @@ * * Hours count from the start of day 0 on the calendar city's clock (sky.ts). The weather * plan, the ships and the sun are all functions of it, and nothing else keeps time. It - * runs on its own animation frame while running, whichever view is open, and remembers - * where it was across reloads. + * runs on its own animation frame while running, whichever view is open. + * + * It opens at the calendar city's time now, running, at whatever speed it was last run + * at (author, 2026-09-27): the speed and the Light are remembered, the time is not. */ import { type Dial, isDial } from './sky.js' const KEY = 'tower.clock' -/** Where the interior view kept the time before there was a clock of its own. */ -const OLD_KEY = 'tower.interior' -const SAVE_MS = 400 type Listener = () => void @@ -26,7 +25,6 @@ export class WorldClock { private listeners = new Set<Listener>() private last = 0 private frame = 0 - private saveTimer: ReturnType<typeof setTimeout> | null = null constructor(private storage: Pick<Storage, 'getItem' | 'setItem'> | null = safeStorage()) { this.restore() @@ -60,11 +58,13 @@ export class WorldClock { setDial(dial: Dial): void { this.dial = dial + this.save() this.changed() } setRate(rate: number): void { this.rate = rate + this.save() this.changed() } @@ -91,37 +91,38 @@ export class WorldClock { private changed(): void { for (const fn of this.listeners) fn() - if (this.saveTimer) clearTimeout(this.saveTimer) - this.saveTimer = setTimeout(() => this.save(), SAVE_MS) } + /** + * Only what is remembered, and only when it changes. Saving from `changed` was a debounce + * that a running clock reset every frame, so it never fired and the speed was forgotten. + */ private save(): void { try { - this.storage?.setItem(KEY, JSON.stringify({ hours: this.hours, rate: this.rate, dial: this.dial })) + this.storage?.setItem(KEY, JSON.stringify({ rate: this.rate, dial: this.dial })) } catch { /* not remembering the time is survivable */ } } - /** Every field is checked, and a clock saved by the interior view is picked up. */ + /** + * The speed and the Light, every field checked. The time is deliberately not restored: + * the clock opens at the city's time now (`start`). + */ private restore(): void { try { - const num = (v: unknown, lo: number, hi: number): number | null => - typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null const raw = this.storage?.getItem(KEY) - if (raw) { - const s = JSON.parse(raw) as Record<string, unknown> - this.hours = num(s['hours'], 0, 1e7) ?? this.hours - this.rate = num(s['rate'], 1, 1e6) ?? this.rate - if (isDial(s['dial'])) this.dial = s['dial'] - return - } - const old = this.storage?.getItem(OLD_KEY) - if (!old) return - const s = JSON.parse(old) as Record<string, unknown> - const day = Math.floor(num(s['day'], 0, 1e6) ?? 0) - const phase = num(s['phase'], 0, 1) ?? 0.5 - this.hours = day * 24 + phase * 24 - if (isDial(s['lighting'])) this.dial = s['lighting'] - } catch { /* unreadable storage just means noon on day 0 */ } + if (!raw) return + const s = JSON.parse(raw) as Record<string, unknown> + const rate = s['rate'] + if (typeof rate === 'number' && Number.isFinite(rate) && rate >= 1 && rate <= 1e6) this.rate = rate + if (isDial(s['dial'])) this.dial = s['dial'] + } catch { /* unreadable storage just means the defaults */ } + } + + /** Where the clock opens: a moment, and whether it is already running. */ + start(hours: number, running: boolean): void { + this.hours = Math.max(0, hours) + this.setRunning(running) + this.changed() } } diff --git a/src/world/sky.ts b/src/world/sky.ts index c6bf602..f6daa04 100644 --- a/src/world/sky.ts +++ b/src/world/sky.ts @@ -160,6 +160,22 @@ export function skyAt(cal: Calendar, hours: number, dial: Dial): Sky { } } +/** + * Tower's time at a real instant: the calendar city's date and clock then, as hours since + * day 0 began (D-074). Before day 0 it is day 0's first moment — the calendar has no past. + */ +export function hoursAt(cal: Calendar, at: Date): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: cal.timeZone, hourCycle: 'h23', + year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', + }).formatToParts(at) + const get = (t: string) => Number(parts.find((p) => p.type === t)!.value) + const date = Date.UTC(get('year'), get('month') - 1, get('day')) + const days = Math.round((date - Date.parse(`${cal.day0}T00:00:00Z`)) / 86_400_000) + const hours = days * 24 + get('hour') + get('minute') / 60 + get('second') / 3600 + return Math.max(0, hours) +} + /** The hour on `day` at which the sun is over `z` metres from the sun cap. */ export function hourWhenSunAt(cal: Calendar, day: number, z: number): number { const { sunriseH, sunsetH } = sunTimes(cal, day) diff --git a/test/sky.test.ts b/test/sky.test.ts index f40afb8..e6ea285 100644 --- a/test/sky.test.ts +++ b/test/sky.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { LENGTH_M } from '../src/canon/constants.js' import { defaultCalendar } from '../src/world/schema.js' -import { dateLabel, hourWhenSunAt, skyAt, sunTimes, utcOffsetH } from '../src/world/sky.js' +import { dateLabel, hourWhenSunAt, hoursAt, skyAt, sunTimes, utcOffsetH } from '../src/world/sky.js' const sydney = defaultCalendar() /** The world day on which Sydney has this date (day 0 is 2026-09-23). */ @@ -94,3 +94,15 @@ describe('the calendar is in the world file', () => { expect(cal.latitude).toBe(-31.95) }) }) + +describe('the clock opens at the time in Sydney now', () => { + it('turns a real instant into Tower time', () => { + // 02:00 UTC on day 0 is noon in Sydney (AEST, +10). + expect(hoursAt(sydney, new Date('2026-09-23T02:00:00Z'))).toBeCloseTo(12) + // With daylight saving, 01:00 UTC on 21 Dec is noon AEDT, 89 days on. + expect(hoursAt(sydney, new Date('2026-12-21T01:00:00Z'))).toBeCloseTo(89 * 24 + 12) + }) + it('has no past: before day 0 it is day 0', () => { + expect(hoursAt(sydney, new Date('2026-01-01T00:00:00Z'))).toBe(0) + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index eff7759..301d11f 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -542,7 +542,8 @@ async function setTime(page, minutes) { * and all — was a laptop running hot for a minute. With the cache warmed once (below), * no shot compiles at all. */ -const TOUR_PARAMS = 'workers=2&cache=1' +// And the clock held at noon on day 0: left alone it opens at Sydney's time now, and runs. +const TOUR_PARAMS = 'workers=2&cache=1&clock=12' const searchFor = () => `?${TOUR_PARAMS}` /** The storage every shot's fresh context starts from: the warm cache, and nothing else. */ let warmStorage = undefined