SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* The real adapter: markers, weather and traffic from the Tera API, with the
|
||||
* bundled sample data underneath it.
|
||||
*
|
||||
* This is the one place in the browser build that knows the API exists. The
|
||||
* engine takes `Marker[]`, a `WeatherObservation` and a `FlightSource` and has
|
||||
* no idea where any of them came from — that boundary is what lets one renderer
|
||||
* serve a private career map and a public sector map without either being a
|
||||
* fork (ARCHITECTURE.md §3.3), and it is why this file is an adapter rather than
|
||||
* a client scattered through the scene.
|
||||
*
|
||||
* **Every call degrades instead of failing.** No server, a 404, a static host
|
||||
* answering `/api/v1/markers` with its own index.html, a network that has gone
|
||||
* away mid-session: all of it lands on the sample data in `sample.ts` and the
|
||||
* synthetic clear day below, and the map keeps rendering. That is not defensive
|
||||
* habit, it is the acceptance test the whole repo is held to — a stranger clones
|
||||
* this, runs one command, and gets a city, with no account, no key and no
|
||||
* network (CONTRACT.md §0). A `npm run build` deployed to any static host is a
|
||||
* working Tera; pointing it at a server is an upgrade, not a requirement.
|
||||
*
|
||||
* The wire types live in `src/server/wire.ts` and are types only, so importing
|
||||
* them costs the bundle nothing.
|
||||
*/
|
||||
|
||||
import type { WeatherObservation } from "../engine/atmosphere.ts";
|
||||
import { sampleRoute, SimulatedFlights, type SimRoute } from "../engine/flights.ts";
|
||||
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
|
||||
import { seededRandom } from "../engine/world.ts";
|
||||
import type {
|
||||
FlightsBody,
|
||||
FlightsPlanBody,
|
||||
HealthBody,
|
||||
MarkersBody,
|
||||
OfficeDoc,
|
||||
WeatherBody,
|
||||
} from "../server/wire.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./sample.ts";
|
||||
|
||||
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
|
||||
const DEFAULT_BASE = "/api/v1";
|
||||
|
||||
/** How long any one request may take before the fallback is used instead. */
|
||||
const DEFAULT_TIMEOUT_MS = 4000;
|
||||
|
||||
/** How long to wait before trying the flights endpoint again after it fails. */
|
||||
const RETRY_SECONDS = 30;
|
||||
|
||||
export interface TeraApiOptions {
|
||||
/**
|
||||
* Base URL, with no trailing slash. Absolute is allowed and is what a
|
||||
* self-hoster running the browser build and the API on different origins
|
||||
* wants; the default assumes they are the same origin.
|
||||
*/
|
||||
base?: string;
|
||||
/** Injected for tests. Absent means `globalThis.fetch`. */
|
||||
fetch?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* The palette live markers are coloured by.
|
||||
*
|
||||
* The engine looks `colorKey` up and the wire does not carry colours, so
|
||||
* somebody has to supply this and it cannot be the server: what a key *means*
|
||||
* is the consuming app's business. When the API is absent this is ignored and
|
||||
* `SAMPLE_PALETTE` is used instead, because sample keys are not the caller's
|
||||
* keys.
|
||||
*/
|
||||
palette?: MarkerPalette;
|
||||
}
|
||||
|
||||
/**
|
||||
* A response, plus whether it is real.
|
||||
*
|
||||
* `live` is the field that stops a fallback from being a lie. A demo showing
|
||||
* invented companies and a deployment showing real ones must not look identical
|
||||
* to the code above them — the caller is expected to say so in the interface,
|
||||
* and cannot if the adapter quietly papers over the difference.
|
||||
*/
|
||||
export interface Feed<T> {
|
||||
value: T;
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
export interface MarkerFeed extends Feed<Marker[]> {
|
||||
/** The palette these markers are meant to be read with. */
|
||||
palette: MarkerPalette;
|
||||
/** ISO-8601 snapshot time, or `null` for the sample set, which has no date. */
|
||||
generatedAt: string | null;
|
||||
/**
|
||||
* Rows the server's public-shape gate refused, by reason and count.
|
||||
*
|
||||
* Passed through rather than swallowed. A gate that drops rows silently is
|
||||
* indistinguishable from an empty database, which is exactly the confusion
|
||||
* `MarkersBody.refused` exists to prevent — and it is the visible end of the
|
||||
* provenance rule in CONTRACT.md §8.
|
||||
*/
|
||||
refused: { reason: string; count: number }[];
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
export interface WeatherFeed extends Feed<WeatherObservation> {
|
||||
attribution: string[];
|
||||
}
|
||||
|
||||
export interface TeraClient {
|
||||
/** What the deployment turned out to be, or `null` if there is no server. */
|
||||
health(): Promise<HealthBody | null>;
|
||||
markers(): Promise<MarkerFeed>;
|
||||
weather(): Promise<WeatherFeed>;
|
||||
/**
|
||||
* The traffic source, built once. It fetches on its own schedule and never
|
||||
* blocks the render loop; see `HttpFlights`.
|
||||
*/
|
||||
flights(): FlightSource;
|
||||
/**
|
||||
* One office pack. `null` for anything the server will not serve — including
|
||||
* a private one, which answers 404 rather than 403 so the endpoint cannot be
|
||||
* used to enumerate what exists (CONTRACT.md §6).
|
||||
*
|
||||
* There is deliberately no fallback here. A missing marker can be stood in for
|
||||
* by a fictional one; a missing floorplan cannot be invented, and an app with
|
||||
* a bundled office of its own already has the better answer.
|
||||
*/
|
||||
office(id: string): Promise<OfficeDoc | null>;
|
||||
}
|
||||
|
||||
export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
||||
const base = (options.base ?? DEFAULT_BASE).replace(/\/+$/, "");
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
||||
|
||||
/**
|
||||
* One GET, and `null` for every way it can go wrong.
|
||||
*
|
||||
* Deliberately undiscriminating. A 404, a timeout, a CORS refusal, a static
|
||||
* host serving `index.html` with a 200 and an HTML content type — the caller's
|
||||
* response to all of them is the same, and a taxonomy of failures nobody
|
||||
* branches on is a taxonomy nobody maintains.
|
||||
*/
|
||||
async function get<T>(path: string): Promise<T | null> {
|
||||
if (!doFetch) return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await doFetch(`${base}${path}`, {
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
// Checked rather than trusted: a static host answers an unknown path with
|
||||
// the SPA shell and a 200, and `res.json()` on HTML throws where a content
|
||||
// type check just returns.
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (!type.includes("json")) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
let flightSource: FlightSource | null = null;
|
||||
|
||||
return {
|
||||
health: () => get<HealthBody>("/health"),
|
||||
|
||||
async markers(): Promise<MarkerFeed> {
|
||||
const body = await get<MarkersBody>("/markers");
|
||||
if (!body || !Array.isArray(body.markers)) return sampleMarkerFeed();
|
||||
return {
|
||||
// A `WireMarker` *is* a `Marker` with a provenance field on it, so this
|
||||
// is a widening and not a translation — which is the property
|
||||
// `wire.ts` chose the shape for. The provenance itself is the server's
|
||||
// to enforce and is deliberately not re-checked here: a browser
|
||||
// silently dropping rows a self-hoster explicitly allowlisted would look
|
||||
// exactly like an empty database.
|
||||
value: body.markers,
|
||||
live: true,
|
||||
palette: options.palette ?? {},
|
||||
generatedAt: body.generatedAt ?? null,
|
||||
refused: body.refused ?? [],
|
||||
attribution: body.attribution ?? [],
|
||||
};
|
||||
},
|
||||
|
||||
async weather(): Promise<WeatherFeed> {
|
||||
const body = await get<WeatherBody>("/weather");
|
||||
if (!body) return { value: CLEAR_DAY, live: false, attribution: [] };
|
||||
// `WeatherBody` is structurally a `WeatherObservation` plus fields no
|
||||
// renderer reads, which `atmosphere.ts` says in as many words. The extra
|
||||
// fields ride along harmlessly and the engine never sees them.
|
||||
return { value: body, live: !body.synthetic, attribution: body.attribution ?? [] };
|
||||
},
|
||||
|
||||
flights(): FlightSource {
|
||||
flightSource ??= new HttpFlights(get, SAMPLE_ROUTES);
|
||||
return flightSource;
|
||||
},
|
||||
|
||||
office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The clear day a zero-config box serves, restated in the browser.
|
||||
*
|
||||
* The server does this too — a weather source configured without what it needs
|
||||
* is demoted rather than fatal, and it answers `synthetic: true` forever
|
||||
* (CONTRACT.md §5.1). This is the same answer for the case where there is no
|
||||
* server at all. Note what it does *not* do: `visibilityKm` stays null, which
|
||||
* `atmosphere.ts` reads as "nobody measured" rather than as "unlimited", so San
|
||||
* Francisco's marine layer still runs off its own climatology instead of being
|
||||
* overruled by a fact nobody observed.
|
||||
*/
|
||||
const CLEAR_DAY: WeatherObservation = {
|
||||
cloudCover: 0.1,
|
||||
precipitation: 0,
|
||||
visibilityKm: null,
|
||||
windKph: null,
|
||||
windDirDeg: null,
|
||||
condition: "clear",
|
||||
};
|
||||
|
||||
function sampleMarkerFeed(): MarkerFeed {
|
||||
return {
|
||||
value: SAMPLE_MARKERS,
|
||||
live: false,
|
||||
palette: SAMPLE_PALETTE,
|
||||
generatedAt: null,
|
||||
refused: [],
|
||||
attribution: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Traffic --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Traffic over HTTP, in whichever of the two shapes the server chose.
|
||||
*
|
||||
* `poll()` is synchronous and never awaits the network, which is the whole
|
||||
* design. `scene.ts` calls it from the render loop, and a source that returned a
|
||||
* promise resolving on a slow fetch would put a frame's aircraft update behind a
|
||||
* round trip; instead the network runs in the background on the body's own TTL
|
||||
* and `poll` answers from whatever is currently in hand.
|
||||
*
|
||||
* The two modes are not symmetrical, and `wire.ts` explains why. A *plan* — the
|
||||
* simulator's routes, a fixed epoch and a seed — is evaluated locally at one
|
||||
* request per TTL, and because the epoch is fixed rather than the server's start
|
||||
* time, two people on different machines see the same aircraft in the same
|
||||
* places. *Live* traffic has no closed form, so it arrives as positions and is
|
||||
* refetched.
|
||||
*
|
||||
* Until the first response lands, and after any failure, this is the simulator
|
||||
* over `SAMPLE_ROUTES`. An empty sky is a worse answer than an invented one, and
|
||||
* the invented one is labelled as such in `sample.ts`.
|
||||
*/
|
||||
class HttpFlights implements FlightSource {
|
||||
/**
|
||||
* One second, which is the *evaluation* cadence and not the request cadence.
|
||||
* A plan is arithmetic and wants to be evaluated every frame or close to it;
|
||||
* the network is on `nextFetchAt` and is a great deal slower.
|
||||
*/
|
||||
readonly interval = 1;
|
||||
|
||||
private readonly fallback: SimulatedFlights;
|
||||
private plan: FlightsPlanBody | null = null;
|
||||
private planPhase: number[] = [];
|
||||
private live: Aircraft[] | null = null;
|
||||
private nextFetchAt = 0;
|
||||
private fetching = false;
|
||||
|
||||
constructor(
|
||||
private readonly get: <T>(path: string) => Promise<T | null>,
|
||||
fallbackRoutes: SimRoute[],
|
||||
) {
|
||||
this.fallback = new SimulatedFlights(fallbackRoutes);
|
||||
}
|
||||
|
||||
poll(): Aircraft[] {
|
||||
this.refreshIfStale();
|
||||
if (this.plan) return evaluatePlan(this.plan, this.planPhase, Date.now());
|
||||
if (this.live) return this.live;
|
||||
return this.fallback.poll();
|
||||
}
|
||||
|
||||
private refreshIfStale(): void {
|
||||
const now = Date.now();
|
||||
if (this.fetching || now < this.nextFetchAt) return;
|
||||
this.fetching = true;
|
||||
void this.get<FlightsBody>("/flights")
|
||||
.then((body) => {
|
||||
if (!body) {
|
||||
// Hold whatever was already in hand rather than reverting to the
|
||||
// simulator: a deployment that has been showing real traffic for an
|
||||
// hour and drops one request should keep showing it, slightly stale,
|
||||
// not silently swap in fiction.
|
||||
this.nextFetchAt = now + RETRY_SECONDS * 1000;
|
||||
return;
|
||||
}
|
||||
if (body.mode === "plan") {
|
||||
this.plan = body;
|
||||
this.planPhase = phasesFor(body);
|
||||
this.live = null;
|
||||
} else {
|
||||
this.live = body.aircraft;
|
||||
this.plan = null;
|
||||
}
|
||||
this.nextFetchAt = now + Math.max(1, body.ttlSeconds) * 1000;
|
||||
})
|
||||
.finally(() => {
|
||||
this.fetching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-route phase offsets, from the seed the server sent.
|
||||
*
|
||||
* Same generator and same order as `SimulatedFlights`, which is what makes the
|
||||
* server's promise true: every viewer draws the seed once, in route order, and
|
||||
* arrives at the same sky.
|
||||
*/
|
||||
function phasesFor(plan: FlightsPlanBody): number[] {
|
||||
const rand = seededRandom(plan.seed);
|
||||
return plan.routes.map(() => rand());
|
||||
}
|
||||
|
||||
function evaluatePlan(plan: FlightsPlanBody, phase: number[], nowMs: number): Aircraft[] {
|
||||
const seconds = (nowMs - plan.t0) / 1000;
|
||||
// `WireSimRoute` is structurally `SimRoute`; the restatement in `wire.ts` is
|
||||
// there so the server can build one without importing three.js.
|
||||
return plan.routes.map((route, i) => sampleRoute(route, seconds / route.duration + (phase[i] ?? 0)));
|
||||
}
|
||||
Reference in New Issue
Block a user