Town Mode: close town view by default, build_district + place_road, bank/market/house, grow-one-town steering, behold-the-world reveal, tamed needle
b0d758d verified | // GODSEED — headless screenshot harness (adapted from glassblower tools/shot.mjs). | |
| // Town Mode suite: the DEFAULT load is now a CLOSE TOWN VIEW (SimCity feel), the | |
| // full planet is the dramatic "behold the world" reveal. Serves web/ itself on a | |
| // free port (8200-8299), drives the mock world through deterministic seeks, and | |
| // saves shots to tools/shots/. | |
| // | |
| // node tools/shot.mjs # full suite | |
| // node tools/shot.mjs --base http://localhost:8234 # reuse a running server | |
| // | |
| // Playwright is resolved from this repo if installed, else from the glassblower | |
| // checkout next door (its node_modules + ~/Library/Caches/ms-playwright browsers). | |
| 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 }); | |
| 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); | |
| }); | |
| } | |
| const argBase = process.argv.indexOf("--base"); | |
| let BASE = argBase > -1 ? process.argv[argBase + 1] : null; | |
| 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.__godseed && window.__godseed.ready, { timeout: 30000 }); | |
| await page.evaluate(() => window.__godseed.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(() => { | |
| const tc = window.__godseed.townCenter?.(); | |
| return { ...window.__godseed.stats(), mode: window.__godseed.rig?.mode, | |
| tc: tc ? `${tc.lat.toFixed(1)},${tc.lon.toFixed(1)}` : "?" }; | |
| }); | |
| console.log(` 📸 ${name} mode=${s.mode} dist=${s.camDist} tc=${s.tc} sky=${s.sky}`); | |
| } | |
| // ------------------------------------------------------------------ suite | |
| // 1 · DEFAULT load — the close TOWN view (SimCity feel: a town you stand over, | |
| // dense with houses, civic buildings, roads — NOT a tiny planet). | |
| await open("index.html?mock=1&static=1"); | |
| await page.evaluate(() => { window.__godseed.tick(6); }); // settle camera + grow-ins | |
| await shot("town-01-default.png"); | |
| // 2 · a dense DISTRICT close-up — lean the gaze right into the south block so the | |
| // gabled houses + footpaths + civic crown read large at the close zoom. | |
| await page.evaluate(() => { | |
| window.__godseed.gazeLatLon(11, 36, 30); // the w_000003 quarter | |
| window.__godseed.tick(5); | |
| }); | |
| await shot("town-02-district-close.png"); | |
| // 3 · roads + civic mix at the square — frame the bank/market/tower heart. | |
| await page.evaluate(() => { | |
| window.__godseed.gazeLatLon(14, 38, 30); | |
| window.__godseed.tick(5); | |
| }); | |
| await shot("town-03-square.png"); | |
| // 4 · BEHOLD THE WORLD — pull back to the full planet; the town is one warm | |
| // patch ringed by the orbiting inscription on a whole shared world. | |
| await page.evaluate(() => { window.__godseed.beholdWorld(); window.__godseed.tick(9); }); | |
| await shot("town-04-behold-world.png"); | |
| // 5 · return to the town (the toggle flips back to the close view). | |
| await page.evaluate(() => { window.__godseed.beholdWorld(); window.__godseed.tick(8); }); | |
| await shot("town-05-return.png"); | |
| // 6 · a GRANTING moment, framed WITHIN the town — run the looping town-growth | |
| // wish (a new quarter east of the square) and freeze mid-grant with the | |
| // thought ticker live. | |
| await open("index.html?mock=1"); | |
| await page.evaluate(() => { | |
| window.__godseed.tick(2); // let the loop arm | |
| window.__godseed.runFeedOnce(); | |
| window.__godseed.tick(15); // reading → district + market + cafe deltas | |
| }); | |
| await shot("town-06-granting.png"); | |
| // 7 · the same wish a beat later — road + carts land, the new quarter is joined. | |
| await page.evaluate(() => { window.__godseed.tick(9); }); | |
| await shot("town-07-granted.png"); | |
| // 8 · the DESCENT within the town — swoop down over the just-granted quarter. | |
| await page.evaluate(() => { | |
| window.__godseed.descend(15, 43, 13); // dive over the new market quarter | |
| window.__godseed.tick(5.4); // mid-glide, just above the surface | |
| }); | |
| await shot("town-08-descent.png"); | |
| // 9 · genesis-only world — fallback townCenter = the monolith, framed up close. | |
| await open("index.html?mock=1&upto=0&static=1"); | |
| await page.evaluate(() => { window.__godseed.tick(6); }); | |
| await shot("town-09-genesis.png"); | |
| // 10 · Genesis Log — replay the town-build wish (w_000002): the district + bank + | |
| // market + tower + road rising, in the close town view. | |
| await open("book.html?mock=1"); | |
| await page.evaluate(() => { | |
| window.__godseed.replay("w_000002"); | |
| window.__godseed.tick(20); // through the district + civic deltas | |
| }); | |
| await shot("town-10-book-replay.png"); | |
| // ------------------------------------------------------------------ wrap | |
| if (errors.length) { | |
| console.log("\n⚠️ page errors:"); | |
| for (const e of errors.slice(0, 25)) console.log(" " + e); | |
| process.exitCode = 1; | |
| } else { | |
| console.log("\n✓ no page errors"); | |
| } | |
| await browser.close(); | |
| cleanup(); | |
| console.log("done →", OUT); | |