fuzzyworld

tower / files / src / terrain / service.ts

service.ts

Blame: the commit that last changed each line · file · history

376f2ce6 2026-09-16 Pavel Safronov1/**
2 * The terrain service: the main thread's handle on world generation.
3 * See world-generation.md §5.
4 *
5 * Owns the worker pool. When the world changes in a way that changes its terrain, it
6 * has the first worker compile it — step by step, for the status dialog — hands the
7 * result to every worker, renders the map from it, and tells the views. After that
8 * the views ask it for tiles, and the observer asks it where the ground is.
9 *
10 * There is one of these for the whole app. The map, both 3D views and the panel all
11 * see the same world at the same generation.
12 */
13import { type Remote, proxy, wrap } from 'comlink'
14import { mapThetaOrigin } from '../canon/geometry.js'
15import { type World, terrainSignature } from '../world/schema.js'
80bcc97a 2026-09-17 Pavel Safronov16import { cacheKey, loadCompiled, saveCompiled } from './cache.js'
376f2ce6 2026-09-16 Pavel Safronov17import type { CompiledWorld } from './compile.js'
18import { type Runtime, prepare } from './field.js'
19import type { TileData, TileGrid, TileKey } from './tile.js'
20import type { WorkerApi } from './worker.js'
3413c443 2026-09-19 Pavel Safronov21/**
22 * The worker, bundled as a worker, and its URL. This must go through Vite's `?worker`
23 * import: a bare `new URL('./worker.ts', import.meta.url)` held in a variable is not
24 * recognised as a worker, so the build copied worker.ts raw as an asset — inlined as a
25 * `data:video/mp2t` URL, since `.ts` is also the MPEG transport stream extension. The
26 * worker failed to start, nothing listened for that, and the first compile call waited
27 * forever on "Reading the plans". Dev served the file transformed, so only builds broke.
28 */
29import workerUrl from './worker.ts?worker&url'
376f2ce6 2026-09-16 Pavel Safronov30
31/** The map image, matching v1's raster so the map reads at the same resolution. */
32export const MAP_WIDTH = 2048
33export const MAP_HEIGHT = 1192
34
35export interface Progress {
36 busy: boolean
37 /** The line the status dialog shows. */
38 label: string
39 /** Steps finished, and how many there are in all. */
40 done: number
41 total: number
42 /** When this generation started, performance.now(). */
43 startedAt: number
44}
45
46type Listener = () => void
47
48interface Slot {
7df218ae 2026-09-28 Pavel Safronov49 worker: Worker
376f2ce6 2026-09-16 Pavel Safronov50 api: Remote<WorkerApi>
51 inFlight: number
e18dad04 2026-09-30 Pavel Safronov52 /**
53 * Rejects the moment the worker reports an error — for a worker still starting, that its
54 * script did not load. A comlink call to such a worker never settles, so the start-up
55 * ping is raced against this rather than waiting out its deadline (D-092).
56 */
57 failed: Promise<never>
376f2ce6 2026-09-16 Pavel Safronov58}
59
7df218ae 2026-09-28 Pavel Safronov60/**
61 * How long a worker gets to load its code and answer a ping, and to take a compiled world.
62 * Generous: in dev a worker loads its modules one by one, and a slow machine is not a dead
63 * worker. A dead one is not slow — it never answers at all.
64 */
65const WORKER_START_MS = 15_000
66const WORKER_LOAD_MS = 30_000
e18dad04 2026-09-30 Pavel Safronov67/**
68 * How many times a worker that fails to start is replaced before the pool does without it,
69 * and how long to wait before each try. A script caught mid-rebuild by the dev server comes
70 * back empty; a moment later it is whole (D-092).
71 */
72const WORKER_TRIES = 4
73const WORKER_RETRY_MS = 400
7df218ae 2026-09-28 Pavel Safronov74
75class Timeout extends Error {}
76
77/** A promise that rejects if `p` has not settled in `ms`. */
78function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
79 let timer: ReturnType<typeof setTimeout>
80 const late = new Promise<never>((_, reject) => {
81 timer = setTimeout(() => reject(new Timeout(`${what}: no answer in ${ms / 1000} s`)), ms)
82 })
83 return Promise.race([p, late]).finally(() => clearTimeout(timer))
84}
85
376f2ce6 2026-09-16 Pavel Safronov86export class TerrainService {
87 private slots: Slot[]
88 private signature = ''
e7a9f9dd 2026-09-17 Pavel Safronov89 /** A compile is running; edits made meanwhile wait here, and only the latest is kept. */
90 private compiling = false
91 private pending: World | null = null
376f2ce6 2026-09-16 Pavel Safronov92 /** Bumped on every terrain change. Anything built for an older one is thrown away. */
93 generation = 0
94 compiled: CompiledWorld | null = null
a088e5d5 2026-09-17 Pavel Safronov95 /** The latest world handed in, compiled or not yet. */
96 world: World | null = null
376f2ce6 2026-09-16 Pavel Safronov97 private runtime: Runtime | null = null
98 /** The map image of the current generation, or null until it is drawn. */
99 mapCanvas: HTMLCanvasElement | null = null
100 progress: Progress = { busy: false, label: '', done: 0, total: 1, startedAt: 0 }
101
102 private worldListeners = new Set<Listener>()
103 private mapListeners = new Set<Listener>()
104 private progressListeners = new Set<Listener>()
105
80bcc97a 2026-09-17 Pavel Safronov106 /** The compiled world was read from the cache last time rather than compiled. */
107 lastFromCache = false
3413c443 2026-09-19 Pavel Safronov108 private readonly workerUrl = new URL(workerUrl, import.meta.url)
80bcc97a 2026-09-17 Pavel Safronov109 /**
110 * Whether the compiled world may be cached. In a build the worker's URL carries a hash
111 * of its code, so a new build can never read an old build's world. In dev the URL never
112 * changes while the code does, so the cache is off there — unless a page asks for it
113 * with `?cache=1`, as the screenshot tour does from a browser it starts fresh.
114 */
115 private readonly caching: boolean
116
376f2ce6 2026-09-16 Pavel Safronov117 constructor() {
80bcc97a 2026-09-17 Pavel Safronov118 const params = new URLSearchParams(location.search)
376f2ce6 2026-09-16 Pavel Safronov119 const cores = navigator.hardwareConcurrency || 4
80bcc97a 2026-09-17 Pavel Safronov120 /**
121 * `?workers=N` caps the pool. The screenshot tour runs several pages at once, and a
122 * full pool in each is two dozen threads fighting for the same cores.
123 */
124 const cap = Number(params.get('workers'))
125 const size = Math.max(1, Math.min(Number.isFinite(cap) && cap > 0 ? cap : 6, cores - 1))
7df218ae 2026-09-28 Pavel Safronov126 this.slots = Array.from({ length: size }, (_, i) => this.spawn(i))
80bcc97a 2026-09-17 Pavel Safronov127 this.caching = !import.meta.env.DEV || params.get('cache') === '1'
376f2ce6 2026-09-16 Pavel Safronov128 }
129
7df218ae 2026-09-28 Pavel Safronov130 private spawn(index: number): Slot {
131 const worker = new Worker(this.workerUrl, { type: 'module' })
132 // A worker whose script fails to load or throws says so here and nowhere else.
e18dad04 2026-09-30 Pavel Safronov133 const failed = new Promise<never>((_, reject) => {
134 worker.addEventListener('error', (e) => {
135 console.warn(`Terrain worker ${index} reported an error:`, e.message || 'its script did not load')
136 reject(new Error(`Terrain worker ${index} failed to start`))
137 })
7df218ae 2026-09-28 Pavel Safronov138 })
e18dad04 2026-09-30 Pavel Safronov139 // Nobody may be waiting on it by the time a live worker throws: that is logged above.
140 failed.catch(() => {})
141 return { worker, api: wrap<WorkerApi>(worker), inFlight: 0, failed }
7df218ae 2026-09-28 Pavel Safronov142 }
143
144 /**
145 * Make sure every worker in the pool is alive, before the world is handed to them.
146 *
147 * The page used to wait on all of them with no limit, so one worker that never started
148 * froze the world on the compile's last step — "Teaching Eastwall Water which way is
149 * down" — with nothing in the console but Firefox's "Attempting to create a Worker from
e18dad04 2026-09-30 Pavel Safronov150 * an empty source" (D-080). Then a silent worker was given 15 s before it was replaced,
151 * which was the freeze the author still saw "every so often" (D-092).
152 *
153 * Now a worker is replaced as soon as it is known to be dead, and tried up to
154 * `WORKER_TRIES` times. Dead shows two ways. A script that throws, or fails to load,
155 * says so in the worker's `error` event. An **empty** script says nothing at all — Firefox
156 * starts it, it runs nothing, and it never answers — so it is judged against its siblings:
157 * they load the same modules at the same time, so once one of them has answered, a
158 * worker still silent a few seconds later is not slow but empty. Only a pool with no
159 * worker alive at all waits out the full `WORKER_START_MS`. The pool may end smaller, but
160 * the world loads; an empty pool is the one case that is an error.
7df218ae 2026-09-28 Pavel Safronov161 */
162 private async ensureWorkers(): Promise<void> {
e18dad04 2026-09-30 Pavel Safronov163 const began = performance.now()
164 let firstAnswer: (ms: number) => void
165 const answered = new Promise<number>((resolve) => { firstAnswer = resolve })
166 // Once a sibling has answered: its own load time again, and never less than 3 s.
167 const siblingDeadline = (): Promise<never> => answered.then((ms) =>
168 new Promise<never>((_, reject) => setTimeout(
169 () => reject(new Error('silent after its siblings answered: an empty script')),
170 Math.max(3000, ms))))
171 const alive = await Promise.all(this.slots.map(async (first, i) => {
172 let slot = first
173 for (let attempt = 1; ; attempt++) {
7df218ae 2026-09-28 Pavel Safronov174 try {
e18dad04 2026-09-30 Pavel Safronov175 await within(Promise.race([
176 slot.api.ping().then(() => firstAnswer(performance.now() - began)),
177 slot.failed,
178 siblingDeadline(),
179 ]), WORKER_START_MS, `Terrain worker ${i}`)
180 if (attempt > 1) console.info(`Terrain worker ${i} started on try ${attempt}.`)
181 return slot
182 } catch (e) {
183 slot.worker.terminate()
184 if (attempt >= WORKER_TRIES) {
185 console.error(`${(e as Error).message}, ${attempt} times — carrying on without it.`)
186 return null
187 }
188 console.warn(`Terrain worker ${i}: ${(e as Error).message} — replacing it.`)
189 await new Promise((r) => setTimeout(r, WORKER_RETRY_MS * attempt))
190 slot = this.spawn(i)
7df218ae 2026-09-28 Pavel Safronov191 }
192 }
193 }))
194 this.slots = alive.filter((s): s is Slot => s !== null)
195 if (!this.slots.length) throw new Error('No terrain worker started; the world cannot be made.')
196 }
197
376f2ce6 2026-09-16 Pavel Safronov198 get workers(): number {
199 return this.slots.length
200 }
201
202 /** Called when a new generation is ready for tiles. */
203 onWorld(fn: Listener): () => void {
204 this.worldListeners.add(fn)
205 return () => this.worldListeners.delete(fn)
206 }
207
208 onMap(fn: Listener): () => void {
209 this.mapListeners.add(fn)
210 return () => this.mapListeners.delete(fn)
211 }
212
213 onProgress(fn: Listener): () => void {
214 this.progressListeners.add(fn)
215 return () => this.progressListeners.delete(fn)
216 }
217
218 private setProgress(p: Partial<Progress>): void {
219 this.progress = { ...this.progress, ...p }
220 for (const fn of this.progressListeners) fn()
221 }
222
223 /**
224 * Generate the world, if its terrain has changed. A rename, a blurb or a note does
225 * not change the terrain and does nothing here (schema.ts `terrainSignature`).
226 */
227 async setWorld(world: World): Promise<void> {
a088e5d5 2026-09-17 Pavel Safronov228 // Kept whether or not the terrain changed: the weather, winds and ships are read off
229 // the latest world every frame, and none of them needs a compile.
230 this.world = world
376f2ce6 2026-09-16 Pavel Safronov231 const signature = terrainSignature(world)
232 if (signature === this.signature) return
e7a9f9dd 2026-09-17 Pavel Safronov233 /**
234 * One compile at a time, and edits made during one collapse into the next. Dragging a
235 * summit updates the world on every pointer move; each of those starting a compile of
236 * a few seconds would queue minutes of work that is stale before it starts. The world
237 * is copied because the store edits it in place.
238 */
239 if (this.compiling) {
240 this.pending = structuredClone(world)
241 return
242 }
376f2ce6 2026-09-16 Pavel Safronov243 this.signature = signature
e7a9f9dd 2026-09-17 Pavel Safronov244 this.compiling = true
245 try {
246 await this.generate(world)
247 } finally {
248 this.compiling = false
249 }
250 const next = this.pending
251 this.pending = null
252 if (next) await this.setWorld(next)
253 }
254
255 private async generate(world: World): Promise<void> {
376f2ce6 2026-09-16 Pavel Safronov256 const gen = ++this.generation
257
258 // Every compile step, plus drawing the map.
259 const counted = world.features.filter((f) =>
abc0aad0 2026-10-01 Pavel Safronov260 ['desert', 'forest', 'farmland', 'mountains', 'lake', 'sea', 'river'].includes(f.kind)
14d35b7d 2026-09-16 Pavel Safronov261 || (f.kind === 'region' && f.upliftM)).length
e7a9f9dd 2026-09-17 Pavel Safronov262 // Mountain ranges add an erosion step per group; counting one per range is an upper bound.
263 const ranges = world.features.filter((f) => f.kind === 'mountains').length
51c39d8b 2026-09-17 Pavel Safronov264 // A desert names three steps of its own: its rock, its washes and its salt (desert.ts).
265 const deserts = world.features.filter((f) => f.kind === 'desert').length
266 const total = 1 + counted + ranges + 2 * deserts + 1
376f2ce6 2026-09-16 Pavel Safronov267 let done = 0
268 this.setProgress({ busy: true, label: 'Reading the plans', done: 0, total, startedAt: performance.now() })
269
7df218ae 2026-09-28 Pavel Safronov270 await this.ensureWorkers()
80bcc97a 2026-09-17 Pavel Safronov271 const key = cacheKey(`${this.workerUrl.href}\n${terrainSignature(world)}`)
272 let compiled = this.caching ? await loadCompiled(key) : null
273 this.lastFromCache = compiled !== null
274 if (!compiled) {
275 compiled = await this.slots[0]!.api.compile(world, proxy((label: string) => {
276 this.setProgress({ label, done: done++ })
277 }))
278 if (this.caching) void saveCompiled(key, compiled)
279 }
376f2ce6 2026-09-16 Pavel Safronov280
7df218ae 2026-09-28 Pavel Safronov281 // Each worker gets the world with a deadline; one that misses it leaves the pool rather
282 // than holding up the rest (D-080).
283 const loaded = await Promise.all(this.slots.map(async (s, i) => {
284 try {
285 await within(s.api.load(compiled, gen), WORKER_LOAD_MS, `Terrain worker ${i} taking the world`)
286 return s
287 } catch (e) {
288 console.error(`${(e as Error).message} — carrying on without it.`)
289 s.worker.terminate()
290 return null
291 }
292 }))
293 this.slots = loaded.filter((s): s is Slot => s !== null)
294 if (!this.slots.length) throw new Error('No terrain worker took the world; it cannot be shown.')
376f2ce6 2026-09-16 Pavel Safronov295 this.compiled = compiled
296 this.runtime = prepare(compiled)
297 for (const fn of this.worldListeners) fn()
298
299 this.setProgress({ label: 'Drawing the map', done: total - 1 })
300 const canvas = await this.renderMap(gen)
e7a9f9dd 2026-09-17 Pavel Safronov301 if (canvas) {
302 this.mapCanvas = canvas
303 for (const fn of this.mapListeners) fn()
304 }
305 // Still busy if an edit arrived meanwhile: its compile starts as this one returns.
306 this.setProgress({ busy: this.pending !== null, done: total })
376f2ce6 2026-09-16 Pavel Safronov307 }
308
309 /** The map image, rendered in bands across the whole pool. */
310 private async renderMap(gen: number): Promise<HTMLCanvasElement | null> {
311 const bands = this.slots.length * 4
312 const per = Math.ceil(MAP_HEIGHT / bands)
313 const origin = mapThetaOrigin()
314 const jobs: Promise<{ y0: number; rows: Uint8ClampedArray | null }>[] = []
315 for (let b = 0; b < bands; b++) {
316 const y0 = b * per
317 const y1 = Math.min(MAP_HEIGHT, y0 + per)
318 if (y0 >= y1) break
319 jobs.push(this.run((api) => api.mapRows(gen, MAP_WIDTH, MAP_HEIGHT, y0, y1, origin))
320 .then((rows) => ({ y0, rows })))
321 }
322 const parts = await Promise.all(jobs)
323 if (gen !== this.generation || parts.some((p) => !p.rows)) return null
324 const canvas = document.createElement('canvas')
325 canvas.width = MAP_WIDTH
326 canvas.height = MAP_HEIGHT
327 const ctx = canvas.getContext('2d')
328 if (!ctx) return null
329 for (const { y0, rows } of parts) {
330 ctx.putImageData(new ImageData(rows as Uint8ClampedArray<ArrayBuffer>, MAP_WIDTH), 0, y0)
331 }
332 return canvas
333 }
334
335 /** Run a job on the least busy worker. */
336 private async run<T>(job: (api: Remote<WorkerApi>) => Promise<T>): Promise<T> {
337 const slot = this.slots.reduce((a, b) => (b.inFlight < a.inFlight ? b : a))
338 slot.inFlight++
339 try {
340 return await job(slot.api)
341 } finally {
342 slot.inFlight--
343 }
344 }
345
346 /** How many jobs are queued or running across the pool. */
347 get inFlight(): number {
348 return this.slots.reduce((n, s) => n + s.inFlight, 0)
349 }
350
351 /** A tile of the current generation, or null if the world changed before it was built. */
352 tile(t: TileKey, n: TileGrid): Promise<TileData | null> {
353 const gen = this.generation
354 return this.run((api) => api.tile(gen, t, n))
355 }
356
357 /**
358 * The surface under a point, in metres: the ground, or the water standing on it.
359 * Full detail, since this is where somebody's feet are. Zero until the first world
360 * has compiled, which is standing on the datum.
361 */
362 surfaceM(theta: number, z: number): number {
363 if (!this.runtime) return 0
364 const s = this.runtime.sample(theta, z, 1)
365 return Math.max(s.h, s.water)
366 }
00ceeedb 2026-09-27 Pavel Safronov367
368 /**
369 * What the ground is at a point, for the things that stand on it: the trees near the eye
370 * (views/forest-view.ts) ask this on a grid and then place themselves between its nodes.
371 * `detailM` is the finest wavelength worth computing, as a tile's would be.
372 */
373 groundAt(theta: number, z: number, detailM = 8): GroundAt | null {
374 if (!this.runtime) return null
375 const s = this.runtime.sample(theta, z, detailM)
376 return {
377 h: s.h, water: s.water > s.h, stems: s.stems, canopyM: s.canopyM,
e18dad04 2026-09-30 Pavel Safronov378 desert: s.desert, rock: s.rock, glacier: s.glacier > 0,
00ceeedb 2026-09-27 Pavel Safronov379 }
380 }
381}
382
383/** The ground as anything standing on it needs to know it. */
384export interface GroundAt {
385 h: number
386 water: boolean
387 stems: number
388 canopyM: number
389 desert: number
390 rock: number
e18dad04 2026-09-30 Pavel Safronov391 /** Grounded sea ice: the ice wall at the cap (D-093). */
392 glacier: boolean
376f2ce6 2026-09-16 Pavel Safronov393}
394
395let instance: TerrainService | null = null
396
397/** The one service. Built on first use, so nothing pays for workers it never asked for. */
398export function terrain(): TerrainService {
399 if (!instance) {
400 instance = new TerrainService()
401 // A handle for the console, beside the interior's `tower` and `towerDump` (D-058):
402 // what generation the world is at, and what the pool is doing.
403 ;(window as unknown as Record<string, unknown>)['towerTerrain'] = instance
404 }
405 return instance
406}