/**
* The before/after pair, at every authored chapter of all three packs.
*
* `TERA_RECONCILE` exists so that four data changes can be *looked at* rather
* than argued about, and the acceptance for every one of them is a photograph:
* a board that is worse in the picture means that rule reverts. Twenty-six
* chapters times two flag states is fifty-two frames, which is more than anyone
* will take one `look.mjs` invocation at a time — so this is one command, one
* preview and one browser, and it names its output after the chapter's identity
* rather than after its position in a list.
*
* node scripts/reconcile-shots.mjs [--dist
] [--out ]
* [--rules ] [--only ]
* [--at ] [--wait ]
*
* `--rules` is passed through as `?reconcile=`, so a single rule can be
* photographed alone — which is the point of the four being independently
* switchable in the first place.
*
* The port and build checks are `look.mjs`'s, for `look.mjs`'s reason: this box
* once accumulated a hundred and forty-seven abandoned previews in an afternoon
* and photographed somebody else's dist through one of them.
*/
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { createServer } from "node:net";
import { chromium } from "playwright";
import CALIFORNIA from "../src/cities/california.ts";
import SF from "../src/cities/sf.ts";
import SOCAL from "../src/cities/socal.ts";
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
};
const DIST = flag("--dist", "dist");
const OUT = flag("--out", "/tmp/tera-look/reconcile");
const RULES = flag("--rules", "1");
const ONLY = flag("--only", null);
const AT = flag("--at", "2026-08-21T20:00:00Z");
const WAIT = Number(flag("--wait", "6000"));
const FIRST_WAIT = Number(flag("--first-wait", "11000"));
mkdirSync(OUT, { recursive: true });
/**
* The two California chapters that are doors, not poses.
*
* `main.ts` matches them against `CALIFORNIA_DESTINATIONS` and calls
* `switchCity()` rather than `flyTo()`, so clicking one leaves the board — which
* is what made `look.mjs`'s `glyph-la` and `glyph-sf` presets photographs of the
* wrong board for as long as they existed. They are shot anyway, because a
* before/after pair at *every* authored chapter is what was asked for and
* because the frame they produce is a real frame of the product; the page is
* reloaded afterwards so the next chapter is aimed from California again.
*/
const DOORS = new Set(["los-angeles", "san-francisco"]);
const BOARDS = [CALIFORNIA, SF, SOCAL].filter((city) => ONLY === null || city.id === ONLY);
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));
});
});
const server = spawn(
new URL("../node_modules/.bin/vite", import.meta.url).pathname,
["preview", "--outDir", DIST, "--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 !== PORT) {
console.error(`reconcile-shots: preview bound ${bound}, not ${PORT} — refusing to photograph it`);
shutdown();
process.exit(1);
}
const browser = await chromium.launch({
channel: "chrome",
args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const context = await browser.newContext({
viewport: { width: 1600, height: 1000 },
deviceScaleFactor: 1,
timezoneId: "America/Los_Angeles",
// The opening arrival collapses to a cut under this, which is the only way
// fifty-two frames taken over half an hour are the same fifty-two frames when
// the box is busy.
reducedMotion: "reduce",
});
await context.clock.setFixedTime(new Date(AT));
const page = await context.newPage();
const errors = [];
page.on("console", (m) => {
if (m.type() === "error") errors.push(m.text());
});
async function land(url) {
await page.goto(`http://localhost:${PORT}${url}`, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(FIRST_WAIT);
try {
await page.getByText(/^Skip$/).first().click({ timeout: 2000 });
await page.waitForTimeout(1200);
} catch {
/* already dismissed, or not shown */
}
}
let taken = 0;
for (const city of BOARDS) {
for (const state of ["off", "on"]) {
const url = `/?city=${city.id}${state === "on" ? `&reconcile=${RULES}` : ""}`;
await land(url);
for (const chapter of city.chapters) {
// Aim by identity. `data-view` is the chapter's own id, so a pack that is
// reordered under this script still photographs the chapter it names —
// the failure `look.mjs`'s two broken presets were an instance of.
const button = page.locator(`.chapter[data-view="${chapter.id}"]`).first();
try {
await button.click({ timeout: 5000 });
} catch {
console.log(`reconcile-shots: could not click ${city.id}/${chapter.id}`);
await land(url);
continue;
}
await page.waitForTimeout(WAIT);
const path = `${OUT}/${city.id}-${chapter.id}-${state}.png`;
await page.screenshot({ path });
taken += 1;
console.log(`reconcile-shots: ${path}`);
if (DOORS.has(chapter.id)) await land(url);
}
}
}
const real = errors.filter((e) => !/404|Failed to load resource/.test(e));
console.log(`reconcile-shots: ${taken} frames into ${OUT}`);
console.log(real.length === 0 ? "reconcile-shots: no console errors" : `reconcile-shots: ERRORS ${JSON.stringify([...new Set(real)].slice(0, 5))}`);
await browser.close();
shutdown();
process.exit(0);