/** * Render the built app and write a PNG you can open and judge. * * The defects this exists for cannot be asserted. "The ocean has no specular * response", "the board ends in a hard diamond edge", "the aircraft are * illegible at board scale" are all true of code that typechecks, passes every * test and meets every performance budget. The only instrument that finds them * is a picture, so this makes taking one cheap. * * node scripts/look.mjs [--url ] [--phone] [--at ] * [--wait ] [--click ] [--api ] * [--chapter ] [--expect ] * * `--chapter` aims at a chapter by its **identity** — the `data-view` attribute * the button carries, which is the chapter's id in the city pack — and not by * its position in the list or by the text printed on it. Both of the other two * have already produced a photograph of somewhere else in this repo: an index * because a pack was reordered under it, and button text because two of * California's six chapters are *doors* whose labels ("LA", "SF") match a * click and then leave the board entirely. `--expect` asserts the printed short * label as a second, independent check on the same button, which is what * `scripts/brand-assets/shots.mjs` does and is worth keeping. * * Writes /tmp/tera-look/.png. `--at` pins the clock, because the sun's * position is computed from the real one and a shot taken at 03:00 tells you * nothing about how the water reads at noon. * * This box has an AMD card and no monitor; the ANGLE/Vulkan flags below are what * make Chrome render headless here rather than falling back to a blank canvas. */ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { chromium } from "playwright"; const args = process.argv.slice(2); const name = args[0] ?? "look"; /** * The frames this project keeps taking, by name. * * `node scripts/look.mjs sf-night` and nothing else — no query string to * remember and no timestamp to get wrong. The point is not convenience, it is * that two people asking for "the night board" get the same photograph: an * acceptance criterion that reads "shoot sf-night and look at it" is only worth * writing if `sf-night` means one thing. * * Every field is a default. An explicit flag on the command line still wins, so * a preset is a starting point rather than a cage, and adding one is two lines. */ const PRESETS = { // The fire boards. California is the one with fires on it on a normal day; // SoCal is the one that must be *empty* and say so — see ARCHITECTURE.md §9.2. "fires-california": { url: "/?city=california" }, "fires-socal": { url: "/?city=socal" }, // The LA studio's upper floor. The door lands on the SF studio, so the shot // switches rooms and waits for the swap. "mateo-loft": { url: "/?city=socal&view=office", click: "LA HQ" }, // Night. 04:35Z is 21:35 in Los Angeles: full dark, and well clear of both // twilight edges, so a shot taken a minute late is the same shot. "sf-night": { url: "/?city=sf", at: "2026-08-23T04:35:00Z" }, "sky-night": { url: "/?city=california", at: "2026-08-23T04:35:00Z" }, "socal-night": { url: "/?city=socal", at: "2026-08-23T04:35:00Z" }, /* * The aeroplane glyph, at the four stand-offs its clamp has to serve. * * TWO OF THESE WERE PHOTOGRAPHS OF SOMEWHERE ELSE, AND THE SET AS A WHOLE WAS * DESCRIBED WRONG. The old comment read "a whole board, two chapter closeups * on that board, and a detailed metro", and `glyph-la` / `glyph-sf` were * `click: "^LA$"` / `click: "^SF$"` — the California board's chapters 04 and * 05. Neither of those is a camera pose. Both are matched against * `CALIFORNIA_DESTINATIONS` in `main.ts` and call `switchCity()` rather than * `flyTo()`, so clicking them left California entirely: `glyph-la` returned a * SoCal frame and `glyph-sf` a Bay Area one, byte-comparable to `hero-socal` * and `hero-sf`. Two of the four stand-offs the set claimed to cover were * duplicates of two others. * * Photographing the fix turned up the rest of it. California's six chapters * are one whole-board pose, TWO DRIVES (`la-sf-us-101` and `la-sf-i-5` call * `requestControlMode("drive")` at `main.ts:4493`, so clicking either lands a * chase camera on a freeway, not a stand-off), two doors, and the north. The * state board therefore has exactly TWO aerial poses on it — `State` and * `North` — and "two chapter closeups on that board" was never available. * * So the clamp's four stand-offs are spread across the boards that have them, * which is a better ladder anyway: three orders of magnitude of stand-off, * every rung an aerial camera, every rung aimed by `data-view`. */ "glyph-board": { url: "/?city=california", chapter: "california-overview", expect: "State" }, "glyph-north": { url: "/?city=california", chapter: "shasta-cascades", expect: "North" }, "glyph-bay": { url: "/?city=sf", chapter: "bay-area", expect: "The Bay" }, "glyph-fidi": { url: "/?city=sf", chapter: "fidi", expect: "FiDi" }, /* * The corridor, which is a drive and is photographed as one. * * These are what `glyph-la` and `glyph-sf` now resolve to: the two California * chapters that stay on the California board. They are named for what they * are rather than for the glyph, because the frame they produce is a chase * camera behind the EV and no aeroplane is in it. */ "corridor-101": { url: "/?city=california", chapter: "la-sf-us-101", expect: "101" }, "corridor-i5": { url: "/?city=california", chapter: "la-sf-i-5", expect: "I-5" }, // The opening move, landed. `--reduced` collapses it to a cut, which is what // makes an arrival frame reproducible. "hero-california": { url: "/?city=california" }, "hero-socal": { url: "/?city=socal" }, "hero-sf": { url: "/?city=sf" }, }; /** * Old names that still have to answer, pointed at what they always meant. * * Deleting them would be tidier and worse: `glyph-la` appears in the write-ups * that commissioned these frames, and a name that 404s sends somebody back to * `--click "^LA$"`, which is the exact command that produced the wrong picture. * An alias that says out loud what it resolved to cannot do that. */ const ALIASES = { "glyph-la": "corridor-101", "glyph-sf": "corridor-i5", }; const resolved = ALIASES[name] ?? name; if (resolved !== name) { console.log( `look: "${name}" is an alias for "${resolved}". The California board's LA and SF chapters ` + `are doors into the metro boards, not camera poses, so a preset named for them used to ` + `photograph the other board. The two chapters that stay on California are the corridor ` + `legs, and both open in DRIVE mode — for the aeroplane glyph's aerial stand-offs use ` + `glyph-board, glyph-north, glyph-bay, glyph-fidi.`, ); } const preset = PRESETS[resolved] ?? {}; const flag = (f, d) => { const i = args.indexOf(f); if (i !== -1) return args[i + 1]; return preset[f.replace(/^--/, "")] ?? d; }; const has = (f) => args.includes(f); const OUT = "/tmp/tera-look"; mkdirSync(OUT, { recursive: true }); /** * The port is asked for, not guessed, and the build is then verified. * * This used to draw a random port in 4700–4899 and start `vite preview * --strictPort` on it. When the draw collided with an abandoned preview from an * earlier run — and this box accumulated a hundred and forty-seven of them in * one afternoon — the new preview exited on the strict-port check, `page.goto` * succeeded against the squatter, and Playwright silently photographed somebody * else's dist. Three shots came back byte-identical while the bundle under them * provably changed. A screenshot tool that can photograph the wrong build is * worse than no screenshot tool, because you believe it. * * So: take a port from the kernel rather than from `Math.random`, then read the * URL the preview actually bound out of its own stdout, then fetch `/` and * assert it is the `dist/index.html` sitting on this disk. Any one of the three * would have caught it; all three cost nothing. */ const PORT = await new Promise((resolve, reject) => { const probe = createServer(); probe.once("error", reject); probe.listen(0, "127.0.0.1", () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); }); // `detached`, and vite's own binary rather than `npx`, because of the *other* // half of the orphan story: `npx` is a wrapper, `server.kill()` killed only the // wrapper, and the preview it had spawned went on holding its port forever. A // hundred and forty-seven of them accumulated in one afternoon. Detached gives // the pair a process group, and killing the group at the end kills both. const server = spawn( new URL("../node_modules/.bin/vite", import.meta.url).pathname, ["preview", "--port", String(PORT), "--strictPort"], { detached: true, stdio: ["ignore", "pipe", "pipe"] }, ); const shutdown = () => { try { process.kill(-server.pid, "SIGTERM"); } catch { /* already gone */ } }; process.on("exit", shutdown); const bound = await new Promise((resolve) => { let seen = ""; const settle = setTimeout(() => resolve(null), 30000); const read = (chunk) => { seen += String(chunk); const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen); if (match) { clearTimeout(settle); resolve(Number(match[1])); } }; server.stdout.on("data", read); server.stderr.on("data", read); }); if (bound === null) { console.error(`look: vite preview never announced a URL on ${PORT}; is dist/ built?`); shutdown(); process.exit(1); } if (bound !== PORT) { console.error(`look: preview bound ${bound}, not ${PORT} — refusing to photograph it`); shutdown(); process.exit(1); } // And the served page is this checkout's build, hashed script tag and all. { const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text()); const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8"); const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null; if (bundle(served) === null || bundle(served) !== bundle(onDisk)) { console.error( `look: :${PORT} is serving ${bundle(served)}, dist/ holds ${bundle(onDisk)} — ` + `something else owns that port. Not photographing it.`, ); shutdown(); process.exit(1); } } const phone = has("--phone"); const browser = await chromium.launch({ channel: "chrome", args: [ "--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", ], }); /** * `--reduced` asks the page for `prefers-reduced-motion: reduce`. * * Which is not only an accessibility check. The opening arrival — `arrive()` in * `scene.ts`, and `beginOfficeArrival` in `main.ts` — collapses to a cut under * this preference, so a shot taken with it is the resting frame and nothing * else, whatever the machine's load did to the four and a half seconds before * it. Without it a slow build and a fast one photograph different cameras. */ const context = await browser.newContext({ viewport: phone ? { width: 390, height: 844 } : { width: 1600, height: 1000 }, deviceScaleFactor: 2, timezoneId: "America/Los_Angeles", ...(has("--reduced") ? { reducedMotion: "reduce" } : {}), ...(phone ? { isMobile: true, hasTouch: true, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", } : {}), }); await context.clock.setFixedTime(new Date(flag("--at", "2026-08-21T20:00:00Z"))); const page = await context.newPage(); /** * `--api http://127.0.0.1:8431` photographs the dist against a real server. * * `vite preview` serves the static build and nothing else, so without this every * frame is the **keyless** experience: `/api/v1/health` 404s, `access` resolves * with no `feeds` at all, and the weather, the aircraft, the satellites and the * fires are all off. That is the right default — CONTRACT §0's stranger is * exactly that visitor, and most frames should be shot as they see them — but it * makes the one question a fire board exists to answer unphotographable. * * Proxied through Playwright rather than through a Vite config, for two reasons. * The build is not modified, so what is photographed is byte-identical to what * ships; and the response is *fulfilled* rather than redirected, so the page * sees a same-origin answer and no CORS header has to exist on the API for a * screenshot to work. */ const api = flag("--api", ""); if (api !== "") { const base = api.replace(/\/+$/, ""); await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); try { const response = await route.fetch({ url: `${base}${url.pathname}${url.search}` }); await route.fulfill({ response }); } catch (error) { // A refused upstream must look like a refused upstream, not like a hung // request: the app's own degraded paths are part of what is being judged. console.log(`look: api proxy failed for ${url.pathname} — ${error}`); await route.fulfill({ status: 502, body: "{}", contentType: "application/json" }); } }); } const errors = []; page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, { waitUntil: "networkidle", timeout: 60000, }); await page.waitForTimeout(Number(flag("--wait", "10000"))); // The first-run flow covers the scene it is teaching you about. try { await page.getByText(/^Skip$/).first().click({ timeout: 2500 }); await page.waitForTimeout(1200); } catch { /* already dismissed, or not shown */ } /** * The chapter, by identity, before any `--click`. * * `flyTo` eases over about two seconds of scene time, so the wait after it is * the same 8 s a click gets. The label check is a hard failure rather than a * log line: a preset that silently photographs the wrong chapter is worse than * one that refuses, because the picture still looks deliberate. */ /* * `--lat/--lng` plants the camera at a place instead of at a chapter. * * Every other aim in this harness goes through a control a reader also uses, * and that stays the default: `--chapter` clicks a chapter button. But a board * can then only be photographed where a chapter already points, and the merged * "one California" board carries the state pack's six — the whole state, the * north, two corridors and two doors — none of which is near a city. The board * exists to put cities on the state, and there was no way to take a picture of * one. Three attempts by clicking the minimap and guessing wheel notches landed * in open ocean twice and on empty coast once. * * The seek moves the camera and nothing else. `--standoff` and `--height` are * true metres, so a pose reads the same on a 94 m board and a 1,919 m one. */ const seekLat = flag("--lat", null); const seekLng = flag("--lng", null); if (seekLat !== null && seekLng !== null) { const at = { lat: Number(seekLat), lng: Number(seekLng), standoffM: Number(flag("--standoff", "20000")), heightM: flag("--height", null) === null ? undefined : Number(flag("--height", "0")), azimuth: flag("--azimuth", null) === null ? undefined : Number(flag("--azimuth", "0")), }; const placed = await page.evaluate((pose) => { const cam = globalThis.__teraCamera; if (!cam || typeof cam.seek !== "function") return null; return { board: cam.board, ...cam.seek(pose) }; }, at); if (placed === null) { console.error("look: no camera hook on the page — is this a build with publishCameraHook?"); process.exit(1); } console.log( `look: seek ${at.lat},${at.lng} on ${placed.board} — standoff ${at.standoffM} m`, ); await page.waitForTimeout(Number(flag("--settle", "2500"))); } const chapter = flag("--chapter", null); if (chapter !== null && chapter !== "") { const expect = flag("--expect", null); const found = await page.evaluate((id) => { const button = document.querySelector(`#chapters .chapter[data-view="${id}"]`); if (!(button instanceof HTMLElement)) { return { ok: false, available: [...document.querySelectorAll("#chapters .chapter")].map((node) => node.getAttribute("data-view"), ), }; } const spans = [...button.querySelectorAll("span")]; const label = (spans[spans.length - 1]?.textContent ?? "").trim(); const index = button.getAttribute("data-view-index"); button.click(); return { ok: true, label, index }; }, chapter); if (!found.ok) { console.error( `look: no chapter with data-view="${chapter}" on this board — it has ` + `${JSON.stringify(found.available)}. Not photographing a frame nobody asked for.`, ); await browser.close(); shutdown(); process.exit(1); } if (expect !== null && found.label !== expect) { console.error( `look: chapter "${chapter}" prints "${found.label}", the preset expects "${expect}" — ` + `a pack was re-labelled, so this shot would be captioned wrong.`, ); await browser.close(); shutdown(); process.exit(1); } console.log(`look: chapter ${chapter} — "${found.label}" at position ${found.index}`); await page.waitForTimeout(8000); } /** * `--click` may be given more than once, and they run in order. * * One click opens a door; two get you somewhere inside it. The LA office's * upper storey is the case that forced this: the first click opens the * building and the second flies to a viewpoint on the floor above, and there is * no single label that does both. */ const clicks = args.flatMap((arg, i) => (arg === "--click" ? [args[i + 1] ?? ""] : [])); if (clicks.length === 0 && typeof preset.click === "string") clicks.push(preset.click); for (const click of clicks) { if (click === "") continue; try { await page.getByText(new RegExp(click, "i")).first().click({ timeout: 5000 }); await page.waitForTimeout(8000); } catch { console.log(`look: could not click ${click}`); } } const path = `${OUT}/${name}.png`; await page.screenshot({ path }); console.log(`look: wrote ${path}`); // Two 404s on a static preview are the zero-config path working: see deploy/STATIC.md. const real = errors.filter((e) => !/404|Failed to load resource/.test(e)); console.log(real.length === 0 ? "look: no console errors" : `look: ERRORS ${JSON.stringify(real)}`); await browser.close(); shutdown(); // Explicit, because the preview's stdout pipes are inherited by a grandchild // and Node will happily wait on them for the rest of the afternoon. process.exit(0);