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:
+69
-4
@@ -135,6 +135,16 @@ export interface Capabilities {
|
||||
export interface Feeds {
|
||||
weather: boolean;
|
||||
flights: boolean;
|
||||
/**
|
||||
* Device state for the studios.
|
||||
*
|
||||
* `false` on a zero-config box and on every clone, which is the default and
|
||||
* is not a gap: `src/devices/adapter.ts` reads this and runs the bundled
|
||||
* simulator in the tab instead, so the studio is alive either way. What the
|
||||
* flag actually prevents is a poll against a box that will answer 404 to all
|
||||
* of it, forever, on every open tab.
|
||||
*/
|
||||
devices: boolean;
|
||||
/**
|
||||
* A satellite catalogue. Off on almost every box, including this repo's own
|
||||
* default — see `loadSatellites` in the server's `config.ts` for why a clone
|
||||
@@ -160,6 +170,34 @@ export interface Access {
|
||||
* read a different field of the same body is a request nobody needs to make.
|
||||
*/
|
||||
feeds: Feeds | null;
|
||||
/**
|
||||
* Every demotion this deployment made, in the server's own words.
|
||||
*
|
||||
* `/api/v1/health` has carried this since the config learned to demote rather
|
||||
* than to die (CONTRACT.md §5.1), and until now **nothing in `src/` read
|
||||
* it**: it was built, served, logged and then dropped on the floor by the one
|
||||
* consumer that could put it in front of a person. So an operator whose
|
||||
* `TERA_WEATHER_CONTACT` was missing saw a permanently clear sky, with the
|
||||
* sentence explaining exactly that sitting in a JSON body one fetch away.
|
||||
*
|
||||
* It rides along here because this module has already paid for the round
|
||||
* trip — `/health` is the first thing boot asks for — and a second identical
|
||||
* GET to read a different field of the same body is a request nobody needs to
|
||||
* make. Empty on a fully-configured box, and empty when nothing answered:
|
||||
* a deployment that does not exist has not demoted anything.
|
||||
*
|
||||
* The interface shows it to admin-tier viewers. It names environment
|
||||
* variables and internal source ids, which is diagnostic detail rather than a
|
||||
* secret — but it is also noise to everybody who cannot act on it.
|
||||
*
|
||||
* **Optional in the type and always present in practice**: every `Access`
|
||||
* `resolveAccess` returns carries one, empty when there is nothing to report.
|
||||
* The `?` is there only so that a hand-written pre-boot literal — the closed
|
||||
* default `main.ts` holds before `resolveAccess()` settles — does not have to
|
||||
* restate an empty array to keep compiling. Read it as `access.degraded ?? []`
|
||||
* and the two cases are the same case.
|
||||
*/
|
||||
degraded?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +259,7 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
const health = await getJson<{
|
||||
auth?: { mode?: unknown; entryUrl?: unknown };
|
||||
sources?: unknown;
|
||||
degraded?: unknown;
|
||||
}>(fetcher, "/health");
|
||||
|
||||
// Something is mounted at `/api/v1` and it is unwell. That is not the same
|
||||
@@ -233,6 +272,7 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
// because we do not yet know which door this deployment uses.
|
||||
if (health.kind === "broken") return access("anon", null, null);
|
||||
|
||||
|
||||
// Nothing answered. Clone-and-run: full experience, no door, no godmode.
|
||||
if (health.kind === "gone") return access("member", null, null);
|
||||
|
||||
@@ -240,10 +280,11 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
|
||||
const entryUrl = entryHref(body.auth?.entryUrl);
|
||||
const feeds = feedsFrom(body.sources);
|
||||
const degraded = degradedFrom(body.degraded);
|
||||
|
||||
// A box with auth switched off is a self-host that chose to stay open. Same
|
||||
// deal as no API at all, and for the same reason it is `member` and not `god`.
|
||||
if (mode === "none") return access("member", null, null, feeds);
|
||||
if (mode === "none") return access("member", null, null, feeds, degraded);
|
||||
|
||||
const fetched = await getJson<{
|
||||
authenticated?: unknown;
|
||||
@@ -286,8 +327,8 @@ export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<
|
||||
*/
|
||||
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
|
||||
|
||||
if (!authenticated) return access("anon", null, signInUrl, feeds);
|
||||
return access(admin ? "god" : "member", subject, signInUrl, feeds);
|
||||
if (!authenticated) return access("anon", null, signInUrl, feeds, degraded);
|
||||
return access(admin ? "god" : "member", subject, signInUrl, feeds, degraded);
|
||||
}
|
||||
|
||||
function access(
|
||||
@@ -295,8 +336,9 @@ function access(
|
||||
subject: string | null,
|
||||
signInUrl: string | null,
|
||||
feeds: Feeds | null = null,
|
||||
degraded: string[] = [],
|
||||
): Access {
|
||||
return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds };
|
||||
return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds, degraded };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,9 +358,32 @@ function feedsFrom(raw: unknown): Feeds {
|
||||
flights: wired("flights"),
|
||||
satellites: wired("satellites"),
|
||||
markers: wired("markers"),
|
||||
devices: wired("devices"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `/health`'s `degraded` block, read as sentences.
|
||||
*
|
||||
* Filtered rather than cast, and for a reason beyond tidiness: these strings go
|
||||
* into the interface, so a body carrying numbers, objects or `null` in that
|
||||
* array would put `[object Object]` in front of an operator who is already
|
||||
* looking at this list because something is wrong. Anything that is not a
|
||||
* string is not a sentence and is dropped.
|
||||
*
|
||||
* Bounded as well. The list is one line per demotion and a fully-configured box
|
||||
* has none, so a body with thousands in it is a server this client should not
|
||||
* be rendering unboundedly — the same disposition `presence/store.ts` takes to
|
||||
* a roster with fifty thousand rows in it.
|
||||
*/
|
||||
function degradedFrom(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((line): line is string => typeof line === "string").slice(0, MAX_DEGRADED);
|
||||
}
|
||||
|
||||
/** More demotions than any real configuration can produce. See `degradedFrom`. */
|
||||
const MAX_DEGRADED = 32;
|
||||
|
||||
/**
|
||||
* The three answers a request to `/api/v1` can carry, which is one more than
|
||||
* this used to have.
|
||||
|
||||
Reference in New Issue
Block a user