diff --git a/scripts/brand-assets/capture.mjs b/scripts/brand-assets/capture.mjs index d32d243..96cfe7f 100644 --- a/scripts/brand-assets/capture.mjs +++ b/scripts/brand-assets/capture.mjs @@ -203,7 +203,7 @@ try { * opens, so no flight is needed — but the label is checked anyway, because * "the chapter I did not click" is exactly as reorderable as the one I did. */ - await shootApp(browser, "http://tera.lumbridgecorp.com:5210/?city=california", "art-tera.png", { + await shootApp(browser, "http://tera.lumbridgecorp.com:5210/?city=california&handover=0", "art-tera.png", { at: AFTERNOON, chapter: 0, expect: "State", diff --git a/scripts/brand-assets/films.mjs b/scripts/brand-assets/films.mjs index 15b9d20..3213342 100644 --- a/scripts/brand-assets/films.mjs +++ b/scripts/brand-assets/films.mjs @@ -564,7 +564,23 @@ async function expose(page, instant) { async function film(browser, spec, dir) { const host = spec.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com"; - const query = spec.city ? `?city=${spec.city}` : ""; + /* + * **Both halves of this are explicit on purpose, and neither used to be.** + * + * `city` used to fall through to the app's own default when a shot did not + * declare one, and four of the twenty-one shots relied on that. The default + * then moved from the state board to the Bay Area, which would have re-pointed + * those four at a different place while every filename, caption and `alt` + * stayed exactly as it was — the silent kind of wrong this harness exists to + * prevent. So the fallback is named here rather than borrowed from the app. + * + * `handover=0` pins the free-camera tier promotion off. With it on, a planted + * pose wider than a board's retention band demotes to the coarser tier while + * the shutter is open, and the frame that comes back is a real photograph of + * the wrong board. A capture harness must not share a camera policy with a + * visitor. + */ + const query = `?city=${spec.city ?? "california"}&handover=0`; const frames = frameOverride ?? spec.frames; const page = await browser.newPage({ viewport: spec.viewport ?? VIEWPORT, diff --git a/scripts/brand-assets/shots.mjs b/scripts/brand-assets/shots.mjs index 41997fb..9131584 100644 --- a/scripts/brand-assets/shots.mjs +++ b/scripts/brand-assets/shots.mjs @@ -1346,7 +1346,23 @@ async function serveFeed(page, name) { async function shoot(browser, shot, frame) { const host = shot.door === "office" ? "office.lumbridgecorp.com" : "tera.lumbridgecorp.com"; - const query = shot.city ? `?city=${shot.city}` : ""; + /* + * **Both halves of this are explicit on purpose, and neither used to be.** + * + * `city` used to fall through to the app's own default when a shot did not + * declare one, and four of the twenty-one shots relied on that. The default + * then moved from the state board to the Bay Area, which would have re-pointed + * those four at a different place while every filename, caption and `alt` + * stayed exactly as it was — the silent kind of wrong this harness exists to + * prevent. So the fallback is named here rather than borrowed from the app. + * + * `handover=0` pins the free-camera tier promotion off. With it on, a planted + * pose wider than a board's retention band demotes to the coarser tier while + * the shutter is open, and the frame that comes back is a real photograph of + * the wrong board. A capture harness must not share a camera policy with a + * visitor. + */ + const query = `?city=${shot.city ?? "california"}&handover=0`; const page = await browser.newPage({ viewport: VIEWPORT, deviceScaleFactor: preview ? 1 : 2, diff --git a/scripts/performance-budget.mjs b/scripts/performance-budget.mjs index 2c0dca2..d7873ae 100755 --- a/scripts/performance-budget.mjs +++ b/scripts/performance-budget.mjs @@ -49,11 +49,18 @@ const VIEWPORTS = { const SCENES = { california: { host: "tera.lumbridgecorp.com", + // Explicit, and it was implicit until the app's default board moved off the + // state tier. `signature` would have caught it — a board with no + // `california-overview` chapter never becomes ready — but a harness that + // depends on an app default is a harness that reports someone else's change + // as its own flake. + query: "?city=california&handover=0", signature: "california-overview", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null, }, "california-drive": { host: "tera.lumbridgecorp.com", + query: "?city=california&handover=0", signature: "california-overview", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null && document.querySelector("#chapters .chapter[data-view='la-sf-us-101']") !== null, activate: () => { @@ -66,7 +73,7 @@ const SCENES = { }, active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false, }, - office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true }, + office: { host: "office.lumbridgecorp.com", query: "?handover=0", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true }, /* * The two metro boards, added after they became the boards with the most on * them and were still the boards nothing measured. @@ -86,13 +93,13 @@ const SCENES = { */ "bay-area": { host: "tera.lumbridgecorp.com", - query: "?city=sf", + query: "?city=sf&handover=0", signature: "hayes-valley", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='hayes-valley']") !== null, }, socal: { host: "tera.lumbridgecorp.com", - query: "?city=socal", + query: "?city=socal&handover=0", signature: "dtla", ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='dtla']") !== null, }, diff --git a/src/engine/scene.ts b/src/engine/scene.ts index d51a093..c146afc 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -602,6 +602,22 @@ export interface SceneHandle { * `AERIAL_SUBJECT_CLEARANCE`. */ cameraStandoffMetres(): number; + /** + * Whether the unrequested opening move is still in the air. + * + * Exposed for one caller and one reason: `main.ts`'s free-camera handover must + * not read a stand-off that the *arrival* is still changing. `arrivalStart` + * begins the move at `ARRIVAL_STANDOFF` — 1.5x the resting stand-off — and the + * ladder demotes a board above `DEMOTE`, which is 1.15x its handover ceiling. + * 1.5 is greater than 1.15 for every board there will ever be, so the opening + * frame of *any* board is outside that board's own retention band and a + * handover that ran during it would immediately throw the visitor back to the + * coarser tier they came from. Which is exactly what it did. + * + * This is the same class of guard as the drag guard: not "the camera is + * somewhere odd" but "nobody has asked for this camera position yet". + */ + arriving(): boolean; /** The reflectivity raster, or `null`. A no-op without a precipitation layer. */ setPrecip(field: RadarField | null): void; /** Tonight's migration, or `null`. A no-op without a migration layer. */ @@ -1382,8 +1398,26 @@ export async function createScene( } const rest = heroPose(openingPose, orbitMaxDistance); const from = arrivalStart(rest, orbitMaxDistance); - kit.setPose(from); + /* + * **Record the move before making it.** `kit.setPose` drives + * `OrbitControls`, which fires `change` *synchronously*, and `main.ts` + * listens on that event to decide whether the camera has left this board. + * Written the obvious way round — pose first, then bookkeeping — that + * listener runs on the line between them, sees a camera 1.5x further out + * than this board's own resting stand-off and an `arriving()` that is + * still false, and hands the visitor back to the coarser tier before the + * opening move has drawn a single frame. + * + * That is precisely what shipped: landing on the Bay Area bounced to the + * state board with no input at all, and the guard written to prevent it + * could not fire because the flag it reads was set one statement too late. + * The trajectory log that found it showed the handover tick arriving + * *before* the first `arriving=true` sample, at the same stand-off. + * + * So the assignment goes first. Nothing else in this function changed. + */ arrival = { from, to: rest, elapsed: 0 }; + kit.setPose(from); }, setLighting: (state) => { lighting = state; @@ -1429,6 +1463,9 @@ export async function createScene( }, setFires: (view) => fireLayer?.setFires(view), setFireSmoke: (visible) => fireLayer?.setSmokeVisible(visible), + arriving() { + return arrival !== null; + }, cameraStandoffMetres() { // `metresPerUnit` and NOT `unitsToMetres`, which is the vertical // conversion and divides the exaggeration back out. A stand-off is a @@ -1661,7 +1698,7 @@ const HERO_ELEVATION_DEG = 29; const HERO_DISTANCE = 0.9; /** How much further out the camera stands before the move, as a multiple. */ -const ARRIVAL_STANDOFF = 1.5; +export const ARRIVAL_STANDOFF = 1.5; /** How much higher, as a multiple. Larger than the stand-off: the move descends. */ const ARRIVAL_LIFT = 2.2; /** How far round the board it swings, in radians. Negative is anticlockwise. */ diff --git a/src/main.ts b/src/main.ts index 8abbce9..f23b81d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -230,6 +230,40 @@ const CITIES: { id: string; label: string; city: City }[] = [ { id: "socal", label: "SoCal", city: SOCAL }, ]; +/** + * The board a bare URL lands on. **The detailed one, not the state tier.** + * + * ## Why this is not `CITIES[0]` + * + * It used to be, implicitly, in three places — a `wantedCity` initialiser, the + * office's return-link builder and the boot fallback — and the board it named + * was the 1,919 m-per-unit state tier. So the first thing every visitor saw was + * the coarsest representation the product owns: seventeen districts over + * 1063x930 km, no city legible, and a left column whose first offer is a list of + * somewhere else to go. The detailed boards carry 52 and 47 districts at 94 and + * 391 m per unit, and they are what the engine is actually for. + * + * With the free-camera handover on, the state tier has stopped being a + * destination and become **what you get when you pull back** — which is the role + * it is good at, since it is the only board that draws 97.4% of California. + * Landing on the Bay and zooming out to the state is the same map at two + * scales. Landing on the state and hunting for a city is three products. + * + * ## Why the constant rather than reordering `CITIES` + * + * Because `CITIES` order is load-bearing elsewhere: twenty-nine capture guards + * in `scripts/brand-assets` and every cell in `scripts/performance-budget.mjs` + * aim with an explicit `?city=`, and the budget harness binds each scene to the + * `data-board` it asserts precisely because an unknown `?city=` falls back to + * the first entry rather than failing. Reordering the array to change a landing + * page would silently re-point that fallback and every consumer that reads the + * array positionally. A named constant changes the one thing intended. + * + * `?city=california` still goes straight to the state board, and nothing about + * the deep link changed. + */ +const DEFAULT_CITY_ID = "sf"; + /** The corridor's scale doors, and the office each detailed board arrives near. */ const CALIFORNIA_DESTINATIONS = new Map([ ["los-angeles", { cityId: "socal", officeId: "mateo-court" }], @@ -495,7 +529,7 @@ let cityId = "california"; * to be against the intention, and the intention is recorded synchronously in * the click handler. */ -let wantedCity = "california"; +let wantedCity = DEFAULT_CITY_ID; let office: OfficeScene | null = null; /** The pack used by `office`; kept separate from the currently selected door. */ let builtOfficeId: string | null = null; @@ -880,7 +914,7 @@ function cityDoorUrl(cityWanted?: string): string | null { // Carried so that stepping out of the office into SoCal lands in SoCal. The // page load is the cost of telling the truth about where you are; arriving in // the wrong metro on top of it would not be. - if (cityWanted !== undefined && cityWanted !== CITIES[0]?.id) { + if (cityWanted !== undefined && cityWanted !== DEFAULT_CITY_ID) { url.searchParams.set("city", cityWanted); } return url.href; @@ -2442,7 +2476,36 @@ function activateBoard(record: MountedBoard): void { let dragging = false; /** - * Free-camera promotion across the seam. **Behind a flag, and off by default.** + * Free-camera promotion across the seam. **On, and `?handover=0` turns it off.** + * + * ## Why it is on now + * + * Because the owner asked three times why there are three boards, and this is + * the answer to that question rather than a nicety. A wheel notch over the Bay + * now promotes to the detailed Bay Area board and a notch back out demotes to + * the state tier: one map, three scales, entered by moving rather than by + * picking a product off a strip of tabs. Measured on the deployed bundle before + * this default changed, promotion fired at the sixteenth notch in from the state + * pose and the arrival was clean — no boot card, no tab, no click on a name. + * + * The two reservations below were real and both are now discharged. The + * oscillation one had a *cause* rather than a risk — see the arrival guard in + * `maybeHandover`, which is the whole of it. The coarse-descent one is a + * property of the packs and not of this rule, and it is recorded as such: + * + * ## What is still true, and is now a decision rather than a doubt + * + * 97.4% of California's area has no board below 242 km of stand-off. Zooming in + * over Fresno lands on 806 m lots and 2 km-wide freeway symbols with nothing to + * hand over to, and the rule correctly refuses. That picture has now been + * looked at — it is a dark, coarse blur, and it is what the state tier honestly + * is at that range. The judgement made here is that a visitor who deliberately + * zooms into the empty middle of the state and finds it coarse has learned + * something true, and that this costs less than a tab strip that tells every + * visitor there are three products before they touch anything. Authoring + * Sacramento, Fresno and the Central Valley is what retires it. + * + * ## The original argument, kept because it is the reason the guard exists * * The rule itself — `handover` in `engine/ladder.ts` — is pure, tested and * shipped. What is deliberately not shipped is letting a wheel notch fire it, @@ -2460,7 +2523,7 @@ let dragging = false; * the door into Mateo Court rejecting with `wrong-city` while the Southland is * visibly on screen, and nothing throws. */ -const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") === "1"; +const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") !== "0"; /** * Whether a board may be built before anybody asks for it. @@ -2476,6 +2539,21 @@ const PREFETCH_ENABLED = true; function maybeHandover(record: MountedBoard): void { if (!FREE_HANDOVER) return; if (inside || fogDip !== null || record !== visibleBoard) return; + /* + * The opening move is not a camera position anybody asked for, and reading a + * stand-off out of it is how this feature spent a round looking broken. + * + * `arrivalStart` begins every board's arrival at 1.5x its resting stand-off + * and `ladder.ts` demotes above 1.15x the handover ceiling, so the first + * second of *any* board is outside that board's own retention band. Landing on + * the Bay Area with the flag on therefore bounced straight back to the state + * board with no input at all — measured, twice, before this line existed. It + * is not an sf quirk and it is not a tuning problem: 1.5 > 1.15 holds for + * every board there will ever be, which is why the fix is a guard rather than + * a number. `handoverArrivalGuard.test.ts` pins that inequality so the guard + * cannot be deleted as redundant. + */ + if (record.handle.arriving()) return; if (record.handle.controlMode() !== "overview") return; const target = record.handle.stageScene.controls.target; const [lat, lng] = record.handle.world.unproject(target.x, target.z); @@ -6061,9 +6139,10 @@ async function boot() { * black screen. */ const wanted = new URLSearchParams(location.search).get("city"); - const first = CITIES.find((c) => c.id === wanted) ?? CITIES[0]; + const fallback = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0]; + const first = CITIES.find((c) => c.id === wanted) ?? fallback; await building(`Building ${first?.label ?? "the city"}…`, () => - mountCity(first?.id ?? "california"), + mountCity(first?.id ?? DEFAULT_CITY_ID), ); // There must be a mounted scene before remote snapshots have anywhere to go. diff --git a/src/test/handoverArrivalGuard.test.ts b/src/test/handoverArrivalGuard.test.ts new file mode 100644 index 0000000..8f777e7 --- /dev/null +++ b/src/test/handoverArrivalGuard.test.ts @@ -0,0 +1,116 @@ +/** + * The one inequality that makes the arrival guard necessary. + * + * `main.ts`'s `maybeHandover` refuses to run while `handle.arriving()` is true. + * That line looks like belt-and-braces and is not: without it, landing on any + * board with the free-camera handover on threw the visitor straight back to the + * coarser tier they came from, with no input at all. It was measured on the + * deployed bundle twice — `?city=sf` alone stays on the Bay Area, `?city=sf` with + * the handover on reported "San Francisco Bay Area" and then "California". + * + * The cause is arithmetic rather than tuning, which is why it is asserted here + * rather than left to a screenshot. `arrivalStart` opens every board's + * unrequested move at `ARRIVAL_STANDOFF` times the resting stand-off, and + * `handover` demotes a board once the camera passes `DEMOTE` times that board's + * handover ceiling. A board's ceiling is its own widest authored pose, so the + * opening frame sits outside its own retention band for as long as + * `ARRIVAL_STANDOFF > DEMOTE` — and it does, for every board there will ever be, + * because both are single global constants. + * + * So this file exists to fail the day somebody "simplifies" one of three things: + * the guard, `ARRIVAL_STANDOFF`, or `DEMOTE`. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { ARRIVAL_STANDOFF } from "../engine/scene.ts"; +import { DEMOTE, HANDOVER_STANDOFF_M, PROMOTE, handover } from "../engine/ladder.ts"; +import SAN_FRANCISCO from "../cities/sf.ts"; +import SOCAL from "../cities/socal.ts"; +import CALIFORNIA from "../cities/california.ts"; + +const BOARDS = [CALIFORNIA, SAN_FRANCISCO, SOCAL].map((city) => ({ + id: city.id, + bounds: city.bounds, + metresPerUnit: 111_320 / city.latScale, +})); + +describe("the arrival guard", () => { + it("is required, because the opening move starts outside every board's retention band", () => { + assert.ok( + ARRIVAL_STANDOFF > DEMOTE, + `arrival opens at ${ARRIVAL_STANDOFF}x resting and a board is demoted above ` + + `${DEMOTE}x its ceiling — if this ever stops being true the guard in ` + + "maybeHandover may be reconsidered, and until then it may not", + ); + }); + + it("would demote a board on its own opening frame, for each board that has a ceiling", () => { + /* + * The failure the guard prevents, reproduced through the real rule: put the + * camera where `arrivalStart` puts it — 1.5x the resting stand-off, which for + * a board at rest on its own widest pose is 1.5x its ceiling — and ask the + * ladder what board that is. It answers "a coarser one", every time. + */ + for (const [id, ceiling] of Object.entries(HANDOVER_STANDOFF_M)) { + const board = BOARDS.find((candidate) => candidate.id === id); + assert.ok(board, `no pack for ${id}`); + const centre = { lat: (board.bounds.minLat + board.bounds.maxLat) / 2, + lng: (board.bounds.minLng + board.bounds.maxLng) / 2 }; + const opening = handover({ + boards: BOARDS, + current: id, + lat: centre.lat, + lng: centre.lng, + standoffM: ceiling * ARRIVAL_STANDOFF, + dragging: false, + }); + assert.equal( + opening, + "california", + `${id}'s opening frame does not demote, so this test is asserting nothing`, + ); + + // And once the move has landed, the same board is stable: no promotion out + // of it and no demotion off it. That is the state the guard hands over to. + const settled = handover({ + boards: BOARDS, + current: id, + lat: centre.lat, + lng: centre.lng, + standoffM: ceiling, + dragging: false, + }); + assert.equal(settled, null, `${id} is not stable at its own resting stand-off`); + } + }); + + const SF_CEILING = HANDOVER_STANDOFF_M.sf ?? 0; + + it("still promotes on the way in, which is the behaviour being protected", () => { + // Below the ceiling by more than the hysteresis band: the Bay Area is the + // right board for a camera this close to San Francisco. + const promoted = handover({ + boards: BOARDS, + current: "california", + lat: 37.77, + lng: -122.42, + standoffM: SF_CEILING * PROMOTE * 0.9, + dragging: false, + }); + assert.equal(promoted, "sf"); + }); + + it("never swaps a board out from under a finger", () => { + const held = handover({ + boards: BOARDS, + current: "california", + lat: 37.77, + lng: -122.42, + standoffM: SF_CEILING * PROMOTE * 0.9, + dragging: true, + }); + assert.equal(held, null); + }); +});