fuzzyworld

tower / commit

feat(clock): open at the time in Sydney now, and run

1d447a1a85a4fe24032efbc75bca040fc5170167 · Pavel Safronov · 2026-09-27 18:28:53 -0700 · parents 59c1282713a4

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 2 tally 1.4K in / 773.4K out / 240.6M cached · usage by commit and session

feat(clock): open at the time in Sydney now, and run

The clock now opens at 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, and the screenshot tour uses ?clock=12 to stay reproducible.

Also fixes the first version's saving: it was debounced in the change
handler, which a running clock reset every frame, so a changed speed was never
saved. Long durations read in hours.

Usage: 1.4K in / 773.4K out / 240.6M cached (session tally)
rtk:   906.2K saved, 15.2% over 2758 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 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