diff --git a/src/cities/reconcile.ts b/src/cities/reconcile.ts index 4a55599..9668ec1 100644 --- a/src/cities/reconcile.ts +++ b/src/cities/reconcile.ts @@ -80,19 +80,132 @@ export function setReconcile(rules: boolean | readonly Rule[] | null): void { else forced = new Set(rules); } +/** + * Read the rule set out of a flag value. + * + * ## Exact names, and a warning for anything else + * + * This used to prefix-match: `RULES.filter((rule) => wanted.some((part) => + * rule.startsWith(part)))`. Three things were wrong with that, and all three + * corrupt measurements rather than breaking anything loudly. + * + * - **A misspelling reads as "off".** `?reconcile=palette` selected nothing, + * because no rule name begins with "palette" — and an empty set is exactly + * what the flag being absent produces. So a photograph taken to judge a rule + * was a photograph of the unreconciled board, captioned as the rule. There + * is no way to tell those two apart from the picture. + * - **One letter selected a rule.** `?reconcile=p` was `projection`, `r` was + * `roads`, `g` was `ground`. Convenient until a second rule shares a letter, + * at which point the same URL silently means something else. + * - **It is order-dependent in a way nobody would guess.** The result is + * built by filtering `RULES`, so the set is in declaration order however the + * caller wrote it, which is fine — but a caller who writes two prefixes that + * both hit one rule gets one rule and no complaint. + * + * Whatever replaces it has to fail *loudly*, because every number anyone takes + * from this module downstream — every RMSE, every pixel-diff bbox, every "the + * metro boards are unmoved to the digit" — is only as good as the flag having + * meant what the person typing it thought it meant. + */ function parse(value: string | null): ReadonlySet { if (value === null || value === "" || value === "0" || value === "false") return new Set(); if (value === "1" || value === "true" || value === "all") return new Set(RULES); const wanted = value.split(/[,+\s]+/).filter((part) => part !== ""); - return new Set(RULES.filter((rule) => wanted.some((part) => rule.startsWith(part)))); + const known = new Set(); + for (const part of wanted) { + const rule = RULES.find((candidate) => candidate === part); + if (rule === undefined) { + // Not an exception. A bad rule name must not be able to stop a page from + // booting — this flag is read at module load on every visit, including + // from a URL somebody pasted — but it must never pass silently either. + warn( + `reconcile: unknown rule "${part}". Known rules: ${RULES.join(", ")}. ` + + "This rule is being ignored; the flag is NOT off.", + ); + continue; + } + known.add(rule); + } + return known; } -/** Which rules are on right now. */ +/** + * Where the warning goes. + * + * Split out so a test can assert the warning happened without owning a console, + * and because this module is imported by the terrain worker, where `console` is + * present but nobody is reading it. + */ +let warned: (message: string) => void = (message) => { + (globalThis as { console?: { warn?(m: string): void } }).console?.warn?.(message); +}; + +/** Redirect the unknown-rule warning. Pass `null` to restore the console. */ +export function setReconcileWarn(sink: ((message: string) => void) | null): void { + warned = + sink ?? + ((message) => { + (globalThis as { console?: { warn?(m: string): void } }).console?.warn?.(message); + }); +} + +function warn(message: string): void { + warned(message); +} + +/** + * The rules that are on when nobody has said otherwise. + * + * **Empty, and `roads` was tried here and taken back out.** The reason is worth + * keeping, because the argument for turning it on is persuasive and wrong: + * + * `roads` exists to stop California drawing US-101 and I-5 as 1,919 m and + * 2,034 m black ribbons — wider than the cities they join. That complaint is + * real and you can see it in any state frame. But `city.roads` **is not what + * draws them**. `scene.ts` reads + * + * scene.add(options.roadTraffic ? createFreewayWorld(world, pack) : createRoads(world)) + * + * and California is the one board that has `roadTraffic`, so it takes the first + * branch and `createRoads` — the only consumer of `Road.width` — is never called + * for it at all. The corridor you can see is `createFreewayWorld`, built from a + * `TransportPack` at hard-coded scene units (carriageways 1.03 wide at ±0.64, + * shoulders 1.18), which at 1,919 m to the unit is a corridor about 4.7 km + * across. That is deliberate: `main.ts` says so where it sets the corridor's + * altitude — "an atlas glyph just like the black cars: literal metres would put + * the chase camera inside the coarse hills" — because DRIVE mode has to be able + * to drive down it on a board where a real freeway is a fifth of a pixel. + * + * So on California the rule rewrites a column nothing reads, and on the metros + * `reconciledRoadWidth` returns the same number the pack already authored, to + * within float noise. Turned on and photographed at three chapters: Downtown LA + * pixel-identical (empty diff bbox), FiDi RMSE 0.0006 with zero pixels past + * 8/255, California 0.001%. The one measurable consequence anywhere was **−112 + * triangles on Southern California**, from a ribbon width recomputing to a float + * that differs in its last bits. + * + * A default that changes no pixel and 112 triangles is not a feature; it is a + * second baseline for everyone who measures the other three rules afterwards. + * The rule stays available — `?reconcile=roads` — and the real fix for the state + * board's freeway lives in `structures.ts`, not here. + */ +export const DEFAULT_RULES: readonly Rule[] = []; + +/** + * Which rules are on right now. + * + * Order of precedence: an explicit `setReconcile()` from a node script, then the + * query string, then `DEFAULT_RULES`. The middle one includes `?reconcile=0`, + * which is how a measurement asks for the unreconciled board now that "no flag" + * no longer means that. + */ export function activeRules(): ReadonlySet { if (forced !== null) return forced; const search = (globalThis as { location?: { search?: string } }).location?.search; - if (search === undefined) return new Set(); - return parse(new URLSearchParams(search).get("reconcile")); + if (search === undefined) return new Set(DEFAULT_RULES); + const value = new URLSearchParams(search).get("reconcile"); + if (value === null) return new Set(DEFAULT_RULES); + return parse(value); } export function reconcileEnabled(rule?: Rule): boolean { diff --git a/src/engine/boards.ts b/src/engine/boards.ts index 762c7af..fdb2ad0 100644 --- a/src/engine/boards.ts +++ b/src/engine/boards.ts @@ -166,3 +166,171 @@ export function createBoardCache(options: BoardCacheOptions): BoardCache { }, }; } + +// ---- Which board to load before anybody asks for it ------------------------- + +/** A board the prefetcher may load, described by facts a pack already states. */ +export interface PrefetchTier { + id: string; + /** The stand-off at which this tier takes over, in true metres. */ + handoverStandoffM: number; + bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }; +} + +export interface PrefetchQuery { + tiers: readonly PrefetchTier[]; + /** Where the camera is looking, in degrees. */ + lat: number; + lng: number; + /** How far it is standing off what it is looking at, in true metres. */ + standoffM: number; + /** Boards already in memory, however they got there. */ + resident: readonly string[]; + /** The board on screen. Never a prefetch target, never an eviction victim. */ + visible: string | null; + capacity: number; + pinned?: readonly string[]; +} + +/** + * How much closer than the handover the camera must be before a tier loads. + * + * Three handovers. The trigger has to fire early enough that a metro is resident + * before the visitor reaches the stand-off where it would otherwise be built in + * front of them, and late enough that it never fires while they are looking at + * something else entirely. + * + * Three puts San Francisco's trigger at 213.6 km and Southern California's at + * 326.4 km. The first lands inside the **133 km dead band** between California's + * finest authored rung (`la-sf-i-5`, 242,206 m) and the first Bay rung + * (71,200 m) — a stretch of the ladder with nothing authored in it, so the load + * happens while the camera is on a state-scale rung and has somewhere to be. + * It is nowhere near the overview (1,551 km) or Shasta (501 km), which is the + * property that matters: landing on the state board must not load two metros. + */ +export const PREFETCH_HANDOVER_MULTIPLE = 3; + +/** + * How far outside a tier's own rectangle the camera may be and still arm it, + * as a multiple of the tier's half-diagonal. + * + * 1.5 gives San Francisco a 92 km disc and Southern California a 146 km one. + * The two metros are 313.9 km apart edge to edge and 92 + 146 = 238, so **the + * discs provably cannot overlap and at most one tier is ever a candidate**. + * That is the same covering-set argument `residentCapacity` already rests on, + * re-derived here rather than assumed, and it is what makes a two-deep handheld + * cache safe to prefetch into at all. + */ +export const PREFETCH_REACH_MULTIPLE = 1.5; + +/** Metres per degree of latitude. The longitude term is cosine-corrected below. */ +const M_PER_DEG_LAT = 111_320; + +/** Great-circle-ish distance from a point to a rectangle, in true metres. */ +function metresToBounds(lat: number, lng: number, b: PrefetchTier["bounds"]): number { + const dLat = Math.max(b.minLat - lat, 0, lat - b.maxLat); + const dLng = Math.max(b.minLng - lng, 0, lng - b.maxLng); + // Cosine taken at the *camera*, not at the rectangle's centre: the caller may + // be a long way north of a southern tier, and the squash that matters for how + // far it has to travel is the one where it is standing. + const east = dLng * M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180); + return Math.hypot(dLat * M_PER_DEG_LAT, east); +} + +/** Half the diagonal of a tier's rectangle, in true metres. */ +function halfDiagonal(b: PrefetchTier["bounds"]): number { + const midLat = (b.minLat + b.maxLat) / 2; + const north = (b.maxLat - b.minLat) * M_PER_DEG_LAT; + const east = (b.maxLng - b.minLng) * M_PER_DEG_LAT * Math.cos((midLat * Math.PI) / 180); + return Math.hypot(north, east) / 2; +} + +/** + * Whether a board may be admitted without evicting the one on screen. + * + * The policy lives here and the effects live in `main.ts`, so this module stays + * free of DOM, WebGL and `three` — see this file's header. A prefetch that + * evicted the visible board to make room for an invisible one would be the worst + * possible trade, and on a two-deep handheld cache it is one `put` away. + */ +export function canAdmit(query: { + id: string; + resident: readonly string[]; + visible: string | null; + capacity: number; + pinned?: readonly string[]; +}): boolean { + if (query.resident.includes(query.id)) return true; + if (query.resident.length < query.capacity) return true; + const pinned = new Set(query.pinned ?? []); + // `ids()` is least-recently-shown first, so the victim is the first entry that + // is neither pinned nor on screen. If there is no such entry, admitting means + // evicting something that must not be evicted. + return query.resident.some((id) => !pinned.has(id) && id !== query.visible); +} + +/** + * The one board worth loading right now, or `null` for "do nothing". + * + * Scalars only, never a camera: this has to be callable from a test with no + * `three` in it, and a function that took a `PerspectiveCamera` would drag the + * whole engine into this module's dependency graph and out of CONTRACT §1's + * "no DOM, no WebGL, no three" promise for it. + * + * Returns at most one id. Loading two metros at once is never right — they are + * 313.9 km apart and the camera cannot be descending into both — and returning + * one keeps the caller's abort handling to a single controller. + */ +export function prefetchTarget(query: PrefetchQuery): string | null { + const resident = new Set(query.resident); + /** + * Whether there is room for another board without dropping one. + * + * This is the difference between two genuinely different situations, and + * collapsing them was the first version's mistake. + * + * **With free room, proximity is the wrong question.** CONTRACT §1.1 already + * promises three resident boards on a desktop with California pinned, and + * measured that all three together are about 60 MB — the residency exists, and + * until now the only thing that ever filled it was a visitor waiting through a + * build. A desktop sitting on the state board with two empty slots should + * simply *have* both metros, because the overwhelmingly common path is to land + * on California and then click a metro, and on that path a proximity trigger + * fires exactly never: the camera is at 1,551 km, hundreds of kilometres above + * any threshold, and the next thing that happens is the click. A rule that only + * helps free-camera explorers is a rule that helps almost nobody. + * + * **With the cache full, proximity is the only question.** Admitting now means + * evicting something, and evicting is only worth it for a board the camera is + * actually descending toward. That is the handheld case — capacity two, one of + * them pinned — and it is why the discs and the dead band still exist. + */ + const hasRoom = query.resident.length < query.capacity; + let best: { id: string; distanceM: number } | null = null; + for (const tier of query.tiers) { + if (tier.id === query.visible) continue; + if (resident.has(tier.id)) continue; + const distanceM = metresToBounds(query.lat, query.lng, tier.bounds); + if (!hasRoom) { + if (query.standoffM > tier.handoverStandoffM * PREFETCH_HANDOVER_MULTIPLE) continue; + if (distanceM > halfDiagonal(tier.bounds) * PREFETCH_REACH_MULTIPLE) continue; + } + if ( + !canAdmit({ + id: tier.id, + resident: query.resident, + visible: query.visible, + capacity: query.capacity, + pinned: query.pinned, + }) + ) { + continue; + } + // Nearest wins. With non-overlapping discs there is never a second + // candidate, so this is a tie-break that should not be reachable — and it is + // written anyway, because "should not be reachable" is how a board gets + // chosen by declaration order the day somebody adds a fourth pack. + if (best === null || distanceM < best.distanceM) best = { id: tier.id, distanceM }; + } + return best?.id ?? null; +} diff --git a/src/engine/environmentRig.ts b/src/engine/environmentRig.ts index cb41904..5f3c19b 100644 --- a/src/engine/environmentRig.ts +++ b/src/engine/environmentRig.ts @@ -54,8 +54,41 @@ import type { LightingState } from "./types.ts"; /** Which of the two worlds is being reflected. */ export type EnvironmentKind = "city" | "office"; +/** Per-call options for {@link EnvironmentRig.apply}. */ +export interface ApplyOptions { + /** + * This scene is being built but is not on screen, and must not disturb one + * that is. + * + * ## Why the rig needs to be told + * + * The rig holds exactly one cached probe per kind. On a key miss it renders a + * new one, **disposes the one the visible board is using**, and repoints every + * applied scene of that kind at the replacement. That is exactly right when + * the miss is time passing: one clock, one sky, every city scene agrees. + * + * It is exactly wrong when the miss comes from a board nobody is looking at. + * A board built ahead of the camera is observed at *its own* centre — across + * California about forty minutes of apparent solar time — so its key differs + * by construction, and the visible board's sky would change because something + * invisible finished loading behind it. + * + * With this set, a key miss is not a rebuild: the off-stage scene borrows + * whatever probe is already cached, and the correct one arrives on its first + * on-stage `apply`, which by then is the same call every board already makes. + * Nothing is disposed, nothing already applied is repointed, and no PMREM + * convolution runs on the shared renderer inside somebody else's frame. + */ + offstage?: boolean; +} + export interface EnvironmentRig { - apply(scene: THREE.Scene, lighting: LightingState, kind: "city" | "office"): void; + apply( + scene: THREE.Scene, + lighting: LightingState, + kind: "city" | "office", + options?: ApplyOptions, + ): void; /** * Forget a scene that is being torn down. * @@ -198,12 +231,17 @@ export function createEnvironmentRig(renderer: THREE.WebGLRenderer): Environment } return { - apply(scene, lighting, kind) { + apply(scene, lighting, kind, options) { if (disposed) return; const key = environmentKey(kind, lighting); const current = built.get(kind); + // An off-stage board never rebuilds. See ApplyOptions.offstage: the + // rebuild path disposes the visible board's probe and repoints it, and a + // board nobody is looking at has no business doing either. It takes + // whatever is cached and gets the right one when it is presented. + const mayRebuild = options?.offstage !== true; - if (!current || current.key !== key) { + if (mayRebuild && (!current || current.key !== key)) { let target: THREE.WebGLRenderTarget; try { target = build(kind, lighting); diff --git a/src/engine/scene.ts b/src/engine/scene.ts index d70ec0d..d51a093 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -806,9 +806,31 @@ export async function createScene( // two disagree for the one frame before the app's first `setLighting`. const opening = options.lighting ?? cityDaylight(pal, boardSpan); kit.applyLighting(opening); - // The environment before the first layer is added, so the very first frame - // has a sky to reflect rather than acquiring one a `setLighting` later. - options.environment?.apply(scene, opening, "city"); + /* + * The environment before the first layer is added, so the very first frame has + * a sky to reflect rather than acquiring one a `setLighting` later. + * + * `offstage` is the `present` flag, one line early. It has to be, because this + * call is two hundred lines above the `if (options.present !== false)` that + * decides whether anybody will ever look at this scene — and the rig holds one + * probe per kind, disposes it on a key miss, and repoints every applied scene + * at the replacement. A board built ahead of the camera is observed at its own + * centre, so its key differs by construction, and without this a board loading + * invisibly in the background would change the sky of the board on screen. + * `onEnter` below applies it again, for real, when it is presented. + */ + /** + * The lighting this scene was last told about, and whether anybody is looking. + * + * Both are held because the environment is applied from three places — here at + * construction, from `setLighting` on every clock tick, and from `onEnter` on + * arrival — and all three have to agree about the same two facts. A board that + * is off screen must never rebuild the shared probe; a board that is on screen + * must always be allowed to. + */ + let lighting = opening; + let presented = options.present !== false; + options.environment?.apply(scene, opening, "city", { offstage: !presented }); scene.add(createWater(world)); scene.add(createShorePlates(world)); @@ -1172,6 +1194,23 @@ export async function createScene( // stale detail card for something the pointer is nowhere near reads as a // bug. onExit: () => kit.resetPick(), + /* + * Claim the environment on arrival. + * + * A board built with `present: false` was applied `offstage`, which means it + * borrowed whatever probe was cached rather than building its own — right + * while it was invisible, wrong the moment it is not. This is the same call + * every board already makes; on a cache hit it costs no convolution, which + * is the case a prefetched board arriving at the same hour always hits. + * + * It runs on every entry, not only the first, and that is deliberate: a + * board returned to after an office visit or another board has been off + * screen while the clock moved, and re-asserting is how it catches up. + */ + onEnter: () => { + presented = true; + options.environment?.apply(scene, lighting, "city"); + }, tick(dt) { // Exactly one subsystem owns the camera. In particular, OrbitControls // must stay disabled while the road layer writes its follow pose. @@ -1347,6 +1386,7 @@ export async function createScene( arrival = { from, to: rest, elapsed: 0 }; }, setLighting: (state) => { + lighting = state; kit.applyLighting(state); clouds.setLighting(state); fireLayer?.setLighting(state); @@ -1364,7 +1404,7 @@ export async function createScene( * rig, the scene is applying it, and the environment is derived from the * decision rather than being a second opinion about the light. */ - options.environment?.apply(scene, state, "city"); + options.environment?.apply(scene, state, "city", { offstage: !presented }); }, setAerialFog: ({ near, far }) => { // The three fog owners on a city board and no more. `ports`, `vessels`, diff --git a/src/main.ts b/src/main.ts index 2319cf7..8abbce9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,11 +41,18 @@ import { type Atmosphere, type WeatherObservation, } from "./engine/atmosphere.ts"; -import { createBoardCache, residentCapacity, type BoardCache } from "./engine/boards.ts"; +import { + createBoardCache, + prefetchTarget, + residentCapacity, + type BoardCache, + type PrefetchTier, +} from "./engine/boards.ts"; import { activeRung, buildLadder, handover, + HANDOVER_STANDOFF_M, placesRows, REGION_LABELS, type LadderRung, @@ -66,6 +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 { reconciledCity } from "./cities/reconcile.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; import CALIFORNIA_TRANSPORT from "./transport/california.ts"; @@ -243,7 +251,24 @@ const CALIFORNIA_DESTINATIONS = new Map ({ id: entry.id, city: entry.city }))); +/** + * The ladder is built from the **reconciled** packs, because `World` is. + * + * `world.ts:116` is `this.city = reconciledCity(city)`, so every number the + * engine draws with — `lngScale`, `verticalExaggeration`, road widths — is the + * reconciled one. The ladder reads `focus` to derive each rung's stand-off, and + * a ladder built from the raw packs is a ladder measuring a world nobody is + * looking at. With every rule off that is the same object by identity and this + * line is a no-op; with a rule on it is the difference between the rail agreeing + * with the camera and quietly disagreeing with it. + * + * Deliberately calling `reconciledCity` here rather than reading it off a + * `World`: the ladder is built at module load, before any board is mounted, and + * it must be — `PLACES` is the navigation and it exists before the first frame. + */ +const LADDER = buildLadder( + CITIES.map((entry) => ({ id: entry.id, city: reconciledCity(entry.city) })), +); const PLACES = placesRows(LADDER); const JOURNEY_SESSION_KEY = "tera:journey:v1"; @@ -699,6 +724,23 @@ let satellitesVisible = true; * allocated no WebGL context for the abandoned board. */ let mounting: AbortController | null = null; +/** + * The build nobody asked for. + * + * A strictly lower-priority second lane. It exists because "one map" is a claim + * about continuity, and continuity is paid for by having the detail already on + * the GPU when the camera gets there: CONTRACT §1.1 keeps three boards resident + * on a desktop and pins California, and until now nothing ever *filled* that + * residency except a visitor waiting through a build. + * + * Held separately from `mounting` rather than reusing it, because the two have + * opposite rights. A foreground build may cancel a background one; a background + * build may never cancel or queue in front of a foreground one, and it must be + * abandonable the instant somebody touches anything. Keeping one controller for + * both would make "abort the build" ambiguous at exactly the moment it matters. + */ +let prefetching: AbortController | null = null; + let godmode: Godmode | null = null; let poseEditor: PoseEditor | null = null; /** @@ -1517,6 +1559,16 @@ async function mountCity(id: string): Promise { * build, and a built board belongs to the cache from the moment it lands in * it. */ + /* + * The background lane goes first, and the order is the whole guard. + * + * `createScene` awaits a heightfield the terrain worker is building; if the + * prefetch is still holding that worker when a real request arrives, the + * foreground build queues behind work nobody asked for. Aborting it here — + * before `mounting?.abort()`, before anything else — is what makes a visitor's + * click always the most important thing in the process. + */ + cancelPrefetch(); mounting?.abort(); const mount = new AbortController(); mounting = mount; @@ -1534,22 +1586,41 @@ async function mountCity(id: string): Promise { } showSwitchProgress(entry.label, null); - const record = await buildBoard(entry, mount); - if (record === null) { - pendingPlace = null; - hideSwitchProgress(); - return; + try { + const record = await buildBoard(entry, mount); + if (record === null) { + pendingPlace = null; + hideSwitchProgress(); + return; + } + if (mount.signal.aborted || wantedCity !== id) { + // Somebody changed their mind while this was in the air. It is a complete, + // correct board — but nothing asked for it, and parking an unrequested + // board in a cache with a ceiling would evict one somebody did ask for. + disposeBoard(record); + pendingPlace = null; + hideSwitchProgress(); + return; + } + await presentBoard(record); + } finally { + /* + * A finished mount is not a mount in flight. + * + * `mounting` used to be replaced and never cleared, which was harmless while + * the only thing that read it was the next `mountCity` wanting something to + * abort — an already-resolved controller aborts nothing and costs nothing. + * It stopped being harmless the moment a second lane asked the obvious + * question "is a foreground build happening right now?", because the honest + * answer after the first mount of the session was permanently "yes". The + * background lane armed exactly never, and the only symptom was a feature + * that silently did nothing. + * + * Guarded on identity: a build that was superseded must not clear the + * controller belonging to the build that superseded it. + */ + if (mounting === mount) mounting = null; } - if (mount.signal.aborted || wantedCity !== id) { - // Somebody changed their mind while this was in the air. It is a complete, - // correct board — but nothing asked for it, and parking an unrequested - // board in a cache with a ceiling would evict one somebody did ask for. - disposeBoard(record); - pendingPlace = null; - hideSwitchProgress(); - return; - } - await presentBoard(record); } /** @@ -1565,6 +1636,19 @@ async function mountCity(id: string): Promise { async function buildBoard( entry: { id: string; label: string; city: City }, mount: AbortController, + options: { + /** + * Build without saying so. + * + * The background lane needs this and nothing else does. `onProgress` below + * ends in `bootProgress`, which raises the switch pill — so a board loaded + * ahead of the camera would put "Building The Bay Area… terrain 42%" on + * screen over a visitor who did nothing, which is exactly the "you are + * somewhere else now" chrome that removing the tab strip and the boot card + * was for. A prefetch that announces itself is worse than no prefetch. + */ + quiet?: boolean; + } = {}, ): Promise { const id = entry.id; /** @@ -1804,6 +1888,7 @@ async function buildBoard( // An abandoned build keeps its worker running for a tick or two after the // abort; its percentages must not land on the card the new city is using. onProgress: (p) => { + if (options.quiet) return; if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread); }, ...(access.subject !== null @@ -1890,9 +1975,13 @@ async function buildBoard( const minimap = createMinimap({ world: handle.world, - city: entry.city, + // The pack the *World* is drawing, not the one the module imported. These + // are the same object whenever no reconciliation rule is on, and when one + // is, the plan view drawn from the raw pack is a plan of a different + // California than the one under the camera. + city: handle.world.city, // The plan stops changing shape when the board does. See `planFrame`. - frame: planFrame(entry.city), + frame: planFrame(handle.world.city), camera: handle.stageScene.camera, controls: handle.stageScene.controls, markerPalette: palette, @@ -2029,6 +2118,15 @@ async function presentBoard(record: MountedBoard): Promise { activateBoard(record); hideSwitchProgress(); + /* + * Arriving is itself a reason to look ahead. + * + * The camera listeners below arm the lane as the visitor moves, but landing on + * a board is a pose change nothing fired a `change` event for — and it is the + * single most likely moment for the next board to already be worth having. The + * old prefetch, if any, was cancelled by `mountCity` before this build began. + */ + maybePrefetch(record); // Before the lift, so the incoming board's first drawn frame is already inside // the haze the outgoing one ended in rather than snapping to clear and then @@ -2310,6 +2408,7 @@ function activateBoard(record: MountedBoard): void { lastAltitude = altitude; applyCameraFog(); maybeHandover(record); + maybePrefetch(record); }; const onDragStart = () => { dragging = true; }; const onDragEnd = () => { @@ -2318,6 +2417,7 @@ function activateBoard(record: MountedBoard): void { // refuses while a pointer is down, so without this a descent that ends // below a threshold would sit there until the next event. maybeHandover(record); + maybePrefetch(record); }; controls.addEventListener("change", onCameraMoved); controls.addEventListener("start", onDragStart); @@ -2362,6 +2462,17 @@ let dragging = false; */ const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") === "1"; +/** + * Whether a board may be built before anybody asks for it. + * + * On, and this is the single constant that turns it off — the rollback for the + * whole background lane, per the plan this was built from. It is a constant and + * not a query flag deliberately: a query flag is a thing that ships in one state + * and is measured in another, and every number quoted for this lane was measured + * with it in the state it ships in. + */ +const PREFETCH_ENABLED = true; + function maybeHandover(record: MountedBoard): void { if (!FREE_HANDOVER) return; if (inside || fogDip !== null || record !== visibleBoard) return; @@ -2383,6 +2494,166 @@ function maybeHandover(record: MountedBoard): void { if (next !== null && next !== wantedCity) switchCity(next); } +/** + * The metro tiers the background lane may load, and the stand-off each takes + * over at. Derived from the ladder's own handover table, so there is exactly one + * place in the product that says where a board stops being the right one. + */ +const PREFETCH_TIERS: readonly PrefetchTier[] = CITIES.flatMap((entry) => { + const handoverStandoffM = HANDOVER_STANDOFF_M[entry.id]; + return handoverStandoffM === undefined + ? [] + : [{ id: entry.id, handoverStandoffM, bounds: entry.city.bounds }]; +}); + +/** + * Drop whatever the background lane is doing. + * + * Called before every foreground abort and on every board present, because a + * prefetch that outlives the reason it started is a build competing with a + * visitor for one terrain worker. `createScene` honours the signal by dropping + * the heightfield and resolving `null` without allocating a renderer, so an + * abandoned prefetch costs nothing to abandon. + */ +/** + * How long a board must be on screen and undisturbed before anything loads + * behind it, in milliseconds. + * + * The board that just arrived is still linking shaders, uploading buffers and + * settling its first frames; starting another build inside that window is the + * one way this feature can make the product measurably worse. Deliberately not + * `requestIdleCallback`: Safari did not ship it until recently and it is exactly + * the browser most likely to be on the tightest budget, so the deferral is a + * timer everywhere rather than a good mechanism on some platforms and none on + * others. + */ +const PREFETCH_IDLE_MS = 2_000; + +let prefetchTimer: ReturnType | null = null; + +function cancelPrefetch(): void { + if (prefetchTimer !== null) { + clearTimeout(prefetchTimer); + prefetchTimer = null; + } + prefetching?.abort(); + prefetching = null; +} + +/** + * Load the board the camera is heading for, before it is asked for. + * + * ## Why this is the whole of "loads in the background" + * + * Everything else was already there. `present: false` already exists on + * `SceneOptions` and is already honoured; `boards` already holds three on a + * desktop with California pinned; `presentBoard` already runs a fog dip over a + * cache hit at zero shader links and zero blocked milliseconds. The only missing + * piece was something that decides to build a board nobody has clicked on, and + * the reason it was missing is that it is the piece that can hurt: it competes + * for the terrain worker and it can relight the board on screen. + * + * Both hazards are closed rather than hoped about. The competition is closed by + * `cancelPrefetch()` running before every foreground abort. The relighting is + * closed in `environmentRig.ts` — an off-stage apply borrows the cached probe + * instead of convolving a new one and disposing the visible board's — and by + * `scene.ts` passing `offstage: !presented` on every clock-driven apply. + * + * ## Why the decision is not made here + * + * `prefetchTarget` is pure, takes scalars, and lives in `boards.ts` with the + * eviction policy it has to agree with. This function is the effects half: read + * three numbers off the camera, ask, and act. Keeping the two apart is what lets + * `prefetchPolicy.test.ts` assert against the real pack bounds — including that + * the two metros' trigger discs provably cannot overlap — with no DOM, no WebGL + * and no `three` anywhere near it. + */ +function maybePrefetch(record: MountedBoard): void { + if (!PREFETCH_ENABLED) return; + // Never behind a visitor. A foreground build, an office, or a dip in progress + // all mean something is already happening that matters more than this. + if (inside || mounting !== null || fogDip !== null) return; + if (record !== visibleBoard) return; + if (prefetching !== null) return; + + const target = record.handle.stageScene.controls.target; + const [lat, lng] = record.handle.world.unproject(target.x, target.z); + const id = prefetchTarget({ + tiers: PREFETCH_TIERS, + lat, + lng, + standoffM: record.handle.cameraStandoffMetres(), + resident: boards.ids(), + visible: visibleBoard?.id ?? null, + capacity: residentCapacity(deviceProfile().handheld), + pinned: ["california"], + }); + if (id === null) return; + const entry = CITIES.find((candidate) => candidate.id === id); + if (entry === undefined) return; + + /* + * Armed now, started later. + * + * The timer is what separates "the camera is somewhere a load would be useful" + * from "and it has been there long enough that nothing else needs the machine". + * Every gesture cancels it through `cancelPrefetch`, so a visitor sweeping the + * camera across the state re-arms it repeatedly and starts nothing until they + * stop — which is the behaviour a background lane has to have to be invisible. + */ + if (prefetchTimer !== null) clearTimeout(prefetchTimer); + prefetchTimer = setTimeout(() => { + prefetchTimer = null; + startPrefetch(entry); + }, PREFETCH_IDLE_MS); +} + +/** The build itself, once the deferral above has decided it is safe to start. */ +function startPrefetch(entry: { id: string; label: string; city: City }): void { + const id = entry.id; + // Re-checked after the wait, because two seconds is long enough for every one + // of these to have changed. + if (inside || mounting !== null || fogDip !== null) return; + if (prefetching !== null || boards.has(id) || visibleBoard?.id === id) return; + + const controller = new AbortController(); + prefetching = controller; + void (async () => { + const built = await buildBoard(entry, controller, { quiet: true }); + // Four ways this stops mattering between the ask and the answer, and all + // four have to be checked *after* the await rather than before it: the + // visitor may have clicked something, the lane may have been cancelled, the + // board may have arrived by the front door in the meantime, or the cache may + // no longer have room for it without evicting what is on screen. + if (built === null) { + if (prefetching === controller) cancelPrefetch(); + return; + } + const stale = + controller.signal.aborted || + prefetching !== controller || + boards.has(id) || + visibleBoard?.id === id; + if (stale) { + disposeBoard(built); + if (prefetching === controller) prefetching = null; + return; + } + /* + * Parked, not presented. + * + * `presentBoard` writes the cache itself at the moment a board becomes + * visible; this one never becomes visible, so it writes its own entry — and + * it goes in as the *least* recently shown, which is what `put` already does + * for a board that has never been shown. Anything evicted to make room is + * disposed here, exactly as `presentBoard` does, because the rig's ledger is + * a strong reference and a board left in it retains the whole scene graph. + */ + for (const evicted of boards.put(id, built)) disposeBoard(evicted); + prefetching = null; + })(); +} + /** * The rectangle the plan view draws, which is no longer the board's own. * diff --git a/src/test/integration/sceneWiring.test.ts b/src/test/integration/sceneWiring.test.ts index 2d19645..d913d8a 100644 --- a/src/test/integration/sceneWiring.test.ts +++ b/src/test/integration/sceneWiring.test.ts @@ -632,7 +632,27 @@ test("main.ts makes exactly one call into the chrome, and writes no visibility i test("the city scene is handed the same environment rig the office is", () => { const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8"); const office = readFileSync(path.join(ROOT, "src/interiors/officeScene.ts"), "utf8"); - assert.ok(scene.includes('options.environment?.apply(scene, state, "city")')); + /* + * The call now carries a fourth argument, and this assertion got *stronger* + * rather than looser when it did. + * + * `apply` used to be unconditional everywhere it appeared, which is what let a + * board being built off-stage dispose the visible board's probe and repoint it + * — the rig holds one per kind and rebuilds on a key miss, and a board built + * ahead of the camera is observed at its own centre, so its key differs by + * construction. So the thing worth asserting is not merely that the city hands + * its lighting to the rig; it is that every clock-driven apply is *guarded* by + * whether anyone is looking. A future edit that drops the guard to "simplify" + * fails here. + */ + assert.ok( + scene.includes('options.environment?.apply(scene, state, "city", { offstage: !presented })'), + "the city's clock-driven apply lost its off-stage guard", + ); + assert.ok( + !/options\.environment\?\.apply\(scene, state, "city"\)/.test(scene), + "an unguarded clock-driven apply is back in scene.ts", + ); assert.ok(scene.includes("options.environment?.release(scene)")); assert.ok(office.includes('options.environment?.apply(scene, state, "office")')); assert.ok(office.includes("options.environment?.release(scene)")); diff --git a/src/test/prefetchPolicy.test.ts b/src/test/prefetchPolicy.test.ts new file mode 100644 index 0000000..7ed2d52 --- /dev/null +++ b/src/test/prefetchPolicy.test.ts @@ -0,0 +1,235 @@ +/** + * When a board loads before anybody asks for it. + * + * ## What this is for + * + * "One map" is a claim about continuity, and continuity is paid for by having + * the detail already in memory when the camera arrives. CONTRACT §1.1 already + * keeps three boards resident on a desktop and pins California; what was missing + * was anything that *fills* that residency before a visitor demands it. This is + * the policy half of that — the half with no DOM, no WebGL and no `three` in it, + * so the decision can be argued about in a test instead of in a browser. + * + * ## The two numbers, and why they are safe + * + * `PREFETCH_HANDOVER_MULTIPLE = 3` and `PREFETCH_REACH_MULTIPLE = 1.5` are not + * taste. The first puts San Francisco's trigger at 213.6 km, inside the 133 km + * dead band between California's finest authored rung (242,206 m) and the first + * Bay rung (71,200 m) — so it fires on a state-scale rung with nothing authored + * below it, and never at the 1,551 km overview. The second gives discs of 92 km + * and 146 km around two metros that are 313.9 km apart, and 92 + 146 = 238 < + * 314, so **at most one tier is ever a candidate**. Both properties are asserted + * below against the real bounds, because both are load-bearing for a two-deep + * handheld cache and both would rot silently if a pack's bounds moved. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import CALIFORNIA from "../cities/california.ts"; +import SAN_FRANCISCO from "../cities/sf.ts"; +import SOCAL from "../cities/socal.ts"; +import { HANDOVER_STANDOFF_M } from "../engine/ladder.ts"; +import { + PREFETCH_HANDOVER_MULTIPLE, + PREFETCH_REACH_MULTIPLE, + canAdmit, + prefetchTarget, + residentCapacity, +} from "../engine/boards.ts"; +import type { PrefetchTier } from "../engine/boards.ts"; + +const TIERS: readonly PrefetchTier[] = [ + { id: "sf", handoverStandoffM: HANDOVER_STANDOFF_M.sf!, bounds: SAN_FRANCISCO.bounds }, + { id: "socal", handoverStandoffM: HANDOVER_STANDOFF_M.socal!, bounds: SOCAL.bounds }, +]; + +const DESKTOP = residentCapacity(false); +const HANDHELD = residentCapacity(true); + +/** The camera parked over each metro's own middle. */ +function centre(b: PrefetchTier["bounds"]) { + return { lat: (b.minLat + b.maxLat) / 2, lng: (b.minLng + b.maxLng) / 2 }; +} + +const SF_MID = centre(SAN_FRANCISCO.bounds); +const SOCAL_MID = centre(SOCAL.bounds); + +function ask(over: { lat: number; lng: number }, standoffM: number, extra: Partial[0]> = {}) { + return prefetchTarget({ + tiers: TIERS, + lat: over.lat, + lng: over.lng, + standoffM, + resident: ["california"], + visible: "california", + capacity: DESKTOP, + pinned: ["california"], + ...extra, + }); +} + +describe("what the prefetcher arms on", () => { + it("loads the Bay while the camera is descending toward it", () => { + assert.equal(ask(SF_MID, HANDOVER_STANDOFF_M.sf! * 2), "sf"); + }); + + it("loads the Southland while the camera is descending toward it", () => { + assert.equal(ask(SOCAL_MID, HANDOVER_STANDOFF_M.socal! * 2), "socal"); + }); + + /** + * The common path, and the reason the free-room rule exists at all. + * + * A visitor lands on the state overview at 1,551 km and then clicks a metro. + * On that path a proximity trigger fires exactly never — the camera is + * hundreds of kilometres above every threshold and the next thing that happens + * is the click. A desktop has two empty slots and CONTRACT §1.1 already + * promises they may be filled, so they are. + */ + it("fills the free residency from the state overview, where the visitor actually is", () => { + assert.equal(ask(SF_MID, 1_551_000), "sf"); + // And having taken one, it goes back for the other rather than stopping. + assert.equal(ask(SF_MID, 1_551_000, { resident: ["california", "sf"] }), "socal"); + // Until there is nothing left to want. + assert.equal(ask(SF_MID, 1_551_000, { resident: ["california", "sf", "socal"] }), null); + }); + + it("never proposes the board on screen, or one already resident", () => { + assert.notEqual(ask(SF_MID, HANDOVER_STANDOFF_M.sf! * 2, { visible: "sf" }), "sf"); + assert.notEqual( + ask(SF_MID, HANDOVER_STANDOFF_M.sf! * 2, { resident: ["california", "sf"] }), + "sf", + ); + }); + + /** + * With the cache full, proximity is the only thing that justifies an eviction — + * and this is the handheld case, capacity two with California pinned. A phone + * parked on the state board must load nothing at all. + */ + it("arms nothing on a full handheld cache away from either metro", () => { + const full = { + resident: ["california", "sf"], + visible: "california", + capacity: HANDHELD, + pinned: ["california"], + }; + // Redding, in the far north: nowhere near the Southland. + assert.equal(ask({ lat: 40.58, lng: -122.39 }, HANDOVER_STANDOFF_M.socal!, full), null); + // And the state overview, which is above every threshold. + assert.equal(ask(SOCAL_MID, 1_551_000, full), null); + }); + + it("arms on a full cache only while descending toward the tier it would evict for", () => { + const full = { + resident: ["california", "sf"], + visible: "california", + capacity: HANDHELD, + pinned: ["california"], + }; + assert.equal(ask(SOCAL_MID, HANDOVER_STANDOFF_M.socal! * 2, full), "socal"); + }); +}); + +describe("the two multiples are safe against the real packs", () => { + /** + * The dead band. If the Bay's trigger were above California's finest rung the + * prefetch would fire while the visitor was still choosing a region; if it + * were below the first Bay rung it would fire too late to have helped. + */ + it("puts the Bay trigger inside the ladder's authored dead band", () => { + const trigger = HANDOVER_STANDOFF_M.sf! * PREFETCH_HANDOVER_MULTIPLE; + assert.ok(trigger > HANDOVER_STANDOFF_M.sf!, "the trigger is below the handover"); + assert.ok(trigger < 242_206, `the Bay trigger ${trigger} is above California's finest rung`); + }); + + /** + * The covering-set argument, re-derived. Two overlapping discs would mean a + * camera position from which both metros are candidates, and on a two-deep + * handheld cache that is one board too many. + */ + it("gives the two metros discs that cannot overlap", () => { + const M = 111_320; + const reach = (b: PrefetchTier["bounds"]) => { + const mid = (b.minLat + b.maxLat) / 2; + const north = (b.maxLat - b.minLat) * M; + const east = (b.maxLng - b.minLng) * M * Math.cos((mid * Math.PI) / 180); + return (Math.hypot(north, east) / 2) * PREFETCH_REACH_MULTIPLE; + }; + const gapM = + (SAN_FRANCISCO.bounds.minLat - SOCAL.bounds.maxLat) * M; + const together = reach(SAN_FRANCISCO.bounds) + reach(SOCAL.bounds); + assert.ok( + SAN_FRANCISCO.bounds.minLat > SOCAL.bounds.maxLat, + "the metro bounds now intersect — the whole residency argument rests on them not doing so", + ); + assert.ok( + together < gapM, + `the prefetch discs overlap: ${Math.round(together / 1000)} km of reach across a ` + + `${Math.round(gapM / 1000)} km gap`, + ); + }); + + it("keeps California inside every board's bounds, which is what makes it the pin", () => { + for (const metro of [SAN_FRANCISCO, SOCAL]) { + assert.ok(metro.bounds.minLat >= CALIFORNIA.bounds.minLat); + assert.ok(metro.bounds.maxLat <= CALIFORNIA.bounds.maxLat); + assert.ok(metro.bounds.minLng >= CALIFORNIA.bounds.minLng); + assert.ok(metro.bounds.maxLng <= CALIFORNIA.bounds.maxLng); + } + }); +}); + +describe("admission never costs the board on screen", () => { + it("refuses when the only victim left is the visible board", () => { + // A handheld holding the pin plus the board being looked at is full, and the + // only thing it could drop is the thing the visitor is looking at. + assert.equal( + canAdmit({ + id: "socal", + resident: ["california", "sf"], + visible: "sf", + capacity: HANDHELD, + pinned: ["california"], + }), + false, + ); + }); + + it("admits when a board that is neither pinned nor visible can go", () => { + assert.equal( + canAdmit({ + id: "socal", + resident: ["california", "sf"], + visible: "california", + capacity: HANDHELD, + pinned: ["california"], + }), + true, + ); + }); + + it("admits freely below capacity, and is idempotent for a resident board", () => { + assert.equal( + canAdmit({ id: "sf", resident: ["california"], visible: "california", capacity: DESKTOP }), + true, + ); + assert.equal( + canAdmit({ id: "sf", resident: ["california", "sf"], visible: "sf", capacity: 2 }), + true, + ); + }); + + /** And the policy must be reflected in what `prefetchTarget` proposes. */ + it("proposes nothing a full handheld cache could not take", () => { + assert.equal( + ask(SOCAL_MID, HANDOVER_STANDOFF_M.socal! * 2, { + resident: ["california", "sf"], + visible: "sf", + capacity: HANDHELD, + pinned: ["california"], + }), + null, + ); + }); +}); diff --git a/src/test/reconcileConsumers.test.ts b/src/test/reconcileConsumers.test.ts new file mode 100644 index 0000000..2bb407b --- /dev/null +++ b/src/test/reconcileConsumers.test.ts @@ -0,0 +1,181 @@ +/** + * Everything that reads a pack must read the pack the World is drawing. + * + * ## The bug this is downstream of + * + * `World`'s constructor is `this.city = reconciledCity(city)` (`world.ts:116`), + * so the moment any reconciliation rule is on, there are **two** Californias in + * the process: the object `cities/california.ts` exported, and the rewritten one + * the engine is actually projecting, exaggerating and drawing roads from. + * + * Two consumers were reading the first one while the camera stood in the second: + * + * - `buildLadder` (`main.ts`) derives every rung's stand-off from `focus`, so + * a raw-pack ladder measures a world nobody is looking at — the Places rail + * silently disagreeing with the camera about where it is. + * - `createMinimap` was handed `entry.city` beside a reconciled `handle.world`, + * so the plan view drew a different California than the frame beside it. + * + * Neither is visible with the flag off, because `reconciledCity` returns the + * pack **by identity** when no rule is on — which is exactly what made the bug + * survive: it is latent until the day somebody turns a rule on to take a + * measurement, and then it corrupts the measurement rather than announcing + * itself. + * + * ## What is asserted + * + * Not "main.ts calls the right function" — that is a source-text assertion and + * `integration/sceneWiring.test.ts` already owns that genre. This asserts the + * *property*: for every pack and every rule, the reconciled object differs from + * the raw one in the columns that rule owns, so a consumer holding the wrong one + * is holding provably different numbers. A future consumer wired to the raw pack + * is a bug this file describes even if it cannot name the call site. + */ +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; + +import CALIFORNIA from "../cities/california.ts"; +import SAN_FRANCISCO from "../cities/sf.ts"; +import SOCAL from "../cities/socal.ts"; +import { RULES, reconciledCity, setReconcile, setReconcileWarn } from "../cities/reconcile.ts"; +import type { Rule } from "../cities/reconcile.ts"; +import type { City } from "../engine/types.ts"; + +const PACKS: readonly (readonly [string, City])[] = [ + ["california", CALIFORNIA], + ["sf", SAN_FRANCISCO], + ["socal", SOCAL], +]; + +after(() => { + setReconcile(null); + setReconcileWarn(null); +}); + +/** The columns each rule owns, as the reader of a pack would see them. */ +const OWNED: Readonly unknown>> = { + projection: (city) => city.lngScale ?? null, + exaggeration: (city) => city.verticalExaggeration, + roads: (city) => city.roads.map((road) => road.width).join(","), + ground: (city) => `${city.coastFalloff}|${JSON.stringify(city.palette ?? null)}`, +}; + +describe("a reconciled pack is not the pack the module exported", () => { + it("returns the very same object when nothing is on", () => { + setReconcile(false); + for (const [id, pack] of PACKS) { + assert.equal(reconciledCity(pack), pack, `${id} was copied with the flag off`); + } + }); + + /** + * The load-bearing one. If a rule changes nothing for every pack then a + * consumer reading the raw pack is harmless and this whole file is theatre — + * so the file has to prove it is not. + */ + it("changes the column its rule owns, for at least one pack, for every rule", () => { + for (const rule of RULES) { + setReconcile([rule]); + const moved = PACKS.filter(([, pack]) => { + const read = OWNED[rule]; + return read(reconciledCity(pack)) !== read(pack); + }); + assert.ok( + moved.length > 0, + `rule "${rule}" changed nothing on any pack — either the rule is dead ` + + "or OWNED is reading the wrong column", + ); + } + }); + + it("never mutates the pack the module exported", () => { + const before = PACKS.map(([, pack]) => JSON.stringify(pack)); + setReconcile(true); + for (const [, pack] of PACKS) reconciledCity(pack); + setReconcile(false); + for (const [index, [id]] of PACKS.entries()) { + assert.equal(JSON.stringify(PACKS[index]![1]), before[index], `${id} was mutated in place`); + } + }); + + /** + * The worker's half of the contract. `World` posts `this.city` to the terrain + * worker as a structured clone and the worker builds a second `World` from it; + * `reconciled: true` is what stops that second pass applying every rule again + * on top of itself. Without it the exaggeration rule would square. + */ + it("is idempotent, so the worker's second pass is a no-op", () => { + setReconcile(true); + for (const [id, pack] of PACKS) { + const once = reconciledCity(pack); + assert.equal(once.reconciled, true, `${id} did not mark itself reconciled`); + assert.equal(reconciledCity(once), once, `${id} was reconciled twice`); + } + }); +}); + +describe("the rule parser", () => { + it("takes exact rule names", () => { + setReconcile(null); + for (const rule of RULES) { + // Through the public surface: setReconcile with an explicit list is what + // a node script uses, and the query-string path shares `parse`. + setReconcile([rule]); + const read = OWNED[rule]; + const moved = PACKS.some(([, pack]) => read(reconciledCity(pack)) !== read(pack)); + assert.ok(moved, `"${rule}" did not select itself`); + } + }); + + /** + * The defect: `parse` prefix-matched, so `?reconcile=palette` selected nothing + * and produced an empty set — **identical to the flag being absent**. A + * photograph taken to judge a rule was a photograph of the raw board, and + * nothing in the picture could tell you which. + */ + it("warns loudly rather than silently reading an unknown rule as off", async () => { + const said: string[] = []; + setReconcileWarn((message) => said.push(message)); + setReconcile(null); + const { activeRules } = await import("../cities/reconcile.ts"); + const search = { search: "?reconcile=palette" }; + const globals = globalThis as { location?: unknown }; + const had = "location" in globals; + const previous = globals.location; + globals.location = search; + try { + const on = activeRules(); + assert.equal(on.size, 0, "an unknown rule must not select a real one"); + assert.equal(said.length, 1, "an unknown rule must warn exactly once"); + assert.match(said[0] ?? "", /unknown rule "palette"/); + assert.match(said[0] ?? "", /the flag is NOT off/); + } finally { + if (had) globals.location = previous; + else delete globals.location; + setReconcileWarn(null); + } + }); + + /** + * The other half of prefix-matching: one letter used to select a whole rule, + * which is fine until two rules share it. `p` must now select nothing. + */ + it("does not accept an abbreviation", async () => { + const said: string[] = []; + setReconcileWarn((message) => said.push(message)); + setReconcile(null); + const { activeRules } = await import("../cities/reconcile.ts"); + const globals = globalThis as { location?: unknown }; + const had = "location" in globals; + const previous = globals.location; + globals.location = { search: "?reconcile=p" }; + try { + assert.equal(activeRules().size, 0, '"p" selected a rule by prefix'); + assert.equal(said.length, 1); + } finally { + if (had) globals.location = previous; + else delete globals.location; + setReconcileWarn(null); + } + }); +}); diff --git a/src/test/render/environmentRig.test.ts b/src/test/render/environmentRig.test.ts index 3816192..1bc2e4e 100644 --- a/src/test/render/environmentRig.test.ts +++ b/src/test/render/environmentRig.test.ts @@ -353,3 +353,145 @@ test("the rig constructs no light of any kind", () => { // hence matching the import statement rather than the word.) assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m); }); + +// ---- The prefetch hazard --------------------------------------------------- + +/** + * A board being built off-stage must not relight the board on screen. + * + * ## The mechanism + * + * `createScene` calls `options.environment?.apply(scene, opening, "city")` at + * `scene.ts:811` — unconditionally, and two hundred lines above the + * `if (options.present !== false) stage.setScene(stageScene)` at `:1331` that + * decides whether anybody is going to look at this scene at all. `main.ts` + * builds one rig on one renderer for the life of the page, so an off-stage + * board and the visible one are the same rig. + * + * The rig caches exactly one PMREM target per kind, keyed on a fingerprint of + * the lighting. On a key **miss** it does three things, in this order: renders + * and convolves a new probe on the shared renderer, `dispose()`s the target the + * visible board is currently using, and then walks `applied` reassigning every + * scene of that kind to the new texture. + * + * That is correct and necessary when the miss comes from time passing. It is a + * defect when the miss comes from a board **nobody is looking at**: a board + * prefetched for another part of the state is observed at its own centre, which + * across California is about forty minutes of apparent solar time, so its sun + * direction — and therefore its key — *will* differ. The visible board's sky + * would change because something invisible was loaded behind it. + * + * It is harmless today only because every build is followed within about + * 800 ms by the swap that makes it the visible board. It stops being harmless + * the moment anything builds a board it does not intend to show. + * + * ## What this asserts + * + * The property, at the rig's own boundary: applying to a scene while telling the + * rig it is off-stage must not disturb any scene already applied. Written before + * the fix, and it failed before the fix, which is the only way to know a + * regression test is testing anything. + */ +test("an off-stage apply does not relight the scene already on screen", () => { + const fake = fakeRenderer(); + const rig = createEnvironmentRig(fake.as()); + + const visible = new THREE.Scene(); + rig.apply(visible, lightingState(), "city"); + const wasShowing = visible.environment; + const wasTarget = targetOf(fake, visible); + assert.ok(wasShowing, "the visible board never got an environment"); + + // A board prefetched for elsewhere in the state: same kind, different sun. + // This is the key miss that does the damage. + const offstage = new THREE.Scene(); + rig.apply(offstage, lightingState({ sun: { direction: [0.62, 0.51, -0.6], color: 0xffd9a8, intensity: 1.7 } }), "city", { + offstage: true, + }); + + assert.equal( + visible.environment, + wasShowing, + "building a board off-stage reassigned the visible board's environment", + ); + assert.equal( + wasTarget.texture, + wasShowing, + "building a board off-stage disposed the texture the visible board is using", + ); + + rig.dispose(); +}); + +/** + * The other half, and the reason the fix is a deferral rather than a refusal: + * the off-stage board must still end up correctly lit when it is presented. A + * fix that simply skipped the apply forever would give the incoming board no + * environment at all, which is the "plastic" look this rig exists to cure. + * + * Two cases, and they are different on purpose. With nothing cached there is + * nothing to borrow, so the build happens at presentation — which is correct, + * and is the same one build the board would have paid anyway. With a probe + * already cached on the same key, the off-stage board borrows it and + * presentation costs no convolution at all, which is the case that actually + * matters: it is what a prefetched board hits when the visitor arrives. + */ +test("an off-stage board is lit when it is finally presented", () => { + const fake = fakeRenderer(); + const rig = createEnvironmentRig(fake.as()); + + const offstage = new THREE.Scene(); + rig.apply(offstage, lightingState(), "city", { offstage: true }); + rig.apply(offstage, lightingState(), "city"); + assert.ok(offstage.environment, "a presented board has no environment"); + + rig.dispose(); +}); + +test("a prefetched board borrows the cached probe and presents for free", () => { + const fake = fakeRenderer(); + const rig = createEnvironmentRig(fake.as()); + + const visible = new THREE.Scene(); + rig.apply(visible, lightingState(), "city"); + const built = fake.renders; + assert.ok(built > 0, "the first apply did not build anything"); + + // The board built behind it, at the same hour, is the common case. + const prefetched = new THREE.Scene(); + rig.apply(prefetched, lightingState(), "city", { offstage: true }); + assert.equal(prefetched.environment, visible.environment, "the probe was not borrowed"); + assert.equal(fake.renders, built, "an off-stage apply convolved a new probe"); + + // And arriving costs nothing. + rig.apply(prefetched, lightingState(), "city"); + assert.equal(fake.renders, built, "presenting a cache hit convolved a new probe"); + assert.ok(prefetched.environment, "the presented board lost its environment"); + + rig.dispose(); +}); + +/** + * The failure mode the fix must not introduce: an off-stage board whose sun + * differs must not go dark. It borrows the cached probe — slightly wrong for its + * own hour, and invisible, and corrected the instant it is shown. + */ +test("an off-stage board at a different hour borrows rather than going unlit", () => { + const fake = fakeRenderer(); + const rig = createEnvironmentRig(fake.as()); + + const visible = new THREE.Scene(); + rig.apply(visible, lightingState(), "city"); + + const elsewhere = new THREE.Scene(); + rig.apply( + elsewhere, + lightingState({ sun: { direction: [0.62, 0.51, -0.6], color: 0xffd9a8, intensity: 1.7 } }), + "city", + { offstage: true }, + ); + assert.ok(elsewhere.environment, "an off-stage board at another hour was left with no environment"); + assert.equal(elsewhere.environment, visible.environment, "it did not borrow the cached probe"); + + rig.dispose(); +});