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);
|
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 {
|
||||||
|
|||||||
@@ -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. */
|
/** 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
@@ -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`,
|
||||||
|
|||||||
+290
-19
@@ -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,22 +1586,41 @@ async function mountCity(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showSwitchProgress(entry.label, null);
|
showSwitchProgress(entry.label, null);
|
||||||
const record = await buildBoard(entry, mount);
|
try {
|
||||||
if (record === null) {
|
const record = await buildBoard(entry, mount);
|
||||||
pendingPlace = null;
|
if (record === null) {
|
||||||
hideSwitchProgress();
|
pendingPlace = null;
|
||||||
return;
|
hideSwitchProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mount.signal.aborted || wantedCity !== id) {
|
||||||
|
// Somebody changed their mind while this was in the air. It is a complete,
|
||||||
|
// correct board — but nothing asked for it, and parking an unrequested
|
||||||
|
// board in a cache with a ceiling would evict one somebody did ask for.
|
||||||
|
disposeBoard(record);
|
||||||
|
pendingPlace = null;
|
||||||
|
hideSwitchProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await presentBoard(record);
|
||||||
|
} finally {
|
||||||
|
/*
|
||||||
|
* A finished mount is not a mount in flight.
|
||||||
|
*
|
||||||
|
* `mounting` used to be replaced and never cleared, which was harmless while
|
||||||
|
* the only thing that read it was the next `mountCity` wanting something to
|
||||||
|
* abort — an already-resolved controller aborts nothing and costs nothing.
|
||||||
|
* It stopped being harmless the moment a second lane asked the obvious
|
||||||
|
* question "is a foreground build happening right now?", because the honest
|
||||||
|
* answer after the first mount of the session was permanently "yes". The
|
||||||
|
* background lane armed exactly never, and the only symptom was a feature
|
||||||
|
* that silently did nothing.
|
||||||
|
*
|
||||||
|
* Guarded on identity: a build that was superseded must not clear the
|
||||||
|
* controller belonging to the build that superseded it.
|
||||||
|
*/
|
||||||
|
if (mounting === mount) mounting = null;
|
||||||
}
|
}
|
||||||
if (mount.signal.aborted || wantedCity !== id) {
|
|
||||||
// Somebody changed their mind while this was in the air. It is a complete,
|
|
||||||
// correct board — but nothing asked for it, and parking an unrequested
|
|
||||||
// board in a cache with a ceiling would evict one somebody did ask for.
|
|
||||||
disposeBoard(record);
|
|
||||||
pendingPlace = null;
|
|
||||||
hideSwitchProgress();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await presentBoard(record);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1565,6 +1636,19 @@ async function mountCity(id: string): Promise<void> {
|
|||||||
async function buildBoard(
|
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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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)"));
|
||||||
|
|||||||
@@ -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.)
|
// 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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user