tower / files / tools / browser.mjs
browser.mjs
3cc2b5bd 2026-10-04 Pavel Safronov | 1 | /** |
| 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 | */ | |
| 5 | import { 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 | */ | |
| 23 | export 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 | */ | |
| 38 | export 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 | */ | |
| 60 | export 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 | */ | |
| 79 | export 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. */ | |
| 94 | export 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 | } |