4bd8481be1
The owner asked twice why there are still three separate boards. The honest answer, and what this round executes: **it feels like three boards, but not because the scale jumps 20x — because the three packs draw three different Californias, and the loudest difference is that the mountains are four times taller on one of them.** **THE 20x HORIZONTAL SCALE JUMP IS INVISIBLE**, and measuring that collapsed the cost of this whole round. `World.project` is a uniform scale in x/z with no vertical term, and a uniform scale leaves a perspective image identical — so a camera carried across the seam on matched true-metre offsets draws a pixel-identical horizontal frame. 1,919 -> 94 m/unit costs nothing to look at. Rescaling was never the problem. A boot card, a tab strip and a 4.17x vertical deflation were. **THE PAUSE WAS MOSTLY FAKE.** A switch covered the screen for 1,715 ms but only 608 ms blocked the main thread; the page drew 46 of 69 frames with nothing to show, because the outgoing board had already been disposed. `mountCity` now retains it: the incoming board builds BEHIND a live, interactive picture, and `stage.setScene` fires only on completion. Measured across all six directions, three runs each — boot card yes -> **no**, opaque cover 726-1,415 ms -> **0**, blank frames 21-46 -> **exactly 1**, wall clock down 12-29%, blocked main thread down 15-47%. A return to a board already seen links **zero** shader programs and blocks **zero** milliseconds: 298-312 ms of camera flight where it was ~1,600 ms behind a card. Disposal had been throwing away the shader cache too — linkProgram ran 38, 59, 78, 109, 127 across five mounts and never reused one. **The transition is a fog dip, not a crossfade**, through the `setAerialFog` seam built last round. Every both-boards-live crossfade breaks a budget — ca+sf is 2,640,307 triangles against bay-area's 2,600,000 cap — and a fade never lands inside the harness's sample window, which is the "a cap you do not measure is a cap you do not have" failure this repo already argues against. The dip costs zero triangles and zero draw calls, and it hides the 4.17x deflation, the 4,025 m projection disagreement and the vanishing 2 km freeway symbols at once, because all three happen at maximum obscuration. It is also diegetic: a descent through haze. The first dip was wrong and the photograph caught it: collapsing to 6% of board SPAN turned the whole night frame into one flat field — the exact "turns the map off" failure the risk list named. Re-anchored to 70% of camera STAND-OFF, so the coastline survives and only the relief melts. **One ladder, one places list.** 26 authored chapters become 24 rungs sorted descending by STAND-OFF, not altitude — by altitude they interleave badly and altitude cannot tell a low oblique from a high plan. The three-board tab strip is off by default; the left column is now one scrolling list of all 24 rungs under three region headings that does not change when the board does. Only which row is lit changes. Label collisions are resolved in the ladder and never in a pack, so the 29 index-aimed capture guards are untouched. The minimap stops turning through 90 degrees between boards: every board is pinned to a rectangle with California's proportions. **SF and SoCal are not regressed**, and that was the acceptance that mattered: 95.9-98.8% of board pixels are delta-0 against a baseline hash-verified identical to what the live site serves, and every one of the 34-70 surviving pixels per frame is an aircraft or a hull. **A real defect found only by photograph:** `minimap.setMarkers()` had zero call sites. Every marker on every board was gone — the LA studio's door dot, the Bay Area's eight company markers — dropped when the minimap went per-board. Typecheck, tests, budgets and the console were all green with that bug in. Also fixed: two capture presets that lied. `look.mjs`'s `glyph-la` and `glyph-sf` claimed California chapter closeups and returned SoCal and Bay Area frames, because they aimed by chapter index and the indices had moved. Aiming is now by identity, with a guard test. NOT SHIPPED, DELIBERATELY: the pack merge. At Bay density it is 34.04M triangles, 13x the highest budget — dead, not a trade. At SoCal density it is 1.99M and fits today, and the price is San Francisco rendering at 164 m lots instead of 40 m, i.e. SF looking the way SoCal looks now. SF and SoCal carry every marketing still on the site. That is the owner's decision and it is worthless as an argument and decisive as a photograph, so it ships as a measurement artifact with a side-by-side still and is wired into nothing. The four data reconciliations that would make one world honest — one exaggeration rule, roads in metres, one projection centre, one coastline convention — are behind TERA_RECONCILE, default OFF. Tests 1,570 -> 1,651, server 295. All ten budget cells pass, no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
400 lines
17 KiB
JavaScript
400 lines
17 KiB
JavaScript
/**
|
||
* 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 <name> [--url <path-and-query>] [--phone] [--at <iso>]
|
||
* [--wait <ms>] [--click <text>] [--api <origin>]
|
||
* [--chapter <data-view>] [--expect <short label>]
|
||
*
|
||
* `--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/<name>.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.
|
||
*/
|
||
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);
|