// NEMOCITY — headless screenshot harness (the screenshot-and-LOOK loop). // Serves web/ itself on a free port (8200-8299), drives the mock city through // deterministic seeks (?mock=1&t= fixes the shared clock), and saves // shots to tools/shots/. // // node tools/shot.mjs # full suite // node tools/shot.mjs --god-night --street # subset // node tools/shot.mjs --base http://localhost:8123 // // Playwright resolves from this repo's node_modules if installed, else from // the glassblower checkout next door (shares its browser cache). import { mkdirSync } from "node:fs"; import { createServer } from "node:net"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import path from "node:path"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const WEB = path.join(HERE, "..", "web"); const OUT = path.join(HERE, "shots"); mkdirSync(OUT, { recursive: true }); const FLAGS = ["--god-day", "--god-night", "--street", "--heatmap", "--construction"]; const argv = process.argv.slice(2); const baseIdx = argv.indexOf("--base"); let BASE = baseIdx > -1 ? argv[baseIdx + 1] : null; const chosen = new Set(argv.filter((a) => FLAGS.includes(a))); const want = (k) => chosen.size === 0 || chosen.has(k); async function loadPlaywright() { try { return await import("playwright"); } catch { const fallback = path.resolve(HERE, "../../glassblower/node_modules/playwright/index.mjs"); return import(`file://${fallback}`); } } function freePort(start = 8200, end = 8299) { return new Promise((resolve, reject) => { const tryPort = (p) => { if (p > end) return reject(new Error("no free port in 8200-8299")); const srv = createServer(); srv.once("error", () => tryPort(p + 1)); srv.once("listening", () => srv.close(() => resolve(p))); srv.listen(p, "127.0.0.1"); }; tryPort(start); }); } let server = null; if (!BASE) { const port = await freePort(); server = spawn("python3", ["-m", "http.server", String(port), "--directory", WEB], { stdio: "ignore", }); BASE = `http://127.0.0.1:${port}`; await new Promise((r) => setTimeout(r, 900)); // let it bind } const cleanup = () => { if (server) { server.kill(); server = null; } }; process.on("exit", cleanup); process.on("SIGINT", () => { cleanup(); process.exit(130); }); const { chromium } = await loadPlaywright(); const browser = await chromium.launch({ headless: true, args: [ "--enable-unsafe-swiftshader", "--use-gl=angle", "--use-angle=swiftshader", "--ignore-gpu-blocklist", "--enable-webgl", ], }); const page = await browser.newPage({ viewport: { width: 1512, height: 945 }, deviceScaleFactor: 2 }); const errors = []; page.on("console", (m) => { if (m.type() === "error") errors.push("console: " + m.text()); }); page.on("pageerror", (e) => errors.push("pageerror: " + e.message)); async function open(url) { console.log("->", url); await page.goto(`${BASE}/${url}`, { waitUntil: "networkidle", timeout: 45000 }); await page.waitForFunction(() => window.__app && window.__app.ready, { timeout: 30000 }); await page.evaluate(() => window.__app.pause(true)); await page.evaluate(async () => { if (document.fonts?.ready) await document.fonts.ready; }); } async function shot(name) { await page.screenshot({ path: path.join(OUT, name) }); const s = await page.evaluate(() => window.__app.stats()); console.log(` shot ${name} simTime=${s.simTime} calls=${s.calls ?? "?"} tris=${s.triangles ?? "?"} epoch=${s.epoch}`); } // ------------------------------------------------------------------ suite // Day page: t=78 -> 07:48, morning rush + low golden sun. The welcome build // (Cafe Luna) starts at t0+4 and its delta lands ~t0+7.7 — tick past it so a // construction site is alive in the god view. const needDayPage = want("--god-day") || want("--heatmap") || want("--construction") || want("--street"); if (needDayPage) { await open("index.html?mock=1&t=78"); await page.evaluate(() => window.__app.tick(14)); if (want("--god-day")) await shot("god-day.png"); if (want("--heatmap")) { await page.evaluate(() => { window.__app.setHeatmap(true); window.__app.tick(3); }); await shot("heatmap.png"); await page.evaluate(() => { window.__app.setHeatmap(false); window.__app.tick(0.5); }); } if (want("--construction")) { // apartments script (5 floors -> scaffold + crane), framed close mid-rise await page.evaluate(() => { window.__app.runScript(1); // Elm Court, anchor (1,-8) 2x2 window.__app.tick(6); // through plan + world_delta window.__app.gazeCell(2, -7, 30); window.__app.tick(9); // floors rising (45 s build) }); await shot("construction.png"); } if (want("--street")) { // low camera down Main St (x=2). Manual override LAST on this page — it // bypasses the rig, so no further rig-driven shots afterwards. await page.evaluate(() => { const a = window.__app.app; window.__app.pause(true); if (a.rig?.streetView) { a.rig.streetView(); window.__app.tick(0.5); } else if (a.camera) { a.camera.position.set(2, 5, 60); a.camera.lookAt(2, 2, -20); a.renderOnce?.(); } else { console.warn("street shot: no rig.streetView or app.camera exposed"); } }); await shot("street.png"); } } // Night page: t=206 + tick(8) -> 21:24. Lamps, window glow, and the welcome // construction site lit in orange. if (want("--god-night")) { await open("index.html?mock=1&t=206"); await page.evaluate(() => window.__app.tick(8)); await shot("god-night.png"); } // ------------------------------------------------------------------ wrap if (errors.length) { console.log("\npage errors:"); for (const e of errors.slice(0, 25)) console.log(" " + e); process.exitCode = 1; } else { console.log("\nno page errors"); } await browser.close(); cleanup(); console.log("done ->", OUT);