feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+154
-2
@@ -21,8 +21,10 @@ import { readFileSync } from "node:fs";
|
||||
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
|
||||
import { loadRegions, type RegionSet } from "./regions.ts";
|
||||
import { isSafeIceUrl } from "../../src/media/iceValidation.ts";
|
||||
import { adsbAttribution, checkAdsbEndpoint, FIRST_PARTY_RECEIVER } from "./flights/licence.ts";
|
||||
import type {
|
||||
AuthMode,
|
||||
DevicesSourceId,
|
||||
FlightsSourceId,
|
||||
MarkersSourceId,
|
||||
SatellitesSourceId,
|
||||
@@ -38,8 +40,32 @@ export interface WeatherConfig {
|
||||
|
||||
export interface FlightsConfig {
|
||||
source: FlightsSourceId;
|
||||
/** Base URL for the `adsb` source. */
|
||||
/**
|
||||
* Base URL for the `adsb` source, **validated and normalised**.
|
||||
*
|
||||
* Empty on every other source, including a source that was demoted to `sim`
|
||||
* because its endpoint failed the licence gate. That is deliberate: a refused
|
||||
* URL does not survive into the config, so no later code can fetch it by
|
||||
* accident and no later reader can mistake it for one this box vouches for.
|
||||
* `flights/licence.ts` is the gate and explains what it is protecting.
|
||||
*/
|
||||
endpoint: string;
|
||||
/**
|
||||
* The credit lines a live body carries, derived from the endpoint's host.
|
||||
*
|
||||
* Not a constant, and not written next to the fetch. The whole point of
|
||||
* computing it here is that there is no way for the credit and the source to
|
||||
* disagree — which they did, for every value of `TERA_ADSB_ENDPOINT` that was
|
||||
* not adsb.lol.
|
||||
*/
|
||||
attribution: string[];
|
||||
/**
|
||||
* Whether this source's terms let the box re-serve the bytes to third
|
||||
* parties. Gates public caching on `/api/v1/flights`.
|
||||
*/
|
||||
redistributable: boolean;
|
||||
/** The licence id behind `redistributable`, or `null` when nothing is live. */
|
||||
licence: string | null;
|
||||
/** Radius in nautical miles for the `adsb` source. */
|
||||
radiusNm: number;
|
||||
/** Path to a local dump1090 `aircraft.json`. */
|
||||
@@ -61,6 +87,32 @@ export interface SatellitesConfig {
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface DevicesConfig {
|
||||
source: DevicesSourceId;
|
||||
/**
|
||||
* How long a device snapshot may be held before it is asked for again.
|
||||
*
|
||||
* Short, and shorter than weather by two orders of magnitude, because the two
|
||||
* are different kinds of fact: cloud cover moves over ten minutes and a mute
|
||||
* button moves when somebody presses it. This is also the TTL the browser is
|
||||
* told to poll on, so it is the floor on how long a viewer waits to see the
|
||||
* result of somebody else's command.
|
||||
*
|
||||
* It is deliberately **not** a public cache lifetime. Nothing on the devices
|
||||
* routes is ever publicly cached — see `routes/devices.ts`.
|
||||
*/
|
||||
ttlSeconds: number;
|
||||
/**
|
||||
* The simulator's seed, so a deployment can be reproduced.
|
||||
*
|
||||
* The same seed and the same declarations give the same sequence of readings
|
||||
* on every box, which is what makes a bug report about a level meter
|
||||
* actionable and what lets the arena wrap this exact simulator and replay a
|
||||
* rollout. `src/devices/sim.ts` owns the arithmetic.
|
||||
*/
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export interface MarkersConfig {
|
||||
source: MarkersSourceId;
|
||||
/** Path to the JSON snapshot written by the sync oneshot. */
|
||||
@@ -171,6 +223,7 @@ export interface Config {
|
||||
flights: FlightsConfig;
|
||||
satellites: SatellitesConfig;
|
||||
markers: MarkersConfig;
|
||||
devices: DevicesConfig;
|
||||
offices: { dir: string };
|
||||
/**
|
||||
* Where the rosters are. Separate from `offices.dir` because the two hold
|
||||
@@ -192,6 +245,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
const weather = loadWeather(env, degraded);
|
||||
const flights = loadFlights(env, degraded);
|
||||
const satellites = loadSatellites(env, degraded);
|
||||
const devices = loadDevices(env, degraded);
|
||||
const auth = loadAuth(env, degraded);
|
||||
const ice = loadIce(env, degraded);
|
||||
// After auth, because a marker feed with nobody able to sign in is worth a
|
||||
@@ -222,6 +276,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
flights,
|
||||
satellites,
|
||||
markers,
|
||||
devices,
|
||||
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
|
||||
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
|
||||
auth,
|
||||
@@ -330,6 +385,15 @@ const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"];
|
||||
*/
|
||||
const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1);
|
||||
|
||||
/**
|
||||
* The feed pointed at when `TERA_FLIGHTS_SOURCE=adsb` and nothing else is said.
|
||||
*
|
||||
* It is on the allowlist, so the default configuration passes its own gate —
|
||||
* which is the only kind of default worth shipping, and is asserted in
|
||||
* `test/adsbLicence.test.ts` so it stays that way.
|
||||
*/
|
||||
const DEFAULT_ADSB_ENDPOINT = "https://api.adsb.lol";
|
||||
|
||||
function loadFlights(env: Env, degraded: string[]): FlightsConfig {
|
||||
const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
|
||||
let source = oneOf(asked, FLIGHT_SOURCES);
|
||||
@@ -350,9 +414,47 @@ function loadFlights(env: Env, degraded: string[]): FlightsConfig {
|
||||
source = "sim";
|
||||
}
|
||||
|
||||
const askedEndpoint = str(env, "TERA_ADSB_ENDPOINT", DEFAULT_ADSB_ENDPOINT);
|
||||
let endpoint = "";
|
||||
let attribution: string[] = [];
|
||||
let redistributable = false;
|
||||
let licence: string | null = null;
|
||||
|
||||
if (source === "adsb") {
|
||||
// The licence gate. Everything the wire will say about this source is
|
||||
// decided here, from the host, before a single request goes out.
|
||||
const verdict = checkAdsbEndpoint(askedEndpoint);
|
||||
if (verdict.ok) {
|
||||
endpoint = verdict.endpoint;
|
||||
attribution = verdict.attribution;
|
||||
redistributable = verdict.terms.redistributable;
|
||||
licence = verdict.terms.licence;
|
||||
if (verdict.caveat !== null) degraded.push(verdict.caveat);
|
||||
} else {
|
||||
degraded.push(
|
||||
`TERA_ADSB_ENDPOINT="${askedEndpoint}" ${verdict.reason}. Demoted to the simulated ` +
|
||||
"plan: this box will not republish a feed whose terms it cannot name, and it will " +
|
||||
"not credit one feed for another feed's data.",
|
||||
);
|
||||
source = "sim";
|
||||
}
|
||||
}
|
||||
|
||||
if (source === "dump1090") {
|
||||
// The same claim the loopback entry makes, from the same table, because a
|
||||
// receiver's own aircraft.json and a receiver's own HTTP port are the same
|
||||
// data arriving by different doors and must not be credited differently.
|
||||
attribution = adsbAttribution(FIRST_PARTY_RECEIVER);
|
||||
redistributable = FIRST_PARTY_RECEIVER.redistributable;
|
||||
licence = FIRST_PARTY_RECEIVER.licence;
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
endpoint: str(env, "TERA_ADSB_ENDPOINT", "https://api.adsb.lol"),
|
||||
endpoint,
|
||||
attribution,
|
||||
redistributable,
|
||||
licence,
|
||||
radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded),
|
||||
dump1090Path,
|
||||
epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
|
||||
@@ -380,6 +482,56 @@ function radius(asked: number, degraded: string[]): number {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
const DEVICE_SOURCES: DevicesSourceId[] = ["none", "sim", "homeassistant"];
|
||||
|
||||
/**
|
||||
* `none` by default, and the default is the honest one rather than the
|
||||
* impressive one.
|
||||
*
|
||||
* A box that has not been told about any hardware has no hardware. It serves an
|
||||
* empty array and the studio's panels say so, which is the correct picture of a
|
||||
* deployment nobody has wired anything into — the same posture
|
||||
* `TERA_WEATHER_SOURCE` takes for exactly the reason CONTRACT.md §5.1 gives.
|
||||
* `sim` is one variable away and is what the reference deployment runs: a
|
||||
* deterministic state machine, `synthetic: true` on every reading it produces,
|
||||
* and every panel that draws it carries the declaration's own disclosure
|
||||
* sentence.
|
||||
*
|
||||
* `homeassistant` is in the union and is not implemented. That is deliberate
|
||||
* and it demotes loudly rather than silently serving simulated readings under a
|
||||
* name that promises real ones — a source that quietly downgraded from a real
|
||||
* bridge to a simulator would be the exact `first-party-sensor`/`simulated`
|
||||
* confusion `DeviceProvenance` exists to prevent, and it would do it in the one
|
||||
* direction that matters.
|
||||
*/
|
||||
function loadDevices(env: Env, degraded: string[]): DevicesConfig {
|
||||
const asked = str(env, "TERA_DEVICES_SOURCE", "none");
|
||||
let source = oneOf(asked, DEVICE_SOURCES);
|
||||
if (source === null) {
|
||||
degraded.push(
|
||||
`TERA_DEVICES_SOURCE="${asked}" is not one of ${DEVICE_SOURCES.join(", ")}; ` +
|
||||
`serving no device state at all.`,
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
if (source === "homeassistant") {
|
||||
degraded.push(
|
||||
"TERA_DEVICES_SOURCE=homeassistant is named in the wire contract and is not " +
|
||||
"implemented in this build. Demoted to none rather than to sim: serving " +
|
||||
"invented readings under a source that promises a real bridge is the one " +
|
||||
"mistake this field exists to prevent.",
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
ttlSeconds: num(env, "TERA_DEVICES_TTL", 5, degraded),
|
||||
seed: num(env, "TERA_DEVICES_SEED", 8731, degraded),
|
||||
};
|
||||
}
|
||||
|
||||
const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user