1
0

merge: boards load before you ask for them

This commit is contained in:
2026-08-23 13:15:43 -07:00
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); 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> { function parse(value: string | null): ReadonlySet<Rule> {
if (value === null || value === "" || value === "0" || value === "false") return new Set(); if (value === null || value === "" || value === "0" || value === "false") return new Set();
if (value === "1" || value === "true" || value === "all") return new Set(RULES); if (value === "1" || value === "true" || value === "all") return new Set(RULES);
const wanted = value.split(/[,+\s]+/).filter((part) => part !== ""); 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> { export function activeRules(): ReadonlySet<Rule> {
if (forced !== null) return forced; if (forced !== null) return forced;
const search = (globalThis as { location?: { search?: string } }).location?.search; const search = (globalThis as { location?: { search?: string } }).location?.search;
if (search === undefined) return new Set(); if (search === undefined) return new Set(DEFAULT_RULES);
return parse(new URLSearchParams(search).get("reconcile")); const value = new URLSearchParams(search).get("reconcile");
if (value === null) return new Set(DEFAULT_RULES);
return parse(value);
} }
export function reconcileEnabled(rule?: Rule): boolean { 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. */ /** Which of the two worlds is being reflected. */
export type EnvironmentKind = "city" | "office"; 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 { 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. * Forget a scene that is being torn down.
* *
@@ -198,12 +231,17 @@ export function createEnvironmentRig(renderer: THREE.WebGLRenderer): Environment
} }
return { return {
apply(scene, lighting, kind) { apply(scene, lighting, kind, options) {
if (disposed) return; if (disposed) return;
const key = environmentKey(kind, lighting); const key = environmentKey(kind, lighting);
const current = built.get(kind); 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; let target: THREE.WebGLRenderTarget;
try { try {
target = build(kind, lighting); 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`. // two disagree for the one frame before the app's first `setLighting`.
const opening = options.lighting ?? cityDaylight(pal, boardSpan); const opening = options.lighting ?? cityDaylight(pal, boardSpan);
kit.applyLighting(opening); 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. * The environment before the first layer is added, so the very first frame has
options.environment?.apply(scene, opening, "city"); * 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(createWater(world));
scene.add(createShorePlates(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 // stale detail card for something the pointer is nowhere near reads as a
// bug. // bug.
onExit: () => kit.resetPick(), 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) { tick(dt) {
// Exactly one subsystem owns the camera. In particular, OrbitControls // Exactly one subsystem owns the camera. In particular, OrbitControls
// must stay disabled while the road layer writes its follow pose. // 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 }; arrival = { from, to: rest, elapsed: 0 };
}, },
setLighting: (state) => { setLighting: (state) => {
lighting = state;
kit.applyLighting(state); kit.applyLighting(state);
clouds.setLighting(state); clouds.setLighting(state);
fireLayer?.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 * rig, the scene is applying it, and the environment is derived from the
* decision rather than being a second opinion about the light. * 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 }) => { setAerialFog: ({ near, far }) => {
// The three fog owners on a city board and no more. `ports`, `vessels`, // The three fog owners on a city board and no more. `ports`, `vessels`,
+275 -4
View File
@@ -41,11 +41,18 @@ import {
type Atmosphere, type Atmosphere,
type WeatherObservation, type WeatherObservation,
} from "./engine/atmosphere.ts"; } 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 { import {
activeRung, activeRung,
buildLadder, buildLadder,
handover, handover,
HANDOVER_STANDOFF_M,
placesRows, placesRows,
REGION_LABELS, REGION_LABELS,
type LadderRung, type LadderRung,
@@ -66,6 +73,7 @@ import { createStage, deviceProfile } from "./engine/stage.ts";
import { daylightPhase } from "./engine/solar.ts"; import { daylightPhase } from "./engine/solar.ts";
import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts"; import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts";
import CALIFORNIA from "./cities/california.ts"; import CALIFORNIA from "./cities/california.ts";
import { reconciledCity } from "./cities/reconcile.ts";
import SAN_FRANCISCO from "./cities/sf.ts"; import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts"; import SOCAL from "./cities/socal.ts";
import CALIFORNIA_TRANSPORT from "./transport/california.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 * guards in `scripts/brand-assets` aim at. The ladder is a **view** over that
* data and nothing more. * 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 PLACES = placesRows(LADDER);
const JOURNEY_SESSION_KEY = "tera:journey:v1"; const JOURNEY_SESSION_KEY = "tera:journey:v1";
@@ -699,6 +724,23 @@ let satellitesVisible = true;
* allocated no WebGL context for the abandoned board. * allocated no WebGL context for the abandoned board.
*/ */
let mounting: AbortController | null = null; 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 godmode: Godmode | null = null;
let poseEditor: PoseEditor | 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 * build, and a built board belongs to the cache from the moment it lands in
* it. * 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(); mounting?.abort();
const mount = new AbortController(); const mount = new AbortController();
mounting = mount; mounting = mount;
@@ -1534,6 +1586,7 @@ async function mountCity(id: string): Promise<void> {
} }
showSwitchProgress(entry.label, null); showSwitchProgress(entry.label, null);
try {
const record = await buildBoard(entry, mount); const record = await buildBoard(entry, mount);
if (record === null) { if (record === null) {
pendingPlace = null; pendingPlace = null;
@@ -1550,6 +1603,24 @@ async function mountCity(id: string): Promise<void> {
return; return;
} }
await presentBoard(record); 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;
}
} }
/** /**
@@ -1565,6 +1636,19 @@ async function mountCity(id: string): Promise<void> {
async function buildBoard( async function buildBoard(
entry: { id: string; label: string; city: City }, entry: { id: string; label: string; city: City },
mount: AbortController, 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> { ): Promise<MountedBoard | null> {
const id = entry.id; 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 // 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. // abort; its percentages must not land on the card the new city is using.
onProgress: (p) => { onProgress: (p) => {
if (options.quiet) return;
if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread); if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
}, },
...(access.subject !== null ...(access.subject !== null
@@ -1890,9 +1975,13 @@ async function buildBoard(
const minimap = createMinimap({ const minimap = createMinimap({
world: handle.world, 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`. // The plan stops changing shape when the board does. See `planFrame`.
frame: planFrame(entry.city), frame: planFrame(handle.world.city),
camera: handle.stageScene.camera, camera: handle.stageScene.camera,
controls: handle.stageScene.controls, controls: handle.stageScene.controls,
markerPalette: palette, markerPalette: palette,
@@ -2029,6 +2118,15 @@ async function presentBoard(record: MountedBoard): Promise<void> {
activateBoard(record); activateBoard(record);
hideSwitchProgress(); 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 // 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 // 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; lastAltitude = altitude;
applyCameraFog(); applyCameraFog();
maybeHandover(record); maybeHandover(record);
maybePrefetch(record);
}; };
const onDragStart = () => { dragging = true; }; const onDragStart = () => { dragging = true; };
const onDragEnd = () => { const onDragEnd = () => {
@@ -2318,6 +2417,7 @@ function activateBoard(record: MountedBoard): void {
// refuses while a pointer is down, so without this a descent that ends // refuses while a pointer is down, so without this a descent that ends
// below a threshold would sit there until the next event. // below a threshold would sit there until the next event.
maybeHandover(record); maybeHandover(record);
maybePrefetch(record);
}; };
controls.addEventListener("change", onCameraMoved); controls.addEventListener("change", onCameraMoved);
controls.addEventListener("start", onDragStart); controls.addEventListener("start", onDragStart);
@@ -2362,6 +2462,17 @@ let dragging = false;
*/ */
const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") === "1"; 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 { function maybeHandover(record: MountedBoard): void {
if (!FREE_HANDOVER) return; if (!FREE_HANDOVER) return;
if (inside || fogDip !== null || record !== visibleBoard) return; if (inside || fogDip !== null || record !== visibleBoard) return;
@@ -2383,6 +2494,166 @@ function maybeHandover(record: MountedBoard): void {
if (next !== null && next !== wantedCity) switchCity(next); 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. * 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", () => { 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 scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8");
const office = readFileSync(path.join(ROOT, "src/interiors/officeScene.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(scene.includes("options.environment?.release(scene)"));
assert.ok(office.includes('options.environment?.apply(scene, state, "office")')); assert.ok(office.includes('options.environment?.apply(scene, state, "office")'));
assert.ok(office.includes("options.environment?.release(scene)")); 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.) // hence matching the import statement rather than the word.)
assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m); 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();
});