fuzzyworld

tower / files / tools / browser.mjs

browser.mjs

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

3cc2b5bd 2026-10-04 Pavel Safronov1/**
2 * What the tools that drive the app in a headless browser share: the screenshot tour
3 * (screenshots.mjs) and the photographs (photos.mjs).
4 */
5import { chromium } from 'playwright'
6
7/**
8 * Launch the browser that will do the drawing.
9 *
10 * `channel: 'chromium'` is the full Chrome for Testing build, which reaches the
11 * real GPU; the default headless shell falls back to SwiftShader and renders the
12 * interior's haze integral on the CPU. That is not a small difference — one
13 * interior shot measured **87.3 s on SwiftShader against 6.4 s on the GPU**, and
14 * the 180° turn inside it went from 77.7 s to 2.6 s, because every mouse move
15 * during a drag waits on a frame.
16 *
17 * The cost is that the baseline is now tied to this machine's GPU rather than to a
18 * software renderer that would give the same pixels anywhere. Nothing runs this in
19 * CI, so that is a fair trade — but it is why `--check` compares against images a
20 * particular machine produced, and why a different one will show a large one-off
21 * divergence rather than a regression.
22 */
23export async function launch() {
24 try {
25 return await chromium.launch({ channel: 'chromium' })
26 } catch {
27 console.log(' (no Chrome for Testing build; falling back to the software renderer)')
28 return await chromium.launch()
29 }
30}
31
32/**
33 * Compile the world once, in a page of its own, and return the storage it leaves behind —
34 * the IndexedDB holding the compiled world — for every later context to start from, so
35 * none of them compiles at all. localStorage is emptied first: it holds the view and the
36 * camera, and a page inheriting those would stop being reproducible.
37 */
38export async function warmWorld(browser, base, params) {
39 const t0 = Date.now()
40 const context = await browser.newContext()
41 const page = await context.newPage()
42 await page.goto(`${base}/?${params}`, { waitUntil: 'networkidle' })
43 await page.waitForFunction(() => window.towerTerrain && !window.towerTerrain.progress.busy
44 && window.towerTerrain.compiled, null, { timeout: 120_000, polling: 200 })
45 // saveCompiled is not awaited by the app; give its transaction a moment to land.
46 await page.waitForTimeout(500)
47 await page.evaluate(() => localStorage.clear())
48 const storage = await context.storageState({ indexedDB: true })
49 await context.close()
50 console.log(` world compiled once ${((Date.now() - t0) / 1000).toFixed(1)}s`)
51 return storage
52}
53
54/**
55 * Insurance rather than a live need: the rain that used to be the world's one
56 * randomised thing is gone (D-049), and the star field is hashed in GLSL rather
57 * than sampled. If anything stochastic comes back, the contact sheet stays
58 * comparable between runs without anyone having to remember why.
59 */
60export async function seedRandom(context) {
61 await context.addInitScript(() => {
62 let seed = 0x2f6e2b1
63 Math.random = () => {
64 seed = (seed * 1664525 + 1013904223) >>> 0
65 return seed / 0x100000000
66 }
67 })
68}
69
70/**
71 * Wait until the generated world is all there: compiled, the map drawn, and every
72 * tile of ground the open view wants delivered (world-generation.md §5). Terrain is
73 * made in the browser now, so a fixed delay would photograph the coarse tiles on a
74 * fast machine and a half-built world on a slow one.
75 *
76 * Checked twice with frames between, because the pool can be idle for a moment
77 * between one frame asking for tiles and the next asking for their children.
78 */
79export async function terrainReady(page) {
80 for (let round = 0; round < 2; round++) {
81 await page.waitForFunction(() => {
82 const t = window.towerTerrain
83 if (!t || t.progress.busy || !t.mapCanvas || t.inFlight > 0) return false
84 const interiorOpen = !document.getElementById('interior').classList.contains('hidden')
85 return !interiorOpen || window.tower?.settled === true
86 }, null, { timeout: 120_000, polling: 100 })
87 await page.evaluate(async () => {
88 for (let i = 0; i < 10; i++) await new Promise((r) => requestAnimationFrame(() => r(null)))
89 })
90 }
91}
92
93/** Flush a few animation frames, then hold still, so a settling view has settled. */
94export async function settle(page, ms = 900) {
95 await terrainReady(page)
96 await page.evaluate(async () => {
97 for (let i = 0; i < 20; i++) await new Promise((r) => requestAnimationFrame(() => r(null)))
98 })
99 await page.waitForTimeout(ms)
100}