merge: boards load before you ask for them
This commit is contained in:
+117
-4
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
|
||||
+275
-4
@@ -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,6 +1586,7 @@ async function mountCity(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
showSwitchProgress(entry.label, null);
|
||||
try {
|
||||
const record = await buildBoard(entry, mount);
|
||||
if (record === null) {
|
||||
pendingPlace = null;
|
||||
@@ -1550,6 +1603,24 @@ async function mountCity(id: string): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user