1
0

feat: boards load before you ask for them

The owner asked for one map, with detail arriving in the background rather than
behind a wait. The interface already said one place (4bd8481: one ladder, no tab
strip, no boot card). This is the world catching up: on a desktop both metros are
now resident within seconds of landing, and the switch that used to be a build
becomes a fog dip over a cache hit.

**Measured, three runs each, California -> The Bay after an 8 s dwell:**
the longest blocking task falls from **521 ms to 58 ms** (before 700/501/521,
after 58/58/61) and long tasks from 2 to 1.

**And the cost, stated rather than omitted.** Over ten seconds sitting on the
state board: 601 frames -> ~571, p95 frame interval 18.0 -> 18.7 ms, and **one
long task of ~500 ms that did not exist before**. The build is not chunked yet,
so this moves a freeze from the moment of interaction to a moment nobody asked
for. That is a real trade and the follow-on that removes it is chunking the
build. `PREFETCH_ENABLED` in main.ts turns the whole lane off in one line.

A parked board draws nothing: 371 draws per frame before the lane lands a board
and 371 after, patched at the GL entry points. The performance budget reproduces
every cell to the digit.

**Three defects found on the way, two of them latent for a while.**

1. `environmentRig` had no idea what off-stage meant. `createScene` calls
   `apply()` unconditionally, two hundred lines above the `present` check, and
   the rig holds one probe per kind: on a key miss it convolves a new one,
   disposes the one the visible board is using, and repoints every applied scene
   at the replacement. A board built ahead of the camera is observed at its own
   centre — about forty minutes of apparent solar time across the state — so its
   key differs by construction. The board on screen would have changed colour
   because something invisible finished loading. Harmless until today only
   because every build was followed within ~800 ms by the swap that presented it.
   Fixed with `ApplyOptions.offstage`; the regression test was written first and
   failed first.

2. `mounting` was assigned and never cleared. Harmless while the only reader was
   the next `mountCity` wanting something to abort. It stopped being harmless the
   moment a second lane asked "is a foreground build in flight?", 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. Found by measuring, not by reading.

3. `buildBoard`'s `onProgress` ends in `bootProgress`, which raises the switch
   pill. A prefetch would have put "Building The Bay Area... terrain 42%" over a
   visitor who did nothing — the exact chrome that removing the tab strip was
   for. `quiet` closes it.

**The policy is pure and lives with the eviction policy it has to agree with.**
`prefetchTarget` and `canAdmit` in boards.ts take scalars, never a camera, so
boards.ts keeps CONTRACT section 1's no-DOM/no-WebGL/no-three promise and
prefetchPolicy.test.ts can assert against the real pack bounds. Two regimes,
because collapsing them was the first version's mistake: with free room the gate
is simply "is there a slot", since the common path is to land on the state at
1,551 km and click a metro, and on that path a proximity trigger fires never;
with the cache full — the handheld case, capacity two with California pinned —
proximity is the only thing that justifies an eviction. The discs are asserted
non-overlapping against the shipped bounds (92 + 146 km of reach across a 314 km
gap), which is the covering-set argument residentCapacity already rests on.

**Also fixed, and separately load-bearing: reconcile's flag parser lied.** It
prefix-matched, so `?reconcile=palette` selected nothing and produced an empty
set — indistinguishable from the flag being absent. Every photograph taken to
judge a rule could have been a photograph of the unreconciled board with no way
to tell. Exact names now, with a warning that says "the flag is NOT off".

And two consumers were reading the raw packs beside a reconciled World:
`buildLadder` derives every rung's stand-off from `focus`, and `createMinimap`
was handed `entry.city` next to `handle.world`. Both now read `world.city`.
Latent with the flag off — `reconciledCity` returns by identity — which is what
kept it alive: it corrupts the measurement rather than announcing itself.

**What I did NOT ship, having tried it.** `roads` on by default. The rule exists
to stop California drawing 1,919 m freeways, and `city.roads` is not what draws
them: California is the one board with `roadTraffic`, so `scene.ts:816` takes the
`createFreewayWorld` branch and `createRoads` — the only reader of `Road.width` —
is never called for it. The visible corridor is a deliberate atlas glyph sized so
DRIVE mode can drive down it. Photographed at three chapters the rule moved
Downtown LA not at all (empty diff bbox), FiDi by RMSE 0.0006, California by
0.001% of pixels; the only measurable effect anywhere was -112 triangles on
Southern California. See DEFAULT_RULES for the whole argument.

1,688 + 295 tests, every gate, chapter-identity OK against the unmodified
fixture, ui-smoke PASS, budget PASS with private-request checks clean on all ten
cells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:15:37 -07:00
parent 2ff11a385e
commit 85be0b13f8
9 changed files with 1239 additions and 31 deletions
+117 -4
View File
@@ -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<Rule> {
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<Rule>();
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<Rule> {
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 {
+168
View File
@@ -166,3 +166,171 @@ export function createBoardCache<T>(options: BoardCacheOptions): BoardCache<T> {
},
};
}
// ---- 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;
}
+41 -3
View File
@@ -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);
+44 -4
View File
@@ -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`,
+290 -19
View File
@@ -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<string, { cityId: string; officeId: stri
* guards in `scripts/brand-assets` aim at. The ladder is a **view** over that
* data and nothing more.
*/
const LADDER = buildLadder(CITIES.map((entry) => ({ 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<void> {
* 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<void> {
}
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<void> {
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<MountedBoard | null> {
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<void> {
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<typeof setTimeout> | 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.
*
+21 -1
View File
@@ -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)"));
+235
View File
@@ -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<Parameters<typeof prefetchTarget>[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,
);
});
});
+181
View File
@@ -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<Record<Rule, (city: City) => 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);
}
});
});
+142
View File
@@ -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();
});