1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/regions.ts
T
karti e41c90fe8d Real weather, real aircraft, a heightfield off the main thread, and instruments
Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 03:25:31 -07:00

333 lines
13 KiB
TypeScript

/**
* The places this deployment will answer for, and the refusal of everywhere else.
*
* `TERA_ORIGIN_LAT/LNG` was one point, and the map has two cities six hundred
* kilometres apart with genuinely different skies — the marine-layer comment in
* `engine/atmosphere.ts` makes the point exactly: Los Angeles gets its own
* weather, not San Francisco's fog. So weather and flights have to answer for a
* *requested* place rather than for the box's one origin.
*
* ### Why this is an allowlist and not a lookup
*
* The obvious implementation — take `?lat=&lng=` and hand it to NWS — turns an
* unauthenticated endpoint into a free geocoding proxy for the whole planet.
* Two things go wrong with that, and neither is hypothetical. It is an
* amplification vector: one cheap request here becomes one expensive request to
* somebody else's public-good API, from an address they will blame. And it is
* how a deployment's User-Agent gets blocked, because NWS's fair-use policy is
* written against exactly this pattern and the contact string in
* `TERA_WEATHER_CONTACT` is the operator's own name on the request.
*
* The rule here is therefore: **a caller's coordinate is never forwarded
* upstream. It only selects among the points the operator configured.** A
* request for Berkeley resolves to the San Francisco region and fetches San
* Francisco's centre; a request for Fresno is refused with a 400 naming what is
* served. The upstream key space is the region list, so it is bounded by the
* environment file and cannot be grown by anybody sending requests — which is
* the property that makes per-region caching, the NWS station cache and the
* adsb.lol poll budget in `flights/adsb.ts` all bounded too.
*
* A coarse grid was the other candidate and was rejected: snapping to 0.5° still
* leaves a caller able to name a hundred thousand distinct cells, which bounds
* nothing that matters. Refusing is the honest answer, and a self-hoster who
* wants their own city writes one line of `TERA_REGIONS`.
*/
/** Kilometres per degree of latitude. Good to a tenth of a percent anywhere. */
const KM_PER_DEGREE = 111.195;
/**
* How far from a region's centre a request may land and still resolve there,
* when the operator did not say.
*
* 120 km covers both shipped boards with room to spare — the far corner of the
* Bay Area pack is 89 km from its centre and SoCal's is 97 km — while leaving
* the two regions comfortably disjoint, since their centres are about 440 km
* apart. It is deliberately not tight: the point of the radius is to refuse
* somewhere this deployment has nothing to say about, not to police the edge of
* the rendered board.
*/
export const DEFAULT_RADIUS_KM = 120;
export interface Region {
/** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
id: string;
lat: number;
lng: number;
/** Kilometres. See `DEFAULT_RADIUS_KM`. */
radiusKm: number;
}
/**
* At least one region, always, with the first one being the default.
*
* A tuple rather than an array because "the default region" is read on every
* request that omits a query, and a `Region | undefined` there would be a
* falsehood the type system made everybody handle.
*/
export type RegionSet = [Region, ...Region[]];
/**
* The two cities this repo ships, with the centres their packs declare —
* `SAN_FRANCISCO_CITY.center` and `SOCAL_CITY.center` in `src/cities/`.
*
* They are restated rather than imported for the same reason `wire.ts` restates
* `SimRoute`: a city pack is three thousand lines of coastline for a renderer
* that owns three.js, and the API has one runtime dependency and intends to keep
* it. Two numbers each, and the day a pack moves its centre the weather resolves
* to a point a few kilometres off, which is a rounding error against a radius of
* 120 km.
*/
const SHIPPED: Region[] = [
{ id: "sf", lat: 37.7749, lng: -122.4194, radiusKm: DEFAULT_RADIUS_KM },
{ id: "socal", lat: 33.82, lng: -118.05, radiusKm: DEFAULT_RADIUS_KM },
];
/**
* `id:lat,lng` with an optional `:radiusKm`, which is the whole grammar.
*
* The decimal places are capped in the pattern rather than checked afterwards
* because it is the same cap the query parser applies, and the two agreeing is
* the point: a coordinate nobody could ask for is a coordinate nobody should be
* able to configure either.
*/
const ENTRY = /^([a-z0-9][a-z0-9-]{0,31}):(-?\d{1,3}(?:\.\d{1,6})?),(-?\d{1,3}(?:\.\d{1,6})?)(?::(\d{1,4}(?:\.\d{1,3})?))?$/;
export interface LoadRegionsOptions {
/** `TERA_REGIONS`, raw. Empty means "work it out from the defaults". */
spec: string;
/** `TERA_ORIGIN_LAT/LNG`, already read and defaulted. */
origin: { lat: number; lng: number };
/** Whether the operator actually wrote an origin, as opposed to inheriting SF. */
originConfigured: boolean;
degraded: string[];
}
/**
* The region set for this box.
*
* Three cases, in order, and the ordering is what keeps every existing
* deployment answering exactly as it did:
*
* 1. `TERA_REGIONS` is set — that list is the answer, in the operator's order.
* 2. Otherwise the two shipped cities, because that is what the bundled map
* draws and serving only one of them is wrong for half the board.
* 3. On top of case 2, an operator who pointed `TERA_ORIGIN_LAT/LNG` somewhere
* else gets that point as a region of its own, first in the list and
* therefore the default. A bare `GET /api/v1/weather` on their box answers
* for their origin, exactly as it did before this file existed. If their
* origin already falls inside a shipped region, that region moves to the
* front instead of being duplicated.
*
* Malformed entries are dropped with a line in `degraded` rather than being
* fatal, and a spec in which *nothing* parses falls all the way back to case 2.
* CONTRACT.md §5.1: a typo is a demotion, never a refusal to boot.
*/
export function loadRegions(opts: LoadRegionsOptions): RegionSet {
const configured = parseSpec(opts.spec, opts.degraded);
if (configured.length > 0) return asSet(configured, configured[0] as Region);
const shipped = SHIPPED.map((region) => ({ ...region }));
const containing = shipped.findIndex((region) => contains(region, opts.origin));
if (containing > 0) {
const moved = shipped[containing] as Region;
shipped.splice(containing, 1);
shipped.unshift(moved);
} else if (containing === -1 && opts.originConfigured) {
shipped.unshift({
id: "origin",
lat: opts.origin.lat,
lng: opts.origin.lng,
radiusKm: DEFAULT_RADIUS_KM,
});
}
return asSet(shipped, shipped[0] as Region);
}
/**
* `Region[]` to `RegionSet`, with the caller supplying the head it has already
* proved is there. The alternative is a cast on the whole array, which would
* also silence the empty case this type exists to rule out.
*/
function asSet(regions: Region[], head: Region): RegionSet {
return [head, ...regions.slice(1)];
}
function parseSpec(spec: string, degraded: string[]): Region[] {
const trimmed = spec.trim();
if (trimmed === "") return [];
const regions: Region[] = [];
for (const raw of trimmed.split(/[;\n]/)) {
const entry = raw.trim();
if (entry === "") continue;
const match = ENTRY.exec(entry);
if (match === null) {
degraded.push(
`TERA_REGIONS entry "${entry}" is not \`id:lat,lng\` with an optional ` +
`\`:radiusKm\` (try \`sf:37.7749,-122.4194\`); ignoring it.`,
);
continue;
}
const id = match[1] as string;
const lat = Number(match[2]);
const lng = Number(match[3]);
const radiusKm = match[4] === undefined ? DEFAULT_RADIUS_KM : Number(match[4]);
if (Math.abs(lat) > 90 || Math.abs(lng) > 180 || radiusKm <= 0) {
degraded.push(`TERA_REGIONS entry "${entry}" is not a place on Earth; ignoring it.`);
continue;
}
if (regions.some((region) => region.id === id)) {
degraded.push(`TERA_REGIONS names "${id}" twice; keeping the first one.`);
continue;
}
regions.push({ id, lat, lng, radiusKm });
}
if (regions.length === 0 && trimmed !== "") {
degraded.push(
"TERA_REGIONS was set but nothing in it parsed; serving the two cities the " +
"map ships with instead.",
);
}
return regions;
}
// ---- Answering a request --------------------------------------------------
/**
* What a route hands in. `unknown` throughout because this is untrusted query
* input: Fastify hands back a string for `?lat=1`, an **array** for
* `?lat=1&lat=2`, and `undefined` for absent, and a signature that claimed
* `string` would be a lie the first time somebody repeated a parameter.
*/
export interface RegionQuery {
city?: unknown;
lat?: unknown;
lng?: unknown;
}
export type RegionResolution =
| { ok: true; region: Region }
/** Already a sentence. The route sends it as `ErrorBody.message`. */
| { ok: false; message: string };
/** Six decimal places is about 0.1 m. Anything finer is a bug or a probe. */
const DEGREES = /^-?\d{1,3}(?:\.\d{1,6})?$/;
const CITY_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
/**
* Turn a query into one of the configured regions, or into a refusal.
*
* Every branch that is not a resolved region is a **400**, not a degraded body.
* That is the one place this file departs from the "degrade, never fail"
* convention, and the distinction is who made the mistake: an upstream that is
* down is not the caller's fault and must not become their problem, whereas
* `?lat=banana` is a client bug that a 200 full of clear sky would hide until
* somebody wondered why the fog never rolls in.
*/
export function resolveRegion(regions: RegionSet, query: RegionQuery): RegionResolution {
const city = query.city;
const hasCity = city !== undefined && city !== "";
const hasLat = query.lat !== undefined && query.lat !== "";
const hasLng = query.lng !== undefined && query.lng !== "";
if (hasCity && (hasLat || hasLng)) {
return { ok: false, message: "Ask with ?city= or with ?lat=&lng=, not both." };
}
if (hasCity) {
if (typeof city !== "string" || !CITY_ID.test(city)) {
return { ok: false, message: `city must be one of: ${served(regions)}.` };
}
const region = regions.find((candidate) => candidate.id === city);
if (region === undefined) {
return { ok: false, message: `This deployment serves: ${served(regions)}.` };
}
return { ok: true, region };
}
if (hasLat !== hasLng) {
return { ok: false, message: "lat and lng have to be given together." };
}
if (!hasLat) return { ok: true, region: regions[0] };
const lat = degrees(query.lat, 90);
const lng = degrees(query.lng, 180);
if (lat === null || lng === null) {
return {
ok: false,
message:
"lat and lng must be plain decimal degrees within ±90 and ±180, " +
"with at most six decimal places.",
};
}
const region = nearest(regions, lat, lng);
if (region === null) {
return {
ok: false,
message:
`Nothing this deployment serves is near ${lat},${lng}. It serves: ` +
`${served(regions)}. Ask for one of those by id, or add yours to ` +
`TERA_REGIONS on the server.`,
};
}
return { ok: true, region };
}
function served(regions: RegionSet): string {
return regions.map((region) => region.id).join(", ");
}
function degrees(raw: unknown, limit: number): number | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
// The pattern is doing the work `Number()` would do badly: it rejects `NaN`,
// `Infinity`, `1e400`, `0x2f`, `37.7749deg` and the empty string, all of which
// `Number()` either accepts or turns into a value that then has to be
// re-checked. One regex, one meaning.
if (!DEGREES.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
}
/** The closest region that claims the point, or `null` if none of them does. */
function nearest(regions: RegionSet, lat: number, lng: number): Region | null {
let best: Region | null = null;
let bestKm = Infinity;
for (const region of regions) {
const km = distanceKm(region, lat, lng);
if (km <= region.radiusKm && km < bestKm) {
best = region;
bestKm = km;
}
}
return best;
}
/**
* Equirectangular rather than haversine, because at these distances the error is
* under half a percent and the comparison it feeds is against a radius chosen to
* the nearest ten kilometres. Trigonometry accurate to the metre would be
* decorating a threshold that is deliberately fuzzy.
*/
function distanceKm(region: Region, lat: number, lng: number): number {
const dLat = lat - region.lat;
const meanLat = (((lat + region.lat) / 2) * Math.PI) / 180;
const dLng = (lng - region.lng) * Math.cos(meanLat);
return Math.hypot(dLat, dLng) * KM_PER_DEGREE;
}
function contains(region: Region, point: { lat: number; lng: number }): boolean {
return distanceKm(region, point.lat, point.lng) <= region.radiusKm;
}