tower / files / tools / browser.mjs
browser.mjs
| 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 | } |