diff --git a/scripts/look.mjs b/scripts/look.mjs index 081e5f2..81c1689 100644 --- a/scripts/look.mjs +++ b/scripts/look.mjs @@ -324,6 +324,46 @@ try { * 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); diff --git a/scripts/performance-budget.mjs b/scripts/performance-budget.mjs index d7873ae..dc286bc 100755 --- a/scripts/performance-budget.mjs +++ b/scripts/performance-budget.mjs @@ -97,6 +97,37 @@ const SCENES = { signature: "hayes-valley", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='hayes-valley']") !== null, }, + /* + * One California — the merged board, behind `?one=1`. + * + * It needs a cell of its own, and until it had one the only way to measure it + * was to hand-edit the `california` cell's query, run, and edit it back. That + * was done six times in one session and is exactly the sort of procedure that + * gets half-done: the numbers quoted for this board came from a harness that + * was, at the time, lying about which board it measured. + * + * **`ready` asserts the merged board specifically, not just California.** The + * signature chapter cannot do it — `california-overview` is on the coarse + * state board too, so a cell whose `?one=1` silently stopped working would + * measure the coarse board and pass, which is the "a budget that cannot fail" + * failure this harness already has a comment about. `#sea-section` is the + * discriminator and it is a real one: the note is written only for a board + * that has ports, `california.ts` authors none, and `cities/unify.ts` folds in + * the Southland's two. No ports, no section, no readiness. + * + * The caps are RECORDED from the first measured run with headroom left, in the + * same spirit as `bay-area` and `socal` above — not copied from `california`, + * whose numbers describe a board with no cities on it. + */ + "california-one": { + host: "tera.lumbridgecorp.com", + query: "?city=california&one=1&handover=0", + signature: "california-overview", + ready: () => + document.getElementById("boot")?.hidden === true && + document.querySelector("#chapters .chapter[data-view='california-overview']") !== null && + document.getElementById("sea-section") !== null, + }, socal: { host: "tera.lumbridgecorp.com", query: "?city=socal&handover=0", diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json index 6033161..4a85347 100644 --- a/scripts/performance-budgets.json +++ b/scripts/performance-budgets.json @@ -1,6 +1,6 @@ { "version": 1, - "note": "Caps are never raised. A red p95FrameIntervalMs on this box is noise before it is a finding — the GPU here never leaves 500 MHz of a possible 2,725, so a desktop cell on the vsync deadline flips between 16.8 and 33.3 ms with geometry identical to the digit; judge on maxTriangles and maxDrawCalls. The five scene names are bound to board identity in performance-budget.mjs: every scene asserts the data-board of the pressed tab, because ?city= falls back to the first board rather than failing and a bay-area cell that silently measured California would pass its cap by a factor of six.", + "note": "Caps are never raised. A red p95FrameIntervalMs on this box is noise before it is a finding \u2014 the GPU here never leaves 500 MHz of a possible 2,725, so a desktop cell on the vsync deadline flips between 16.8 and 33.3 ms with geometry identical to the digit; judge on maxTriangles and maxDrawCalls. The five scene names are bound to board identity in performance-budget.mjs: every scene asserts the data-board of the pressed tab, because ?city= falls back to the first board rather than failing and a bay-area cell that silently measured California would pass its cap by a factor of six. `california-one` is the merged board and its caps are RECORDED, not copied: measured 352,927 triangles / 420 draws desktop and 352,527 / 416 mobile on its first full run, set here with headroom in the same spirit as bay-area and socal. They were briefly copied from `california`, which is wrong for the reason that cell's own numbers are wrong for this board \u2014 `california` describes a state with no cities on it, and this one carries 56,327 buildings, two ports and both metros' bridges and airports. No existing cap was raised to accommodate it.", "scenes": { "california": { "desktop": { @@ -61,6 +61,18 @@ "maxDrawCalls": 170, "maxTriangles": 900000 } + }, + "california-one": { + "desktop": { + "p95FrameIntervalMs": 16.7, + "maxDrawCalls": 460, + "maxTriangles": 400000 + }, + "mobile": { + "p95FrameIntervalMs": 33.3, + "maxDrawCalls": 455, + "maxTriangles": 400000 + } } } } diff --git a/src/adapters/http.ts b/src/adapters/http.ts index 223c09d..6aa3f55 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -1053,6 +1053,70 @@ export interface TrafficSource extends FlightSource { dispose(): void; } +/** + * One `TrafficSource` over several regions. + * + * A board that spans more than one served region cannot be a single circle. + * `regionOf` derives its circle from the board's *bounds*, which on the merged + * statewide board is 402 nautical miles centred on the middle of the state — + * and the API refuses it, correctly: `400 bad_request, "Nothing this deployment + * serves is near 37.3,-119.25"`. What this deployment serves is San Francisco + * and the Southland, five hundred and sixty kilometres apart, and the honest + * request is both of them rather than one circle drawn around the gap between. + * + * Here and not in `engine/flights.ts` for the reason `TrafficSource` itself is + * here: the engine draws darts at coordinates and has no business with + * provenance, and every interesting part of this merge — which regions are + * live, whose attribution to carry — is provenance. + * + * Three properties worth stating, because each is a way this could be wrong: + * + * - **De-duplicated by id.** Regions may overlap, and two circles drawn around + * neighbouring metros both see an aircraft between them. Drawn twice it gets + * two glyphs and two trails, and `flights.ts` measures a track's span from + * repeated observations, so a duplicate is not merely a double image. + * - **`live()` is `some`, not `every`.** One dark metro must not make the + * other's observed traffic claim to be simulated; the label is about what is + * on screen. `describeFeeds` in `ui/chromeState.ts` reads this. + * - **`poll()` stays synchronous**, as this interface narrows it to be. Each + * source answers from what it has in hand, so merging is a concat and never + * a round trip — a promise here would put a frame's aircraft behind the + * network, which is the whole thing this file's design avoids. + */ +export function mergedTraffic(sources: readonly TrafficSource[]): TrafficSource { + return { + interval: sources.reduce((a, s) => Math.min(a, s.interval), Number.POSITIVE_INFINITY), + poll(): Aircraft[] { + const seen = new Set(); + const out: Aircraft[] = []; + for (const source of sources) { + for (const aircraft of source.poll()) { + if (seen.has(aircraft.id)) continue; + seen.add(aircraft.id); + out.push(aircraft); + } + } + return out; + }, + live(): boolean { + return sources.some((s) => s.live()); + }, + attribution(): string[] { + return [...new Set(sources.flatMap((s) => s.attribution()))]; + }, + detail(id: string): AircraftDetail | null { + for (const source of sources) { + const found = source.detail(id); + if (found !== null) return found; + } + return null; + }, + dispose(): void { + for (const source of sources) source.dispose(); + }, + }; +} + /** * Traffic over HTTP, in whichever of the two shapes the server chose. * diff --git a/src/cities/unify.ts b/src/cities/unify.ts index 712fe5f..9fd6834 100644 --- a/src/cities/unify.ts +++ b/src/cities/unify.ts @@ -130,6 +130,18 @@ let cached: { city: City; report: UnifyReport } | null = null; * The one board. Memoised, because `World` builds one per mount and the merge * walks every district polygon in the product. */ +/** + * The detailed packs this board was built from. + * + * Exported because a board that spans more than one served region has to ask + * the live feeds for each of them — `regionOf` on the merged board's own bounds + * is a 402-nautical-mile circle centred on the middle of the state, and the API + * refuses it. `main.ts` derives one sky region per entry here instead. Reading + * it from the merge rather than hard-coding two imports is what keeps a third + * metro from being a change in two files. + */ +export const UNIFIED_SOURCES: readonly City[] = [SAN_FRANCISCO, SOCAL]; + export function unifiedCalifornia(): { city: City; report: UnifyReport } { if (cached !== null) return cached; diff --git a/src/main.ts b/src/main.ts index 7196b39..41f934d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -73,7 +73,7 @@ import { createStage, deviceProfile } from "./engine/stage.ts"; import { daylightPhase } from "./engine/solar.ts"; import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts"; import CALIFORNIA from "./cities/california.ts"; -import { unifiedCalifornia } from "./cities/unify.ts"; +import { UNIFIED_SOURCES, unifiedCalifornia } from "./cities/unify.ts"; import { reconciledCity } from "./cities/reconcile.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; @@ -92,6 +92,7 @@ import { createTeraClient, type FireWatch, type PresenceWatch, + mergedTraffic, type TrafficSource, type WeatherWatch, } from "./adapters/http.ts"; @@ -1712,7 +1713,23 @@ async function buildBoard( * entirely, so a deployment with a real ADS-B receiver and no marker file flew * the simulator. */ + /* + * One sky region per detailed source, not one circle around the board. + * + * `regionOf` derives its circle from the board's bounds, which is right for a + * metro board and wrong for the merged one: 402 nautical miles centred on the + * middle of the state, which the API refuses with `400 bad_request, "Nothing + * this deployment serves is near 37.3,-119.25"` — correctly, since what it + * serves is San Francisco and the Southland, five hundred and sixty + * kilometres apart. `region` stays the board's own circle for everything that + * wants one shape (the traffic dial, the sample generator); `skyRegions` is + * what the live feed is actually asked for. + */ const region = regionOf(entry.city); + const skyRegions = + ONE_CALIFORNIA && entry.id === "california" + ? UNIFIED_SOURCES.map((source) => regionOf(source)) + : [region]; // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed // unconditionally and all of it is over San Francisco, so the SoCal board's // entire sky projected ~590 km off the world and rendered as nothing at all. @@ -1722,8 +1739,10 @@ async function buildBoard( // vacuum, so California keeps the honest deterministic sky while its two // detailed boards continue to use live ADS-B when available. const traffic = - id !== "california" && access.can.liveEnvironment && access.feeds?.flights - ? tera.flights(region, routes) + carriesMetroDetail(entry.city) && access.can.liveEnvironment && access.feeds?.flights + ? skyRegions.length === 1 + ? tera.flights(region, routes) + : mergedTraffic(skyRegions.map((r) => tera.flights(r, routes))) : null; /** @@ -1781,7 +1800,29 @@ async function buildBoard( m.lng >= bounds.minLng && m.lng <= bounds.maxLng, ); - const initialMarkers = id === "sf" ? [...markers, ...doors] : doors; + /* + * Bounds **and** metro detail — the doors' rule, plus the feeds' rule. + * + * The sample companies are San Francisco's, so on San Francisco's own board + * they appear and on the Southland's they do not; on a board that contains + * San Francisco *and draws it at metro fidelity* they appear too, which is + * what `cities/unify.ts` needed and what `id === "sf"` silently refused. + * + * Bounds alone is not enough, and the budget said so within one run: the + * coarse statewide board's rectangle contains San Francisco, so it picked up + * forty-four markers it has no business drawing at 1,919 m to the unit and + * went from 373 draw calls to 417. `carriesMetroDetail` is the same predicate + * the live feeds use and it is the same question — is this a board on which a + * building-sized thing means anything. + */ + const sampleMarkers = (carriesMetroDetail(entry.city) ? markers : []).filter( + (m) => + m.lat >= bounds.minLat && + m.lat <= bounds.maxLat && + m.lng >= bounds.minLng && + m.lng <= bounds.maxLng, + ); + const initialMarkers = [...sampleMarkers, ...doors]; /** * The two sky layers, decided here so the options object below reads as four @@ -2166,6 +2207,7 @@ async function presentBoard(record: MountedBoard): Promise { activateBoard(record); hideSwitchProgress(); + publishCameraHook(record); /* * Arriving is itself a reason to look ahead. * @@ -2392,7 +2434,7 @@ function activateBoard(record: MountedBoard): void { // the local climatology model; the detailed SF and SoCal boards keep their // live observations. weatherWatch = - id !== "california" && access.can.liveEnvironment && access.feeds?.weather + carriesMetroDetail(entry.city) && access.can.liveEnvironment && access.feeds?.weather ? tera.watchWeather(entry.city.center, () => { updateSun(); renderChrome(); @@ -2550,6 +2592,89 @@ const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") !== " */ const PREFETCH_ENABLED = true; +/** + * Does this pack carry metro detail, as opposed to being the coarse state board? + * + * **Asked of the pack, not of its id**, and that distinction is a bug this + * function exists to have fixed. Live ADS-B and live weather observations were + * gated on `id !== "california"`, which meant "not the coarse statewide board" + * on the day it was written and was correct then: live aircraft over a board at + * 1,919 m to the unit are a glyph problem, and an observation from one station + * cannot speak for a thousand kilometres of coast. + * + * `cities/unify.ts` then made a board that is *both* — the whole state, at metro + * fidelity, keeping the `california` id deliberately so that the fire gate, the + * ladder's region table and every `?city=` deep link keep working. It therefore + * inherited a gate meant for something else and shipped with live traffic and + * live weather silently off, which is exactly the class of defect that reads as + * "it feels less alive" and never as an error. + * + * `focusRegions` is the honest predicate and needs no new field: the coarse + * state pack declares none, and every pack that has ground worth drawing at + * metro resolution declares at least one. + */ +/** + * A camera the capture harness can plant, exposed on `globalThis`. + * + * **Why this exists.** Every instrument in this repo aims through a control a + * reader also uses — `look.mjs --chapter` clicks a chapter button, `shots.mjs` + * drives the mode dock — and that is the right default, because a harness with + * a back door photographs a state no visitor can reach. But it means a board + * can only be photographed *where a chapter already points*, and the merged + * board carries California's six: the whole state, the north, two corridors and + * two doors that leave the board. There is no rung anywhere near a city on it. + * + * The cost of that was three failed attempts to photograph the very thing the + * board exists for — its cities — by clicking the minimap and guessing wheel + * notches, each of which landed in open ocean or on empty coast. A picture is + * this project's acceptance instrument; a board that cannot be pointed at is a + * board whose work cannot be accepted. + * + * So: a seek, in the same coordinates a pack is authored in. It moves the + * camera and nothing else — no mode change, no board change, no state a visitor + * could not also reach by dragging. It is `globalThis` rather than a module + * export because the harness talks to a built bundle across a page boundary, + * and it is deliberately not wired to any UI. + */ +function publishCameraHook(record: MountedBoard): void { + (globalThis as { __teraCamera?: unknown }).__teraCamera = { + /** + * Look at a place from a distance, in the pack's own units: degrees for the + * target, true metres for the stand-off and the height. + * + * `metresPerUnit` for the horizontal and `metresPerUnit / exaggeration` for + * the vertical, because a stand-off is a plan measurement and a height is + * not — the same distinction `chapterStandoffMetres` and + * `chapterAltitudeMetres` are built on, and getting it wrong is a camera + * under the ground. + */ + seek(at: { lat: number; lng: number; standoffM?: number; heightM?: number; azimuth?: number }) { + const world = record.handle.world; + const scene = record.handle.stageScene; + const [x, z] = world.project(at.lat, at.lng); + const ground = world.groundAt(at.lat, at.lng); + const standoff = (at.standoffM ?? 20_000) / world.metresPerUnit; + const lift = + ((at.heightM ?? (at.standoffM ?? 20_000) * 0.6) / world.metresPerUnit) * + world.city.verticalExaggeration; + const azimuth = at.azimuth ?? 0.6; + scene.controls.target.set(x, ground, z); + scene.camera.position.set( + x + Math.sin(azimuth) * standoff, + ground + lift, + z + Math.cos(azimuth) * standoff, + ); + scene.controls.update(); + return { x, z, ground, standoff, lift }; + }, + board: record.id, + }; +} + +function carriesMetroDetail(city: City): boolean { + return (city.focusRegions?.length ?? 0) > 0; +} + function maybeHandover(record: MountedBoard): void { if (!FREE_HANDOVER) return; if (inside || fogDip !== null || record !== visibleBoard) return; diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts index 248490d..0dc6cc3 100644 --- a/src/test/unify.test.ts +++ b/src/test/unify.test.ts @@ -210,3 +210,34 @@ describe("the detail repack", () => { ); }); }); + +describe("one California is a detailed board, and the engine must agree", () => { + it("declares focus regions, which is what the live-feed gates read", async () => { + /* + * Live ADS-B and live weather were gated on `id !== "california"` — "not the + * coarse statewide board", correct on the day it was written. `unify` then + * built a board that is the whole state AND metro-detailed, keeping the + * `california` id on purpose so the fire gate, the ladder's region table and + * every `?city=` deep link keep working. It inherited a gate meant for + * something else and shipped with live traffic and live weather silently + * off — a defect that reads as "it feels less alive" and never as an error. + * + * The predicate is now `focusRegions`, so this asserts the property the + * gates depend on, and that the coarse pack still fails it. + */ + assert.ok((city.focusRegions?.length ?? 0) > 0, "the merged board must declare focus regions"); + assert.equal(CALIFORNIA.focusRegions ?? undefined, undefined); + + const main = await import("node:fs/promises").then((fs) => + fs.readFile("src/main.ts", "utf8"), + ); + assert.ok( + !/id !== "california" && access\.can\.liveEnvironment/.test(main), + "a live feed is still gated on the board id rather than on the pack", + ); + assert.ok( + !/const initialMarkers = id === "sf"/.test(main), + "markers are still gated on the board id rather than on the board's bounds", + ); + }); +});