1
0

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:
2026-08-21 19:44:24 -07:00
parent 8738367258
commit db074e9cf7
150 changed files with 36237 additions and 2586 deletions
+46 -1
View File
@@ -87,7 +87,10 @@ tera/
├── src/media/ # separate screen signaling, ICE and texture lifecycles
├── src/profile/ # local profile plus ephemeral webcam consent/capture
├── src/adapters/ # generic HTTP and sample-data boundaries
── src/main.ts # the standalone demo app
── src/devices/ # smart hardware: types, a fixed-step simulator, the feed seam
├── src/arena/ # the RL environments, wrapping the same simulators the renderer drives
├── src/ui/ # every interface decision, and the one module that writes to the page
└── src/main.ts # the standalone demo app — assembly, and nothing else
```
The split that matters: **`engine` never imports `cities`, and neither imports
@@ -101,6 +104,27 @@ and a city share the projection, camera, lighting and render loop, and differ
only in what they put in the scene. That is why `scene.ts` owns the loop and
knows nothing about terrain specifically.
`ui` is the newest of these and the split inside it is the one worth knowing.
`ui/chromeState.ts` is a **pure function**: hand it a plain object describing what
the application is, get a plain object back describing what the interface should
be. `ui/mount.ts` is the only module in the product that writes to the DOM, and it
writes exactly what that function returned. `main.ts` therefore makes one call —
`chrome.apply(chromeState(inputs))` — where it used to make about forty
`element.hidden = someCondition` decisions with the condition and the write on the
same line, none of which could be reached without a WebGL context. The rule that
keeps it that way: a new piece of chrome is a field in `ChromeInputs`, a decision
in `chromeState` and a write in `mount`, and never a fortieth mutation in the app.
`devices` follows the same shape one level down, and for the reason §7 gives about
presence: a `DeviceDeclaration` is **authored** — it says a microphone exists, what
it can be asked to do and which prop is its hardware — and lives in the office
pack, public by construction. A `DeviceState` is what that microphone is *hearing*,
never appears in a pack, and arrives from a route that can refuse an anonymous
caller. `src/interiors/devices.ts` is the render layer for the first and
`src/devices/sim.ts` is the state machine behind the second; the arena imports the
state machine directly, so what a policy is trained against and what a viewer is
looking at are the same code.
`transport` also stays outside the renderer. A `TransportPack` is plain JSON and
`VehicleSimulation` returns plain geographic poses. Three.js enters only in the
`roadTraffic` render layer, which projects poses through the active `World`.
@@ -372,6 +396,27 @@ boards; walkable offices; procedural actors, vehicles and aircraft; optional
authoritative realtime; local webcam faces; and separately authorized office
screen signaling. `BUILD_PLAN.md` is the milestone evidence ledger.
Four things landed together and are worth naming, because each crosses a boundary
this document describes:
- **One environment map for the page.** `src/engine/environmentRig.ts` derives a
sky (city) or a room (office) from the `LightingState` that `Atmosphere` already
decided, so the eleven metallic material roles and the Model X's clearcoat have
something to reflect. It sits at the *end* of §4's one-way street: it is handed a
decision and never makes one, and it constructs no light. One rig is built beside
the one renderer and shared by every scene; each scene releases itself from it on
dispose.
- **The interface is a pure function plus an applier.** See §2 above.
- **Smart hardware in the studios**, declared in packs and simulated in the tab
when a deployment has no device source — the same anon-first fallback
`SimulatedFlights` and `sample.ts` already make.
- **A Model X on each studio's arrival apron**, driven by
`src/transport/vehicleTelemetry.ts`, and overhead traffic in a sited office's
sky drawn from the same `FlightSource` the board outside is polling. The
aircraft are placed on a dome at their true bearing and elevation rather than at
their true range: an airliner at twenty kilometres is past a room-scale far
plane, and a map symbol drawn in 3-D is what §4's traffic has always been.
The source code is Apache-2.0. `src/assets/LICENSE-ART` additionally dedicates
the artistic output of the procedural asset library under CC0-1.0.
`PROVENANCE.json`, the dependency allowlist and SPDX SBOM gate the distribution.
+271 -6
View File
@@ -1,9 +1,9 @@
# Tera Arena environments
`@lumbridge/tera/arena` is Tera's renderer-independent RL boundary. It imports
no Three.js scene, canvas, DOM input, network client, or asset code. The five
shipped environments wrap the same fixed-step controllers and plans used by the
interactive client:
no Three.js scene, canvas, DOM input, network client, or asset code — there is a
test that greps for it. The six shipped environments wrap the same fixed-step
controllers and plans used by the interactive client:
| Environment | Existing simulator | Goal | Safety terminal |
|---|---|---|---|
@@ -12,6 +12,11 @@ interactive client:
| `office-jobs-v1` | `Plan` + `robotRoutes` + `robotActivity` | complete an authored simulated patrol/delivery/inspection job | collision stall or repeated invalid interaction |
| `crow-nav-v1` | `ActorController` in crow flight mode | reach a 3D waypoint | altitude/horizontal envelope contact |
| `california-flight-v1` | `AircraftController` | reach a geographic/altitude waypoint | California flight-envelope contact |
| `studio-ops-v1` | all of `Plan` + `robotActivity` + `createSimulatedDevices` + `createSimulatedVehicleTelemetry` + `solarPosition` | run the studio: complete the robot's job without wasting the microphone, the reserve or the departure | collision stall, invalid interaction, reserve exhaustion, missed departure |
The first five isolate one controller each. `studio-ops-v1` is the one where the
variables are coupled, and it is the frame the whole product is presented in:
see [studio-ops-v1](#studio-ops-v1) below.
## Contract
@@ -68,6 +73,110 @@ own package or service.
action and observation fields, component reward meanings, public split ids,
fixed step, maximum steps, safety terminals, and baseline claims.
### Selection changed in v2, and every manifest's version says so
`ArenaScenarioRegistry` used to resolve a bare `{ split }` request with
`candidates[seed % candidates.length]`, which binds every seed anybody has run
to a scenario's *position in an array literal*. Inserting a scenario in the
middle of a split — the most ordinary edit there is, and one a reviewer reads as
purely additive — silently remapped every seed past it. Nothing failed; the
numbers in a results table just stopped meaning what they meant.
Selection is now highest-random-weight over the scenario **id**: each candidate
scores `deriveArenaSeed(seed, "<env>:<split>:<id>")` and the highest wins.
Adding a scenario moves only the seeds the new id actually wins, removing one
moves only the seeds it held, and reordering the literal moves nothing at all.
Explicit ids were never affected and are not affected now.
Because that changes which scenario an existing seed resolves to, the five
environments that shipped at `version: 1` are at `version: 2`. Snapshots and
traces taken under v1 are rejected rather than silently resumed into a different
task, which is exactly what `version` is for.
## Spaces, vectors and rollouts
`observationFields` tells a reader what an environment sees. It does not tell a
trainer how many numbers that is, so every consumer wrote its own encoder
against the environment's TypeScript source — a fork of the observation contract
kept outside this repository and silently invalidated by any field added to it.
Each manifest now also carries `observationSpace` and `actionSpace`: the same
fields, in the same order, each with an encoding.
```ts
import {
ARENA_ENVIRONMENTS,
flattenObservation,
observationWidth,
rollout,
structureAction,
} from "@lumbridge/tera/arena";
const env = ARENA_ENVIRONMENTS["studio-ops-v1"]();
const { observation } = env.reset(115, { split: "train" });
flattenObservation("studio-ops-v1", observation).length === observationWidth("studio-ops-v1");
env.step(structureAction("studio-ops-v1", policyOutputVector));
const { total, steps, final } = rollout(env, (obs) => myPolicy(obs), {
seed: 115,
scenario: { split: "dev" },
});
```
| `kind` | Slots | Encoding |
|---|---|---|
| `float` | 1 | the **clamped raw value**, with `low`/`high` published so a trainer can normalise it its own way |
| `bool` | 1 | `0` or `1` |
| `enum` | one per `values` | one-hot; a value outside the vocabulary is all zeros, which reads as "none of these" rather than colliding with the first member |
| `id` | 1 | a stable hash in `[0, 1)` for an open vocabulary — a change detector, not something to learn from |
`low` and `high` are *declared bounds*, not guarantees. Quietly wrong bounds
should show up as a saturated input rather than as a silently rescaled one,
which is why floats are not normalised here.
`ARENA_ENVIRONMENTS` is the env-id → constructor registry the catalogue was
missing: a harness handed `"drive-101-v1"` off a config file previously had to
keep its own switch. Constructors rather than instances, because an episode is
state and two rollouts in flight need two objects.
`rollout(env, policy, { seed, scenario, maxSteps })` is the loop every consumer
— including this repository's own test file — used to write by hand.
## Determinism across machines, and the arithmetic it forbids
Traces are verified by exact equality, and a verifier runs on hardware the
producer never saw. `Math.sin`, `Math.atan2`, `Math.pow` and `Math.hypot` are
not required by IEEE-754 or ECMA-262 to be correctly rounded — only `+`, `-`,
`*`, `/` and `Math.sqrt` are — so two conforming engines can disagree in the
last place. `studio-ops-v1` was the first environment to put a solar position
and a set of slant ranges into that comparison, and an unguarded float there is
the worst failure this package can have: silent, indistinguishable from fraud,
and only ever somebody else's problem.
Two defences, and both are needed:
1. `canonicalJson` quantises every non-integer it hashes to
`ARENA_CHECKSUM_DECIMALS` (9) places, and `replay` compares rewards the same
way rather than with `!==`.
2. The environment quantises at the point a transcendental is *called*, so the
rounded value is the one that propagates — `quantizeObservable` in
`studioOps.ts`. Rounding only at the checksum cannot rescue a simulation that
has already accumulated a divergence, because the difference then grows with
every step instead of staying in the last place.
`canonicalJson` also **throws** on a `Map`, a `Set`, a `Date`, a typed array or
any other non-plain object. It used to read their own enumerable keys — of which
those have none — and emit `{}`, so a populated `Map` checksummed identically to
an empty object and to every other `Map`. `ResolvedRobotOperations` already
holds `ReadonlyMap`s.
`restore()` now also checks `sourceHashes`. `envHash` covers the *manifest*,
which is semantics and does not move when the physics under it does: edit a
walker's collision epsilon, leave the manifest alone, and a snapshot taken
before the edit used to restore cleanly and resume into a different simulation.
`replay` always guarded this; a checkpoint is exactly as dangerous as a trace.
## Reward and baseline policy
Rewards are counterweighted rather than progress-only. Progress and sparse
@@ -91,13 +200,169 @@ future seeded schedule entries are not leaked. SF and LA definitions are
explicit same-floor scenarios tied to resolved room/prop anchors. They are
demonstration data and never claim to describe live staff or company operations.
## studio-ops-v1
The environment the product is framed as. Every other one isolates a controller;
this one is a studio, and the reason it exists is that the variables are
coupled.
### What it wraps
Nothing here is a headless reimplementation. That is the whole claim:
| Wrapped | Where the browser uses the same object |
|---|---|
| `Plan(LUMBRIDGE_HQ)` / `Plan(MATEO_COURT)` | `interiors/officeScene.ts` builds the room from it |
| `resolveRobotOperations` + `createRobotActivity` | the robots a visitor watches working |
| `createSimulatedDevices` | the mic and speaker panel, and the LEDs on the hardware |
| `createSimulatedVehicleTelemetry` | the Model X parked on the apron |
| `solarPosition` | `engine/atmosphere.ts`'s light rig |
A second implementation "for the trainer" would be a simulation nobody can look
at, optimised against a picture nobody can reproduce. The point of building an
environment inside a renderer is that the thing being optimised is the thing
being shown.
The one thing it does *not* wrap is `engine/flights.ts`, because that module
imports three.js on its first line and `src/arena/` may not. The overflight
schedule here is deliberately less than that module — a pass time, a miss
distance, an altitude and a speed, and no callsign, registration or route —
because all the reward needs is a slant range and anything more would look like
a claim about a real flight.
### Observation — 44 fields in five groups
- **the agent and its job** (18): `officeId`, `levelId`, `x`, `z`, `mode`,
`phase`, `jobKind`, `payload`, `battery`, `jobProgress`, `nextStationId`,
`nextX`, `nextZ`, `deltaX`, `deltaZ`, `distanceToNextM`, `canInteract`,
`blockedStreak`
- **the sky over the roof** (10): `hourOfDay`, `sunAltitudeDeg`,
`sunAzimuthDeg`, `cloudCover`, `precipitation`, `visibilityKm`, `windKph`,
`windDirDeg`, `weatherCondition`, `weatherReported`
- **the hardware on the desk** (8): `micPowered`, `micGainDb`, `micLevelDb`,
`micMuted`, `speakerPowered`, `speakerVolume`, `speakerPlaying`,
`deskOccupied`
- **the car on the apron** (4): `vehicleSocPct`, `vehicleCabinC`,
`vehiclePluggedIn`, `vehicleReadyByDeparture`
- **traffic overhead** (2): `aircraftOverheadCount`, `nearestAircraftSlantM`
- **the shared reserve and the clock** (2): `energyReservePct`,
`stepsToDeparture`
The last two are additions to the field list the build spec fixed, and they are
there for one reason: a terminal an agent cannot see coming is not a task, it is
a trap. `energyReservePct` is what `battery-depleted` counts down and
`stepsToDeparture` is what `departure-missed` counts down.
`deskOccupied` is an **observation about the robot**, not a claim about a
person. The only body in the building is the one the policy is driving, and it
is fed to `createSimulatedDevices` through the `setOccupancy` input that module
documents — the same input a deployment with a real presence source would use.
### Action
One struct: `{ x, z, interact, micGain, micMute, speakerVolume, speakerPlay,
vehiclePrecondition, vehicleCharge }`, every field clamped in
`normalizeAction`, with `micGain` and `speakerVolume` clamped to the same
`DEVICE_RANGES` the browser's panel clamps to.
Device *power* is deliberately not an action. A studio's rig being on is a fact
about the episode rather than a decision inside it; both devices are powered at
`reset` and the mute is the lever. An environment whose optimal policy opens
with two mandatory "turn it on" presses is one whose first two steps carry no
information.
### Reward — what it optimises
Thirteen components, summed by `base.ts` and never authored as a total.
| Component | Pays for | Pulled against by |
|---|---|---|
| `navigation`, `job`, `success` | reaching and working the authored station | `time`, `control` |
| `audioReady` | a live, correctly-gained mic and quiet monitoring **at an occupied desk** | `audioWaste`, `energy`, `noise` |
| `audioWaste` | — | a hot mic or a playing speaker at an *empty* desk |
| `energy` | — | every kilowatt drawn, as a share of the reserve, priced up as `cloudCover` rises |
| `vehicleReady` | closing the gap to a charged, comfortable car, plus a bonus at the departure | `energy`: the apron post draws from the same reserve |
| `noise` | — | playback under an aircraft, in wind, or into a live microphone |
| `collision`, `interaction`, `safety` | — | the four failure modes |
The properties that keep it from being gamed:
- **`vehicleReady` is potential-based.** It pays the *change* in a bounded
readiness rather than the level, so it telescopes over an episode and
plugging/unplugging round-trips to zero instead of paying twice.
- **Parking at the desk with a hot mic loses.** `audioReady` is smaller per step
than `time` plus the reserve draw, so an agent that stops working to collect
it finishes below an agent that does the job.
- **`audioWaste` is twice `audioReady`.** Leaving the mic live for the whole
episode to catch the short window where it pays is a net loss.
- **`noise` needs the sky.** Monitoring is worth `0.004` a step and bleed into a
live mic costs `0.009` a unit of volume, so there is an optimum in the volume
knob rather than a binary; an aircraft directly overhead costs `0.05` and
buries the gain at any volume. A policy cannot decide whether to press play
without reading `nearestAircraftSlantM` and `windKph`.
- **Charging is cheap in time and expensive in reserve.** Reaching the departure
target costs about a fifth of the whole allowance. Leaving the car plugged in
past the target pays nothing and keeps charging.
### Terminals
`job-complete` is the goal. `collision-stall`, `wrong-interaction-limit`,
`battery-depleted` (the studio's reserve, or the robot's own pack) and
`departure-missed` are the four safety terminals, and a test reaches each of
them with a targeted policy. `max-steps` truncates at 1200 steps — 120 seconds
of simulated time at the 0.1 s fixed step.
The reserve is the episode's energy **allowance**, not a claim about a
building's battery: two minutes is far too short for a real site battery to
matter, so the allowance is sized so that a studio at rest cannot come close to
exhausting it and a studio charging a car off it runs out with a couple of
hundred steps to spare. That is a design decision about where the trade-off
should bite, stated rather than dressed up as a specification.
### Weather and traffic are scenario parameters
`{ startEpochMs, weatherProfileId, cloudCoverBase, precipitationBase,
windKphBase, windDirDeg, visibilityKm, ambientC, aircraftScheduleSeed }` live in
the scenario, are covered by `scenario.hash`, and evolve by a pure function of
(scenario, step). Four oscillators and a schedule of overflights; no fetch
anywhere. A network read inside `step()` would make `replay()` impossible and
every published trace unverifiable a day later.
A real NWS observation may be the *source* of a scenario — captured once, frozen
into those parameters, and marked `weatherReported: true`. **None of the shipped
scenarios is such a capture**: they are invented profiles and every one of them
reports `weatherReported: false`. The field exists so an operator who does
freeze an observation has somewhere honest to record it.
### The evaluation boundary, said again
`studio-ops-v1` is the environment somebody will most want to hold out, so it is
worth repeating what the section above says: **a private eval published in the
client package is not private.** The five scenarios here are public fixtures for
smoke-proofing and demonstration. An operator running a real evaluation should
keep its scenario definitions in its own package or service and instantiate this
same contract against them; nothing in `tera.arena/v1` requires a scenario to
have shipped in this repository.
Two more honest notes about the shipped fixtures. Only two of the five have a
desk the robot can actually occupy — `sf-studio-monitor` is the one station in
either pack that stands at a microphone's seat — so in the other three the
correct play is to mute and get on with the job, which is a real operating case
and is why they are included. And `dev-la-marine-layer-loft-delivery` runs on
mateo-court's level 2, where every device is on level 1: its desk is never
occupied, on purpose.
## Adding an environment
Keep the environment under `src/arena/`, wrap an existing renderer-neutral
controller or plan, expose train/dev scenario ids and a manifest, and add it to
`ARENA_MANIFESTS`. Do not import a scene adapter to obtain simulation state.
Update `scripts/check-arena-source-hashes.mjs`, pin the new SHA-256 values, and
add the same determinism, floor, scripted, safety, snapshot and replay proofs.
`ARENA_MANIFESTS` **and** `ARENA_ENVIRONMENTS`. Declare `observationSpace` and
`actionSpace` alongside the field lists. Do not import a scene adapter, three.js
or the network to obtain simulation state. Quantise anything a transcendental
produced before it is observed or rewarded on. Update
`scripts/check-arena-source-hashes.mjs` with every file the environment wraps,
pin the new SHA-256 values, and add the same determinism, floor, scripted,
safety, snapshot and replay proofs.
Run the complete gate:
+583 -521
View File
File diff suppressed because it is too large Load Diff
+62 -18
View File
@@ -2,38 +2,74 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex" />
<title>Sign in — Lumbridge Simulate</title>
<style>
/* The same stack index.html uses. No webfont, no CDN, nothing to fetch:
this page has to work on a box with no network but its own. */
/*
* The same stack index.html uses. No webfont, no CDN, nothing to fetch:
* this page has to work on a box with no network but its own — which is
* also why the tokens are restated here rather than imported. They are the
* same values `src/ui/tokens.ts` publishes; this page is served on its own
* and cannot see the map's stylesheet.
*/
:root {
--amber: #f2b134;
--amber-lit: #ffc555;
--ink: rgba(255, 255, 255, 0.78);
--ink-2: rgba(255, 255, 255, 0.56);
--ink-3: rgba(255, 255, 255, 0.4);
--ink-4: rgba(255, 255, 255, 0.26);
--hairline: rgba(255, 255, 255, 0.11);
--r: 8px;
--r-sm: 5px;
--tap: 44px;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0d1219;
font-family: ui-monospace, "SF Mono", Menlo, monospace; color: rgba(255,255,255,0.72); }
body { display: flex; align-items: center; justify-content: center; padding: 1rem; }
font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--ink); }
body { display: flex; align-items: center; justify-content: center;
padding: max(1rem, env(safe-area-inset-top)) 1rem calc(1rem + env(safe-area-inset-bottom)); }
main { width: 20rem; }
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: #f2b134; }
color: var(--amber); }
h1 + p { margin: 0.15rem 0 1.1rem; font-size: 11px; line-height: 1.5;
color: rgba(255,255,255,0.42); }
color: var(--ink-3); }
form { display: flex; flex-direction: column; gap: 0.5rem;
background: rgba(255,255,255,0.04); border-radius: 6px; padding: 0.9rem; }
background: rgba(255,255,255,0.04); border: 1px solid var(--hairline);
border-radius: var(--r); padding: 0.9rem; }
label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
color: rgba(255,255,255,0.42); }
input { font: inherit; font-size: 13px; padding: 0.5rem 0.6rem; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.12); background: rgba(8,12,16,0.6);
color: rgba(255,255,255,0.9); }
input:focus { outline: none; border-color: #f2b134; }
color: var(--ink-3); }
/* Every field and the button clear the 44px touch target, the same one the
map's chrome is held to. This form was ~34px tall per control. */
input { font: inherit; font-size: 13px; min-height: var(--tap); padding: 0.5rem 0.6rem;
border-radius: var(--r-sm); border: 1px solid var(--hairline);
background: rgba(8,12,16,0.6); color: rgba(255,255,255,0.9); }
input:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
button { font: inherit; font-size: 12px; font-weight: 600; margin-top: 0.35rem;
padding: 0.55rem 0.7rem; cursor: pointer; border: 0; border-radius: 6px;
color: #10161d; background: #f2b134; }
button:hover:enabled { background: #ffc555; }
min-height: var(--tap); padding: 0.55rem 0.7rem; cursor: pointer; border: 0;
border-radius: var(--r-sm); color: #10161d; background: var(--amber); }
button:hover:enabled { background: var(--amber-lit); }
button:disabled { opacity: 0.55; cursor: default; }
button:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
#note { min-height: 1.4rem; margin: 0.6rem 0 0; font-size: 11px; line-height: 1.4;
color: rgba(255,255,255,0.5); }
color: var(--ink-2); }
#note.bad { color: #ffb4a2; }
footer { margin-top: 0.9rem; font-size: 10px; color: rgba(255,255,255,0.25); }
/*
* The way back out, and it is not decoration.
*
* The whole product is designed for the signed-out visitor: the city, both
* studios, the aircraft, the weather and the Model X are all open. A sign-in
* page that presents itself as the entrance contradicts that on the one
* screen where somebody has already been made to feel they are outside. This
* says what an account adds and offers the door back to the demo.
*/
.escape { margin: 1.1rem 0 0; padding-top: 0.9rem; border-top: 1px solid var(--hairline);
font-size: 10px; line-height: 1.7; color: var(--ink-3); }
.escape a { display: inline-flex; align-items: center; min-height: var(--tap);
color: var(--amber-lit); }
footer { margin-top: 0.9rem; font-size: 10px; color: var(--ink-4); }
</style>
</head>
<body>
@@ -54,6 +90,14 @@
</form>
<p id="note" role="status" aria-live="polite"></p>
<p class="escape">
You do not need an account to use Tera. The city, both studios, the live
aircraft and the weather are open to everyone — signing in adds the people:
live occupancy, your own character on the floor, and the studio desk.<br />
<a href="/">← Back to the open demo</a>
</p>
<footer id="footer">The session is a cookie this server signs. Nothing leaves the box.</footer>
</main>
+1 -1
View File
@@ -20,7 +20,7 @@
"provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs",
"licenses": "node scripts/check-dependency-licenses.mjs",
"sbom": "npm sbom --package-lock-only --sbom-format=spdx",
"test": "node --test \"src/test/*.test.ts\"",
"test": "node --test \"src/test/*.test.ts\" \"src/test/**/*.test.ts\"",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"performance": "node scripts/performance-budget.mjs"
+28
View File
@@ -57,6 +57,34 @@ const sourceSets = {
environment: [...sharedEnvironment, "src/arena/californiaFlight.ts"],
simulator: ["src/aircraft/controller.ts"],
},
// `studio-ops-v1` pins the widest simulator set in the package, and every
// entry earns its place: it is the list of files an edit to which changes
// what a rollout does. Both office packs and both operations packs are in it
// because the environment resolves a `Plan` of each; `devices/types.ts` is in
// it because the capability vocabulary decides the observation's width; and
// `engine/solar.ts` is in it because the sun is an input rather than a
// decoration. If this list is ever shorter than what the environment imports,
// a snapshot can survive a change it should not have survived.
"studio-ops-v1": {
environment: [...sharedEnvironment, "src/arena/studioOps.ts"],
simulator: [
"src/devices/sim.ts",
"src/devices/types.ts",
"src/engine/solar.ts",
"src/interiors/plan.ts",
"src/interiors/types.ts",
"src/interiors/walker.ts",
"src/interiors/robotActivity.ts",
"src/interiors/robotOperations.ts",
"src/interiors/robotRoutes.ts",
"src/offices/lumbridge-hq.ts",
"src/offices/mateo-court.ts",
"src/offices/operations/lumbridge-hq.ts",
"src/offices/operations/mateo-court.ts",
"src/offices/sites.ts",
"src/transport/vehicleTelemetry.ts",
],
},
};
async function digest(paths) {
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env node
/**
* The interface gate: does the chrome actually mount, on both shapes of screen
* and on both sides of the sign-in line?
*
* node scripts/ui-smoke.mjs # against dist/, which must be built
* node scripts/ui-smoke.mjs --headed # watch it happen
*
* ## Why this exists
*
* Until `ui/chromeState.ts` landed, every visibility decision in this product
* was a line in `main.ts` that needed a `Stage`, a heightfield and a WebGL
* context to reach — so the interface, on a product whose entire pitch is the
* interface, was the one subsystem with no coverage at all. `chromeState` made
* the *decisions* testable in `node --test`; this makes the *wiring* testable,
* which is the other half and the half that breaks at a seam nobody owns.
*
* Four combinations, because each of the two axes has genuinely changed
* behaviour behind it and neither implies the other:
*
* - **1600 × 1000 and 390 × 844.** The phone is a different design, not a narrow
* desktop: no hover, no keyboard, one thumb. Live defect 10 was the plan view
* disappearing entirely there, and 12 was there being no visible movement
* control at all.
* - **Anonymous and signed in.** Owner decision 1 makes the signed-out visitor
* the audience this is designed for rather than a degraded tier, so anonymous
* is the case that must be *good*, not merely the case that must not crash.
*
* ## GPU flags
*
* This box has an AMD card and no monitor. `--use-gl=angle --use-angle=vulkan`
* is what makes Chrome render headlessly on it at all, and the SwiftShader
* fallback behind it is what makes this runnable on a CI box with no card —
* `--enable-unsafe-swiftshader` is required since Chrome 137 made software WebGL
* opt-in. Exactly the ladder `scripts/performance-budget.mjs` climbs, for
* exactly the same reason.
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const DIST = join(ROOT, "dist");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".webmanifest": "application/manifest+json",
};
const args = process.argv.slice(2);
const headed = args.includes("--headed");
const softwareOnly = args.includes("--software");
const VIEWPORTS = {
desktop: { width: 1_600, height: 1_000 },
phone: { width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 3 },
};
/**
* The two tiers, as the two bodies `/api/v1/session` can return.
*
* `/health` says `mode: "jwt"` in both, which is what makes the anonymous case
* a *real* anonymous case: `resolveAccess` treats `mode: "none"` as a self-host
* that chose to stay open and hands it `member`, so a smoke that left auth off
* would never once exercise the tier this product is designed for.
*/
const TIERS = {
anonymous: { authenticated: false, subject: null, passwordLogin: true, admin: false },
"signed-in": {
authenticated: true,
subject: "smoke@lumbridgecorp.test",
passwordLogin: true,
admin: false,
},
};
// ---- The deployment, as a few hundred bytes of JSON -----------------------
async function serve(tier) {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://local.invalid");
const json = (status, body) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/api/v1/health") {
return json(200, {
auth: { mode: "jwt", entryUrl: "/login.html" },
// Every source off, which is the shipped default and the configuration
// CONTRACT §0's acceptance test is written against. The city still gets
// a sky, traffic and a studio full of instruments — from the simulators.
sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
regions: [],
});
}
if (url.pathname === "/api/v1/session") return json(200, TIERS[tier]);
if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
const target = resolve(DIST, `.${relative}`);
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
try {
const body = await readFile(target);
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
res.end(body);
return;
} catch { /* fall through to the SPA entry */ }
}
res.writeHead(404).end("not found");
});
await new Promise((ok, fail) => {
server.once("error", fail);
server.listen(0, "127.0.0.1", ok);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("no TCP port");
return { server, port: address.port };
}
// ---- Chrome ---------------------------------------------------------------
const COMMON_ARGS = [
"--no-sandbox",
"--disable-dev-shm-usage",
"--enable-unsafe-swiftshader",
"--ignore-gpu-blocklist",
];
async function launch() {
const ladder = softwareOnly
? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]]
: [
["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]],
];
let last;
for (const [backend, flags] of ladder) {
try {
const browser = await chromium.launch({
channel: "chrome",
headless: !headed,
args: [...COMMON_ARGS, ...flags],
});
return { browser, backend };
} catch (error) {
last = error;
}
}
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}
// ---- Assertions -----------------------------------------------------------
const failures = [];
const notes = [];
function check(label, condition, detail = "") {
if (condition) return true;
failures.push(`${label}${detail === "" ? "" : `${detail}`}`);
return false;
}
/**
* A `console.error` or an uncaught exception, and nothing softer.
*
* Warnings are deliberately not fatal: three.js warns about the tone-mapping
* change on some drivers and a missing optional API is a warning by design in
* three separate adapters here. An *error* means something threw or something
* decided the page was broken, and neither may happen on a first load.
*/
function watchForErrors(page, seen) {
page.on("console", (message) => {
if (message.type() !== "error") return;
/*
* Chrome logs its own console error for every non-2xx response, and the
* optional API surface answering 4xx is not an application error — it is the
* architecture. `adapters/http.ts` degrades to bundled fiction rather than
* failing, and CONTRACT §0's acceptance test is a clone with no server at
* all, so a build that treated `/api/v1/*` 404s as fatal would be gating on
* the opposite of the promise.
*
* Everything else stays fatal, and the case that matters is a missing
* `/assets/*.js` chunk: a deploy that moved a file under an open tab is
* exactly the failure this check exists to catch, and it arrives as a 404 on
* a URL that is not under `/api/v1/`.
*/
const from = message.location()?.url ?? "";
if (/\/api\/v1\//.test(from)) return;
seen.push(`console: ${message.text()}${from === "" ? "" : ` (${from})`}`);
});
page.on("pageerror", (error) => seen.push(`uncaught: ${String(error)}`));
page.on("requestfailed", (request) => {
// A cancelled navigation request is not a failure of the page.
const failure = request.failure();
if (failure && !/ERR_ABORTED/.test(failure.errorText)) {
seen.push(`request: ${request.url()} ${failure.errorText}`);
}
});
page.on("response", (response) => {
const url = new URL(response.url());
if (url.pathname.startsWith("/api/v1/")) return;
if (response.status() >= 400) seen.push(`http ${response.status()}: ${url.pathname}`);
});
}
const chromeSnapshot = () => {
const visible = (id) => {
const element = document.getElementById(id);
return element !== null && element.hidden === false;
};
const text = (id) => document.getElementById(id)?.textContent?.trim() ?? "";
return {
bootHidden: document.getElementById("boot")?.hidden === true,
chapters: document.querySelectorAll("#chapters .chapter").length,
boards: document.querySelectorAll("#cities .board").length,
activeBoard: document.querySelectorAll("#cities .board[aria-pressed='true']").length,
modeButtons: document.querySelectorAll("#mode-dock [data-control-mode]:not([hidden])").length,
tierVisible: visible("tier"),
tierLabel: text("tier-label"),
tierAdds: text("tier-adds"),
signInVisible: visible("tier-signin"),
enterLabel: text("enter"),
panelToggleVisible: visible("panel-toggle"),
planVisible: visible("corner"),
planPressed: document.getElementById("plan-toggle")?.getAttribute("aria-pressed"),
helpLabel: text("help"),
onboardingCards: document.querySelectorAll("#onboarding-host [data-onboarding-step]").length,
onboardingHostChildren: document.getElementById("onboarding-host")?.childElementCount ?? 0,
deviceSectionVisible: visible("device-section"),
devicesButtonVisible: visible("devices"),
deviceCards: document.querySelectorAll("#device-host [data-device]").length,
deviceControls: document.querySelectorAll("#device-host button, #device-host input").length,
stickVisible: visible("play-stick"),
canvasLabel: document.getElementById("scene")?.getAttribute("aria-label") ?? "",
bodyClasses: [...document.body.classList],
// The renderer counting its own draws is the one witness a fast frame
// cannot fool: an increment means a frame of the real scene was submitted.
frames: window.__teraSmokeFrames ?? 0,
};
};
/** Count real WebGL draws, so "it booted" means pixels rather than a DOM tree. */
const countFrames = () => {
let frames = 0;
const patch = (prototype, method) => {
if (!prototype || typeof prototype[method] !== "function") return;
const original = prototype[method];
prototype[method] = function (...values) {
frames += 1;
return original.apply(this, values);
};
};
for (const prototype of [
globalThis.WebGLRenderingContext?.prototype,
globalThis.WebGL2RenderingContext?.prototype,
]) {
patch(prototype, "drawArrays");
patch(prototype, "drawElements");
patch(prototype, "drawArraysInstanced");
patch(prototype, "drawElementsInstanced");
}
Object.defineProperty(globalThis, "__teraSmokeFrames", { get: () => frames });
};
async function settle(page, timeoutMs = 120_000) {
await page.waitForFunction(() => document.getElementById("boot")?.hidden === true, null, {
timeout: timeoutMs,
});
// One more paint after the card goes, so anything the fade uncovers has been
// through `apply` at least once.
await page.evaluate(
() => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))),
);
}
// ---- One combination -------------------------------------------------------
async function run(browser, port, viewportName, tier) {
const label = `${viewportName} · ${tier}`;
const viewport = VIEWPORTS[viewportName];
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
isMobile: viewport.isMobile ?? false,
hasTouch: viewport.hasTouch ?? false,
});
const errors = [];
try {
const page = await context.newPage();
watchForErrors(page, errors);
await page.addInitScript(countFrames);
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "domcontentloaded" });
await settle(page);
const city = await page.evaluate(chromeSnapshot);
// ---- The board ----
check(`${label}: boot card faded`, city.bootHidden);
check(
`${label}: a real frame reached the glass`,
city.frames > 0,
"no WebGL draw call was ever issued, so the card faded over an empty canvas",
);
check(`${label}: chapter list mounted`, city.chapters > 0, `${city.chapters} rows`);
check(`${label}: board strip mounted`, city.boards === 3, `${city.boards} tabs`);
check(`${label}: exactly one board is current`, city.activeBoard === 1);
check(
`${label}: canvas describes itself`,
/California/.test(city.canvasLabel),
JSON.stringify(city.canvasLabel),
);
check(`${label}: layout class applied`, city.bodyClasses.some((c) => c.startsWith("layout-")));
// ---- The tier, and the anon-first promise ----
check(`${label}: tier badge visible`, city.tierVisible);
if (tier === "anonymous") {
check(
`${label}: the signed-out badge names what you get`,
city.tierLabel === "Open demo",
JSON.stringify(city.tierLabel),
);
check(`${label}: a way in is offered`, city.signInVisible);
check(
`${label}: the offer says what signing in adds`,
city.tierAdds.length > 0,
"'Public view · Sign in' was the entire signed-out story before this line",
);
} else {
check(
`${label}: the signed-in badge reads Full view`,
city.tierLabel === "Full view",
JSON.stringify(city.tierLabel),
);
check(`${label}: no sign-in offer once you are in`, city.signInVisible === false);
}
// ---- The two layouts ----
if (viewportName === "phone") {
check(
`${label}: the plan view is on screen`,
city.planVisible,
"live defect 10: the minimap disappeared entirely on a phone, because the " +
"seed was width > 600 and a phone has no M key",
);
check(`${label}: the panel has a toggle`, city.panelToggleVisible);
check(
`${label}: the shortcuts button is labelled for a thumb`,
city.helpLabel === "Guide",
`live defect 11: "? shortcuts" on a device with no keyboard; got ` +
JSON.stringify(city.helpLabel),
);
check(
`${label}: no joystick while the camera is the only thing you control`,
city.stickVisible === false,
);
} else {
check(`${label}: the panel is furniture, not a sheet`, city.panelToggleVisible === false);
check(`${label}: the shortcuts button is the key hint`, city.helpLabel === "?");
}
// ---- Onboarding, which must appear exactly once ----
check(
`${label}: the first-run coach appeared`,
city.onboardingHostChildren > 0,
"there was no onboarding of any kind in this product before this build",
);
await page.reload({ waitUntil: "domcontentloaded" });
await settle(page);
const second = await page.evaluate(chromeSnapshot);
check(
`${label}: the coach does not come back on the second visit`,
second.onboardingHostChildren === 0,
`${second.onboardingHostChildren} children after a reload in the same context`,
);
// ---- Taking control ----
//
// Live defect 12 was that a phone in VIEW mode offered no on-screen way to
// move and no way to discover that one existed. The joystick has been in
// `input/pointerStick.ts` since the play modes landed; what it never had was
// a state that showed it.
await page.click("#mode-dock [data-control-mode='actor']");
await page.evaluate(
() => new Promise((ok) => requestAnimationFrame(() => requestAnimationFrame(ok))),
);
const playing = await page.evaluate(chromeSnapshot);
check(
`${label}: the play HUD appears with a body under control`,
playing.bodyClasses.includes("playing"),
JSON.stringify(playing.bodyClasses),
);
if (viewportName === "phone") {
check(
`${label}: and a thumb is given something to move with`,
playing.stickVisible,
"live defect 12: VIEW mode on a phone had no visible movement control at all",
);
}
await page.click("#mode-dock [data-control-mode='overview']");
// ---- The studio, through the door rather than through the URL ----
//
// The in-page swap is the path a visitor takes and the one that exercises
// `enterOffice` / `leaveOffice`: the city is paused and kept, the office is
// built, its hardware feed and its car are started, and the whole lot is torn
// down again on the way out.
if (viewportName === "phone") await page.click("#panel-toggle");
await page.click("#enter");
await settle(page);
const office = await page.evaluate(chromeSnapshot);
check(
`${label}: the studio opened`,
/Back to the city/.test(office.enterLabel),
JSON.stringify(office.enterLabel),
);
check(
`${label}: the hardware panel is offered`,
office.devicesButtonVisible && office.deviceSectionVisible,
"the studio declares mic and speaker hardware and the panel is anon-visible " +
"by design — the declarations are authored, only the readings are an account",
);
check(
`${label}: the hardware panel rendered one card per declared device`,
office.deviceCards === 2,
`lumbridge-hq declares a desk mic and a desk speaker; found ${office.deviceCards}`,
);
check(
`${label}: every card carries real controls`,
office.deviceControls >= 4,
`power, mute, gain and volume are the minimum; found ${office.deviceControls}`,
);
check(`${label}: the studio has viewpoints`, office.chapters > 0);
check(
`${label}: the board strip became the studio picker`,
office.boards === 3 && office.activeBoard === 1,
);
// ---- And back out, which is where a leak would show ----
await page.click("#enter");
await settle(page);
const back = await page.evaluate(chromeSnapshot);
check(
`${label}: stepping back out returns to the board`,
back.chapters === city.chapters && !/Back to the city/.test(back.enterLabel),
`${back.chapters} chapters, enter reads ${JSON.stringify(back.enterLabel)}`,
);
check(
`${label}: the studio's hardware panel went with the studio`,
back.deviceSectionVisible === false && back.deviceCards === 0,
);
notes.push(
`${label}: ${city.chapters} chapters, ${office.chapters} viewpoints, ` +
`${office.deviceCards} devices / ${office.deviceControls} controls, ` +
`${city.frames} draws`,
);
} finally {
await context.close();
}
if (errors.length > 0) {
for (const error of errors) failures.push(`${label}: ${error}`);
}
}
// ---- Main ------------------------------------------------------------------
async function main() {
try {
await readFile(join(DIST, "index.html"));
} catch {
console.error("ui-smoke: dist/index.html is missing. Run `npm run build` first.");
process.exit(2);
}
const { browser, backend } = await launch();
console.log(`ui-smoke: chrome up on ${backend}`);
try {
for (const tier of Object.keys(TIERS)) {
const { server, port } = await serve(tier);
try {
for (const viewportName of Object.keys(VIEWPORTS)) {
await run(browser, port, viewportName, tier);
}
} finally {
await new Promise((ok) => server.close(ok));
}
}
} finally {
await browser.close();
}
for (const note of notes) console.log(` ${note}`);
if (failures.length > 0) {
console.error(`\nui-smoke: FAIL — ${failures.length} problem(s)\n`);
for (const failure of failures) console.error(`${failure}`);
console.error("");
process.exit(1);
}
console.log("\nui-smoke: PASS — 2 viewports × 2 access tiers, no console errors\n");
}
await main();
+32 -2
View File
@@ -12,9 +12,10 @@
* one and binds it.
*/
import Fastify, { type FastifyInstance } from "fastify";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import { registerCachePolicy } from "./cache.ts";
import { loadConfig, type Config } from "./config.ts";
import { registerDevices } from "./routes/devices.ts";
import { registerFlights } from "./routes/flights.ts";
import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
@@ -54,6 +55,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
registerMedia(app, services);
registerOffices(app, services);
registerPresence(app, services);
registerDevices(app, services);
registerRealtime(app, services);
registerSession(app, services);
@@ -62,7 +64,35 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
return reply.code(404).send(body);
});
app.setErrorHandler(async (err, _req, reply) => {
/**
* The one error handler, and the one place a client's mistake is told apart
* from ours.
*
* It used to answer 500 to everything, which was right for the only thing
* that reached it at the time — a route that threw. It stopped being right
* the moment a route accepted a body: Fastify raises its own errors for a
* payload that is not JSON, a content type it was not offered and a body over
* a route's `bodyLimit`, and every one of those is the caller's mistake,
* carries a 4xx `statusCode`, and was being reported as "something went wrong
* on the server". An operator watching error rates cannot tell a broken box
* from somebody POSTing nonsense at it, and the caller is told to retry
* something that will never work.
*
* So a 4xx from the framework is passed through with its own status and the
* `ErrorBody` shape every other refusal here uses; anything else is still a
* 500 with nothing in it, because the inside of an exception is not a thing
* to hand to the internet.
*/
app.setErrorHandler(async (err: FastifyError, req, reply) => {
const status = typeof err.statusCode === "number" ? err.statusCode : 500;
if (status >= 400 && status < 500) {
req.log.info({ err: err.message, status }, "refused a malformed request");
const body: ErrorBody =
status === 404
? { error: "not_found", message: "No such route." }
: { error: "bad_request", message: "That request could not be read." };
return reply.code(status).send(body);
}
app.log.error({ err }, "unhandled error");
const body: ErrorBody = { error: "internal", message: "Something went wrong." };
return reply.code(500).send(body);
+154 -2
View File
@@ -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"];
/**
+178
View File
@@ -0,0 +1,178 @@
/**
* Which source answers for device state, and what happens when none does.
*
* The shape `createWeatherService` and `createFlightsService` established: one
* function, the source chosen by the environment, and **it never throws**.
* `current()` always returns a body and `command()` always returns an outcome,
* because the two routes above this have nothing sensible to do with an
* exception and a 500 on a device panel is a studio that looks broken.
*
* Three rules beyond that, and the first is the one that differs from weather:
*
* 1. **`none` serves an empty array, not a fabrication.** The flights service
* falls back to a simulated plan because an empty sky over a city reads as a
* bug; an office with no hardware in it reads as an office with no hardware
* in it, which is the truth and is a perfectly good picture. A box that
* invented microphones nobody had configured would be making a claim about a
* room. `TERA_DEVICES_SOURCE=sim` is one variable away for anyone who wants
* the demonstration studio, and it is what the reference deployment runs.
* 2. **Nothing is simulated until somebody asks.** No interval, no background
* tick; see `devices/sim.ts`.
* 3. **The declarations come from the pack, never from the caller.**
* `devices/store.ts` resolves them against a `Plan` this process built, which
* is what makes a command checkable at all.
*
* ### Commands are memory-only and bounded
*
* A command mutates a simulator held in this process and nothing else. Nothing
* is written to disk, no state outlives a restart, and the number of offices
* simulated at once is capped (CONTRACT.md §5). That is the honest scope of
* what this build's write surface is: a shared, resettable, obviously-simulated
* studio — not a control system, and never a control system by accident.
*/
import { resolveDevices } from "./store.ts";
import { createDeviceRuntime, type DeviceRuntime } from "./sim.ts";
import {
normalizeDeviceCommand,
type DeviceCommand,
type DeviceState,
} from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { Config } from "../config.ts";
import type { DevicesBody } from "../../../src/server/wire.ts";
export interface DevicesService {
/**
* Every device in one office, right now. Never throws; an office with no
* declarations, or a box with no source, is an empty list and a 200.
*/
current(office: Office): DevicesBody;
/**
* Apply one command.
*
* The failure cases are collapsed into one on purpose. `no-such-device` and
* `not-permitted-op` are different mistakes by the same caller, and the route
* answers 400 to both — a caller who has been told *which* of its guesses was
* wrong is a caller being helped to guess again.
*/
command(office: Office, command: DeviceCommand): DeviceCommandOutcome;
/** What this box will serve for an office. The route uses it for its 400s. */
declarationCount(office: Office): number;
}
export type DeviceCommandOutcome =
| { ok: true; device: DeviceState }
| { ok: false; reason: string };
export interface DevicesLog {
warn(msg: string): void;
}
/**
* Attribution for a source nobody but us produced.
*
* Empty, and it stays empty for `sim`: the readings are this repo's own
* arithmetic and there is nobody to thank for them. Crediting anybody would be
* the same mistake the flights service made when it credited adsb.lol for its
* own simulator's aircraft. A `homeassistant` bridge would put the operator's
* own attribution here, which is why the field exists on the body at all.
*/
const NO_ATTRIBUTION: string[] = [];
export function createDevicesService(config: Config, log: DevicesLog): DevicesService {
const { source, ttlSeconds, seed } = config.devices;
// Built even for `none`, because it costs one empty `Map` and it means the
// two branches below differ by a single condition rather than by a structure.
const runtime: DeviceRuntime = createDeviceRuntime({ seed });
/**
* Offices already complained about, so a poll every five seconds does not
* become a log line every five seconds. Bounded by the same office-id key
* space everything else here is, and it is only ever added to when a pack is
* genuinely broken.
*/
const complained = new Set<string>();
/**
* One line, once, for a pack that declares hardware none of which resolved.
*
* The single operator-facing diagnostic this service has, and it is worth
* having: every drop in `store.ts` is silent by design — a public route must
* not narrate a pack's mistakes — so without this a mistyped `anchor.propId`
* produces an empty panel and no explanation anywhere.
*/
const report = (office: Office, resolved: { declarations: readonly unknown[]; authored: number }) => {
if (resolved.authored === 0 || resolved.declarations.length > 0) return;
if (complained.has(office.id) || complained.size > 64) return;
complained.add(office.id);
log.warn(
`devices: office "${office.id}" declares ${resolved.authored} device(s) and none of them ` +
"resolved — check that each anchor.propId names a prop on that level whose kind is the " +
"declaration's assetId. See server/src/devices/store.ts.",
);
};
/** The shared shell of a body, so the two paths cannot disagree about it. */
const body = (office: Office, devices: DeviceState[], observedAt: number): DevicesBody => ({
officeId: office.id,
devices,
observedAt,
source,
// Never `false` in this build. The only implemented source is a state
// machine, and a body that claimed observation would be a lie told by a
// constructor — the same sentence `initialDeviceState` carries.
synthetic: true,
ttlSeconds,
...(NO_ATTRIBUTION.length > 0 ? { attribution: NO_ATTRIBUTION } : {}),
});
return {
current(office: Office): DevicesBody {
const now = Date.now();
if (source === "none") return body(office, [], now);
const resolved = resolveDevices(office);
report(office, resolved);
const { declarations } = resolved;
if (declarations.length === 0) return body(office, [], now);
const simulator = runtime.advance(office.id, declarations, now);
// Restamped with the request's clock: the simulator's own `observedAt` is
// its epoch plus its simulated elapsed time, which lags by up to a step
// and by the whole of a catch-up cap. What a viewer wants to know is when
// this reading was taken, and that is now.
return body(
office,
simulator.current().map((state) => ({ ...state, observedAt: now })),
now,
);
},
command(office: Office, command: DeviceCommand): DeviceCommandOutcome {
if (source === "none") return { ok: false, reason: "this deployment has no device source" };
const { declarations } = resolveDevices(office);
const declaration = declarations.find((d) => d.id === command.deviceId);
// The whole of the authorisation for a write, in two lines. The device
// must be one this process found in the pack, and the op must be one that
// declaration declared — checked against the *resolved* pack rather than
// against anything in the request, which is what makes this a boundary
// rather than a formality.
if (declaration === undefined) return { ok: false, reason: "no such device in this office" };
const normalized = normalizeDeviceCommand(declaration, command);
if (normalized === null) return { ok: false, reason: "that device will not accept that command" };
const now = Date.now();
const simulator = runtime.advance(office.id, declarations, now);
simulator.command(normalized);
const device = simulator.current().find((state) => state.id === normalized.deviceId);
// Unreachable: `normalized` names a declaration this simulator was built
// from. Reported rather than asserted anyway — a route that threw here
// would turn a device the pack author renamed into a 500.
if (device === undefined) return { ok: false, reason: "no such device in this office" };
return { ok: true, device: { ...device, observedAt: now } };
},
declarationCount(office: Office): number {
return source === "none" ? 0 : resolveDevices(office).declarations.length;
},
};
}
+166
View File
@@ -0,0 +1,166 @@
/**
* The simulator, on a wall clock.
*
* `src/devices/sim.ts` is a fixed-step state machine that reads no clock. This
* file is the ten lines that make it answer questions asked over HTTP: one
* simulator per office, advanced to *now* the moment somebody asks and never
* otherwise.
*
* **It is the same module the browser and the arena drive.** Not a port, not a
* server-side reimplementation — the import is `src/devices/sim.ts`. That is
* the property that makes a bug report about a level meter reproducible and
* makes an arena rollout describe the same studio a viewer is looking at, and
* it is worth the one awkwardness it costs: a package under `src/` imported by
* the server, which `media/bindings.ts` and `regions.ts` already do for exactly
* the same reason.
*
* ### Nothing ticks in the background
*
* There is no interval here. A box nobody is looking at advances no simulation,
* makes no outbound request and does no work at all — the third rule
* `upstream.ts` states for weather and flights, applied to a source that
* happens to be local. The cost is that the first request after a quiet hour
* has an hour to catch up on, which is what `MAX_CATCHUP_STEPS` is about.
*/
import { createSimulatedDevices, type SimulatedDevices } from "../../../src/devices/sim.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";
/**
* Seconds per simulated step, server-side.
*
* A tenth of a second, matching the arena's `fixedStepSeconds`, so a rollout
* and a deployment are running the same physics at the same resolution. Finer
* would buy nothing over a five-second poll; coarser would make a level meter
* step visibly between polls.
*/
const STEP_SECONDS = 0.1;
/**
* How far one request may advance a simulator that has been idle.
*
* Sixty seconds' worth. Past that the simulated clock simply jumps: catching up
* honestly on an office nobody has opened since yesterday would be nearly a
* million steps inside one request, to arrive at a level meter reading that
* nobody watched accumulate and that carries no information — the state a
* microphone converges to is not a function of how long it has been ignored.
* Commands and settings are unaffected, because they are held state rather than
* integrated state.
*/
const MAX_CATCHUP_STEPS = 600;
/**
* A ceiling on how many offices are simulated at once.
*
* CONTRACT.md §5: memory-only, bounded state. The key space is office ids and
* `offices/store.ts` will look up any id matching its pattern, so without a cap
* an anonymous caller — well, a *signed-in* caller, this route takes a session —
* could grow this map one request at a time. The least recently touched entry
* is dropped, which loses nothing that cannot be rebuilt: a dropped simulator
* comes back powered-off, which is where it started.
*/
const MAX_OFFICES = 16;
export interface DeviceRuntime {
/** The simulator for one office, advanced to `nowMs`. */
advance(officeId: string, declarations: readonly DeviceDeclaration[], nowMs: number): SimulatedDevices;
/** How many offices are currently being simulated. For tests and for the bound. */
size(): number;
}
interface Entry {
simulator: SimulatedDevices;
/** The declaration ids and capabilities this simulator was built for. */
signature: string;
/** Simulated time, in epoch milliseconds, that this simulator has reached. */
clockMs: number;
/** When it was last asked for, so the cap can drop the coldest. */
touchedMs: number;
}
export interface DeviceRuntimeOptions {
seed: number;
}
export function createDeviceRuntime(options: DeviceRuntimeOptions): DeviceRuntime {
const offices = new Map<string, Entry>();
return {
advance(officeId, declarations, nowMs): SimulatedDevices {
const signature = signatureOf(declarations);
let entry = offices.get(officeId);
// A pack that has been edited on disk is a different studio, and resuming
// a simulator built for the old one would leave readings for devices that
// no longer exist and none for the ones that do. Rebuilt rather than
// patched: the state a device machine carries is a few booleans and a
// level, and none of it is worth migrating.
if (entry === undefined || entry.signature !== signature) {
entry = {
simulator: createSimulatedDevices(declarations, {
// The office id is mixed into the seed so two studios on one box do
// not run in lockstep — every mic in the building peaking together
// is the tell that gives a simulation away.
seed: (options.seed ^ hash(officeId)) | 0,
fixedStepSeconds: STEP_SECONDS,
epochMs: nowMs,
}),
signature,
clockMs: nowMs,
touchedMs: nowMs,
};
offices.set(officeId, entry);
evict(offices);
return entry.simulator;
}
const stepMs = STEP_SECONDS * 1000;
const behind = Math.max(0, nowMs - entry.clockMs);
const steps = Math.min(MAX_CATCHUP_STEPS, Math.floor(behind / stepMs));
for (let i = 0; i < steps; i += 1) entry.simulator.stepFixed();
// The clock is set to `now` whichever branch ran. Advancing it by
// `steps * stepMs` instead would leave a simulator that had been capped
// permanently behind, and it would try to catch up again on every
// subsequent request — one poll's worth of work turning into a treadmill.
entry.clockMs = nowMs;
entry.touchedMs = nowMs;
// Re-inserted so the map's iteration order is least-recently-touched
// first, which is what makes `evict` drop the coldest office rather than
// the oldest one.
offices.delete(officeId);
offices.set(officeId, entry);
return entry.simulator;
},
size: () => offices.size,
};
}
function evict(offices: Map<string, Entry>): void {
while (offices.size > MAX_OFFICES) {
const coldest = offices.keys().next();
if (coldest.done) return;
offices.delete(coldest.value);
}
}
/**
* What a simulator was built for, as a string.
*
* Ids and capabilities, in order — everything that changes which readings exist
* and which commands are legal. Deliberately not the labels or the disclosure,
* which are prose a pack author may reword without changing an instrument.
*/
function signatureOf(declarations: readonly DeviceDeclaration[]): string {
return declarations.map((d) => `${d.id}:${d.kind}:${d.capabilities.join(",")}`).join("|");
}
/** FNV-1a, so two office ids that differ by one character seed differently. */
function hash(text: string): number {
let value = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
value ^= text.charCodeAt(i);
value = Math.imul(value, 0x01000193);
}
return value >>> 0;
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Which devices an office actually has, decided by the server.
*
* The device routes never take a client's word for what is in a room. A read
* answers with the hardware **this** process found in the pack, and a command is
* refused unless the id it names is one of them — which is the same move
* `officeHasMediaBinding()` makes for a screen share, for the same reason: a
* device id in a request body is a claim, and the only thing that can check a
* claim about a building is the building.
*
* ### It asks `Plan`, and does not hold a second opinion
*
* The rules a device has to pass are real and they are exacting — the anchor
* prop must exist, be on the level the declaration claims, and not be some
* *other* instrument's hardware; the declaration must validate; the id must be
* unique; the disclosure must say "simulated" if the provenance does. All of
* that is implemented once, in `Plan.resolveDevice`, because it is the same
* question the renderer asks and the answers have to agree.
*
* They have to agree in a specific direction that is easy to miss. `Plan`
* deliberately *allows* a microphone anchored to a desk — `DeviceAnchor.offset`
* exists for exactly those few centimetres — and only refuses an anchor to a
* prop that is itself a device of another kind. A stricter copy of that rule
* here would silently drop a legitimate self-hosted pack's devices from the API
* while the browser drew them, which is the worst kind of disagreement: the
* panel is populated and every command it sends is refused.
*
* So this file resolves one `Plan` and reads `allDevices()` off it. What it adds
* is the two things a request path needs and a build step does not: it never
* throws, and it is bounded.
*
* ### Why it cannot simply trust the pack
*
* An `Office` reaches this from two places: a bundled pack compiled into this
* repo, and a JSON file in `TERA_OFFICES_DIR` that an operator wrote by hand.
* The second is untrusted input in the ordinary sense — `Plan` is written for
* authored TypeScript and reads a declaration's `label.trim()` without asking
* whether it is a string, which a hand-edited file is entitled to get wrong. A
* `TypeError` from inside a resolver would leave the route answering 500 for a
* studio whose only fault is a typo, so the whole resolution is wrapped and a
* pack this process cannot read has no devices. `offices/store.ts` takes the
* same posture toward the document that carries it.
*/
import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";
/**
* A ceiling on how many devices one office may declare.
*
* Not a statement about studio size — the two shipped packs declare a handful
* each. It is a bound on what one file can do to this process: every device is
* a simulator entry advanced on every poll and a row in a body served to every
* viewer, so a pack with fifty thousand microphones in it, by mistake or
* otherwise, is a box that stops answering. Dropping the tail is visible and
* recoverable; the alternative is not. `presence/store.ts` bounds a roster for
* the identical reason.
*/
const MAX_DEVICES = 64;
export interface ResolvedDevices {
/** The devices this box will serve and command, in pack order. */
declarations: readonly DeviceDeclaration[];
/**
* How many the pack *tried* to declare.
*
* Carried so that "this office has no devices" and "this office declares
* devices and not one of them resolved" can be told apart by the one party
* who can fix the second — the operator, through one line in the log. Without
* it a pack with a mistyped `propId` is indistinguishable from a pack that
* never mentioned a microphone, and the visible symptom of both is an empty
* panel.
*/
authored: number;
}
interface Cached extends ResolvedDevices {
office: Office;
}
/**
* One entry per office, keyed on the id and validated against the object.
*
* `Plan` is not free — it re-resolves every wall, prop and seat — and the device
* routes are polled every few seconds, so resolving per request would make a
* device panel the most expensive thing on the box. The `office` field is the
* real key: a bundled pack is one stable object for the life of the process, and
* a pack read off disk is a fresh object every time the file is re-read, which
* is exactly when the answer should be recomputed.
*/
const cache = new Map<string, Cached>();
/**
* A ceiling on how many offices are remembered at once.
*
* "Bounded by what is on disk" is bounded by whatever a caller can name, and
* `offices/store.ts` will look up any id matching its pattern. So the map is
* capped and the oldest entry is dropped, which keeps this a cache rather than
* an unbounded index a stranger can grow by asking for offices that do not
* exist. CONTRACT.md §5.
*/
const MAX_CACHED_OFFICES = 16;
/** The devices this box will serve and command for one office. Never throws. */
export function resolveDevices(office: Office): ResolvedDevices {
const hit = cache.get(office.id);
if (hit !== undefined && hit.office === office) return hit;
const resolved = resolve(office);
if (cache.size >= MAX_CACHED_OFFICES) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(office.id, { office, ...resolved });
return resolved;
}
/** For tests, and for anything that swaps a pack under a running process. */
export function forgetResolvedDevices(): void {
cache.clear();
}
function resolve(office: Office): ResolvedDevices {
const authored = authoredCount(office);
if (authored === 0) return { declarations: [], authored: 0 };
let resolved;
try {
// `full` depth, because this is the server deciding what hardware exists,
// not what a particular viewer may see. Who may *read* the state is the
// route's decision and it is made before this is ever called. `warn: false`
// because a pack's problems are the pack author's business and this is a
// request path, not a build step.
resolved = new Plan(office, { depth: "full", warn: false }).allDevices();
} catch {
// See the header: a hand-edited pack is entitled to be malformed, and the
// honest answer to one this process cannot read is that it has no devices —
// not a 500 on a studio somebody is standing in.
return { declarations: [], authored };
}
const declarations = resolved.slice(0, MAX_DEVICES).map(
(device): DeviceDeclaration => ({
id: device.id,
kind: device.kind,
label: device.label,
assetId: device.assetId,
// The resolved coordinate is deliberately dropped. This side never renders
// anything, and a declaration here exists to answer two questions — does
// this device exist, and may it be asked to do this — neither of which a
// position is part of. `anchor.seatId` survives because it is the one
// address the simulator uses: a microphone's level responds to whether
// anybody is at the desk it serves.
anchor: {
levelId: device.levelId,
propId: device.propId,
...(device.roomId === undefined ? {} : { roomId: device.roomId }),
...(device.seatId === undefined ? {} : { seatId: device.seatId }),
},
capabilities: [...device.capabilities],
provenance: device.provenance,
disclosure: device.disclosure,
}),
);
return { declarations, authored };
}
/**
* How many devices the pack's files mention, whatever state they are in.
*
* Counted rather than resolved, and read as `unknown` rather than through the
* `Floorplan` type, because the whole value of the number is that it is
* available when the resolution produced nothing — including when the pack is
* malformed enough that `Plan` refused it outright.
*/
function authoredCount(office: Office): number {
const levels: unknown = office.levels;
if (!Array.isArray(levels)) return 0;
let count = 0;
for (const level of levels) {
if (level === null || typeof level !== "object") continue;
const floorplan = (level as { floorplan?: unknown }).floorplan;
if (floorplan === null || typeof floorplan !== "object") continue;
const devices = (floorplan as { devices?: unknown }).devices;
if (Array.isArray(devices)) count += devices.length;
}
return count;
}
+50 -8
View File
@@ -2,19 +2,23 @@
* Real aircraft, from feeds that can actually be pointed at.
*
* Two sources, one shape. `adsb.lol` and `airplanes.live` serve the same
* volunteer-fed ADS-B in the same JSON, keyless and with open terms — set
* `TERA_ADSB_ENDPOINT` to whichever. A local `dump1090` writes that same JSON to
* disk, and reading it is the best answer of the three: an RTL-SDR on a box in
* the Bay produces first-party data with no terms to comply with at all.
* volunteer-fed ADS-B in the same JSON, keyless and under the ODbL — set
* `TERA_ADSB_ENDPOINT` to whichever, and to nothing else: `licence.ts` holds
* the allowlist and derives the credit from whichever answered. A local
* `dump1090` writes that same JSON to disk, and reading it is the best answer
* of the three: an RTL-SDR on a box in the Bay produces first-party data with
* no terms to comply with at all.
*
* FlightRadar24 is deliberately absent and will stay absent. Their terms forbid
* scraping and forbid redistribution, so a client for it in an Apache-2.0 repo
* would be shipping instructions for breaking a ToS. If a private deployment
* wants it, it is an adapter in that deployment. ARCHITECTURE.md §4.
* FlightRadar24 is deliberately absent and will stay absent: their terms do not
* permit scraping and do not permit redistribution, so a client for it in an
* Apache-2.0 repo would be shipping instructions for breaking a ToS. If a
* private deployment wants it, it is an adapter in that deployment.
* ARCHITECTURE.md §4.
*/
import { readFile } from "node:fs/promises";
import { getJson } from "../http.ts";
import { isOpenAdsbUrl } from "./licence.ts";
import type { WireAircraft } from "../../../src/server/wire.ts";
/** The shared dump1090/readsb aircraft record, as both feeds emit it. */
@@ -55,6 +59,15 @@ export interface AdsbLog {
*
* How often this is allowed to be called, and the arithmetic that keeps it
* inside adsb.lol's one-request-per-second ceiling, is in `flights/index.ts`.
*
* The same argument applies to *which* feed, and is enforced twice. `config.ts`
* refuses an endpoint that is not on `licence.ts`'s allowlist at boot, so the
* only string that can reach this parameter today is one an operator was told
* about. The check below is the second lock, on the door itself: this function
* will fetch anywhere, and it is the one that turns a URL into bytes this box
* republishes under a derived open-terms credit. A caller that one day builds
* the endpoint from somewhere else pays one `new URL()` per poll to find that
* out, rather than the public finding out later.
*/
export async function fetchAdsb(
endpoint: string,
@@ -63,6 +76,13 @@ export async function fetchAdsb(
log?: AdsbLog,
): Promise<FlightsSnapshot | null> {
const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
if (!isOpenAdsbUrl(url)) {
log?.warn(
`flights:adsb: refusing to fetch ${endpoint} — it is not an openly-licensed feed this ` +
`build may republish. See server/src/flights/licence.ts.`,
);
return null;
}
const body = await getJson<AircraftEnvelope>(url);
if (body === null) return null;
return normalise(body, (dropped) =>
@@ -151,9 +171,17 @@ function normalise(
const callsign = a.flight?.trim();
const id = a.hex ?? callsign;
if (id === undefined || id === "") continue;
const address = icao24(a.hex);
aircraft.push({
id,
callsign: callsign === "" ? undefined : callsign,
// Carried only when it is one. Both feeds emit `~`-prefixed anonymous
// addresses for TIS-B and MLAT targets, which are not ICAO addresses at
// all, and `id` falls back to the callsign for a record with no `hex` —
// so the id is not a reliable address and a detail card must not present
// it as one. `WireAircraft.icao24` says the same thing from the other
// end of the wire.
...(address === null ? {} : { icao24: address }),
lat: a.lat,
lng: a.lon,
// The feeds report barometric altitude in feet, and send the string
@@ -165,6 +193,20 @@ function normalise(
return { aircraft, observedAt: observedAtMs(body.now) };
}
/**
* A transponder address as the feeds write one: six hex digits, no prefix.
*
* Anchored and lowercased rather than pattern-matched loosely, because the
* output is something a person pastes into a registry lookup — a wrong one
* names a different aircraft, which is worse than saying nothing. Anything
* else, including the `~abcdef` anonymous form, is `null`.
*/
function icao24(hex: string | undefined): string | null {
if (typeof hex !== "string") return null;
const trimmed = hex.trim().toLowerCase();
return /^[0-9a-f]{6}$/.test(trimmed) ? trimmed : null;
}
/**
* dump1090 stamps `now` in seconds and the hosted feeds stamp it in
* milliseconds, using the same field name. Anything past the year 2001 in
+19 -3
View File
@@ -14,6 +14,18 @@
* empty coastline apart, with no sane radius that covers both. The requested
* region supplies the centre; the radius stays what the operator configured.
*
* ### Whose data this is
*
* Nothing in this file decides who gets the credit. `TERA_ADSB_ENDPOINT` is
* checked against an allowlist of openly-licensed feeds in `flights/licence.ts`
* before the process finishes booting, and the credit lines, the licence id and
* the one bit that says whether a shared cache may keep the body all come out
* of the entry that matched. An endpoint that is not on the list never reaches
* this file at all — `config.ts` has already demoted the source to `sim` and
* said so on `/api/v1/health`. The version of this file that hardcoded
* `adsb.lol` into the attribution regardless of the endpoint is why that gate
* exists.
*
* ### Staying inside adsb.lol's limits
*
* adsb.lol asks for **no more than one request per second** from a client and
@@ -75,6 +87,10 @@ const RECEIVER_KEY = "receiver";
export function createFlightsService(config: Config, log: FlightsLog): FlightsService {
const { source, endpoint, radiusNm, dump1090Path, epochMs, seed, ttlSeconds } = config.flights;
// Both derived in `config.ts` from the host that will actually answer, by
// `flights/licence.ts`. Read once here so that no code path in this file can
// construct a live body with a credit line it made up.
const { attribution, redistributable, licence } = config.flights;
// Built once per region and kept: the plan is a pure function of the centre,
// and it is handed out on every cacheable request.
@@ -119,9 +135,9 @@ export function createFlightsService(config: Config, log: FlightsLog): FlightsSe
observedAt: snapshot.observedAt,
aircraft: snapshot.aircraft,
ttlSeconds: liveTtl,
...(source === "adsb"
? { attribution: ["Aircraft positions from the adsb.lol community feed"] }
: {}),
redistributable,
...(attribution.length > 0 ? { attribution } : {}),
...(licence === null ? {} : { licence }),
};
},
};
+296
View File
@@ -0,0 +1,296 @@
/**
* Which ADS-B feeds this box is allowed to republish, and who gets the credit.
*
* This is the one module in the repo that exists because of a **live real-world
* exposure** rather than a feature. `TERA_ADSB_ENDPOINT` used to be a free-form
* string: whatever it pointed at, the aircraft that came back were served from
* `/api/v1/flights` with `Cache-Control: public` and an `attribution` array that
* said, unconditionally, *adsb.lol*. Three things were therefore true at once on
* any deployment one environment variable away from the default:
*
* 1. this box would fetch a feed nobody had checked the terms of,
* 2. it would hand the bytes to a shared cache to hand to everyone else, and
* 3. it would credit a community feed that had never seen them.
*
* (1) is an operator's business. (2) is *Publicly Using a Derivative Database*
* and is the trigger CONTRACT.md §8 is about — the same reasoning that made the
* geocoder a US Census one. (3) is worse than either: an open-terms credit
* attached to data that did not come from the open lane is a false licence
* statement, and it is false in the direction that invites a downstream
* consumer to redistribute something they may not.
*
* So the endpoint is checked against a table, and **everything the wire says
* about the source is derived from the host that actually answered**. There is
* no path here that lets a credit line and a hostname disagree, because the
* credit is computed from the hostname. An endpoint that is not in the table is
* refused — `config.ts` demotes the source to the simulated plan and writes one
* sentence into `degraded[]` naming the host — and the demotion is deliberately
* not fatal, for the same reason nothing else in `config.ts` is: CONTRACT.md
* §5.1 says a misconfigured source is demoted, not a boot failure. The operator
* gets a working map, a plan-mode sky and a line on `/api/v1/health` telling
* them exactly which variable to fix.
*
* ### Adding a feed
*
* Read the feed's published terms. Write down, in the entry: the licence, a URL
* where the next person can read the same terms, the credit line that feed asks
* for, and whether those terms let this box re-serve the bytes to third parties.
* If you cannot answer all four, the entry does not go in — a guess here is the
* failure this module exists to prevent. `adsb.fi` and a handful of other
* community mirrors serve the same `/v2/point` shape and are plausible next
* entries; they are absent because nobody on this side has read their terms,
* which is the honest reason and the only one that should ever appear here.
*
* ARCHITECTURE.md §4, NOTICE's AIRCRAFT DATA block.
*/
import type { FlightsBody } from "../../../src/server/wire.ts";
/**
* The licence a feed's data arrives under.
*
* `first-party` is not a licence at all and says so: it is the operator's own
* antenna, and there is nobody to comply with. See `LOOPBACK_CAVEAT` for why
* that claim is only ever as good as the operator making it.
*/
export type AdsbLicenceId = "ODbL-1.0" | "first-party";
export interface AdsbFeedTerms {
/** Lowercased hostname, matched exactly against the configured endpoint's. */
host: string;
/** Named in the credit line. This is who the bytes came from, in prose. */
credit: string;
licence: AdsbLicenceId;
/** Where a human reads the terms this entry claims. Never a guess. */
terms: string;
/**
* May this box re-serve the data to third parties?
*
* Gates `publicCache` on the flights route — see `mayRepublish`. A feed that
* is free to *use* and not free to *redistribute* is a real category (several
* aviation feeds are exactly that), and the whole point of carrying the flag
* rather than assuming it is that such an entry can be added later without
* anybody having to remember to also change the route.
*/
redistributable: boolean;
/**
* Plain HTTP is acceptable for this host.
*
* True only for loopback: a `readsb` on the same box has no certificate and
* needs none, and forcing TLS there would push self-hosters towards a public
* feed for no security gain. Everything reachable off-box must be https —
* without it, whoever is between us and the feed chooses what this box
* publishes under an open-terms credit.
*/
loopback?: boolean;
/** One sentence for `degraded[]` when this entry is the configured one. */
caveat?: string;
}
/**
* A receiver on the same box. First-party by construction — and by trust.
*
* Shared with the `dump1090` file source, which is the same data arriving by a
* different door, so the two cannot drift apart in what they claim.
*/
export const FIRST_PARTY_RECEIVER: AdsbFeedTerms = {
host: "localhost",
credit: "this deployment's own ADS-B receiver",
licence: "first-party",
terms: "no licence: data received by the operator's own antenna",
redistributable: true,
loopback: true,
caveat:
"TERA_ADSB_ENDPOINT points at a loopback address, so aircraft are credited to this " +
"deployment's own receiver. That credit is derived from the host and only you can vouch " +
"for it: if the process on that port is a proxy for somebody else's feed, this box is " +
"publishing their data under your name.",
};
/**
* The feeds this repo will point at, with the terms it publishes about them.
*
* Two hosted community feeds and the loopback family. Both hosted entries serve
* volunteer-fed ADS-B under the Open Database Licence, both are keyless, and
* both answer the same `/v2/point/:lat/:lng/:radiusNm` shape `adsb.ts` parses —
* which is not a coincidence, they are the same lineage of software.
*
* The apex domains are absent on purpose: `adsb.lol` serves a website and
* `api.adsb.lol` serves the API, and an operator who types the former gets a
* near-miss hint out of `checkAdsbEndpoint` rather than a 404 loop.
*/
export const ADSB_ALLOWLIST: readonly AdsbFeedTerms[] = [
{
host: "api.adsb.lol",
credit: "the adsb.lol community feed",
licence: "ODbL-1.0",
terms: "https://adsb.lol/legal-and-license/",
redistributable: true,
},
{
host: "api.airplanes.live",
credit: "the airplanes.live community feed",
licence: "ODbL-1.0",
terms: "https://airplanes.live/",
redistributable: true,
caveat:
"TERA_ADSB_ENDPOINT=airplanes.live: their feed is volunteer-funded and asks callers to " +
"stay inside a request-per-second ceiling, which flights/index.ts holds structurally. " +
"The ODbL credit is attached to every body automatically.",
},
FIRST_PARTY_RECEIVER,
{ ...FIRST_PARTY_RECEIVER, host: "127.0.0.1" },
{ ...FIRST_PARTY_RECEIVER, host: "::1" },
];
export type AdsbEndpointVerdict =
| {
ok: true;
/** The endpoint, normalised: origin plus path, no trailing slash. */
endpoint: string;
terms: AdsbFeedTerms;
/** What the wire must say about this source. Derived, never authored. */
attribution: string[];
/** One sentence for `degraded[]`, or `null` when there is nothing to say. */
caveat: string | null;
}
| { ok: false; reason: string };
/**
* Is this endpoint one this box may fetch, republish and credit?
*
* Every refusal `reason` is a fragment that reads correctly after
* `TERA_ADSB_ENDPOINT="…"` — `config.ts` builds the sentence, this builds the
* clause, and the host is always named so the operator can see which part of
* their URL was the problem.
*/
export function checkAdsbEndpoint(raw: string): AdsbEndpointVerdict {
const trimmed = raw.trim();
if (trimmed === "") return { ok: false, reason: "is empty" };
let url: URL;
try {
url = new URL(trimmed);
} catch {
return { ok: false, reason: "is not a URL" };
}
// A credential is not a syntax problem, it is a category one: every feed in
// the table is keyless, so a URL carrying a secret is by construction not one
// of them — it is somebody's paid account, and paid accounts are exactly the
// terms that do not permit republication.
if (url.username !== "" || url.password !== "") {
return {
ok: false,
reason:
"carries credentials, and every openly-licensed feed here is keyless — a URL with a " +
"secret in it is an account, and an account's data is not ours to re-serve",
};
}
// `adsb.ts` appends `/v2/point/…` to this string. A query or a fragment
// would end up in the middle of the path, so it is refused rather than
// silently dropped: an operator who put an API key in `?key=` needs to see
// that it was neither used nor honoured.
if (url.search !== "" || url.hash !== "") {
return {
ok: false,
reason:
"carries a query string or fragment; this is a base URL that /v2/point/:lat/:lng/:nm " +
"is appended to, so anything after it would land in the middle of the path",
};
}
// `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]/`
// parses to a hostname of `[::1]` — so they come off before the table is
// consulted, and the table stores the address the way a person writes it.
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
const terms = ADSB_ALLOWLIST.find((entry) => entry.host === host);
if (terms === undefined) {
return { ok: false, reason: `points at ${host}, which ${notOnTheList(host)}` };
}
if (url.protocol !== "https:" && !(terms.loopback === true && url.protocol === "http:")) {
return {
ok: false,
reason:
`reaches ${host} over ${url.protocol.replace(":", "")}, and only https will do off-box: ` +
"without it, whoever is on the path chooses what this deployment publishes under an " +
"open-terms credit",
};
}
return {
ok: true,
endpoint: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
terms,
attribution: adsbAttribution(terms),
caveat: terms.caveat ?? null,
};
}
/**
* The credit lines for a feed, built from the entry rather than written twice.
*
* ODbL §4.3 wants the notice to name the source *and* point at the licence, so
* an ODbL feed gets two lines and a first-party receiver gets one. The browser
* displays whatever it is sent, in order, which is why the source line is first.
*/
export function adsbAttribution(terms: AdsbFeedTerms): string[] {
const lines = [`Aircraft positions from ${terms.credit}`];
if (terms.licence === "ODbL-1.0") {
lines.push(
"Made available under the Open Database License (ODbL) v1.0 — " +
"https://opendatacommons.org/licenses/odbl/1-0/",
);
}
return lines;
}
/**
* The last line of defence, at the point of the request.
*
* `config.ts` has already validated the endpoint by the time anything calls
* `fetchAdsb`, so this should never fire. It is here because "should never" is
* a property of today's call graph and this module is about the case where that
* property quietly stops holding — a future caller that builds a URL from
* somewhere else pays one `new URL()` per poll to find out it was wrong,
* instead of the public getting a credited copy of a feed nobody checked.
*/
export function isOpenAdsbUrl(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return ADSB_ALLOWLIST.some((entry) => entry.host === host);
} catch {
return false;
}
}
/**
* May a shared cache keep this body and hand it to the next caller?
*
* The plan is this project's own arithmetic under Apache-2.0, so it always may.
* A live body may only if the entry the positions came from said so. The route
* asks this instead of asking the config, because the thing being cached is the
* body, and a body that outlives a config change is precisely the bug a shared
* cache produces.
*/
export function mayRepublish(body: FlightsBody): boolean {
return body.mode === "plan" ? true : body.redistributable;
}
/**
* "…is not on the open-feed allowlist", plus a nudge when the host looks like
* somebody reaching for one of the entries and missing by a subdomain.
*/
function notOnTheList(host: string): string {
const near = ADSB_ALLOWLIST.find(
(entry) => entry.host.endsWith(`.${host}`) || host.endsWith(`.${entry.host}`),
);
const hosts = ADSB_ALLOWLIST.map((entry) => entry.host).join(", ");
const hint = near === undefined ? "" : ` (did you mean https://${near.host}?)`;
return (
`is not one of the openly-licensed feeds this build will republish — ${hosts}${hint}. ` +
"The endpoint is not fetched and its data is not credited to anyone"
);
}
+24 -4
View File
@@ -1,4 +1,13 @@
/** Server-authoritative lookup for authored office screen surfaces. */
/**
* Server-authoritative lookup for the things an office pack *authored* — screen
* surfaces today, device hardware as well now.
*
* The rule both callers share: a client says "this screen", "this microphone",
* and the server checks the claim against a pack it resolved itself rather than
* against anything in the request. `officeHasMediaBinding` does it for a screen
* share; `server/src/devices/store.ts` does it for a device command, and reads
* the same bundled packs through `bundledOffice` for the same reason.
*/
import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
@@ -17,10 +26,21 @@ const BUNDLED_OFFICES: ReadonlyMap<string, Office> = new Map(
);
/**
* A bundled pack is public sample geometry, so its exact authored screen
* catalogue is the only bundled exception. No real/private pack is inferred.
* The three packs this repo ships, by id, or `null` for anything else.
*
* A bundled pack is public sample geometry — it is compiled into the browser
* bundle, so its authored catalogue of screens and devices is already public by
* construction and the server reading its own copy tells nobody anything new.
* That is what makes it a safe exception, and it is the **only** exception: no
* real or private pack is ever inferred from anything, and an id that is not one
* of these three is not an office as far as this function is concerned.
*
* Why it is needed at all: the browser renders bundled packs without asking the
* API for them, so a viewer can be standing in `lumbridge-hq` on a deployment
* that has no `TERA_OFFICES_DIR` at all. Without this, every server-side check
* about the room they are in would have nothing to check against.
*/
export function bundledMediaOffice(officeId: string): Office | null {
export function bundledOffice(officeId: string): Office | null {
return BUNDLED_OFFICES.get(officeId) ?? null;
}
+1 -1
View File
@@ -5,7 +5,7 @@ export {
type MediaSignalService,
type MediaSignalServiceOptions,
} from "./service.ts";
export { bundledMediaOffice, officeHasMediaBinding } from "./bindings.ts";
export { bundledOffice, officeHasMediaBinding } from "./bindings.ts";
export {
createIceCredentialProvider,
deriveIceCredentialRateKeys,
+175
View File
@@ -0,0 +1,175 @@
/**
* `GET /api/v1/offices/:id/devices` and
* `POST /api/v1/offices/:id/devices/command`.
*
* The read is a sibling of `/presence` and behaves exactly like it: viewer
* first, then the office, 401 for anonymous, 404 for anything this caller may
* not see, and never a shared cache. The reasoning is written out at length in
* `routes/presence.ts` and is not repeated here; what follows is what is
* *different* about devices, which is the write.
*
* ### Two routes, and that is the security property
*
* A command never rides in the read body and a read never applies one. This is
* the first write surface in this product that changes something another viewer
* can see, and folding it into the GET would mean a body that mutates a
* microphone is a body a shared cache is entitled to keep and replay. That is
* precisely the outcome the fail-closed `private, no-store` default in
* `cache.ts` exists to prevent, and the way to not have that problem is to not
* have that shape: reads are GETs and are never cached, writes are POSTs with a
* body of their own.
*
* Neither route ever calls `publicCache`. Stated rather than merely omitted,
* because the absence of a call is not self-evidently a decision — the same
* note `routes/presence.ts` leaves for the same reason.
*
* ### What a command is checked against
*
* The pack, resolved by this process. `devices/store.ts` builds a `Plan` and
* accepts a declaration only if the prop it is anchored to exists, is on the
* level it claims, and *is* the hardware the declaration names. So a command
* carries an id, and the id either names one of those or it does not — nothing
* in the request describes the device, which means nothing in the request can
* describe it wrongly. `officeHasMediaBinding()` makes the identical move for a
* screen share.
*
* ### Reading is the demo; writing is the account
*
* An anonymous visitor gets 401 here and gets a **locally simulated studio**
* from `src/devices/adapter.ts` instead — alive, labelled, and honest about
* what it is. That is the anon-first posture applied to a route that genuinely
* cannot be opened: the readings describe a room somebody is standing in, and
* a command changes it for everybody else in there. What an account buys is the
* shared room, not the demonstration.
*/
import type { FastifyInstance } from "fastify";
import { bundledOffice } from "../media/index.ts";
import { isDeviceCommandOp, type DeviceCommand } from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type {
DeviceCommandBody,
DeviceCommandResultBody,
ErrorBody,
} from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
const UNAUTHORIZED: ErrorBody = {
error: "unauthorized",
message: "Device state is for signed-in members.",
};
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." };
/**
* The largest command body this route will read, in bytes.
*
* A `DeviceCommand` is three short fields and the largest legitimate one is
* well under two hundred bytes. The cap is not about those — it is about the
* body nobody meant to send, and it is set here rather than trusted to a global
* because this is the only route on the box that accepts one.
*/
const MAX_COMMAND_BYTES = 2048;
export function registerDevices(app: FastifyInstance, services: Services): void {
app.get<{ Params: { id: string } }>("/api/v1/offices/:id/devices", async (req, reply) => {
// Viewer first, before the id is looked at. The ordering is the property
// that stops this being an enumeration oracle — an anonymous caller gets
// the same 401 for a real office, a private one and an invented one — and
// `routes/presence.ts` explains why doing it the other way round is the bug.
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const office = await officeFor(services, req.params.id);
if (office === null) return reply.code(404).send(NOT_FOUND);
// No `publicCache`, ever. See the header.
return services.devices.current(office);
});
app.post<{ Params: { id: string }; Body: unknown }>(
"/api/v1/offices/:id/devices/command",
{ bodyLimit: MAX_COMMAND_BYTES },
async (req, reply) => {
const viewer = await services.auth.resolve(req);
if (!viewer.authenticated) {
return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
}
const office = await officeFor(services, req.params.id);
if (office === null) return reply.code(404).send(NOT_FOUND);
const command = readCommand(req.body);
if (command === null) {
const error: ErrorBody = { error: "bad_request", message: "Not a device command." };
return reply.code(400).send(error);
}
const outcome = services.devices.command(office, command);
if (!outcome.ok) {
// One status and one message for every way a command can be wrong. A
// caller told *which* of its guesses missed is a caller being helped to
// guess again, and the honest audience for the distinction is the log.
req.log.info({ officeId: office.id, reason: outcome.reason }, "device command refused");
const error: ErrorBody = { error: "bad_request", message: "That command was refused." };
return reply.code(400).send(error);
}
const body: DeviceCommandResultBody = {
officeId: office.id,
device: outcome.device,
observedAt: outcome.device.observedAt,
};
return body;
},
);
}
/**
* The office this request is about, or `null` if there is not one this viewer
* may see.
*
* Served packs win over bundled ones, so an operator who has put their own
* `lumbridge-hq.json` in `TERA_OFFICES_DIR` gets theirs. A bundled pack is the
* fallback and not a leak: it is compiled into the browser bundle that made the
* request, so its authored device list is already in the caller's hands — see
* `bundledOffice`. Without it, every deployment that has not configured an
* offices directory would answer 404 for the very studios it is rendering.
*
* Visibility is honoured the way `routes/offices.ts` honours it: a private pack
* is 404 to anyone who is not signed in. By the time this is called the viewer
* already is, so the check that remains is the one for a document that does not
* exist at all.
*/
async function officeFor(services: Services, id: string): Promise<Office | null> {
const doc = await services.offices.get(id);
if (doc !== null) return doc.floor;
return bundledOffice(id);
}
/**
* One command, read out of an untrusted body.
*
* Shape only. Whether the device exists, whether it accepts this op and whether
* the value is in range are all decided against the resolved pack in
* `devices/index.ts`, which is the only place that can decide them — this
* function's whole job is to make sure there is something of the right shape to
* hand it.
*/
function readCommand(raw: unknown): DeviceCommand | null {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
const body = raw as Partial<DeviceCommandBody>;
const command: unknown = body.command;
if (command === null || typeof command !== "object" || Array.isArray(command)) return null;
const c = command as Record<string, unknown>;
if (typeof c.deviceId !== "string" || c.deviceId === "") return null;
if (!isDeviceCommandOp(c.op)) return null;
// A value of the wrong type is dropped rather than passed through, which
// makes it a *missing* value — and `normalizeDeviceCommand` refuses a command
// whose op needs one and has none. The refusal is therefore made in the one
// place that knows which ops need what, rather than half here.
const value = typeof c.value === "boolean" || typeof c.value === "number" ? c.value : undefined;
return { deviceId: c.deviceId, op: c.op, ...(value === undefined ? {} : { value }) };
}
+13 -4
View File
@@ -7,9 +7,17 @@
* traffic endpoint that will fetch any coordinate on demand is an amplifier
* pointed at a volunteer-funded feed.
*
* Publicly cacheable, because the whole design of the plan is that one response
* serves every viewer of a region for its whole TTL. Aircraft are not personal
* data and this body never varies by who asked — only by where.
* Publicly cacheable **when the licence allows it**, because the whole design of
* the plan is that one response serves every viewer of a region for its whole
* TTL. Aircraft are not personal data and this body never varies by who asked —
* only by where.
*
* The condition is not hypothetical caution. Handing a body to a shared cache is
* republication: the CDN serves it to people this box never spoke to, under
* whatever credit the body carries. So the answer comes from the body's own
* `redistributable` flag, which `flights/licence.ts` derived from the terms of
* the feed that answered — and a body that may not be shared simply keeps the
* fail-closed `private, no-store` every reply starts with (CONTRACT.md §5).
*
* ### `radiusNm` on the query is ignored, deliberately
*
@@ -30,6 +38,7 @@
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import { mayRepublish } from "../flights/licence.ts";
import { resolveRegion, type RegionQuery } from "../regions.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
@@ -43,7 +52,7 @@ export function registerFlights(app: FastifyInstance, services: Services): void
}
const body = await services.flights.current(resolved.region);
publicCache(req, reply, body.ttlSeconds);
if (mayRepublish(body)) publicCache(req, reply, body.ttlSeconds);
return body;
});
}
+10 -18
View File
@@ -11,36 +11,27 @@
* config made is printed here, so "why is the weather always clear" has an
* answer that does not require log access.
*
* `regions` joins it for the same reason. Weather and flights now refuse a place
* this box does not serve, so a client that guesses `?city=` and a 400 it cannot
* explain is the failure this field prevents: ask health once, learn what may be
* asked for, and an operator diagnosing "why is there no SoCal weather" reads
* the answer instead of the env file. Publishing the allowlist gives nothing
* `regions` is a field of `HealthBody` for the same reason, and it is a field
* of `HealthBody` rather than of a local alias widening it next to this route:
* a body's shape stated anywhere but the wire contract is a shape the browser
* cannot read. Weather and flights now refuse a place this box does not serve,
* so a client that guesses `?city=` and earns a 400 it cannot explain is the
* failure this field prevents: ask health once, learn what may be asked for,
* and an operator diagnosing "why is there no SoCal weather" reads the answer
* instead of the env file. Publishing the allowlist gives nothing
* away — knowing what is served is not the same as widening it, and the ids are
* the names of the cities the map already draws.
*/
import type { FastifyInstance } from "fastify";
import type { Region } from "../regions.ts";
import type { HealthBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";
/**
* `HealthBody` plus the served regions.
*
* The field belongs in `src/server/wire.ts` beside the body it extends, and it
* is stated here only because that file is the browser side of this change and
* lands with it. Fold `regions: Region[]` into `HealthBody` and this alias goes
* away; nothing else has to move, because the shape is already exactly what the
* route serves.
*/
type HealthBodyWithRegions = HealthBody & { regions: Region[] };
export function registerHealth(app: FastifyInstance, services: Services): void {
const { config, startedAt } = services;
app.get("/api/v1/health", async () => {
const body: HealthBodyWithRegions = {
const body: HealthBody = {
ok: true,
service: "tera-api",
version: config.version,
@@ -50,6 +41,7 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
flights: config.flights.source,
satellites: config.satellites.source,
markers: config.markers.source,
devices: config.devices.source,
},
auth: {
mode: config.auth.mode,
+2 -2
View File
@@ -12,7 +12,7 @@ import type {
ScreenShareSignalRequest,
ScreenShareStopRequest,
} from "../../../src/media/signalingTypes.ts";
import { bundledMediaOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts";
import { bundledOffice, officeHasMediaBinding, type MediaSignalFailureCode } from "../media/index.ts";
import type { Services } from "../services.ts";
const BODY_LIMIT = 32 * 1024;
@@ -34,7 +34,7 @@ function fail(reply: FastifyReply, failure: { code: MediaSignalFailureCode; mess
async function bindingAllowed(binding: ScreenShareBinding, services: Services): Promise<boolean> {
const doc = await services.offices.get(binding.officeId);
const office = doc?.floor ?? bundledMediaOffice(binding.officeId);
const office = doc?.floor ?? bundledOffice(binding.officeId);
return office !== null && officeHasMediaBinding(office, binding);
}
+3
View File
@@ -8,6 +8,7 @@
*/
import { createAuth, type AuthService } from "./auth/index.ts";
import { createDevicesService, type DevicesService } from "./devices/index.ts";
import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import {
@@ -29,6 +30,7 @@ export interface Services {
flights: FlightsService;
satellites: SatellitesService;
markers: MarkerStore;
devices: DevicesService;
media: MediaSignalService;
ice: IceCredentialProvider;
offices: OfficeStore;
@@ -51,6 +53,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
flights: createFlightsService(config, log),
satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log),
devices: createDevicesService(config, log),
media: createMediaSignalService(),
ice: createIceCredentialProvider(config.ice),
offices: createOfficeStore(config.offices.dir),
+346
View File
@@ -0,0 +1,346 @@
/**
* The licence gate on `TERA_ADSB_ENDPOINT`.
*
* This is the test file for the one exposure in this repo that is not a
* feature. Before the gate, `TERA_ADSB_ENDPOINT` accepted any string; whatever
* came back was served from `/api/v1/flights` with `Cache-Control: public` and
* an `attribution` array that read "Aircraft positions from the adsb.lol
* community feed" — hardcoded, next to the fetch, regardless of where the fetch
* went. A deployment was therefore one environment variable away from
* publishing somebody else's non-redistributable data, to a shared cache, under
* an open-terms credit belonging to a volunteer feed that had never seen it.
*
* Two properties are asserted here more than any other, because they are the
* two that were false:
*
* 1. **An allowlisted host is credited to itself.** Not to the default, not
* to the first entry in the table — to the host that actually answered.
* 2. **A host that is not on the allowlist is refused**, loudly, rather than
* fetched and mis-credited. Refused means: no request, no live body, one
* sentence in `degraded[]` naming the host, and the simulated plan on the
* wire instead.
*
* Everything else in this file is a door on the same room: schemes, credentials,
* query strings, loopback, and the `redistributable` bit that decides whether a
* CDN is allowed to keep a copy.
*/
import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { createFlightsService } from "../flights/index.ts";
import {
ADSB_ALLOWLIST,
adsbAttribution,
checkAdsbEndpoint,
FIRST_PARTY_RECEIVER,
isOpenAdsbUrl,
mayRepublish,
type AdsbFeedTerms,
} from "../flights/licence.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";
const realFetch = globalThis.fetch;
let calls: string[] = [];
/** One aircraft, from whatever host was asked. Shape is the shared dump1090 one. */
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
calls.push(url);
if (!/\/v2\/point\//.test(url)) return new Response("nope", { status: 404 });
return new Response(
JSON.stringify({
now: 1_770_000_000_000,
ac: [{ hex: "a1b2c3", flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as unknown as typeof globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
beforeEach(() => {
calls = [];
});
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb", ...env });
config.logLevel = "silent";
return { config, app: buildApp(config) };
}
/**
* The URL an operator would write for this entry. Loopback speaks plain http,
* and an IPv6 literal has to be bracketed before it is a URL at all — which is
* exactly the kind of detail a table-driven test finds and a hand-written one
* does not.
*/
function baseUrlFor(terms: AdsbFeedTerms): string {
const host = terms.host.includes(":") ? `[${terms.host}]` : terms.host;
return `${terms.loopback === true ? "http" : "https"}://${host}`;
}
/** The `degraded[]` lines this configuration produced, minus the ones about radius etc. */
function endpointLines(degraded: string[]): string[] {
return degraded.filter((line) => line.includes("TERA_ADSB_ENDPOINT"));
}
describe("an allowlisted endpoint", () => {
it("credits the host that actually answered, not the default", async () => {
// The exact bug, in one assertion: point the box at airplanes.live and the
// credit must say airplanes.live. It used to say adsb.lol.
const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://api.airplanes.live" });
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.equal(config.flights.source, "adsb");
assert.ok(body.mode === "live");
const credits = (body.attribution ?? []).join(" ");
assert.match(credits, /airplanes\.live/);
assert.doesNotMatch(credits, /adsb\.lol/);
assert.equal(calls[0], "https://api.airplanes.live/v2/point/37.7749/-122.4194/40");
});
it("credits every entry to itself, whichever one is configured", () => {
// Generalises the assertion above over the whole table, so an entry added
// later cannot inherit the previous one's credit by being copied.
for (const terms of ADSB_ALLOWLIST) {
const verdict = checkAdsbEndpoint(baseUrlFor(terms));
assert.ok(verdict.ok, `${terms.host} should be allowed`);
const credits = verdict.attribution.join(" ");
assert.ok(credits.includes(terms.credit), `${terms.host} must be credited to itself`);
for (const other of ADSB_ALLOWLIST) {
if (other.credit === terms.credit) continue;
assert.ok(
!credits.includes(other.credit),
`${terms.host} must not be credited to ${other.host}`,
);
}
}
});
it("states the licence as well as the source, which is what ODbL asks for", () => {
const verdict = checkAdsbEndpoint("https://api.adsb.lol");
assert.ok(verdict.ok);
assert.equal(verdict.terms.licence, "ODbL-1.0");
assert.match(verdict.attribution.join(" "), /Open Database License/);
assert.match(verdict.attribution.join(" "), /opendatacommons\.org/);
});
it("passes its own gate on the default configuration", () => {
// A default that fails its own validation is a gate nobody can use.
const { config } = appWith({});
assert.equal(config.flights.source, "adsb");
assert.equal(config.flights.endpoint, "https://api.adsb.lol");
assert.deepEqual(endpointLines(config.degraded), []);
assert.equal(config.flights.redistributable, true);
});
it("normalises the endpoint so a trailing slash is not a second URL", () => {
const verdict = checkAdsbEndpoint("https://api.adsb.lol///");
assert.ok(verdict.ok);
assert.equal(verdict.endpoint, "https://api.adsb.lol");
});
});
describe("an endpoint that is not on the allowlist", () => {
it("is refused rather than fetched and mis-credited", async () => {
const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" });
after(() => app.close());
// Demoted, not fatal: CONTRACT.md §5.1. The map still works.
assert.equal(config.flights.source, "sim");
const lines = endpointLines(config.degraded);
assert.equal(lines.length, 1, "exactly one sentence about the endpoint");
assert.match(lines[0] ?? "", /flights\.example\.com/, "the line names the host");
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.equal(body.mode, "plan");
assert.deepEqual(calls, [], "a refused endpoint is never fetched");
// The whole point: nobody is credited for data nobody served.
assert.equal(JSON.stringify(body).includes("adsb.lol"), false);
});
it("does not leave the refused URL anywhere later code could use it", () => {
const { config } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" });
assert.equal(config.flights.endpoint, "");
assert.deepEqual(config.flights.attribution, []);
assert.equal(config.flights.redistributable, false);
assert.equal(config.flights.licence, null);
});
it("refuses plain http off-box, where the path chooses what we publish", () => {
const verdict = checkAdsbEndpoint("http://api.adsb.lol");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /https/);
});
it("refuses a URL carrying credentials, because open feeds are keyless", () => {
const verdict = checkAdsbEndpoint("https://user:secret@api.adsb.lol");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /credential/);
});
it("refuses a query string rather than pasting it into the middle of a path", () => {
const verdict = checkAdsbEndpoint("https://api.adsb.lol?key=hunter2");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /query string/);
});
it("refuses something that is not a URL at all", () => {
assert.ok(!checkAdsbEndpoint("api.adsb.lol").ok);
assert.ok(!checkAdsbEndpoint("").ok);
});
it("refuses a host that merely contains an allowlisted name", () => {
// The attack the allowlist has to survive: an exact-match table, never a
// substring test.
for (const host of ["api.adsb.lol.example.com", "notapi.adsb.lol", "adsb.lol.evil.test"]) {
const verdict = checkAdsbEndpoint(`https://${host}`);
assert.ok(!verdict.ok, `${host} must not pass`);
}
});
it("points a near miss at the entry it was probably reaching for", () => {
// `adsb.lol` serves a website; `api.adsb.lol` serves the API. An operator
// who types the first one deserves better than a flat refusal.
const verdict = checkAdsbEndpoint("https://adsb.lol");
assert.ok(!verdict.ok);
assert.match(verdict.reason, /did you mean https:\/\/api\.adsb\.lol/);
});
});
describe("a receiver of one's own", () => {
it("takes loopback over plain http and credits the operator, not a feed", async () => {
const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "http://127.0.0.1:8080" });
after(() => app.close());
assert.equal(config.flights.source, "adsb");
assert.equal(config.flights.licence, "first-party");
const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
assert.ok(body.mode === "live");
assert.match((body.attribution ?? []).join(" "), /own ADS-B receiver/);
assert.equal(calls[0], "http://127.0.0.1:8080/v2/point/37.7749/-122.4194/40");
});
it("says out loud that only the operator can vouch for a loopback claim", () => {
const { config } = appWith({ TERA_ADSB_ENDPOINT: "http://localhost:8080" });
const lines = endpointLines(config.degraded);
assert.equal(lines.length, 1);
assert.match(lines[0] ?? "", /loopback/);
});
it("credits the dump1090 file source from the same table", () => {
// Same data, different door. If these two ever disagree about who to
// credit, one of them is lying.
const config = loadConfig({
TERA_FLIGHTS_SOURCE: "dump1090",
TERA_DUMP1090_PATH: "/tmp/aircraft.json",
});
assert.equal(config.flights.source, "dump1090");
assert.deepEqual(config.flights.attribution, adsbAttribution(FIRST_PARTY_RECEIVER));
assert.equal(config.flights.licence, "first-party");
});
});
describe("what a shared cache is allowed to keep", () => {
it("lets a CDN hold a redistributable live body", async () => {
const { app } = appWith({});
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/flights" });
assert.match(res.headers["cache-control"] as string, /^public, max-age=\d+$/);
});
it("keeps a non-redistributable body private, which is the fail-closed default", () => {
// No entry in today's table is non-redistributable, and the flag exists so
// that adding one — a feed we may use but not re-serve — needs no change to
// the route. This is the branch that would then run.
const body: FlightsBody = {
mode: "live",
source: "adsb",
observedAt: 0,
aircraft: [],
ttlSeconds: 5,
redistributable: false,
};
assert.equal(mayRepublish(body), false);
assert.equal(mayRepublish({ ...body, redistributable: true }), true);
});
it("always lets the simulated plan be shared, because it is ours", () => {
const plan: FlightsBody = {
mode: "plan",
source: "sim",
t0: 0,
seed: 1,
routes: [],
ttlSeconds: 300,
};
assert.equal(mayRepublish(plan), true);
});
});
describe("the second lock, on the door itself", () => {
it("will not fetch a non-allowlisted endpoint even if one reaches the service", async () => {
// `config.ts` cannot produce this. A future caller building the endpoint
// from somewhere else could, and this is what happens when it does: no
// request, and the plan on the wire.
const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb" });
config.flights.endpoint = "https://flights.example.com";
const service = createFlightsService(config, { warn: () => {} });
const body = await service.current(config.regions[0]);
assert.equal(body.mode, "plan");
assert.deepEqual(calls, []);
});
it("knows an open feed's URL from any other", () => {
assert.equal(isOpenAdsbUrl("https://api.adsb.lol/v2/point/1/2/40"), true);
assert.equal(isOpenAdsbUrl("https://flights.example.com/v2/point/1/2/40"), false);
assert.equal(isOpenAdsbUrl("not a url"), false);
});
});
describe("the table itself", () => {
it("says where every claim it makes can be checked", () => {
for (const terms of ADSB_ALLOWLIST) {
assert.equal(terms.host, terms.host.toLowerCase(), `${terms.host} must be lowercase`);
assert.notEqual(terms.credit, "", `${terms.host} needs a credit line`);
assert.notEqual(terms.terms, "", `${terms.host} needs terms a human can read`);
assert.ok(
terms.licence === "ODbL-1.0" || terms.licence === "first-party",
`${terms.host} names a licence`,
);
// Anything off-box must be https-only; only a loopback entry may relax it.
if (terms.loopback !== true) {
assert.ok(!checkAdsbEndpoint(`http://${terms.host}`).ok, `${terms.host} must be https`);
}
}
});
it("carries no feed whose terms forbid what this box does with it", () => {
// A `redistributable: false` entry is legitimate, but it must never be
// reachable while the route is still handing bodies to a shared cache. That
// is `mayRepublish`'s job, and this asserts the two stay wired together.
for (const terms of ADSB_ALLOWLIST) {
const verdict = checkAdsbEndpoint(baseUrlFor(terms));
assert.ok(verdict.ok);
assert.equal(
mayRepublish({
mode: "live",
source: "adsb",
observedAt: 0,
aircraft: [],
ttlSeconds: 5,
redistributable: verdict.terms.redistributable,
}),
terms.redistributable,
);
}
});
});
+572
View File
@@ -0,0 +1,572 @@
/**
* The device routes: the refusal, the ordering, and the write.
*
* Three properties carry this file, and the third is the one that is new to
* this repo — everything before devices was read-only.
*
* **The refusal.** Device state describes a room somebody is standing in, so
* the read takes a session unconditionally, exactly as occupancy does. An
* anonymous caller gets an identical 401 for a real office, a private one and
* an invented one, because the viewer is resolved before the id is looked at:
* check the office first and the status codes tell them apart perfectly, which
* is CONTRACT.md §6's enumeration oracle wearing a different number.
*
* **The cache.** Neither route may ever be publicly cached, in any
* configuration, including the ones that answer 401 and 404. A shared cache
* holding a body that took a credential to obtain is one viewer's studio served
* to the next; a shared cache holding a *command* is a microphone that can be
* switched on by replaying a request nobody made. Both are asserted, on every
* status code, because `cache.ts` is fail-closed by default and the way that
* default gets lost is a route quietly opting out.
*
* **The write.** A command is checked against the pack this process resolved
* and not against anything in the request: an id that names no authored
* declaration, an op the declaration never declared, or a value of the wrong
* type is a 400 and — this is the assertion that matters — **no state
* changes**. The read after the refused write is what proves it.
*/
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { forgetResolvedDevices, resolveDevices } from "../devices/store.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { DeviceState } from "../../../src/devices/types.ts";
import type { DevicesBody } from "../../../src/server/wire.ts";
const SECRET = "not-a-real-secret-and-never-was";
const MIC = "tera:device.mic.desk";
const SPEAKER = "tera:device.speaker.desk";
/**
* A studio with two real props and four declarations, three of which are wrong
* in a different way.
*
* Written out rather than borrowed from a shipped pack on purpose: the shipped
* packs are the `packs` workstream's to change, and a route test that fails
* because somebody moved a desk in Los Angeles is a test nobody trusts. What is
* asserted here is the *rule*, and the rule needs a pack that deliberately
* breaks it.
*/
function studio(id: string): Office {
return {
id,
name: "Test studio",
viewpoints: [],
levels: [
{
id: "l1",
name: "Ground",
elevation: 0,
wallHeight: 3,
floorplan: {
rooms: [
{
id: "room",
name: "Room",
floor: "tera:carpet.loop",
outline: [
{ x: 0, z: 0 },
{ x: 8, z: 0 },
{ x: 8, z: 6 },
{ x: 0, z: 6 },
],
},
],
walls: [],
seats: [{ id: "desk-01", position: { x: 2, z: 2 }, facing: 0 }],
props: [
{ id: "mic-prop", kind: MIC, position: { x: 2, z: 2 }, rotation: 0 },
{ id: "speaker-prop", kind: SPEAKER, position: { x: 3, z: 2 }, rotation: 0 },
{ id: "desk-prop", kind: "tera:desk.workstation", position: { x: 2, z: 2.4 }, rotation: 0 },
],
devices: [
{
id: "mic-1",
kind: "mic",
label: "Desk mic",
assetId: MIC,
anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
capabilities: ["power", "mute", "gain", "level"],
provenance: "simulated",
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
},
{
id: "speaker-1",
kind: "speaker",
label: "Monitor",
assetId: SPEAKER,
anchor: { levelId: "l1", propId: "speaker-prop" },
capabilities: ["power", "volume", "playback"],
provenance: "simulated",
disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
},
// Standing on a desk rather than being its own prop. This is the
// ordinary case and it is **accepted**: `DeviceAnchor.offset` exists
// for exactly those few centimetres, and a desk claims to be no
// instrument at all, so there is nothing for it to disagree with.
{
id: "desk-mounted-mic",
kind: "mic",
label: "Boom mic",
assetId: MIC,
anchor: { levelId: "l1", propId: "desk-prop", offset: { x: 0, y: 0.74, z: 0.1 } },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "Simulated studio hardware.",
},
// Anchored to another instrument's hardware. A microphone bolted to
// a speaker is not a rendering nit — it is a command routed to the
// wrong instrument in a real room — and it is dropped.
{
id: "mic-on-a-speaker",
kind: "mic",
label: "Nothing",
assetId: MIC,
anchor: { levelId: "l1", propId: "speaker-prop" },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "Simulated.",
},
// Anchored to a prop that does not exist.
{
id: "mic-nowhere",
kind: "mic",
label: "Nothing",
assetId: MIC,
anchor: { levelId: "l1", propId: "no-such-prop" },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "Simulated.",
},
// Says it is simulated in its provenance and not in its words. The
// same check `resolveRobotOperations` makes, applied by
// `validateDeviceDeclaration` and enforced here.
{
id: "mic-undisclosed",
kind: "mic",
label: "Nothing",
assetId: MIC,
anchor: { levelId: "l1", propId: "mic-prop" },
capabilities: ["power", "level"],
provenance: "simulated",
disclosure: "A microphone.",
},
],
},
},
],
} as unknown as Office;
}
let offices = "";
before(async () => {
offices = await mkdtemp(join(tmpdir(), "tera-devices-"));
await writeFile(
join(offices, "open.json"),
JSON.stringify({ id: "open", name: "Open", visibility: "public", floor: studio("open") }),
);
await writeFile(
join(offices, "closed.json"),
JSON.stringify({ id: "closed", name: "Closed", visibility: "private", floor: studio("closed") }),
);
await writeFile(
join(offices, "bare.json"),
JSON.stringify({
id: "bare",
name: "Bare",
visibility: "public",
floor: { id: "bare", name: "Bare", levels: [], viewpoints: [] },
}),
);
});
const jwt = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET };
const sim = { TERA_DEVICES_SOURCE: "sim" };
function appWith(env: Record<string, string>) {
const config = loadConfig({ TERA_OFFICES_DIR: offices, ...env });
config.logLevel = "silent";
return buildApp(config);
}
function hs256(claims: Record<string, unknown>): string {
const encode = (value: unknown): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`;
}
function bearer(): { authorization: string } {
return {
authorization: `Bearer ${hs256({ sub: "someone", exp: Math.floor(Date.now() / 1000) + 3600 })}`,
};
}
const url = (id: string) => `/api/v1/offices/${id}/devices`;
describe("the refusal", () => {
it("refuses an anonymous read even for a public office", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("open") });
assert.equal(res.statusCode, 401);
assert.equal(res.headers["www-authenticate"], "Bearer");
// The point: the office itself is public and readable by this same caller.
const doc = await app.inject({ method: "GET", url: "/api/v1/offices/open" });
assert.equal(doc.statusCode, 200);
});
it("refuses an anonymous command", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 401);
});
it("tells a real, a private and an absent office apart for nobody", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const real = await app.inject({ method: "GET", url: url("open") });
const priv = await app.inject({ method: "GET", url: url("closed") });
const gone = await app.inject({ method: "GET", url: url("no-such-thing") });
assert.equal(real.statusCode, 401);
assert.equal(priv.statusCode, 401);
assert.equal(gone.statusCode, 401);
assert.deepEqual(real.json(), priv.json());
assert.deepEqual(real.json(), gone.json());
});
it("answers 404, never 403, for an office a signed-in caller cannot see", async () => {
// `offices/store.ts` refuses an id that is not filename-shaped, which is the
// same 404 by a different door — both are "there is no office here".
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const gone = await app.inject({ method: "GET", url: url("no-such-thing"), headers: bearer() });
assert.equal(gone.statusCode, 404);
assert.equal(gone.json().error, "not_found");
const traversal = await app.inject({ method: "GET", url: url(".."), headers: bearer() });
assert.ok(traversal.statusCode === 404 || traversal.statusCode === 400);
});
});
describe("the cache", () => {
it("never lets a shared cache keep device state, whatever the answer was", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const anonymous = await app.inject({ method: "GET", url: url("open") });
const missing = await app.inject({ method: "GET", url: url("nope"), headers: bearer() });
const ok = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
const commanded = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
for (const res of [anonymous, missing, ok, commanded]) {
assert.equal(res.headers["cache-control"], "private, no-store");
}
});
});
describe("what the pack is allowed to declare", () => {
it("serves only the declarations whose hardware is really there", () => {
forgetResolvedDevices();
const { declarations, authored } = resolveDevices(studio("open"));
assert.deepEqual(
declarations.map((d) => d.id),
["mic-1", "speaker-1", "desk-mounted-mic"],
);
// Every one of the six was authored, which is what lets the service tell
// "no devices" from "no devices survived".
assert.equal(authored, 6);
});
it("accepts a microphone standing on a desk, and refuses one bolted to a speaker", () => {
forgetResolvedDevices();
const { declarations } = resolveDevices(studio("open"));
// The rule is `Plan`'s and there is deliberately no second copy of it here:
// a stricter one on this side would drop a legitimate pack's devices from
// the API while the browser went on drawing them, which is the worst kind
// of disagreement — a populated panel whose every command is refused.
assert.ok(declarations.some((d) => d.id === "desk-mounted-mic"));
assert.equal(declarations.find((d) => d.id === "mic-on-a-speaker"), undefined);
assert.equal(declarations.find((d) => d.id === "mic-nowhere"), undefined);
});
it("drops a simulated device whose disclosure does not say so", () => {
forgetResolvedDevices();
const { declarations } = resolveDevices(studio("open"));
assert.equal(declarations.find((d) => d.id === "mic-undisclosed"), undefined);
});
it("is a 200 and an empty list for an office that declares nothing", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("bare"), headers: bearer() });
assert.equal(res.statusCode, 200);
const body = res.json() as DevicesBody;
assert.deepEqual(body.devices, []);
// Not a 404: "no such office" and "no hardware in this office" are
// different facts with different fixes.
assert.equal(body.officeId, "bare");
});
});
describe("what a signed-in member reads", () => {
it("serves one state per surviving declaration, all marked synthetic", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal(res.statusCode, 200);
const body = res.json() as DevicesBody;
assert.equal(body.source, "sim");
assert.equal(body.synthetic, true);
assert.ok(body.ttlSeconds > 0);
assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]);
for (const device of body.devices) assert.equal(device.synthetic, true);
// Readings follow the declaration, not the kind: this mic declared `gain`
// and `level`, this speaker declared neither.
const mic = body.devices[0] as DeviceState;
assert.equal(typeof mic.gainDb, "number");
assert.equal(typeof mic.levelDb, "number");
const speaker = body.devices[1] as DeviceState;
assert.equal(speaker.levelDb, undefined);
assert.equal(typeof speaker.volume, "number");
});
it("reports no hardware at all when the box has no device source", async () => {
const app = appWith(jwt);
after(() => app.close());
const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal(res.statusCode, 200);
const body = res.json() as DevicesBody;
assert.equal(body.source, "none");
// Empty rather than invented. A box nobody has configured has no hardware,
// and that is the truth rather than a degraded picture of one.
assert.deepEqual(body.devices, []);
});
});
describe("the command", () => {
it("applies one, and the next read shows it", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const before = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal((before.json() as DevicesBody).devices[0]?.powered, false);
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 200);
assert.equal(res.json().device.powered, true);
const then = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
assert.equal((then.json() as DevicesBody).devices[0]?.powered, true);
});
it("clamps a value into the declared range rather than refusing it", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "gain", value: 400 } },
});
assert.equal(res.statusCode, 200);
// 36 dB is the top of `DEVICE_RANGES.gain`. A slider reporting 400 is a
// caller who overshot, not an attack — and the body says what the hardware
// actually did, so the control can snap to it.
assert.equal(res.json().device.gainDb, 36);
});
it("refuses a device that does not name an authored declaration, and changes nothing", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
// Deliberately the id of a declaration the pack *did* write and the server
// dropped, because its prop is a desk. It exists in the file and it must
// not be commandable.
for (const deviceId of ["mic-on-a-speaker", "mic-nowhere", "mic-undisclosed", "invented"]) {
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId, op: "power", value: true } },
});
assert.equal(res.statusCode, 400, deviceId);
assert.equal(res.json().error, "bad_request");
}
const read = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
const body = read.json() as DevicesBody;
assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]);
// Nothing was created and nothing was switched on by any of that.
assert.equal(body.devices.every((d) => d.powered === false), true);
});
it("refuses an op the declaration never declared", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
// The speaker declares power, volume and playback. `gain` is a microphone's.
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "speaker-1", op: "gain", value: 4 } },
});
assert.equal(res.statusCode, 400);
});
it("refuses a body that is not a command", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const bodies: unknown[] = [
{},
{ command: null },
{ command: [] },
{ command: { op: "power", value: true } },
{ command: { deviceId: "mic-1", op: "explode", value: true } },
{ command: { deviceId: "mic-1", op: "power" } },
{ command: { deviceId: "mic-1", op: "power", value: "yes" } },
{ command: { deviceId: "mic-1", op: "gain", value: true } },
];
for (const payload of bodies) {
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: payload as never,
});
assert.equal(res.statusCode, 400, JSON.stringify(payload));
assert.equal(res.json().error, "bad_request");
}
});
it("refuses a body that is not JSON at all, as the caller's mistake", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: { ...bearer(), "content-type": "text/plain" },
payload: "turn the microphone on please",
});
// 400 or 415 depending on which of Fastify's own checks fires first, and
// the assertion that matters is neither of those numbers: it is that this
// is a 4xx carrying the one error shape rather than a 500. This route was
// the first body on the box, and until it existed the error handler
// reported every framework refusal as a server fault.
assert.ok(res.statusCode >= 400 && res.statusCode < 500, `status ${res.statusCode}`);
assert.equal(res.json().error, "bad_request");
});
it("refuses a command body larger than a command could be", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true }, padding: "x".repeat(4096) },
});
assert.equal(res.statusCode, 413);
assert.equal(res.json().error, "bad_request");
});
it("refuses a command for an office that does not exist", async () => {
const app = appWith({ ...jwt, ...sim });
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("no-such-thing")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 404);
});
it("refuses every command when the box has no device source", async () => {
const app = appWith(jwt);
after(() => app.close());
const res = await app.inject({
method: "POST",
url: `${url("open")}/command`,
headers: bearer(),
payload: { command: { deviceId: "mic-1", op: "power", value: true } },
});
assert.equal(res.statusCode, 400);
});
});
describe("health says whether asking is worth it", () => {
it("names the device source and the served regions", async () => {
const app = appWith(sim);
after(() => app.close());
const res = await app.inject({ method: "GET", url: "/api/v1/health" });
assert.equal(res.statusCode, 200);
const body = res.json();
assert.equal(body.sources.devices, "sim");
assert.ok(Array.isArray(body.regions));
assert.ok(body.regions.length > 0);
});
it("says none on a box nobody configured, without calling it a demotion", async () => {
const app = appWith({});
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json();
assert.equal(body.sources.devices, "none");
// The default is a choice, not a failure. A zero-config box's `degraded`
// list stays empty, which `check-zero-config-boot.mjs` also asserts.
assert.deepEqual(body.degraded, []);
});
it("demotes a source it cannot honour and says which", async () => {
const app = appWith({ TERA_DEVICES_SOURCE: "homeassistant" });
after(() => app.close());
const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json();
assert.equal(body.sources.devices, "none");
assert.equal(
body.degraded.filter((line: string) => line.includes("TERA_DEVICES_SOURCE")).length,
1,
);
});
});
+69 -4
View File
@@ -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.
+67 -2
View File
@@ -16,6 +16,10 @@ else builds without any of them being a fork. See ARCHITECTURE.md §3.3.
| `sample.ts` | fabricated demo markers and flight routes, so a fresh clone has something on it |
| `http.ts` | the real adapter — the Tera API described in `src/server/wire.ts`, falling back to `sample.ts` |
Devices have a seam of their own next door, in `src/devices/adapter.ts`, for a
reason worth stating: it is the one feed with a **write** on it, and the write
does not degrade the way a read does. See "Devices" below.
## The fallback is the product, not the safety net
`http.ts` never throws and never leaves the map empty. No server, a 404, a
@@ -62,6 +66,67 @@ markers have to be awaited first. `markers.palette` is the sample palette when
the feed is the sample set and the palette you passed in `TeraApiOptions` when it
is real — the sample keys are not your keys.
## Clicking an aircraft
`TrafficSource.detail(id)` answers from what `poll()` last handed over —
callsign, ICAO 24-bit address, altitude in both units, heading as degrees and as
a compass point, position, distance from the board centre, whether anybody
observed it, and the credit lines owed for it. Synchronous, so a click opens a
card in the same frame rather than behind a round trip, and `null` for an
aircraft that has left the feed rather than a card showing where something was
two minutes ago.
**It is available to an anonymous visitor**, and that is a decision rather than
an oversight. An ADS-B position is broadcast unencrypted by the aircraft to
anybody with a forty-dollar receiver; there is nothing here an account could
grant access to, and gating it would cost the first-visit moment this map exists
for while protecting nothing.
Two fields are about the data rather than about the aeroplane. `observed`
travels with the aircraft because a card is read on its own, away from any
corner label, and a fabricated flight number in the same frame as a real one is
the confusion `live` exists to prevent. `attribution` travels with it because a
card is where the data is *displayed*, which is what an ODbL notice is about.
The address is `null` unless the feed gave a real one: the simulator's ids are
route names and the `~`-prefixed ids both community feeds emit for TIS-B and
MLAT targets are not ICAO addresses, and somebody pastes that field into a
registry lookup.
## Devices: reading is the demo, writing is the account
`src/devices/adapter.ts` is the seam, and it chooses between two strategies once,
at construction:
- **the API**, when the deployment has a device source (`/health` says so) and
the viewer may read it; or
- **the simulator in this tab** — `src/devices/sim.ts`, seeded, deterministic,
`synthetic: true` and `live: false` — for everybody else.
The second is not a degraded mode, it is the anonymous visitor's studio and the
zero-config clone's studio, and it is the same argument the marker fallback
makes one section up: a studio is never dark. `GET /offices/:id/devices` refuses
an anonymous caller, and it should — a reading describes a room somebody is
standing in — so the refusal produces a working, honestly-labelled instrument
rather than a dead one.
**Commands do not get the same treatment**, and this is the one place in this
directory where a failure is reported rather than papered over. On the API
strategy a refused command is `null` and the interface says so; it is never
quietly applied to a local copy, because a control that appears to work and
changes nothing anybody else can see is worse than one that says no. On the
simulated strategy a command is applied locally and openly, because nothing
there claims to be a real room.
A command travels as a **POST on a route of its own** and never in a read body.
A shared cache that kept a GET which turned a microphone on could replay it, and
that is precisely what the fail-closed `Cache-Control` default in CONTRACT.md §5
exists to prevent.
`src/devices/adapter.ts` owns no timer: the simulated strategy is advanced by
`tick(dt)` from whatever render loop already exists, and the API strategy's
polling lives in `watchDevices` here in `http.ts`, where every other watch's
timer already is.
## No real company data ships in this repo
Two separate constraints want the same thing here, which is the reason this
@@ -110,8 +175,8 @@ Two things to keep if you write one:
body's own TTL in the background. A `poll()` that awaits a slow fetch puts a
frame behind a round trip.
**FlightRadar24 is not an option.** Their terms forbid scraping and forbid
redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
**FlightRadar24 is not an option.** Their terms do not permit scraping and do
not permit redistributing the data, so an Apache-2.0 repo containing an FR24 client would be
publishing instructions for breaking a ToS and shipping data it has no right to
relicense. `src/engine/flights.ts` ships a simulator and points at the open
community ADS-B feeds instead; anything commercial belongs in an adapter in a
+464 -8
View File
@@ -32,12 +32,16 @@
*/
import type { WeatherObservation } from "../engine/atmosphere.ts";
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
import { deviceStateSignature } from "../devices/types.ts";
import {
aircraftDetail,
distanceNm,
inRegion,
sampleRoute,
SimulatedFlights,
syntheticRoutes,
type AircraftDetail,
type Place,
type SimRoute,
type SkyRegion,
@@ -46,6 +50,10 @@ import type { SatelliteElements } from "../engine/satellites.ts";
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
import { seededRandom } from "../engine/world.ts";
import type {
DeviceCommandBody,
DeviceCommandResultBody,
DevicesBody,
DevicesSourceId,
FlightsBody,
FlightsPlanBody,
HealthBody,
@@ -54,6 +62,7 @@ import type {
PresenceBody,
SatellitesBody,
WeatherBody,
WireAircraft,
} from "../server/wire.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
@@ -197,6 +206,54 @@ export interface PresenceWatch {
stop(): void;
}
/**
* One office's hardware, plus whether anybody actually asked a server about it.
*
* `live` here means *this deployment answered*, and it is not the same claim as
* `synthetic`, which means *nobody observed these readings*. All four
* combinations are real deployments and the interface has to be able to say
* each of them:
*
* | live | synthetic | what it is |
* | --- | --- | --- |
* | false | true | no server, or an anonymous viewer: the local simulator |
* | true | true | a server running `TERA_DEVICES_SOURCE=sim` |
* | true | false | a real bridge to real hardware |
* | false | false | impossible, and nothing constructs it |
*
* The second row is the reference deployment and the first is every clone of
* this repo, which is why both of them have to look alive and both have to say
* so. `DeviceDeclaration.disclosure` is the sentence a viewer reads; these two
* booleans are what the interface branches on.
*/
export interface DeviceFeed extends Feed<DeviceState[]> {
/** Which source the server said it was using, or `"none"` when nobody answered. */
source: DevicesSourceId;
/** True when nobody observed these readings. True for everything this build ships. */
synthetic: boolean;
/** Epoch milliseconds of the snapshot, or `null` when there is no snapshot. */
observedAt: number | null;
attribution: string[];
}
/**
* A running poll of one office's hardware.
*
* `PresenceWatch` with a `current()` on it. Devices need the accessor and
* occupancy does not, because a device panel is opened *after* the room has
* been drawn — the panel wants the last reading immediately rather than waiting
* up to a TTL for the next publish, and re-fetching to answer that would spend
* a request on a body this object is already holding.
*/
export interface DeviceWatch {
/** The latest feed. Empty and not live until an answer lands. */
current(): DeviceFeed;
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
refresh(): void;
/** Stop polling, abort anything in flight, and drop any late answer. */
stop(): void;
}
export interface TeraClient {
/** What the deployment turned out to be, or `null` if there is no server. */
health(): Promise<HealthBody | null>;
@@ -262,6 +319,40 @@ export interface TeraClient {
* to be able to draw.
*/
watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch;
/**
* What the hardware in one office is doing.
*
* Always an authenticated call, exactly like `presence` and for a related
* reason: `routes/devices.ts` refuses an anonymous one whatever else the
* deployment is set to. A refusal — no API, no session, an office this
* viewer may not see — is an empty feed with `live: false`, and the caller's
* answer to that is the *locally simulated* studio in `src/devices/sim.ts`,
* not an empty panel. See `src/devices/adapter.ts`, which is where that
* decision is made once instead of at every call site.
*/
devices(officeId: string, options?: { signal?: AbortSignal }): Promise<DeviceFeed>;
/**
* The same question, asked repeatedly, until the caller stops it.
*
* Modelled on `watchPresence` rather than on `watchWeather`, because it
* describes the room somebody is standing in rather than the sky: it stops
* dead while the tab is hidden, wakes the moment it comes back, and publishes
* only when a reading a viewer could see has changed.
*/
watchDevices(officeId: string, onFeed: (feed: DeviceFeed) => void): DeviceWatch;
/**
* Ask one device to do something. Resolves to the state it ended up in, or
* `null` for any refusal.
*
* **A POST, on a route of its own, never folded into the read.** A command
* riding in a GET response could be replayed by any shared cache that kept a
* copy, and a cache that turned a microphone on by replaying a read is
* exactly what the fail-closed `Cache-Control` default in CONTRACT.md §5
* exists to prevent. It is also the first write surface in this product that
* changes something another viewer can see, which is the other half of why
* it is separate: reading is the demo, writing is the account.
*/
commandDevice(officeId: string, command: DeviceCommand): Promise<DeviceState | null>;
}
/**
@@ -326,6 +417,40 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
}
};
/**
* One POST, and `null` for every way it can go wrong.
*
* The same deliberate coarseness as `get` — a 401, a 400, a timeout and a
* static host answering with its own HTML are one outcome to the caller — with
* one difference that matters: **nothing here retries**. A GET that failed can
* be repeated because asking twice costs a request; a command that failed may
* have been applied before the connection died, and repeating it is the
* difference between "turn the microphone on" and "turn the microphone on
* twice". Idempotence is not a property this can assume on the caller's
* behalf, so a refusal is reported and the panel asks the person.
*/
const post = async <T,>(path: string, body: unknown): Promise<T | null> => {
if (!doFetch) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await doFetch(`${base}${path}`, {
method: "POST",
signal: controller.signal,
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) return null;
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);
}
};
return {
health: () => get<HealthBody>("/health"),
@@ -410,6 +535,30 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
watchPresence(officeId, onBody) {
return watchPresence(get, officeId, onBody);
},
async devices(officeId, opts: { signal?: AbortSignal } = {}): Promise<DeviceFeed> {
const body = await get<DevicesBody>(devicesPath(officeId), {
...(opts.signal ? { signal: opts.signal } : {}),
});
return deviceFeed(body);
},
watchDevices(officeId, onFeed) {
return watchDevices(get, officeId, onFeed);
},
async commandDevice(officeId, command): Promise<DeviceState | null> {
const request: DeviceCommandBody = { command };
const body = await post<DeviceCommandResultBody>(
`${devicesPath(officeId)}/command`,
request,
);
// Checked rather than trusted, like every other body this file adopts: a
// 200 with the wrong shape in it is what a server one version behind this
// one sends, and `null` is already the caller's "it did not happen".
if (!body || body.device === null || typeof body.device !== "object") return null;
return body.device;
},
};
}
@@ -755,6 +904,16 @@ function queryString(query: Record<string, string | number> | undefined): string
* it.
*/
export interface TrafficSource extends FlightSource {
/**
* Narrowed from `FlightSource.poll()`, which may return a promise.
*
* Not a convenience: it is the file's central promise made into a type. This
* is called from the render loop, so it answers from whatever is in hand and
* refreshes on the body's own TTL in the background — a `poll()` that awaited
* a slow fetch would put a frame's aircraft update behind a round trip. An
* adapter that cannot promise that is a `FlightSource` and not one of these.
*/
poll(): Aircraft[];
/** True while the aircraft `poll()` returns are observed positions for this region. */
live(): boolean;
/**
@@ -767,6 +926,26 @@ export interface TrafficSource extends FlightSource {
* exists. `describeLiveness` says what is live; this says who to thank for it.
*/
attribution(): string[];
/**
* Everything known about one aircraft that is currently being drawn, or
* `null` for an id that is not.
*
* The click target of the whole city board, and it is on this interface for
* the same reason `live()` and `attribution()` are: the engine draws darts at
* coordinates and the *deployment* knows what those coordinates are. It is
* synchronous and answers from what `poll()` last handed over, so a click can
* open a card in the same frame — going back to the network for a record this
* object already holds would put a panel behind a round trip, and would ask a
* volunteer-funded feed for a row it just sent.
*
* **Available to an anonymous visitor**, and that is a decision rather than
* an oversight. An ADS-B position is broadcast unencrypted by the aircraft to
* anybody with a receiver; there is nothing here an account could grant
* access to, and gating it would cost the first-visit moment this map exists
* for while protecting nothing. `access.ts` makes the same argument about the
* sky at greater length.
*/
detail(id: string): AircraftDetail | null;
/** Stop fetching and abort anything in flight. Idempotent. */
dispose(): void;
}
@@ -809,30 +988,91 @@ class HttpFlights implements TrafficSource {
*/
readonly interval = 1;
private readonly get: Get;
private readonly region: SkyRegion;
private readonly fallback: SimulatedFlights;
private mode: "fallback" | "plan" | "live" = "fallback";
private plan: FlightsPlanBody | null = null;
private planPhase: number[] = [];
private aircraft: Aircraft[] = [];
private credits: string[] = [];
/**
* Everything the live body said about each aircraft, keyed by id.
*
* Only the live path fills this, because only the live path is told anything
* an `Aircraft` cannot carry — `WireAircraft.icao24` in particular, which is
* the transponder address and is the field a detail card is actually about.
* Cleared and rebuilt with every adopted body, so it can never outlive the
* positions it describes.
*/
private readonly wire = new Map<string, WireAircraft>();
private latest: Aircraft[] = [];
private nextFetchAt = 0;
private inFlight: AbortController | null = null;
private stopped = false;
constructor(
private readonly get: Get,
private readonly region: SkyRegion,
fallbackRoutes: SimRoute[],
) {
/**
* Fields assigned in the constructor body rather than declared as parameter
* properties.
*
* **This is not a style preference and it must stay this way.** A parameter
* property is the one piece of TypeScript syntax that emits code — it is a
* hidden assignment, not a type annotation — so Node's type stripping refuses
* the *whole module* with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Vite never
* cared, so for the entire life of this file `node --test` could not import
* `adapters/http.ts` at all: every degrade path, every rung of the back-off
* ladder and the whole region filter below had zero coverage, in the module
* whose job is to be correct when everything else has failed.
*
* `engine/flights.ts` says the same thing over `AdsbFlights` and adds the
* consequence: the module with the worst bug this project has shipped was, by
* construction, the one module that could not be tested. Two modules had that
* property; this was the second.
*/
constructor(get: Get, region: SkyRegion, fallbackRoutes: SimRoute[]) {
this.get = get;
this.region = region;
this.fallback = new SimulatedFlights(fallbackRoutes);
}
poll(): Aircraft[] {
this.refreshIfStale();
const { mode, plan, planPhase } = this;
if (mode === "plan" && plan) return evaluatePlan(plan, planPhase, Date.now());
if (this.mode === "live") return this.aircraft;
return this.fallback.poll();
// Kept, so that `detail()` can answer a click about the aircraft that were
// actually drawn rather than about a fresh evaluation a few milliseconds
// later — the plan path is a function of `Date.now()`, so re-evaluating it
// for a lookup would return a position slightly ahead of the dart the
// viewer aimed at.
this.latest =
mode === "plan" && plan
? evaluatePlan(plan, planPhase, Date.now())
: mode === "live"
? this.aircraft
: this.fallback.poll();
return this.latest;
}
/**
* One aircraft, as a card.
*
* Looked up in what was last polled, which is also what is on screen. An id
* that has left the feed answers `null` rather than the last known position:
* a card showing where something was two minutes ago, with no way to say so,
* is the same class of quiet staleness `WeatherFeed.observedAt` exists to
* prevent — and the caller's honest response is to close the card.
*/
detail(id: string): AircraftDetail | null {
const found = this.latest.find((a) => a.id === id);
if (found === undefined) return null;
const wire = this.wire.get(id);
return aircraftDetail(found, {
// Only a live body carries an address, and only a live body was observed.
// The plan's aircraft are this repo's own arithmetic and say so.
...(wire?.icao24 === undefined ? {} : { icao24: wire.icao24 }),
observed: this.mode === "live",
attribution: this.attribution(),
from: this.region.center,
});
}
/**
@@ -924,6 +1164,12 @@ class HttpFlights implements TrafficSource {
private adopt(body: FlightsBody): number {
const ttl = Number.isFinite(body.ttlSeconds) ? Math.max(1, body.ttlSeconds) : RETRY_SECONDS;
this.credits = [];
// Both of these describe the body that is about to be adopted, so both are
// dropped before it is looked at rather than on each of the four ways this
// method can decide not to adopt it. A transponder address left over from a
// previous body would otherwise be attached, by id collision, to whatever
// the next one draws.
this.wire.clear();
if (body.mode === "plan") {
if (!Array.isArray(body.routes)) {
@@ -981,6 +1227,11 @@ class HttpFlights implements TrafficSource {
return ELSEWHERE_SECONDS;
}
this.aircraft = here;
// Only the live path has anything to record: a plan carries routes, not
// transponders. Cleared at the top of this method, so a record that has
// left the feed leaves this map with it rather than surviving to answer a
// click about an aircraft nobody is drawing.
for (const a of here) this.wire.set(a.id, a);
this.plan = null;
this.mode = "live";
// Only the live body carries credits — `wire.ts` puts `attribution` on
@@ -1232,3 +1483,208 @@ function watchPresence(
},
};
}
// ---- Devices --------------------------------------------------------------
/**
* How often to ask what the hardware is doing, when the server does not say.
*
* Five seconds, and the number comes from the data rather than from the
* network. Occupancy moves when somebody stands up and is polled every thirty;
* a level meter moves continuously and a mute button moves the instant it is
* pressed, and a panel that took half a minute to notice somebody else had
* muted the room would read as broken. The server's `ttlSeconds` overrides this
* whenever it answers — `TERA_DEVICES_TTL` is the operator's dial and this is
* only what to do before they have had their say.
*
* It is still a poll rather than a stream. The realtime service exists and this
* deliberately does not use it: a device panel is open for a minute at a time
* on a handful of tabs, and a socket per viewer for a body this size is a
* standing cost for an occasional need. If the panels are ever open all day,
* that is the moment to revisit it.
*/
const DEVICES_INTERVAL_MS = 5_000;
/** Bounds on whatever the server asks for, so one bad TTL cannot become a flood. */
const DEVICES_MIN_INTERVAL_MS = 2_000;
const DEVICES_MAX_INTERVAL_MS = 60_000;
/**
* The ceiling on the back-off ladder, five minutes — `watchPresence`'s number,
* not the weather watch's hour, and for the same reason it gives: somebody is
* standing in the room this describes.
*/
const DEVICES_MAX_BACKOFF_MS = 5 * 60_000;
/** Nobody answered. Empty, not live, and not claiming to have observed anything. */
function noDevices(): DeviceFeed {
return { value: [], live: false, source: "none", synthetic: true, observedAt: null, attribution: [] };
}
/**
* One devices body, judged.
*
* Two outcomes, and the check is on the array rather than on its length. A body
* with `devices: []` is a real answer — an office nobody has declared any
* hardware in, which is most offices — and it is `live`, because the deployment
* answered and said so. A body with no array in it at all is not an answer, and
* it is the shape a server one version behind this one sends. The same
* distinction `adsb.ts` draws between an empty circle of sky and an unreadable
* envelope, for the same reason: coercing the second into the first serves
* fiction under a live badge.
*/
function deviceFeed(body: DevicesBody | null): DeviceFeed {
if (!body || !Array.isArray(body.devices)) return noDevices();
return {
value: body.devices,
live: true,
source: body.source ?? "none",
// Absent means synthetic. A body that did not say whether anybody observed
// its readings is not a body that may be presented as observation.
synthetic: body.synthetic !== false,
observedAt: typeof body.observedAt === "number" ? body.observedAt : null,
attribution: Array.isArray(body.attribution) ? body.attribution : [],
};
}
/**
* Poll one office's hardware until told to stop.
*
* `watchPresence` with a TTL from the body and a signature that covers readings
* instead of seats. The three properties it inherits are the ones that matter
* and they are argued for at length there:
*
* - **stops dead while the tab is hidden**, and asks immediately on the way
* back, because a backgrounded panel polling a meter nobody can see is
* waste at both ends;
* - **publishes only on change**, by comparing `deviceStateSignature` — every
* answer is a fresh array, so identity says nothing, and a still studio
* would otherwise rebuild its panel every few seconds forever;
* - **reports a refusal** rather than freezing on the last good reading, so
* "the room went quiet" and "I have stopped hearing about the room" stay
* distinguishable.
*
* The signature deliberately ignores `observedAt`, which moves on every poll of
* an unchanged studio and would defeat the whole comparison.
*/
function watchDevices(
get: Get,
officeId: string,
onFeed: (feed: DeviceFeed) => void,
): DeviceWatch {
let feed = noDevices();
let signature: string | null = null;
let published = false;
let failures = 0;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight: AbortController | null = null;
const path = devicesPath(officeId);
function schedule(delayMs: number) {
if (stopped) return;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => void tick(), delayMs);
}
function publish(next: DeviceFeed) {
const nextSignature = `${next.live ? "1" : "0"}${deviceStateSignature(next.value)}`;
// The first answer always goes through, even when it matches the empty
// signature the caller may have assumed. "I asked and there is nothing" and
// "I have not asked yet" are different states and only one of them should
// leave a panel saying so on purpose.
feed = next;
if (published && nextSignature === signature) return;
signature = nextSignature;
published = true;
onFeed(next);
}
async function tick(): Promise<void> {
timer = null;
if (stopped) return;
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
// Nothing reaches this with a request already out, but a watch that stalled
// would stay stalled until the page reloaded, and that is too quiet a
// failure to leave to the reasoning being right.
if (inFlight) {
schedule(DEVICES_INTERVAL_MS);
return;
}
inFlight = new AbortController();
const body = await get<DevicesBody>(path, { signal: inFlight.signal });
inFlight = null;
// Stopped while this was in the air: the office was left or the panel was
// closed. Whatever came back describes a room nobody is looking at, and it
// is also why a cancelled request must not count as a failure below.
if (stopped) return;
publish(deviceFeed(body));
if (body === null) {
failures += 1;
schedule(Math.min(DEVICES_INTERVAL_MS * 2 ** (failures - 1), DEVICES_MAX_BACKOFF_MS));
return;
}
failures = 0;
schedule(intervalFor(body));
}
function onVisibility() {
if (stopped) return;
if (document.visibilityState === "visible") {
// Immediately rather than at the next tick: somebody has just come back
// to this tab and the panel in front of them is the stale thing.
failures = 0;
schedule(0);
} else if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
void tick();
return {
current: () => feed,
refresh() {
if (stopped || inFlight) return;
schedule(0);
},
stop() {
stopped = true;
if (timer !== null) clearTimeout(timer);
timer = null;
inFlight?.abort();
inFlight = null;
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
},
};
}
/**
* The cadence the server asked for, clamped.
*
* Checked rather than trusted, exactly as `HttpFlights.adopt` learned to be: an
* absent `ttlSeconds` makes `Math.max(1, undefined)` a `NaN`, `NaN` clears every
* comparison, and the poll interval quietly becomes the frame rate. The floor
* is the load-bearing half — a `TERA_DEVICES_TTL=0` that reads like "as fresh
* as possible" would otherwise be one request per tick per open tab.
*/
function intervalFor(body: DevicesBody): number {
const asked = Number.isFinite(body.ttlSeconds) ? body.ttlSeconds * 1000 : DEVICES_INTERVAL_MS;
return Math.min(DEVICES_MAX_INTERVAL_MS, Math.max(DEVICES_MIN_INTERVAL_MS, asked));
}
/** One place the route is spelled, so the read and the command cannot drift apart. */
function devicesPath(officeId: string): string {
return `/offices/${encodeURIComponent(officeId)}/devices`;
}
+35 -5
View File
@@ -1,4 +1,4 @@
import { arenaChecksum } from "./checksum.ts";
import { arenaChecksum, quantizeForChecksum } from "./checksum.ts";
import type { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
@@ -108,6 +108,9 @@ export abstract class BaseArenaEnvironment<
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
// Inside the checksummed core, so a snapshot cannot be re-pinned to a
// different simulator without invalidating itself. See `ArenaSnapshot`.
sourceHashes: this.sourceHashes,
seed: scenario.seed,
scenarioId: scenario.id,
scenarioSplit: scenario.split,
@@ -127,7 +130,8 @@ export abstract class BaseArenaEnvironment<
if (arenaChecksum(core) !== checksum) throw new Error("arena snapshot checksum mismatch");
if (
snapshot.apiVersion !== ARENA_API_VERSION || snapshot.envId !== this.manifest.id ||
snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash()
snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash() ||
arenaChecksum(snapshot.sourceHashes) !== arenaChecksum(this.sourceHashes)
) throw new Error("arena snapshot is incompatible with this environment");
if (
!Number.isSafeInteger(snapshot.step) || snapshot.step < 0 ||
@@ -148,7 +152,27 @@ export abstract class BaseArenaEnvironment<
this.truncated = snapshot.truncated;
this.terminalReason = snapshot.terminalReason;
this.frames = [];
const observation = this.restoreSimulation(structuredClone(snapshot.simulation));
let observation: O;
try {
observation = this.restoreSimulation(structuredClone(snapshot.simulation));
} catch (error) {
// A concrete environment can reject the simulation payload after the
// episode bookkeeping above has already been written — and several do,
// because validating a controller's snapshot means handing it to the
// controller. Leaving the object in that state was the worst of the three
// options: the step index, the cumulative reward and the terminal flags
// would say one thing and the simulators another, and the *next* call
// would succeed and quietly produce a mixture of two episodes.
//
// Rolling back is not available either: `restoreSimulation` has usually
// rebuilt the simulators from the scenario before it validates, so the
// state it threw from is not the state it started in. So the environment
// is marked un-reset, which makes every subsequent call fail loudly until
// somebody calls `reset` or `restore` again — the same disposition
// `devices/sim.ts` takes when it refuses a snapshot outright.
this.scenario = null;
throw error;
}
this.initialStateChecksum = arenaChecksum(this.statePayload());
return { observation, info: this.info() };
}
@@ -200,7 +224,13 @@ export abstract class BaseArenaEnvironment<
const actual = this.step(expected.action);
observation = actual.observation;
if (
actual.info.stateChecksum !== expected.stateChecksum || actual.reward !== expected.reward ||
actual.info.stateChecksum !== expected.stateChecksum ||
// Quantised rather than `!==`, for the reason `checksum.ts` sets out at
// length: a reward is a float, a verifier runs on hardware the producer
// never saw, and `Math.pow` is not required to be correctly rounded. An
// exact comparison here would have re-introduced on one line precisely
// the cross-runtime failure the checksum was hardened against.
quantizeForChecksum(actual.reward) !== quantizeForChecksum(expected.reward) ||
actual.terminated !== expected.terminated || actual.truncated !== expected.truncated ||
arenaChecksum(actual.rewardComponents) !== arenaChecksum(expected.rewardComponents)
) throw new Error(`arena trace diverged at step ${expected.index}`);
@@ -208,7 +238,7 @@ export abstract class BaseArenaEnvironment<
const replayed = this.trace();
if (
replayed.finalStateChecksum !== trace.finalStateChecksum ||
replayed.cumulativeReward !== trace.cumulativeReward
quantizeForChecksum(replayed.cumulativeReward) !== quantizeForChecksum(trace.cumulativeReward)
) throw new Error("arena trace final state mismatch");
return {
observation,
+39 -3
View File
@@ -8,6 +8,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaFieldSpec,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
@@ -119,7 +120,13 @@ export const CALIFORNIA_FLIGHT_SCENARIOS = new ArenaScenarioRegistry<FlightScena
export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "california-flight-v1",
version: 1,
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
// request by hashing the seed against each scenario id instead of indexing
// definition order, so a seed run before that change may resolve to a
// different scenario after it. `version` is what a snapshot, a trace and a
// results table are pinned to, and a selection change that nothing recorded
// is exactly the silent remap the new selector exists to prevent.
version: 2,
title: "California electric-flight waypoint",
description: "Manual fixed-wing waypoint control over Tera's renderer-neutral aircraft simulator.",
simulator: "AircraftController",
@@ -131,6 +138,33 @@ export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({
"verticalSpeedMps", "goalLat", "goalLng", "goalAltitudeM", "distanceToGoalM",
"bearingToGoalDeg", "altitudeErrorM", "envelopeContact",
],
actionSpace: [
{ name: "throttle", kind: "float", low: 0, high: 1, unit: "fraction" },
{ name: "yaw", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "pitch", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "roll", kind: "float", low: -1, high: 1, unit: "fraction" },
] satisfies readonly ArenaFieldSpec[],
// Latitude and longitude are bounded to California rather than to the globe.
// A ±90/±180 box would put every scenario in this environment inside four
// decimal places of the same normalized value, which is a constant input
// wearing a coordinate's clothes.
observationSpace: [
{ name: "lat", kind: "float", low: 32, high: 42.2, unit: "deg north" },
{ name: "lng", kind: "float", low: -124.5, high: -114, unit: "deg east" },
{ name: "altitudeM", kind: "float", low: 0, high: 4000, unit: "m" },
{ name: "headingDeg", kind: "float", low: 0, high: 360, unit: "deg true" },
{ name: "pitchDeg", kind: "float", low: -90, high: 90, unit: "deg" },
{ name: "rollDeg", kind: "float", low: -90, high: 90, unit: "deg" },
{ name: "speedMps", kind: "float", low: 0, high: 260, unit: "m/s" },
{ name: "verticalSpeedMps", kind: "float", low: -60, high: 60, unit: "m/s" },
{ name: "goalLat", kind: "float", low: 32, high: 42.2, unit: "deg north" },
{ name: "goalLng", kind: "float", low: -124.5, high: -114, unit: "deg east" },
{ name: "goalAltitudeM", kind: "float", low: 0, high: 4000, unit: "m" },
{ name: "distanceToGoalM", kind: "float", low: 0, high: 20000, unit: "m" },
{ name: "bearingToGoalDeg", kind: "float", low: 0, high: 360, unit: "deg true" },
{ name: "altitudeErrorM", kind: "float", low: -2000, high: 2000, unit: "m" },
{ name: "envelopeContact", kind: "bool" },
] satisfies readonly ArenaFieldSpec[],
rewardComponents: {
progress: "Reduction in three-dimensional waypoint distance.",
success: "Sparse arrival bonus.",
@@ -158,14 +192,16 @@ function horizontalDistanceM(a: AircraftGeographicPoint, b: AircraftGeographicPo
const mean = (a.lat + b.lat) / 2 * Math.PI / 180;
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
return Math.hypot(north, east);
return Math.sqrt(north * north + east * east);
}
function distance3dM(
a: AircraftGeographicPoint & { altitudeM: number },
b: AircraftGeographicPoint & { altitudeM: number },
): number {
return Math.hypot(horizontalDistanceM(a, b), b.altitudeM - a.altitudeM);
const horizontal = horizontalDistanceM(a, b);
const vertical = b.altitudeM - a.altitudeM;
return Math.sqrt(horizontal * horizontal + vertical * vertical);
}
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
+129 -2
View File
@@ -1,11 +1,132 @@
/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */
/**
* Canonical JSON and a small cross-runtime checksum (no Node or Web APIs).
*
* Two properties matter here and they pull in opposite directions.
*
* **Nothing may hash to the same string as something it is not.** A checksum
* that silently accepts a value it cannot describe is worse than one that
* refuses: `canonical` used to reach `typeof value === "object"` for a `Map`, a
* `Set` and a `Date` alike, read their *own enumerable* keys of which those
* three have none and emit `{}`. So a `Map` with a thousand entries in it
* checksummed identically to an empty object, and to every other `Map`. Nothing
* in the five shipped environments carries one, but `ResolvedRobotOperations`
* already holds `ReadonlyMap`s and `studio-ops-v1` reaches into it: the
* environment was one careless snapshot field away from a trace that verified
* against a state it had never seen. Anything that is not a plain object, an
* array, a string, a boolean, a finite number or `null` now throws.
*
* **Two honest runs of the same rollout on different hardware must agree.**
* That is the harder one, and it is a float problem rather than a shape
* problem. `Math.sin`, `Math.atan2`, `Math.pow` and friends are not required by
* IEEE-754 or by ECMA-262 to be correctly rounded only `+`, `-`, `*`, `/` and
* `Math.sqrt` are so two conforming engines may return results a unit in the
* last place apart for the same input. `studio-ops-v1` runs a solar position
* and a set of bearings through its observation on every step. Against an
* *exact-equality* checksum that is a verifier on different hardware rejecting
* an honest rollout, which is the single worst failure this file can have: it
* is silent, it looks like fraud, and it only happens to somebody else.
*
* The same argument rules out `Math.hypot`, which is a library function with no
* correctly-rounded guarantee and which every environment in this package used
* to reach for. All of them now compute `Math.sqrt(a * a + b * b)` instead:
* identical arithmetic, one guarantee more, and marginally faster.
*
* `quantizeToPlaces` is the answer, applied in two places:
*
* 1. here, to every non-integer that is hashed, so last-place noise on a
* value that is *reported* but never fed back cannot change a checksum;
* 2. and this is the load-bearing one at the point a transcendental is
* called, by the environment itself, so that the quantised value is the
* one that propagates. See `quantizeObservable` in `studioOps.ts`.
*
* Be clear about what (1) alone cannot do: once a simulation has *accumulated*
* a divergence, no amount of rounding at the boundary brings the two runs back
* together, because the difference grows with every step rather than staying in
* the last place. Quantising at the source is what keeps the divergence from
* ever starting; quantising at the checksum is what keeps a value that is
* computed fresh each step, reported and then discarded from tripping the
* comparison. Both, or neither is worth much.
*/
/**
* Decimal places kept when a number is hashed.
*
* Nine, which is a quantum of 1e-9 in the units of whatever is being hashed
* a nanometre for a position, a nano-newton-metre of reward. Every quantity
* this package hashes is comfortably inside ±9e6, where a double's own spacing
* is at most ~2e-9 and the ratio of that spacing to the quantum bounds how
* often two values a last place apart can land either side of a rounding
* boundary. Coarser would buy a wider margin and start throwing away signal a
* reward shaper can see; finer stops being a quantisation at all.
*/
export const ARENA_CHECKSUM_DECIMALS = 9;
/**
* `value` rounded to `places` decimals, or returned unchanged where rounding
* cannot be done exactly.
*
* Integers pass through untouched: they are already exact, and scaling a large
* one by 1e9 would push it out of the safe-integer range and *lose* precision
* in the name of adding some. The same bail-out covers a magnitude so large
* that the requested quantum is finer than the double's own spacing, where
* rounding is a no-op that cannot be computed. Both cases return the input
* rather than an approximation of it.
*
* `Math.round` and the multiply/divide either side of it are all exact
* operations that every conforming engine performs identically, which is the
* entire reason this is decimal scaling and not `Math.log2`-based mantissa
* surgery: the fix must not be built out of the family of functions it exists
* to defend against.
*/
export function quantizeToPlaces(value: number, places: number): number {
if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
if (Number.isInteger(value)) return value;
const scale = 10 ** places;
const scaled = value * scale;
if (!Number.isFinite(scaled) || Math.abs(scaled) > Number.MAX_SAFE_INTEGER) return value;
// `+ 0` collapses a rounded -0 back to 0 so the sign of a vanishing quantity
// cannot change a checksum. `canonical` below does the same for a literal -0.
return Math.round(scaled) / scale + 0;
}
/** `quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS)`. */
export function quantizeForChecksum(value: number): number {
return quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS);
}
/**
* Whether `value` is a plain data object rather than an instance of something.
*
* Prototype identity rather than a `constructor` name or a `Symbol.toStringTag`
* sniff, because the question is not "what does this call itself" but "are its
* own enumerable keys the whole of it". `Object.create(null)` passes for the
* same reason a `{}` literal does.
*/
function isPlainObject(value: object): boolean {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
/**
* The name to put in the refusal.
*
* Worth the eight lines: "arena checksums do not accept Map" sends the reader
* to the field they added, and "arena checksums do not accept object" sends
* them here to find out what this function meant.
*/
function describe(value: object): string {
const named = (value as { constructor?: { name?: unknown } }).constructor;
const name = typeof named?.name === "string" && named.name.length > 0 ? named.name : null;
return name ?? "a non-plain object";
}
function canonical(value: unknown, stack: Set<object>): string {
if (value === null) return "null";
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
return Object.is(value, -0) ? "0" : JSON.stringify(value);
if (Object.is(value, -0)) return "0";
return JSON.stringify(quantizeForChecksum(value));
}
if (Array.isArray(value)) {
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
@@ -15,6 +136,12 @@ function canonical(value: unknown, stack: Set<object>): string {
return result;
}
if (typeof value === "object") {
// The refusal that gives this module its point. A `Map`, a `Set`, a `Date`,
// a typed array and a class instance all reach here, all have no own
// enumerable keys worth reading, and all used to hash as `{}`.
if (!isPlainObject(value)) {
throw new TypeError(`arena checksums do not accept ${describe(value)}`);
}
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
stack.add(value);
const record = value as Record<string, unknown>;
+40 -2
View File
@@ -7,6 +7,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaFieldSpec,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
@@ -103,7 +104,13 @@ export const CROW_NAV_SCENARIOS = new ArenaScenarioRegistry<CrowScenarioParamete
export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "crow-nav-v1",
version: 1,
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
// request by hashing the seed against each scenario id instead of indexing
// definition order, so a seed run before that change may resolve to a
// different scenario after it. `version` is what a snapshot, a trace and a
// results table are pinned to, and a selection change that nothing recorded
// is exactly the silent remap the new selector exists to prevent.
version: 2,
title: "Crow waypoint navigation",
description: "Three-dimensional waypoint control over Tera's deterministic crow flight controller.",
simulator: "ActorController(kind=crow, mode=flight)",
@@ -115,6 +122,34 @@ export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({
"goalX", "goalY", "goalZ", "deltaX", "deltaY", "deltaZ",
"distanceToGoalM", "altitudeBoundContact",
],
actionSpace: [
{ name: "forward", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "turn", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "pitch", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "climb", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "glide", kind: "bool" },
] satisfies readonly ArenaFieldSpec[],
// The horizontal bounds are `BOUNDS` above and the altitude band is the
// controller's own 2..40 m envelope, both stated here rather than restated:
// a policy normalising against a wider box than the simulator enforces learns
// a state distribution the environment never produces.
observationSpace: [
{ name: "x", kind: "float", low: -120, high: 120, unit: "m" },
{ name: "y", kind: "float", low: 0, high: 40, unit: "m" },
{ name: "z", kind: "float", low: -120, high: 120, unit: "m" },
{ name: "yaw", kind: "float", low: -Math.PI, high: Math.PI, unit: "rad" },
{ name: "pitch", kind: "float", low: -Math.PI / 2, high: Math.PI / 2, unit: "rad" },
{ name: "speedMps", kind: "float", low: 0, high: 30, unit: "m/s" },
{ name: "verticalSpeedMps", kind: "float", low: -20, high: 20, unit: "m/s" },
{ name: "goalX", kind: "float", low: -120, high: 120, unit: "m" },
{ name: "goalY", kind: "float", low: 0, high: 40, unit: "m" },
{ name: "goalZ", kind: "float", low: -120, high: 120, unit: "m" },
{ name: "deltaX", kind: "float", low: -240, high: 240, unit: "m" },
{ name: "deltaY", kind: "float", low: -40, high: 40, unit: "m" },
{ name: "deltaZ", kind: "float", low: -240, high: 240, unit: "m" },
{ name: "distanceToGoalM", kind: "float", low: 0, high: 350, unit: "m" },
{ name: "altitudeBoundContact", kind: "enum", values: ["none", "minimum", "maximum"] },
] satisfies readonly ArenaFieldSpec[],
rewardComponents: {
progress: "Reduction in 3D distance to the waypoint.",
success: "Sparse waypoint completion bonus.",
@@ -275,7 +310,10 @@ export class CrowNavEnvironment extends BaseArenaEnvironment<
private goalDistance(): number {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return Math.hypot(goal.goalX - state.x, goal.goalY - state.y, goal.goalZ - state.z);
const dx = goal.goalX - state.x;
const dy = goal.goalY - state.y;
const dz = goal.goalZ - state.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
private requireController(): ActorController {
+29 -1
View File
@@ -8,6 +8,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaFieldSpec,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
@@ -104,7 +105,13 @@ export const DRIVE_101_SCENARIOS = new ArenaScenarioRegistry<DriveScenarioParame
export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "drive-101-v1",
version: 1,
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
// request by hashing the seed against each scenario id instead of indexing
// definition order, so a seed run before that change may resolve to a
// different scenario after it. `version` is what a snapshot, a trace and a
// results table are pinned to, and a selection change that nothing recorded
// is exactly the silent remap the new selector exists to prevent.
version: 2,
title: "California corridor driving",
description: "Manual route-relative driving on Tera's authored US-101 and I-5 plans.",
simulator: "VehicleController + CALIFORNIA_TRANSPORT",
@@ -115,6 +122,27 @@ export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({
"routeId", "progressM", "remainingM", "lateralOffsetM", "speedMps",
"speedLimitMps", "steering", "guardrailContact", "roadName",
],
actionSpace: [
{ name: "throttle", kind: "float", low: 0, high: 1, unit: "fraction" },
{ name: "brake", kind: "float", low: 0, high: 1, unit: "fraction" },
{ name: "steering", kind: "float", low: -1, high: 1, unit: "fraction, + is right" },
{ name: "handbrake", kind: "bool" },
] satisfies readonly ArenaFieldSpec[],
observationSpace: [
{ name: "routeId", kind: "id" },
// Bounded by the episode rather than by the corridor: `progressM` counts
// distance travelled *this episode*, and 360 steps at the 42 m/s ceiling
// cannot exceed 504 m — 1200 is generous headroom on a target of ~850 that
// no policy reaches, and the bound is what a consumer normalises with.
{ name: "progressM", kind: "float", low: 0, high: 1200, unit: "m" },
{ name: "remainingM", kind: "float", low: 0, high: 1200, unit: "m" },
{ name: "lateralOffsetM", kind: "float", low: -12, high: 12, unit: "m from centreline" },
{ name: "speedMps", kind: "float", low: 0, high: 45, unit: "m/s" },
{ name: "speedLimitMps", kind: "float", low: 0, high: 45, unit: "m/s" },
{ name: "steering", kind: "float", low: -1, high: 1, unit: "fraction" },
{ name: "guardrailContact", kind: "bool" },
{ name: "roadName", kind: "id" },
] satisfies readonly ArenaFieldSpec[],
rewardComponents: {
progress: "Forward route progress, normalized by the episode target.",
success: "Sparse completion bonus.",
+87 -6
View File
@@ -1,5 +1,11 @@
export { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
export { arenaChecksum, canonicalJson } from "./checksum.ts";
export {
ARENA_CHECKSUM_DECIMALS,
arenaChecksum,
canonicalJson,
quantizeForChecksum,
quantizeToPlaces,
} from "./checksum.ts";
export { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
export {
ArenaScenarioRegistry,
@@ -7,9 +13,22 @@ export {
type ScenarioSampler,
} from "./scenarios.ts";
export { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
export {
actionWidth,
arenaEnvironmentIds,
arenaFieldWidth,
arenaManifest,
flattenAction,
flattenObservation,
observationWidth,
structureAction,
} from "./spaces.ts";
export { rollout, type ArenaPolicy, type RolloutOptions, type RolloutResult } from "./rollout.ts";
export {
ARENA_API_VERSION,
type ArenaEnvironment,
type ArenaFieldKind,
type ArenaFieldSpec,
type ArenaInfo,
type ArenaManifest,
type ArenaReplayResult,
@@ -74,12 +93,41 @@ export {
type CaliforniaFlightObservation,
type CaliforniaFlightReward,
} from "./californiaFlight.ts";
export {
STUDIO_OPS_INACTION,
STUDIO_OPS_MANIFEST,
STUDIO_OPS_SCENARIOS,
StudioOpsEnvironment,
localSolarHour,
quantizeObservable,
studioDeviceKw,
studioHvacKw,
studioOpsEnergyPenalty,
studioOpsNoisePenalty,
studioOpsScriptedBaseline,
studioOverflights,
studioSkyAt,
studioSolarKw,
studioVehicleReadiness,
studioWeatherAt,
weatherConditionOf,
type OverflightTrack,
type StudioNoiseInput,
type StudioOpsAction,
type StudioOpsObservation,
type StudioOpsReward,
type StudioOpsScenarioParameters,
type StudioSky,
type StudioWeather,
} from "./studioOps.ts";
import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
import { DRIVE_101_MANIFEST } from "./drive101.ts";
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts";
import { CALIFORNIA_FLIGHT_MANIFEST, CaliforniaFlightEnvironment } from "./californiaFlight.ts";
import { CROW_NAV_MANIFEST, CrowNavEnvironment } from "./crowNav.ts";
import { DRIVE_101_MANIFEST, Drive101Environment } from "./drive101.ts";
import { OFFICE_NAV_MANIFEST, OfficeNavEnvironment } from "./officeNav.ts";
import { OFFICE_JOBS_MANIFEST, OfficeJobsEnvironment } from "./officeJobs.ts";
import { STUDIO_OPS_MANIFEST, StudioOpsEnvironment } from "./studioOps.ts";
import type { ArenaEnvironment } from "./types.ts";
/** Machine-readable public environment catalogue. */
export const ARENA_MANIFESTS = Object.freeze([
@@ -88,4 +136,37 @@ export const ARENA_MANIFESTS = Object.freeze([
OFFICE_JOBS_MANIFEST,
CROW_NAV_MANIFEST,
CALIFORNIA_FLIGHT_MANIFEST,
STUDIO_OPS_MANIFEST,
]);
/**
* Env id to constructor.
*
* `ARENA_MANIFESTS` describes six environments and, until this existed, gave a
* harness no supported way to *build* any of them: a caller handed the string
* `"drive-101-v1"` off a config file, a command line, a results table had
* to maintain its own switch mapping ids to classes, which is a copy of this
* catalogue kept outside the package and silently wrong the day a sixth
* environment lands. Which is today.
*
* Constructors rather than instances, because an `ArenaEnvironment` is
* stateful: two rollouts in flight need two objects, and a frozen map of shared
* singletons would have them stepping each other's episodes.
*
* `any` in the value type is deliberate and is the one place in this package it
* appears. A registry keyed by a runtime string cannot promise a caller which
* action and observation types it will get back that is the nature of a
* dynamic lookup and the alternative is a union that every consumer would
* immediately have to narrow by the same string it just looked up. A caller
* that knows the type imports the class.
*/
export const ARENA_ENVIRONMENTS: Readonly<
Record<string, () => ArenaEnvironment<any, any, Record<string, number>, any>>
> = Object.freeze({
"drive-101-v1": () => new Drive101Environment(),
"office-nav-v1": () => new OfficeNavEnvironment(),
"office-jobs-v1": () => new OfficeJobsEnvironment(),
"crow-nav-v1": () => new CrowNavEnvironment(),
"california-flight-v1": () => new CaliforniaFlightEnvironment(),
"studio-ops-v1": () => new StudioOpsEnvironment(),
});
+78 -7
View File
@@ -17,7 +17,7 @@ import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import { ARENA_API_VERSION, type ArenaManifest, type ArenaScenario } from "./types.ts";
import { ARENA_API_VERSION, type ArenaFieldSpec, type ArenaManifest, type ArenaScenario } from "./types.ts";
export interface OfficeJobsAction {
x: number;
@@ -112,7 +112,13 @@ export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry<OfficeJobsScenari
export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "office-jobs-v1",
version: 1,
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
// request by hashing the seed against each scenario id instead of indexing
// definition order, so a seed run before that change may resolve to a
// different scenario after it. `version` is what a snapshot, a trace and a
// results table are pinned to, and a selection change that nothing recorded
// is exactly the silent remap the new selector exists to prevent.
version: 2,
title: "Seeded office robot jobs",
description: "Headless job execution over explicit simulated SF/LA operations and resolved office collision.",
simulator: "Plan + robotRoutes + fixed-step robotActivity",
@@ -124,6 +130,64 @@ export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({
"battery", "jobProgress", "nextStationId", "nextX", "nextZ", "deltaX", "deltaZ",
"distanceToNextM", "canInteract", "blockedStreak", "recoveryCount", "completedJobs",
],
actionSpace: [
{ name: "x", kind: "float", low: -1, high: 1, unit: "normalized drive demand" },
{ name: "z", kind: "float", low: -1, high: 1, unit: "normalized drive demand" },
{ name: "interact", kind: "bool" },
] satisfies readonly ArenaFieldSpec[],
// `mode`, `phase`, `jobKind` and `payload` are closed vocabularies in
// `robotActivity.ts` and `robotOperations.ts` and are one-hot here rather than
// hashed: a controller with five modes that a trainer sees as five unrelated
// reals is a controller a trainer cannot condition on.
observationSpace: [
{ name: "officeId", kind: "enum", values: ["lumbridge-hq", "mateo-court"] },
{ name: "robotId", kind: "id" },
{ name: "levelId", kind: "enum", values: ["level-1", "level-2"] },
{ name: "x", kind: "float", low: 0, high: 40, unit: "m" },
{ name: "z", kind: "float", low: 0, high: 40, unit: "m" },
// `RobotActivityMode` in robotOperations.ts, member for member.
{
name: "mode",
kind: "enum",
values: ["idle", "patrol", "deliver", "inspect", "charge", "blocked-recovery"],
},
// Every string `navigationPhase`, `activityPhase` and the recovery paths in
// robotActivity.ts can assign. `phase` is typed `string` there rather than
// as a union, so this list is a transcription and the spaces test walks a
// rollout of every scenario asserting nothing outside it is ever observed.
{
name: "phase",
kind: "enum",
values: [
"scheduled-idle", "patrolling", "patrol-check", "to-pickup", "loading-parcel",
"to-dropoff", "delivering-parcel", "to-inspection", "inspecting", "to-charge",
"charging", "awaiting-interaction", "replanning", "backoff-and-replan", "terminal",
],
},
// `RobotJobKind` plus the `"none"` this observation substitutes for `null`.
{
name: "jobKind",
kind: "enum",
values: ["none", "patrol", "deliver", "inspect", "charge"],
},
// One member and a legal absence: `payload` is `"parcel" | null`, and a null
// encodes as the zero vector rather than as a second category. That is the
// documented meaning of an out-of-vocabulary value and it is the right one
// here — "carrying nothing" is not a thing being carried.
{ name: "payload", kind: "enum", values: ["parcel"] },
{ name: "battery", kind: "float", low: 0, high: 1, unit: "fraction" },
{ name: "jobProgress", kind: "float", low: 0, high: 1, unit: "fraction" },
{ name: "nextStationId", kind: "id" },
{ name: "nextX", kind: "float", low: 0, high: 40, unit: "m" },
{ name: "nextZ", kind: "float", low: 0, high: 40, unit: "m" },
{ name: "deltaX", kind: "float", low: -40, high: 40, unit: "m" },
{ name: "deltaZ", kind: "float", low: -40, high: 40, unit: "m" },
{ name: "distanceToNextM", kind: "float", low: 0, high: 60, unit: "m" },
{ name: "canInteract", kind: "bool" },
{ name: "blockedStreak", kind: "float", low: 0, high: 720, unit: "steps" },
{ name: "recoveryCount", kind: "float", low: 0, high: 8, unit: "count" },
{ name: "completedJobs", kind: "float", low: 0, high: 32, unit: "count" },
] satisfies readonly ArenaFieldSpec[],
rewardComponents: {
navigation: "Bounded reduction in distance to the current authored job station.",
job: "Dense progress through pickup, delivery, inspection, or charge phases.",
@@ -153,7 +217,9 @@ export const OFFICE_JOBS_INACTION: Readonly<OfficeJobsAction> = Object.freeze({
export function officeJobsScriptedBaseline(observation: OfficeJobsObservation): OfficeJobsAction {
if (observation.canInteract) return { x: 0, z: 0, interact: true };
const length = Math.hypot(observation.deltaX, observation.deltaZ);
const length = Math.sqrt(
observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ,
);
if (length <= 1e-9) return { x: 0, z: 0, interact: false };
const magnitude = Math.min(1, length / (1.05 * OFFICE_JOBS_MANIFEST.fixedStepSeconds));
return {
@@ -206,7 +272,7 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment<
protected normalizeAction(action: OfficeJobsAction): OfficeJobsAction {
const x = Number.isFinite(action?.x) ? action.x : 0;
const z = Number.isFinite(action?.z) ? action.z : 0;
const length = Math.hypot(x, z);
const length = Math.sqrt(x * x + z * z);
return {
x: length > 1 ? x / length : x,
z: length > 1 ? z / length : z,
@@ -222,8 +288,10 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment<
const canInteract = this.canInteract(before);
if (action.interact && !canInteract) this.wrongInteractions += 1;
const after = this.requireActivity().step({ [this.robotId]: action }).robots[0]!;
const moved = Math.hypot(after.position.x - beforePosition.x, after.position.z - beforePosition.z);
const demand = Math.hypot(action.x, action.z);
const movedX = after.position.x - beforePosition.x;
const movedZ = after.position.z - beforePosition.z;
const moved = Math.sqrt(movedX * movedX + movedZ * movedZ);
const demand = Math.sqrt(action.x * action.x + action.z * action.z);
const blocked = demand > 0.2 && moved < 0.012;
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
const distance = this.distanceToNext(after);
@@ -325,7 +393,10 @@ export class OfficeJobsEnvironment extends BaseArenaEnvironment<
private distanceToNext(robot: RobotActivityState): number {
const station = this.nextStation(robot);
return station ? Math.hypot(station.position.x - robot.position.x, station.position.z - robot.position.z) : 0;
if (!station) return 0;
const dx = station.position.x - robot.position.x;
const dz = station.position.z - robot.position.z;
return Math.sqrt(dx * dx + dz * dz);
}
private canInteract(robot: RobotActivityState): boolean {
+38 -9
View File
@@ -10,6 +10,7 @@ import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaFieldSpec,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
@@ -98,7 +99,13 @@ export const OFFICE_NAV_SCENARIOS = new ArenaScenarioRegistry<OfficeScenarioPara
export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "office-nav-v1",
version: 1,
// 2 rather than 1: `ArenaScenarioRegistry` now selects a bare `{ split }`
// request by hashing the seed against each scenario id instead of indexing
// definition order, so a seed run before that change may resolve to a
// different scenario after it. `version` is what a snapshot, a trace and a
// results table are pinned to, and a selection change that nothing recorded
// is exactly the silent remap the new selector exists to prevent.
version: 2,
title: "Frontier Valley office navigation",
description: "Headless navigation through the resolved public office plan and exact wall collision.",
simulator: "Plan(FRONTIER_VALLEY) + createWalker",
@@ -109,6 +116,25 @@ export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
"levelId", "x", "z", "goalX", "goalZ", "deltaX", "deltaZ",
"distanceToGoalM", "travelledM", "blockedStreak",
],
actionSpace: [
{ name: "x", kind: "float", low: -1, high: 1, unit: "normalized walk demand" },
{ name: "z", kind: "float", low: -1, high: 1, unit: "normalized walk demand" },
] satisfies readonly ArenaFieldSpec[],
// Office-world metres, and the bounds are the reference pack's floor plate
// with slack: Frontier Valley's authored waypoints run from x=18 to x=46, and
// a walker resolved against collision cannot leave the building.
observationSpace: [
{ name: "levelId", kind: "id" },
{ name: "x", kind: "float", low: 0, high: 80, unit: "m" },
{ name: "z", kind: "float", low: 0, high: 80, unit: "m" },
{ name: "goalX", kind: "float", low: 0, high: 80, unit: "m" },
{ name: "goalZ", kind: "float", low: 0, high: 80, unit: "m" },
{ name: "deltaX", kind: "float", low: -80, high: 80, unit: "m" },
{ name: "deltaZ", kind: "float", low: -80, high: 80, unit: "m" },
{ name: "distanceToGoalM", kind: "float", low: 0, high: 120, unit: "m" },
{ name: "travelledM", kind: "float", low: 0, high: 400, unit: "m" },
{ name: "blockedStreak", kind: "float", low: 0, high: 160, unit: "steps" },
] satisfies readonly ArenaFieldSpec[],
rewardComponents: {
progress: "Reduction in Euclidean distance to the goal.",
success: "Sparse arrival bonus.",
@@ -131,7 +157,9 @@ export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
export const OFFICE_NAV_INACTION: Readonly<OfficeNavAction> = Object.freeze({ x: 0, z: 0 });
export function officeNavScriptedBaseline(observation: OfficeNavObservation): OfficeNavAction {
const length = Math.hypot(observation.deltaX, observation.deltaZ);
const length = Math.sqrt(
observation.deltaX * observation.deltaX + observation.deltaZ * observation.deltaZ,
);
if (length === 0) return { x: 0, z: 0 };
return { x: observation.deltaX / length, z: observation.deltaZ / length };
}
@@ -167,7 +195,7 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment<
protected normalizeAction(action: OfficeNavAction): OfficeNavAction {
const x = Number.isFinite(action?.x) ? action.x : 0;
const z = Number.isFinite(action?.z) ? action.z : 0;
const length = Math.hypot(x, z);
const length = Math.sqrt(x * x + z * z);
return length > 1 ? { x: x / length, z: z / length } : { x, z };
}
@@ -177,11 +205,10 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment<
const walker = this.requireWalker();
const before = walker.state();
const after = walker.tick(FIXED_STEP, action);
const moved = Math.hypot(
after.position.x - before.position.x,
after.position.z - before.position.z,
);
const demand = Math.hypot(action.x, action.z);
const movedX = after.position.x - before.position.x;
const movedZ = after.position.z - before.position.z;
const moved = Math.sqrt(movedX * movedX + movedZ * movedZ);
const demand = Math.sqrt(action.x * action.x + action.z * action.z);
const blocked = demand > 0.2 && moved < demand * 2 * FIXED_STEP * 0.2;
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
const distance = this.goalDistance();
@@ -240,7 +267,9 @@ export class OfficeNavEnvironment extends BaseArenaEnvironment<
private goalDistance(): number {
const state = this.requireWalker().state();
const goal = this.currentScenario().parameters;
return Math.hypot(goal.goalX - state.position.x, goal.goalZ - state.position.z);
const dx = goal.goalX - state.position.x;
const dz = goal.goalZ - state.position.z;
return Math.sqrt(dx * dx + dz * dz);
}
private requireWalker(): WalkerController {
+97
View File
@@ -0,0 +1,97 @@
/**
* One episode, run to its end.
*
* Nine lines of loop that every consumer of this package had written for
* itself: the test file, both baseline proofs, and every example in ARENA.md.
* Four copies of a loop is four places for the step budget to be off by one,
* for `truncated` to be checked and `terminated` not to be, or for the returned
* total to be the environment's cumulative reward in one copy and the caller's
* own running sum in another. They agree today; nothing made them.
*
* It is deliberately thin. A trainer does not want a framework here it wants
* the loop it was going to write, exported once so that a regression in it is a
* regression in one place. Anything richer (vectorised environments, batching,
* an action buffer) belongs in the trainer, which knows things this package
* cannot: how many workers there are, what device the policy is on, and whether
* an episode is worth finishing.
*/
import type {
ArenaEnvironment,
ArenaScenarioRequest,
ArenaStepResult,
} from "./types.ts";
/**
* What a policy is, from this package's point of view.
*
* The step index is passed as well as the observation because a *scripted*
* baseline sometimes needs it the studio-ops targeted policies switch
* behaviour at a known step to reach a particular terminal and because a
* policy that wants only the observation can ignore a second argument for free.
* It is the step that is about to be taken, counting from zero.
*/
export type ArenaPolicy<A, O> = (observation: O, step: number) => A;
export interface RolloutOptions {
/** Defaults to 0, which is a legal seed and a boring one. */
seed?: number;
/** An exact public scenario id, or a split request. Defaults to `train`. */
scenario?: string | ArenaScenarioRequest;
/**
* Stop after this many steps even if the episode has not ended.
*
* Clamped to the manifest's own cap, because a caller cannot extend an
* episode past the point where the environment sets `truncated` asking for
* more steps than the manifest allows is a mistake worth silently correcting
* rather than a request worth honouring. Below the cap it is a genuine early
* cut, and `final.terminated`/`final.truncated` will both be false, which is
* how a caller tells "I stopped it" from "it ended".
*/
maxSteps?: number;
}
export interface RolloutResult<O, R extends Record<string, number>> {
/** The sum of every step's total reward. */
total: number;
/** How many steps were actually taken. */
steps: number;
/** The last transition, carrying the terminal reason and final observation. */
final: ArenaStepResult<O, R>;
}
export function rollout<A, O, R extends Record<string, number>, S>(
environment: ArenaEnvironment<A, O, R, S>,
policy: ArenaPolicy<A, O>,
options: RolloutOptions = {},
): RolloutResult<O, R> {
const budget = Math.min(
environment.manifest.maxSteps,
options.maxSteps ?? environment.manifest.maxSteps,
);
if (!Number.isSafeInteger(budget) || budget < 1) {
throw new RangeError("arena rollout needs a budget of at least one step");
}
let observation = environment.reset(
options.seed ?? 0,
options.scenario ?? { split: "train" },
).observation;
let total = 0;
let steps = 0;
let final: ArenaStepResult<O, R> | undefined;
for (let step = 0; step < budget; step += 1) {
const result = environment.step(policy(observation, step));
observation = result.observation;
total += result.reward;
steps += 1;
final = result;
if (result.terminated || result.truncated) break;
}
// Unreachable while `budget >= 1`, and asserted rather than assumed because
// the alternative is a non-null assertion that would go stale the moment the
// loop above grows a `continue`.
if (!final) throw new Error("arena rollout took no steps");
return { total, steps, final };
}
+41 -2
View File
@@ -42,6 +42,46 @@ export class ArenaScenarioRegistry<P extends object> {
}
}
/**
* The scenario a bare `{ split }` request resolves to, chosen by hashing the
* seed against each candidate's **id** rather than by indexing definition
* order.
*
* This used to be `candidates[seed % candidates.length]`, which is one
* character shorter and quietly binds every seed anybody has ever run to the
* position of a scenario in an array literal. Insert a scenario in the middle
* of a split the most ordinary edit there is, and one a reviewer reads as
* purely additive and every seed past it silently resolves to a different
* task. Nothing fails; the numbers in a results table just stop meaning what
* they meant, and there is no artefact anywhere that records the change.
*
* Highest-random-weight selection instead: each candidate scores
* `deriveArenaSeed(seed, "<env>:<split>:<id>")` and the highest wins. Adding a
* scenario moves only the seeds the new id actually wins, which is the
* irreducible minimum for a set that grew; removing one moves only the seeds
* it held; reordering the literal moves nothing at all, because position is
* not an input any more.
*
* The tie-break is the id rather than the array index, so that even a 32-bit
* score collision between two ids in one split resolves the same way whatever
* order they were declared in. Ties are the one case where "first wins" would
* have smuggled definition order back in through the door it was shown out
* of.
*/
private select(seed: number, split: ArenaSplit): ArenaScenarioDefinition<P> | undefined {
let best: ArenaScenarioDefinition<P> | undefined;
let bestScore = -1;
for (const candidate of this.definitions) {
if (candidate.split !== split) continue;
const score = deriveArenaSeed(seed, `${this.envId}:${split}:${candidate.id}`);
if (score > bestScore || (score === bestScore && best !== undefined && candidate.id < best.id)) {
best = candidate;
bestScore = score;
}
}
return best;
}
ids(split: ArenaSplit): readonly string[] {
return this.definitions
.filter((definition) => definition.split === split)
@@ -57,8 +97,7 @@ export class ArenaScenarioRegistry<P extends object> {
definition = this.byId.get(request.id);
if (definition?.split !== request.split) definition = undefined;
} else {
const candidates = this.definitions.filter((entry) => entry.split === request.split);
definition = candidates[seed % candidates.length];
definition = this.select(seed, request.split);
}
if (!definition) throw new RangeError(`unknown ${this.envId} scenario`);
const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`));
+12 -8
View File
@@ -6,23 +6,27 @@ import type { ArenaSourceHashes } from "./types.ts";
*/
export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> = Object.freeze({
"drive-101-v1": {
environment: "sha256:a8f3bb5c04985215a2b98b75dd2ce82a13b794b9f4933536c5821a49cf1f8125",
simulator: "sha256:be24cacb480279e88c64e72803d2a8a94db1b084312b64da55050d2ca95c90af",
environment: "sha256:394b6892c13966ab999ba88d912b3fb86083a057aabcba2ae82a53ae29726b40",
simulator: "sha256:eae9a74358885691a3ea7c1ef58bb58bcfff171de7fa8fb1611d6900dcaead4a",
},
"office-nav-v1": {
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
environment: "sha256:ba6de0b6f940c20c1a2af3a8b2c332034a1270c2110b9bc403253705a645cd8c",
simulator: "sha256:d88513546aecacd950cb0c29d6e11874f51c0756f2d62f744e5b76670a6d69b6",
},
"office-jobs-v1": {
environment: "sha256:76cb8ec23745b05d1f9d408b948618d8fcbd93cf3ac46e018163702bffdc3e0f",
simulator: "sha256:aca85f5c0ec1430b8c5fab38233af757f93eefda1341c4c9d0fb5dfad24b0476",
environment: "sha256:446a8216784bee2875c3a14bfebf17d40e3ee2b2f55afbdd5f085e8aecdd570c",
simulator: "sha256:841391e89e83a98feeb9e503f15e2ca8e5840f00fd312166a2e16fdaba58e965",
},
"crow-nav-v1": {
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
environment: "sha256:448f061a182826decfdb0b6c54cdc62f39df2b2351b7dcaf33c0c77a0607dc51",
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
},
"california-flight-v1": {
environment: "sha256:8833a25d5da376278ae56da1056ae7fa20ed243b8de0b3ef1c10d5317afbcb3f",
environment: "sha256:50dc69d1f7541b1e98bea1ec8a0a69dc13a7b3cf75f1785fd2a2e309cb4d56ea",
simulator: "sha256:997aa7c63779ae77af44d584758f55b6679836305115aef5e13f207232ec4d6f",
},
"studio-ops-v1": {
environment: "sha256:18375ef89e9f890356428a7b62fc6b48b94fc019dd8ca1ac05eabede6d70e03f",
simulator: "sha256:6884955c43d7bf6488769b1c38a94a87591042322ee01ffb5e0ef013976dff1f",
},
});
+246
View File
@@ -0,0 +1,246 @@
/**
* The observation and action *shapes*, and the encoder every trainer was
* writing by hand.
*
* `ArenaManifest` has always published `observationFields` a list of names.
* A name list answers "what does this environment see"; it does not answer the
* only question a trainer asks before it can allocate anything, which is "how
* many numbers is that, and what are they". So every consumer wrote its own
* encoder against the environment's TypeScript source: a fork of the
* observation contract, kept outside this repository, silently invalidated by
* any field this repository adds.
*
* `ArenaFieldSpec` (in `types.ts`) closes that. Each field declares how it
* becomes numbers, `flattenObservation` performs exactly that encoding, and the
* width is a function of the manifest rather than of a comment.
*
* ### Raw values, declared bounds
*
* A float lands in the vector as its **clamped raw value**, not as a 0..1
* normalisation of it. Two reasons. Normalisation is a modelling decision a
* layer norm, a running mean, a tanh squash are all defensible and the trainer
* is the only party that knows which it is using and baking one in here would
* make it impossible to recover the metre or the decibel on the other side.
* And `low`/`high` are *declared* bounds rather than guarantees: they are what
* an author believes the field spans, published so a consumer can normalise,
* and quietly wrong bounds should show up as a saturated input rather than as
* a silently rescaled one.
*
* ### Why this module imports the manifests directly
*
* `index.ts` re-exports this file, so importing `ARENA_MANIFESTS` from there
* would close a cycle. Six named imports instead, which is the same list
* `index.ts` itself keeps, and `src/test/arena/spaces.test.ts` asserts the two
* catalogues hold the same ids so the duplication cannot drift.
*/
import { deriveArenaSeed } from "./random.ts";
import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
import { DRIVE_101_MANIFEST } from "./drive101.ts";
import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts";
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
import { STUDIO_OPS_MANIFEST } from "./studioOps.ts";
import type { ArenaFieldSpec, ArenaManifest } from "./types.ts";
const BY_ID: ReadonlyMap<string, ArenaManifest> = new Map(
[
DRIVE_101_MANIFEST,
OFFICE_NAV_MANIFEST,
OFFICE_JOBS_MANIFEST,
CROW_NAV_MANIFEST,
CALIFORNIA_FLIGHT_MANIFEST,
STUDIO_OPS_MANIFEST,
].map((manifest) => [manifest.id, manifest]),
);
/** Every environment id this package can encode for, in catalogue order. */
export function arenaEnvironmentIds(): readonly string[] {
return [...BY_ID.keys()];
}
/** The manifest for an id. Throws rather than returning `undefined`: a caller
* that mistyped an env id wants to know now, not to receive a zero-width
* vector fifty thousand steps into a run. */
export function arenaManifest(envId: string): ArenaManifest {
const manifest = BY_ID.get(envId);
if (!manifest) throw new RangeError(`unknown arena environment: ${envId}`);
return manifest;
}
/**
* How many slots one field occupies.
*
* This is also where a malformed spec is caught, because it is on the path of
* every width and every encode: a float without finite ordered bounds and an
* enum without a usable vocabulary are authoring mistakes that would otherwise
* surface as a vector of the wrong length or a slot that is always zero.
*/
export function arenaFieldWidth(spec: ArenaFieldSpec): number {
switch (spec.kind) {
case "float": {
const { low, high } = spec;
if (
typeof low !== "number" || typeof high !== "number" ||
!Number.isFinite(low) || !Number.isFinite(high) || !(low < high)
) throw new RangeError(`arena float field ${spec.name} needs finite low < high`);
return 1;
}
case "bool":
case "id":
return 1;
case "enum": {
const values = spec.values;
if (!Array.isArray(values) || values.length === 0) {
throw new RangeError(`arena enum field ${spec.name} needs a non-empty vocabulary`);
}
if (new Set(values).size !== values.length) {
throw new RangeError(`arena enum field ${spec.name} has a duplicate value`);
}
return values.length;
}
}
}
/** Total slots in a flattened observation for this environment. */
export function observationWidth(envId: string): number {
return spaceWidth(arenaManifest(envId).observationSpace);
}
/** Total slots in a flattened action for this environment. */
export function actionWidth(envId: string): number {
return spaceWidth(arenaManifest(envId).actionSpace);
}
function spaceWidth(space: readonly ArenaFieldSpec[]): number {
let width = 0;
for (const spec of space) width += arenaFieldWidth(spec);
return width;
}
/**
* One observation as a fixed-width vector of finite numbers.
*
* Fixed-width is the whole contract and it holds unconditionally: a field the
* observation does not carry, carries as `undefined`, or carries as the wrong
* type still occupies its slots. That is not leniency, it is the property a
* trainer depends on a vector whose length changes at step 400 because a
* station id went null is a crash a long way from its cause, and a batch that
* silently absorbs a `NaN` is worse than one that never sees the value at all.
*
* What an unusable value encodes as is chosen to be *inert* rather than
* plausible: a float falls back to its declared `low`, a bool to 0, an enum to
* all-zeros, an id to 0. None of those can be mistaken for a measurement, and
* `observationFields` plus the environment's own tests are what keep the case
* from arising.
*/
export function flattenObservation(envId: string, observation: unknown): number[] {
const space = arenaManifest(envId).observationSpace;
const record = isRecord(observation) ? observation : {};
const vector: number[] = [];
for (const spec of space) encodeField(spec, record[spec.name], vector);
return vector;
}
/** The same encoding applied to an action struct, for a trainer logging what
* it did as well as what it saw. */
export function flattenAction(envId: string, action: unknown): number[] {
const space = arenaManifest(envId).actionSpace;
const record = isRecord(action) ? action : {};
const vector: number[] = [];
for (const spec of space) encodeField(spec, record[spec.name], vector);
return vector;
}
/**
* The inverse of `flattenAction`: a policy's output vector as the struct
* `step()` takes.
*
* A harness that can read an observation vector but cannot emit an action is
* half a harness, and the half it is missing is the one every consumer has to
* hand-write against a field order it read out of a source file.
*
* Only `float` and `bool` are accepted, because that is what the six action
* spaces contain and because the alternatives are worse than a refusal: an
* `enum` action would need an argmax whose tie-break this module would be
* inventing, and an `id` action cannot be inverted from a hash at all. If an
* environment ever wants a categorical action, this function should learn about
* it deliberately rather than by falling through to a default.
*
* Floats are clamped into their declared bounds and a bool is `value >= 0.5`,
* so a raw network output needs no squashing before it gets here.
*/
export function structureAction(
envId: string,
vector: readonly number[],
): Record<string, number | boolean> {
const space = arenaManifest(envId).actionSpace;
const width = spaceWidth(space);
if (vector.length !== width) {
throw new RangeError(`arena ${envId} action needs exactly ${width} values`);
}
const action: Record<string, number | boolean> = {};
let cursor = 0;
for (const spec of space) {
const value = vector[cursor] ?? 0;
cursor += arenaFieldWidth(spec);
if (spec.kind === "float") {
action[spec.name] = Number.isFinite(value)
? clamp(value, spec.low as number, spec.high as number)
: (spec.low as number);
} else if (spec.kind === "bool") {
action[spec.name] = Number.isFinite(value) && value >= 0.5;
} else {
throw new RangeError(`arena ${envId} action field ${spec.name} is not invertible`);
}
}
return action;
}
function encodeField(spec: ArenaFieldSpec, value: unknown, into: number[]): void {
// Validated first, always: `arenaFieldWidth` is where a malformed spec is
// caught, and reading `spec.low` before asking whether it is a number would
// be the one path on which a bad manifest reached a vector instead of a throw.
arenaFieldWidth(spec);
switch (spec.kind) {
case "float": {
const low = spec.low as number;
const high = spec.high as number;
into.push(typeof value === "number" && Number.isFinite(value) ? clamp(value, low, high) : low);
return;
}
case "bool":
into.push(value === true ? 1 : 0);
return;
case "enum": {
for (const member of spec.values as readonly string[]) {
into.push(value === member ? 1 : 0);
}
return;
}
case "id":
into.push(typeof value === "string" && value.length > 0 ? idHash(value) : 0);
return;
}
}
/**
* A string as a stable number in `[0, 1)`.
*
* `deriveArenaSeed` rather than a second hash written here: it is already the
* package's string mixer, it is already exercised by every scenario
* materialization, and two hashes over identifiers is two things to keep in
* step. The constant is an arbitrary non-zero base it exists so that this
* stream is not the same one scenario seeds are drawn from.
*/
function idHash(value: string): number {
return deriveArenaSeed(0x9e3779b9, value) / 4_294_967_296;
}
function clamp(value: number, low: number, high: number): number {
return Math.min(high, Math.max(low, value));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -57,6 +57,48 @@ export interface ArenaStepResult<O, R extends Record<string, number>> {
info: ArenaInfo;
}
/**
* How one observation or action field is encoded as numbers.
*
* `observationFields` was always a list of names, which tells a reader what the
* environment observes and tells a trainer nothing it can allocate against: how
* wide is the vector, which slots are angles, what does `2` mean in the slot
* holding a phase. Every consumer had to read the environment's source and
* write the encoder by hand, which is a fork of the observation contract kept
* in a file this repository cannot see.
*
* Four kinds, because four is what the six environments actually contain:
*
* - `float` a quantity, with `low`/`high` as *declared bounds* rather than
* guarantees. `flattenObservation` clamps into them; a policy normalises with
* them. One slot.
* - `bool` one slot, 0 or 1.
* - `enum` a closed vocabulary, one-hot over `values`. As many slots as
* there are values. A value outside the vocabulary encodes as all zeros,
* which reads as "none of these" rather than silently colliding with the
* first member.
* - `id` an open-vocabulary identifier: a station id, a robot id, a road
* name. One slot holding a stable hash in `[0, 1)`. Nothing should try to
* *learn* from that slot it has no order and no metric but dropping the
* field would make the vector and `observationFields` incommensurable, and a
* hash at least changes exactly when the identity does, which is enough to
* detect "the station I am walking to is not the one I was walking to".
*/
export type ArenaFieldKind = "float" | "bool" | "enum" | "id";
export interface ArenaFieldSpec {
/** Matches the key in the observation or action object, exactly. */
name: string;
kind: ArenaFieldKind;
/** `float` only. Both required for a float, finite, and `low < high`. */
low?: number;
high?: number;
/** `enum` only. Non-empty and free of duplicates. */
values?: readonly string[];
/** Documentation for a reader of the manifest. Never parsed. */
unit?: string;
}
export interface ArenaManifest {
apiVersion: typeof ARENA_API_VERSION;
id: string;
@@ -68,6 +110,17 @@ export interface ArenaManifest {
maxSteps: number;
actionFields: readonly string[];
observationFields: readonly string[];
/**
* The same fields as `actionFields`/`observationFields`, in the same order,
* with an encoding for each.
*
* Two lists rather than one because the name lists are the human-readable
* contract that has been published since v1 and the spaces are the machine
* one; `src/test/arena/spaces.test.ts` asserts they agree name for name and
* in order, so the redundancy cannot rot into a disagreement.
*/
actionSpace: readonly ArenaFieldSpec[];
observationSpace: readonly ArenaFieldSpec[];
rewardComponents: Readonly<Record<string, string>>;
safetyTerminals: readonly string[];
scenarioIds: Readonly<Record<ArenaSplit, readonly string[]>>;
@@ -82,6 +135,26 @@ export interface ArenaSnapshot<S> {
envId: string;
envVersion: number;
envHash: string;
/**
* The source pins the snapshot was taken under.
*
* `envHash` is a hash of the *manifest*, which is semantics: the id, the
* version, the field names, the reward vocabulary. It does not move when the
* physics under it does. Edit the walker's collision epsilon or the device
* simulator's release rate, leave the manifest alone, and a snapshot taken
* before the edit restored cleanly afterwards and resumed into a different
* simulation silently, mid-episode, with a checksum that still verified
* because it was recomputed over the new state.
*
* `replay()` has always guarded this by comparing the trace envelope's
* `sourceHashes`; `restore()` did not, and a checkpoint is exactly as
* dangerous as a trace. So the pins travel in the snapshot too, inside the
* checksummed core, and `restore()` refuses a snapshot whose simulator or
* environment source has moved. The cost is that a legitimate source edit
* invalidates outstanding checkpoints, which is the correct thing for it to
* do: they were checkpoints of a program that no longer exists.
*/
sourceHashes: ArenaSourceHashes;
seed: number;
scenarioId: string;
scenarioSplit: ArenaSplit;
+196 -6
View File
@@ -69,6 +69,11 @@ export type SurfaceRole =
| "lightHousing"
| "lightDiffuser"
| "whiteboard"
// Devices
| "deviceShell"
| "deviceMesh"
| "deviceIndicator"
| "screenContent"
// Objects
| "foliage"
| "planter"
@@ -82,6 +87,11 @@ export type SurfaceRole =
*/
export type MaterialQuality = "low" | "medium" | "high";
/**
* `MeshPhysicalMaterial` is a subclass of `MeshStandardMaterial`, so it needs no
* arm of its own here but it is worth knowing it is in the union, because
* `glazing` is one at `medium` and `high` and a `MeshStandardMaterial` at `low`.
*/
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;
interface RoleSpec {
@@ -92,10 +102,50 @@ interface RoleSpec {
texture?: TextureKind;
/** Fraction of the role's own colour emitted. Screens and diffusers only. */
glow?: number;
/**
* Emit through `texture` rather than flat across the surface.
*
* Only meaningful with a `texture` that carries content rather than grain, and
* `screenContent` is the only such role. It is the difference between a
* monitor and a light box: with a flat `glow` the whole panel emits and the
* drawn interface is a pattern printed on a lamp, and with the map bound to
* `emissiveMap` the lit pixels emit and the chrome around them does not.
*/
emissiveFromMap?: boolean;
/**
* A coverage map, and the threshold a fragment has to clear to be drawn.
*
* Cutout, not blend. `alphaTest` discards below the threshold and leaves the
* material opaque, so a leaf still writes depth, still sorts like solid
* geometry and still casts a correctly-shaped shadow three's depth material
* copies `alphaMap` and `alphaTest` across for exactly this. Making foliage
* `transparent` instead would buy a soft edge and cost the shadow, the depth
* write and the sort order, on the one class of object there are hundreds of.
*/
alphaTexture?: TextureKind;
alphaTest?: number;
/** Opacity below 1 makes the material transparent. */
opacity?: number;
/** Leaf cards and glass want both faces. */
doubleSided?: boolean;
/**
* Refract through the surface instead of blending over it.
*
* `MeshPhysicalMaterial`'s transmission is the difference between glass and a
* grey film: it takes the *lit* colour of what is behind the surface, tints it
* by `color`, bends it by `ior` over `thickness`, and the part that actually
* sells it leaves a specular highlight and an environment reflection on top
* that a 22%-opacity blend cannot have. `roughness` becomes frosting rather
* than a matte grey, which is what a fritted partition wants.
*
* It costs a copy of the render target per transmissive draw, which is why it
* is `medium` and `high` only and why exactly one role uses it.
*/
transmission?: number;
/** Refractive index. 1.5 is soda-lime glass. Only read with `transmission`. */
ior?: number;
/** Metres of glass the refraction is integrated over. Only read with `transmission`. */
thickness?: number;
}
const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
@@ -115,7 +165,31 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
// Glass writes no depth. With it on, anything behind a window disappears
// depending on which mesh the sorter happens to draw first, and a meeting
// room made of glass is exactly the case where that is most visible.
glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true },
//
// That reasoning survives the move to transmission unchanged, and it has to be
// said out loud because three.js *encourages* the opposite: a transmissive
// material is drawn in the transmission pass and the usual advice is to let it
// write depth. Here it must not. An office is a box of glass boxes — a meeting
// room seen through a corridor screen through an external window is three
// sheets deep — and depth-writing glass makes whichever sheet the sorter
// reached first erase the other two. The `opacity` stays as well: it is what
// `low` quality falls back to, and it is what keeps the frame visible against
// the glass in the ghosted wall-occlusion copy.
glazing: {
roughness: 0.05,
// Was 0.1, and had to go: three.js scales transmission by `1 - metalness`
// because a metal is opaque by definition, so a tenth of metalness is a
// tenth of the glass quietly turned back into a mirror.
metalness: 0,
opacity: 0.22,
doubleSided: true,
transmission: 0.92,
ior: 1.5,
// Millimetres, not metres of solid glass: `thickness` scales the volumetric
// tint, and a 6 mm pane that tints like a 6 m aquarium is the classic way
// this parameter goes wrong.
thickness: 0.006,
},
glazingFrame: { roughness: 0.35, metalness: 0.7 },
doorLeaf: { roughness: 0.6, metalness: 0 },
@@ -139,7 +213,38 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },
foliage: { roughness: 0.8, metalness: 0, doubleSided: true },
/**
* The four device roles.
*
* `deviceMesh` is a grille or a windscreen the perforated part and it is
* double-sided because you can see through it to the inside of the housing at
* a glancing angle, which is most of what makes a speaker look like a speaker.
* `deviceIndicator` is the only role in the table with a `glow` of 1: an LED
* is a light source rather than a lit surface, and under the tone curve
* `stage.ts` now runs, a full-strength emissive reads as a lamp instead of
* saturating to the same white as the housing beside it.
*/
deviceShell: { roughness: 0.42, metalness: 0.28 },
deviceMesh: { roughness: 0.52, metalness: 0.8, doubleSided: true },
deviceIndicator: { roughness: 0.35, metalness: 0, glow: 1 },
screenContent: {
roughness: 0.18,
metalness: 0,
texture: "screenUI",
glow: 0.9,
emissiveFromMap: true,
},
// A leaf is a quad with a leaf cut out of it. See `leafAlpha` in textures.ts
// for why that is worth a texture channel, and `alphaTest` at 0.5 for why the
// threshold sits in the middle of a hard-edged drawing rather than at its toe.
foliage: {
roughness: 0.8,
metalness: 0,
doubleSided: true,
alphaTexture: "leafAlpha",
alphaTest: 0.5,
},
planter: { roughness: 0.7, metalness: 0 },
paper: { roughness: 0.9, metalness: 0 },
accent: { roughness: 0.6, metalness: 0.1 },
@@ -227,6 +332,11 @@ export class MaterialRegistry {
* The map is dropped deliberately: carpet grain at 18% opacity is visual
* noise on top of whatever it is supposed to be letting you see. Depth
* writing goes with it, for the same reason glazing does not write depth.
*
* `alphaMap` is deliberately *not* dropped with it. The colour map is
* decoration and the coverage map is shape a ghosted leaf with its cutout
* removed is not a faint leaf, it is the flat green shard the cutout exists to
* get rid of, at 18% opacity.
*/
ghostOf(role: SurfaceRole): SurfaceMaterial {
const hit = this.ghosts.get(role);
@@ -234,6 +344,16 @@ export class MaterialRegistry {
const ghost = this.get(role).clone();
ghost.name = `${role}:ghost`;
ghost.map = null;
// The relief goes with the colour map and for the same reason. It also has
// to: a normal map on a surface that is 82% see-through is a lighting cue
// for a surface nobody is being asked to look at.
if ("normalMap" in ghost) ghost.normalMap = null;
// A ghost is a hint, not a window. Leaving transmission on would put the
// occlusion fade — which exists to be cheap and is redrawn as the camera
// moves — through the transmission pass and its render-target copy.
if ("transmission" in ghost) {
(ghost as THREE.MeshPhysicalMaterial).transmission = 0;
}
ghost.transparent = true;
ghost.opacity = 0.18;
ghost.depthWrite = false;
@@ -257,6 +377,38 @@ export class MaterialRegistry {
return made;
}
/**
* A role drawn with a different layout of its own texture.
*
* Only `screenContent` has more than one today (`SCREEN_UI_VARIANTS` of them),
* and this exists because of a constraint one layer up rather than a wish for
* variety: `furnish.ts` batches props per kind and draws `ctx.rand` **once per
* kind**, so a screen asset cannot roll for a layout per instance. Variety has
* to arrive as a parameter, from a pack authoring separate batches, which
* means it has to arrive as a separate material one material per layout, all
* of them cached here, and the draw-call cost is one call per layout actually
* used rather than one per screen.
*
* `color` is optional so the common case reads `variant(role, n)`; pass one to
* get a tinted layout, which is the same shape `tinted` offers.
*/
variant(role: SurfaceRole, variant: number, color?: number): SurfaceMaterial {
const texture = ROLE_SPECS[role].texture;
const count = texture ? this.textures.variants(texture) : 1;
const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0;
const hue = color ?? this.palette[role];
// Variant 0 with the role's own colour *is* the base material. Minting a
// second identical one would be a second draw call for the same picture.
if (index === 0 && color === undefined) return this.get(role);
const key = `${role}:${hue.toString(16)}:${index}`;
const hit = this.tints.get(key);
if (hit) return hit;
const made = this.create(role, hue, index);
made.name = key;
this.tints.set(key, made);
return made;
}
/**
* Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
*
@@ -276,27 +428,65 @@ export class MaterialRegistry {
return this.get(this.resolve(surface, fallback));
}
private create(role: SurfaceRole, color: number): SurfaceMaterial {
private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial {
const spec = ROLE_SPECS[role];
const map = spec.texture ? this.textures.get(spec.texture) : null;
const map = spec.texture ? this.textures.get(spec.texture, variant) : null;
const alphaMap = spec.alphaTexture ? this.textures.get(spec.alphaTexture) : null;
// Relief comes from the same kind as the colour, and the bin answers `null`
// for the kinds that have none — a whiteboard and a display are flat, and
// `low` quality has no maps at all. No role opts in separately: a surface
// either has a texture or it does not, and asking for the grain without the
// relief that produced it is not a combination worth spelling.
const normalMap = spec.texture ? this.textures.normal(spec.texture) : null;
const transparent = spec.opacity !== undefined && spec.opacity < 1;
const shared = {
color,
map,
// `alphaTest` is only set when there is a map to test against. Left on
// with a null `alphaMap` at `low` quality it would test the material's
// flat opacity of 1 against the threshold on every fragment — which
// passes, but compiles a branch into the shader for nothing.
alphaMap,
alphaTest: alphaMap ? (spec.alphaTest ?? 0.5) : 0,
side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
transparent,
opacity: spec.opacity ?? 1,
depthWrite: !transparent,
emissive: spec.glow ? color : 0x000000,
// White rather than the role's colour when the map is doing the emitting:
// `emissive` multiplies `emissiveMap`, so anything but white would tint
// the drawn interface a second time on top of `color` already tinting it.
emissive: spec.emissiveFromMap ? 0xffffff : spec.glow ? color : 0x000000,
emissiveMap: spec.emissiveFromMap ? map : null,
emissiveIntensity: spec.glow ?? 0,
};
// `low` is flat Lambert: no maps, no roughness, no transmission. The
// `normalMap` is not merely unused there — `MeshLambertMaterial` does have
// one, but the whole point of `low` is to compile the cheap shader.
if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
return new THREE.MeshStandardMaterial({
const physical = {
...shared,
normalMap,
roughness: spec.roughness,
metalness: spec.metalness,
};
if (spec.transmission === undefined) return new THREE.MeshStandardMaterial(physical);
return new THREE.MeshPhysicalMaterial({
...physical,
transmission: spec.transmission,
ior: spec.ior ?? 1.5,
thickness: spec.thickness ?? 0.01,
// Transmission carries the see-through, so the blend must not do it a
// second time. Left transparent at 0.22 the sheet would be four fifths
// invisible *and* refracting the fifth that was left, which reads as a
// smear rather than as glass. `depthWrite` stays false regardless — see
// the note on the role.
transparent: false,
opacity: 1,
depthWrite: false,
});
}
+80 -6
View File
@@ -40,11 +40,17 @@
* the refusal as "skip this material", and the result is not an error but a
* chair with no shell on it which is a lot harder to notice than a crash.
*
* In practice: `roundedBox` is an `ExtrudeGeometry` and carries no index, while
* every other part in `parts.ts` does. So a material is a rounded material or a
* boxy one, and where that forces a choice the honest fix is to move the part
* to the material it belongs to anyway a task chair's arm pads are upholstery
* as readily as they are shell.
* In practice: `roundedBox` and `roundedBoxOf` are `ExtrudeGeometry` and carry
* no index, while every other part in `parts.ts` does. So a material is a
* rounded material or a boxy one, and where that forces a choice the honest fix
* is to move the part to the material it belongs to anyway a task chair's arm
* pads are upholstery as readily as they are shell.
*
* `slab`'s `chamfer` option is the one place that rule had to be worked around
* rather than worked with, and `nonIndexed` below is how. A chamfered desktop
* wants an extruded body *and* a metric top face in the same material, and those
* two are on opposite sides of the rule; converting the quad is four vertices of
* cost and keeps the whole desktop in one merge.
*
* ### Light fixtures emit no light
*
@@ -54,10 +60,35 @@
* shadow-casting lights, the end of the frame budget.
*/
import * as THREE from "three";
import { tintFor, type AssetContext } from "../kit.ts";
import type { SurfaceMaterial, SurfaceRole } from "../materials.ts";
import type { MeshBin } from "../parts.ts";
/**
* A de-indexed copy of one of the shared parts, memoised per source geometry.
*
* `mergeGeometries` refuses a mixture of indexed and non-indexed inputs, so a
* material carrying an `ExtrudeGeometry` cannot also carry a `PlaneGeometry`.
* Rather than give up either the chamfer or the metric UVs, the quad is
* converted once and cached.
*
* A `WeakMap` rather than a `Map` because the key is a geometry owned by
* `PartBin`: if the bin is ever disposed and rebuilt, the derived copies become
* unreachable with their sources instead of pinning a disposed buffer forever.
*/
const NON_INDEXED = new WeakMap<THREE.BufferGeometry, THREE.BufferGeometry>();
function nonIndexed(source: THREE.BufferGeometry): THREE.BufferGeometry {
if (source.getIndex() === null) return source;
const hit = NON_INDEXED.get(source);
if (hit) return hit;
const made = source.toNonIndexed();
made.name = `${source.name || "part"}:flat`;
NON_INDEXED.set(source, made);
return made;
}
/**
* The material for the one part of an asset that answers to `Prop.colorKey`
* a chair's fabric, a locker's doors, a rug's pile. Every asset names its
@@ -84,16 +115,59 @@ export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial
* two different materials (see the UV note in `parts.ts`). The quad sits 0.6 mm
* proud of the box so the two never z-fight.
*
* ### `chamfer`, and why three millimetres is worth a whole code path
*
* A sharp arris is the single most reliable tell that a surface was rendered
* rather than built. Every real desktop, worktop and shelf board has a small
* radius on its edge, and what that radius does is catch a *line* of specular
* highlight along the whole length of the board one bright edge that separates
* the top from the front and tells you where the object stops. Without it a
* desktop and the wall behind it meet in a hard colour change and the desk reads
* as a decal.
*
* Three to six millimetres is the range; past that it starts reading as a
* moulded plastic table. `roundedBoxOf` takes the radius in metres and applies it
* after the proportions are known, which is the only way to get a circular
* corner on a board that is fifty times wider than it is thick.
*
* It is opt-in rather than the default because it changes the primitive class of
* the whole material (see the header): every other part an asset draws in the
* same material has to become an extrusion too, and for a shelf board carrying
* books that trade is not worth making.
*
* `y` is the underside of the slab.
*/
export function slab(
bin: MeshBin,
ctx: AssetContext,
material: SurfaceMaterial,
s: { x?: number; y: number; z?: number; width: number; depth: number; thickness: number },
s: {
x?: number;
y: number;
z?: number;
width: number;
depth: number;
thickness: number;
/** Edge radius in metres. 0.0030.006 for a board; omit for a sharp edge. */
chamfer?: number;
},
): void {
const x = s.x ?? 0;
const z = s.z ?? 0;
const chamfer = s.chamfer ?? 0;
if (chamfer > 0) {
bin.add(ctx.parts.roundedBoxOf(s.width, s.thickness, s.depth, chamfer), material, {
x,
y: s.y,
z,
});
bin.add(nonIndexed(ctx.parts.metricQuad(s.width - chamfer * 2, s.depth - chamfer * 2)), material, {
x,
y: s.y + s.thickness + 0.0006,
z,
});
return;
}
bin.add(ctx.parts.box(), material, {
x,
y: s.y,
+4
View File
@@ -46,11 +46,15 @@ export const deskWorkstation = defineAsset<WorkstationParams>({
const frame = ctx.materials.get("deskFrame");
const deckY = p.height - TOP_THICKNESS;
// 4 mm on the edge of the desktop. `deskSurface` is used by nothing else in
// this asset, which is what makes the chamfer affordable here — see the
// primitive-class note on `slab`.
slab(bin, ctx, ctx.materials.get("deskSurface"), {
y: deckY,
width: p.width,
depth: p.depth,
thickness: TOP_THICKNESS,
chamfer: 0.004,
});
const legX = p.width / 2 - 0.09;
+467
View File
@@ -0,0 +1,467 @@
/**
* The two pieces of hardware the smart-device layer drives: a desk microphone
* and a desk monitor speaker.
*
* These are not props with a light glued on. They are the *instruments* the
* studio simulation observes and commands a mic that can be muted and gained,
* a speaker that can be turned up and played through so they are modelled to
* be looked at from the distance somebody sits from their own desk, which is
* about sixty centimetres. At that range a speaker with a painted-on grille is a
* lie you can see, which is why the grille here is slats with gaps between them
* and the driver is a real cone in a real surround.
*
* ### The id convention is load-bearing
*
* `<namespace>:device.<kind>.<placement>` `tera:device.mic.desk`,
* `tera:device.speaker.desk`. `deviceKindOfAssetId()` in `src/devices/types.ts`
* parses the kind straight back out of the id, and the device API refuses a
* declaration whose asset kind disagrees with its declared kind. That is what
* lets a self-hoster register `acme:device.mic.boom` and have it read as a
* microphone for free, with no table to edit and no fork; and it is why anything
* not matching the pattern is treated as "not device hardware" rather than as a
* silently mis-typed device.
*
* ### Every device exposes a sub-object named `indicator`
*
* The device render layer (`src/interiors/devices.ts`) tints one part of each
* device per state powered, muted, idle by swapping the material on it with
* `materials.tinted("deviceIndicator", colour)`, which reaches both `color` and
* `emissive`. It finds that part by **name**, not by material and not by index:
* a name survives a self-hoster's override, a material does not (two devices
* sharing `deviceIndicator` would both light up), and an index does not survive
* anybody adding a part.
*
* So the contract is exactly: `object.getObjectByName("indicator")` returns a
* group holding the LED and nothing else. It is a separate group rather than a
* separate mesh because `MeshBin.build` names its meshes after their material,
* and the layer above should not have to know what material an LED happens to
* be made of.
*
* ### Roles, and the primitive-class rule
*
* `deviceShell` is the moulded housing, `deviceMesh` the perforated parts
* grille, basket, windscreen and `deviceIndicator` the LED. `common.ts` warns
* that every part under one material must be all-indexed or all-non-indexed or
* `mergeGeometries` silently drops it; both assets here are built entirely from
* indexed primitives (`box`, `cylinder`, `rod`, `cone`, `sphere`, `disc`), which
* makes that rule impossible to break by accident rather than merely remembered.
*
* Neither takes a `colorKey`. A studio microphone is the colour a studio
* microphone is, and the one part that changes colour changes it because of
* *state*, which is the device layer's business and not a pack's.
*/
import * as THREE from "three";
import { defineAsset, type AssetContext, type AssetId } from "../kit.ts";
import { MeshBin } from "../parts.ts";
/** The built-in device ids, in the order `src/devices` expects to find them. */
export const DEVICE_ASSET_IDS: readonly AssetId[] = [
"tera:device.mic.desk",
"tera:device.speaker.desk",
];
/** The name the device render layer looks up to find the tintable LED. */
export const DEVICE_INDICATOR_NAME = "indicator";
/**
* Assemble a device: its static hardware, plus the one sub-object that changes
* colour, under the agreed name.
*
* The LED bin is built with shadows off in both directions. A four-millimetre
* emissive dot has nothing meaningful to cast and nothing meaningful to receive,
* and leaving it in the shadow pass costs a draw call in every cascade for a
* part that is smaller than a shadow-map texel at any sensible resolution.
*/
function deviceGroup(name: string, hardware: MeshBin, led: MeshBin): THREE.Group {
const group = new THREE.Group();
group.name = name;
group.add(hardware.build(name));
const indicator = led.build(DEVICE_INDICATOR_NAME, {
castShadow: false,
receiveShadow: false,
});
indicator.name = DEVICE_INDICATOR_NAME;
group.add(indicator);
return group;
}
/**
* A ring of `count` short bars around the Y axis at `radius`.
*
* Used for the shock mount's elastic suspension and for the speaker's driver
* surround. Worth a helper rather than three copies because getting the local
* yaw wrong produces a ring of bars that all face the same way, which reads as a
* mistake rather than as a detail.
*/
function ringOfBars(
bin: MeshBin,
ctx: AssetContext,
material: Parameters<MeshBin["add"]>[1],
r: {
count: number;
radius: number;
y: number;
size: [number, number, number];
pitch?: number;
phase?: number;
},
): void {
for (let i = 0; i < r.count; i++) {
const yaw = (i / r.count) * Math.PI * 2 + (r.phase ?? 0);
bin.add(ctx.parts.box(), material, {
x: Math.sin(yaw) * r.radius,
z: Math.cos(yaw) * r.radius,
y: r.y,
size: r.size,
yaw,
pitch: r.pitch ?? 0,
});
}
}
// ---- Microphone -----------------------------------------------------------
type MicParams = {
/** Length of the capsule body itself, metres. A large-diaphragm condenser. */
bodyLength: number;
/** Diameter of the weighted desk base. */
baseDiameter: number;
/** Floor of the base to the centre of the capsule. */
standHeight: number;
/** Radians the capsule leans back from vertical, toward the person at +Z. */
tilt: number;
};
/**
* A shock-mounted desk condenser.
*
* Four things carry it, in the order they matter at desk distance:
*
* 1. **The shock mount.** A microphone hanging inside a ring on visible elastic
* is the single most recognisable thing about a studio desk, and it is the
* part that says "this is a real microphone" before any of the rest resolves.
* 2. **The basket.** A grille with gaps in it, not a painted cylinder eight
* vertical wires and two hoops, in the double-sided `deviceMesh` role, so you
* see through it to the shaded inside of the head at a glancing angle.
* 3. **The windscreen**, as a foam sphere pushed over the top of the basket. It
* is what breaks the hard cylinder silhouette.
* 4. **The mute LED**, on the front of the body under the basket, where the
* hardware button is on almost every one of these.
*
* Faces Z at yaw zero like everything else, which puts the front of the capsule
* and the LED at +Z, toward the person (`common.ts`).
*/
export const deviceMicDesk = defineAsset<MicParams>({
id: "tera:device.mic.desk",
label: "Desk microphone",
defaults: { bodyLength: 0.09, baseDiameter: 0.12, standHeight: 0.21, tilt: 0.16 },
footprint(p) {
// The shock mount is wider than the base and the capsule leans out of it, so
// the footprint is the ring's swept width rather than the base's diameter.
// 1.5 × the body, measured off the built mesh rather than guessed: the ring
// is `bodyLength × 0.66` in radius and the suspension bars stick out past it,
// and the foam windscreen is wider again than the basket it is pushed over.
const ring = p.bodyLength * 1.5;
const height = p.standHeight + p.bodyLength * 0.95 + 0.05;
return { width: Math.max(p.baseDiameter, ring), depth: ring + 0.04, height, clearance: 0.12 };
},
build(p, ctx) {
const P = ctx.parts;
const bin = new MeshBin();
const led = new MeshBin();
const shell = ctx.materials.get("deviceShell");
const grille = ctx.materials.get("deviceMesh");
const metal = ctx.materials.get("metalTrim");
// ---- The weighted base. Two discs and a fillet: a microphone base is heavy
// and low, and a straight cylinder reads as a cotton reel.
bin.add(P.cylinder(20), shell, { size: [p.baseDiameter, 0.014, p.baseDiameter] });
bin.add(P.cylinder(20), shell, {
y: 0.014,
size: [p.baseDiameter * 0.82, 0.012, p.baseDiameter * 0.82],
});
bin.add(P.cylinder(16), metal, {
y: 0.026,
size: [p.baseDiameter * 0.34, 0.01, p.baseDiameter * 0.34],
});
// ---- The post, and the yoke that carries the ring.
const ringY = p.standHeight;
bin.add(P.rod(), metal, { y: 0.03, size: [0.014, ringY - 0.03 - 0.01, 0.014] });
const ringR = p.bodyLength * 0.66;
for (const sx of [-1, 1]) {
bin.add(P.box(), metal, {
x: sx * ringR * 0.7,
y: ringY - 0.055,
size: [0.008, 0.075, 0.008],
roll: sx * 0.32,
});
}
// ---- The shock-mount ring. Twelve short bars around the circle rather than
// a torus: `parts.ts` has no torus, and twelve boxes is both cheaper and, at
// this size, indistinguishable from one.
ringOfBars(bin, ctx, metal, {
count: 12,
radius: ringR,
y: ringY - 0.006,
size: [ringR * 0.58, 0.012, 0.009],
pitch: 0,
});
// The elastic. Six lines from the ring in to the body, alternating up and
// down the capsule the way a real suspension is strung.
for (let i = 0; i < 6; i++) {
const yaw = (i / 6) * Math.PI * 2 + 0.26;
const lift = i % 2 === 0 ? 0.022 : -0.022;
bin.add(P.box(), metal, {
x: (Math.sin(yaw) * ringR) / 2,
z: (Math.cos(yaw) * ringR) / 2,
y: ringY + lift * 0.5,
size: [ringR, 0.004, 0.004],
yaw: yaw + Math.PI / 2,
roll: lift > 0 ? 0.42 : -0.42,
});
}
// ---- The capsule. Built about the ring's centre and tilted back, so the
// whole head — body, basket, windscreen, LED — leans as one piece.
const tilt = p.tilt;
const dia = p.bodyLength * 0.54;
const lean = (d: number): { y: number; z: number } => ({
y: Math.cos(tilt) * d,
z: Math.sin(tilt) * d,
});
const bodyBase = lean(-p.bodyLength * 0.5);
bin.add(P.cylinder(18), shell, {
y: ringY + bodyBase.y,
z: bodyBase.z,
size: [dia, p.bodyLength * 0.62, dia],
pitch: -tilt,
});
// A collar where the body meets the head. Every one of these has one and it
// is what stops the capsule reading as a single extruded tube.
const collar = lean(p.bodyLength * 0.1);
bin.add(P.cylinder(18), metal, {
y: ringY + collar.y,
z: collar.z,
size: [dia * 1.08, 0.006, dia * 1.08],
pitch: -tilt,
});
// ---- The basket: two hoops and eight wires, with a dome on top.
const headBase = lean(p.bodyLength * 0.12);
const headLength = p.bodyLength * 0.5;
const headDia = dia * 1.12;
for (const at of [0.12, 0.62]) {
const hoop = lean(p.bodyLength * (0.12 + at * 0.5));
bin.add(P.cylinder(18), metal, {
y: ringY + hoop.y,
z: hoop.z,
size: [headDia * 1.02, 0.004, headDia * 1.02],
pitch: -tilt,
});
}
for (let i = 0; i < 8; i++) {
const yaw = (i / 8) * Math.PI * 2;
// The wires stand in the head's own tilted frame, so they are placed at
// the head's base and rotated with it rather than around the world Y.
const offset = headDia * 0.5;
bin.add(P.box(), grille, {
x: Math.sin(yaw) * offset,
y: ringY + headBase.y + Math.cos(yaw) * offset * Math.sin(tilt),
z: headBase.z + Math.cos(yaw) * offset * Math.cos(tilt),
size: [0.0035, headLength, 0.0035],
pitch: -tilt,
});
}
const domeAt = lean(p.bodyLength * 0.12 + headLength);
bin.add(P.sphere(14), grille, {
y: ringY + domeAt.y - headDia * 0.25,
z: domeAt.z,
size: [headDia, headDia * 0.5, headDia],
pitch: -tilt,
});
// ---- The foam windscreen, pushed over the basket. Slightly bigger than the
// head and slightly squashed, because foam is.
const foamAt = lean(p.bodyLength * 0.3);
bin.add(P.sphere(16), grille, {
y: ringY + foamAt.y - headDia * 0.62,
z: foamAt.z,
size: [headDia * 1.24, headDia * 1.34, headDia * 1.24],
pitch: -tilt,
});
// ---- The mute LED, on the front of the body below the basket.
const ledAt = lean(-p.bodyLength * 0.12);
led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
y: ringY + ledAt.y,
z: ledAt.z + dia * 0.5,
size: [0.008, 0.003, 0.008],
pitch: Math.PI / 2 - tilt,
});
return deviceGroup("device.mic.desk", bin, led);
},
});
// ---- Speaker --------------------------------------------------------------
type SpeakerParams = {
width: number;
height: number;
depth: number;
/** Slats across the grille. Fewer reads as a radiator, more as a solid panel. */
slats: number;
};
/**
* A compact powered monitor speaker.
*
* The grille is the reason this asset exists in geometry rather than in a
* texture. `deviceMesh` is double-sided (`materials.ts`) precisely so that the
* gaps between the slats show the shaded inside of the cabinet behind them, and
* that parallax slats in front, driver behind, dark cabinet behind that is
* the entire difference between a speaker and a box with stripes on it. It costs
* a dozen boxes.
*
* The bass port is a real hole in the same sense: a recessed dark tube on the
* front baffle rather than a black circle drawn on it.
*
* The cabinet is `roundedBoxOf` at its finished size, so the 4 mm chamfer round
* its edges is genuinely circular instead of an ellipse stretched by a
* non-uniform scale which is the whole reason that method exists.
*/
export const deviceSpeakerDesk = defineAsset<SpeakerParams>({
id: "tera:device.speaker.desk",
label: "Desk monitor speaker",
defaults: { width: 0.14, height: 0.22, depth: 0.17, slats: 11 },
footprint(p) {
// The isolation pad under it is a touch wider than the cabinet, and the
// grille stands a few millimetres proud of the baffle at +Z.
return {
width: p.width + 0.012,
depth: p.depth + 0.018,
height: p.height + 0.016,
clearance: 0.1,
};
},
build(p, ctx) {
const P = ctx.parts;
const bin = new MeshBin();
const led = new MeshBin();
const shell = ctx.materials.get("deviceShell");
const mesh = ctx.materials.get("deviceMesh");
const metal = ctx.materials.get("metalTrim");
// ---- The isolation pad. A monitor on a desk stands on foam, and the 16 mm
// of shadow under the cabinet is what stops it looking stuck to the desktop.
const pad = 0.014;
bin.add(P.box(), ctx.materials.get("upholstery"), {
y: 0,
size: [p.width + 0.012, pad, p.depth + 0.006],
pitch: 0,
});
// ---- The cabinet, and the baffle recessed into its front face.
bin.add(P.roundedBoxOf(p.width, p.height, p.depth, 0.005), shell, { y: pad, size: 1 });
const baffleZ = p.depth / 2 - 0.008;
bin.add(P.roundedBoxOf(p.width - 0.014, p.height - 0.014, 0.01, 0.004), shell, {
y: pad + 0.007,
z: baffleZ - 0.004,
size: 1,
});
// ---- The woofer: surround ring, cone, dust cap. The cone points at the
// person, so it is a `cone()` rolled over — its base is its wide end.
const wooferY = pad + p.height * 0.36;
const wooferR = Math.min(p.width * 0.36, p.height * 0.24);
bin.add(P.cylinder(20), metal, {
y: wooferY,
z: baffleZ,
size: [wooferR * 2.2, 0.006, wooferR * 2.2],
pitch: Math.PI / 2,
});
bin.add(P.cone(20), mesh, {
y: wooferY,
z: baffleZ - 0.024,
size: [wooferR * 1.9, 0.026, wooferR * 1.9],
pitch: -Math.PI / 2,
});
// The dust cap rides on `metalTrim` rather than on `deviceShell`: the shell
// is all `roundedBoxOf` in this asset and a sphere is indexed, and mixing the
// two under one material makes `mergeGeometries` drop the whole cabinet.
bin.add(P.sphere(12), metal, {
y: wooferY,
z: baffleZ - 0.004,
size: [wooferR * 0.7, wooferR * 0.5, wooferR * 0.7],
pitch: -Math.PI / 2,
});
// ---- The tweeter, in its own shallow waveguide.
const tweeterY = pad + p.height * 0.78;
const tweeterR = wooferR * 0.42;
bin.add(P.cylinder(16), metal, {
y: tweeterY,
z: baffleZ,
size: [tweeterR * 2.6, 0.005, tweeterR * 2.6],
pitch: Math.PI / 2,
});
bin.add(P.sphere(12), mesh, {
y: tweeterY,
z: baffleZ - 0.002,
size: [tweeterR * 1.6, tweeterR * 1.2, tweeterR * 1.6],
pitch: -Math.PI / 2,
});
// ---- The bass port: a tube sunk into the baffle between the drivers.
const portY = pad + p.height * 0.16;
bin.add(P.cylinder(14), mesh, {
y: portY,
z: baffleZ - 0.03,
size: [wooferR * 0.62, 0.034, wooferR * 0.62],
pitch: Math.PI / 2,
});
// ---- The grille. Horizontal slats standing proud of the baffle, with real
// gaps between them — the point of the whole asset.
const slats = Math.max(3, Math.round(p.slats));
const span = p.height - 0.03;
const pitch = span / slats;
for (let i = 0; i < slats; i++) {
bin.add(P.box(), mesh, {
y: pad + 0.015 + i * pitch,
z: baffleZ + 0.004,
size: [p.width - 0.018, pitch * 0.52, 0.004],
});
}
// Two uprights holding the slats, so the grille is a frame and not a stack.
for (const sx of [-1, 1]) {
bin.add(P.box(), mesh, {
x: (sx * (p.width - 0.018)) / 2,
y: pad + 0.015,
z: baffleZ + 0.004,
size: [0.005, span, 0.005],
});
}
// ---- The power LED, bottom-centre under the grille.
led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
y: pad + 0.008,
z: baffleZ + 0.006,
size: [0.006, 0.003, 0.006],
pitch: Math.PI / 2,
});
return deviceGroup("device.speaker.desk", bin, led);
},
});
/** Both device assets, for `index.ts` and for anything registering a subset. */
export const DEVICE_ASSETS = [deviceMicDesk, deviceSpeakerDesk] as const;
+146 -19
View File
@@ -1,23 +1,131 @@
/**
* Plants. A small one for a desk or a sill, and a tall one for a corner.
*
* Leaves are single quads in a double-sided `foliage` material rather than
* modelled solids: forty cards is forty quads, a modelled leaf is a hundred
* triangles each, and at the distance an office plant is ever seen the two look
* the same. They are laid out on the golden angle, which is what stops a ring of
* cards from reading as a ring, plus a little jitter from `ctx.rand` seeded
* per prop, so the plant on the third desk is the same plant on every reload.
* ### The shard problem, and what fixed it
*
* Neither takes a `colorKey`. A plant is the colour a plant is.
* Every leaf in here used to be a bare `P.panel()` an untextured rectangle in
* an opaque green material. That is fine in a thumbnail and catastrophic at eye
* height, and the live LA studio proved it: walk into the courtyard and the
* corner planting fills a third of the frame with flat green shards. It was the
* worst-looking asset in the product and no amount of lighting work was ever
* going to fix it, because the problem is the *silhouette* a rectangle has the
* wrong outline no matter how it is shaded.
*
* The fix is a coverage map. `foliage` now carries the `leafAlpha` cutout with
* `alphaTest 0.5` (`materials.ts`), so the leaf-shaped part of each quad is
* drawn and the rest is discarded. Three things follow from that, and all three
* are why this is a cutout rather than a blend:
*
* - the leaf still writes depth and still sorts like solid geometry, so a plant
* in front of a window does not have to be drawn in a particular order;
* - the shadow it casts is leaf-shaped, because three's depth material copies
* `alphaMap` and `alphaTest` across;
* - it survives `low` quality, because `leafAlpha` carries a resolution floor.
* "No maps" at low quality is a statement about *shading* cost; a cutout is
* one fetch and a discard, and the alternative at low quality is not a
* cheaper plant, it is the shard again.
*
* ### What the geometry still has to do
*
* A cutout only works if the quad's UVs run along the leaf. `leafAlpha` is drawn
* tip-at-top with the stem at the bottom, so **+V must run from the base of the
* quad to its tip**, which is exactly what `P.panel()` gives (0..1 over a
* standing rectangle, base on the floor) the panel was never the problem, the
* missing map was.
*
* Beyond that, two changes make a card read as a frond rather than as a
* postcard with a leaf printed on it. Blades are **curved**, by splitting each
* one into two or three cards that each pick up a little more pitch, so the leaf
* arches instead of standing dead straight; and each blade gets a small **roll**
* so it is not edge-on flat to its own stem. Both are geometry the alpha map
* cannot supply, and both cost one extra quad per leaf at most.
*
* Leaves stay cards rather than modelled solids: a modelled leaf is a hundred
* triangles and forty of them is a plant nobody can afford in a room that also
* has furniture in it.
*
* Neither asset takes a `colorKey`. A plant is the colour a plant is.
*/
import { defineAsset } from "../kit.ts";
import { defineAsset, type AssetContext } from "../kit.ts";
import type { SurfaceMaterial } from "../materials.ts";
import { MeshBin } from "../parts.ts";
import { clamp, jitter } from "./common.ts";
/** ~137.5°, the angle a real plant puts between successive leaves. */
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
/**
* One arching blade, as `segments` cards laid end to end.
*
* Exported because `studio.ts` plants a courtyard trough out of the same
* blades: two files drawing foliage two different ways is how a library starts
* looking like two libraries.
*
* Each card starts where the last one ended and carries a little more pitch, so
* the blade curves over instead of leaving in a straight line. The arithmetic is
* the only fiddly part: a card placed with `pitch` has its base at the given
* point and its tip `length` away along the pitched direction, and pitch here
* runs in the *yawed* frame so `advance` steps along the plant's local Z
* (outward, in the blade's own yaw) and up by whatever the pitch leaves.
*
* `pitch` is measured from vertical, matching the rest of the file: 0 stands
* straight up and π/2 lies flat.
*/
export function leafBlade(
bin: MeshBin,
ctx: AssetContext,
material: SurfaceMaterial,
b: {
x?: number;
y: number;
z?: number;
/** Total length along the blade. */
length: number;
/** Width of the widest card. Tapers toward the tip. */
width: number;
yaw: number;
/** Pitch at the base, radians from vertical. */
pitch: number;
/** How much further the blade has arched over by its tip. */
droop: number;
roll?: number;
segments: number;
},
): void {
const segments = Math.max(1, Math.round(b.segments));
const step = b.length / segments;
const sin = Math.sin(b.yaw);
const cos = Math.cos(b.yaw);
// Base of the current card, in the plant's own frame.
let x = b.x ?? 0;
let y = b.y;
let z = b.z ?? 0;
for (let i = 0; i < segments; i++) {
const t = i / segments;
const pitch = b.pitch + b.droop * t * t;
// Cards narrow toward the tip; the last one is roughly half the first.
const width = b.width * (1 - 0.42 * t);
bin.add(ctx.parts.panel(), material, {
x,
y,
z,
size: [width, step, 1],
yaw: b.yaw,
pitch,
roll: b.roll ?? 0,
});
// Where this card's tip is: `step` along the pitched direction, resolved
// back into the plant's frame through the blade's yaw.
const rise = Math.cos(pitch) * step;
const reach = Math.sin(pitch) * step;
y += rise;
x -= sin * reach;
z -= cos * reach;
}
}
type PottedParams = {
/** Overall height including the pot. */
height: number;
@@ -49,6 +157,8 @@ export const plantPotted = defineAsset<PottedParams>({
y: potH - 0.03,
size: [p.potDiameter, 0.03, p.potDiameter],
});
// The soil. A disc rather than nothing: without it you see straight down
// into an open cylinder from the dollhouse camera.
bin.add(P.disc(14), pot, {
y: potH - 0.012,
size: [p.potDiameter * 0.9, 1, p.potDiameter * 0.9],
@@ -59,13 +169,16 @@ export const plantPotted = defineAsset<PottedParams>({
for (let i = 0; i < count; i++) {
const t = i / count;
const length = reach * (0.55 + 0.45 * (1 - t)) * (0.85 + ctx.rand() * 0.3);
bin.add(P.panel(), leaf, {
leafBlade(bin, ctx, leaf, {
y: potH - 0.02,
size: [length * 0.34, length, 1],
length,
width: length * 0.42,
yaw: i * GOLDEN_ANGLE + jitter(ctx.rand, 0.2),
// Outer leaves lean further out; the middle ones stand up. Pitch runs
// in the yawed frame, so this is a lean along whichever way it faces.
pitch: 0.25 + t * 0.8 + jitter(ctx.rand, 0.12),
// Outer leaves lean further out; the middle ones stand up.
pitch: 0.22 + t * 0.7 + jitter(ctx.rand, 0.12),
droop: 0.42 + t * 0.3,
roll: jitter(ctx.rand, 0.2),
segments: 2,
});
}
@@ -81,8 +194,10 @@ type TallParams = {
};
/** Pitch of the lowest whorl and of the highest. The bottom droops, the top stands. */
const TALL_DROOP = 1.35;
const TALL_CROWN = 0.6;
const TALL_DROOP = 1.2;
const TALL_CROWN = 0.5;
/** Extra arch a blade picks up between its base and its tip. */
const TALL_ARCH = 0.5;
/**
* The one piece of arithmetic `footprint` and `build` have to agree on.
@@ -92,6 +207,10 @@ const TALL_CROWN = 0.6;
* apart. Written the first time, they had the stated height was a fifth
* taller than the plant, because a leaf at 60° from vertical contributes
* `cos 60°` of its length and not all of it.
*
* The blades now arch as well as lean, so the effective angle used here is the
* *mid-blade* one base pitch plus a third of the arch which is the honest
* average of a curve that starts at one angle and finishes at another.
*/
function tallCanopy(p: TallParams): {
potHeight: number;
@@ -102,14 +221,16 @@ function tallCanopy(p: TallParams): {
const potHeight = clamp(p.height * 0.26, 0.24, 0.55);
const trunk = (p.height - potHeight) * 0.55;
const rise = p.height - potHeight - trunk;
const crownAngle = TALL_CROWN + TALL_ARCH / 3;
const droopAngle = TALL_DROOP + TALL_ARCH / 3;
// The top whorl starts a third of the way up the canopy and reaches the rest
// of the way with the vertical component of one leaf.
const leaf = (rise * 0.66) / Math.cos(TALL_CROWN);
const leaf = (rise * 0.66) / Math.cos(crownAngle);
return {
potHeight,
trunk,
leaf,
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(TALL_DROOP)),
spread: Math.max(p.potDiameter, 2 * leaf * Math.sin(droopAngle)),
};
}
@@ -162,11 +283,17 @@ export const plantTall = defineAsset<TallParams>({
const y = potH + trunkH + rise * 0.34 * t;
const blades = 7 - tier;
for (let i = 0; i < blades; i++) {
bin.add(P.panel(), leaf, {
leafBlade(bin, ctx, leaf, {
y,
size: [leafLen * 0.26, leafLen * (0.85 + ctx.rand() * 0.3), 1],
length: leafLen * (0.85 + ctx.rand() * 0.3),
width: leafLen * 0.3,
yaw: n++ * GOLDEN_ANGLE + jitter(ctx.rand, 0.25),
pitch: TALL_DROOP + (TALL_CROWN - TALL_DROOP) * t + jitter(ctx.rand, 0.15),
droop: TALL_ARCH,
roll: jitter(ctx.rand, 0.22),
// Three cards on the long blades of a corner plant: this is the one
// the camera gets closest to, and it is the one that was broken.
segments: 3,
});
}
}
+33 -4
View File
@@ -163,7 +163,9 @@ export const kitchenRun = defineAsset<KitchenRunParams>({
defaults: { width: 3.6, depth: 0.62, counterHeight: 0.91, height: 2.16, bays: 5 },
footprint(p) {
return { width: p.width, depth: p.depth, height: p.height, clearance: 1 };
// The worktop oversails the carcass by 50 mm at the front, which is what a
// worktop does and what the stated depth was missing.
return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 1 };
},
build(p, ctx) {
@@ -195,11 +197,14 @@ export const kitchenRun = defineAsset<KitchenRunParams>({
size: [0.012, 0.12, 0.012],
});
}
// The worktop's front edge is at hand height and is the one edge in a
// kitchen you actually touch, so it gets the full 6 mm.
slab(bin, ctx, top, {
y: p.counterHeight - 0.045,
width: p.width,
depth: p.depth + 0.05,
thickness: 0.045,
chamfer: 0.006,
});
// Backsplash and upper units stop short over the sink to create a focal bay.
@@ -282,6 +287,7 @@ export const kitchenIsland = defineAsset<IslandParams>({
width: p.length,
depth: p.depth,
thickness: 0.05,
chamfer: 0.006,
});
for (const sx of [-1, 1]) {
bin.add(ctx.parts.rod(), ctx.materials.get("metalTrim"), {
@@ -304,7 +310,10 @@ export const storageWardrobe = defineAsset<WardrobeParams>({
defaults: { width: 1.8, depth: 0.58, height: 2.18, doors: 3 },
footprint(p) {
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.75 };
// Doors and pulls stand proud of the carcass at +Z, and the stated depth has
// to include them or a wardrobe pushed flush to a wall puts its handles
// through the plaster.
return { width: p.width, depth: p.depth + 0.03, height: p.height, clearance: 0.75 };
},
build(p, ctx) {
@@ -349,7 +358,16 @@ export const seatStool = defineAsset<StoolParams>({
defaults: { diameter: 0.4, seatHeight: 0.68, back: true },
footprint(p) {
return { width: p.diameter + 0.1, depth: p.diameter + 0.12, height: p.back ? 0.96 : p.seatHeight, clearance: 0.35 };
// The back reaches 380 mm above the seat pan, and the height has to be
// derived from `seatHeight` rather than hard-coded: it was 0.96, which was
// right for the default 0.68 seat and 100 mm short of the geometry, and
// wrong by an arbitrary amount for any other seat height a pack asked for.
return {
width: p.diameter + 0.1,
depth: p.diameter + 0.12,
height: p.back ? p.seatHeight + 0.38 : p.seatHeight,
clearance: 0.35,
};
},
build(p, ctx) {
@@ -404,7 +422,18 @@ export const lightFloor = defineAsset<FloorLightParams>({
defaults: { height: 1.55, reach: 0.42, shadeDiameter: 0.32 },
footprint(p) {
return { width: Math.max(0.42, p.shadeDiameter), depth: p.reach + p.shadeDiameter / 2, height: p.height };
// The one asset in the kit that is genuinely not centred on its own origin:
// the base is under the column and the shade cantilevers out to Z over it.
// The footprint has to enclose both, so it is the reach plus half a shade in
// front and the base's own radius behind — which is 180 mm the stated depth
// used to be missing, and 180 mm is enough to push a floor lamp through a
// wall.
const base = 0.18;
return {
width: Math.max(0.42, p.shadeDiameter),
depth: p.reach + p.shadeDiameter / 2 + base,
height: p.height,
};
},
build(p, ctx) {
+58 -1
View File
@@ -17,14 +17,35 @@
* somebody walks through a wall leave the collider with no gap where the door
* is.
*
* Twenty-five assets is not a furniture catalogue and is not trying to be. It is
* Forty assets is not a furniture catalogue and is not trying to be. It is
* the set that gets a real floor plate looking like an office: somewhere to
* work, somewhere to sit, somewhere to meet, somewhere to put things, something
* to look at, something alive, and light.
*
* ### Why the count keeps going up, and why more *instances* would not have done
*
* Fifteen of these arrived at once thirteen studio props and two devices and
* the reason is a property of `furnish.ts` rather than an appetite for
* furniture. Props are batched by **(asset, colorKey)** and every instance in a
* batch is geometrically identical, `ctx.rand` included. So a room that looks
* sparse cannot be fixed by placing more of what is already there: ten more
* shelves are the same shelf with the same books on it in ten places. Apparent
* density is a function of how many distinct *kinds* a floor uses, and that
* makes the length of this list the lever a pack author actually has.
*
* The catalogue is now three groups:
*
* - the **office kit** (`desks`, `seating`, `tables`, `storage`, `screens`,
* `lighting`, `surfaces`, `greenery`) a floor plate of desks and meetings;
* - the **habitat kit** (`habitat.ts`) a studio home rather than an office;
* - the **studio kit** (`studio.ts`) and the **devices** (`devices.ts`) a
* working production floor: a courtyard, a robotics lab, a model loft, and
* the two instruments the smart-device layer drives.
*/
import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts";
import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts";
import { DEVICE_ASSETS, deviceMicDesk, deviceSpeakerDesk } from "./devices.ts";
import { plantPotted, plantTall } from "./greenery.ts";
import {
bedPlatform,
@@ -40,6 +61,22 @@ import { robotOptimus } from "./optimus.ts";
import { screenMonitor, screenWallDisplay } from "./screens.ts";
import { seatLounge, seatTaskChair } from "./seating.ts";
import { storageLocker, storageShelf } from "./storage.ts";
import {
STUDIO_ASSETS,
acousticBaffle,
benchLab,
benchSlat,
cameraTripod,
canopyParasol,
cartTool,
caseStack,
dividerSlat,
dockRobot,
lightSoftbox,
planterTrough,
rackEquipment,
shelfWall,
} from "./studio.ts";
import { rug, whiteboard } from "./surfaces.ts";
import { tableMeeting, tableSide } from "./tables.ts";
@@ -69,6 +106,8 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [
storageWardrobe,
seatStool,
lightFloor,
...STUDIO_ASSETS,
...DEVICE_ASSETS,
];
/** Register the built-in catalogue into a registry. Defaults to the shared one. */
@@ -104,4 +143,22 @@ export {
storageWardrobe,
seatStool,
lightFloor,
planterTrough,
benchSlat,
canopyParasol,
benchLab,
rackEquipment,
cartTool,
dockRobot,
caseStack,
lightSoftbox,
cameraTripod,
acousticBaffle,
dividerSlat,
shelfWall,
deviceMicDesk,
deviceSpeakerDesk,
};
export { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "./devices.ts";
export { STUDIO_ASSET_IDS } from "./studio.ts";
+29 -8
View File
@@ -58,11 +58,25 @@
* darkens their screen surrounds darkens the robot's face, which is the right
* coupling rather than a coincidental one.
*
* `metalTrim` was tried as a third material for the joint barrels and dropped.
* The office rig carries no environment map, so a `metalness: 0.85` role has
* nothing to reflect and renders as a dull dark grey indistinguishable from
* `metalTrim` was tried as a third material for the joint barrels and dropped,
* and then taken back up. The original reasoning was sound and is no longer
* true, so it is worth recording both halves rather than quietly deleting one:
* the office rig carried **no environment map**, so a `metalness: 0.85` role had
* nothing to reflect and rendered as a dull dark grey indistinguishable from
* `screenBezel` at ten metres while costing another mesh in nine of the eleven
* groups. Two materials, eighteen meshes.
* groups.
*
* `engine/environmentRig.ts` now supplies one. A metal barrel therefore picks up
* the room around it and reads as a machined surface rather than as a darker
* patch of plastic, which is the whole difference between "a robot" and "a
* figurine of a robot" at the distance somebody stands next to one.
*
* The cost was re-scoped rather than re-accepted, though. `metalTrim` is used
* **only for the four joint barrels** hip axle, shoulder, knee, elbow so it
* appears in four groups rather than nine, and the figure is three materials and
* twenty-two meshes rather than three materials and twenty-seven. The sole, the
* ankle and the visor stay `screenBezel`, because a sole is rubber, an ankle is a
* gap and a visor is glass, and none of the three wants a specular ring on it.
*
* ### The indexed/non-indexed rule bites here harder than anywhere else
*
@@ -245,6 +259,12 @@ export interface OptimusRig {
interface Skin {
shell: SurfaceMaterial;
frame: SurfaceMaterial;
/**
* The joint barrels, and nothing else. Kept separate from `frame` so that
* widening its use is a deliberate act with a visible cost in the mesh count
* rather than a one-character change see the header.
*/
metal: SurfaceMaterial;
}
/** A point in whichever joint frame the emitter is drawing into. */
@@ -315,7 +335,7 @@ function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void {
// is the gap rule 4 in the header is about and the one the hip did not have.
// Shorten it again and the hip goes back to one unbroken pale mass from the
// waist to the knee, which is what a mannequin looks like.
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14);
barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.14);
}
/**
@@ -385,7 +405,7 @@ function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void {
// robot and 80 draw calls for a crowd of four instead of 72. Eight draw
// calls for a 24 mm band of dark under a cap that already reads as a
// separate piece is not the trade. A fatter drum is free.
barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17);
barrel(bin, P, s.metal, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.135, 0.17);
}
}
@@ -602,7 +622,7 @@ function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void {
*/
function emitShin(bin: MeshBin, P: PartBin, s: Skin): void {
const drop = OPTIMUS.kneeY - OPTIMUS.ankleY;
barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
barrel(bin, P, s.metal, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] });
@@ -707,7 +727,7 @@ const DIGITS = [
*/
function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void {
const drop = OPTIMUS.elbowY - OPTIMUS.wristY;
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
barrel(bin, P, s.metal, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] });
// Wider than the palm below it and narrower than the forearm above, in that
// order. At 0.062 it was narrower than both, which put a 6 mm slot of
@@ -779,6 +799,7 @@ export function buildOptimus(ctx: AssetContext): OptimusRig {
const skin: Skin = {
shell: ctx.materials.get("paper"),
frame: ctx.materials.get("screenBezel"),
metal: ctx.materials.get("metalTrim"),
};
const root = new THREE.Group();
+162 -19
View File
@@ -7,14 +7,110 @@
* `mount` height, because how high a display hangs is a property of the display
* and not of the room it is in.
*
* Neither takes a `colorKey`. A screen is bezel and glass, and there is no part
* of it that anybody wants to be the colour of a team.
* ### A screen is three surfaces, not one
*
* Both of these used to be a bezel with a flat `screenDisplay` panel glued to
* it, and on the live site every display in the building was one uniform glowing
* rectangle. Under the tone curve `stage.ts` now runs it was worse than uniform:
* a 40%-emissive white panel clips, so a monitor, a wall display and a whiteboard
* were all the same shade of blown-out white.
*
* The fix is to separate the three things a display physically is:
*
* - **`screenBezel`** the moulded surround, a lit surface like any other;
* - **`screenDisplay`** the *dark* panel, the black border of glass around the
* active area and what an off screen looks like;
* - **`screenContent`** the active area, carrying the `screenUI` drawing as
* both `map` and `emissiveMap`. That second binding is the whole point: with
* a flat glow the drawn interface is a pattern printed on a lamp, and with the
* map on `emissiveMap` the lit pixels emit and the dark chrome between them
* does not. It is the difference between a monitor and a light box.
*
* ### Where the layout comes from, and why it cannot be random
*
* `screenUI` draws `SCREEN_UI_VARIANTS` different layouts, and a wall of screens
* showing the same one is the same defect one level down. But an asset cannot
* roll for a layout per instance: `furnish.ts` batches props **per kind** and
* draws `ctx.rand` once for the whole batch, so twelve monitors in one batch
* would roll once between them and get one layout anyway.
*
* So the layout arrives as *authored data*: it is derived from the prop's
* `colorKey`, which is already part of the batch key. A pack that writes
* `colorKey: "ui-b"` on half its monitors gets two batches, two materials and
* two layouts, and gets them deterministically the same pack renders the same
* wall of screens on every reload. `colorKey` is opaque here in exactly the way
* `ARCHITECTURE.md` §3.3 requires: this file will never learn that `"ui-b"` means
* anything, it only hashes it.
*
* Neither asset tints. A screen is bezel and glass, and there is no part of it
* anybody wants to be the colour of a team.
*/
import { defineAsset } from "../kit.ts";
import { defineAsset, type AssetContext } from "../kit.ts";
import { MeshBin } from "../parts.ts";
import { alongFacing } from "./common.ts";
/**
* The layout this instance's batch shows, from its `colorKey`.
*
* FNV-1a, the same four lines `furnish.ts` uses to seed a batch, and for the
* same reason: any stable string-to-number would do and this one needs no
* dependency. An absent key gives layout 0 rather than a random one, so a pack
* that says nothing gets the plainest screen rather than an arbitrary one.
*/
function layoutFor(ctx: AssetContext): number {
const key = ctx.colorKey;
if (!key) return 0;
let h = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/**
* The dark panel and the lit content face, as two quads a fraction of a
* millimetre apart.
*
* The dark one is the full active-area rectangle and the lit one is inset by the
* black border every panel has, so the content never runs to the edge of the
* glass which is the single cue that separates a screen from a sheet of paper
* with a picture on it. `screenContent` is `DoubleSide`-free and faces +Z, so
* both quads are drawn only from the front.
*/
function displayFace(
bin: MeshBin,
ctx: AssetContext,
face: {
y: number;
z: number;
width: number;
height: number;
pitch?: number;
/** Black border between the glass edge and the drawn content, metres. */
border: number;
},
): void {
const pitch = face.pitch ?? 0;
const dark = ctx.materials.get("screenDisplay");
const lit = ctx.materials.variant("screenContent", layoutFor(ctx));
bin.add(ctx.parts.panel(), dark, {
y: face.y,
z: face.z,
size: [face.width, face.height, 1],
pitch,
});
const proud = alongFacing(pitch, 0.0015);
bin.add(ctx.parts.panel(), lit, {
y: face.y + face.border + proud.y,
z: face.z + proud.z,
size: [face.width - face.border * 2, face.height - face.border * 2, 1],
pitch,
});
}
type MonitorParams = {
/** Bezel width, metres. 0.56 is a 24-inch panel. */
width: number;
@@ -40,23 +136,57 @@ export const screenMonitor = defineAsset<MonitorParams>({
const trim = ctx.materials.get("metalTrim");
const bezel = ctx.materials.get("screenBezel");
bin.add(P.box(), trim, { size: [p.width * 0.4, 0.016, 0.15] });
// `metalTrim` here is all `roundedBoxOf` and `screenBezel` is all
// `roundedBoxOf` too — see the indexed/non-indexed rule in `common.ts`. A
// 4 mm chamfer on the foot is what makes it catch a highlight along its edge
// instead of reading as a printed rectangle on the desk.
bin.add(P.roundedBoxOf(p.width * 0.42, 0.014, 0.16, 0.004), trim, {
size: 1,
z: -0.008,
});
// The neck runs a few centimetres past the bottom of the bezel, so the
// joint is hidden behind the panel however far it is tilted.
bin.add(P.box(), trim, { y: 0.01, z: -0.02, size: [0.055, p.standHeight + 0.07, 0.045] });
bin.add(P.roundedBoxOf(0.052, p.standHeight + 0.07, 0.042, 0.008), trim, {
y: 0.01,
z: -0.02,
size: 1,
});
const baseY = p.standHeight + 0.02;
const pitch = -p.tilt;
const front = alongFacing(pitch, 0.014);
bin.add(P.roundedBox(0.03), bezel, {
const front = alongFacing(pitch, 0.013);
// The shell, and a shallower housing behind it. A monitor is not a slab: it
// is a thin panel with the electronics in a bulge behind the middle, and
// that bulge is what its silhouette from three-quarters is made of.
bin.add(P.roundedBoxOf(p.width, p.height, 0.022, 0.006), bezel, {
y: baseY,
size: [p.width, p.height, 0.024],
size: 1,
pitch,
});
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
y: baseY + 0.012 + front.y,
const back = alongFacing(pitch, -0.02);
bin.add(P.roundedBoxOf(p.width * 0.6, p.height * 0.55, 0.026, 0.01), bezel, {
y: baseY + p.height * 0.22 + back.y,
z: back.z,
size: 1,
pitch,
});
displayFace(bin, ctx, {
y: baseY + 0.011 + front.y,
z: front.z,
size: [p.width - 0.018, p.height - 0.026, 1],
width: p.width - 0.016,
height: p.height - 0.024,
pitch,
border: 0.008,
});
// Standby light, bottom-right of the chin as it is on almost every panel.
bin.add(P.box(), ctx.materials.get("deviceIndicator"), {
x: p.width * 0.36,
y: baseY + 0.005 + front.y,
z: front.z + 0.002,
size: [0.012, 0.004, 0.004],
pitch,
});
@@ -85,20 +215,33 @@ export const screenWallDisplay = defineAsset<WallDisplayParams>({
build(p, ctx) {
const P = ctx.parts;
const bin = new MeshBin();
const bezel = ctx.materials.get("screenBezel");
// The bracket. Boxes, and `metalTrim` uses nothing else in this asset.
bin.add(P.box(), ctx.materials.get("metalTrim"), {
y: p.mount + p.height / 2 - 0.16,
z: -0.045,
size: [0.44, 0.32, 0.04],
z: -0.05,
size: [0.44, 0.32, 0.03],
});
bin.add(P.roundedBox(0.02), ctx.materials.get("screenBezel"), {
for (const sx of [-1, 1]) {
bin.add(P.box(), ctx.materials.get("metalTrim"), {
x: sx * 0.19,
y: p.mount + p.height / 2 - 0.16,
z: -0.028,
size: [0.05, 0.3, 0.026],
});
}
bin.add(P.roundedBoxOf(p.width, p.height, 0.042, 0.008), bezel, {
y: p.mount,
size: [p.width, p.height, 0.05],
size: 1,
});
bin.add(P.panel(), ctx.materials.get("screenDisplay"), {
y: p.mount + 0.014,
z: 0.027,
size: [p.width - 0.024, p.height - 0.028, 1],
displayFace(bin, ctx, {
y: p.mount + 0.012,
z: 0.023,
width: p.width - 0.022,
height: p.height - 0.024,
border: 0.01,
});
return bin.build("screen.wall-display");
+13 -6
View File
@@ -40,10 +40,14 @@ export const storageShelf = defineAsset<ShelfParams>({
const bayH = (p.height - (bays + 1) * BOARD) / bays;
const inner = p.width - 2 * BOARD;
// Every part in the `shelf` material is a `roundedBoxOf`, uprights included,
// because the primitive class has to be uniform across a material and a
// chamfered board beside a sharp upright would look like a mistake anyway.
// A 2.5 mm radius: shelf boards are thin and anything larger reads as a
// moulded plastic unit rather than as a board.
for (const sx of [-1, 1]) {
bin.add(P.box(), board, {
x: sx * (p.width - BOARD) / 2,
size: [BOARD, p.height, p.depth],
bin.add(P.roundedBoxOf(BOARD, p.height, p.depth, 0.0025), board, {
x: (sx * (p.width - BOARD)) / 2,
});
}
bin.add(P.box(), ctx.materials.get("cabinet"), {
@@ -52,9 +56,8 @@ export const storageShelf = defineAsset<ShelfParams>({
});
for (let i = 0; i <= bays; i++) {
bin.add(P.box(), board, {
bin.add(P.roundedBoxOf(inner, BOARD, p.depth, 0.0025), board, {
y: i * (bayH + BOARD),
size: [inner, BOARD, p.depth],
});
}
@@ -112,7 +115,11 @@ export const storageLocker = defineAsset<LockerParams>({
footprint(p) {
// A door has to swing, and a locker with a metre of nothing in front of it
// is the difference between a corridor and a corridor you can use.
return { width: p.width, depth: p.depth, height: p.height, clearance: 0.9 };
//
// The stated depth includes the doors and their pulls, which stand 24 mm
// proud of the carcass at +Z. It did not, and a bank of lockers pushed
// flush to a wall by its own footprint put its handles through the plaster.
return { width: p.width, depth: p.depth + 0.05, height: p.height, clearance: 0.9 };
},
build(p, ctx) {
File diff suppressed because it is too large Load Diff
+99 -2
View File
@@ -8,7 +8,7 @@
import { defineAsset } from "../kit.ts";
import { MeshBin } from "../parts.ts";
import { panelSlab, tintable } from "./common.ts";
import { jitter, panelSlab, tintable } from "./common.ts";
type RugParams = {
width: number;
@@ -56,6 +56,8 @@ type WhiteboardParams = {
/** Floor to the bottom edge of the writing surface. */
mount: number;
tray: boolean;
/** Sticky notes and abstract marker strokes on the face. */
worked: boolean;
};
/**
@@ -66,11 +68,35 @@ type WhiteboardParams = {
* The Z face is skipped: it is against a wall, and drawing it would put a
* second sheet of whiteboard texture into the merge for a surface nobody can
* ever see.
*
* ### Why there is anything on it
*
* The live site shows this as "a large blank white rectangle", and it is the
* biggest single flat surface in a meeting room. The `whiteboard` texture draws
* faint ghosting from previous wipes, which is right and is not enough: what
* makes a board read as *used* is objects on it that catch their own light
* sticky notes standing a millimetre proud, and strokes with a shadow under
* them.
*
* So `worked` adds relief rather than more texture. Two millimetres is enough to
* cast a hairline shadow under the rig's key light, which is what separates a
* note stuck to a board from a coloured rectangle printed on one.
*
* Everything on it is **abstract**: rectangles and strokes, no glyphs, no words,
* no diagrams of anything in particular. That is `ARCHITECTURE.md` §3.1
* legible content on a board is either somebody's real work or a convincing
* imitation of it, and neither belongs in an Apache-2.0 repo. Read from two
* metres it says "a team used this room", which is the whole job.
*
* It is seeded from `ctx.rand`, so every board in one batch carries the same
* notes in the same places. That is the price `furnish.ts` documents and it is
* paid knowingly; a pack that wants two different boards authors two batches
* with different `colorKey`s, which is the same seam the screens use.
*/
export const whiteboard = defineAsset<WhiteboardParams>({
id: "tera:whiteboard",
label: "Whiteboard",
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true },
defaults: { width: 1.8, height: 1.2, mount: 0.9, tray: true, worked: true },
footprint(p) {
return { width: p.width, depth: 0.1, height: p.mount + p.height };
@@ -102,6 +128,77 @@ export const whiteboard = defineAsset<WhiteboardParams>({
});
}
if (p.worked) {
// The writing surface, inset from the frame — the same rectangle the
// panel above occupies, minus its border.
const faceX = p.width - 0.14;
const faceY = p.height - 0.14;
const originX = -faceX / 2;
const originY = p.mount + 0.06;
const face = 0.012;
// Two columns of sticky notes. A grid rather than a scatter, because a
// board that has been worked on has structure on it and a scatter reads as
// confetti.
const notes = ctx.materials.get("accent");
const pale = ctx.materials.get("paper");
for (let column = 0; column < 3; column++) {
const rows = 2 + Math.floor(ctx.rand() * 2);
for (let row = 0; row < rows; row++) {
const size = 0.07 + ctx.rand() * 0.02;
bin.add(P.box(), ctx.rand() < 0.55 ? notes : pale, {
x: originX + faceX * (0.62 + column * 0.13) + jitter(ctx.rand, 0.008),
y: originY + faceY * (0.62 - row * 0.19) + jitter(ctx.rand, 0.008),
z: face,
size: [size, size, 0.002],
roll: jitter(ctx.rand, 0.06),
});
}
}
// Marker work on the left two thirds: a few boxes and the strokes joining
// them. Thin dark slabs, standing proud enough to catch an edge highlight.
const ink = ctx.materials.get("screenBezel");
const boxes: [number, number][] = [];
for (let i = 0; i < 3; i++) {
const bx = originX + faceX * (0.1 + i * 0.16);
const by = originY + faceY * (0.28 + (i % 2) * 0.3);
boxes.push([bx, by]);
const w = 0.16 + ctx.rand() * 0.06;
const h = 0.09 + ctx.rand() * 0.03;
// Four strokes rather than a filled rectangle: a drawn box is an
// outline, and a solid one reads as a sticker.
for (const sy of [-1, 1]) {
bin.add(P.box(), ink, { x: bx, y: by + (sy * h) / 2, z: face, size: [w, 0.006, 0.002] });
}
for (const sx of [-1, 1]) {
bin.add(P.box(), ink, { x: bx + (sx * w) / 2, y: by, z: face, size: [0.006, h, 0.002] });
}
}
for (let i = 0; i < boxes.length - 1; i++) {
const from = boxes[i];
const to = boxes[i + 1];
if (!from || !to) continue;
const dx = to[0] - from[0];
const dy = to[1] - from[1];
bin.add(P.box(), ink, {
x: from[0] + dx / 2,
y: from[1] + dy / 2,
z: face,
size: [Math.hypot(dx, dy), 0.005, 0.002],
roll: Math.atan2(dy, dx),
});
}
// One underlined heading bar across the top, which is what the eye reads
// as "this board has a subject" without anything being legible.
bin.add(P.box(), ink, {
x: originX + faceX * 0.26,
y: originY + faceY * 0.88,
z: face,
size: [faceX * 0.4, 0.008, 0.002],
});
}
if (p.tray) {
bin.add(P.box(), trim, {
y: p.mount - 0.03,
+10 -1
View File
@@ -51,7 +51,16 @@ export const tableMeeting = defineAsset<MeetingParams>({
return bin.build("table.meeting");
}
slab(bin, ctx, top, { y: deckY, width: p.length, depth: p.width, thickness: TOP });
// A 5 mm edge radius. A 2.4 m board is the longest specular highlight in the
// room and it is worth having; the round top a few lines above already has a
// circular edge for free, which is why only this branch asks for one.
slab(bin, ctx, top, {
y: deckY,
width: p.length,
depth: p.width,
thickness: TOP,
chamfer: 0.005,
});
if (p.legs === "post") {
for (const sx of [-1, 1]) {
+54 -2
View File
@@ -33,8 +33,36 @@ import { DEFAULT_PALETTE } from "../engine/terrain.ts";
import type { ScenePalette } from "../engine/types.ts";
import type { SurfaceRole } from "./materials.ts";
/** How far outside the city's lightness range an interior role may sit. */
export const LIGHTNESS_HEADROOM = 0.14;
/**
* How far outside the city's lightness range an interior role may sit.
*
* ### Why this is 0.22 and not 0.14
*
* It moved when `stage.ts` took the renderer off `NoToneMapping`. The old value
* was set against a renderer that clipped at linear 1.0, and under a clipping
* renderer the top of the range is not a range at all every role above about
* 0.9 albedo, lit by a sun the atmosphere drives past 2.3, displayed as exactly
* the same white. Widening the band would have bought darker darks and, at the
* other end, more roles indistinguishable from each other. So the number was
* held down, and roles that wanted to be genuinely dark `screenBezel` at
* L0.24, `chairBase`, `deviceShell` were clamped up into a mid-grey they did
* not want to be.
*
* ACES's shoulder gives the top two stops back: linear 1.0, 2.0 and 4.0 now
* display at about 0.90, 0.95 and 0.98 and stay separable all the way up. With
* the top recoverable, the band can be widened for the sake of the bottom
* without the top collapsing, and 0.22 is what lets the darkest roles reach
* L0.17 a charcoal, which is what a screen bezel and a microphone body
* actually are.
*
* One asymmetry worth knowing, since this is documented as one number applied to
* both ends. `bandOf` clamps `maxL` at 1.0, and the city palette's lightest
* entry (`skyHorizon`, L0.89) already reached that ceiling at 0.14. So in
* practice this number only ever bites at the dark end. It is still written as
* one number, because the moment it becomes two somebody will tune them
* independently and the band stops meaning anything.
*/
export const LIGHTNESS_HEADROOM = 0.22;
/** One role's derivation: a city colour, and how far to move it. */
export interface RoleShift {
@@ -103,6 +131,30 @@ export const ROLE_SHIFTS: Record<SurfaceRole, RoleShift> = {
lightDiffuser: { from: "skyHorizon", dh: 6, ds: -0.2, dl: 0.1 },
whiteboard: { from: "shore", dh: 8, ds: -0.03, dl: 0.26 },
// Devices
//
// A desk microphone and a monitor speaker are the two darkest objects in a
// studio and they are dark for a reason that is not styling: a hot LED and a
// level meter have to read against their own body from three metres away. The
// shell therefore goes as far down as the widened band allows (L≈0.20), and
// the grille sits a hair under it so the two do not merge into one silhouette.
//
// `deviceIndicator` is the one role here whose *default* colour barely
// matters. It descends from the city's park green at high lightness, which is
// a credible "powered, idle" lamp, but the device render layer tints it per
// state through `MaterialRegistry.tinted()` and that path bypasses the band
// entirely. Authoring a saturated red here instead would not survive `bandOf`
// anyway — saturation is clamped hard, by design, and an LED is exactly the
// kind of thing that would talk somebody into softening that rule.
deviceShell: { from: "flats", dh: 2, ds: -0.03, dl: -0.4 },
deviceMesh: { from: "upland", dh: 6, ds: -0.04, dl: -0.34 },
deviceIndicator: { from: "park", dh: -6, ds: 0.06, dl: 0.18 },
// Near-white on purpose: `screenContent` carries the one texture in the
// library that is *not* neutral (see `screenUI` in textures.ts), so this
// colour has to get out of its way. A mid-grey here would multiply the drawn
// interface down into mud.
screenContent: { from: "shore", dh: 2, ds: -0.05, dl: 0.3 },
// Objects
foliage: { from: "park", dh: 4, ds: 0.06, dl: -0.06 },
planter: { from: "shore", dh: -4, ds: 0.02, dl: -0.06 },
+94 -37
View File
@@ -63,57 +63,93 @@ export class PartBin {
* A box with rounded vertical corners and bevelled top and bottom cushions,
* chair shells, monitor bodies, anything moulded.
*
* `radius` is a *fraction of the unit*, and it does not survive non-uniform
* scaling: a 0.06 rounded box scaled to 2 × 0.1 × 1 has visibly oval corners
* on two sides. Ask for a radius near the one you will end up with, or use
* `box()` and accept the sharp edge.
* `radius` is a fraction of the unit and is only correct while the part stays
* cubic, because a corner is round in *object* space and a non-uniform scale
* turns a circle into an ellipse. That is not a caveat you can design around
* almost nothing in an office is a cube and it is why `seating.ts` gave up
* and went back to sharp boxes.
*
* **Use `roundedBoxOf` instead**, which takes the finished metres and gets a
* genuinely circular corner at any proportion. This one stays for the parts
* that really are cubic, and because it is the cache entry `roundedBoxOf(1, 1,
* 1, r)` resolves to anyway.
*/
roundedBox(radius = 0.06): THREE.BufferGeometry {
const bevel = Math.min(0.24, Math.max(0.01, radius));
return this.memo(`rounded:${mm(bevel)}`, () => {
const half = 0.5 - bevel;
const r = Math.min(half * 0.98, bevel * 2);
const shape = new THREE.Shape();
shape.moveTo(-half + r, -half);
shape.lineTo(half - r, -half);
shape.quadraticCurveTo(half, -half, half, -half + r);
shape.lineTo(half, half - r);
shape.quadraticCurveTo(half, half, half - r, half);
shape.lineTo(-half + r, half);
shape.quadraticCurveTo(-half, half, -half, half - r);
shape.lineTo(-half, -half + r);
shape.quadraticCurveTo(-half, -half, -half + r, -half);
return this.roundedBoxOf(1, 1, 1, radius);
}
// Extrusion runs along +Z and the bevel overhangs both ends, so the solid
// spans -bevel..1-bevel before it is stood up and dropped onto the floor.
const geo = new THREE.ExtrudeGeometry(shape, {
depth: 1 - 2 * bevel,
bevelEnabled: true,
bevelSize: bevel,
bevelThickness: bevel,
bevelSegments: 2,
curveSegments: 4,
});
geo.rotateX(-Math.PI / 2);
geo.translate(0, bevel, 0);
geo.computeVertexNormals();
return geo;
});
/**
* The same moulded box, authored at its finished size in metres.
*
* The corner radius is in **metres** and is applied after the proportions are
* known, so a 1.6 × 0.05 × 0.9 desk return gets a 12 mm round on all four
* corners rather than a 12 mm round on two of them and a 380 mm oval on the
* others. Place it with `size: 1` the geometry is already the right size,
* and scaling it is what this method exists to avoid.
*
* The radius is clamped to a fifth of the shortest side. Past that the bevel
* eats the extrusion (a 0.5 radius on a 0.9-thick shelf has no flat left to
* extrude) and `ExtrudeGeometry` starts emitting self-intersecting caps.
*/
roundedBoxOf(width: number, height: number, depth: number, radius = 0.06): THREE.BufferGeometry {
const shortest = Math.max(0.002, Math.min(width, height, depth));
const bevel = Math.min(shortest * 0.2, Math.max(0.001, radius));
return this.memo(
`rounded:${mm(width)}:${mm(height)}:${mm(depth)}:${mm(bevel)}`,
() => {
// The shape is drawn in the extruder's XY and the extrusion runs along
// its +Z; the `rotateX` below maps that to width × depth on the floor
// with the extrusion standing up as height.
const halfX = width / 2 - bevel;
const halfY = depth / 2 - bevel;
const r = Math.min(halfX * 0.98, halfY * 0.98, bevel * 2);
const shape = new THREE.Shape();
shape.moveTo(-halfX + r, -halfY);
shape.lineTo(halfX - r, -halfY);
shape.quadraticCurveTo(halfX, -halfY, halfX, -halfY + r);
shape.lineTo(halfX, halfY - r);
shape.quadraticCurveTo(halfX, halfY, halfX - r, halfY);
shape.lineTo(-halfX + r, halfY);
shape.quadraticCurveTo(-halfX, halfY, -halfX, halfY - r);
shape.lineTo(-halfX, -halfY + r);
shape.quadraticCurveTo(-halfX, -halfY, -halfX + r, -halfY);
// The bevel overhangs both ends of the extrusion, so the solid spans
// -bevel..height-bevel before it is stood up and dropped onto the floor.
const geo = new THREE.ExtrudeGeometry(shape, {
depth: height - 2 * bevel,
bevelEnabled: true,
bevelSize: bevel,
bevelThickness: bevel,
bevelSegments: 3,
curveSegments: 6,
});
geo.rotateX(-Math.PI / 2);
geo.translate(0, bevel, 0);
geo.computeVertexNormals();
return geo;
},
);
}
/** Unit-diameter cylinder, base on the floor. */
cylinder(segments = 16): THREE.BufferGeometry {
cylinder(segments = 20): THREE.BufferGeometry {
return this.memo(`cyl:${segments}`, () =>
new THREE.CylinderGeometry(0.5, 0.5, 1, segments).translate(0, 0.5, 0),
);
}
/**
* A six-sided cylinder. Legs, columns, pen barrels anything thin enough
* An eight-sided cylinder. Legs, columns, pen barrels anything thin enough
* that nobody will count the sides, which is most of the office.
*
* Six read as a hexagon on a chair column at desk distance, and the office
* measures 44,754 triangles against a 550,000 budget: eight is four extra
* triangles on the commonest part in the library and there is nowhere for the
* saving to go.
*/
rod(): THREE.BufferGeometry {
return this.cylinder(6);
return this.cylinder(8);
}
/** Unit-diameter cone, base on the floor. */
@@ -143,7 +179,7 @@ export class PartBin {
}
/** Unit-diameter disc lying in XZ, facing up. */
disc(segments = 24): THREE.BufferGeometry {
disc(segments = 32): THREE.BufferGeometry {
return this.memo(`disc:${segments}`, () =>
new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2),
);
@@ -294,6 +330,27 @@ export class MeshBin {
return this.add(parts.box(), material, place);
}
/**
* A moulded box with a true corner radius at whatever proportions `place.size`
* asks for the drop-in replacement for `box()` on anything that is not a
* sawn edge.
*
* The size is spent on the *geometry* rather than on the placement matrix,
* which is the whole trick: `parts.roundedBox()` scaled to 1.4 × 0.06 × 0.7
* has 42 mm corners on two sides and 3 mm on the others, and looking at that
* is why `seating.ts` reverted to sharp boxes. Everything else about the
* placement position, yaw, pitch, roll is passed through untouched.
*
* The cost is a cache entry per distinct size rather than one for the whole
* library, so this is for the parts a reader will see the silhouette of, not
* for a hundred randomised trinkets.
*/
rounded(material: THREE.Material, place: Placement, radius = 0.02): this {
const size = place.size ?? 1;
const [w, h, d] = typeof size === "number" ? [size, size, size] : size;
return this.add(parts.roundedBoxOf(w, h, d, radius), material, { ...place, size: 1 });
}
/** Number of parts waiting to be merged. Handy in an asset's own tests. */
get size(): number {
let n = 0;
+936 -42
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,12 +1,15 @@
export {
MODEL_X_METRICS,
MODEL_X_PAINTS,
LUMBRIDGE_EV_METRICS,
advanceModelXWheels,
buildModelX,
buildLumbridgeEV,
cloneModelX,
createModelXMaterials,
createModelXPaintPool,
disposeModelX,
disposeModelXPaintPool,
modelXInstanceParts,
setModelXSteering,
setModelXWheelRotation,
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
/**
* Where device readings come from and what to do when nowhere will say.
*
* The seam. Above it, the office scene and the device panel take a
* `DeviceSource` and never learn whether a server answered. Below it there are
* two strategies and one rule for choosing between them, and both the
* strategies and the rule live here so that no consumer has to hold an opinion
* about deployments.
*
* ### The rule
*
* **A studio is never dark.** If this deployment has a device source and this
* viewer may read it, the readings come over the API. Otherwise they come from
* `sim.ts`, running in this tab, labelled `synthetic: true` and `live: false`.
* That is the same shape `markers()` takes to `sample.ts` and `HttpFlights`
* takes to `SimulatedFlights`, and it is the same argument: an empty panel and
* a working panel look identical to a broken one, and the clone-and-run case in
* CONTRACT.md §0 is the commonest way this bundle is used.
*
* ### Reading is the demo; writing is the account
*
* `routes/devices.ts` refuses an anonymous read, and it should a reading is
* about a room somebody is standing in. But an anonymous visitor is the
* audience this product is designed for, so a refusal must not produce a dead
* instrument: it produces the local simulator, which is honest, alive and says
* in the panel exactly what it is.
*
* Commands do **not** get the same treatment across the boundary. On the API
* strategy a refused command is a refusal, reported as `null`, and the panel
* tells the person to sign in it is never quietly applied to a local copy,
* because a control that appears to work and changes nothing anybody else can
* see is worse than one that says no. On the simulated strategy a command is
* applied locally and openly: nothing there claims to be a real room.
*
* ### No timers
*
* Nothing here owns a `setInterval`. The simulated strategy is advanced by
* `tick(dt)` from whatever render loop already exists the same idiom
* `luminaires.ts` and `FlightLayer` use so there is no handle to leak, no
* work done in a tab nobody is looking at, and a test can step it by hand and
* get the same numbers every time. The API strategy's polling lives in
* `watchDevices` in `adapters/http.ts`, which is where every other watch's
* timer already is.
*/
import type { DevicesSourceId } from "../server/wire.ts";
import { createSimulatedDevices, type SimulatedDevices } from "./sim.ts";
import {
deviceStateSignature,
initialDeviceState,
normalizeDeviceCommand,
type DeviceCommand,
type DeviceDeclaration,
type DeviceState,
} from "./types.ts";
/**
* What the caller is handed, with the two provenance facts attached.
*
* `live` is "a deployment answered"; `synthetic` is "nobody observed this".
* They are independent and the interface needs both see `DeviceFeed` in
* `adapters/http.ts`, which carries the same pair over the wire and tabulates
* every combination that exists.
*/
export interface DeviceReading {
states: DeviceState[];
live: boolean;
synthetic: boolean;
source: DevicesSourceId;
attribution: string[];
}
export interface DeviceSource {
/** The latest reading. Never null and never a promise; safe in a render loop. */
current(): DeviceReading;
/**
* Advance simulated time by `dtSeconds`.
*
* A no-op on the API strategy, which is driven by its own poll. Call it every
* frame; it is a handful of arithmetic per device and it is what makes the
* meters move.
*/
tick(dtSeconds: number): void;
/** Ask again now. A no-op on the simulated strategy, which is always current. */
refresh(): void;
/**
* Send a command. Resolves to the resulting state, or `null` for a refusal.
*
* `null` is the honest answer to "you are not signed in", "that device is not
* in this office" and "that op is not one this device declared" alike. The
* caller shows one message; distinguishing them here would be a taxonomy
* nobody branches on.
*/
command(command: DeviceCommand): Promise<DeviceState | null>;
/**
* Which seats have somebody in them, so a microphone can respond to the room.
*
* Only the simulated strategy uses it a real bridge is reading real
* hardware and does not need to be told who is at the desk. Passing an empty
* array means "nobody", which is a different statement from never calling it
* at all; see `setOccupancy` in `sim.ts`.
*/
setOccupancy(seatIds: readonly string[]): void;
/** Stop any poll and drop any late answer. Idempotent. */
stop(): void;
}
/**
* Just enough of `TeraClient` to read and command devices.
*
* A structural slice rather than the whole client, so this module does not
* depend on the adapter's other nine methods and a test can hand in an object
* with two functions on it. `adapters/http.ts` satisfies it by construction.
*/
export interface DeviceClient {
watchDevices(
officeId: string,
onFeed: (feed: {
value: DeviceState[];
live: boolean;
synthetic: boolean;
source: DevicesSourceId;
attribution: string[];
}) => void,
): { current(): unknown; refresh(): void; stop(): void };
commandDevice(officeId: string, command: DeviceCommand): Promise<DeviceState | null>;
}
export interface DeviceSourceOptions {
/** What this office declared. The simulated strategy runs exactly these. */
declarations: readonly DeviceDeclaration[];
/** Fired whenever a reading a viewer could notice has changed. */
onReading?: (reading: DeviceReading) => void;
/** The API client, or `null` on a build with no server behind it. */
client?: DeviceClient | null;
officeId?: string;
/**
* Whether `/health` said this deployment has a device source at all.
*
* `false` means the API strategy is skipped without a request being made
* the same job `Feeds` does in `access.ts` for weather and markers, and for
* the same reason: a poll against a box whose answer is structurally empty is
* a request per TTL per tab, forever, to be told nothing.
*/
serverHasDevices?: boolean;
/** The simulator's seed. Same seed, same studio, on every machine. */
seed?: number;
/** Seconds per simulated step. Smaller is smoother and costs arithmetic. */
fixedStepSeconds?: number;
/** What simulated time zero means, for `observedAt`. Defaults to now. */
epochMs?: number;
}
/**
* How many fixed steps one `tick` may run.
*
* A tab that was backgrounded for ten minutes comes back with a `dt` of six
* hundred seconds, and catching up honestly would be six thousand steps in one
* frame a visible hitch to arrive at a meter reading nobody was watching
* accumulate. The excess is dropped rather than queued: simulated time is
* allowed to lag, because nothing downstream measures it against a clock.
*/
const MAX_STEPS_PER_TICK = 12;
const DEFAULT_FIXED_STEP_SECONDS = 0.1;
const DEFAULT_SEED = 8731;
/**
* A source that never has anything to say.
*
* For an office that declares no devices which is most offices, and every
* pack written before devices existed. Distinct from a simulated source with an
* empty declaration list only in that it is obviously nothing: no simulator is
* constructed and `tick` does no work at all.
*/
export function createNullDeviceSource(): DeviceSource {
const reading: DeviceReading = {
states: [],
live: false,
// An empty studio has invented nothing, but saying `synthetic: false` would
// read as "these zero readings were observed", which is a claim about a
// room. Nothing here observed anything.
synthetic: true,
source: "none",
attribution: [],
};
return {
current: () => reading,
tick: () => {},
refresh: () => {},
command: () => Promise.resolve(null),
setOccupancy: () => {},
stop: () => {},
};
}
/**
* The source for one office, choosing its own strategy.
*
* See the header for the rule. The choice is made once, at construction, from
* facts the caller already has there is no runtime failover, because a
* strategy that silently swapped a real bridge for a simulator mid-session
* would be the `first-party-sensor`/`simulated` confusion `DeviceProvenance`
* exists to prevent, arriving without a word in the interface.
*
* What *does* change at runtime is `live`: an API strategy whose deployment
* stops answering reports `live: false` and an empty list, exactly as
* `watchPresence` reports a floor it has stopped hearing about. It does not
* quietly start inventing readings instead.
*/
export function createDeviceSource(options: DeviceSourceOptions): DeviceSource {
const declarations = options.declarations;
if (declarations.length === 0) return createNullDeviceSource();
const useApi =
options.client !== null &&
options.client !== undefined &&
typeof options.officeId === "string" &&
options.officeId !== "" &&
options.serverHasDevices !== false;
return useApi
? apiSource(options.client as DeviceClient, options.officeId as string, declarations, options)
: simulatedSource(declarations, options);
}
/**
* Readings over the API.
*
* Thin on purpose: `watchDevices` already owns the poll, the back-off, the
* hidden-tab rule and the publish-on-change comparison, and duplicating any of
* it here would be a second place for the cadence to be wrong.
*
* The pre-connection reading is `initialDeviceState` for every declaration
* rather than an empty list, so the panel draws its instruments immediately
* powered off, meters at the floor, marked not live. An empty list would make
* the panel flicker into existence a poll later, and would be indistinguishable
* from an office that declared nothing.
*/
function apiSource(
client: DeviceClient,
officeId: string,
declarations: readonly DeviceDeclaration[],
options: DeviceSourceOptions,
): DeviceSource {
const observedAt = options.epochMs ?? Date.now();
/**
* The instruments, at rest, before anything has been heard and again
* whenever the deployment stops answering.
*
* A refusal must not empty the panel. An empty `states` means "this office
* declares no hardware", which is a different sentence from "nobody will tell
* me what the hardware is doing", and collapsing the two makes a panel
* disappear at exactly the moment somebody is wondering why it is not
* working. `live: false` is what says nobody answered; the readings alongside
* it are the at-rest defaults, every one of them `synthetic: true`, which is
* the same claim `initialDeviceState` is documented to make.
*/
const atRest = (): DeviceState[] => declarations.map((d) => initialDeviceState(d, observedAt));
let reading: DeviceReading = {
states: atRest(),
live: false,
synthetic: true,
source: "none",
attribution: [],
};
const watch = client.watchDevices(officeId, (feed) => {
reading = {
states: feed.live ? feed.value : atRest(),
live: feed.live,
synthetic: feed.synthetic,
source: feed.source,
attribution: feed.attribution,
};
options.onReading?.(reading);
});
return {
current: () => reading,
// The server's clock, not ours. Advancing a local simulation alongside a
// real feed would put two sets of numbers on one meter.
tick: () => {},
refresh: () => watch.refresh(),
async command(command: DeviceCommand): Promise<DeviceState | null> {
const declaration = declarations.find((d) => d.id === command.deviceId);
if (declaration === undefined) return null;
// Validated before it is sent as well as after it arrives. Not
// redundancy: this is what stops a slider that has been dragged past its
// own bounds from spending a round trip to be told no, and the server's
// copy of the check is there because a browser is not a boundary.
const normalized = normalizeDeviceCommand(declaration, command);
if (normalized === null) return null;
const state = await client.commandDevice(officeId, normalized);
// A command that landed changes the room, so ask for the new picture
// rather than waiting out the poll — and ask rather than patching the
// local copy, because the server is the authority on what the state now
// is and it may have clamped what we sent.
if (state !== null) watch.refresh();
return state;
},
setOccupancy: () => {},
stop: () => watch.stop(),
};
}
/**
* Readings from the simulator in this tab.
*
* The anonymous visitor's studio, the offline clone's studio, and the studio a
* self-hoster gets before they have configured anything. Everything it produces
* is `synthetic: true` and `live: false`, which is what the panel puts in front
* of the viewer alongside each declaration's own disclosure sentence.
*/
function simulatedSource(
declarations: readonly DeviceDeclaration[],
options: DeviceSourceOptions,
): DeviceSource {
const fixedStepSeconds = options.fixedStepSeconds ?? DEFAULT_FIXED_STEP_SECONDS;
const simulator: SimulatedDevices = createSimulatedDevices(declarations, {
seed: options.seed ?? DEFAULT_SEED,
fixedStepSeconds,
epochMs: options.epochMs ?? Date.now(),
});
let states = simulator.current();
let signature = deviceStateSignature(states);
let carried = 0;
let stopped = false;
const read = (): DeviceReading => ({
states,
live: false,
synthetic: true,
// `"sim"` and not `"none"`: something is producing these readings and the
// interface is entitled to name it. `"none"` is reserved for a source that
// produces nothing, which is what `createNullDeviceSource` is.
source: "sim",
attribution: [],
});
let reading = read();
function republish(): void {
states = simulator.current();
const next = deviceStateSignature(states);
reading = read();
// Only when something a viewer could see has moved. `observedAt` advances
// on every step and is deliberately not in the signature, or this would
// publish at the tick rate forever.
if (next === signature) return;
signature = next;
options.onReading?.(reading);
}
return {
current: () => reading,
tick(dtSeconds: number): void {
if (stopped || !Number.isFinite(dtSeconds) || dtSeconds <= 0) return;
carried += dtSeconds;
let steps = 0;
while (carried >= fixedStepSeconds && steps < MAX_STEPS_PER_TICK) {
simulator.stepFixed();
carried -= fixedStepSeconds;
steps += 1;
}
// Whatever is left over after the cap is dropped rather than banked; see
// `MAX_STEPS_PER_TICK`.
if (steps === MAX_STEPS_PER_TICK) carried = 0;
if (steps > 0) republish();
},
refresh(): void {
// Always current by construction — there is nothing to ask. Republished
// anyway so a caller that calls `refresh()` to force a redraw gets one.
republish();
},
command(command: DeviceCommand): Promise<DeviceState | null> {
if (stopped) return Promise.resolve(null);
const declaration = declarations.find((d) => d.id === command.deviceId);
if (declaration === undefined) return Promise.resolve(null);
const normalized = normalizeDeviceCommand(declaration, command);
if (normalized === null) return Promise.resolve(null);
simulator.command(normalized);
republish();
const applied = states.find((s) => s.id === normalized.deviceId) ?? null;
// A promise even though nothing is awaited, so the two strategies are the
// same shape to the caller and a panel does not have to know which it has.
return Promise.resolve(applied);
},
setOccupancy(seatIds: readonly string[]): void {
simulator.setOccupancy(seatIds);
},
stop(): void {
stopped = true;
},
};
}
+64
View File
@@ -0,0 +1,64 @@
/**
* The device surface, in one import.
*
* A barrel and nothing else no logic, no re-shaping, no defaults. It exists
* because four consumers want different thirds of this directory and none of
* them should have to know which file a name lives in: a pack authors against
* `types.ts`, the arena drives `sim.ts`, the office scene takes a
* `DeviceSource` from `adapter.ts`, and the panel wants the vocabulary tables
* from all three.
*
* **Nothing here imports THREE, the DOM or the network**, and that is a
* property worth stating rather than assuming: `src/index.ts` re-exports this
* onto the package surface, and `src/arena/` may import it, so a three.js
* import added anywhere under `src/devices/` would break both at once. The
* render layer for devices is `src/interiors/devices.ts`, which is deliberately
* *not* re-exported from here.
*/
export {
CANONICAL_CAPABILITIES,
CAPABILITY_READING,
DEVICE_CAPABILITIES,
DEVICE_COMMAND_OPS,
DEVICE_KINDS,
DEVICE_PROVENANCE,
DEVICE_RANGES,
deviceKindOfAssetId,
deviceStateSignature,
hasCapability,
initialDeviceState,
isDeviceCapability,
isDeviceCommandOp,
isDeviceKind,
isDeviceProvenance,
normalizeDeviceCommand,
validateDeviceDeclaration,
type DeviceAnchor,
type DeviceAssetId,
type DeviceCapability,
type DeviceCommand,
type DeviceCommandOp,
type DeviceCommandValue,
type DeviceDeclaration,
type DeviceKind,
type DeviceOffset,
type DeviceProvenance,
type DeviceRange,
type DeviceState,
} from "./types.ts";
export {
createSimulatedDevices,
type SimulatedDevices,
type SimulatedDevicesOptions,
} from "./sim.ts";
export {
createDeviceSource,
createNullDeviceSource,
type DeviceClient,
type DeviceReading,
type DeviceSource,
type DeviceSourceOptions,
} from "./adapter.ts";
+536
View File
@@ -0,0 +1,536 @@
/**
* Devices that behave like hardware without being any.
*
* A fixed-step, seeded state machine over a list of `DeviceDeclaration`s. Same
* seed, same declarations, same commands, same readings on every machine,
* forever, with no clock read anywhere inside `stepFixed()`.
*
* ### Why determinism is the whole design and not a nicety
*
* The arena imports **this module**, not a headless copy of it. `studio-ops-v1`
* observes a microphone's level, a speaker's programme and whether the desk in
* front of the mic is occupied, and it has to be able to replay a rollout and
* arrive at the identical checksum which is only true if every number here is
* a pure function of (seed, step, commands). So:
*
* - nothing calls `Date.now()`, `Math.random()` or `performance.now()`;
* - `stepFixed()` advances by exactly `fixedStepSeconds` and takes no argument
* that could vary with a frame rate;
* - the random stream is per device and is advanced only by `stepFixed`, so
* issuing a command does not shift the sequence a replay would draw;
* - `snapshot()` is plain JSON data and `restore()` reproduces the remainder
* of a run bit for bit.
*
* The property that makes the arena honest is that the renderer drives this
* exact object. A second implementation "for the trainer" would be a simulator
* nobody can see and a picture nobody can train against.
*
* ### What it models, and what it refuses to
*
* A microphone with a level that responds to whether anybody is at the desk it
* serves, and a speaker with an output meter that follows its volume. That is
* enough for the two things a viewer does with this watch a meter move, press
* mute and see it stop and enough for the cross-variable coupling the arena
* needs (a hot mic at an empty desk is waste; playback under an aircraft is
* noise).
*
* It does **not** invent a track title, an artist, a speaker identity or
* anything else that would read as a fact about a real room. `DeviceState`
* carries no field for one, deliberately: every reading here is `synthetic` and
* the panel says so, and the line between "a meter that moves" and "Marta is
* talking" is the line between a simulation and a claim. `DeviceProvenance` in
* `types.ts` is the same argument at the level of the whole device.
*
* ### Occupancy
*
* Who is at the desk is an **input**, not an invention, whenever the caller
* knows: `setOccupancy()` hands over the seats that are occupied and the mics
* anchored to them go live. A caller that never says gets a deterministic
* schedule drawn from the same seed, so a deployment with no presence source
* which is most of them, and every anonymous viewer still shows a studio that
* is alive rather than a row of flat meters. Both paths are deterministic; the
* schedule is not a fallback to randomness, it is a fallback to a fixture.
*/
import {
DEVICE_RANGES,
hasCapability,
normalizeDeviceCommand,
type DeviceCapability,
type DeviceCommand,
type DeviceDeclaration,
type DeviceKind,
type DeviceState,
} from "./types.ts";
// ---- The public shape -----------------------------------------------------
export interface SimulatedDevicesOptions {
seed: number;
/**
* Seconds per `stepFixed()`. The arena runs 0.1; the browser and the server
* run whatever their tick is, and both get the same physics because every
* rate below is expressed per second and multiplied by this.
*/
fixedStepSeconds: number;
/**
* What instant simulated time zero corresponds to, in epoch milliseconds.
*
* `0` by default, which makes `observedAt` an elapsed-milliseconds count and
* is what the arena wants a wall-clock stamp in a replayed rollout would be
* the one field that could not match. A server or a browser driving this in
* real time passes `Date.now()` at construction, and then `observedAt` is a
* real timestamp because the caller advances the simulation in step with the
* clock.
*/
epochMs?: number;
}
export interface SimulatedDevices {
/** Every declared device's current reading, in declaration order. */
current(): DeviceState[];
/**
* Apply one command, or ignore it.
*
* Ignored not thrown on for an id this simulator does not carry, an op
* the declaration did not declare, or a value of the wrong type; the shared
* `normalizeDeviceCommand()` makes that decision so that the browser, the API
* and this all refuse exactly the same things. A number outside its range is
* clamped rather than refused, which is that function's documented
* disposition and not a second opinion held here.
*/
command(command: DeviceCommand): void;
/** Advance by exactly `fixedStepSeconds`. Reads no clock. */
stepFixed(): void;
/** Plain JSON data. Safe to `JSON.stringify`, store and hand back later. */
snapshot(): unknown;
/** Resume from a snapshot. A snapshot this simulator cannot read is ignored. */
restore(state: unknown): void;
/**
* Which seats have somebody in them, if the caller knows.
*
* Additive to the signature the build spec fixed, and additive on purpose:
* without it the mic level could not respond to the room, which is the one
* behaviour that makes a level meter worth drawing. Calling it once switches
* this simulator off its own occupancy schedule for good including a call
* with an empty array, which means "I looked, and nobody is there", not "I
* have nothing to say".
*/
setOccupancy(occupiedSeatIds: readonly string[]): void;
}
// ---- Level model ----------------------------------------------------------
//
// Everything here is in dBFS and every rate is per second. The numbers are
// chosen to be *watchable* rather than to be a measurement: a meter that sits
// still is indistinguishable from a broken one, and a meter that jumps the full
// scale every frame is indistinguishable from noise.
/** The floor. A device that is off, muted or silent reports exactly this. */
const FLOOR_DB = DEVICE_RANGES.level.min;
/** The gain at which the mic model is calibrated — anything else shifts it. */
const REFERENCE_GAIN_DB = DEVICE_RANGES.gain.initial;
/** An empty studio with the air handling running, at reference gain. */
const ROOM_TONE_DB: Span = { low: -56, high: -47 };
/** Somebody talking at the desk this mic serves, at reference gain. */
const SPEECH_DB: Span = { low: -27, high: -8 };
/** A speaker at full volume, playing. Scaled by volume below. */
const PROGRAMME_DB: Span = { low: -14, high: -5 };
/** How long one syllable-scale target lasts, in seconds. */
const SPEECH_PHASE: Span = { low: 0.25, high: 0.9 };
/** Room tone drifts far more slowly than speech does. */
const ROOM_PHASE: Span = { low: 1.4, high: 3.6 };
/** Programme material moves between the two. */
const PROGRAMME_PHASE: Span = { low: 0.4, high: 1.6 };
/**
* How fast the meter rises and falls, in dB per second.
*
* Asymmetric, like every programme meter ever built: fast attack so a syllable
* registers, slow release so the eye can read the peak it just produced. A
* symmetric filter reads as a wobble rather than as a level.
*/
const ATTACK_DB_PER_SECOND = 220;
const RELEASE_DB_PER_SECOND = 34;
/**
* How long the desk this mic serves stays occupied, and stays empty, when
* nobody has told us. Seconds.
*
* Minutes rather than seconds, because this is a person at a desk rather than a
* syllable, and because a mic whose meter came alive every four seconds would
* read as a fault. The two spans differ: studios are empty more than they are
* busy.
*/
const BUSY_PHASE: Span = { low: 25, high: 90 };
const IDLE_PHASE: Span = { low: 40, high: 180 };
interface Span {
low: number;
high: number;
}
// ---- Randomness -----------------------------------------------------------
/**
* One 32-bit stream per device.
*
* Local rather than `seededRandom` from `engine/world.ts`, and that is a hard
* requirement rather than a preference: this module is imported by
* `src/arena/`, which may not reach three.js, the DOM or the network, and
* `world.ts` reaches the first. It is the same mulberry32 arithmetic, stated in
* eleven lines, so the dependency this module carries stays at zero.
*
* Per device rather than one shared stream so that adding a device to a pack
* does not re-roll every other device's future the same reason
* `HttpFlights` draws its route phases over the whole plan before filtering.
*/
function nextRandom(state: number): { value: number; state: number } {
let t = (state + 0x6d2b79f5) >>> 0;
let x = Math.imul(t ^ (t >>> 15), 1 | t);
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
return { value: ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296, state: t };
}
/** A 32-bit seed for one device, mixed from the run seed and the device id. */
function streamSeed(seed: number, id: string): number {
// FNV-1a over the id, then mixed with the run seed. The same construction
// `arena/checksum.ts` uses, for the same reason: two devices whose ids differ
// by one character must not draw neighbouring streams.
let hash = 0x811c9dc5 ^ (seed | 0);
for (let i = 0; i < id.length; i += 1) {
hash ^= id.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
// ---- Per-device runtime ---------------------------------------------------
interface Runtime {
id: string;
kind: DeviceKind;
capabilities: readonly DeviceCapability[];
/** The seat this device serves, for occupancy. `""` when it serves none. */
seatId: string;
powered: boolean;
muted: boolean;
gainDb: number;
volume: number;
playing: boolean;
/** The smoothed meter reading, dBFS. */
levelDb: number;
/** What the meter is heading for until the phase ends. */
targetDb: number;
/** Seconds left in the current level phase. */
phaseLeft: number;
/** The device's own random stream. */
rng: number;
/** Self-driven occupancy, used only while nobody has called `setOccupancy`. */
busy: boolean;
/** Seconds left in the current self-driven occupancy phase. */
busyLeft: number;
}
/**
* The snapshot format. Versioned, flat, and plain JSON.
*
* Versioned because a snapshot outlives the code that wrote it the arena
* stores one mid-episode and restores it later and an unreadable snapshot has
* to be *recognisably* unreadable rather than half-applied. `restore()` ignores
* anything that is not this, which leaves the simulator on its own consistent
* state instead of a mixture of two.
*/
interface Snapshot {
v: 1;
elapsedMs: number;
occupancyProvided: boolean;
occupied: string[];
devices: Runtime[];
}
const SNAPSHOT_VERSION = 1;
export function createSimulatedDevices(
declarations: readonly DeviceDeclaration[],
options: SimulatedDevicesOptions,
): SimulatedDevices {
const dt = Math.max(0, options.fixedStepSeconds);
const epochMs = options.epochMs ?? 0;
// Copied, so a caller mutating the array it handed in cannot change what this
// simulator validates commands against half way through a run.
const declared = declarations.map((d) => d);
const byId = new Map<string, DeviceDeclaration>(declared.map((d) => [d.id, d]));
let elapsedMs = 0;
let occupancyProvided = false;
let occupied = new Set<string>();
let devices = declared.map((d) => initialRuntime(d, options.seed));
/** Whether the desk this device serves has somebody at it, right now. */
function isOccupied(device: Runtime): boolean {
if (!occupancyProvided) return device.busy;
return device.seatId !== "" && occupied.has(device.seatId);
}
/**
* Where the meter is heading, and for how long.
*
* One draw per phase rather than one per step: a target redrawn every frame is
* white noise, and white noise through a smoothing filter is a meter that
* hovers around its own mean and never peaks.
*/
function drawPhase(device: Runtime): void {
const quiet = !device.powered;
if (device.kind === "mic") {
if (quiet || device.muted) {
device.targetDb = FLOOR_DB;
device.phaseLeft = span(device, ROOM_PHASE);
return;
}
const busy = isOccupied(device);
device.targetDb = clamp(
span(device, busy ? SPEECH_DB : ROOM_TONE_DB) + (device.gainDb - REFERENCE_GAIN_DB),
FLOOR_DB,
DEVICE_RANGES.level.max,
);
device.phaseLeft = span(device, busy ? SPEECH_PHASE : ROOM_PHASE);
return;
}
if (quiet || !device.playing) {
device.targetDb = FLOOR_DB;
device.phaseLeft = span(device, PROGRAMME_PHASE);
return;
}
// Volume is a fraction and the meter is decibels, so the knob enters as
// 20·log10 — which is why halving the volume drops the meter about 6 dB
// rather than halving the number on it. Floored well above zero so a
// speaker turned all the way down reads as silent rather than as -Infinity.
const attenuation = 20 * Math.log10(Math.max(device.volume, 0.001));
device.targetDb = clamp(
span(device, PROGRAMME_DB) + attenuation,
FLOOR_DB,
DEVICE_RANGES.level.max,
);
device.phaseLeft = span(device, PROGRAMME_PHASE);
}
/** One draw from the device's own stream, mapped into a span. */
function span(device: Runtime, range: Span): number {
const drawn = nextRandom(device.rng);
device.rng = drawn.state;
return range.low + (range.high - range.low) * drawn.value;
}
function initialRuntime(declaration: DeviceDeclaration, seed: number): Runtime {
const device: Runtime = {
id: declaration.id,
kind: declaration.kind,
capabilities: [...declaration.capabilities],
seatId: declaration.anchor.seatId ?? "",
// Off, like everything in `initialDeviceState`. A studio whose hardware
// powers itself on because a page was loaded is a studio making a claim.
powered: false,
muted: false,
gainDb: DEVICE_RANGES.gain.initial,
volume: DEVICE_RANGES.volume.initial,
playing: false,
levelDb: FLOOR_DB,
targetDb: FLOOR_DB,
phaseLeft: 0,
rng: streamSeed(seed, declaration.id),
busy: false,
busyLeft: 0,
};
// Drawn immediately so that two devices do not change phase on the same
// step for the whole run, which is what a zero initial phase would produce.
device.busyLeft = span(device, IDLE_PHASE);
drawPhase(device);
return device;
}
return {
current(): DeviceState[] {
const observedAt = epochMs + elapsedMs;
return devices.map((device) => reading(device, byId.get(device.id), observedAt));
},
command(command: DeviceCommand): void {
const declaration = byId.get(command.deviceId);
if (declaration === undefined) return;
const normalized = normalizeDeviceCommand(declaration, command);
if (normalized === null) return;
const device = devices.find((d) => d.id === normalized.deviceId);
if (device === undefined) return;
switch (normalized.op) {
case "power":
device.powered = normalized.value === true;
// A speaker that has been switched off is not playing. Leaving
// `playing` true would make a powered-down speaker report a
// now-playing state, which is the one combination no real box has.
if (!device.powered) device.playing = false;
break;
case "mute":
device.muted = normalized.value === true;
break;
case "playback":
// Refused rather than queued on an unpowered speaker: nothing else in
// this file turns a device on as a side effect of another command,
// and a press that silently powered the room would be a surprise.
if (device.powered) device.playing = normalized.value === true;
break;
case "gain":
device.gainDb = normalized.value as number;
break;
case "volume":
device.volume = normalized.value as number;
break;
}
// The meter follows the new state from the next step, not from the next
// phase. Without this, muting a mic leaves the meter at speech level for
// up to a second, which reads as a button that did not work.
device.phaseLeft = 0;
},
stepFixed(): void {
elapsedMs += dt * 1000;
for (const device of devices) {
if (!occupancyProvided) {
device.busyLeft -= dt;
if (device.busyLeft <= 0) {
device.busy = !device.busy;
device.busyLeft = span(device, device.busy ? BUSY_PHASE : IDLE_PHASE);
}
}
device.phaseLeft -= dt;
if (device.phaseLeft <= 0) drawPhase(device);
const rate = device.targetDb > device.levelDb ? ATTACK_DB_PER_SECOND : RELEASE_DB_PER_SECOND;
const move = rate * dt;
const gap = device.targetDb - device.levelDb;
device.levelDb =
Math.abs(gap) <= move ? device.targetDb : device.levelDb + Math.sign(gap) * move;
}
},
snapshot(): unknown {
const state: Snapshot = {
v: SNAPSHOT_VERSION,
elapsedMs,
occupancyProvided,
occupied: [...occupied],
// Deep-copied, because a caller holding a snapshot must not be holding a
// live view of the state this simulator is about to mutate.
devices: devices.map((d) => ({ ...d, capabilities: [...d.capabilities] })),
};
return state;
},
restore(state: unknown): void {
const parsed = readSnapshot(state);
if (parsed === null) return;
elapsedMs = parsed.elapsedMs;
occupancyProvided = parsed.occupancyProvided;
occupied = new Set(parsed.occupied);
// Only devices this simulator was constructed with, and in *its* order:
// a snapshot from a pack with an extra microphone in it must not add one
// here, because `current()` is answered against the declarations the
// caller validated commands against.
devices = devices.map((device) => {
const saved = parsed.devices.find((d) => d.id === device.id);
return saved === undefined ? device : { ...device, ...saved, id: device.id };
});
},
setOccupancy(occupiedSeatIds: readonly string[]): void {
occupancyProvided = true;
occupied = new Set(occupiedSeatIds);
},
};
}
/**
* One runtime as a `DeviceState`, carrying only the readings its declaration
* implies.
*
* A field is omitted rather than zeroed when the device does not declare the
* capability behind it, because `undefined` and `0` mean genuinely different
* things to every consumer: the panel renders no control for a reading that is
* absent, and would render a dead one for a reading that is present and zero.
*/
function reading(
device: Runtime,
declaration: DeviceDeclaration | undefined,
observedAt: number,
): DeviceState {
const has = (capability: DeviceCapability): boolean =>
declaration === undefined
? device.capabilities.includes(capability)
: hasCapability(declaration, capability);
const state: DeviceState = {
id: device.id,
kind: device.kind,
powered: device.powered,
observedAt,
// Never anything else from this file. Everything above is invented, and the
// one field that says so is not a flag a caller may set.
synthetic: true,
};
if (has("mute")) state.muted = device.muted;
if (has("gain")) state.gainDb = round(device.gainDb, 2);
if (has("level")) state.levelDb = round(device.levelDb, 1);
if (has("volume")) state.volume = round(device.volume, 3);
if (has("playback")) state.playing = device.playing;
return state;
}
/**
* A snapshot, if it is one.
*
* Checked rather than cast, because a snapshot arrives from wherever the caller
* kept it a JSON file, an arena trace, a previous version of this module
* and a half-applied restore is worse than a refused one: the caller believes
* it is replaying and is not. `null` means "not mine", and `restore()` leaves
* the simulator exactly as it was.
*/
function readSnapshot(state: unknown): Snapshot | null {
if (state === null || typeof state !== "object" || Array.isArray(state)) return null;
const raw = state as Partial<Snapshot>;
if (raw.v !== SNAPSHOT_VERSION) return null;
if (typeof raw.elapsedMs !== "number" || !Number.isFinite(raw.elapsedMs)) return null;
if (!Array.isArray(raw.devices)) return null;
if (!Array.isArray(raw.occupied)) return null;
return {
v: SNAPSHOT_VERSION,
elapsedMs: raw.elapsedMs,
occupancyProvided: raw.occupancyProvided === true,
occupied: raw.occupied.filter((id): id is string => typeof id === "string"),
devices: raw.devices.filter((d): d is Runtime => d !== null && typeof d === "object"),
};
}
function clamp(value: number, low: number, high: number): number {
return Math.min(high, Math.max(low, value));
}
/**
* Rounded on the way out, never in the running state.
*
* The meter is smoothed at full precision and reported at a tenth of a decibel,
* which is both what a meter is read to and what `deviceStateSignature` hashes
* so a reading that has not visibly changed does not republish, and a run
* that is bit-identical internally stays bit-identical on the wire.
*/
function round(value: number, places: number): number {
const scale = 10 ** places;
return Math.round(value * scale) / scale;
}
+544
View File
@@ -0,0 +1,544 @@
/**
* What a device *is*, before anything simulates one, draws one or commands one.
*
* This file is deliberately the first one written in this directory and it
* exports no behaviour beyond small pure helpers, because four separate pieces
* of the build are held up on the same four questions: what does a pack author
* write into a room, what does a live reading look like, what can be commanded,
* and how does a consumer know which of those apply to a given device. Packs
* authors declarations from this; the device panel builds its controls from it;
* the arena builds part of its observation vector from it; the API validates
* against it. It therefore imports nothing no THREE, no interiors, no wire
* and it can be imported by all of them.
*
* ### The split that matters most
*
* **A declaration is authored and lives in the office pack. A state never
* does.** That is the same line `Presence` draws in `interiors/types.ts`, for
* the same reason and with the same consequence: a pack is bundled into the
* static build, so everything in it is public by construction, and anything
* that must be refused to an anonymous caller has to arrive over the API from a
* route that can refuse it. A `DeviceDeclaration` says *there is a microphone
* on that desk, here is the hardware, here is what it can be asked to do*, and
* publishing that is fine it is a description of a room. A `DeviceState` says
* what the microphone is hearing right now, and that never appears in a file
* anybody can download.
*
* ### Strictly JSON-serialisable
*
* CONTRACT.md §2, non-negotiable, because a declaration is authored inside an
* `Office` and a hand-written pack and one arriving over HTTP have to be the
* same thing. No classes, no functions, no `Date`, no THREE types, no getters,
* nothing that survives `structuredClone` but not `JSON.stringify`. The helpers
* below are functions *about* the data, never fields *in* it.
*
* ### Two kinds now, five more later, without a new shape
*
* This build carries a mic and a speaker. A smart light, a thermostat, a door
* sensor and a vehicle charger were all drawn on paper against this shape
* before it was written, and all four fit: each is an authored declaration
* anchored to a prop, a set of capabilities drawn from one closed list, and a
* state carrying one reading per capability. Adding one is a new member of
* `DeviceKind`, an entry in `CANONICAL_CAPABILITIES`, and for a reading that
* genuinely does not exist yet, such as a thermostat's setpoint one optional
* field on `DeviceState` and one row in `CAPABILITY_READING`. Nothing nests
* differently, no authored pack is invalidated, and no consumer has to learn a
* second way to ask what a device can do. That is what "fits without a schema
* change" means here: the *structure* is fixed, the vocabulary grows.
*
* Weather is **not** a device and is not modelled as one. It is an observation
* of the world that arrives from `/api/v1/weather`, it is nobody's hardware, it
* is anchored to no prop, and there is nothing to command. `Environment` in
* CONTRACT.md §4 is where it lives and it stays there.
*/
// ---- Identifiers ----------------------------------------------------------
/**
* A namespaced asset id whose asset is device hardware, like
* `"tera:device.mic.desk"`.
*
* The same string as `AssetId` in `src/assets/kit.ts` and in
* `src/interiors/types.ts`, restated here for the reason those two give for
* each other: this contract is data, and data should not have to import the
* mesh library to be parsed, validated or stored. The narrower name is a
* courtesy to the reader not every `AssetId` is a device, and `assetId` on a
* declaration is only ever one that is.
*
* The convention, which `deviceKindOfAssetId` reads and the API relies on, is
* `<namespace>:device.<kind>.<placement>`. A self-hoster's
* `acme:device.mic.boom` is a mic to every consumer here without registering
* anything with us, which is the same reskinning story `overrides` tells for
* furniture.
*/
export type DeviceAssetId = string;
// ---- Kinds ----------------------------------------------------------------
/** The device kinds this build carries. See the header on growing this list. */
export type DeviceKind = "mic" | "speaker";
export const DEVICE_KINDS: readonly DeviceKind[] = ["mic", "speaker"];
// ---- Capabilities ---------------------------------------------------------
/**
* One thing a device can do or report the unit both the UI and the simulator
* are built out of.
*
* A capability is declared per *device*, not per kind, because two microphones
* in the same building are genuinely not always the same instrument: the desk
* condenser has a gain stage, the ceiling array does not. The device panel
* renders one control per declared capability and the simulator advances one
* reading per declared capability, so a device that declares nothing it does
* not have cannot grow a control that does nothing.
*
* `level` is the odd one and is why this union is not simply a list of buttons:
* it is a *reading only*. You can ask a microphone for its level; you cannot
* set it. `DeviceCommandOp` is derived from this union by removing exactly
* that, so the two can never drift apart.
*/
export type DeviceCapability = "power" | "gain" | "mute" | "volume" | "playback" | "level";
export const DEVICE_CAPABILITIES: readonly DeviceCapability[] = [
"power",
"gain",
"mute",
"volume",
"playback",
"level",
];
/** Everything a `DeviceCommand` may ask for: the capabilities that are not read-only. */
export type DeviceCommandOp = Exclude<DeviceCapability, "level">;
export const DEVICE_COMMAND_OPS: readonly DeviceCommandOp[] = [
"power",
"gain",
"mute",
"volume",
"playback",
];
/**
* What each kind normally has, for a pack author who wants the ordinary answer.
*
* Advisory, not enforced: a declaration carries its own `capabilities` and that
* is what every consumer reads. This is here so that two studios authored
* months apart describe the same instrument the same way, which is worth more
* than it looks the arena's observation vector is a fixed width, and a mic
* that quietly stopped declaring `gain` in one pack would make two scenarios
* incomparable.
*/
export const CANONICAL_CAPABILITIES: Readonly<Record<DeviceKind, readonly DeviceCapability[]>> = {
mic: ["power", "mute", "gain", "level"],
speaker: ["power", "volume", "playback"],
};
/**
* The reading each capability implies on `DeviceState`.
*
* This table is the joint that makes the whole design hold. Without it, every
* consumer would carry its own `if (kind === "mic") show gain` ladder and they
* would disagree the first time a kind was added. With it: the UI renders
* `capabilities.map(...)`, the simulator advances `capabilities.map(...)`, the
* observation flattens `capabilities.map(...)`, and a new kind is data.
*/
export const CAPABILITY_READING: Readonly<Record<DeviceCapability, keyof DeviceState>> = {
power: "powered",
gain: "gainDb",
mute: "muted",
volume: "volume",
playback: "playing",
level: "levelDb",
};
// ---- Ranges ---------------------------------------------------------------
/**
* The bounds of a numeric reading, in the units it is reported in.
*
* Published rather than left to each consumer because three of them need the
* same numbers for different reasons and a disagreement would be silent: the
* panel draws a slider between them, the API clamps a command into them, and
* the arena normalises an observation by them. `initial` is the value at rest
* what an unobserved or freshly-powered device reports which is what lets
* `initialDeviceState` be a pure function of a declaration.
*/
export interface DeviceRange {
min: number;
max: number;
/** The value at rest, before anything has been observed or commanded. */
initial: number;
unit: string;
}
export const DEVICE_RANGES: Readonly<Record<"gain" | "volume" | "level", DeviceRange>> = {
/** Preamp gain on a desk condenser. Below zero is pad, not silence. */
gain: { min: -12, max: 36, initial: 12, unit: "dB" },
/** Output level, as a fraction. Not decibels: this is the knob, not the meter. */
volume: { min: 0, max: 1, initial: 0.35, unit: "fraction" },
/**
* Programme level on the meter, full-scale referenced. At rest it sits at the
* floor, because a microphone nobody has switched on is not hearing 20 dBFS
* of anything.
*/
level: { min: -60, max: 0, initial: -60, unit: "dBFS" },
};
// ---- Provenance -----------------------------------------------------------
/**
* Where a device's readings come from, as a closed vocabulary.
*
* A closed union rather than free text, and the same idea as the per-row
* provenance the marker gate enforces under CONTRACT.md §8: a value the server
* can check is worth more than a sentence it can only pass through. What is
* being guarded is different but the failure is identical something presented
* as observed when it was invented, or as invented when it was observed.
*
* - `simulated` a deterministic state machine. Everything this build ships.
* - `operator-authored` a fixed value an operator wrote down. Honest, static.
* - `first-party-sensor` a real reading from the operator's own hardware,
* which is the door a Home Assistant bridge comes through later.
*
* `synthetic` on `DeviceState` is the runtime half of the same statement, and
* it is the one a viewer is shown.
*/
export type DeviceProvenance = "simulated" | "operator-authored" | "first-party-sensor";
export const DEVICE_PROVENANCE: readonly DeviceProvenance[] = [
"simulated",
"operator-authored",
"first-party-sensor",
];
// ---- The authored declaration ---------------------------------------------
/** Metres, in the anchor prop's own frame. */
export interface DeviceOffset {
x: number;
y: number;
z: number;
}
/**
* Where a device physically is by reference, never by restatement.
*
* `propId` is required, and that is the whole design of this type: a device is
* a piece of hardware, hardware sits on something, and the something is already
* placed in the floorplan with a position and a rotation that `Plan` resolves.
* Giving the declaration its own world coordinate would create a second answer
* to "where is the mic", and the two would disagree the first time somebody
* nudged the desk. So the position is *derived*: the anchor prop's transform,
* plus an optional `offset` in the prop's own frame for the few centimetres
* between the desk's origin and the top of the mic stand.
*
* The same argument `Prop.seat` makes for chairs, and the same one CONTRACT.md
* makes about packs importing their site rather than restating its coordinates.
*
* `roomId` and `seatId` are addresses, not positions. They are what lets a
* consumer ask "is anybody sitting where this mic is pointed" which the arena
* does, and which is the difference between a hot mic and a wasted one.
*/
export interface DeviceAnchor {
levelId: string;
/** The authored prop that *is* this device's hardware. */
propId: string;
/** The room the prop stands in, when a consumer wants it without a lookup. */
roomId?: string;
/** The seat this device serves, if it serves one. */
seatId?: string;
/** Metres from the anchor prop's origin, in the prop's frame. */
offset?: DeviceOffset;
}
/**
* One device, as a pack author writes it.
*
* Authored, public, and inert: nothing here is a reading and nothing here
* changes. `Plan` resolves it the way it resolves every other authored address
* dropping what it cannot bind and recording the problem rather than throwing
* so one typo in a device id does not stop an office from opening.
*/
export interface DeviceDeclaration {
/** Unique within the office. Referenced by every `DeviceState` and command. */
id: string;
kind: DeviceKind;
/** Shown in the panel. "Desk mic", not "tera:device.mic.desk". */
label: string;
/** The hardware to build. Its kind segment must agree with `kind`. */
assetId: DeviceAssetId;
anchor: DeviceAnchor;
/** What this particular unit can do. Usually `CANONICAL_CAPABILITIES[kind]`. */
capabilities: readonly DeviceCapability[];
provenance: DeviceProvenance;
/**
* One sentence shown to a viewer next to the readings.
*
* Mandatory, and validated: a `simulated` device must say so in words a
* person reading the panel would understand, exactly as
* `RobotOperationsDefinition.disclosure` is checked to contain "simulat"
* before any of it is drawn. A studio that shows a live-looking level meter
* without saying where the level came from is making a claim about a real
* room, and this is the field that stops it.
*/
disclosure: string;
}
// ---- The live state -------------------------------------------------------
/**
* What a device is doing right now. **Never authored, never in a pack.**
*
* Every reading past `powered` is optional because a device only carries the
* readings its capabilities imply see `CAPABILITY_READING`. A consumer that
* wants to know whether a field is meaningful asks the declaration, not the
* state: `undefined` here means "this device has no such reading", which is a
* different thing from zero.
*/
export interface DeviceState {
id: string;
kind: DeviceKind;
/** Every device has this one. A device with no power state is a prop. */
powered: boolean;
muted?: boolean;
gainDb?: number;
/** Programme level, dBFS. A reading only — no command sets it. */
levelDb?: number;
volume?: number;
playing?: boolean;
/** Epoch milliseconds. */
observedAt: number;
/**
* Was this reading invented?
*
* `true` for everything this build ships, and it is not a flag anybody may
* default to `false` for convenience. The panel shows it, the wire carries
* it, and `DevicesBody.synthetic` is the same statement one level up.
*/
synthetic: boolean;
}
// ---- Commands -------------------------------------------------------------
/** Booleans for the switches, numbers for the knobs. Nothing else is a value. */
export type DeviceCommandValue = boolean | number;
/**
* One instruction for one device.
*
* Deliberately tiny and deliberately not batched. It travels in a POST body of
* its own never in a read response, because a shared cache that replayed a
* GET which turned a microphone on is precisely what the fail-closed
* `Cache-Control` default in CONTRACT.md §5 exists to prevent.
*
* `value` is absent only for an op that carries no argument, and today there is
* none: `power`, `mute` and `playback` take a boolean, `gain` and `volume` take
* a number. It stays optional because a future `playback: "next"`-shaped op
* would want it to be, and because a command that arrives without one must be
* rejected by `normalizeDeviceCommand` rather than by the type system alone
* the wire can send anything.
*/
export interface DeviceCommand {
deviceId: string;
op: DeviceCommandOp;
value?: DeviceCommandValue;
}
// ---- Helpers --------------------------------------------------------------
//
// Pure, total, and free of I/O. They exist because the alternative is four
// consumers each writing their own slightly different version of the same
// check, which is how a validation gate ends up being enforced in three places
// and skipped in the fourth.
export function isDeviceKind(value: unknown): value is DeviceKind {
return typeof value === "string" && (DEVICE_KINDS as readonly string[]).includes(value);
}
export function isDeviceCapability(value: unknown): value is DeviceCapability {
return typeof value === "string" && (DEVICE_CAPABILITIES as readonly string[]).includes(value);
}
export function isDeviceCommandOp(value: unknown): value is DeviceCommandOp {
return typeof value === "string" && (DEVICE_COMMAND_OPS as readonly string[]).includes(value);
}
export function isDeviceProvenance(value: unknown): value is DeviceProvenance {
return typeof value === "string" && (DEVICE_PROVENANCE as readonly string[]).includes(value);
}
/**
* The kind an asset id claims to be, or `null` if it does not claim to be a
* device at all.
*
* `"tera:device.mic.desk"` `"mic"`. Namespace-agnostic, so a self-hoster's
* `acme:device.speaker.shelf` reads as a speaker without registering anything.
*
* This is what lets the API check that a declaration's `assetId` and `kind`
* agree, and check it *server-side* against the resolved plan rather than
* trusting a body the same move `officeHasMediaBinding()` makes for screens.
* A mic declaration pointing at a speaker's hardware is not a rendering bug, it
* is a command routed to the wrong instrument.
*/
export function deviceKindOfAssetId(assetId: DeviceAssetId): DeviceKind | null {
const path = assetId.includes(":") ? assetId.slice(assetId.indexOf(":") + 1) : assetId;
const segments = path.split(".");
if (segments[0] !== "device") return null;
const kind = segments[1];
return isDeviceKind(kind) ? kind : null;
}
export function hasCapability(
declaration: Pick<DeviceDeclaration, "capabilities">,
capability: DeviceCapability,
): boolean {
return declaration.capabilities.includes(capability);
}
/**
* Everything wrong with an authored declaration, as sentences. Empty is good.
*
* **It never throws**, which is the whole point: `Plan` resolves authored data
* by dropping what it cannot use and recording why, so that one bad device does
* not cost a viewer the building. Compare `resolveRobotOperations`, which does
* throw that is authored *behaviour*, resolved once at build time by whoever
* wrote it, and a robot station with no floor under it is a bug in the pack. A
* device is authored *furniture*, and furniture degrades.
*/
export function validateDeviceDeclaration(declaration: DeviceDeclaration): string[] {
const problems: string[] = [];
const id = declaration.id === "" ? "<unnamed device>" : declaration.id;
if (declaration.id === "") problems.push("device has no id");
if (!isDeviceKind(declaration.kind)) problems.push(`device ${id} has an unknown kind`);
if (declaration.label.trim() === "") problems.push(`device ${id} has no label`);
if (declaration.anchor.levelId === "") problems.push(`device ${id} names no level`);
if (declaration.anchor.propId === "") {
problems.push(`device ${id} is anchored to no prop, and a device with no hardware is fiction`);
}
const assetKind = deviceKindOfAssetId(declaration.assetId);
if (assetKind === null) {
problems.push(`device ${id} names ${declaration.assetId}, which is not device hardware`);
} else if (assetKind !== declaration.kind) {
problems.push(
`device ${id} is declared a ${declaration.kind} but its asset ${declaration.assetId} is a ` +
`${assetKind}`,
);
}
if (declaration.capabilities.length === 0) {
problems.push(`device ${id} declares no capabilities and could do nothing`);
}
for (const capability of declaration.capabilities) {
if (!isDeviceCapability(capability)) {
problems.push(`device ${id} declares an unknown capability ${String(capability)}`);
}
}
if (!isDeviceProvenance(declaration.provenance)) {
problems.push(`device ${id} has an unknown provenance`);
}
// The same check `resolveRobotOperations` makes, for the same reason: a
// simulated reading displayed without the word is a claim about a real room.
if (declaration.disclosure.trim() === "") {
problems.push(`device ${id} has no disclosure`);
} else if (
declaration.provenance === "simulated" &&
!declaration.disclosure.toLowerCase().includes("simulat")
) {
problems.push(`device ${id} is simulated and its disclosure does not say so`);
}
return problems;
}
/**
* The state a declaration implies before anything has been observed.
*
* Powered off, every reading at rest, and `synthetic: true` whatever the
* declaration's provenance says because nothing has been observed yet, and a
* state that claimed otherwise would be a lie told by a constructor.
*/
export function initialDeviceState(
declaration: DeviceDeclaration,
observedAt: number,
): DeviceState {
const state: DeviceState = {
id: declaration.id,
kind: declaration.kind,
powered: false,
observedAt,
synthetic: true,
};
if (hasCapability(declaration, "mute")) state.muted = false;
if (hasCapability(declaration, "gain")) state.gainDb = DEVICE_RANGES.gain.initial;
if (hasCapability(declaration, "level")) state.levelDb = DEVICE_RANGES.level.initial;
if (hasCapability(declaration, "volume")) state.volume = DEVICE_RANGES.volume.initial;
if (hasCapability(declaration, "playback")) state.playing = false;
return state;
}
/**
* A command this declaration will actually accept, clamped or `null`.
*
* The one validator, run by everybody: the panel before it sends, the API
* before it mutates, the simulator before it applies. `null` is a refusal and
* the caller decides what that means a 400 on the route, a no-op in the UI.
* A returned command is a fresh object, so a caller cannot hold a reference to
* something the store is about to mutate.
*
* Clamping rather than refusing an out-of-range number is deliberate and is the
* one place this is lenient: a slider that reports 1.0000000002 is not an
* attack, and `TERA_ADSB_RADIUS_NM` has the same disposition for the same
* reason. A *wrong type* is refused, because that is a caller who has
* misunderstood the contract rather than one who overshot.
*/
export function normalizeDeviceCommand(
declaration: DeviceDeclaration,
command: DeviceCommand,
): DeviceCommand | null {
if (command.deviceId !== declaration.id) return null;
if (!isDeviceCommandOp(command.op)) return null;
if (!hasCapability(declaration, command.op)) return null;
if (command.op === "gain" || command.op === "volume") {
const range = DEVICE_RANGES[command.op];
if (typeof command.value !== "number" || !Number.isFinite(command.value)) return null;
return {
deviceId: declaration.id,
op: command.op,
value: Math.min(range.max, Math.max(range.min, command.value)),
};
}
if (typeof command.value !== "boolean") return null;
return { deviceId: declaration.id, op: command.op, value: command.value };
}
/**
* A string that changes exactly when something a viewer would notice changes.
*
* `observedAt` is left out on purpose. It moves on every poll of an unchanged
* room and would defeat the whole comparison which is the same trap, and the
* same answer, as the presence watch in `adapters/http.ts`. `watchDevices`
* publishes on a change of this and on nothing else.
*/
export function deviceStateSignature(states: readonly DeviceState[]): string {
return states
.map((s) =>
[
s.id,
s.powered ? "1" : "0",
s.muted === undefined ? "" : s.muted ? "1" : "0",
s.gainDb === undefined ? "" : s.gainDb.toFixed(2),
s.levelDb === undefined ? "" : s.levelDb.toFixed(1),
s.volume === undefined ? "" : s.volume.toFixed(3),
s.playing === undefined ? "" : s.playing ? "1" : "0",
s.synthetic ? "1" : "0",
].join("|"),
)
.join(";");
}
+84 -26
View File
@@ -556,12 +556,31 @@ const NIGHT_FLOOR_HORIZON = 0x16203a;
* a roof is lighter than a wall; and the keyframe table's token sidelight
* survives at full strength on a moonless night (see `applyNight`) so the hills
* still have a lit side and a dark one.
*
* ### Why these two numbers moved when tone mapping arrived
*
* They went up by a third, and the ratios between all five did not change,
* which is the point. `stage.ts` now runs ACES filmic instead of a bare
* `saturate()`, and ACES has a toe: it is steeper than a plain sRGB encode
* everywhere below about linear 0.1, which is the entire range a moonless night
* occupies. A ground reading that displayed at 0.212 under the old renderer
* came out at 0.174 under the new one for the same physical light an 18% loss
* concentrated exactly where this file has the least to give.
*
* A third more linear light puts it back at 0.240 and leaves the *shape* of the
* night alone, because every one of the five terms was scaled by the same
* factor. That is deliberate: the comment above spends four paragraphs on the
* ratios between them, and a re-tune that fixed the brightness by flattening the
* sky-to-ground gradient would have thrown away the argument to keep the number.
* The city's own lit windows keep their four-to-five-times lead as well they
* are emissive, they were clipping at 1.0 before, and under a shoulder they now
* separate rather than all rendering as the same white.
*/
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
const NIGHT_FLOOR_HEMI_INTENSITY = 1.05;
const NIGHT_FLOOR_AMBIENT = 0x47557f;
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.3;
/**
* The same sky, and the same fill, with a full moon in it.
@@ -636,6 +655,45 @@ interface Keyframe extends Rig {
* so a city that declares a paler or bluer sky keeps it at noon and still gets
* the same dusk as everywhere else dusk is not regional in any way this
* renderer can see.
*
* ### The intensities are tuned against a tone curve, and it is ACES
*
* Read the three intensity columns as a set, because they were re-tuned as one
* when `stage.ts` stopped rendering through `NoToneMapping`. The old numbers
* were not wrong they were correct for a renderer that hard-clipped at linear
* 1.0, and being correct for that is exactly what makes them wrong now.
*
* Two things changed, in opposite directions, and both are visible in the table.
*
* **Below the horizon the fills went up by about a third.** ACES's toe is
* steeper than a plain sRGB encode everywhere under about linear 0.1, which is
* the whole of the range a night frame lives in. See `NIGHT_FLOOR_HEMI_SKY` for
* the arithmetic; the three night stops move with the floor because they sit
* just under it and it is the floor that binds.
*
* **Above the horizon the key went up and the fill came down.** That is not a
* brightness change, it is a contrast change, and it is the whole reason to have
* done this. Under a clipping renderer a sunlit 0.7-albedo wall and a sunlit
* 0.95-albedo wall were both exactly white, so the only way to make a daylit
* scene read as *lit* was to pour in fill until the shadows came up to meet the
* blown highlights which is a description of a flat picture. With a shoulder
* over the top, a sun at 2.6 puts a lit face at about 0.88 display and its own
* shaded face at about 0.52, and the difference between them is the modelling
* that was missing. So `hemiIntensity` loses roughly a tenth and
* `ambientIntensity` roughly a quarter at the two day stops.
*
* The fill can afford that for a second reason: it is no longer the only
* indirect light in the scene. `environmentRig.ts` derives a real sky
* environment from this same `LightingState` and puts it on `Scene.environment`,
* so the diffuse bounce the hemisphere light was standing in for now arrives
* from something with a direction and a horizon in it. Trimming here and adding
* there is one move, not two.
*
* The sky columns are untouched, and that is not an oversight. Three marks the
* background mesh `toneMapped = false` for an sRGB-transfer texture and mixes
* fog after the tone map from an already-encoded uniform, so `skyTop`,
* `skyHorizon` and the fog colour are displayed exactly as written here. Only
* the lit geometry moved, so only the light was re-tuned.
*/
function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
return [
@@ -660,36 +718,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
skyTop: 0x05070f,
skyHorizon: 0x0b1120,
sunColor: 0x44558a,
sunIntensity: 0.16,
sunIntensity: 0.22,
hemiSky: 0x2f447e,
hemiGround: 0x1b2234,
hemiIntensity: 0.55,
hemiIntensity: 0.74,
ambientColor: 0x414e78,
ambientIntensity: 0.16,
ambientIntensity: 0.22,
},
{
elevation: -12,
skyTop: 0x080d1e,
skyHorizon: 0x141d38,
sunColor: 0x51629b,
sunIntensity: 0.19,
sunIntensity: 0.26,
hemiSky: 0x32477d,
hemiGround: 0x1d2437,
hemiIntensity: 0.57,
hemiIntensity: 0.77,
ambientColor: 0x424f7a,
ambientIntensity: 0.17,
ambientIntensity: 0.23,
},
{
elevation: -6,
skyTop: 0x101a3a,
skyHorizon: 0x2b3560,
sunColor: 0x66699a,
sunIntensity: 0.26,
sunIntensity: 0.35,
hemiSky: 0x3c558c,
hemiGround: 0x23293c,
hemiIntensity: 0.6,
hemiIntensity: 0.81,
ambientColor: 0x485389,
ambientIntensity: 0.19,
ambientIntensity: 0.26,
},
{
// The sun on the horizon. Warm at the bottom, cold at the top, and the
@@ -698,36 +756,36 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
skyTop: 0x2a4275,
skyHorizon: 0x9a6a63,
sunColor: 0xc2795c,
sunIntensity: 0.45,
sunIntensity: 0.58,
hemiSky: 0x4a5f8c,
hemiGround: 0x2a2a2c,
hemiIntensity: 0.6,
hemiIntensity: 0.72,
ambientColor: 0x6a6a80,
ambientIntensity: 0.2,
ambientIntensity: 0.25,
},
{
elevation: 3,
skyTop: 0x4d76ac,
skyHorizon: 0xdba078,
sunColor: 0xff9c56,
sunIntensity: 1.25,
sunIntensity: 1.45,
hemiSky: 0x86a6cc,
hemiGround: 0x54503f,
hemiIntensity: 0.85,
hemiIntensity: 0.9,
ambientColor: 0xffd9b8,
ambientIntensity: 0.24,
ambientIntensity: 0.26,
},
{
elevation: 8,
skyTop: 0x6b96c6,
skyHorizon: 0xebc9a4,
sunColor: 0xffc489,
sunIntensity: 1.8,
sunIntensity: 2.0,
hemiSky: 0xb2cbe4,
hemiGround: 0x6a6752,
hemiIntensity: 0.98,
hemiIntensity: 0.95,
ambientColor: 0xffe7cf,
ambientIntensity: 0.28,
ambientIntensity: 0.26,
},
{
// Ordinary daylight, and the one stop that reproduces `cityDaylight()`.
@@ -735,12 +793,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
skyTop: dayTop,
skyHorizon: dayHorizon,
sunColor: 0xfff3e0,
sunIntensity: 2.1,
sunIntensity: 2.35,
hemiSky: 0xdcecf7,
hemiGround: 0x6b6f5e,
hemiIntensity: 1.05,
hemiIntensity: 0.92,
ambientColor: 0xffffff,
ambientIntensity: 0.32,
ambientIntensity: 0.24,
},
{
// A high sun. The zenith deepens — less air to scatter through overhead —
@@ -749,12 +807,12 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
skyTop: mixHex(dayTop, 0x2f6bb0, 0.35),
skyHorizon: mixHex(dayHorizon, 0xffffff, 0.2),
sunColor: 0xfffdf6,
sunIntensity: 2.35,
sunIntensity: 2.6,
hemiSky: 0xe6f2fb,
hemiGround: 0x74786a,
hemiIntensity: 1.1,
hemiIntensity: 0.95,
ambientColor: 0xffffff,
ambientIntensity: 0.3,
ambientIntensity: 0.22,
},
];
}
+557
View File
@@ -0,0 +1,557 @@
/**
* The environment map: what every reflective surface in the world is looking at.
*
* ## What this fixes
*
* Before this file existed there was no `Scene.environment` anywhere in `src/`,
* and that single absence is the whole explanation for the most common
* complaint about how this product looks. A `MeshStandardMaterial` with
* `metalness > 0` has, by construction, almost no diffuse term metal does not
* scatter, it *reflects* so with nothing to reflect it renders as a flat dark
* grey wash and reads as painted plastic. The library has eleven such roles:
* `metalTrim` at 0.85, `chairBase` at 0.75, `glazingFrame` and `deskFrame` at
* 0.7 and 0.65, `partitionFrame`, `deviceMesh`, and the five Model X materials.
* All of them were being asked to look like metal with a black room around them.
*
* The codebase already documents this against itself in two places, which is
* how you know it is not a matter of taste. `office/optimus.ts:62` abandoned a
* whole material role over it, and `vehicles/modelX.ts:96` fakes an `emissive`
* term on the car paint to stand in for the sky bounce that was missing. Both
* are workarounds for this file not existing.
*
* ## What it is allowed to do
*
* **It constructs no light. Not one, of any type.** CONTRACT.md §4 makes
* `Atmosphere` the sole light owner and gives the data exactly one direction to
* flow: an `Environment` is observed, `atmosphere.apply()` turns it into a
* `LightingState`, a scene applies that, and nothing writes back. This rig sits
* at the end of that same one-way street it is handed the `LightingState`
* that has *already been decided* and derives an environment from it. It never
* decides anything about the light itself, so there is no second owner and
* nothing to keep in sync.
*
* That constraint is also why there is no `RoomEnvironment` import here.
* Three's version is a perfectly good office environment and the office path
* below is recognisably descended from it, but it is a fixed room: it does not
* know the hour, the weather, or which way the building faces, so an office at
* 4 p.m. in August would reflect the same neutral studio light as one at
* midnight in January. Deriving the room from `LightingState` instead costs
* about forty lines and means the reflections move with the sun like everything
* else in the scene does.
*
* ## Procedural, like everything else
*
* No `.hdr`, no `.exr`, no cubemap faces on disk. The city environment is a
* `DataTexture` filled in a double loop from the same sky colours the
* background gradient uses, and the office environment is nine untextured
* quads. `scripts/check-no-binaries.mjs` stays satisfied for the same reason
* `textures.ts` keeps it satisfied the art is the code (CONTRACT.md §3).
*/
import * as THREE from "three";
import type { LightingState } from "./types.ts";
/** Which of the two worlds is being reflected. */
export type EnvironmentKind = "city" | "office";
export interface EnvironmentRig {
apply(scene: THREE.Scene, lighting: LightingState, kind: "city" | "office"): void;
/**
* Forget a scene that is being torn down.
*
* `apply` records every scene it has written to, so that a rebuilt
* environment can be pushed to all of them at once rather than only to the
* one that happened to ask. That ledger is a strong reference, and a page
* that switches city three times disposes three scenes the rig would
* otherwise hold forever the whole graph, because `createScene`'s dispose
* frees geometries and materials without clearing its children. This is the
* matching call, and a disposing scene is the only correct caller: it nulls
* `scene.environment` and drops the entry. The cached PMREM targets are
* shared across every scene of that kind and are **not** freed here; that is
* `dispose()`'s job, and it belongs to whoever owns the Stage.
*/
release(scene: THREE.Scene): void;
dispose(): void;
}
/**
* Equirectangular source resolution, in texels.
*
* Small on purpose. PMREM takes a cube face of `width / 4`, so 256 gives a
* 64² cubemap, and the thing being encoded is a vertical gradient with one
* bright lobe in it there is no detail here that 512 would preserve and 256
* would lose. What that buys is the right to rebuild often: the whole cost of
* a sky change is 32k texels of CPU fill, a 128 KB upload and a handful of
* quarter-megapixel GPU passes, which is affordable several times a second and
* therefore affordable during a time-lapse.
*/
const EQUIRECT_WIDTH = 256;
const EQUIRECT_HEIGHT = 128;
/**
* How much of the sky's brightness the environment carries, against the
* hemisphere light that was already carrying all of it.
*
* This number exists because the environment map and the hemisphere light are
* two descriptions of the same physical thing light arriving from the sky
* and applying both at full strength counts it twice. Three's
* `getIBLIrradiance` returns `PI * radiance`, and `BRDF_Lambert` divides by PI,
* so an environment of uniform radiance R contributes exactly `albedo * R` to a
* diffuse surface, against `albedo * intensity * colour / PI` from the
* hemisphere. At 0.12 the environment lands at roughly a fifth of the
* hemisphere's diffuse contribution, and the day stops in `atmosphere.ts` gave
* up about a tenth of `hemiIntensity` and a quarter of `ambientIntensity` to
* make room for it. Those two edits are one decision and were made together.
*
* The reason it is a minority share rather than a replacement: a PMREM
* irradiance probe has no shadow term and no local occlusion, so raising it
* until it *is* the sky fill would light the inside of a closed room as
* brightly as the roof of it. The hemisphere light has the same flaw, but it
* is the flaw the rest of the world is already tuned against.
*/
const SKY_RADIANCE_SHARE = 0.12;
/**
* The sun's own disc, as a radiance and a tightness.
*
* Kept narrow and kept modest. A cosine power of 400 is a lobe about four
* degrees across, which contributes almost nothing to the irradiance integral
* so the directional light `Atmosphere` already owns is not double-counted
* while giving every smooth metal and every pane of glass a specular highlight
* with a *direction* in it. That highlight is most of what separates "this is
* metal" from "this is grey", and it is the one thing a uniform ambient can
* never supply.
*/
const SUN_LOBE_RADIANCE = 1.2;
const SUN_LOBE_TIGHTNESS = 400;
const SUN_GLOW_RADIANCE = 0.22;
const SUN_GLOW_TIGHTNESS = 9;
/**
* How much of the sky bounces back off the ground.
*
* The lower half of the sphere is not black it is the city, or the floor, lit
* by the same sun. `LightingState.hemisphere.ground` is the number
* `Atmosphere` already publishes for exactly this and it is reused rather than
* re-derived, so a hazy afternoon greys the underside of a car and the top of
* it together.
*/
const GROUND_RADIANCE_SHARE = 0.09;
/**
* The office room, in metres. A generous meeting room rather than a studio,
* because the reflection of a room reads as the *proportions* of a room and a
* cube reads as a lift.
*/
const ROOM_WIDTH = 9;
const ROOM_DEPTH = 7;
const ROOM_HEIGHT = 3.2;
export function createEnvironmentRig(renderer: THREE.WebGLRenderer): EnvironmentRig {
/**
* Everything below is built on first use and not before.
*
* A `PMREMGenerator` compiles three shader programs the moment it is asked to
* do anything, and a page that opens on a city board should not pay for the
* office's blur chain until somebody walks into an office. `createStage`
* builds one rig for the life of the page (see the note in `stage.ts` about
* why there is exactly one renderer), so "first use" here means once.
*/
let pmrem: THREE.PMREMGenerator | null = null;
let equirect: THREE.DataTexture | null = null;
let room: RoomProbe | null = null;
/** One cached PMREM target per kind, with the key it was built from. */
const built = new Map<EnvironmentKind, { key: string; target: THREE.WebGLRenderTarget }>();
/**
* Every scene this rig has written an environment onto, and which kind it
* was given.
*
* The kind is carried rather than just the scene because a page can hold both
* at once CONTRACT.md §1 keeps the city alive and paused while an office is
* on screen and when one kind rebuilds, the scenes that need the new texture
* are the ones on *that* kind. Handing a sunset city sky to the office
* standing beside it would light the room through a wall.
*/
const applied = new Map<THREE.Scene, EnvironmentKind>();
let disposed = false;
function generator(): THREE.PMREMGenerator {
if (!pmrem) pmrem = new THREE.PMREMGenerator(renderer);
return pmrem;
}
function build(kind: EnvironmentKind, lighting: LightingState): THREE.WebGLRenderTarget {
if (kind === "office") {
if (!room) room = createRoomProbe();
room.tune(lighting);
// A little blur at capture time. The room is nine flat quads and a hard
// edge between two of them would show up as a visible seam in the
// reflection on a polished desk; four hundredths of a radian is under a
// pixel of the cube face and is enough to take that edge off.
return generator().fromScene(room.scene, 0.04, 0.1, 40);
}
equirect = fillSkyEquirect(equirect, lighting);
return generator().fromEquirectangular(equirect);
}
return {
apply(scene, lighting, kind) {
if (disposed) return;
const key = environmentKey(kind, lighting);
const current = built.get(kind);
if (!current || current.key !== key) {
let target: THREE.WebGLRenderTarget;
try {
target = build(kind, lighting);
} catch (error) {
/*
* Degrade rather than take the frame down.
*
* Everything in here runs against a live GL context, and the two ways
* that goes wrong in the field are a lost context and a driver that
* refuses a half-float render target. Neither is a reason for a city
* to stop drawing: without an environment the world looks the way it
* looked before this file was written, which is worse and still a
* world. The warning is deliberately not swallowed silently a
* missing environment is very hard to diagnose from the picture
* alone, because "everything is slightly duller" does not look like
* an error.
*/
console.warn("environmentRig: could not build an environment map", error);
return;
}
current?.target.dispose();
built.set(kind, { key, target });
// The previous texture has just been freed, and every scene that was
// holding it is now pointing at a disposed target — not only the scene
// that happened to ask for the rebuild. Scenes on the other kind are
// left strictly alone.
for (const [other, otherKind] of applied) {
if (otherKind === kind) other.environment = target.texture;
}
}
const target = built.get(kind);
if (!target) return;
scene.environment = target.target.texture;
// Stated rather than left at its default, because the brightness of the
// environment is decided by the radiances above and a stray intensity
// here would silently override all of that reasoning.
scene.environmentIntensity = 1;
applied.set(scene, kind);
},
release(scene) {
if (!applied.delete(scene)) return;
scene.environment = null;
},
dispose() {
disposed = true;
for (const scene of applied.keys()) {
scene.environment = null;
}
applied.clear();
for (const entry of built.values()) entry.target.dispose();
built.clear();
equirect?.dispose();
equirect = null;
room?.dispose();
room = null;
// `PMREMGenerator.dispose()` frees its own blur materials and ping-pong
// target. It does not touch the targets it handed out, which is why they
// are disposed above first.
pmrem?.dispose();
pmrem = null;
},
};
}
// ---- Rebuild key ----------------------------------------------------------
/**
* A coarse fingerprint of the lighting, used to decide whether to rebuild.
*
* Coarse is the entire point. `Atmosphere` interpolates its keyframe table
* continuously, so on a running clock every field of a `LightingState` changes
* by a fraction every single frame an exact key would rebuild the environment
* sixty times a second and none of those rebuilds would be visible. Colours are
* reduced to five bits a channel and the sun direction to twelfths of a unit
* vector, which is roughly a five-degree bucket, so a real dawn still rebuilds
* often enough to track and a static afternoon rebuilds once.
*/
function environmentKey(kind: EnvironmentKind, l: LightingState): string {
const d = l.sun.direction;
return [
kind,
quantiseColor(l.sky?.top ?? l.hemisphere.sky),
quantiseColor(l.sky?.horizon ?? l.hemisphere.ground),
quantiseColor(l.hemisphere.sky),
quantiseColor(l.hemisphere.ground),
Math.round(l.hemisphere.intensity * 20),
quantiseColor(l.ambient.color),
Math.round(l.ambient.intensity * 20),
quantiseColor(l.sun.color),
Math.round(l.sun.intensity * 20),
Math.round(d[0] * 12),
Math.round(d[1] * 12),
Math.round(d[2] * 12),
].join(":");
}
/** 24-bit colour down to 15, which is finer than the eye reads off a gradient. */
function quantiseColor(hex: number): number {
return (((hex >> 19) & 0x1f) << 10) | (((hex >> 11) & 0x1f) << 5) | ((hex >> 3) & 0x1f);
}
// ---- The city sky ---------------------------------------------------------
/**
* Fill (or refill) the equirectangular sky.
*
* Written into an existing buffer where there is one. A `DataTexture` is 128 KB
* of `Uint16Array` and the rebuild happens on a timer nobody controls, so
* allocating a new one each time hands the garbage collector a steady drip of
* medium-sized buffers for no reason and the GPU-side texture object would be
* recreated with it, which is the expensive half.
*
* The sphere is built in three parts, all of them derived from the
* `LightingState` and none of them invented here:
*
* - **Above the horizon**, the same top-to-horizon gradient `scenekit.ts`
* paints on the background, so the environment and the visible sky are the
* same sky. It is blended on `sin(elevation)` raised to a power rather than
* linearly on the angle, because that is what puts the pale band near the
* horizon where the eye expects it.
* - **Below the horizon**, the hemisphere light's ground colour. The lower
* half of a real environment is the terrain, and leaving it black is the
* single most common way an environment map makes a car look like a toy.
* - **The sun**, as a narrow lobe plus a wide glow, at the direction
* `Atmosphere` already computed. Half-float storage is what makes this
* possible at all: the lobe sits several times above 1.0 and an 8-bit
* texture would clip it to the same white as the sky beside it, which is
* the exact failure `stage.ts` just removed from the main render path.
*/
function fillSkyEquirect(existing: THREE.DataTexture | null, l: LightingState): THREE.DataTexture {
const width = EQUIRECT_WIDTH;
const height = EQUIRECT_HEIGHT;
const texture =
existing ??
new THREE.DataTexture(
new Uint16Array(width * height * 4),
width,
height,
THREE.RGBAFormat,
THREE.HalfFloatType,
);
const data = texture.image.data as Uint16Array;
const top = linearOf(l.sky?.top ?? l.hemisphere.sky);
const horizon = linearOf(l.sky?.horizon ?? l.hemisphere.ground);
const ground = linearOf(l.hemisphere.ground);
const skyScale = SKY_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
const groundScale = GROUND_RADIANCE_SHARE * Math.max(0, l.hemisphere.intensity);
const sun = linearOf(l.sun.color);
const sunScale = Math.max(0, l.sun.intensity);
const [sx, sy, sz] = l.sun.direction;
const half = THREE.DataUtils.toHalfFloat;
for (let j = 0; j < height; j++) {
// Row 0 is v = 0. Three's `equirectUv` puts v = 0 at `dir.y = -1`, and a
// `DataTexture` does not flip, so row 0 is straight down.
const v = (j + 0.5) / height;
const phi = (v - 0.5) * Math.PI;
const sinPhi = Math.sin(phi);
const cosPhi = Math.cos(phi);
// The vertical blend, before the sun is added. Above the horizon it walks
// the sky gradient; below it fades the ground colour down as it goes under,
// so there is no hard band at the equator to show up in a mirror.
let baseR: number;
let baseG: number;
let baseB: number;
if (sinPhi >= 0) {
const t = Math.pow(sinPhi, 0.55);
baseR = (horizon[0] + (top[0] - horizon[0]) * t) * skyScale;
baseG = (horizon[1] + (top[1] - horizon[1]) * t) * skyScale;
baseB = (horizon[2] + (top[2] - horizon[2]) * t) * skyScale;
} else {
const t = Math.pow(-sinPhi, 0.7);
const dim = 1 - 0.55 * t;
baseR = (horizon[0] * (1 - t) * skyScale + ground[0] * t * groundScale) * dim;
baseG = (horizon[1] * (1 - t) * skyScale + ground[1] * t * groundScale) * dim;
baseB = (horizon[2] * (1 - t) * skyScale + ground[2] * t * groundScale) * dim;
}
for (let i = 0; i < width; i++) {
const u = (i + 0.5) / width;
const theta = (u - 0.5) * Math.PI * 2;
const dx = cosPhi * Math.cos(theta);
const dy = sinPhi;
const dz = cosPhi * Math.sin(theta);
const cos = dx * sx + dy * sy + dz * sz;
let solar = 0;
if (cos > 0) {
solar =
SUN_LOBE_RADIANCE * Math.pow(cos, SUN_LOBE_TIGHTNESS) +
SUN_GLOW_RADIANCE * Math.pow(cos, SUN_GLOW_TIGHTNESS);
solar *= sunScale;
}
const o = (j * width + i) * 4;
data[o] = half(baseR + sun[0] * solar);
data[o + 1] = half(baseG + sun[1] * solar);
data[o + 2] = half(baseB + sun[2] * solar);
data[o + 3] = half(1);
}
}
texture.mapping = THREE.EquirectangularReflectionMapping;
// Half-float data is already linear radiance; naming a transfer function here
// would apply an sRGB decode to numbers that were never encoded.
texture.colorSpace = THREE.NoColorSpace;
texture.needsUpdate = true;
return texture;
}
// ---- The office room ------------------------------------------------------
interface RoomProbe {
scene: THREE.Scene;
tune(l: LightingState): void;
dispose(): void;
}
/**
* Nine quads that reflect like a room.
*
* The list is short and every entry earns its place in a reflection: a bright
* ceiling and two brighter light panels (which is what puts the long vertical
* highlight down the edge of a monitor bezel), a floor darker than the walls, a
* window wall carrying the sun's own colour, and a warm end wall so the room
* has a direction to it and a chrome chair leg is not the same colour all the
* way round.
*
* Built once and re-coloured, not rebuilt. `tune` only assigns to
* `material.color`, so the geometry, the materials and their shader programs
* survive every change of hour.
*/
function createRoomProbe(): RoomProbe {
const scene = new THREE.Scene();
const geometries: THREE.BufferGeometry[] = [];
const materials: THREE.MeshBasicMaterial[] = [];
function quad(
w: number,
h: number,
position: [number, number, number],
rotation: [number, number, number],
): THREE.MeshBasicMaterial {
const geometry = new THREE.PlaneGeometry(w, h);
// `DoubleSide` so the probe camera at the origin sees every quad whichever
// way it was authored — an inside-out wall in an environment reads as a
// hole, and a hole reads as a black stripe across everything shiny.
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(position[0], position[1], position[2]);
mesh.rotation.set(rotation[0], rotation[1], rotation[2]);
scene.add(mesh);
geometries.push(geometry);
materials.push(material);
return material;
}
const HALF_W = ROOM_WIDTH / 2;
const HALF_D = ROOM_DEPTH / 2;
const HALF_H = ROOM_HEIGHT / 2;
const ceiling = quad(ROOM_WIDTH, ROOM_DEPTH, [0, HALF_H, 0], [Math.PI / 2, 0, 0]);
const floor = quad(ROOM_WIDTH, ROOM_DEPTH, [0, -HALF_H, 0], [-Math.PI / 2, 0, 0]);
const back = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, -HALF_D], [0, 0, 0]);
const front = quad(ROOM_WIDTH, ROOM_HEIGHT, [0, 0, HALF_D], [0, Math.PI, 0]);
const left = quad(ROOM_DEPTH, ROOM_HEIGHT, [-HALF_W, 0, 0], [0, Math.PI / 2, 0]);
const window = quad(ROOM_DEPTH, ROOM_HEIGHT, [HALF_W, 0, 0], [0, -Math.PI / 2, 0]);
const panelA = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, -1.4], [Math.PI / 2, 0, 0]);
const panelB = quad(ROOM_WIDTH * 0.62, 0.5, [0, HALF_H - 0.02, 1.4], [Math.PI / 2, 0, 0]);
const accent = quad(ROOM_WIDTH * 0.9, 0.35, [0, -0.6, -HALF_D + 0.01], [0, 0, 0]);
return {
scene,
tune(l) {
const sky = linearOf(l.hemisphere.sky);
const groundC = linearOf(l.hemisphere.ground);
const ambient = linearOf(l.ambient.color);
const sun = linearOf(l.sun.color);
// An interior probe is normalised against the *fill*, not against the
// sun: a room's own surfaces are what a desk reflects, and they are lit
// by whatever is getting inside. Reading `hemisphere.intensity` keeps a
// night office reflecting a dim room and a noon office a bright one
// without this file forming its own opinion about either.
const fill = Math.max(0.05, l.hemisphere.intensity) * SKY_RADIANCE_SHARE;
const solar = Math.max(0, l.sun.intensity) * SKY_RADIANCE_SHARE;
setLinear(ceiling, ambient, fill * 1.5);
setLinear(floor, groundC, fill * 0.8);
setLinear(back, ambient, fill * 1.1);
setLinear(front, ambient, fill * 1.0);
setLinear(left, ambient, fill * 1.2);
// The window is the only surface that knows what time it is, and it is
// the one that gives a monitor bezel a bright edge on the daylight side.
setLinear(window, sun, solar * 2.2 + fill * 0.6);
setLinear(panelA, sky, fill * 7);
setLinear(panelB, sky, fill * 7);
setLinear(accent, groundC, fill * 1.6);
},
dispose() {
for (const g of geometries) g.dispose();
for (const m of materials) m.dispose();
geometries.length = 0;
materials.length = 0;
scene.clear();
},
};
}
// ---- Colour ---------------------------------------------------------------
const SCRATCH = new THREE.Color();
/**
* An authored `0xrrggbb` as linear-light RGB.
*
* Named rather than inlined because getting it wrong is invisible until it is
* everywhere: `LightingState` colours are sRGB by the definition in `types.ts`,
* and everything on this page is radiance, which is linear. Multiplying an
* un-decoded 0.5 by an intensity is off by more than a factor of two at the
* dark end of the range, and the symptom is a night sky that reflects like an
* overcast noon.
*/
function linearOf(hex: number): [number, number, number] {
SCRATCH.setHex(hex, THREE.SRGBColorSpace);
return [SCRATCH.r, SCRATCH.g, SCRATCH.b];
}
/**
* Set a material's colour from linear radiance, which may exceed 1.
*
* `Color.setHex` and the `{ color }` constructor argument both go through an
* sRGB decode and both saturate at 1.0, so neither can express a light panel
* seven times brighter than the wall beside it. `setRGB` in the working colour
* space can, and a light panel that cannot be brighter than the wall is not a
* light panel.
*/
function setLinear(
material: THREE.MeshBasicMaterial,
color: readonly [number, number, number],
scale: number,
): void {
material.color.setRGB(color[0] * scale, color[1] * scale, color[2] * scale);
}
+202 -6
View File
@@ -2,12 +2,14 @@
* Aircraft over the city.
*
* The engine takes a `FlightSource` rather than talking to any particular
* service, because the obvious one cannot ship here. FlightRadar24's terms
* forbid scraping and forbid redistributing their data, so an Apache-2.0 repo
* containing an FR24 client would be publishing instructions for breaking a
* ToS and shipping data it has no right to relicense. Commercial sources are
* adapters in a private deployment; this file holds what we can actually give
* away. See ARCHITECTURE.md §4.
* service, because the obvious one cannot ship here. FlightRadar24's terms do
* not permit scraping and do not permit redistributing their data. An
* Apache-2.0 repo shipping such a client would not merely be breaking a ToS
* it would be publishing instructions for doing so, alongside data it has no
* right to relicense. Commercial sources are adapters in a private deployment;
* this file holds what we can actually give away. See ARCHITECTURE.md §4, and
* `server/src/flights/licence.ts` for the allowlist that keeps the open lane
* open in practice rather than in principle.
*
* `SimulatedFlights` is the default and is genuinely enough for the map what
* a city view wants is convincing motion in the right corridors, not a
@@ -345,6 +347,16 @@ const ADSB_HOLD_SECONDS = 60;
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
* first-party data, nothing to comply with.
*
* **`endpoint` is not free-form, even though its type is `string`.** The
* allowlist of feeds this project will fetch, and the credit line each of them
* is owed, live in `server/src/flights/licence.ts`, which is where the API's
* `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing
* a feed for itself is not republishing it and so is not the exposure that gate
* exists for but a self-hoster who constructs this class with some other
* endpoint is choosing terms nobody here has read, and this is the sentence
* that says so. Nothing in this repo constructs it: the shipped path is
* `HttpFlights` against our own API.
*
* The region is required and has no default. It used to default to a point in
* San Francisco, which is a fine centre for one of the two cities in this build
* and a five-hundred-kilometre error for the other and a wrong default is
@@ -445,10 +457,183 @@ interface RawAircraft {
track?: number;
}
// ---- Detail ---------------------------------------------------------------
/**
* One aircraft, described well enough to put on a card somebody clicked.
*
* The demo this project leads with is a signed-out visitor clicking a dart over
* a city they recognise and being told what it is, so this type is written for
* **anon** and carries nothing an account would be needed for. Everything in it
* is either broadcast unencrypted by the aircraft itself ADS-B is receivable
* with a forty-dollar dongle or arithmetic on top of that. There is no route,
* no registration and no operator here, because the open feeds do not carry
* them and inventing them would be the same class of lie `synthetic` exists to
* prevent. `owner-decisions.md` reserves those for an openly-licensed registry
* we have not wired.
*
* Two fields are about the *provenance* rather than the aeroplane, and they are
* the reason this is a type and not an object literal built in the UI:
* `observed` says whether anybody actually saw this, and `attribution` carries
* whatever the feed asks to be credited with **at the point the data is
* displayed**, which is what an ODbL notice is for. A card is a display. A
* corner label on the other side of the screen is not obviously one.
*/
export interface AircraftDetail {
/** The source's own id. The ICAO address for a live feed; a route name for the simulator. */
id: string;
/** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */
callsign: string | null;
/**
* The transponder's 24-bit ICAO address, lowercase hex, or `null`.
*
* `null` rather than a guess for anything that does not look like one the
* simulator's ids are route names and a `~`-prefixed id on a real feed is a
* non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely
* not an ICAO24 and must not be presented as one. Somebody can paste this
* into a registry lookup, so a wrong one sends them to another aircraft.
*/
icao24: string | null;
lat: number;
lng: number;
/** Barometric altitude, metres — the unit the wire and the engine both use. */
altitudeM: number;
/** The same altitude in feet, which is the unit aviation is actually read in. */
altitudeFt: number;
/** Degrees clockwise from true north. */
headingDeg: number;
/** The heading as a 16-point compass name, for a card a human reads. */
headingCompass: string;
/** Nautical miles from the board's centre, or `null` when no centre was given. */
distanceNm: number | null;
/**
* Did somebody observe this, or did this repo invent it?
*
* The same statement `TrafficSource.live()` makes about the whole feed, made
* about one aircraft, and it must travel with the aircraft: a card is read on
* its own, away from any corner label, and a fabricated flight number
* presented in the same frame as a real one is the confusion the `live` flag
* exists to prevent.
*/
observed: boolean;
/** Credit lines owed for this aircraft, to be shown on the card itself. */
attribution: string[];
}
/**
* An ICAO 24-bit address as the feeds write it: six hex digits, lowercase.
*
* Anchored, so `sim-BA286` fails and `~abc123` the anonymous-address form
* both community feeds emit for targets whose real address is not known fails
* too, which is the point. See `AircraftDetail.icao24`.
*/
const ICAO24 = /^[0-9a-f]{6}$/;
/** The sixteen names, in the order the compass runs. */
const COMPASS = [
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
];
/**
* A bearing as a compass point.
*
* Sixteen points rather than eight because the difference between "north-east"
* and "east-north-east" is the difference between two departure corridors, and
* rather than thirty-two because nobody reads "NNE by N" off a card. Negative
* and out-of-range degrees are wrapped rather than refused: a heading is an
* angle and every angle names a direction.
*/
export function compassPoint(degrees: number): string {
if (!Number.isFinite(degrees)) return "—";
const wrapped = ((degrees % 360) + 360) % 360;
return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
}
/** Metres to feet. The wire carries metres; aviation is read in feet. */
const FEET_PER_METRE = 3.280_84;
export interface AircraftDetailOptions {
/**
* The transponder address, when the caller was told one separately.
*
* `HttpFlights` is: `WireAircraft.icao24` is a field on the body and
* `Aircraft` has nowhere to put it, so the adapter keeps the wire record
* beside the position and hands it back here. Absent, the id is tested
* against `ICAO24` which is right for every feed that keys on the hex, and
* correctly declines for the simulator.
*/
icao24?: string | null;
/** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */
observed?: boolean;
/** Credit lines the feed asks for, shown on the card. */
attribution?: readonly string[];
/** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */
from?: Place;
}
/**
* Turn an `Aircraft` into something a panel can render, without the panel
* knowing where aircraft come from.
*
* Pure, total and free of I/O, so the interface layer can call it on a click
* without awaiting anything, and so it can be tested without a network. It
* invents nothing: every field is a restatement, a unit conversion or a `null`.
*/
export function aircraftDetail(
aircraft: Aircraft,
options: AircraftDetailOptions = {},
): AircraftDetail {
const callsign = aircraft.callsign?.trim();
const declared = options.icao24?.trim().toLowerCase();
const fromId = aircraft.id.trim().toLowerCase();
const icao24 =
declared !== undefined && ICAO24.test(declared)
? declared
: ICAO24.test(fromId)
? fromId
: null;
return {
id: aircraft.id,
callsign: callsign === undefined || callsign === "" ? null : callsign,
icao24,
lat: aircraft.lat,
lng: aircraft.lng,
altitudeM: aircraft.altitude,
altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE),
headingDeg: aircraft.heading,
headingCompass: compassPoint(aircraft.heading),
distanceNm:
options.from === undefined
? null
: Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10,
observed: options.observed === true,
attribution: [...(options.attribution ?? [])],
};
}
// ---- Rendering ------------------------------------------------------------
export interface FlightLayer {
group: THREE.Group;
/**
* The aircraft meshes currently in the sky, as a **live** array, each
* carrying `userData.aircraftId`.
*
* Here rather than on the caller because only this layer knows which mesh is
* which track: the map from id to mesh is private and the group's child order
* is an artefact of when each aircraft appeared. It is the same shape
* `MarkerLayer.pickables` publishes and it exists for the same reason a
* pick is resolved from the object that was hit, and something has to say
* what the object stands for.
*
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
* position is broadcast unencrypted to anybody with a receiver, so the card
* it opens is available to an anonymous visitor and the picking that reaches
* it must be too.
*/
pickables: THREE.Object3D[];
/**
* Hand over a fresh observation. Called on the source's own timer, which is
* once a second for the simulator and once every several seconds for a real
@@ -716,6 +901,9 @@ interface Track {
export function createFlightLayer(world: World): FlightLayer {
const group = new THREE.Group();
group.name = "flights";
// Mutated in place as tracks appear and expire, so `setPicking` can hold the
// array itself as its target list rather than re-reading it every pointer move.
const pickables: THREE.Object3D[] = [];
const geo = airlinerGeometry();
const materials = new Map<number, THREE.MeshLambertMaterial>();
@@ -799,7 +987,11 @@ export function createFlightLayer(world: World): FlightLayer {
// Yaw then pitch, because the heading is about the world's vertical and
// the climb angle is about the aircraft's own wing.
mesh.rotation.order = "YXZ";
// The id, on the object, so a raycast hit resolves to an aeroplane
// without this layer having to expose its private track table.
mesh.userData.aircraftId = a.id;
group.add(mesh);
pickables.push(mesh);
track = {
mesh,
samples: [],
@@ -921,6 +1113,8 @@ export function createFlightLayer(world: World): FlightLayer {
if (track.missingSince === 0) track.missingSince = now;
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
group.remove(track.mesh);
const at = pickables.indexOf(track.mesh);
if (at >= 0) pickables.splice(at, 1);
tracks.delete(id);
}
@@ -1139,6 +1333,7 @@ export function createFlightLayer(world: World): FlightLayer {
return {
group,
pickables,
update,
tick,
dispose() {
@@ -1148,6 +1343,7 @@ export function createFlightLayer(world: World): FlightLayer {
trailGeo.dispose();
trailMat.dispose();
tracks.clear();
pickables.length = 0;
group.clear();
},
};
+490
View File
@@ -0,0 +1,490 @@
/**
* The apron outside the front door, and the car standing on it.
*
* Every shipped pack authors one `ExteriorArrival` a marked stall on the
* ground outside the building, in the pack's own plan frame. This layer turns
* that one anchor into a piece of the world: a paved pad, a painted bay, a kerb,
* a charge post, and a Model X parked in it whose lamps and cabin reflect a live
* {@link VehicleTelemetryState}.
*
* ### Why the car stands here rather than on the board
*
* There has been a Model X in this product since the freeway corridor shipped,
* and it has only ever existed as traffic: forty instanced glyphs at 0.18 scale
* on a board where one scene unit is 94 metres, seen from four kilometres up.
* That is a map symbol. This is the same asset at 1 unit = 1 m, three metres
* from a doorway you can walk through, which is the first time anything in the
* product has asked it to be a car and it is why `assets/vehicles/modelX.ts`
* was rebuilt with arches, glass openings and a real tyre.
*
* ### What this layer owns and what it borrows
*
* It owns geometry and exactly one material (the cabin glow, whose emissive
* strength varies continuously and therefore cannot be a shared registry
* material). Everything else is borrowed: surface roles come from the
* `MaterialRegistry`, indicator colours come from `materials.tinted`, and the
* kerbside planter is built through the `AssetRegistry` so a self-hoster who has
* re-skinned `tera:planter.trough` gets their trough out here too.
*
* `dispose()` frees what this layer made and deliberately leaves the registries
* alone. Disposing a shared `polishedConcrete` here would blank the floor of the
* office the apron stands outside of.
*
* ### It constructs no light
*
* CONTRACT §4: `Atmosphere` is the sole light owner. A charge lamp, a marker
* lamp and a lit cabin are all *emissive materials*, exactly as
* `interiors/luminaires.ts` makes a ceiling fitting glow without becoming one.
* Nothing in this file is a light source, and the release gate's grep for the
* five three.js light constructors returns nothing over it on purpose.
*/
import * as THREE from "three";
import type { AssetRegistry } from "../assets/kit.ts";
import { createAssetContext } from "../assets/kit.ts";
import type { MaterialRegistry } from "../assets/materials.ts";
import { MeshBin, parts } from "../assets/parts.ts";
import {
MODEL_X_METRICS,
MODEL_X_PAINTS,
buildModelX,
disposeModelX,
type ModelXDetail,
} from "../assets/vehicles/index.ts";
import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts";
import {
apronKindFor,
apronMetrics,
exteriorVehicleAppearance,
lampTint,
parkPose,
type ApronMetrics,
type ExteriorVehicleAppearance,
} from "../transport/exteriorVehicle.ts";
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
export interface OfficeExteriorOptions {
site: OfficeSite;
arrival: ExteriorArrival;
assets: AssetRegistry;
materials: MaterialRegistry;
/** Seeded per office, so the same studio has the same car outside it forever. */
rand: () => number;
/**
* Which Model X to build.
*
* `corridor` is the right answer for a parked car and is what a caller should
* pass unless the camera is close enough to read a door shutline: 3,192
* triangles across 18 draw calls, against 13,080 and 25 for `follow`. The
* difference a viewer can see at three metres is the wing mirrors, the glass
* frames and the brake calipers; at ten it is nothing at all.
*/
detail: ModelXDetail;
}
export interface OfficeExterior {
object: THREE.Object3D;
/** Reflect one telemetry observation. Cheap, idempotent, safe every frame. */
apply(telemetry: VehicleTelemetryState): void;
dispose(): void;
}
/**
* Where the charge flap sits on the vehicle's left rear quarter, in the asset's
* own frame.
*
* These are not invented: they are the surface the bodyshell loft actually
* produces there, measured by casting a ray across the flank at that height
* (`x = 1.0004` on both LODs), plus 3.6 mm so the flap stands proud the way a
* real one does rather than z-fighting with the paint. Aft of the rear arch and
* below the shoulder line, which is where a charge port goes on every car that
* has one.
*/
const CHARGE_PORT = { x: -1.004, y: 0.86, z: 1.95 } as const;
/** Radius of the charge-flap ring, metres. */
const CHARGE_PORT_RADIUS = 0.055;
/**
* The cabin glow slab, in the vehicle's frame: a dome light under the roof.
*
* Deliberately a thin horizontal plate rather than a filled cabin volume. The
* greenhouse is real glass with an interior behind it, so a lit box would be
* seen *through* the seats; a plate at head height reads as the light being on
* and disappears when it is not.
*/
const CABIN_GLOW = { width: 1.24, thickness: 0.02, depth: 2.05, y: 1.3, z: -0.05 } as const;
/** How hard the cabin plate is driven at `cabinGlow === 1`. */
const CABIN_GLOW_MAX_INTENSITY = 1.6;
/**
* Build the apron and the vehicle at a pack's arrival anchor.
*
* The returned object sits at the plan's origin with an identity transform, and
* everything inside it is positioned in the pack's own metres so a caller adds
* it to the level group and nothing has to agree about a frame. That is also
* what makes the anchor assertable: the Model X's world position is
* `arrival.position` plus a bounded parking jitter, and nothing else.
*/
export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExterior {
const { site, arrival, assets, materials, rand, detail } = options;
const root = new THREE.Group();
root.name = "office-exterior";
root.userData.kind = "office-exterior";
root.userData.arrivalKind = arrival.kind;
if (arrival.label) root.userData.label = arrival.label;
if (site.label) root.userData.siteLabel = site.label;
const kind = apronKindFor(site.elevation);
const metrics = apronMetrics(
{ length: MODEL_X_METRICS.length, width: MODEL_X_METRICS.width },
kind,
);
// Geometries this layer minted and must free. Registry materials are not in
// here and must not be: they belong to the office this apron stands outside.
const ownedGeometries: THREE.BufferGeometry[] = [];
const ownedMaterials: THREE.Material[] = [];
// ---- The apron ----------------------------------------------------------
const apron = new THREE.Group();
apron.name = "office-exterior:apron";
apron.position.set(arrival.position.x, 0, arrival.position.z);
// `Yaw` is `object.rotation.y` with no conversion (interiors/types.ts), which
// is why nothing in this file converts an angle.
apron.rotation.y = arrival.rotation;
root.add(apron);
const pavingColor = kind === "street" ? 0x6f7370 : 0x8d908a;
const paving = materials.tinted("polishedConcrete", pavingColor);
const kerbMaterial = materials.get("skirting");
const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd);
const postShell = materials.get("deviceShell");
const postCap = materials.get("metalTrim");
// Two bins rather than one, split on whether the piece casts a shadow. A
// `MeshBin` sets the flags per build, and a flat slab lying on the ground has
// nothing to cast onto while a 1.3 m post very much does.
const flat = new MeshBin();
const upright = new MeshBin();
const top = metrics.padThickness;
// The slab. `metricQuad` under the top face rather than a scaled `quad`,
// because the paving carries a texture and its relief now carries a normal
// map: a unit quad scaled to six metres smears one 2 m tile across the whole
// pad, colour and relief together.
flat.box(paving, {
y: 0,
size: [metrics.padWidth, metrics.padThickness, metrics.padDepth],
});
flat.add(parts.metricQuad(metrics.padWidth, metrics.padDepth), paving, { y: top + 0.001 });
// A kerb upstand around the pad, with a dropped crossing at the open end.
//
// Two decisions in one shape, and both are worth naming. The upstand runs all
// the way round rather than along one edge because the exterior layer is
// handed a stall and a site and *neither of them says which side the street
// is on*: `mateo-court`'s bay runs east along Mateo Street with the façade to
// its left, `frontier-valley`'s faces an apron with taxiway on three sides,
// and `lumbridge-hq`'s has no street at all. A carriageway laid on a guessed
// side would be wrong half the time; a kerbed island is right every time.
//
// The gap at +Z is the crossing the car drove in over. Without it the kerb
// closes the bay on all four sides and the car reads as having been craned
// into a planter, which is a small thing that reliably breaks the illusion.
const openingWidth = metrics.stallWidth + 0.4;
const returnWidth = Math.max(0.2, (metrics.padWidth - openingWidth) / 2);
const returnX = openingWidth / 2 + returnWidth / 2;
const frontZ = metrics.padDepth / 2 - metrics.kerbDepth / 2;
const kerbRuns: [number, number, number, number][] = [
// The head of the bay, and the two long flanks.
[0, -metrics.padDepth / 2 + metrics.kerbDepth / 2, metrics.padWidth, metrics.kerbDepth],
[-metrics.padWidth / 2 + metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
[metrics.padWidth / 2 - metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
// The two returns either side of the crossing.
[-returnX, frontZ, returnWidth, metrics.kerbDepth],
[returnX, frontZ, returnWidth, metrics.kerbDepth],
];
for (const [x, z, w, d] of kerbRuns) {
upright.box(kerbMaterial, { x, y: top, z, size: [w, metrics.kerbHeight, d] });
}
// The painted bay: two flanks and a head. Three strips and not a rectangle
// outline, because a bay is open at the end you drive in through, and the
// open end is what tells a viewer which way the car came in.
const lineY = top + 0.004;
const halfW = metrics.stallWidth / 2;
const halfL = metrics.stallLength / 2;
flat.box(lineMaterial, {
x: -halfW, y: lineY, z: 0,
size: [metrics.lineWidth, 0.004, metrics.stallLength],
});
flat.box(lineMaterial, {
x: halfW, y: lineY, z: 0,
size: [metrics.lineWidth, 0.004, metrics.stallLength],
});
flat.box(lineMaterial, {
x: 0, y: lineY, z: -halfL,
size: [metrics.stallWidth, 0.004, metrics.lineWidth],
});
// The charge post. A moulded column with a metal cap and a recessed face; the
// lamps and the charge bar are separate meshes because they change.
upright.box(postShell, {
x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
size: [metrics.postWidth, metrics.postHeight, metrics.postDepth],
});
upright.box(postCap, {
x: metrics.postOffsetX, y: top + metrics.postHeight, z: metrics.postOffsetZ,
size: [metrics.postWidth + 0.03, 0.035, metrics.postDepth + 0.03],
});
upright.box(postCap, {
x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
size: [metrics.postWidth + 0.06, 0.05, metrics.postDepth + 0.06],
});
for (const group of [
flat.build("office-exterior:paving", { castShadow: false }),
upright.build("office-exterior:furniture", { castShadow: true }),
]) {
for (const child of group.children) {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
}
apron.add(group);
}
// ---- Kerbside dressing, through the asset registry ----------------------
//
// Built rather than modelled inline so that a self-hoster who registered
// `acme:planter.trough` with `overrides: "tera:planter.trough"` gets their
// planter out here as well as inside. Gated on `has()` because a stripped
// registry is a legitimate configuration and a placeholder box on the kerb is
// worse than an empty kerb.
const PLANTER_ID = "tera:planter.trough";
if (assets.has(PLANTER_ID)) {
const planter = assets.build(
PLANTER_ID,
createAssetContext({ materials, registry: assets, rand }),
);
const footprint = assets.footprintOf(PLANTER_ID);
// Turned side-on so its length runs along the bay rather than across it,
// and set just inside the kerb on the side away from the charge post.
planter.rotation.y = Math.PI / 2;
planter.position.set(halfW + footprint.depth / 2 + 0.18, top, 0);
planter.name = "office-exterior:planter";
apron.add(planter);
planter.traverse((object) => {
const mesh = object as THREE.Mesh;
if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
});
}
// ---- Indicators ---------------------------------------------------------
//
// One geometry shared by three lamps, and a material per lamp swapped on the
// way through `materials.tinted`. See `LAMP_INTENSITY_STEPS` for why the
// brightness is quantised rather than continuous.
const lampGeometry = new THREE.BoxGeometry(
metrics.lampSize, metrics.lampSize, metrics.lampSize,
);
ownedGeometries.push(lampGeometry);
function makeLamp(name: string, index: number): THREE.Mesh {
const mesh = new THREE.Mesh(lampGeometry, materials.tinted("deviceIndicator", 0x000000));
mesh.name = `office-exterior:${name}`;
mesh.position.set(
metrics.postOffsetX,
top + metrics.lampHeight - index * (metrics.lampSize + 0.04),
metrics.postOffsetZ + metrics.postDepth / 2,
);
apron.add(mesh);
return mesh;
}
const chargeLamp = makeLamp("lamp-charge", 0);
const climateLamp = makeLamp("lamp-climate", 1);
const lockLamp = makeLamp("lamp-lock", 2);
// The charge bar. Scaling a mesh is free; minting a material per percent is
// not, so the *quantity* is the scale and the colour is fixed.
const barGeometry = new THREE.BoxGeometry(0.045, 1, 0.012).translate(0, 0.5, 0);
ownedGeometries.push(barGeometry);
const chargeBar = new THREE.Mesh(
barGeometry,
materials.tinted("deviceIndicator", 0x46d07a),
);
chargeBar.name = "office-exterior:charge-bar";
chargeBar.position.set(
metrics.postOffsetX,
top + 0.24,
metrics.postOffsetZ + metrics.postDepth / 2,
);
apron.add(chargeBar);
// ---- The vehicle --------------------------------------------------------
const pose = parkPose(arrival, rand);
const vehicle = new THREE.Group();
vehicle.name = "office-exterior:vehicle";
vehicle.position.set(pose.x, top, pose.z);
vehicle.rotation.y = pose.yaw;
root.add(vehicle);
const paintIndex = Math.min(
MODEL_X_PAINTS.length - 1,
Math.max(0, Math.floor(rand() * MODEL_X_PAINTS.length)),
);
const rig = buildModelX({ detail, paint: MODEL_X_PAINTS[paintIndex] ?? 0x465157 });
rig.root.name = "office-exterior:model-x";
vehicle.add(rig.root);
const portGeometry = new THREE.CircleGeometry(CHARGE_PORT_RADIUS, 20);
ownedGeometries.push(portGeometry);
const chargePort = new THREE.Mesh(
portGeometry,
materials.tinted("deviceIndicator", 0x000000),
);
chargePort.name = "office-exterior:charge-port";
chargePort.position.set(CHARGE_PORT.x, CHARGE_PORT.y, CHARGE_PORT.z);
// A `CircleGeometry` faces +Z; the flap is on the vehicle's left, so it turns
// to face X.
chargePort.rotation.y = -Math.PI / 2;
vehicle.add(chargePort);
const cabinGeometry = new THREE.BoxGeometry(
CABIN_GLOW.width, CABIN_GLOW.thickness, CABIN_GLOW.depth,
);
ownedGeometries.push(cabinGeometry);
// The one material this layer owns. It cannot come from the registry because
// its `emissiveIntensity` is a continuous function of telemetry, and a shared
// material is shared: dimming this one would dim every indicator in the
// office with it.
const cabinMaterial = new THREE.MeshStandardMaterial({
name: "office-exterior.cabin-glow",
color: 0xffe9c8,
emissive: 0xffe4bc,
emissiveIntensity: 0,
roughness: 1,
metalness: 0,
transparent: true,
opacity: 0.9,
// The plate lives inside a closed glass volume and is only ever seen through
// it. Writing depth would let it punch a hole in the tinted glass in front
// of it, which reads as a rectangular window cut in the roof.
depthWrite: false,
});
ownedMaterials.push(cabinMaterial);
const cabinGlow = new THREE.Mesh(cabinGeometry, cabinMaterial);
cabinGlow.name = "office-exterior:cabin-glow";
cabinGlow.position.set(0, CABIN_GLOW.y, CABIN_GLOW.z);
cabinGlow.castShadow = false;
cabinGlow.receiveShadow = false;
cabinGlow.visible = false;
vehicle.add(cabinGlow);
// ---- The cable ----------------------------------------------------------
//
// Built in the root's frame rather than either child's, because its two ends
// live in different frames: the socket is on the post (apron frame) and the
// flap is on the car (vehicle frame, which carries the parking jitter). A
// curve between two world points is the only version of this that stays
// attached when the car parks 90 mm off the line.
// Both children have just been positioned and nothing has rendered yet, so
// their world matrices are stale until this runs.
root.updateMatrixWorld(true);
const socket = apron.localToWorld(
new THREE.Vector3(
metrics.postOffsetX,
top + 0.72,
metrics.postOffsetZ + metrics.postDepth / 2,
),
);
const flap = vehicle.localToWorld(
new THREE.Vector3(CHARGE_PORT.x - 0.02, CHARGE_PORT.y, CHARGE_PORT.z),
);
const sag = socket.clone().add(flap).multiplyScalar(0.5);
// A charging cable hangs. Half a metre of droop over a two-metre span is what
// a heavy DC lead actually does, and it is the difference between a cable and
// a stick.
sag.y = Math.min(socket.y, flap.y) - 0.42;
const cableGeometry = new THREE.TubeGeometry(
new THREE.QuadraticBezierCurve3(socket, sag, flap),
12,
0.021,
6,
false,
);
ownedGeometries.push(cableGeometry);
const cable = new THREE.Mesh(cableGeometry, postShell);
cable.name = "office-exterior:cable";
cable.castShadow = true;
cable.receiveShadow = true;
cable.visible = false;
root.add(cable);
// ---- Telemetry ----------------------------------------------------------
/**
* The last appearance applied, as a string.
*
* `apply` is safe to call every frame and a caller should not have to know
* that. Everything downstream of a change is cheap except the registry tint
* lookups, and those are a `Map.get` and a string concat each small, but
* three of them sixty times a second for a car that has not changed state in
* an hour is work nobody asked for.
*/
let lastSignature = "";
function signatureOf(look: ExteriorVehicleAppearance, plugged: boolean): string {
return [
lampTint(look.charge), lampTint(look.climate), lampTint(look.lock),
look.cabinGlow.toFixed(3), look.chargeFraction.toFixed(3), plugged ? 1 : 0,
].join("|");
}
function apply(telemetry: VehicleTelemetryState): void {
const look = exteriorVehicleAppearance(telemetry);
const signature = signatureOf(look, telemetry.pluggedIn);
if (signature === lastSignature) return;
lastSignature = signature;
chargeLamp.material = materials.tinted("deviceIndicator", lampTint(look.charge));
climateLamp.material = materials.tinted("deviceIndicator", lampTint(look.climate));
lockLamp.material = materials.tinted("deviceIndicator", lampTint(look.lock));
chargePort.material = materials.tinted("deviceIndicator", lampTint(look.charge));
// A zero-height bar is a degenerate mesh rather than an absent one, so the
// floor of the scale is a millimetre and visibility carries the rest.
chargeBar.scale.y = Math.max(0.001, look.chargeFraction * 0.62);
chargeBar.visible = look.chargeFraction > 0.005;
cabinMaterial.emissiveIntensity = look.cabinGlow * CABIN_GLOW_MAX_INTENSITY;
cabinGlow.visible = look.cabinGlow > 0.01;
cable.visible = telemetry.pluggedIn;
}
return {
object: root,
apply,
dispose() {
// The rig owns its own materials (nothing external was handed in), so it
// frees them; `disposeModelX` already defaults to exactly that when
// `ownsMaterials` is true, and it is stated here rather than implied.
disposeModelX(rig, { disposeMaterials: true });
for (const geometry of ownedGeometries) geometry.dispose();
ownedGeometries.length = 0;
for (const material of ownedMaterials) material.dispose();
ownedMaterials.length = 0;
root.clear();
apron.clear();
vehicle.clear();
},
};
}
/** Re-exported so a caller can size a bay without reaching into `transport/`. */
export type { ApronMetrics };
+9 -1
View File
@@ -35,7 +35,15 @@ export interface RoadTrafficOptions {
seed?: number;
/** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
scale?: number;
/** Route-distance compression for playable corridor travel. Defaults to 900. */
/**
* Route-distance compression for playable corridor travel. Defaults to 900.
*
* The counterpart is `METRE_SCALE_VEHICLE_OPTIONS` in
* `transport/exteriorVehicle.ts`, which is this same controller at 1 for the
* apron outside a studio. One state machine, one dial; see the note on
* `VehicleControllerOptions.travelScale` for what the dial does and does not
* touch.
*/
travelScale?: number;
}
+40 -2
View File
@@ -300,6 +300,20 @@ export interface SatelliteLayer {
group: THREE.Group;
/** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
update(fixes: SatelliteFix[]): void;
/**
* How dark the sky is, 0..1 `nightFactor(sun.elevation)` from
* `atmosphere.ts`, and nothing else.
*
* This layer draws additively, which is correct at night and catastrophic in
* daylight: at 15:55 with the sun at +44° every dot was adding to an already
* bright sky and clipping to a hard white square, which is what a first-time
* visitor to the California board saw scattered across the frame before
* anything else registered. A satellite in daylight is not visible to the
* naked eye, so the honest alpha is zero and the fade is the *same* curve
* `nightlights.ts` switches the city on with, so the sky does not empty at a
* different dusk from the one the windows light up at.
*/
setSkyDarkness(darkness: number): void;
/**
* Whether the layer draws at all. The catalogue keeps propagating either way
* see `setVisible` for why that is deliberate rather than wasteful.
@@ -481,6 +495,24 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
const scratchVec = new THREE.Vector3();
/**
* 1 until somebody says otherwise, so a caller that never calls
* `setSkyDarkness` gets exactly the behaviour this layer had before it
* existed. A silent regression to an invisible sky would be worse than the
* defect being fixed.
*/
let skyDarkness = 1;
/** Whether the godmode switch wants this layer at all. Two questions, two flags. */
let wanted = true;
/** The hour outranks the switch: a god at noon still gets no white squares. */
function applyVisibility(): void {
// Below a fiftieth the dots contribute nothing a screen can show, and
// skipping the draw entirely is what makes the daytime cost of this layer
// zero rather than merely invisible.
group.visible = wanted && skyDarkness > 0.02;
}
function update(fixes: SatelliteFix[]): void {
let n = 0;
for (const fix of fixes) {
@@ -500,10 +532,11 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
colors[n * 4] = scratch.r;
colors[n * 4 + 1] = scratch.g;
colors[n * 4 + 2] = scratch.b;
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit);
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit) * skyDarkness;
n += 1;
}
applyVisibility();
geo.setDrawRange(0, n);
positionAttr.needsUpdate = true;
colorAttr.needsUpdate = true;
@@ -512,6 +545,10 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
return {
group,
update,
setSkyDarkness(darkness) {
skyDarkness = Math.min(1, Math.max(0, darkness));
applyVisibility();
},
/**
* Hiding the layer stops it drawing and does **not** stop the catalogue
* propagating, which is the right way round: turning the sky back on should
@@ -521,7 +558,8 @@ export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
* re-entry is worth more than reclaiming it.
*/
setVisible(visible: boolean) {
group.visible = visible;
wanted = visible;
applyVisibility();
},
dispose() {
geo.dispose();
+125 -12
View File
@@ -34,6 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { solarPosition, sunDirection } from "./solar.ts";
import { nightFactor } from "./atmosphere.ts";
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
import {
createSatelliteLayer,
@@ -41,6 +42,7 @@ import {
type SatelliteLayer,
} from "./satellites.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import type { EnvironmentRig } from "./environmentRig.ts";
import {
createRoadTrafficLayer,
type RoadTrafficLayer,
@@ -55,6 +57,7 @@ import type {
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
Aircraft,
Chapter,
City,
FlightSource,
@@ -84,6 +87,11 @@ import { cityControlOwnership, type CityControlMode } from "../play/controlMode.
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
/** What the pointer is over: an authored place, or an observed aeroplane. */
type Pick =
| { kind: "marker"; marker: Marker }
| { kind: "aircraft"; aircraft: Aircraft };
export interface SceneOptions {
city: City;
markerPalette?: MarkerPalette;
@@ -118,6 +126,34 @@ export interface SceneOptions {
satellites?: SatelliteCatalogue;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
/**
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
*
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
* picks by hover, so a click handler upstairs reads whatever the last hover
* resolved. What comes back is the engine's own `Aircraft` a position and a
* callsign and nothing about where it came from, because that is a question
* about the deployment and `adapters/http.ts` is the layer that can answer it.
*
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
* with a receiver, so there is nothing here an account could grant; see
* `owner-decisions.md` and the note on `TrafficSource.detail`.
*/
onAircraftPick?: (aircraft: Aircraft | null) => void;
/**
* The shared environment map, when the page has one.
*
* Handed in rather than built here, and that is the whole of the wiring rule:
* a `PMREMGenerator` and its render targets belong to the **renderer**, which
* outlives every city on the page, so one rig is built beside the `Stage` and
* shared. A rig per `createScene` would allocate a fresh blur chain and a
* fresh target for every board and leak both on the next switch, which is
* exactly the arithmetic `stage.ts` records for the renderer itself.
*
* Absent, everything renders as it did before the rig existed: duller metal,
* no sky in the water, and no error.
*/
environment?: EnvironmentRig;
/**
* Opening light rig. Comes from an `Atmosphere` when there is one; without
* one the city gets `cityDaylight()`, because a scene that renders black
@@ -356,6 +392,9 @@ export async function createScene(
// two disagree for the one frame before the app's first `setLighting`.
const opening = options.lighting ?? cityDaylight(pal, boardSpan);
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.
options.environment?.apply(scene, opening, "city");
scene.add(createWater(world));
scene.add(createShorePlates(world));
@@ -443,6 +482,15 @@ export async function createScene(
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
/**
* The last observation, by id, so a pick has something to hand back.
*
* The layer interpolates between observations and keeps no record a caller
* could read; this is the record. Rebuilt wholesale on every poll rather than
* merged, so an aeroplane that has left the region leaves this table with it
* and a card cannot be opened on a track that is no longer in the sky.
*/
const lastAircraft = new Map<string, Aircraft>();
if (options.flights) {
flightLayer = createFlightLayer(world);
scene.add(flightLayer.group);
@@ -553,12 +601,38 @@ export async function createScene(
// ---- Picking ------------------------------------------------------------
// `pickables` is mutated in place by the layer, so the array itself is the
// live target list.
kit.setPicking<Marker>({
targets: markerLayer.pickables,
resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
onChange: (marker) => options.onMarkerPick?.(marker),
/**
* Two things on this board are worth pointing at, and both are resolved here.
*
* `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
* place by their layers, so neither array can simply be concatenated once
* the picking target list has to be a getter that reads both at the moment of
* the test. A pin is an authored place; an aeroplane is an observation, and
* `Pick` keeps them apart as a union rather than flattening both to a string,
* because the aircraft card is five fields and a provenance line and the
* moment it becomes a sentence it can never be anything else again.
*/
const pickTargets = (): THREE.Object3D[] =>
flightLayer === null
? markerLayer.pickables
: [...markerLayer.pickables, ...flightLayer.pickables];
kit.setPicking<Pick>({
targets: pickTargets,
resolve: (hit) => {
const marker = hit.object.userData.marker as Marker | undefined;
if (marker) return { kind: "marker", marker };
const id = hit.object.userData.aircraftId as string | undefined;
const aircraft = id === undefined ? undefined : lastAircraft.get(id);
return aircraft ? { kind: "aircraft", aircraft } : null;
},
onChange: (picked) => {
// Both callbacks fire on every change, including the change back to
// `null`, so whichever card is up is retired by a pointer that leaves —
// and by a pointer that moves from a pin straight onto an aeroplane.
options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
},
});
// ---- The scene, as the stage sees it ------------------------------------
@@ -588,7 +662,11 @@ export async function createScene(
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
void Promise.resolve(options.flights.poll()).then((ac) => {
lastAircraft.clear();
for (const a of ac) lastAircraft.set(a.id, a);
flightLayer?.update(ac);
});
}
}
// Every frame and on no timer of its own. The catalogue's sweep is
@@ -610,16 +688,40 @@ export async function createScene(
* dusk geometry that lights a Starlink pass.
*/
const when = skyOverride ?? new Date();
const solar = solarPosition(city.center.lat, city.center.lng, when);
/**
* The sky's own brightness, and the fix for the worst thing on this
* board at first load.
*
* Both satellite layers draw light *added* to the sky: the dot cloud is
* `AdditiveBlending` and the near-field buses are unlit white. That is
* exactly right against a night sky and is a hard white square against a
* daytime one which is what the California board showed at 15:55 with
* the sun at +44°, scattered across the frame, reading as render
* artefacts before anything else on the page registered.
*
* `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
* the same one `nightlights.ts` switches the city on with, so the sky
* does not empty at a different dusk from the one the windows light up
* at. It is computed here rather than taken from the rig for the reason
* the sun vector below is: `atmosphere.ts` floors the *rig's* light
* direction at `shadowFloorDeg` to keep the shadow camera usable, and a
* sun pinned above the horizon is precisely the wrong input for a
* question about how dark it is.
*/
const darkness = nightFactor(solar.elevation);
satelliteLayer.setSkyDarkness(darkness);
starlinkMeshes?.setSkyDarkness(darkness);
const fixes = options.satellites.fixes(when);
satelliteLayer.update(fixes);
starlinkMeshes?.update(
fixes,
kit.camera,
sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
);
starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
}
},
dispose() {
// Before anything else frees a texture: the rig holds this scene in a
// ledger so a rebuilt environment can be pushed to every scene using it,
// and a disposed city left in that ledger is the whole graph retained.
options.environment?.release(scene);
options.flights?.dispose?.();
flightLayer?.dispose();
satelliteLayer?.dispose();
@@ -654,6 +756,17 @@ export async function createScene(
setLighting: (state) => {
kit.applyLighting(state);
clouds.setLighting(state);
/**
* Every lighting change, and it is cheap to do it every one.
*
* The rig fingerprints the state coarsely and rebuilds only when the
* fingerprint moves, so an unchanged sky is a map lookup and an
* assignment. Calling it here rather than on a timer of its own is what
* keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
* rig, the scene is applying it, and the environment is derived from the
* decision rather than being a second opinion about the light.
*/
options.environment?.apply(scene, state, "city");
},
setCloudCover: (fraction) => clouds.setCover(fraction),
setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
+80
View File
@@ -83,8 +83,35 @@ export interface StageOptions {
/** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */
maxPixelRatio?: number;
shadows?: boolean;
/**
* Tone mapping exposure. Defaults to `DEFAULT_TONE_MAPPING_EXPOSURE`.
*
* Live afterwards as `stage.renderer.toneMappingExposure` the renderer is
* on the `Stage` for exactly this kind of reason, and a `setExposure` method
* would widen the interface CONTRACT.md §1 pins down for one assignment.
*/
exposure?: number;
}
/**
* The exposure the world is tuned at.
*
* ACES takes linear radiance, divides by 0.6, runs the RRT+ODT fit and saturates,
* so `toneMappingExposure` is a photographic stop dial and not a brightness
* slider: at 1.0 an 18% grey card lands on 0.5 sRGB, which is the definition of
* the curve being *neutral*. 1.15 is a little over a fifth of a stop above
* neutral, and it is there because ACES darkens the bottom of the range a
* linear 0.02 that used to display at 0.152 comes out at 0.080 and this world
* spends a third of its day at night. The lift buys most of that back at the
* bottom while the shoulder eats it at the top, where nothing is left to lose.
*
* Exported because the number is a fact about the whole picture, not about this
* file: `atmosphere.ts`'s keyframe table is tuned against this exposure and
* `environmentRig.ts` builds its sky radiances to sit under the same shoulder.
* Anything that changes it is changing all three.
*/
export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.15;
/**
* What kind of machine this is, to the extent a browser will say.
*
@@ -172,6 +199,59 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio),
);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
/**
* The two lines that decide what every colour in the product looks like.
*
* ### Why the output colour space is written down
*
* `outputColorSpace` has defaulted to `SRGBColorSpace` since r152, and this
* file relied on that default for its whole life. That is a bet on a default
* staying put across a library this repo pins with a caret, and the same bet
* lost once already three lines below: `PCFSoftShadowMap` was silently
* demoted to unfiltered basic shadows by an upstream change nobody here saw.
* A renderer's output transfer function is not a thing to inherit quietly
* if it ever moves, every texture, palette and keyframe in the repo is wrong
* at once and the symptom is "the whole app looks washed out", which is the
* least diagnosable bug shape there is. So it is stated.
*
* ### Why ACES, and what it fixes
*
* `NoToneMapping` is not "no transform". It is `saturate()`: everything above
* linear 1.0 becomes exactly 1.0, and every distinction above that value is
* destroyed before the sRGB encode ever runs. This world drives values well
* past 1.0 on purpose `atmosphere.ts` peaks the sun above 2.3, the office
* assets set `emissiveIntensity` up to 3.2 so the brightest and most
* expensive third of the lighting range was being flattened into a single
* flat white. That is the whole explanation for the two worst-looking things
* in the product: sunlit walls with no shading gradient left in them, and
* every light fitting and screen rendering as an identical white rectangle
* regardless of how bright it was told to be.
*
* ACES filmic replaces the cliff with a shoulder. Linear 1.0 displays at
* about 0.90, 2.0 at 0.95, 4.0 at 0.98 still separable, all the way up so
* a diffuser at 0.85 and a screen at 3.2 finally look like different things.
* It costs a little in the shadows, where the toe is steeper than a plain
* gamma encode; the exposure above and the re-tuned night stops in
* `atmosphere.ts` are what pay that back. Both halves of that trade are
* required. Turning this on and leaving the intensity table alone gives a
* world that is correctly *shaped* and too dark, which reads as worse.
*
* Two things this deliberately does not touch, and it is worth knowing which
* so nobody goes hunting for a horizon seam that is not there. Three sets
* `toneMapped = false` on the background mesh whenever the background texture
* carries an sRGB transfer `scenekit.ts`'s sky gradient does and fog is
* mixed in `fog_fragment` *after* both `tonemapping_fragment` and
* `colorspace_fragment`, from a uniform already converted into the renderer's
* output space. So the sky and the fog are still displayed exactly as
* `atmosphere.ts` authored them, and they still agree with each other at the
* horizon. Only lit geometry moves, which is precisely the surface the
* intensity table controls.
*/
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = options.exposure ?? DEFAULT_TONE_MAPPING_EXPOSURE;
if (options.shadows ?? true) {
renderer.shadowMap.enabled = true;
/**
+30 -1
View File
@@ -320,6 +320,18 @@ export interface StarlinkMeshLayer {
* pointing its solar panels at yesterday afternoon.
*/
update(fixes: readonly SatelliteFix[], camera: THREE.Camera, sun: SunVector): void;
/**
* How dark the sky is, 0..1 `nightFactor(sun.elevation)`, the same number
* `SatelliteLayer` takes and from the same call site.
*
* These buses are unlit white boxes (`MeshBasicMaterial({ color: 0xffffff })`)
* because that is what a sunlit satellite twenty pixels across looks like
* against a night sky. Under a tone curve, against a *daytime* sky, they are
* the literal white squares live defect 1 named the most damaging thing on
* the board at first load, and drawn at 15:55 with the sun at +44°, where no
* naked eye would see a satellite at all.
*/
setSkyDarkness(darkness: number): void;
setVisible(visible: boolean): void;
dispose(): void;
}
@@ -382,6 +394,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
* twelve kilometres of air a city sits in. This is 550 km above all of it.
*/
const busMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, fog: false });
/**
* Whether the caller wants this layer, and whether the sky is dark enough for
* it to be honest. Both have to be true, and they are two different questions:
* the first is a godmode switch and the second is the hour.
*/
let wanted = true;
let skyDarkness = 1;
const arrayMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
fog: false,
@@ -799,6 +819,14 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
return {
group,
update,
setSkyDarkness(darkness: number) {
skyDarkness = Math.min(1, Math.max(0, darkness));
// Whole-group rather than a per-material opacity, because these two
// materials are opaque by design: making them `transparent` to fade them
// would buy a sort order and a blend for objects that are never partly
// visible — they are either in a sky you could see them in or they are not.
group.visible = wanted && skyDarkness > 0.02;
},
/**
* Unlike `SatelliteLayer.setVisible`, this one also stops the work see the
* early return in `update`. The distinction is not an inconsistency: that
@@ -808,7 +836,8 @@ export function createStarlinkMeshLayer(options: StarlinkMeshOptions): StarlinkM
* on and the next visible frame is complete.
*/
setVisible(visible: boolean) {
group.visible = visible;
wanted = visible;
group.visible = visible && skyDarkness > 0.02;
},
dispose() {
// The instanced meshes first. `InstancedMesh.dispose()` releases the
+249 -82
View File
@@ -5,15 +5,145 @@
* Roads follow the terrain: each path is resampled far more finely than it is
* written in the city pack, and every sample takes its height from the ground,
* so a street climbs out of the flats instead of burrowing through the hill.
*
* ### Everything here is batched, and it has to be
*
* The city ran at 616 draw calls against a budget of 650 while the office spent
* 8% of its triangle budget: quality is nearly free indoors and is not free at
* all out here, so anything this module can hand back is headroom the exterior
* vehicles and the aircraft get to spend. Batching the corridor and the bridges
* took the California board to 557 measured 59 calls, from 59 freeway meshes
* down to 20 plus the four extra shadow-pass draws the guardrails and sign
* posts used to cost.
*
* Two rules keep it honest, and both were broken before:
*
* 1. **Materials are cached by colour**, in a `Batch` that lives as long as
* the build call. Twelve identical asphalt decks used to be twelve
* `MeshLambertMaterial`s, which is twelve things that can never merge, and
* a single suspension bridge minted a fresh material for its deck, each
* tower, each brace, each cable and each hanger about thirty-four.
* 2. **Geometry is merged per material.** Every helper below returns a
* `BufferGeometry` rather than a `Mesh`, and the caller drops it into a
* named bucket; one mesh comes out per bucket at the end.
*
* The cache is deliberately *not* module-level. `createScene().dispose()` walks
* the scene and disposes every material it finds, so a cache that outlived one
* build would hand the next board a disposed material and render it black.
*
* The corollary for anyone adding a helper here: give every geometry the **same
* attribute set** position, normal, uv, indexed or `mergeGeometries`
* refuses the bucket and silently drops it. That is why the ribbons below carry
* UVs they have no texture for.
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
import type { TransportPack } from "../transport/types.ts";
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
// ---- Batching -------------------------------------------------------------
/**
* The three ways a surface out here is shaded.
*
* `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip
* that has to survive being looked at from underneath on a bridge approach, and
* a tower is a closed solid where a back face is a waste.
*
* `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the
* one thing in the frame whose job is to be a fixed, known white it is
* retroreflective, it is what a driver navigates by, and putting it through the
* ACES shoulder with everything else turns a lane line into a grey smear at
* midday and loses it entirely at dusk.
*/
type SurfaceKind = "deck" | "solid" | "marking";
interface Bucket {
readonly name: string;
readonly material: THREE.Material;
readonly castShadow: boolean;
readonly receiveShadow: boolean;
readonly parts: THREE.BufferGeometry[];
}
/**
* One build's worth of materials and geometry, merged on the way out.
*
* Buckets are keyed on **name and material together** rather than on the
* material alone. Sharing the material is what saves the draw call; keeping the
* name is what lets somebody looking at the scene graph still find the
* guardrails, and the one extra call it costs where two classes happen to share
* a material is worth being able to debug the thing.
*/
class Batch {
private readonly materials = new Map<string, THREE.Material>();
private readonly buckets = new Map<string, Bucket>();
/** The one material for a kind and colour in this build. */
material(kind: SurfaceKind, color: number): THREE.Material {
const key = `${kind}:${color.toString(16)}`;
const hit = this.materials.get(key);
if (hit) return hit;
const made =
kind === "marking"
? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide })
: new THREE.MeshLambertMaterial({
color,
side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide,
});
made.name = key;
this.materials.set(key, made);
return made;
}
add(
name: string,
geometry: THREE.BufferGeometry,
material: THREE.Material,
shadows: { cast?: boolean; receive?: boolean } = {},
): void {
const key = `${material.uuid}|${name}`;
const bucket = this.buckets.get(key);
if (bucket) {
bucket.parts.push(geometry);
return;
}
this.buckets.set(key, {
name,
material,
castShadow: shadows.cast ?? false,
receiveShadow: shadows.receive ?? true,
parts: [geometry],
});
}
/** Merge every bucket and hang the results off `into`. */
flush(into: THREE.Group): void {
for (const bucket of this.buckets.values()) {
const merged =
bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
// `mergeGeometries` returns null when the attribute sets disagree. Losing
// the bucket silently is exactly the failure the module comment warns
// about, so say so rather than rendering a road with no markings on it.
if (!merged) {
console.warn(`structures: "${bucket.name}" has mismatched attributes and was not merged`);
continue;
}
if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose();
const mesh = new THREE.Mesh(merged, bucket.material);
mesh.name = bucket.name;
mesh.castShadow = bucket.castShadow;
mesh.receiveShadow = bucket.receiveShadow;
into.add(mesh);
}
this.buckets.clear();
}
}
/** Resample a lat/lng path into scene-space points that ride the ground. */
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
const out: THREE.Vector3[] = [];
@@ -35,25 +165,32 @@ function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14
return out;
}
function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Mesh {
/** A tube swept along a path — a bridge deck, a cable, a barrier. */
function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry {
const curve = new THREE.CatmullRomCurve3(points);
const geo = new THREE.TubeGeometry(curve, points.length * 2, width / 2, 4, false);
const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color }));
mesh.receiveShadow = true;
return mesh;
return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false);
}
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
function roadRibbon(
/**
* A draped, flat road deck. A tube turns a freeway into a raised pipeline.
*
* The UVs run 0..1 across the carriageway and in **metres** along it, which is
* the sane convention if anyone ever puts a surface texture on a road. Right
* now nothing does, and they are here for a duller reason: `mergeGeometries`
* only merges geometries whose attribute sets match exactly, so a strip without
* UVs cannot share a bucket with the tube barriers beside it.
*/
function roadRibbonGeometry(
points: readonly THREE.Vector3[],
width: number,
color: number,
lift = 0,
): THREE.Mesh {
): THREE.BufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
const half = width / 2;
let along = 0;
for (let index = 0; index < points.length; index += 1) {
const point = points[index];
@@ -65,11 +202,13 @@ function roadRibbon(
const length = Math.hypot(dx, dz) || 1;
const nx = -dz / length;
const nz = dx / length;
if (index > 0) along += point.distanceTo(previous);
positions.push(
point.x + nx * half, point.y + lift, point.z + nz * half,
point.x - nx * half, point.y + lift, point.z - nz * half,
);
normals.push(0, 1, 0, 0, 1, 0);
uvs.push(0, along, 1, along);
if (index < points.length - 1) {
const a = index * 2;
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
@@ -79,14 +218,10 @@ function roadRibbon(
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
);
mesh.receiveShadow = true;
return mesh;
return geometry;
}
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
@@ -100,15 +235,15 @@ function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vec
});
}
/** Merge alternating path spans into one dashed marking mesh. */
function dashedRibbon(
/** Merge alternating path spans into one dashed marking geometry. */
function dashedRibbonGeometry(
points: readonly THREE.Vector3[],
offset: number,
width: number,
color: number,
): THREE.Mesh {
): THREE.BufferGeometry {
const shifted = offsetPath(points, offset);
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
for (let index = 0; index < shifted.length - 1; index += 2) {
const a = shifted[index];
@@ -127,18 +262,15 @@ function dashedRibbon(
b.x + nx, b.y + 0.035, b.z + nz,
b.x - nx, b.y + 0.035, b.z - nz,
);
uvs.push(0, 0, 1, 0, 0, 1, 1, 1);
indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide }),
);
mesh.name = "freeway:lane-dashes";
return mesh;
return geometry;
}
function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
@@ -178,6 +310,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const plan = buildFreewayWorldPlan(pack);
group.userData.planSeed = plan.seed;
const batch = new Batch();
const asphalt = [0x353a3d, 0x303538];
const shoulder = [0x555759, 0x4e5153];
const berm = [0x64705c, 0x74674c];
@@ -211,6 +344,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
let poleCount = 0;
let siloCount = 0;
const dummy = new THREE.Object3D();
const reflectorMatrices: THREE.Matrix4[] = [];
world.city.roads.forEach((road, roadIndex) => {
if (road.kind !== "freeway") return;
@@ -219,39 +353,58 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const identityIndex = route?.identity === "interstate" ? 1 : 0;
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
// Broad earthwork under separate decks makes grade and curve changes read.
group.add(roadRibbon(path, 2.75, berm[identityIndex] ?? berm[0]!, -0.09));
batch.add(
"freeway:berm",
roadRibbonGeometry(path, 2.75, -0.09),
batch.material("deck", berm[identityIndex] ?? berm[0]!),
);
for (const side of [-1, 1] as const) {
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.18, shoulder[identityIndex] ?? shoulder[0]!, 0.004));
group.add(roadRibbon(offsetPath(path, side * 0.64), 1.03, asphalt[identityIndex] ?? asphalt[0]!, 0.012));
batch.add(
"freeway:shoulder",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
);
batch.add(
"freeway:carriageway",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
);
// Inner yellow edge, two lane dividers, outer white shoulder edge.
group.add(roadRibbon(offsetPath(path, side * 0.12), 0.026, 0xf0c84f, 0.038));
group.add(roadRibbon(offsetPath(path, side * 1.16), 0.026, 0xe8ece8, 0.038));
group.add(dashedRibbon(path, side * 0.47, 0.022, 0xf4f4ec));
group.add(dashedRibbon(path, side * 0.81, 0.022, 0xf4f4ec));
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
batch.material("deck", 0xf0c84f),
);
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
batch.material("deck", 0xe8ece8),
);
const dashes = batch.material("marking", 0xf4f4ec);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
const guardPath = offsetPath(path, side * 1.27);
const guard = new THREE.Mesh(
batch.add(
"freeway:outer-guardrail",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
guardMaterial,
{ cast: true },
);
guard.name = "freeway:outer-guardrail";
guard.castShadow = true;
group.add(guard);
}
// Low concrete median walls keep both carriageways visually independent.
for (const side of [-1, 1] as const) {
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
const median = new THREE.Mesh(
batch.add(
"freeway:median-barrier",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
barrierMaterial,
);
median.name = "freeway:median-barrier";
group.add(median);
}
// Retroreflectors are instanced and restrained, never roadside light blobs.
// The matrices are collected across every corridor and committed to one
// `InstancedMesh` after the loop, because two corridors' worth of the same
// 0.018 m box is two draw calls for something nobody can resolve.
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
const reflectors = new THREE.InstancedMesh(reflectorGeometry, reflectorMaterial, reflectorPoints.length * 4);
reflectors.name = "freeway:reflectors";
let reflectorIndex = 0;
for (const pointIndex of reflectorPoints.keys()) {
const point = reflectorPoints[pointIndex];
if (!point) continue;
@@ -261,11 +414,9 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
dummy.updateMatrix();
reflectors.setMatrixAt(reflectorIndex++, dummy.matrix);
reflectorMatrices.push(dummy.matrix.clone());
}
}
reflectors.count = reflectorIndex;
group.add(reflectors);
if (!route || !routePath) return;
const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
@@ -278,17 +429,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
const pz = z + Math.sin(heading) * sceneSetback * feature.side;
const ground = world.groundAt(sample.lat, sample.lng);
if (feature.kind === "route-sign") {
const sign = new THREE.Group();
sign.name = `freeway:sign:${route.shield}`;
const post = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.62, 0.035), guardMaterial);
post.position.y = 0.31;
const board = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.31), shieldMaterial);
board.position.y = 0.69;
board.rotation.y = -heading + (feature.side === 1 ? Math.PI : 0);
sign.add(post, board);
sign.position.set(px, ground + 0.08, pz);
sign.userData.routeId = route.routeId;
group.add(sign);
// Baked into world space rather than parented under a per-sign `Group`.
// Nine signs used to be nine groups of two meshes; they are now two
// meshes for the whole route, and the shield's own name survives on the
// board so the scene graph still says which route it belongs to.
const post = new THREE.BoxGeometry(0.035, 0.62, 0.035);
post.translate(px, ground + 0.08 + 0.31, pz);
batch.add("freeway:sign-post", post, guardMaterial, { cast: true });
const board = new THREE.PlaneGeometry(0.42, 0.31);
board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
board.translate(px, ground + 0.08 + 0.69, pz);
batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
continue;
}
const visualScale = feature.scale * 0.58;
@@ -309,6 +461,18 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
}
}
});
batch.flush(group);
const reflectors = new THREE.InstancedMesh(
reflectorGeometry,
reflectorMaterial,
Math.max(1, reflectorMatrices.length),
);
reflectors.name = "freeway:reflectors";
reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix));
reflectors.count = reflectorMatrices.length;
group.add(reflectors);
trunks.count = trunkCount;
poles.count = poleCount;
silos.count = siloCount;
@@ -321,16 +485,22 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
export function createRoads(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "roads";
const batch = new Batch();
for (const road of world.city.roads) {
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
const path = drapePath(world, road.path);
group.add(roadRibbon(path, road.width, color));
batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color));
if (road.kind === "freeway") {
// One warm median stroke is enough at corridor scale to read as divided
// highway without spending a textured asset or a draw call per lane.
group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012));
batch.add(
"road:median-stroke",
roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012),
batch.material("deck", 0xd7c27c),
);
}
}
batch.flush(group);
return group;
}
@@ -347,32 +517,37 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
const deckY = world.metres(bridge.deckHeight);
const towerY = world.metres(bridge.towerHeight);
const material = () => new THREE.MeshLambertMaterial({ color: bridge.color });
/**
* One material for the whole bridge, and one mesh out of it.
*
* This used to read `const material = () => new THREE.MeshLambertMaterial(…)`
* and be called once per part, so the Golden Gate arrived as about
* thirty-four meshes with thirty-four identical materials thirty-four draw
* calls the sorter had to keep apart, for one orange object. Everything a
* bridge is made of is painted the same colour, so everything a bridge is made
* of belongs in one bucket.
*/
const batch = new Batch();
const paint = batch.material("solid", bridge.color);
const part = (geometry: THREE.BufferGeometry) =>
batch.add(bridge.name, geometry, paint, { cast: true });
const deckPoints = bridge.path.map(([lat, lng]) => {
const [x, z] = world.project(lat, lng);
return new THREE.Vector3(x, deckY, z);
});
const deck = ribbon(deckPoints, 0.5, bridge.color);
deck.castShadow = true;
group.add(deck);
part(tubeGeometry(deckPoints, 0.5));
const towerTops: THREE.Vector3[] = [];
for (const [lat, lng] of bridge.towers) {
const [x, z] = world.project(lat, lng);
const geo = new THREE.BoxGeometry(0.34, towerY, 0.34);
geo.translate(0, towerY / 2, 0);
const tower = new THREE.Mesh(geo, material());
tower.position.set(x, 0, z);
tower.castShadow = true;
group.add(tower);
part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z));
// Cross-braces, which is most of what you see of a tower at distance.
for (const frac of [0.55, 0.82]) {
const brace = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.16, 0.4), material());
brace.position.set(x, towerY * frac, z);
group.add(brace);
part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z));
}
towerTops.push(new THREE.Vector3(x, towerY, z));
}
@@ -392,12 +567,7 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
p.y -= Math.sin(t * Math.PI) * sag;
pts.push(p);
}
group.add(
new THREE.Mesh(
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false),
material(),
),
);
part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false));
// Vertical hangers down to the deck.
for (let s = 2; s < 18; s += 2) {
@@ -406,14 +576,11 @@ export function createBridge(world: World, bridge: Bridge): THREE.Group {
const top = p.y - Math.sin(t * Math.PI) * sag;
if (top <= deckY + 0.2) continue;
const h = top - deckY;
const geo = new THREE.BoxGeometry(0.035, h, 0.035);
geo.translate(0, h / 2, 0);
const hanger = new THREE.Mesh(geo, material());
hanger.position.set(p.x, deckY, p.z);
group.add(hanger);
part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z));
}
}
batch.flush(group);
return group;
}
+28 -2
View File
@@ -179,9 +179,27 @@ export function createWater(world: World): THREE.Group {
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
/**
* Standard rather than Lambert, and it is the whole difference between an
* ocean and a blue card.
*
* `MeshLambertMaterial` has no specular term at all none, by construction
* so the Pacific, which is between a third and a half of the California
* board's frame, rendered as one flat value at every hour and from every
* angle. A low roughness gives it the sun's glint back, and now that the
* scene carries an environment map (`engine/environmentRig.ts`) it also gives
* it the sky: `MeshStandardMaterial` reads `scene.environment`, so the water
* reflects whatever the atmosphere decided the sky is, for free and with no
* second pass.
*
* `metalness: 0` is stated rather than defaulted because water is a
* dielectric: its reflection is a Fresnel term over a coloured body, which is
* exactly what metalness 0 with low roughness produces, and a metallic water
* would lose `pal.sea` entirely.
*/
const sea = new THREE.Mesh(
new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
new THREE.MeshLambertMaterial({ color: pal.sea }),
new THREE.MeshStandardMaterial({ color: pal.sea, roughness: 0.14, metalness: 0 }),
);
sea.rotation.x = -Math.PI / 2;
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
@@ -192,9 +210,17 @@ export function createWater(world: World): THREE.Group {
const pts = world.projectPolygon(poly).map(([x, z]) => new THREE.Vector2(x, z));
const geo = new THREE.ShapeGeometry(new THREE.Shape(pts));
geo.rotateX(Math.PI / 2);
// The same change as the sea above, and for the same reason. Slightly
// rougher: an inland lake is sheltered, and a mirror-smooth bay next to a
// wind-roughened ocean reads as the wrong way round.
const lake = new THREE.Mesh(
geo,
new THREE.MeshLambertMaterial({ color: pal.lake, side: THREE.DoubleSide }),
new THREE.MeshStandardMaterial({
color: pal.lake,
roughness: 0.2,
metalness: 0,
side: THREE.DoubleSide,
}),
);
lake.position.y = 0.05;
group.add(lake);
+6 -5
View File
@@ -319,11 +319,12 @@ export interface Aircraft {
/**
* Where aircraft come from.
*
* An interface rather than a client because the obvious source FlightRadar24
* cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid
* redistributing the data. This package ships a simulator and open community
* sources; anything commercial is an adapter in a private deployment. See
* ARCHITECTURE.md §4.
* An interface rather than a client, because this repo must not ship one for the
* obvious source: FlightRadar24's terms do not permit scraping and do not permit
* redistributing the data, so a client for it in an Apache-2.0 repo would be
* publishing instructions for violating a ToS. This package ships a simulator
* and open community sources; anything commercial is an adapter in a private
* deployment. See ARCHITECTURE.md §4.
*/
export interface FlightSource {
/** Current traffic. Called on a timer; must be cheap and must not throw. */
+19 -1
View File
@@ -1,13 +1,31 @@
/** Tera's renderer-independent public package surface. */
/**
* Tera's renderer-independent public package surface.
*
* Everything exported here imports **no three.js, no DOM and no network**, which
* is the property that makes the package usable from a verifier, a test harness
* or a Node service that has no GPU. `src/test/integration/barrel.test.ts`
* enforces it by importing this file under Node's type stripping and failing if
* anything in the graph reaches for a renderer.
*
* That rule is why three of the modules this build added are conspicuously
* absent. `src/interiors/devices.ts`, `src/engine/officeExterior.ts` and
* `src/interiors/officeScene.ts` are the *render layers* for the same
* simulations whose state machines are exported below they take a `Plan` and a
* `MaterialRegistry` and produce meshes, and exporting one of them would put
* three.js on the package surface for every consumer of a device type.
*/
export * from "./arena/index.ts";
export * from "./actors/controller.ts";
export * from "./aircraft/controller.ts";
export * from "./devices/index.ts";
export * from "./interiors/plan.ts";
export * from "./interiors/robotActivity.ts";
export * from "./interiors/robotOperations.ts";
export * from "./interiors/robotRoutes.ts";
export * from "./interiors/types.ts";
export * from "./interiors/walker.ts";
export * from "./transport/exteriorVehicle.ts";
export * from "./transport/types.ts";
export * from "./transport/vehicleController.ts";
export * from "./transport/vehicleSim.ts";
export * from "./transport/vehicleTelemetry.ts";
-81
View File
@@ -1,81 +0,0 @@
/** Device adapters for the renderer-independent vehicle action contract. */
import {
normalizeVehicleActions,
type VehicleActionSnapshot,
} from "../transport/vehicleController.ts";
export interface GamepadButtonLike {
pressed: boolean;
value: number;
}
export interface GamepadLike {
axes: readonly number[];
buttons: readonly GamepadButtonLike[];
}
export interface GamepadButtonState {
assist: boolean;
reset: boolean;
}
export interface GamepadVehicleSample {
actions: VehicleActionSnapshot;
buttons: GamepadButtonState;
}
function axis(value: number | undefined, deadzone = 0.12): number {
if (!Number.isFinite(value)) return 0;
const clamped = Math.max(-1, Math.min(1, value ?? 0));
if (Math.abs(clamped) <= deadzone) return 0;
return Math.sign(clamped) * ((Math.abs(clamped) - deadzone) / (1 - deadzone));
}
function button(pad: GamepadLike, index: number): number {
const found = pad.buttons[index];
if (!found) return 0;
return Math.max(0, Math.min(1, Number.isFinite(found.value) ? found.value : found.pressed ? 1 : 0));
}
/**
* Standard-layout mapping: left stick steers, triggers brake/throttle, B is
* handbrake, Y resumes assistance, and X resets. Mode/reset are rising edges.
*/
export function sampleStandardGamepad(
pad: GamepadLike,
previous: GamepadButtonState = { assist: false, reset: false },
): GamepadVehicleSample {
const buttons = {
assist: button(pad, 3) > 0.5,
reset: button(pad, 2) > 0.5,
};
return {
actions: normalizeVehicleActions({
steering: axis(pad.axes[0]),
brake: button(pad, 6),
throttle: button(pad, 7),
handbrake: button(pad, 1) > 0.5,
modeRequest: buttons.assist && !previous.assist ? "assisted" : "none",
reset: buttons.reset && !previous.reset,
}),
buttons,
};
}
/** Keyboard/touch and gamepad may be used together; strongest intent wins. */
export function mergeVehicleActions(
primary: Partial<VehicleActionSnapshot>,
secondary: Partial<VehicleActionSnapshot>,
): VehicleActionSnapshot {
const a = normalizeVehicleActions(primary);
const b = normalizeVehicleActions(secondary);
return normalizeVehicleActions({
throttle: Math.max(a.throttle, b.throttle),
brake: Math.max(a.brake, b.brake),
steering: Math.abs(b.steering) > Math.abs(a.steering) ? b.steering : a.steering,
handbrake: a.handbrake || b.handbrake,
modeRequest: b.modeRequest !== "none" ? b.modeRequest : a.modeRequest,
reset: a.reset || b.reset,
});
}
+275
View File
@@ -0,0 +1,275 @@
/**
* The hardware, in the room.
*
* One microphone-sized object per authored `DeviceDeclaration`, standing on the
* prop that declaration named, with a lamp on it that changes colour when the
* state changes. That is the whole layer.
*
* ### It resolves nothing
*
* Every coordinate comes from `Plan.device()`, which has already turned the
* authored anchor a prop id and an offset in that prop's frame into a
* position and a yaw. This file does not repeat that arithmetic, does not parse
* an asset id and does not decide whether a declaration is valid;
* `validateDeviceDeclaration` and `Plan` did all three. A second derivation
* here would be a second answer to "where is the mic", and the two would
* disagree the first time somebody nudged the desk which is precisely what
* `DeviceAnchor` is shaped to prevent, and it would be this file undoing it.
*
* A declaration the plan dropped is therefore skipped rather than placed from
* some other source. The plan drops one when its anchor prop is not there a
* typo, or a private prop in a public build and inventing a position would
* leave a microphone standing on the lobby floor.
*
* ### Nothing here is a light source
*
* `src/interiors/luminaires.ts` opens with that sentence and it is repeated
* here because the temptation is stronger, not weaker: an LED is *obviously* a
* light, and a `PointLight` per device would be one line. CONTRACT.md §4 says
* Atmosphere is the sole light owner, and past about four shadow casters a
* frame budget ends.
*
* The indicator reads as lit without one, through the `deviceIndicator`
* material role: `materials.tinted("deviceIndicator", colour)` reaches both
* `color` and `emissive`, and the role carries `emissiveIntensity: 1.0`, so
* under the tone curve it reads as a lamp rather than as a white dot. A 3 mm
* LED also implies **no house light at all** unlike a ceiling fitting, which
* is why `luminaires.ts` hands a scalar to the rig and this file has nothing to
* hand anybody. If a device is ever added that genuinely lights a room, the
* scalar goes to Atmosphere and the light still does not get constructed here.
*
* ### Materials are borrowed; geometry is owned
*
* Every mesh comes out of `MeshBin`, which clones and merges, so the geometry
* in this subtree belongs to this layer and is disposed with it. The materials
* come from the shared `MaterialRegistry` and are cached there across the whole
* office three states across a dozen devices is three materials, not
* thirty-six so `dispose()` deliberately does **not** touch them. Disposing a
* registry material here would empty the desks in the rest of the building.
*/
import * as THREE from "three";
import { createAssetContext, type AssetRegistry } from "../assets/kit.ts";
import type { MaterialRegistry } from "../assets/materials.ts";
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../devices/types.ts";
import type { Plan } from "./plan.ts";
/**
* The sub-object every device asset is expected to expose.
*
* A name rather than a `userData` flag because it is what an asset author
* already writes `MeshBin.build(name)` names the group and because a name
* survives the merge that turns an asset into one mesh per material. An asset
* without one still builds and still stands on the desk; it simply has no lamp
* to change, which is the right degrade for a self-hoster's own hardware model.
*/
const INDICATOR = "indicator";
/**
* What each state looks like, as a colour.
*
* Four states and no more, deliberately: an indicator that encodes a continuous
* reading is a display, and a display needs a legend. These are the four a
* person can read across a room without one dark, live, muted, idle and the
* numbers behind them belong in the panel, which has the room to say what they
* mean.
*/
const INDICATOR_COLORS = {
/** Powered off. Not black: an unlit LED is grey plastic, and black reads as a hole. */
off: 0x2b3138,
/** A microphone that is open, or a speaker that is playing. */
live: 0x46d17a,
/** A microphone that is muted. The one state worth reading from the doorway. */
muted: 0xe2543f,
/** Powered, idle: a speaker that is on with nothing playing. */
idle: 0xd7a63c,
} as const;
/**
* How much the indicator grows at full scale, as a fraction of its own size.
*
* The only reading this layer draws, and it is deliberately tiny. A meter
* belongs in the panel; what the room needs is a hint that the thing is doing
* something, which is what a lamp that breathes with the programme gives you.
* It is a transform on one small object, so it costs nothing and it mints no
* material a level-driven *colour* would mint one per tenth of a decibel,
* which is the version of this idea that must not be written.
*/
const INDICATOR_LEVEL_GAIN = 0.3;
export interface DeviceLayer {
object: THREE.Object3D;
/**
* Show these readings. Ids this layer does not carry are ignored, and a
* device this layer carries that is absent from `states` is left as it was
* a poll that dropped one device is not the same event as that device being
* switched off, and only one of them should change what is on screen.
*/
apply(states: readonly DeviceState[]): void;
dispose(): void;
}
export interface DeviceLayerOptions {
plan: Plan;
declarations: readonly DeviceDeclaration[];
assets: AssetRegistry;
materials: MaterialRegistry;
}
interface Mounted {
id: string;
/** The asset's own indicator, or `null` for hardware that exposes none. */
indicator: THREE.Object3D | null;
/** The indicator's authored scale, so the level response is relative to it. */
baseScale: number;
}
export function createDeviceLayer(options: DeviceLayerOptions): DeviceLayer {
const { plan, declarations, assets, materials } = options;
const object = new THREE.Group();
object.name = "devices";
const mounted = new Map<string, Mounted>();
const warned = new Set<string>();
for (const declaration of declarations) {
const resolved = plan.device(declaration.id);
if (resolved === null) {
warnOnce(
warned,
`device ${declaration.id} is not in this plan — check that its anchor prop exists on ` +
`level ${declaration.anchor.levelId} and survived this build's depth`,
);
continue;
}
// The transform, whole, from the plan. The offset is already folded into
// `position` there, in the anchor prop's frame; re-applying it here would
// place the mic twice as far up the desk as the pack asked for.
const mount = new THREE.Group();
mount.name = `device:${declaration.id}`;
mount.position.set(resolved.position.x, resolved.position.y, resolved.position.z);
mount.rotation.y = resolved.rotation;
const hardware = assets.build(declaration.assetId, contextFor(declaration, materials, assets));
mount.add(hardware);
object.add(mount);
const indicator = hardware.getObjectByName(INDICATOR) ?? null;
if (indicator === null) {
warnOnce(warned, `device asset ${declaration.assetId} exposes no "${INDICATOR}" sub-object; its state will not be visible in the room`);
}
mounted.set(declaration.id, {
id: declaration.id,
indicator,
baseScale: indicator?.scale.x ?? 1,
});
}
return {
object,
apply(states: readonly DeviceState[]): void {
for (const state of states) {
const device = mounted.get(state.id);
if (device === undefined || device.indicator === null) continue;
paint(device.indicator, materials, colorFor(state));
device.indicator.scale.setScalar(device.baseScale * (1 + INDICATOR_LEVEL_GAIN * levelOf(state)));
}
},
dispose(): void {
object.traverse((child) => {
const mesh = child as THREE.Mesh;
// Geometry only. See the header: every material here belongs to the
// shared registry and is still holding up the rest of the office.
if (mesh.isMesh) mesh.geometry.dispose();
});
object.clear();
mounted.clear();
},
};
}
/**
* Which lamp, for one reading.
*
* Kind-aware rather than capability-aware, and that is the one place in this
* whole surface where switching on the kind is right: this is a *picture* of a
* device, and what a green light means on a microphone ("open") is not what it
* means on a speaker ("playing"). Everywhere a control or an observation is
* built, the capability list is the thing to iterate.
*/
function colorFor(state: DeviceState): number {
if (!state.powered) return INDICATOR_COLORS.off;
if (state.kind === "mic") return state.muted === true ? INDICATOR_COLORS.muted : INDICATOR_COLORS.live;
return state.playing === true ? INDICATOR_COLORS.live : INDICATOR_COLORS.idle;
}
/**
* The reading as 0..1, or zero for a device that reports no level.
*
* `undefined` is not zero it means this device has no meter but both come
* out here as "do not grow the lamp", which is the honest picture for a device
* that is not reporting anything to grow it by.
*/
function levelOf(state: DeviceState): number {
if (state.levelDb === undefined || !state.powered) return 0;
const { min, max } = DEVICE_RANGES.level;
return Math.min(1, Math.max(0, (state.levelDb - min) / (max - min)));
}
/**
* Point every mesh under the indicator at the tinted material for a state.
*
* `tinted` is cached by the registry, so the whole building's microphones share
* one material per state and the assignment is a pointer write rather than a
* new draw call. It reaches `color` and `emissive` together, which is what
* makes a tinted LED read as lit instead of as a coloured pebble.
*/
function paint(indicator: THREE.Object3D, materials: MaterialRegistry, color: number): void {
const material = materials.tinted("deviceIndicator", color);
indicator.traverse((child) => {
const mesh = child as THREE.Mesh;
if (mesh.isMesh) mesh.material = material;
});
}
/**
* An asset context for one device.
*
* The random stream is seeded from the device id rather than from `Math.random`
* so that the same pack builds the same hardware on every machine and in every
* capture the determinism rule every asset in this repo is held to. A device
* is one small object built once, so the generator is three lines rather than a
* dependency.
*/
function contextFor(
declaration: DeviceDeclaration,
materials: MaterialRegistry,
assets: AssetRegistry,
) {
let seed = 0x811c9dc5;
for (let i = 0; i < declaration.id.length; i += 1) {
seed ^= declaration.id.charCodeAt(i);
seed = Math.imul(seed, 0x01000193);
}
const rand = (): number => {
seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
return seed / 4_294_967_296;
};
return createAssetContext({ materials, registry: assets, rand });
}
/**
* One line per problem, once.
*
* A pack with a typo in a device id is a pack that repeats it the same
* declaration is rebuilt every time the office is entered and a warning per
* entry is a console nobody reads. `AssetRegistry.placeholder` warns once per
* id for the same reason.
*/
function warnOnce(seen: Set<string>, message: string): void {
if (seen.has(message)) return;
seen.add(message);
console.warn(`[tera/devices] ${message}`);
}
+400 -3
View File
@@ -68,8 +68,15 @@
import * as THREE from "three";
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
import type { StageScene } from "../engine/stage.ts";
import type { LightingState, Pin, View } from "../engine/types.ts";
import type { AssetRegistry } from "../assets/kit.ts";
import type { Aircraft, FlightSource, LightingState, Pin, View } from "../engine/types.ts";
import { airlinerGeometry } from "../engine/aircraftGeometry.ts";
import type { EnvironmentRig } from "../engine/environmentRig.ts";
import { createOfficeExterior, type OfficeExterior } from "../engine/officeExterior.ts";
import { createDeviceLayer, type DeviceLayer } from "./devices.ts";
import type { DeviceDeclaration, DeviceState } from "../devices/types.ts";
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";
import type { ModelXDetail } from "../assets/vehicles/index.ts";
import { kit as assetKit, type AssetRegistry } from "../assets/kit.ts";
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
import type { InteriorPalette } from "../assets/palette.ts";
// Importing the catalogue registers the built-in `tera:` assets into the shared
@@ -140,6 +147,53 @@ const HORIZON_EXTENT = 12_000;
*/
const HORIZON_DARKEN = 0.5;
/**
* How far out the overhead traffic dome sits, in metres, at most.
*
* The camera's far plane is `HORIZON_EXTENT * 0.7` 8.4 km so 3.6 km is
* comfortably inside it with the horizon plane still behind. The number itself
* carries no claim: an aeroplane on this dome is a **map symbol drawn in 3-D**,
* placed at the bearing and elevation it is genuinely at and at a distance
* chosen so it is visible, exactly as `aircraftGeometry.ts` argues for the
* city's own traffic. Drawing airliners at true metre range would put most of
* them past the far plane and the rest inside the fog.
*/
const OVERHEAD_MAX_RADIUS_M = 3_600;
/**
* How large an aeroplane is drawn, as an angle at the eye.
*
* 0.012 rad is about 0.7 degrees a little over the width of a fingernail at
* arm's length, which is roughly what an airliner at cruise actually looks like
* from directly beneath and is enough to read the sweep of a wing. It is an
* angle rather than a length so the glyph does not have to be retuned if the
* dome radius ever changes.
*/
const OVERHEAD_ANGULAR_SIZE = 0.012;
/** The bounding length of `airlinerGeometry()`, which the angular size divides. */
const AIRLINER_LENGTH = 0.42;
/**
* How low an aeroplane may be and still be drawn, in degrees above the horizon.
*
* Below this it is behind the ground plane from any viewpoint inside the
* building, so drawing it is drawing an aeroplane through a floor. Five degrees
* is also about where an airliner stops being distinguishable from the haze.
*/
const OVERHEAD_MIN_ELEVATION_DEG = 5;
/**
* How many aeroplanes the dome can hold at once.
*
* One `InstancedMesh` and therefore one draw call at any occupancy, so the cost
* of the ceiling is 32 unused matrices rather than 32 unused objects. The
* godmode traffic dial can put four hundred aircraft over the city; a room's
* sky wants the nearest few, and the nearest few is what a person looking up
* would see anyway.
*/
const OVERHEAD_CAPACITY = 32;
export interface OfficeSceneOptions {
/**
* The renderer's canvas. Orbit input and pointer coordinates are read against
@@ -236,6 +290,45 @@ export interface OfficeSceneOptions {
*/
background?: number | null;
plan?: PlanOptions;
/**
* The page's one environment map, shared with the city.
*
* The same argument `scene.ts` makes: a `PMREMGenerator` and its targets
* belong to the renderer, not to a scene, so one rig is built beside the
* `Stage` and handed to both. It matters more indoors than out `deviceMesh`,
* `chairBase`, `metalTrim`, `glazingFrame` and the Model X's paint are all
* metal or clearcoat, and metal with nothing to reflect is grey plastic.
*
* Absent, the office renders exactly as it did before the rig existed.
*/
environment?: EnvironmentRig;
/**
* Overhead traffic for a sited office's sky.
*
* The same `FlightSource` the city board is drawing, deliberately: a studio in
* the Arts District and the SoCal board above it are one world, and an arena
* that observes an overflight while the viewer standing in the room sees an
* empty sky is two. Polled here and **never disposed** here the source
* belongs to whoever built it, which is the city.
*
* Ignored on a pack with no `site`: without a coordinate there is no bearing
* to put an aeroplane on, and without a horizon there is no sky to put it in.
*/
flights?: FlightSource;
/**
* Park a Model X on the pack's arrival apron.
*
* Ignored unless `office.site.arrival` names a stall, which is a pack's own
* decision `ExteriorArrival` is optional and a floorplan with no outdoors
* has nowhere to put a car.
*
* `detail` is a required choice by the exterior's own contract: `corridor` is
* 33 draw calls and 4,098 triangles against `follow`'s 40 and 13,986, and the
* difference a viewer can see at three metres is mirrors, glass frames and
* brake calipers. `seed` makes the parking jitter and the paint a property of
* the studio rather than of the page load.
*/
exteriorVehicle?: { detail: ModelXDetail; seed: number };
}
export interface OfficeScene extends StageScene {
@@ -281,6 +374,28 @@ export interface OfficeScene extends StageScene {
*/
setRobotsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
/**
* The hardware this pack declared, in the order it authored it.
*
* Authored, public and inert a declaration says a microphone exists and what
* it can be asked to do. It is on the handle so that the interface can build a
* panel for a studio without reading the pack a second time, and it is the
* **resolved** list: a declaration `Plan` dropped, because its anchor prop is
* not on this level or did not survive this build's depth, is not here.
*/
devices: readonly DeviceDeclaration[];
/**
* Show these readings on the hardware. Cheap and idempotent; call it whenever
* a feed publishes. A no-op for an office that declared no devices.
*/
setDeviceStates(states: readonly DeviceState[]): void;
/**
* Reflect one vehicle telemetry observation on the car outside.
*
* Signature-guarded downstream, so calling it every frame costs a comparison.
* A no-op for a pack with no arrival stall, or when no exterior was asked for.
*/
setVehicleTelemetry(state: VehicleTelemetryState): void;
/**
* The sun's height, in degrees, from whatever clock the app is running.
*
@@ -402,7 +517,12 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// reads as a stall.
flightSpeed: 0.95,
});
kit.applyLighting(options.lighting ?? officeInterior());
const openingLighting = options.lighting ?? officeInterior();
kit.applyLighting(openingLighting);
// Before a single surface is built, so the first frame already has a room to
// reflect. The rig fingerprints the state and caches per kind, so this and
// every later `setLighting` cost a map lookup unless the light actually moved.
options.environment?.apply(scene, openingLighting, "office");
/**
* The sky wins over the flat colour when there is one.
@@ -562,6 +682,64 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
if (presence) scene.add(presence.group);
shell.ceilings.visible = options.showCeilings ?? false;
// ---- The hardware on the furniture --------------------------------------
//
// Read off the floorplans rather than taken as an option, because a device is
// part of a pack in exactly the way a desk is: `Floorplan.devices` says the
// microphone exists and which prop it stands on, and `Plan` has already
// decided which of those declarations survived this build's depth. Filtering
// to what `Plan` accepted is what stops the interface offering a panel for a
// device the room does not contain — the layer would have skipped it and the
// panel would have shown a control that reaches nothing.
const registry: AssetRegistry = options.registry ?? assetKit;
const declared: DeviceDeclaration[] = [];
for (const level of office.levels) {
for (const declaration of level.floorplan.devices ?? []) declared.push(declaration);
}
const deviceDeclarations: readonly DeviceDeclaration[] = declared.filter(
(declaration) => plan.device(declaration.id) !== null,
);
const deviceLayer: DeviceLayer | null =
deviceDeclarations.length > 0
? createDeviceLayer({ plan, declarations: deviceDeclarations, assets: registry, materials })
: null;
if (deviceLayer) scene.add(deviceLayer.object);
// ---- The car outside -----------------------------------------------------
//
// Guarded on the pack having authored a stall, which most will not: an
// `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere
// to put one. The exterior positions everything in the pack's own metres from
// the plan origin, so the only transform it needs is the storey its stall is
// measured from — a podium deck at level 1 is 188 m off the street, and the
// apron stands on the floor of `arrival.levelId` by the exterior's own wording.
const arrivalStall = office.site?.arrival;
let exterior: OfficeExterior | null = null;
if (options.exteriorVehicle && office.site && arrivalStall) {
exterior = createOfficeExterior({
site: office.site,
arrival: arrivalStall,
assets: registry,
materials,
rand: mulberry32(options.exteriorVehicle.seed),
detail: options.exteriorVehicle.detail,
});
exterior.object.position.y = plan.level(arrivalStall.levelId)?.floorY ?? 0;
scene.add(exterior.object);
}
// ---- The traffic overhead ------------------------------------------------
const overhead: OverheadTraffic | null =
options.flights && office.site && options.horizon
? createOverheadTraffic({
source: options.flights,
site: office.site,
centre: plan.bounds.center,
radius: Math.min(far * 0.42, OVERHEAD_MAX_RADIUS_M),
})
: null;
if (overhead) scene.add(overhead.group);
// ---- Viewpoints ---------------------------------------------------------
const viewpointById = new Map(plan.viewpoints.map((v) => [v.id, v]));
@@ -818,6 +996,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
onViewChange(fn) {
viewListeners.push(fn);
},
devices: deviceDeclarations,
setDeviceStates(states) {
deviceLayer?.apply(states);
},
setVehicleTelemetry(state) {
exterior?.apply(state);
},
setPresence(people) {
if (!presence) {
// Once, not once per poll: an occupancy feed pointed at the public
@@ -843,6 +1028,10 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
setLighting(state) {
kit.applyLighting(state);
paintHorizon(state);
// One direction, still: `Atmosphere` decided this rig, `officeDaylight`
// turned it into the building's frame, and the environment is derived
// from the result rather than being a second opinion about the light.
options.environment?.apply(scene, state, "office");
},
setSolarElevation(degrees) {
luminaires.setSolarElevation(degrees);
@@ -887,8 +1076,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
// *now*, not to where they were last frame.
robots?.tick(dt);
luminaires.tick(dt);
overhead?.tick(dt);
},
dispose() {
// First, because the rig keeps a ledger of every scene it has written to
// so a rebuilt environment reaches all of them — and a disposed office
// left in that ledger is a whole floor plate retained.
options.environment?.release(scene);
overhead?.dispose();
exterior?.dispose();
deviceLayer?.dispose();
mediaSurfaces.dispose();
officeWalker?.dispose();
realtimePeers?.dispose();
@@ -922,6 +1119,206 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
};
}
/**
* A deterministic generator from one integer, so a studio's car is the same car
* on every machine and on every reload.
*
* Mulberry32, four lines, no dependency. It is here rather than imported
* because the only thing in this file that needs randomness is the parking
* jitter, and `createOfficeExterior` takes a `() => number` precisely so that
* the caller owns the reproducibility rather than the layer.
*/
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---- Overhead traffic -----------------------------------------------------
interface OverheadTraffic {
group: THREE.Group;
tick(dt: number): void;
dispose(): void;
}
interface OverheadTrafficOptions {
source: FlightSource;
site: NonNullable<Office["site"]>;
/** The middle of the floor plate, in the pack's metres. The dome is centred here. */
centre: { x: number; z: number };
radius: number;
}
/**
* The same aeroplanes the city board is drawing, seen from inside a building.
*
* ### Why a dome rather than a position
*
* An airliner over Los Angeles is ten kilometres up and twenty across. Placed at
* true metre range in a scene whose far plane is 8.4 km it is clipped, and if
* the far plane were moved out to reach it the fog which saturates at 4.2 km,
* because that is what makes the horizon a horizon would have swallowed it
* long before. So the *direction* is kept exactly and the *distance* is not:
* every track is put on a fixed dome at the bearing and elevation it is really
* at, sized by an angle rather than a length. That is the same bargain
* `aircraftGeometry.ts` already makes for the city, written down again here
* because the reason is different: the city trades scale for legibility, and
* this trades range for a depth buffer that works.
*
* ### Why it is instanced
*
* One geometry, one material, one draw call whatever the occupancy against
* the city layer's mesh-per-track, which exists there because each aircraft
* carries its own altitude-banded material and its own pick target. Neither is
* wanted here: a room's sky is scenery, nothing in it is clickable, and the
* office's draw-call budget is the one that has to hold an entire studio.
*
* ### One direction, again
*
* `site.heading` is the bearing the pack's Z points along, so a compass bearing
* becomes a building-frame yaw by subtracting it the same rotation
* `officeDaylight` applies to the sun, for the same reason and in the same
* sense. Getting it backwards would put the afternoon traffic over the wrong
* wall, which is exactly as wrong as putting the afternoon sun there.
*/
function createOverheadTraffic(options: OverheadTrafficOptions): OverheadTraffic {
const { source, site, centre, radius } = options;
const group = new THREE.Group();
group.name = "overhead-traffic";
const geometry = airlinerGeometry();
/**
* Lit, with a small emissive floor, and out of the fog.
*
* The emissive is the city layer's number and is there for the city layer's
* reason: after sunset the rig is a tenth of an intensity and a purely diffuse
* dart simply vanishes, on the one evening sky worth looking at. `fog: false`
* because the dome's radius is a drawing convention rather than a distance
* applying 3.6 km of haze to a symbol that stands for twenty kilometres is
* fogging an arbitrary number.
*/
const material = new THREE.MeshLambertMaterial({
color: 0xdfe7ef,
emissive: 0xdfe7ef,
emissiveIntensity: 0.35,
fog: false,
});
const mesh = new THREE.InstancedMesh(geometry, material, OVERHEAD_CAPACITY);
mesh.name = "overhead-traffic-instances";
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// The dome is centred on the building and always in frame; a bounding-sphere
// test on something that can never be culled is pure cost.
mesh.frustumCulled = false;
mesh.count = 0;
mesh.castShadow = false;
mesh.receiveShadow = false;
group.add(mesh);
const scale = (radius * OVERHEAD_ANGULAR_SIZE) / AIRLINER_LENGTH;
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const euler = new THREE.Euler(0, 0, 0, "YXZ");
const scaleVector = new THREE.Vector3(scale, scale, scale);
const headingRad = (site.heading * Math.PI) / 180;
const cosLat = Math.cos((site.lat * Math.PI) / 180);
const minSinElevation = Math.sin((OVERHEAD_MIN_ELEVATION_DEG * Math.PI) / 180);
let timer = 0;
let disposed = false;
function place(aircraft: readonly Aircraft[]): void {
let count = 0;
for (const a of aircraft) {
if (count >= OVERHEAD_CAPACITY) break;
/*
* Equirectangular, not great-circle, and that is a decision rather than a
* shortcut: an aeroplane still above five degrees from a building is at
* most a couple of hundred kilometres away, where the cosine-corrected
* flat approximation is wrong by metres in a bearing that is then drawn
* on a dome anyway. A haversine here would be four transcendentals per
* aeroplane per poll to move a symbol by less than its own width.
*/
const east = (a.lng - site.lng) * cosLat * METRES_PER_DEGREE;
const north = (a.lat - site.lat) * METRES_PER_DEGREE;
const ground = Math.hypot(east, north);
const up = a.altitude - site.elevation;
const slant = Math.hypot(ground, up);
if (slant < 1) continue;
const sinElevation = up / slant;
if (sinElevation < minSinElevation) continue;
// Bearing clockwise from true north, turned into the building's frame by
// subtracting the heading its own Z points along.
const bearing = Math.atan2(east, north) - headingRad;
const cosElevation = Math.sqrt(Math.max(0, 1 - sinElevation * sinElevation));
position.set(
centre.x + Math.sin(bearing) * cosElevation * radius,
sinElevation * radius,
centre.z - Math.cos(bearing) * cosElevation * radius,
);
// Nose along +Z and Z is the building's own north, so a half turn less
// the track's heading in this frame — the identical mapping `flights.ts`
// uses, and the one whose inverse once flew every departure tail-first.
euler.set(0, Math.PI - ((a.heading * Math.PI) / 180 - headingRad), 0);
quaternion.setFromEuler(euler);
matrix.compose(position, quaternion, scaleVector);
mesh.setMatrixAt(count, matrix);
count += 1;
}
mesh.count = count;
mesh.instanceMatrix.needsUpdate = true;
}
return {
group,
tick(dt) {
if (disposed) return;
timer -= dt;
if (timer > 0) return;
timer = source.interval;
/*
* No interpolation, unlike the city layer, and the sky is why. A track on
* this dome moves a few pixels between polls: at 3.6 km an airliner
* covers about 0.24 degrees a second across the dome, which is a third of
* its own drawn width, so tweening it would be machinery for motion
* nobody can see. The city layer interpolates because there the same
* aeroplane crosses a visible fraction of the board.
*/
void Promise.resolve(source.poll()).then((aircraft) => {
if (!disposed) place(aircraft);
});
},
dispose() {
disposed = true;
// Never `source.dispose()`: this layer is a second reader of a feed the
// city owns, and disposing it here would take the traffic off the board
// the moment somebody stepped indoors.
mesh.dispose();
geometry.dispose();
material.dispose();
group.clear();
},
};
}
/**
* Metres per degree of latitude, and of longitude at the equator.
*
* The WGS-84 mean, which is the same constant `main.ts` uses to turn a scene
* offset back into a coordinate. A degree of latitude varies by about half a
* percent between the equator and the pole; on a bearing drawn as a symbol that
* is nothing.
*/
const METRES_PER_DEGREE = 111_320;
function withoutPeerFactory(options: OfficeRealtimePeersOptions): Omit<ScenePeersOptions, "project" | "groundAt"> {
const { create: _create, ...peerOptions } = options;
return peerOptions;
+366 -5
View File
@@ -43,6 +43,23 @@
* exception with no context in the middle of a 180-prop pack tells the author
* nothing and loses the other 179.
*
* ### Two things resolve late, and both are addresses
*
* Almost everything here is resolved level by level, in one pass, because a wall
* and the slab under it are facts about one storey. Two authored things are not:
* a **device** names a prop, and the prop may be anywhere in the building; the
* **exterior arrival stall** names a level and stands outside every one of them.
* Both are therefore resolved after the level loop has run, next to the
* prop-to-seat binding pass that runs late for exactly the same reason a
* cross-level address checked against a half-built plan reports a problem that
* is not there.
*
* A device is the first authored record in this format that takes its
* *coordinate* from another record rather than restating one. `DeviceAnchor` in
* `src/devices/types.ts` argues that case; the consequence here is that the
* derivation happens once, in `resolveDevice`, and a device that cannot find its
* hardware is dropped rather than given a position of its own.
*
* ### Depth: a public build does not build the private half
*
* `PlanOptions.depth` is the other reason something can be absent from the build
@@ -57,10 +74,18 @@
* and is public whatever it is marked.
*/
import type {
DeviceCapability,
DeviceDeclaration,
DeviceKind,
DeviceProvenance,
} from "../devices/types.ts";
import { deviceKindOfAssetId, validateDeviceDeclaration } from "../devices/types.ts";
import type {
AssetId,
Audience,
DeskBank,
ExteriorArrival,
Level,
Office,
Opening,
@@ -222,6 +247,45 @@ export interface ResolvedSeat {
source: { bankId: string; station: number } | undefined;
}
/**
* A device, bound to the hardware it stands on and given a coordinate.
*
* The declaration in the pack has no position see `DeviceAnchor` in
* `src/devices/types.ts`, which argues the case at length: a device is hardware,
* hardware sits on something, and that something is already placed. So this is
* where the coordinate comes from, exactly once, by reading the anchor prop's
* resolved transform and adding the authored offset **in the prop's own frame**.
* Nudge the desk and the mic moves with it, because there was never a second
* number to forget to update.
*
* `roomId` is filled in even when the pack left it out, because the answer is a
* lookup the pack should not have to restate and a consumer should not have to
* repeat. `seatId` is not a device that serves no seat serves no seat, and
* inventing the nearest one would be the engine deciding what a microphone is
* pointed at.
*/
export interface ResolvedDevice {
id: string;
kind: DeviceKind;
label: string;
assetId: AssetId;
levelId: string;
/** The prop this device's hardware is, or stands on. Always resolves. */
propId: string;
/** Office-world metres: anchor prop transform, plus the prop-frame offset. */
position: { x: number; y: number; z: number };
/** The anchor prop's yaw. A device faces the way its hardware faces. */
rotation: Yaw;
/** The room the hardware stands in, resolved when the pack did not say. */
roomId: string | undefined;
/** The seat this device serves, if the pack bound it to one. An address. */
seatId: string | undefined;
capabilities: readonly DeviceCapability[];
provenance: DeviceProvenance;
/** The sentence a viewer is shown next to the readings. Never empty. */
disclosure: string;
}
/** A room's floor slab, cleaned, re-wound and measured. */
export interface ResolvedRoom {
id: string;
@@ -269,6 +333,8 @@ export interface LevelPlan {
props: readonly PropPlacement[];
seats: readonly ResolvedSeat[];
zones: readonly ResolvedZone[];
/** Resolved after every level exists — see the pass in the constructor. */
devices: readonly ResolvedDevice[];
collision: readonly Segment[];
bounds: Bounds;
}
@@ -289,6 +355,30 @@ export interface PlanProblem {
action: "dropped" | "repaired";
}
/**
* One authored device waiting for the rest of the building to exist.
*
* `sink` is the array the level already handed out as `LevelPlan.devices`, so
* the late pass fills in the answer the level is already advertising rather than
* replacing it.
*/
interface PendingDevice {
where: string;
/** The level whose floorplan declared it, which its anchor must agree with. */
levelId: string;
declaration: DeviceDeclaration;
sink: ResolvedDevice[];
/**
* Prop ids this level had and this *depth* does not the private half, in a
* public build. Shared by every device on the level.
*
* Without it a public build would report a problem for every device standing
* on a private prop, which is not a problem: it is the depth doing exactly
* what it is for. See the note beside the audience skips in `buildLevel`.
*/
hidden: Set<string>;
}
/** How every pass reports. Threaded through rather than closed over, so the
* polygon and opening helpers can stay free functions. */
type Report = (where: string, message: string, action: PlanProblem["action"]) => void;
@@ -365,6 +455,21 @@ export class Plan {
readonly levels: readonly LevelPlan[];
/** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */
readonly viewpoints: readonly Viewpoint[];
/**
* Where a vehicle stands outside, or `null` when the pack authored none or
* authored one that does not resolve.
*
* Deliberately **not** called `arrival`, because `arrival()` next to it means
* something else entirely and has since before this field existed: that one is
* the camera pose you open the building at, this one is a rectangle of tarmac
* outside it. Two things called arrival in one class is how a caller ends up
* parking a car in the lobby.
*
* The authored object is handed back by reference rather than copied. It is
* plain data on a frozen-by-convention pack, and a consumer that wants to keep
* it can `structuredClone` it the same treatment `office` gets.
*/
readonly exteriorArrival: ExteriorArrival | null;
/** Everything the validation pass dropped or repaired, in build order. */
readonly problems: readonly PlanProblem[];
/** The whole office, every level unioned. */
@@ -375,6 +480,7 @@ export class Plan {
private readonly seatsById = new Map<string, ResolvedSeat>();
private readonly propsById = new Map<string, PropPlacement>();
private readonly viewpointsById = new Map<string, Viewpoint>();
private readonly devicesById = new Map<string, ResolvedDevice>();
constructor(office: Office, options: PlanOptions = {}) {
this.office = office;
@@ -400,11 +506,18 @@ export class Plan {
seat: new Set<string>(),
zone: new Set<string>(),
viewpoint: new Set<string>(),
device: new Set<string>(),
};
// `levels` and `viewpoints` are required by the type, but a pack arriving as
// JSON has been through no type checker at all, and a missing array should
// produce an empty office rather than a TypeError with a stack trace in it.
// Devices are collected here and resolved after the level loop, for the same
// reason the prop-to-seat pass below runs late: an anchor is an address into
// the whole building, and checking one against a half-built plan reports a
// problem that is not there.
const pending: PendingDevice[] = [];
const levels: LevelPlan[] = [];
(office.levels ?? []).forEach((level, li) => {
const where = `levels[${li}]`;
@@ -413,7 +526,7 @@ export class Plan {
return;
}
seen.level.add(level.id);
const built = this.buildLevel(level, levels.length, where, seen, report);
const built = this.buildLevel(level, levels.length, where, seen, report, pending);
levels.push(built);
this.levelsById.set(built.id, built);
for (const seat of built.seats) this.seatsById.set(seat.id, seat);
@@ -436,6 +549,8 @@ export class Plan {
}
}
for (const item of pending) this.resolveDevice(item, seen.device, report);
const viewpoints: Viewpoint[] = [];
(office.viewpoints ?? []).forEach((viewpoint, vi) => {
const where = `viewpoints[${vi}]`;
@@ -462,6 +577,7 @@ export class Plan {
this.levels = levels;
this.viewpoints = viewpoints;
this.exteriorArrival = this.acceptArrival(office.site?.arrival, report);
this.problems = problems;
this.bounds = extent.finish();
}
@@ -484,6 +600,15 @@ export class Plan {
return this.viewpointsById.get(id) ?? null;
}
device(id: string): ResolvedDevice | null {
return this.devicesById.get(id) ?? null;
}
/** Every device in the building, in declaration order, levels in order. */
allDevices(): ResolvedDevice[] {
return [...this.devicesById.values()];
}
/** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */
arrival(): Viewpoint | null {
return this.viewpoints[0] ?? null;
@@ -544,6 +669,7 @@ export class Plan {
where: string,
seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set<string>>,
report: Report,
pending: PendingDevice[],
): LevelPlan {
const floorY = level.elevation;
const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS;
@@ -607,15 +733,34 @@ export class Plan {
// which is the one whose author can do something about it.
const props: PropPlacement[] = [];
const seats: ResolvedSeat[] = [];
// Ids the depth took away rather than ids the pack got wrong. Only devices
// read it, and only to tell "your hardware is not in this build" apart from
// "your hardware does not exist".
const hidden = new Set<string>();
(floorplan.deskBanks ?? []).forEach((bank, bi) => {
const at = `${where}.deskBanks[${bi}]`;
if (!included(this.depth, bank.audience)) return;
if (!included(this.depth, bank.audience)) {
// A private bank generates no props at all, so its stations' ids have to
// be derived rather than observed. They are contractual — `types.ts`
// promises a pack author exactly these strings — which is why they are
// computed by the same helper that emits them.
const stations = Math.floor(bank.columns) * Math.floor(bank.rows);
for (let station = 1; station <= stations; station += 1) {
hidden.add(bankPropId(bank.id, "desk", station));
if (bank.chair !== undefined) hidden.add(bankPropId(bank.id, "chair", station));
}
return;
}
this.expandBank(bank, level, floorY, at, seen, report, props, seats);
});
(floorplan.props ?? []).forEach((prop, pi) => {
const at = `${where}.props[${pi}]`;
if (!included(this.depth, prop.audience)) return;
if (!included(this.depth, prop.audience)) {
hidden.add(prop.id);
return;
}
if (seen.prop.has(prop.id)) {
report(at, `duplicate prop id "${prop.id}"`, "dropped");
return;
@@ -661,6 +806,22 @@ export class Plan {
});
});
// Devices are not resolved here, only queued: the array this level exposes is
// filled in by the pass in the constructor, once every prop in the building
// has an id and a transform. Handing the same array out now and filling it
// later is what lets `LevelPlan` stay one flat record rather than growing a
// second, half-built shape nobody can tell from the finished one.
const devices: ResolvedDevice[] = [];
(floorplan.devices ?? []).forEach((declaration, di) => {
pending.push({
where: `${where}.devices[${di}]`,
levelId: level.id,
declaration,
sink: devices,
hidden,
});
});
// Props and seats join the extent last so that bank expansions are included
// too — the bounds of a floor whose only content is one desk bank should not
// come out as a point at the origin.
@@ -681,6 +842,7 @@ export class Plan {
props,
seats,
zones,
devices,
collision,
bounds: extent.finish(),
};
@@ -921,7 +1083,7 @@ export class Plan {
});
pushProp({
id: `${bank.id}-desk-${n}`,
id: bankPropId(bank.id, "desk", station),
kind: bank.desk,
at: { x, z },
rotation: facing,
@@ -932,7 +1094,7 @@ export class Plan {
if (bank.chair !== undefined) {
pushProp({
id: `${bank.id}-chair-${n}`,
id: bankPropId(bank.id, "chair", station),
kind: bank.chair,
at: { x: x + seatX, z: z + seatZ },
rotation: facing,
@@ -944,10 +1106,209 @@ export class Plan {
}
}
}
/**
* One authored device, bound to the hardware it stands on.
*
* Nothing here throws, and that is a deliberate difference from
* `resolveRobotOperations`, which does. A robot's station list is authored
* *behaviour* and a station with no floor under it is a bug in the pack. A
* device is authored *furniture*: one mic with a typo in its anchor should
* cost a viewer that mic and not the building. So every failure below drops
* one device and records why, exactly as a bad wall opening does.
*
* The order is: what the declaration says about itself, then what it says
* about the plan. `validateDeviceDeclaration` owns the first half id, label,
* kind against asset id, capabilities, and the disclosure check that stops a
* simulated reading being shown without the word "simulated" anywhere near it.
* This method owns only the half that needs a resolved building.
*/
private resolveDevice(item: PendingDevice, seen: Set<string>, report: Report): void {
const { where, levelId, declaration } = item;
const id = declaration.id;
const faults = validateDeviceDeclaration(declaration);
if (faults.length > 0) {
for (const fault of faults) report(where, fault, "dropped");
return;
}
if (seen.has(id)) {
report(where, `duplicate device id "${id}"`, "dropped");
return;
}
const anchor = declaration.anchor;
// The anchor restates the level it was declared on, because the declaration
// type has to stand on its own over the wire. Restated facts disagree
// eventually, so the disagreement is caught here rather than resolved by
// picking a winner.
if (anchor.levelId !== levelId) {
report(
where,
`device "${id}" is declared on level "${levelId}" and anchored to "${anchor.levelId}"`,
"dropped",
);
return;
}
// At public depth a private prop was never resolved, so a device standing on
// one lands here and is dropped — which is the right answer: hardware whose
// furniture is not in the build has nowhere to be.
const prop = this.propsById.get(anchor.propId);
if (!prop) {
// Not reported, and not a problem: the pack is fine, it is being read at a
// depth that does not include the furniture this device stands on. The
// device goes with it, which is the answer you want — a public build with
// a floating microphone over a desk it cannot see is worse than a public
// build with no microphone.
if (item.hidden.has(anchor.propId)) return;
report(where, `device "${id}" is anchored to unknown prop "${anchor.propId}"`, "dropped");
return;
}
if (prop.levelId !== levelId) {
report(
where,
`device "${id}" is anchored to prop "${prop.id}", which is on level "${prop.levelId}"`,
"dropped",
);
return;
}
/**
* A mic bolted to a speaker is not a rendering mistake, it is a command sent
* to the wrong instrument, so it is dropped rather than drawn.
*
* Only when the anchor prop is *itself* device hardware, though. A mic
* standing on a desk is the ordinary case `DeviceAnchor.offset` exists for
* exactly those few centimetres and a desk claims to be no device at all,
* so `deviceKindOfAssetId` returns null for it and there is nothing to
* disagree with.
*/
const propKind = deviceKindOfAssetId(prop.kind);
if (propKind !== null && propKind !== declaration.kind) {
report(
where,
`device "${id}" is a ${declaration.kind} anchored to ${prop.kind}, which is a ${propKind}`,
"dropped",
);
return;
}
// The one calculation in this method. Local +X of a prop at yaw φ points at
// (cos φ, -sin φ) and local +Z at (sin φ, cos φ) — the same frame
// `expandBank` lays its stations out in, and the reason the offset is
// authored in the prop's frame rather than the room's: turn the desk and the
// mic stays on the corner of it.
const offset = anchor.offset;
const cos = Math.cos(prop.rotation);
const sin = Math.sin(prop.rotation);
const position = offset
? {
x: prop.position.x + offset.x * cos + offset.z * sin,
y: prop.position.y + offset.y,
z: prop.position.z - offset.x * sin + offset.z * cos,
}
: { x: prop.position.x, y: prop.position.y, z: prop.position.z };
// `roomId` and `seatId` are addresses rather than positions, and they are
// treated the way every other address in this file is: an unknown one is
// cleared and reported, never invented. The room is derived when the pack
// left it out, because that answer is a lookup and a pack should not have to
// restate what the geometry already knows.
let roomId = anchor.roomId;
if (roomId !== undefined) {
const known = this.levelsById.get(levelId)?.rooms.some((room) => room.id === roomId) ?? false;
if (!known) {
report(where, `device "${id}" names unknown room "${roomId}"`, "repaired");
roomId = undefined;
}
}
if (roomId === undefined) {
roomId = this.roomAt(levelId, { x: position.x, z: position.z })?.id;
}
let seatId = anchor.seatId;
if (seatId !== undefined && !this.seatsById.has(seatId)) {
report(where, `device "${id}" serves unknown seat "${seatId}"`, "repaired");
seatId = undefined;
}
seen.add(id);
const resolved: ResolvedDevice = {
id,
kind: declaration.kind,
label: declaration.label,
assetId: declaration.assetId,
levelId,
propId: prop.id,
position,
rotation: prop.rotation,
roomId,
seatId,
// Copied rather than aliased: a build product that shares an array with
// the pack is one `sort()` away from editing the authored data.
capabilities: [...declaration.capabilities],
provenance: declaration.provenance,
disclosure: declaration.disclosure,
};
item.sink.push(resolved);
this.devicesById.set(id, resolved);
}
/**
* The exterior stall, checked for the three things that would make it
* unusable and deliberately not for the fourth.
*
* Level, kind and finite numbers are checked. **Whether the stall is actually
* outside the building is not**, and that is on purpose: a courtyard block
* with a stall in its own yard, a covered undercroft, a loading bay half under
* an overhang are all things a real pack might mean, and `Plan` has no
* business ruling on architecture. A pack that wants that guarantee asserts it
* in its own test the shipped three do, in `src/test/packs/`.
*/
private acceptArrival(
arrival: ExteriorArrival | undefined,
report: Report,
): ExteriorArrival | null {
if (arrival === undefined || arrival === null) return null;
const where = "site.arrival";
if (arrival.kind !== "vehicle-stall") {
report(where, `arrival anchor has unknown kind "${String(arrival.kind)}"`, "dropped");
return null;
}
if (!this.levelsById.has(arrival.levelId)) {
report(where, `arrival anchor is on unknown level "${arrival.levelId}"`, "dropped");
return null;
}
const position = arrival.position;
if (
!position ||
!Number.isFinite(position.x) ||
!Number.isFinite(position.z) ||
!Number.isFinite(arrival.rotation)
) {
report(where, "arrival anchor has a position or rotation that is not a number", "dropped");
return null;
}
return arrival;
}
}
// ---- Placement helpers ----------------------------------------------------
/**
* The prop id a bank station generates.
*
* Contractual: `types.ts` promises a pack author that bank `eng`'s fourth desk
* is `eng-desk-04` and nothing may renumber it. It is a function because two
* places need the answer the expansion that emits them, and the depth pass
* that has to name the ones a private bank did *not* emit and two copies of a
* promise is one copy too many.
*/
function bankPropId(bankId: string, part: "desk" | "chair", station: number): string {
return `${bankId}-${part}-${String(station).padStart(2, "0")}`;
}
function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement {
const s = prop.scale ?? 1;
const scale: [number, number, number] =
+68
View File
@@ -29,6 +29,7 @@
* +Z down the page.
*/
import type { DeviceDeclaration } from "../devices/types.ts";
import type { BuildingGlyph, Pin, View } from "../engine/types.ts";
// ---- Geometry -------------------------------------------------------------
@@ -257,6 +258,57 @@ export interface OfficeSite {
* the generic marker layer; the city still never imports an office pack.
*/
exterior?: BuildingGlyph;
/**
* Where a vehicle stands on the ground outside the front door.
*
* Optional, and it lives on the *site* rather than on the `Office` for the
* same reason `elevation` does: it is a fact about the building's
* relationship to the ground outside it, and a pack with no site has no
* outside for anything to stand in. A pack that never mentions a vehicle
* renders exactly as it did before this field existed, which is the property
* every addition to this file has to keep.
*/
arrival?: ExteriorArrival;
}
/**
* A marked place on the ground outside the building.
*
* The exterior layer needs one number a pack cannot get from anywhere else:
* **where, in the plan's own coordinates, is the apron outside the front
* door.** `lat`/`lng` says where the building is on the earth and nothing about
* which corner of the lot you park on; `Plan.bounds` is the extent of what was
* authored and its edge is a wall rather than a kerb. So the stall is authored,
* like every other number in a pack.
*
* ### It is in the plan's frame, not the world's
*
* `position` is metres in exactly the same XZ frame the walls are in, and
* `rotation` is a `Yaw` zero faces Z, as everything else here does. That is
* what makes the anchor legible next to the wall it stands outside of: a stall
* on the street side of a façade authored at `z = 0` has a negative `z`, and a
* reader can see it is outside the building without converting anything.
*
* `levelId` names the storey whose floor the stall is measured from, which for
* every shipped pack is the ground floor and for a building on a slope is not
* necessarily so.
*
* ### One kind, spelled out
*
* `kind` is a closed union with a single member rather than a free string, so
* that the second member a loading bay, a bike rack, a helipad arrives as a
* decision somebody made rather than as a typo that happened to render.
*/
export interface ExteriorArrival {
/** The storey whose floor this stall is measured from. */
levelId: string;
/** Metres, in the pack's own plan frame, outside the building's footprint. */
position: Point2;
/** Which way a vehicle parked here faces. See `Yaw`. */
rotation: Yaw;
kind: "vehicle-stall";
/** What to call it, for a caption. Absent where it needs no name. */
label?: string;
}
/**
@@ -318,6 +370,22 @@ export interface Floorplan {
deskBanks?: DeskBank[];
seats?: Seat[];
zones?: Zone[];
/**
* Smart hardware standing on the furniture see `src/devices/types.ts`,
* which owns the type and is imported here for it.
*
* A `DeviceDeclaration` is authored, public and inert: it says a microphone
* exists, what it can be asked to do, and which prop is its hardware. What
* that microphone is *hearing* is a `DeviceState`, which never appears in a
* pack at all it arrives over the API from a route that can refuse an
* anonymous caller. That is the same line `Presence` draws one type down, for
* the same reason, and it is why a pack can be published and a reading cannot.
*
* The list is on the floorplan rather than on the `Office` because a device is
* anchored to a prop and props are per storey, so the two lists that have to
* agree with each other sit next to each other.
*/
devices?: readonly DeviceDeclaration[];
}
// ---- Rooms ----------------------------------------------------------------
+1062 -1104
View File
File diff suppressed because it is too large Load Diff
+217 -15
View File
@@ -4,8 +4,11 @@ An **office pack** is one JSON-shaped object describing the inside of a
building: floor slabs, walls, holes in the walls, furniture, seats, and a few
camera poses. `src/interiors/types.ts` is the contract — it is short, it is
commented, and it wins any argument with this document. `lumbridge-hq.ts` in
this directory is a worked example of every feature described here, and copying
it is the intended way to start.
this directory is the small worked example — one storey, four rooms, every core
feature — and copying it is the intended way to start. `mateo-court.ts` is the
large one: two storeys, sixteen rooms, seat bindings, device declarations and a
courtyard, and it is the file to read when you want to see a feature used in
anger rather than demonstrated.
Nothing in a pack requires an account, a key or a network. If you can run the
repo you can author an office, and if you can author an office you can hand
@@ -34,6 +37,7 @@ export const ACME_HQ: Office = {
deskBanks: [...],
seats: [...],
zones: [...],
devices: [...], // smart hardware; see "Devices" below
},
},
],
@@ -91,14 +95,88 @@ walls the light actually comes through. `0` means your "north" really is north.
`elevation` is the other one worth thinking about, because it is what the horizon
is measured from — the difference between an office on the 48th floor and a shed
on an airfield is one number, and it is this one. Both shipped packs are worked
examples: `lumbridge-hq.ts` is 188 m up and rotated 205°, `frontier-valley.ts` is
4 m up and square to the compass.
on an airfield is one number, and it is this one. All three shipped packs are
worked examples: `lumbridge-hq.ts` is 188 m up and rotated 205°,
`frontier-valley.ts` is 4 m up and square to the compass, and `mateo-court.ts` is
1.2 m up — a loading dock — and turned 36° onto the 1781 pueblo grid that
downtown Los Angeles still follows.
### Where the car goes
A sited building may also say where a vehicle stands outside it:
```ts
arrival: {
levelId: "level-1",
position: { x: 22.6, z: -3.4 }, // metres, in YOUR plan frame
rotation: -Math.PI / 2, // Yaw: which way the car points
kind: "vehicle-stall",
label: "Mateo Street kerb",
},
```
`src/engine/officeExterior.ts` builds an apron and a vehicle there. The position
is in **the pack's own frame**, the same one the walls are in — which is why a
stall on the street side of a façade authored at `z = 0` has a *negative* `z`,
and why you can see it is outside the building without converting anything.
Put it on ground that is genuinely outside: `Plan` checks the level, the kind and
that the numbers are numbers, and deliberately does **not** rule on whether the
stall is inside the footprint, because a covered undercroft and a courtyard are
things a pack might legitimately mean. `src/test/packs/arrivalAnchors.test.ts`
is where the three shipped packs assert that theirs are on the street, the
podium kerb and the apron.
One trap, and it is `lumbridge-hq`'s: a pack whose level-0 floor is 188 m above
the ground outside has no pavement to park on at all. Its stall is authored
beside its own front door because the plan frame is the only frame a pack has,
and what "outside" means *vertically* for a tower is the exterior layer's
decision. If your building is up in the air, say so in a comment where the next
person will find it.
Nothing here is geocoded and nothing can be. These are numbers you type, like
every other number in a pack — see CONTRACT.md §8 for why a coordinate's
provenance is a licensing question in this repo.
## Levels, and the one thing you cannot do with a second one
A `Level` is a storey: an `elevation` (floor-to-**floor**, not floor-to-ceiling),
a default wall height, and its own floorplan in its own frame with its slab at
zero. `Plan` adds the elevation to every coordinate on the level exactly once, so
you author an upper floor without holding 5 m in your head.
> ### ⚠️ A walker cannot change levels. Do not author a staircase.
>
> **This is the single most expensive thing to discover by building it.** The
> walk controller is hard-locked to the level it spawned on, in two places:
>
> - `src/interiors/walker.ts` rejects any state whose `levelId` differs from the
> spawn level — a restored or proposed state on another storey is refused as
> *"walker snapshot is incompatible or invalid"*.
> - `src/interiors/officeWalker.ts` **throws** if the level it is on stops
> resolving — *"office walker lost level"*.
>
> There is no vertical transition anywhere in the engine: no stair traversal, no
> lift, no level handoff, and nothing that changes `levelId` after a spawn. So a
> flight of stairs you author is a flight of stairs the collider will never let
> anybody climb, however carefully you model it. `mateo-court.ts` has a real
> dog-leg stair standing in its courtyard, drawn as a floor finish with a 1.2 m
> gap in the balustrade at the head of it, and **its upper floor is unreachable
> on foot**. That is a known, accepted state and not a bug in the pack.
>
> Fixing it is an **engine** change — a cross-level walker state, a transition
> volume, and a collider that knows about both storeys — and it is out of scope
> for a pack author. Until it lands:
>
> - Author a second storey for what it *is*: a place the camera flies to, which
> viewpoints do perfectly well. `mateo-court` gives its upper floor five of
> them.
> - Give each level a viewpoint of its own so nothing up there is unreachable by
> every means at once.
> - Do not spend a day on treads. `viewpoints[0]` is the walk spawn, and it is on
> exactly one level; everything else on that level is walkable and everything
> on any other level is not.
## Rooms are slabs. Walls are segments.
This is the load-bearing idea, and the thing most people get backwards on the
@@ -117,9 +195,10 @@ Consequences worth stating out loud:
id if you ever want to name it.
- Rooms may overlap and may leave gaps. `Plan.roomAt` resolves *later* rooms
first, so a room declared after another wins the lookup where they cross.
Overlapping is legal but the reference pack avoids it — two coplanar slabs at
the same height is a z-fight waiting for the wrong GPU, so the open floor is
notched around the focus booths rather than passing underneath them.
Overlapping is legal but every shipped pack avoids it — two coplanar slabs at
the same height is a z-fight waiting for the wrong GPU, so `mateo-court`
notches its courtyard around the stair standing in it rather than laying one
slab over the other.
Rooms and walls should be authored against the **same numbers**. A wall is
centred on its line and straddles the boundary between the two slabs meeting
@@ -133,10 +212,18 @@ means **no ceiling at all** — an atrium, a void, or a room you want to look do
into. `{ height, surface }` overrides one room.
An office you look down into from an establishing viewpoint cannot have lids on
the rooms you are trying to see, so most of the reference pack declares
`ceiling: null`. The three that keep theirs the focus booths, the server room,
the store — are the three you are never meant to see inside, and a ceiling is a
cheap way of saying so.
the rooms you are trying to see, so most of both large packs declare
`ceiling: null`. The rooms that keep theirs are the ones you are never meant to
see inside — the bath and storage in the SF studio, the equipment store in the LA
one — and a ceiling is a cheap way of saying so.
`mateo-court` uses the field in a third sense that is worth knowing about: its
courtyard and its stair are `ceiling: null` because they are **outside**, and
there is nothing above them at any height. Nothing in the format had to change
for that; the field already said what was needed. One consequence is worth
carrying, though: a room with no ceiling is a room with nothing to hang a light
fitting from, and a ceiling grid over an open courtyard is a lighting plan for a
different building.
## Doors and windows are openings, not props
@@ -339,6 +426,98 @@ only if you place its centre half its own depth off the wall's face:
deep and `tera:whiteboard` is 0.10 m. Name those offsets as constants; you will
use them a dozen times.
## Devices
A pack may declare smart hardware — a mic, a speaker — on its floorplan:
```ts
devices: [
{
id: "la-front-mic",
kind: "mic",
label: "Front desk mic",
assetId: "tera:device.mic.desk",
anchor: { levelId: "level-1", propId: "front-mic", roomId: "lobby", seatId: "front-01" },
capabilities: CANONICAL_CAPABILITIES.mic,
provenance: "simulated",
disclosure: "Simulated studio hardware. These readings are demonstration data…",
},
],
```
`src/devices/types.ts` owns the type and is worth reading; four things about it
matter when you are authoring one.
**A device has no coordinate.** `anchor.propId` is required and it *is* the
position: the device derives its transform from that prop's, plus an optional
`offset` in the prop's own frame for the few centimetres between a desk's origin
and the top of a mic stand. Nudge the desk and the mic goes with it, because
there was never a second number to forget. Same rule `Prop.seat` follows for
chairs, and the same one a pack follows by importing its site instead of
restating the coordinates.
**The anchor prop is the hardware.** Its `kind` is the device asset —
`<namespace>:device.<kind>.<placement>`, so `tera:device.mic.desk` and a
self-hoster's `acme:device.mic.boom` both read as a mic with nothing registered.
`Plan` drops a declaration whose anchor prop is device hardware of the *wrong*
kind: a mic bolted to a speaker is not a rendering mistake, it is a command
routed to the wrong instrument.
**`capabilities` should be `CANONICAL_CAPABILITIES[kind]`.** The device panel
builds its controls by walking that array and the arena's observation width is
the sum of them, so two studios describing a mic differently changes the shape of
an RL observation without anybody editing the arena.
**`disclosure` is mandatory and it is checked.** A declaration with
`provenance: "simulated"` whose disclosure does not contain the word is reported
as a problem and dropped — the same check `RobotOperationsDefinition` gets, for
the same reason. A level meter that moves, with nothing beside it saying where
the number came from, is a claim about a real room.
What a device is *doing* — powered, muted, its level in dBFS — is a `DeviceState`
and **never appears in a pack**. It arrives over the API from a route that can
refuse an anonymous caller, exactly as `Presence` does. A declaration is a
description of a room and is safe to publish; a reading is not, and the split is
the whole design.
## How full a room should be, and the trap in the answer
The number to aim at is **0.26 non-light props per square metre**, building-wide,
with no room over 20 m² below **0.15**. Both shipped studios clear it:
`lumbridge-hq` sits at 0.28 over 100 m², `mateo-court` at 0.29 over 1246 m².
`src/test/packs/mateoContent.test.ts` measures it if you want the exact method —
it counts prop centres by `Plan.roomAt`, and it excludes every `tera:light.*`
because a ceiling grid will satisfy any prop count you like while leaving the
floor bare. Mateo Court's first version proved that: ninety-eight of its props
were troffers, two of the grids hung in rooms declared `ceiling: null`, and it
looked empty from every viewpoint it had.
**But the ratio is the easy half, and on its own it is a lie.** `furnish.ts`
batches props by `(asset, colorKey)` and draws `ctx.rand` **once per batch**, so
every instance of a kind is geometrically identical — the same seeded jitter,
the same books on the same shelf, the same leaves on the same plant. Ten more
shelves in a room are one shelf drawn ten times. So:
> Apparent density is a function of **distinct kinds**, not of prop count.
> A room that looks thin does not get better when you copy what is already in it.
Two consequences for how you fill a room:
- **Reach for a kind you have not used yet before you reach for a second copy.**
Mateo Court's courtyard went from 13 props of 6 kinds to 44 of 13, and it is
the second number that changed what it looks like. Twelve of the assets in
`src/assets/office/studio.ts` exist because of exactly that room.
- **If the kind you need is not in the catalogue, write it.** `defineAsset` plus
a `registerAll` is a smaller change than it looks, `src/assets/office/studio.ts`
is a worked example of a dozen of them, and a `colorKey` on an existing kind
will not stand in for it — the colour is *in* the batch key, so two tints of
one asset are two batches of the same geometry, which is better than one and is
not a new object.
A useful floor for a big room is **seven distinct kinds over 40 m²**, which is
what the reference pack's one large room manages. Below that a room reads as a
pattern rather than a place, whatever the prop count says.
## Zones
A named region of floor with an opaque `colorKey`, no behaviour and no effect on
@@ -450,6 +629,12 @@ without capturing console output. In a dev build it also warns.
- duplicate ids — **ids are unique per kind and building-wide, not per level**,
because a `Presence` binds to a seat id and an occupancy layer dims a prop id,
so both have to mean one thing in the building. Later loses.
- a device whose anchor names a prop that does not exist, sits on another level,
or is device hardware of a different kind; and one whose own declaration is
invalid — no label, a `kind` its `assetId` disagrees with, no capabilities, or
a `simulated` provenance whose disclosure does not say so
- an `arrival` anchor on a level that does not exist, of an unknown `kind`, or
with a position that is not a number
**Repaired** (silently, and recorded):
@@ -457,6 +642,8 @@ without capturing console output. In a dev build it also warns.
- an outline wound the wrong way
- a negative sill clamped to the floor; a head above the wall clamped down
- a prop bound to an unknown seat id — the binding is cleared, the prop stays
- a device naming an unknown room or an unknown seat — the address is cleared,
the device stays and the room is re-derived from where its hardware stands
Missing required arrays read as `[]`, because a pack that arrived over HTTP has
been through no type checker.
@@ -477,12 +664,27 @@ dropping one bad opening does not renumber its siblings.
## A checklist before you call it done
1. `new Plan(office).problems` is empty.
1. `new Plan(office).problems` is empty**at both depths**, `"full"` and
`"public"`. They are different builds and only one of them is what a visitor
gets.
2. Every room you can walk into has a `door` or `arch` with `sill: 0` reaching
at least 1.1 m of head. Walk the graph, or spot-check with `Plan.blocked`.
3. No window opening has `sill: 0` unless you meant a doorway.
4. Seat ids are the ones you are willing to live with for a year.
4. Seat ids are the ones you are willing to live with for a year, and every seat
somebody is meant to occupy has a prop bound to it with `seat:`.
5. Corridors are at least 1.2 m clear, doors 0.9 m, desks 1.41.6 m. Numbers a
person would recognise are the whole difference between a floor plan and a
diagram.
6. Nothing binary landed under `src/`.
6. `JSON.parse(JSON.stringify(office))` **deep-equals** the office. The usual way
to fail this is a helper that writes `elevation: opts.elevation`
unconditionally: `{ elevation: undefined }` and `{}` are different objects and
only one of them survives the wire. Spread optional fields, do not assign
them.
7. Every room worth looking at has a viewpoint whose `focus.at` lands inside it,
and `viewpoints[0]` is somewhere a person can stand — it is the walk spawn as
well as the arrival camera.
8. At least 0.26 non-light props/m² building-wide, no room over 20 m² below
0.15, and — the one that matters — **at least seven distinct kinds in every
room over 40 m²**. See "How full a room should be" above for why the second
number is the real one.
9. Nothing binary landed under `src/`.
+7 -2
View File
@@ -182,8 +182,13 @@ function scatter(
kind,
position,
rotation: opts.rotation ?? NORTH,
elevation: opts.elevation,
colorKey: opts.colorKey,
// Spread rather than assigned, because `elevation: undefined` is not the
// same shape as no `elevation` at all: `JSON.stringify` drops the key and a
// pack that has been through the wire stops deep-equalling the one in the
// bundle. CONTRACT.md §2 says those two have to be literally the same
// thing, and `src/test/packs/packRegression.test.ts` now checks it.
...(opts.elevation === undefined ? {} : { elevation: opts.elevation }),
...(opts.colorKey === undefined ? {} : { colorKey: opts.colorKey }),
}));
}
+77 -2
View File
@@ -12,6 +12,7 @@
* without invalidating those addresses.
*/
import { CANONICAL_CAPABILITIES, type DeviceDeclaration } from "../devices/types.ts";
import type {
AssetId,
DeskBank,
@@ -64,6 +65,8 @@ const PENDANT: AssetId = "tera:light.pendant";
const TROFFER: AssetId = "tera:light.troffer";
const RUG: AssetId = "tera:rug";
const WHITEBOARD: AssetId = "tera:whiteboard";
const MIC: AssetId = "tera:device.mic.desk";
const SPEAKER: AssetId = "tera:device.speaker.desk";
const CONCRETE = "tera:concrete.polished";
const WOOD = "tera:wood.plank";
@@ -230,6 +233,14 @@ const PROPS: Prop[] = [
// screen grants survive this content rewrite.
prop("lobby-monitor", MONITOR, 2.15, 4.45, NORTH, { elevation: 0.73 }),
prop("agent-monitor-b", MONITOR, 3.97, 4.45, NORTH, { elevation: 0.73 }),
// The two pieces of hardware the studio operates, and the only two props in
// this pack that a `DeviceDeclaration` points at: a desk condenser on the
// left-hand workstation and a monitor speaker beside its screen. Both stand on
// the 0.73 m desktop, so both carry the elevation rather than assuming one.
// The declarations are in `DEVICES` at the foot of this file and carry no
// coordinate — they name these ids and take the transform from here.
prop("agent-mic", MIC, 1.62, 4.72, NORTH, { elevation: 0.73, seat: "sf-agent-01" }),
prop("agent-speaker", SPEAKER, 2.72, 4.5, NORTH, { elevation: 0.73 }),
prop("agent-tree", TREE, 5.15, 4.15),
prop("agent-light-a", TROFFER, 2.2, 4.6, NORTH, { elevation: CEILING }),
prop("agent-light-b", TROFFER, 4.0, 4.6, NORTH, { elevation: CEILING }),
@@ -264,6 +275,69 @@ const PROPS: Prop[] = [
prop("lounge-plant", PLANT, 6.4, 7.95),
];
/**
* One sentence, shown to a viewer beside every reading this building publishes.
*
* Mandatory and checked: `validateDeviceDeclaration` refuses a `simulated`
* declaration whose disclosure does not contain the word, exactly as
* `resolveRobotOperations` refuses a robot definition that does not say its
* activity is simulated. A live-looking level meter with no provenance beside it
* is a claim about a real room, and this is the field that stops the pack making
* one by omission.
*/
const DISCLOSURE =
"Simulated studio hardware. These readings are demonstration data, never live " +
"presence data.";
/**
* Two devices: the mic on the desk and the speaker on the computer.
*
* Both anchored to a prop and neither carrying a coordinate see `DeviceAnchor`
* in `src/devices/types.ts` for why that is the whole design of the type. Move
* `agent-mic` 200 mm and the mic moves with it, because there was never a second
* number to forget.
*
* `capabilities` is `CANONICAL_CAPABILITIES[kind]` rather than a hand-written
* list. Two studios authored months apart should describe the same instrument
* the same way the device panel builds its controls by walking this array, and
* the arena's observation width is the sum of them.
*/
const DEVICES: DeviceDeclaration[] = [
{
id: "sf-desk-mic",
kind: "mic",
label: "Desk mic",
assetId: MIC,
anchor: {
levelId: "level-1",
propId: "agent-mic",
roomId: "live-work",
// The seat this mic is in front of. An address, like every other seat
// reference in a pack — it is what lets a consumer ask whether anybody is
// sitting where the mic is pointed without being told a coordinate.
seatId: "sf-agent-01",
},
capabilities: CANONICAL_CAPABILITIES.mic,
provenance: "simulated",
disclosure: DISCLOSURE,
},
{
id: "sf-desk-speaker",
kind: "speaker",
label: "Desk speaker",
assetId: SPEAKER,
anchor: {
levelId: "level-1",
propId: "agent-speaker",
roomId: "live-work",
seatId: "sf-agent-01",
},
capabilities: CANONICAL_CAPABILITIES.speaker,
provenance: "simulated",
disclosure: DISCLOSURE,
},
];
const ZONES: Zone[] = [
{ id: "zone-entry", name: "Entry", outline: rect(FACE, 6.7, 1.75, DEPTH - FACE), colorKey: "social" },
{ id: "zone-agent-bench", name: "Agent Bench", outline: rect(1.1, 3.5, 5.15, 5.6), colorKey: "focus" },
@@ -338,6 +412,7 @@ const LEVEL: Level = {
deskBanks: DESK_BANKS,
seats: SEATS,
zones: ZONES,
devices: DEVICES,
},
};
@@ -354,8 +429,8 @@ export const LUMBRIDGE_HQ: Office = {
"A compact 12 × 9 metre San Francisco live/work studio: two agent workstations, a demo lounge, kitchen, sleeping alcove and bath/storage.",
author: "Lumbridge",
license: "CC0-1.0",
version: "2.0.0",
updated: "2026-08-19",
version: "2.1.0",
updated: "2026-08-21",
},
};
+1105 -61
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -7,7 +7,13 @@ export const MATEO_COURT_ROBOT_OPERATIONS = Object.freeze({
officeId: "mateo-court",
disclosure: "Seeded robot simulation — no live people, company activity, or operational data.",
stations: [
{ id: "la-l1-dock", label: "Court charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 18.4, z: 13.2 }, facing: { x: 1, z: 0 } } },
// Against the west wall of the yard, standing on `court-dock` — the
// `tera:dock.robot` prop the pack now places at (10.15, 16.9). This used to
// read (18.4, 13.2), which is underneath the courtyard's long table: fine
// for as long as nothing was drawn there, and a robot standing in the lunch
// the moment something was. The dock faces east out of the pad, which is
// where its `facing` comes from.
{ id: "la-l1-dock", label: "Court charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 10.9, z: 16.9 }, facing: { x: 1, z: 0 } } },
{ id: "la-l1-paseo", label: "Paseo patrol point", role: "patrol", anchor: { kind: "room", roomId: "paseo" } },
{ id: "la-l1-mess", label: "Mess patrol point", role: "patrol", anchor: { kind: "room", roomId: "mess" } },
{ id: "la-l1-court", label: "Court patrol point", role: "patrol", anchor: { kind: "room", roomId: "court" } },
@@ -16,7 +22,11 @@ export const MATEO_COURT_ROBOT_OPERATIONS = Object.freeze({
{ id: "la-l1-directory", label: "Paseo directory", role: "inspect", anchor: { kind: "prop", propId: "paseo-directory", standoffM: 0.7, side: -1 } },
{ id: "la-l1-display", label: "Works display", role: "inspect", anchor: { kind: "prop", propId: "works-display", standoffM: 0.72, side: -1 } },
{ id: "la-l2-dock", label: "Loft charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 13.2, z: 3.0 }, facing: { x: 1, z: 0 } } },
// The upper dock moved east out of the new `loft-b` bench, whose three
// columns now occupy x 13.416.8. It stands on the `loft-dock` prop in the
// model bay instead, beside the racks, on the one stretch of the street wall
// upstairs with no window in it. Its bay opens south, hence the facing.
{ id: "la-l2-dock", label: "Loft charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 22.6, z: 1.5 }, facing: { x: 0, z: 1 } } },
{ id: "la-l2-loft", label: "Loft patrol point", role: "patrol", anchor: { kind: "room", roomId: "loft" } },
{ id: "la-l2-palmetto", label: "Palmetto patrol point", role: "patrol", anchor: { kind: "room", roomId: "palmetto" } },
{ id: "la-l2-loggia", label: "Loggia patrol point", role: "patrol", anchor: { kind: "point", levelId: "level-2", position: { x: 10.0, z: 8.0 } } },
+49
View File
@@ -19,6 +19,22 @@
* pack importing its own site **from here** rather than declaring it inline. One
* source of truth, and the direction of the dependency is the safe one: the
* small thing does not know about the large one.
*
* ### Where the car stands
*
* Each site below carries an `arrival` anchor: one marked stall on the ground
* outside, in **the pack's own plan frame**, which is the only frame a pack has
* and the same one its walls are in. `src/engine/officeExterior.ts` builds the
* apron and the vehicle there.
*
* Two of the three are honest ground. `mateo-court` sits 1.2 m above its street
* and `frontier-valley` 4 m above an airfield, so a stall a few metres outside
* the façade is a stall on the pavement. **`lumbridge-hq` is 188 m up a tower**
* and there is no pavement outside its west wall at all its anchor is the
* kerb of the podium, authored beside the front door because the pack frame is
* the only place it can be authored, and what "outside" means vertically for a
* tower is the exterior layer's decision and not this file's. It is called out
* here rather than left for somebody to discover from a car parked in the sky.
*/
import type { OfficeSite } from "../interiors/types.ts";
@@ -44,6 +60,17 @@ export const LUMBRIDGE_HQ_SITE: OfficeSite = {
seed: 115,
bodyColor: 0x8799a8,
},
// West of the studio's own front door, which is the doorway 6.8 m along
// `ext-west`. Parallel to the façade and nosed north, the way a kerbside bay
// on a one-way downtown street runs. See the note at the top of this file
// about what 188 m of elevation does to the word "outside".
arrival: {
levelId: "level-1",
position: { x: -4.0, z: 7.4 },
rotation: 0,
kind: "vehicle-stall",
label: "Podium kerb",
},
};
/** A hangar on the old naval air station. See `frontier-valley.ts`. */
@@ -64,6 +91,16 @@ export const FRONTIER_VALLEY_SITE: OfficeSite = {
seed: 2718,
bodyColor: 0x899397,
},
// On the apron, seven metres clear of the twelve-metre hangar door in the east
// gable and centred on it, nosed in. An apron is the one place in these three
// packs where a vehicle is not a visitor but part of the programme.
arrival: {
levelId: "level-1",
position: { x: 61.0, z: 12.0 },
rotation: Math.PI / 2,
kind: "vehicle-stall",
label: "Hangar apron",
},
};
/**
@@ -89,6 +126,18 @@ export const MATEO_COURT_SITE: OfficeSite = {
seed: 1781,
bodyColor: 0xa87960,
},
// On the street, north of the brick façade and a few metres east of the paseo
// arch, so that it is in frame from the arrival viewpoint and three steps from
// the front door. `z` is negative because the street façade is authored at
// `z = 0` and the pavement is on the other side of it, which is the whole
// reason the anchor is in the plan's frame rather than the world's.
arrival: {
levelId: "level-1",
position: { x: 22.6, z: -3.4 },
rotation: -Math.PI / 2,
kind: "vehicle-stall",
label: "Mateo Street kerb",
},
};
/**
+23 -3
View File
@@ -47,17 +47,36 @@ const STYLES = `
.tera-webcam-face[data-status="active"] .tera-webcam-face__status { color: #8fd7aa; }
.tera-webcam-face[data-status="error"] .tera-webcam-face__status,
.tera-webcam-face[data-status="unsupported"] .tera-webcam-face__status { color: #ffb2aa; }
/*
* The "camera active" indicator.
*
* It used to position itself: fixed, right-aligned, and a top offset of
* var(--s4) plus the safe-area inset plus 4.55rem a number hand-derived from
* the heights of two cards in index.html that this file cannot see, plus a
* second copy of the same arithmetic under a media query. Any padding change in
* either of those cards silently moved this on top of one of them.
*
* It is now a plain child of the one top-right flex column, so it has no
* position of its own and no opinion about what is above it. Its *stacking*
* decision moved with it: index.html raises that whole column to the alert
* layer while and only while this element is unhidden, because an indicator
* saying a camera is on is a safety affordance and a dialog that can cover it
* turns "your webcam is live" into a fact the interface knows and the person
* does not. That is one rule, in the file that owns the stacking order, instead
* of a raw stacking literal here that nothing else on the page knew about.
*/
.tera-webcam-indicator {
position: fixed; top: calc(var(--s4, 16px) + env(safe-area-inset-top) + 4.55rem); right: var(--s4, 16px);
z-index: 12; display: flex; align-items: center; gap: 9px; padding: 8px 10px;
display: flex; align-items: center; gap: 9px; padding: 8px 10px;
color: #dff8e8; background: rgba(8, 32, 20, .92); border: 1px solid rgba(100, 220, 145, .45);
border-radius: var(--r-sm, 5px); box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.35));
font: 11px/1.4 ui-monospace, "SF Mono", Menlo, monospace;
}
.tera-webcam-indicator__text { flex: 1; min-width: 0; }
.tera-webcam-indicator__dot { width: 7px; height: 7px; border-radius: 50%; background: #65dc91; box-shadow: 0 0 0 3px rgba(101,220,145,.14); }
.tera-webcam-indicator__stop { min-height: 30px; padding: 4px 8px; color: inherit; background: transparent; border: 1px solid rgba(143,215,170,.4); border-radius: 4px; font: inherit; cursor: pointer; }
.tera-webcam-indicator__stop:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; }
@media (max-width: 600px) { .tera-webcam-indicator { top: calc(var(--s3, 12px) + env(safe-area-inset-top) + 5rem); right: var(--s3, 12px); } }
/* No phone override any more: the column it lives in carries the breakpoint,
which is the whole point of it being in a column. */
`;
const DEFAULT_MESSAGES: Record<WebcamFacePanelStatus, string> = {
@@ -117,6 +136,7 @@ export function createWebcamFacePanel(options: WebcamFacePanelOptions): WebcamFa
dot.className = "tera-webcam-indicator__dot";
dot.setAttribute("aria-hidden", "true");
const indicatorText = doc.createElement("span");
indicatorText.className = "tera-webcam-indicator__text";
indicatorText.setAttribute("role", "status");
indicatorText.setAttribute("aria-live", "polite");
indicatorText.textContent = "Camera active · local face only";
+187 -1
View File
@@ -27,10 +27,22 @@
* | `GET /markers` | `MarkersBody` | yes |
* | `GET /offices/:id` | `OfficeDoc` | public offices only |
* | `GET /offices/:id/presence` | `PresenceBody` | never |
* | `GET /offices/:id/devices` | `DevicesBody` | never |
* | `POST /offices/:id/devices/command` | `DeviceCommandResultBody` | never |
*
* Note the last two. Reading device state and commanding a device are two
* routes and two methods, and that is a **security boundary rather than REST
* taste**: a command that rode in the read body could be replayed by any shared
* cache that had kept a copy of the GET, and turning a microphone on by
* replaying a cached read is precisely the outcome the fail-closed
* `private, no-store` default in CONTRACT.md §5 exists to prevent. Neither
* route is ever `publicCache`d, and the command route is the only body in this
* file that goes *up* the wire.
*
* See CONTRACT.md §5.
*/
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
import type { Marker, SatelliteGroup } from "../engine/types.ts";
import type { Office, Presence } from "../interiors/types.ts";
@@ -61,8 +73,36 @@ export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo";
export type FlightsSourceId = "sim" | "adsb" | "dump1090";
export type SatellitesSourceId = "none" | "celestrak";
export type MarkersSourceId = "none" | "file";
/**
* Where device readings come from.
*
* `none` is the default and serves an empty array a box nobody has told about
* any hardware has no hardware, which renders as a studio whose panels say so
* rather than as an error. `sim` is the deterministic state machine in
* `src/devices/sim.ts`, the same module the arena wraps, and it is what this
* build ships. `homeassistant` is named here and implemented nowhere: it is the
* door a `first-party-sensor` provenance comes through, and naming it in the
* union now is what stops the next person from adding a second, differently
* shaped source field when they build it.
*/
export type DevicesSourceId = "none" | "sim" | "homeassistant";
export type AuthMode = "none" | "sso" | "jwt";
/**
* One place this box will answer about. Structurally `Region` in
* `server/src/regions.ts`, restated here for the same reason `WireSimRoute` is:
* this file is the contract and the server's own module is an implementation of
* it, and the browser must not have to import server code to read a body.
*/
export interface WireRegion {
/** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
id: string;
lat: number;
lng: number;
/** Kilometres. */
radiusKm: number;
}
/**
* What this deployment turned out to be, once the environment had its say.
*
@@ -81,12 +121,33 @@ export interface HealthBody {
flights: FlightsSourceId;
satellites: SatellitesSourceId;
markers: MarkersSourceId;
/**
* Newer than the four above it, and read defensively by
* `feedsFrom()` in `src/access.ts` for exactly that reason: a browser
* meeting a server one version behind this one sees `undefined` and
* concludes the box has no devices, which is the safe direction for a
* missing field to fall. Its job is to let a client know that asking is
* pointless before it opens a watch that will 404 forever.
*/
devices: DevicesSourceId;
};
auth: {
mode: AuthMode;
/** Where a browser sends someone to sign in. `null` unless mode is `sso`. */
entryUrl: string | null;
};
/**
* Every place this box will answer about, in the config's order, so the first
* entry is what a request with no query gets.
*
* Here rather than in a `HealthBodyWithRegions` alias next to the route.
* Weather and flights refuse a place this box does not serve, so a client
* that guesses `?city=` earns a 400 it cannot explain; publishing the
* allowlist turns that into one question asked once. It gives nothing away
* knowing what is served is not the same as widening it, and the ids are the
* names of cities the map already draws.
*/
regions: WireRegion[];
/** One human sentence per demotion. Empty on a fully-configured box. */
degraded: string[];
}
@@ -119,6 +180,23 @@ export interface WireAircraft {
/** Degrees clockwise from true north. */
heading: number;
callsign?: string;
/**
* The transponder's 24-bit ICAO address, lowercase hex, when the feed gave a
* real one.
*
* Carried explicitly even though `id` is usually the same string, because
* "usually" is the problem: `id` falls back to the callsign for a record with
* no hex, and both community feeds emit `~`-prefixed anonymous addresses for
* TIS-B and MLAT targets, which are *not* ICAO addresses. Somebody pastes
* this into a registry lookup, so a wrong one names another aircraft
* altogether and `Aircraft` in `engine/types.ts` has nowhere to put it,
* which is why the adapter keeps this record beside the position rather than
* inferring the address back out of the id.
*
* Absent, never invented. `aircraftDetail()` in `engine/flights.ts` is where
* it becomes a card.
*/
icao24?: string;
}
/**
@@ -153,8 +231,33 @@ export interface FlightsLiveBody {
observedAt: number;
aircraft: WireAircraft[];
ttlSeconds: number;
/** Attribution the consumer is expected to display, if the feed asks for it. */
/**
* Attribution the consumer is expected to display, if the feed asks for it.
*
* **Derived from the host that answered**, in `server/src/flights/licence.ts`,
* and never authored next to the request. It said `adsb.lol` unconditionally
* once, whatever `TERA_ADSB_ENDPOINT` pointed at, which is how a credit line
* and a source come to disagree.
*/
attribution?: string[];
/**
* May a shared cache or the consumer hand these bytes to a third party?
*
* The licence the positions arrived under, reduced to the one bit that
* changes behaviour. `false` keeps the route on the fail-closed
* `private, no-store` default from CONTRACT.md §5, so a feed this deployment
* may *use* but not *redistribute* stops at the browser that asked. Required
* rather than optional: a body with no answer to this question is a body
* somebody will assume `true` for.
*/
redistributable: boolean;
/**
* The licence id the source publishes under, e.g. `"ODbL-1.0"`, or
* `"first-party"` for an operator's own receiver. For display and for a
* human reading `/api/v1/flights` directly; the machine-readable half of the
* same fact `attribution` states in prose.
*/
licence?: string;
}
export type FlightsBody = FlightsPlanBody | FlightsLiveBody;
@@ -368,3 +471,86 @@ export interface PresenceBody {
* an index and is never publicly cached.
*/
export type OfficeVisibility = "public" | "unlisted" | "private";
// ---- Devices --------------------------------------------------------------
/**
* What the hardware in one office is doing, right now.
*
* The runtime half of the split `src/devices/types.ts` opens with, and the
* reason this body exists at all: a `DeviceDeclaration` is authored into the
* office pack and is therefore public by construction, while a `DeviceState`
* never appears in a file anybody can download. It arrives here, from a route
* that can refuse it, and it is the same line `PresenceBody` draws between a
* floorplan and the people standing on it.
*
* **Never publicly cached, in any configuration.** Two reasons and they are
* different: the body took a credential to obtain, so a shared cache holding it
* would hand one viewer's copy to the next; and the state is mutable by a
* command, so a cached copy is a stale claim about a room somebody is standing
* in. `routes/devices.ts` therefore never calls `publicCache`, and says so out
* loud rather than merely omitting the call.
*
* An office with no authored devices is `devices: []` and a 200 not a 404.
* "This office does not exist" and "nobody has declared any hardware in it" are
* different facts with different fixes, exactly as `presence/store.ts` argues
* for a missing roster.
*/
export interface DevicesBody {
officeId: string;
devices: DeviceState[];
/** Epoch milliseconds at which this snapshot was taken. */
observedAt: number;
source: DevicesSourceId;
/**
* Did anybody observe any of this?
*
* `true` for everything this build ships, because the only implemented source
* is a state machine. It is the body-level statement of the same fact
* `DeviceState.synthetic` makes per device, and it is carried separately so
* that an empty array still says where it came from an empty `devices` with
* `synthetic: false` is a box with a real bridge and nothing plugged into it,
* which is a different picture from a box that is making it all up.
*/
synthetic: boolean;
ttlSeconds: number;
attribution?: string[];
}
/**
* A command, going up.
*
* The only body in this file that travels from the browser to the server, and
* deliberately the narrowest one: exactly one command, for exactly one device,
* with no batching. A batch would need partial-failure semantics, and the first
* write surface in this product is not the place to invent those.
*
* The command is validated **server-side against the resolved office plan**
* that the device id names an authored declaration, that the declaration's
* asset really is hardware of the kind it claims, and that the op is one that
* declaration declared. `normalizeDeviceCommand()` is the shared validator and
* both ends run it, which is not redundancy: the browser runs it so a slider
* cannot send nonsense, and the server runs it because a browser is not a
* boundary. The same move `officeHasMediaBinding()` makes for screens.
*/
export interface DeviceCommandBody {
command: DeviceCommand;
}
/**
* What a command did, as the state that resulted from it.
*
* The new state rather than an `ok: true`, so the panel has something to draw
* without a follow-up GET and so the answer to "did that work" is the reading
* itself rather than an acknowledgement that a request was received. A command
* that was accepted and clamped (a gain of 40 dB on a device whose range stops
* at 36) reports the clamped value here, and the slider snaps to what the
* hardware actually did.
*/
export interface DeviceCommandResultBody {
officeId: string;
/** The device as it stands after the command was applied. */
device: DeviceState;
/** Epoch milliseconds. */
observedAt: number;
}
+55 -16
View File
@@ -14,17 +14,22 @@ import {
OFFICE_NAV_SCENARIOS,
OFFICE_JOBS_INACTION,
OFFICE_JOBS_SCENARIOS,
STUDIO_OPS_INACTION,
STUDIO_OPS_SCENARIOS,
CaliforniaFlightEnvironment,
CrowNavEnvironment,
Drive101Environment,
OfficeNavEnvironment,
OfficeJobsEnvironment,
StudioOpsEnvironment,
arenaChecksum,
californiaFlightScriptedBaseline,
crowNavScriptedBaseline,
driveScriptedBaseline,
officeNavScriptedBaseline,
officeJobsScriptedBaseline,
rollout,
studioOpsScriptedBaseline,
type ArenaEnvironment,
type ArenaManifest,
type ArenaScenarioRegistry,
@@ -41,6 +46,17 @@ interface EnvironmentCase {
inaction: unknown;
scripted(observation: unknown): unknown;
successReason: string;
/**
* Where the documented inaction action ends up.
*
* `"max-steps"` for the five environments whose only clock is the step cap.
* `studio-ops-v1` has a second one a departure the studio's car has to be
* ready for and standing still misses it, which is an *outcome* and so sets
* `terminated`. The field exists so the contract test can keep asserting the
* thing that actually matters (`truncated` is set by the step cap and by
* nothing else) rather than being weakened to accommodate the sixth case.
*/
inactionReason: string;
}
const CASES: EnvironmentCase[] = [
@@ -51,6 +67,7 @@ const CASES: EnvironmentCase[] = [
inaction: DRIVE_INACTION,
scripted: () => driveScriptedBaseline(),
successReason: "goal",
inactionReason: "max-steps",
},
{
name: "office",
@@ -61,6 +78,7 @@ const CASES: EnvironmentCase[] = [
observation as Parameters<typeof officeNavScriptedBaseline>[0],
),
successReason: "goal",
inactionReason: "max-steps",
},
{
name: "office-jobs",
@@ -71,6 +89,7 @@ const CASES: EnvironmentCase[] = [
observation as Parameters<typeof officeJobsScriptedBaseline>[0],
),
successReason: "job-complete",
inactionReason: "max-steps",
},
{
name: "crow",
@@ -81,6 +100,7 @@ const CASES: EnvironmentCase[] = [
observation as Parameters<typeof crowNavScriptedBaseline>[0],
),
successReason: "goal",
inactionReason: "max-steps",
},
{
name: "flight",
@@ -91,33 +111,48 @@ const CASES: EnvironmentCase[] = [
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
),
successReason: "goal",
inactionReason: "max-steps",
},
{
name: "studio-ops",
create: () => new StudioOpsEnvironment() as AnyEnvironment,
registry: STUDIO_OPS_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: STUDIO_OPS_INACTION,
scripted: (observation) => studioOpsScriptedBaseline(
observation as Parameters<typeof studioOpsScriptedBaseline>[0],
),
successReason: "job-complete",
inactionReason: "departure-missed",
},
];
/**
* One episode, through the package's own `rollout`.
*
* This function used to be a hand-written loop, and so did the two baseline
* proofs below and every example in ARENA.md four copies of the same nine
* lines, agreeing by luck. `rollout` is now the one copy, and running the
* contract suite through it means a regression in the shared loop fails here
* rather than in a consumer's trainer.
*/
function run(
entry: EnvironmentCase,
scenarioId: string,
seed: number,
policy: (observation: unknown) => unknown,
): { total: number; final: ArenaStepResult<unknown, NumericRewards> } {
const environment = entry.create();
let observation = environment.reset(seed, scenarioId).observation;
let total = 0;
let final: ArenaStepResult<unknown, NumericRewards> | undefined;
for (let step = 0; step < environment.manifest.maxSteps; step += 1) {
final = environment.step(policy(observation));
observation = final.observation;
total += final.reward;
if (final.terminated || final.truncated) break;
}
if (!final) throw new Error("environment manifest must permit at least one step");
return { total, final };
const result = rollout(entry.create(), (observation) => policy(observation), {
seed,
scenario: scenarioId,
});
return { total: result.total, final: result.final };
}
describe("arena contract and manifests", () => {
it("exports five versioned renderer-independent manifests with disjoint public splits", () => {
it("exports six versioned renderer-independent manifests with disjoint public splits", () => {
assert.deepEqual(ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id), [
"drive-101-v1", "office-nav-v1", "office-jobs-v1", "crow-nav-v1", "california-flight-v1",
"studio-ops-v1",
]);
for (const manifest of ARENA_MANIFESTS) {
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
@@ -170,9 +205,13 @@ describe("arena contract and manifests", () => {
for (const entry of CASES) {
const id = entry.registry.ids("train")[0]!;
const idle = run(entry, id, 5, () => entry.inaction).final;
assert.equal(idle.terminated, false, entry.name);
assert.equal(idle.truncated, true, entry.name);
assert.equal(idle.info.terminalReason, "max-steps", entry.name);
// `truncated` is set by the step cap and by nothing else, in every
// environment. An inaction outcome that is not `max-steps` is a genuine
// terminal and must therefore set `terminated` instead — which is the
// whole distinction this test exists to pin.
assert.equal(idle.info.terminalReason, entry.inactionReason, entry.name);
assert.equal(idle.truncated, entry.inactionReason === "max-steps", entry.name);
assert.equal(idle.terminated, entry.inactionReason !== "max-steps", entry.name);
const scripted = run(entry, id, 5, entry.scripted).final;
assert.equal(scripted.terminated, true, entry.name);
+5
View File
@@ -0,0 +1,5 @@
# Test home for the `arena` workstream.
#
# Each build workstream owns its own subdirectory so eight builders can add
# suites in parallel without ever colliding on a path. `npm test` picks these
# up through the widened `src/test/**/*.test.ts` glob in package.json.
+157
View File
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ARENA_CHECKSUM_DECIMALS,
StudioOpsEnvironment,
arenaChecksum,
canonicalJson,
quantizeForChecksum,
quantizeToPlaces,
studioOpsScriptedBaseline,
} from "../../index.ts";
describe("canonical json refuses what it cannot describe", () => {
it("throws on Map, Set and Date rather than silently emitting an empty object", () => {
// The three named in the build spec, and the reason this test exists: each
// of them reached the `typeof value === "object"` branch, had no own
// enumerable keys, and hashed as `{}`.
assert.throws(() => canonicalJson(new Map()), /do not accept Map/);
assert.throws(() => canonicalJson(new Set()), /do not accept Set/);
assert.throws(() => canonicalJson(new Date()), /do not accept Date/);
});
it("would have hashed a populated Map identically to an empty object", () => {
// The regression itself, stated as the thing that must never come back: if
// this ever stops throwing, assert that the two do not agree.
const populated = new Map([["a", 1], ["b", 2]]);
assert.throws(() => arenaChecksum({ operations: populated }));
assert.throws(() => arenaChecksum({ operations: new Map() }));
});
it("throws on every other non-plain object, and names it", () => {
class Operations {
readonly id = "sf";
}
assert.throws(() => canonicalJson(new Operations()), /do not accept Operations/);
assert.throws(() => canonicalJson(new Float64Array(3)), /do not accept Float64Array/);
assert.throws(() => canonicalJson(new WeakMap()), /do not accept WeakMap/);
assert.throws(() => canonicalJson(/x/), /do not accept RegExp/);
assert.throws(() => canonicalJson(Object.create({ inherited: true })), /do not accept/);
// Nested, because the dangerous case is a field somebody added to a
// snapshot rather than a value somebody handed to the hash directly.
assert.throws(() => canonicalJson({ simulation: { stations: new Map() } }), /do not accept Map/);
assert.throws(() => canonicalJson([1, { at: new Date(0) }]), /do not accept Date/);
});
it("throws on the primitives JSON has no room for", () => {
assert.throws(() => canonicalJson(() => 1), /do not accept function/);
assert.throws(() => canonicalJson(10n), /do not accept bigint/);
assert.throws(() => canonicalJson(Symbol("x")), /do not accept symbol/);
assert.throws(() => canonicalJson(Number.NaN), /finite/);
assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite/);
});
it("still accepts everything a snapshot legitimately contains", () => {
assert.equal(
canonicalJson({ b: 1, a: [true, null, "x"], c: Object.create(null) }),
'{"a":[true,null,"x"],"b":1,"c":{}}',
);
// Key order is canonical and `undefined` is omitted rather than encoded, so
// two objects that differ only in those ways hash the same.
assert.equal(arenaChecksum({ a: 1, b: 2 }), arenaChecksum({ b: 2, a: 1, c: undefined }));
});
it("refuses cycles instead of recursing forever", () => {
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
assert.throws(() => canonicalJson(cyclic), /cycles/);
});
});
describe("checksum quantisation survives a last-place float disagreement", () => {
it("rounds non-integers to the documented decimals and leaves integers exact", () => {
assert.equal(ARENA_CHECKSUM_DECIMALS, 9);
assert.equal(quantizeForChecksum(1 / 3), 0.333333333);
// Collapsed to a positive zero, not a negative one: `Object.is` is what
// tells the two apart and `canonicalJson` must never emit "-0".
assert.ok(Object.is(quantizeForChecksum(-0.0000000004), 0));
assert.equal(quantizeToPlaces(1.2345678, 3), 1.235);
// Integers pass through untouched, including ones that scaling by 1e9 would
// push out of the safe-integer range and *lose* precision on.
assert.equal(quantizeForChecksum(9_007_199_254_740_991), 9_007_199_254_740_991);
assert.equal(quantizeForChecksum(0), 0);
// And so does a magnitude whose own spacing is coarser than the quantum:
// rounding it cannot be computed, so the input is returned rather than an
// approximation of it.
const huge = 1.5e300;
assert.equal(quantizeForChecksum(huge), huge);
assert.throws(() => quantizeForChecksum(Number.NaN), /finite/);
});
it("hashes two values a last place apart identically", () => {
// The failure this defends against: `Math.sin` is not required to be
// correctly rounded, so two conforming engines can return neighbouring
// doubles for the same argument. Before quantisation that was a verifier
// rejecting an honest rollout.
const value = 0.8414709848078965;
const neighbour = value + Number.EPSILON * value;
assert.notEqual(value, neighbour, "the two doubles must genuinely differ");
assert.equal(arenaChecksum({ sun: value }), arenaChecksum({ sun: neighbour }));
});
it("still separates values that differ by anything a reward can see", () => {
assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.000001 }));
assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.00000001 }));
assert.notEqual(arenaChecksum({ x: 0 }), arenaChecksum({ x: 1e-8 }));
});
it("collapses a negative zero so a vanishing quantity cannot change a hash", () => {
assert.equal(arenaChecksum({ x: -0 }), arenaChecksum({ x: 0 }));
assert.equal(arenaChecksum({ x: -1e-12 }), arenaChecksum({ x: 0 }));
});
});
describe("restore pins the simulator sources, not only the manifest", () => {
function checkpointed() {
const environment = new StudioOpsEnvironment();
let observation = environment.reset(23, "train-la-overcast-inspection").observation;
for (let index = 0; index < 6; index += 1) {
observation = environment.step(studioOpsScriptedBaseline(observation)).observation;
}
return { environment, snapshot: environment.snapshot() };
}
it("carries the environment's own source pins inside the checksummed core", () => {
const { snapshot } = checkpointed();
assert.match(snapshot.sourceHashes.environment, /^sha256:[0-9a-f]{64}$/);
assert.match(snapshot.sourceHashes.simulator, /^sha256:[0-9a-f]{64}$/);
const { checksum, ...core } = snapshot;
assert.equal(arenaChecksum(core), checksum);
});
it("rejects a snapshot whose sourceHashes differ from the environment's own", () => {
// A snapshot used to survive a change to the physics under it: `envHash`
// covers the manifest, and the manifest does not move when a walker's
// collision epsilon does. Re-signed with a valid checksum, so this can only
// be caught by comparing the pins themselves.
const { environment, snapshot } = checkpointed();
for (const field of ["environment", "simulator"] as const) {
const tampered = {
...snapshot,
sourceHashes: { ...snapshot.sourceHashes, [field]: `sha256:${"9".repeat(64)}` },
};
const { checksum: _drop, ...core } = tampered;
assert.throws(
() => environment.restore({ ...core, checksum: arenaChecksum(core) }),
/incompatible with this environment/,
field,
);
}
});
it("still accepts its own snapshot", () => {
const { environment, snapshot } = checkpointed();
const restored = environment.restore(snapshot);
assert.equal(restored.info.step, snapshot.step);
});
});
+181
View File
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { describe, it } from "node:test";
import {
ARENA_ENVIRONMENTS,
ARENA_MANIFESTS,
ARENA_SOURCE_HASHES,
ArenaScenarioRegistry,
arenaEnvironmentIds,
rollout,
type ArenaManifest,
} from "../../index.ts";
const ARENA_DIR = new URL("../../arena/", import.meta.url);
describe("ARENA_ENVIRONMENTS is the missing half of the catalogue", () => {
it("has a key for every manifest id, and no key that is not one", () => {
// Before this existed, `ARENA_MANIFESTS` described environments a harness
// had no supported way to instantiate: a caller handed "drive-101-v1" off a
// config file kept its own switch, which is a copy of this catalogue
// maintained outside the package and wrong the day a sixth env lands.
const manifestIds = ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id).sort();
assert.deepEqual(Object.keys(ARENA_ENVIRONMENTS).sort(), manifestIds);
assert.deepEqual([...arenaEnvironmentIds()].sort(), manifestIds);
assert.deepEqual(Object.keys(ARENA_SOURCE_HASHES).sort(), manifestIds);
});
it("returns an object satisfying the whole ArenaEnvironment shape, keyed to its own id", () => {
for (const [id, factory] of Object.entries(ARENA_ENVIRONMENTS)) {
const environment = factory();
assert.equal(environment.manifest.id, id);
for (const member of ["reset", "step", "snapshot", "restore", "trace", "replay"] as const) {
assert.equal(typeof environment[member], "function", `${id}.${member}`);
}
}
});
it("returns a fresh instance every call, because an episode is state", () => {
for (const [id, factory] of Object.entries(ARENA_ENVIRONMENTS)) {
const first = factory();
const second = factory();
assert.notEqual(first, second, id);
first.reset(3, { split: "train" });
// The second must still be un-reset: two rollouts in flight through the
// registry must not be stepping each other's episode.
assert.throws(() => second.step({}), /must be reset/, id);
}
});
it("drives every environment through the shared rollout by id alone", () => {
for (const id of arenaEnvironmentIds()) {
const environment = ARENA_ENVIRONMENTS[id]!();
const result = rollout(environment, () => ({}), { seed: 12, maxSteps: 24 });
assert.equal(result.steps, 24, id);
assert.equal(result.final.info.envId, id);
assert.ok(Number.isFinite(result.total), id);
// Cut short rather than ended, and both flags say so.
assert.equal(result.final.terminated || result.final.truncated, false, id);
}
});
it("clamps a rollout budget to the manifest and refuses a budget of nothing", () => {
const environment = ARENA_ENVIRONMENTS["office-nav-v1"]!();
const capped = rollout(environment, () => ({}), { seed: 1, maxSteps: 10_000 });
assert.ok(capped.steps <= environment.manifest.maxSteps);
assert.throws(
() => rollout(ARENA_ENVIRONMENTS["office-nav-v1"]!(), () => ({}), { maxSteps: 0 }),
/at least one step/,
);
});
});
describe("scenario selection is bound to the id, not to definition order", () => {
interface Parameters extends Record<string, number> {
marker: number;
}
function registryOf(ids: readonly string[]): ArenaScenarioRegistry<Parameters> {
return new ArenaScenarioRegistry<Parameters>(
"selection-fixture",
ids.map((id, index) => ({
id,
split: id.startsWith("dev") ? ("dev" as const) : ("train" as const),
parameters: { marker: index },
})),
(parameters) => ({ ...parameters }),
);
}
const SEEDS = Array.from({ length: 400 }, (_, index) => index * 7919 + 3);
it("keeps every seed on the scenario it had when one is inserted in the middle", () => {
// The trap this replaces: `candidates[seed % candidates.length]` binds a
// seed to an *array position*, so inserting a scenario silently remaps
// every seed past it. Nothing fails; the numbers in a results table just
// quietly stop meaning what they meant.
const before = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
const after = registryOf(["train-a", "train-inserted", "train-b", "train-c", "dev-a"]);
let moved = 0;
for (const seed of SEEDS) {
const was = before.resolve(seed, { split: "train" }).id;
const now = after.resolve(seed, { split: "train" }).id;
if (now === "train-inserted") continue;
assert.equal(now, was, `seed ${seed}`);
moved += 1;
}
// And the new scenario genuinely wins some seeds, or the assertion above is
// vacuous rather than reassuring.
assert.ok(moved < SEEDS.length, "the inserted scenario must win some seeds");
assert.ok(moved > SEEDS.length * 0.5, "it must not win most of them either");
});
it("is unaffected by reordering the literal at all", () => {
const declared = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
const shuffled = registryOf(["train-c", "train-a", "dev-a", "train-b"]);
for (const seed of SEEDS) {
assert.equal(
shuffled.resolve(seed, { split: "train" }).id,
declared.resolve(seed, { split: "train" }).id,
`seed ${seed}`,
);
}
});
it("spreads seeds across the split rather than parking them on one scenario", () => {
const registry = registryOf(["train-a", "train-b", "train-c", "dev-a"]);
const counts = new Map<string, number>();
for (const seed of SEEDS) {
const id = registry.resolve(seed, { split: "train" }).id;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
assert.equal(counts.size, 3);
for (const [id, count] of counts) assert.ok(count > SEEDS.length / 6, `${id}=${count}`);
});
it("bumped every shipped manifest's version, because selection changed under them", () => {
// The other half of the fix. A selection change that nothing recorded is
// the same silent remap; `version` is what a snapshot, a trace and a
// results table are pinned to, so it moves when selection does.
for (const manifest of ARENA_MANIFESTS) {
const expected = manifest.id === "studio-ops-v1" ? 1 : 2;
assert.equal(manifest.version, expected, manifest.id);
}
});
});
describe("the arena boundary holds", () => {
const SOURCES = readdirSync(ARENA_DIR)
.filter((name) => name.endsWith(".ts"))
.map((name) => ({ name, text: readFileSync(new URL(name, ARENA_DIR), "utf8") }));
it("has sources to check", () => {
assert.ok(SOURCES.length >= 12);
});
it("imports no three.js, no scene adapter, no DOM and no network", () => {
// The executable form of ARENA.md's first paragraph and of the build spec's
// grep. An arena that reached the renderer would be an arena that cannot be
// run headless, and a `studio-ops-v1` that imported `engine/flights.ts` for
// its aircraft would have done exactly that on one line.
const forbidden =
/from ["'](three|three\/[^"']*|\.\.\/engine\/(scene|stage|scenekit|flights|atmosphere|world)\.ts|\.\.\/actors\/sceneActor\.ts|\.\.\/interiors\/officeScene\.ts)["']/;
for (const source of SOURCES) {
assert.equal(forbidden.test(source.text), false, `${source.name} imports the renderer`);
assert.equal(/\bdocument\.|\bwindow\.|\bfetch\(/.test(source.text), false, source.name);
}
});
it("declares a simulator pin for every file studio-ops actually wraps", () => {
// A pin list shorter than the import list is a snapshot surviving a change
// it should not have survived, so the two are compared rather than trusted.
const studioOps = SOURCES.find((source) => source.name === "studioOps.ts")!;
const script = readFileSync(new URL("../../../scripts/check-arena-source-hashes.mjs", import.meta.url), "utf8");
const pinned = script.slice(script.indexOf('"studio-ops-v1"'));
for (const match of studioOps.text.matchAll(/from "\.\.\/([^"]+)"/g)) {
const relative = `src/${match[1]}`;
assert.ok(pinned.includes(`"${relative}"`), `${relative} is imported but not pinned`);
}
});
});
+267
View File
@@ -0,0 +1,267 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ARENA_ENVIRONMENTS,
ARENA_MANIFESTS,
STUDIO_OPS_INACTION,
STUDIO_OPS_SCENARIOS,
StudioOpsEnvironment,
actionWidth,
arenaEnvironmentIds,
arenaFieldWidth,
arenaManifest,
flattenAction,
flattenObservation,
observationWidth,
structureAction,
studioOpsScriptedBaseline,
type ArenaFieldSpec,
type StudioOpsAction,
type ArenaManifest,
type StudioOpsObservation,
} from "../../index.ts";
describe("every manifest declares a space that matches its field list", () => {
it("names the same fields, in the same order, on both lists", () => {
// The two lists are redundant on purpose — names are the contract that has
// been published since v1, spaces are the machine-readable one — and this
// is what stops the redundancy rotting into a disagreement.
for (const manifest of ARENA_MANIFESTS as readonly ArenaManifest[]) {
assert.deepEqual(
manifest.observationSpace.map((spec: ArenaFieldSpec) => spec.name),
[...manifest.observationFields],
manifest.id,
);
assert.deepEqual(
manifest.actionSpace.map((spec: ArenaFieldSpec) => spec.name),
[...manifest.actionFields],
manifest.id,
);
}
});
it("declares a usable encoding for every field", () => {
for (const manifest of ARENA_MANIFESTS as readonly ArenaManifest[]) {
for (const spec of [...manifest.observationSpace, ...manifest.actionSpace]) {
// `arenaFieldWidth` is where a malformed spec is caught, so calling it
// over the whole catalogue is the validation pass.
assert.ok(arenaFieldWidth(spec) >= 1, `${manifest.id}.${spec.name}`);
}
assert.equal(observationWidth(manifest.id) > 0, true, manifest.id);
assert.equal(actionWidth(manifest.id) > 0, true, manifest.id);
}
});
it("refuses a malformed spec rather than encoding it as something", () => {
assert.throws(() => arenaFieldWidth({ name: "x", kind: "float" }), /finite low < high/);
assert.throws(
() => arenaFieldWidth({ name: "x", kind: "float", low: 1, high: 1 }),
/finite low < high/,
);
assert.throws(
() => arenaFieldWidth({ name: "x", kind: "float", low: 0, high: Number.POSITIVE_INFINITY }),
/finite low < high/,
);
assert.throws(() => arenaFieldWidth({ name: "x", kind: "enum", values: [] }), /non-empty/);
assert.throws(
() => arenaFieldWidth({ name: "x", kind: "enum", values: ["a", "a"] }),
/duplicate/,
);
});
it("throws on an environment id nobody published", () => {
assert.throws(() => arenaManifest("studio-ops-v2"), /unknown arena environment/);
assert.throws(() => flattenObservation("nope", {}), /unknown arena environment/);
});
});
describe("flattenObservation produces a fixed-width vector of finite numbers", () => {
it("matches the declared width at reset for every environment", () => {
for (const id of arenaEnvironmentIds()) {
const environment = ARENA_ENVIRONMENTS[id]!();
const observation = environment.reset(9, { split: "train" }).observation;
const vector = flattenObservation(id, observation);
assert.equal(vector.length, observationWidth(id), id);
assert.ok(vector.every((value) => Number.isFinite(value)), id);
}
});
it("stays exactly that width at every step of a full 1200-step studio-ops episode", () => {
// A full episode rather than a short one, because the width has to survive
// every branch: an empty `nextStationId`, a null payload, a `phase` string
// the enum has to already know about, and the truncation at the cap.
const environment = new StudioOpsEnvironment();
let observation = environment.reset(11, "train-sf-clear-morning-desk-check")
.observation as StudioOpsObservation;
const widths = new Set([flattenObservation("studio-ops-v1", observation).length]);
let steps = 0;
let terminalReason: string | null = null;
for (let index = 0; index < 1200; index += 1) {
// A policy that never works the job but does get the car ready, so the
// departure is met and the episode runs the whole cap.
const result = environment.step({
...STUDIO_OPS_INACTION,
micMute: true,
vehicleCharge: !observation.vehicleReadyByDeparture,
vehiclePrecondition: Math.abs(observation.vehicleCabinC - 21) > 0.5,
});
observation = result.observation as StudioOpsObservation;
widths.add(flattenObservation("studio-ops-v1", observation).length);
steps += 1;
terminalReason = result.info.terminalReason;
if (result.terminated || result.truncated) break;
}
assert.equal(steps, 1200);
assert.equal(terminalReason, "max-steps");
assert.deepEqual([...widths], [observationWidth("studio-ops-v1")]);
});
it("keeps the width when the observation is empty, partial or malformed", () => {
const width = observationWidth("studio-ops-v1");
assert.equal(flattenObservation("studio-ops-v1", {}).length, width);
assert.equal(flattenObservation("studio-ops-v1", null).length, width);
assert.equal(flattenObservation("studio-ops-v1", { x: Number.NaN }).length, width);
assert.ok(
flattenObservation("studio-ops-v1", { x: Number.NaN }).every((v) => Number.isFinite(v)),
);
});
it("encodes each kind the way the manifest says it does", () => {
const space = arenaManifest("studio-ops-v1").observationSpace;
const at = (name: string): number => {
let cursor = 0;
for (const spec of space) {
if (spec.name === name) return cursor;
cursor += arenaFieldWidth(spec);
}
throw new Error(`no field ${name}`);
};
// float: the clamped raw value, not a normalization of it.
const clamped = flattenObservation("studio-ops-v1", { windKph: 5000 });
assert.equal(clamped[at("windKph")], 120);
assert.equal(flattenObservation("studio-ops-v1", { windKph: 31.5 })[at("windKph")], 31.5);
// A field that is absent or unusable falls back to the declared low, which
// cannot be mistaken for a measurement.
assert.equal(flattenObservation("studio-ops-v1", {})[at("micLevelDb")], -60);
// bool: 1 only for a genuine `true`.
assert.equal(flattenObservation("studio-ops-v1", { deskOccupied: true })[at("deskOccupied")], 1);
assert.equal(flattenObservation("studio-ops-v1", { deskOccupied: 1 })[at("deskOccupied")], 0);
// enum: one-hot, and all-zeros for a value outside the vocabulary — which
// is what a null payload has to encode as. "Carrying nothing" is not a
// thing being carried, and must not collide with "carrying a parcel".
const cursor = at("weatherCondition");
const rain = flattenObservation("studio-ops-v1", { weatherCondition: "rain" });
assert.equal(rain.slice(cursor, cursor + 8).reduce((sum, value) => sum + value, 0), 1);
assert.equal(rain[cursor + 5], 1);
const unknown = flattenObservation("studio-ops-v1", { weatherCondition: "hail" });
assert.deepEqual(unknown.slice(cursor, cursor + 8), [0, 0, 0, 0, 0, 0, 0, 0]);
assert.equal(flattenObservation("studio-ops-v1", { payload: null })[at("payload")], 0);
assert.equal(flattenObservation("studio-ops-v1", { payload: "parcel" })[at("payload")], 1);
// id: one stable slot in [0, 1), changing exactly when the identity does.
const first = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-monitor" });
const same = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-monitor" });
const other = flattenObservation("studio-ops-v1", { nextStationId: "sf-studio-display" });
assert.equal(first[at("nextStationId")], same[at("nextStationId")]);
assert.notEqual(first[at("nextStationId")], other[at("nextStationId")]);
assert.ok(first[at("nextStationId")]! >= 0 && first[at("nextStationId")]! < 1);
assert.equal(flattenObservation("studio-ops-v1", { nextStationId: "" })[at("nextStationId")], 0);
});
it("never observes an enum value its own vocabulary does not carry", () => {
// The one way a one-hot silently loses information: a `phase` string the
// controller assigns and the manifest has never heard of encodes as all
// zeros and reads to a policy as an unremarkable state.
const space = arenaManifest("studio-ops-v1").observationSpace;
const enums = space.filter((spec) => spec.kind === "enum");
const seen = new Map<string, Set<string>>(enums.map((spec) => [spec.name, new Set<string>()]));
const record = (observation: StudioOpsObservation): void => {
for (const spec of enums) {
const value = (observation as unknown as Record<string, unknown>)[spec.name];
if (typeof value === "string") seen.get(spec.name)!.add(value);
}
};
// Three policies, because one policy visits one corridor of the phase
// machine: the scripted baseline works its stations, the drifter never
// interacts and sits in `awaiting-interaction`, and the spammer walks into
// walls and reaches the recovery phases.
const POLICIES: ((observation: StudioOpsObservation, step: number) => StudioOpsAction)[] = [
(observation) => studioOpsScriptedBaseline(observation),
(observation, step) => ({
...STUDIO_OPS_INACTION,
micMute: true,
x: Math.sin(step / 40) * 0.4,
z: Math.cos(step / 37) * 0.4,
speakerPlay: observation.deskOccupied,
}),
() => ({ ...STUDIO_OPS_INACTION, z: 1, interact: true }),
];
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
for (const seed of [4, 77]) {
for (const policy of POLICIES) {
const environment = new StudioOpsEnvironment();
let observation = environment.reset(seed, definition.id).observation as
StudioOpsObservation;
record(observation);
for (let index = 0; index < 900; index += 1) {
const result = environment.step(policy(observation, index));
observation = result.observation as StudioOpsObservation;
record(observation);
if (result.terminated || result.truncated) break;
}
}
}
}
for (const spec of enums) {
for (const value of seen.get(spec.name)!) {
assert.ok(spec.values!.includes(value), `${spec.name} observed unknown "${value}"`);
}
}
// And the sweep must actually have seen something, or this proves nothing.
assert.ok(seen.get("phase")!.size >= 6, [...seen.get("phase")!].join(","));
assert.ok(seen.get("mode")!.size >= 3, [...seen.get("mode")!].join(","));
assert.ok(seen.get("weatherCondition")!.size >= 3, [...seen.get("weatherCondition")!].join(","));
assert.ok(seen.get("levelId")!.size === 2, [...seen.get("levelId")!].join(","));
});
});
describe("structureAction inverts the action encoding", () => {
it("round-trips every environment's own baseline action", () => {
for (const id of arenaEnvironmentIds()) {
const environment = ARENA_ENVIRONMENTS[id]!();
environment.reset(2, { split: "train" });
const zeroed = structureAction(id, new Array(actionWidth(id)).fill(0));
const vector = flattenAction(id, zeroed);
assert.equal(vector.length, actionWidth(id), id);
assert.deepEqual(structureAction(id, vector), zeroed, id);
// And the result is a legal action: stepping with it must not throw.
assert.ok(Number.isFinite(environment.step(zeroed).reward), id);
}
});
it("clamps a raw network output instead of refusing it", () => {
const action = structureAction("studio-ops-v1", [
9, -9, 1, 999, 0.4, 5, -1, 0.5, 0.49,
]);
assert.equal(action.x, 1);
assert.equal(action.z, -1);
assert.equal(action.interact, true);
assert.equal(action.micGain, 36);
assert.equal(action.micMute, false);
assert.equal(action.speakerVolume, 1);
assert.equal(action.speakerPlay, false);
assert.equal(action.vehiclePrecondition, true);
assert.equal(action.vehicleCharge, false);
});
it("refuses a vector of the wrong length", () => {
assert.throws(() => structureAction("studio-ops-v1", [0, 0]), /exactly 9 values/);
assert.throws(
() => structureAction("studio-ops-v1", new Array(10).fill(0)),
/exactly 9 values/,
);
});
});
+657
View File
@@ -0,0 +1,657 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
STUDIO_OPS_INACTION,
STUDIO_OPS_MANIFEST,
STUDIO_OPS_SCENARIOS,
StudioOpsEnvironment,
arenaChecksum,
quantizeObservable,
rollout,
studioOpsEnergyPenalty,
studioOpsNoisePenalty,
studioOpsScriptedBaseline,
studioOverflights,
studioSkyAt,
studioVehicleReadiness,
studioWeatherAt,
type StudioOpsAction,
type StudioOpsObservation,
type StudioOpsReward,
} from "../../index.ts";
import { Plan } from "../../interiors/plan.ts";
import { LUMBRIDGE_HQ } from "../../offices/lumbridge-hq.ts";
import { MATEO_COURT } from "../../offices/mateo-court.ts";
const SEEDS = [1, 0xdecafbad];
type Observation = StudioOpsObservation;
function scripted(observation: unknown): StudioOpsAction {
return studioOpsScriptedBaseline(observation as Observation);
}
/** Runs a targeted policy and reports where the episode ended. */
function reach(
scenarioId: string,
seed: number,
policy: (observation: Observation, step: number) => StudioOpsAction,
): { reason: string | null; steps: number; total: number } {
const result = rollout(
new StudioOpsEnvironment(),
(observation, step) => policy(observation as Observation, step),
{ seed, scenario: scenarioId },
);
return { reason: result.final.info.terminalReason, steps: result.steps, total: result.total };
}
describe("studio-ops baselines", () => {
it("keeps inaction below zero and never lets it reach the goal", () => {
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
for (const seed of SEEDS) {
const idle = rollout(new StudioOpsEnvironment(), () => STUDIO_OPS_INACTION, {
seed,
scenario: definition.id,
});
assert.ok(idle.total < 0, `${definition.id}/${seed} inaction=${idle.total}`);
assert.notEqual(idle.final.info.terminalReason, "job-complete");
}
}
});
it("proves a positive scripted completion that beats inaction on every scenario", () => {
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
for (const seed of SEEDS) {
const idle = rollout(new StudioOpsEnvironment(), () => STUDIO_OPS_INACTION, {
seed,
scenario: definition.id,
});
const run = rollout(new StudioOpsEnvironment(), scripted, {
seed,
scenario: definition.id,
});
const label = `${definition.id}/${seed}`;
assert.equal(run.final.info.terminalReason, "job-complete", label);
assert.equal(run.final.terminated, true, label);
assert.equal(run.final.truncated, false, label);
assert.ok(run.total > 0, `${label} scripted=${run.total}`);
assert.ok(run.total > idle.total, `${label} ${run.total} <= ${idle.total}`);
}
}
});
it("sums thirteen finite named components and authors no total", () => {
const run = rollout(new StudioOpsEnvironment(), scripted, {
seed: 5,
scenario: "train-sf-clear-morning-desk-check",
});
const components = run.final.rewardComponents as StudioOpsReward;
assert.deepEqual(
Object.keys(components).sort(),
Object.keys(STUDIO_OPS_MANIFEST.rewardComponents).sort(),
);
const sum = Object.values(components).reduce((total, value) => total + value, 0);
assert.ok(Math.abs(sum - run.final.reward) < 1e-12);
assert.ok(Object.values(components).every((value) => Number.isFinite(value)));
});
});
describe("studio-ops terminals are each individually reachable", () => {
it("completes the job", () => {
assert.equal(reach("train-sf-clear-morning-desk-check", 1, scripted).reason, "job-complete");
});
it("stalls against resolved office collision", () => {
// Straight into a wall, forever. The controller's own recovery gives up.
const result = reach("train-la-overcast-inspection", 2, () => ({
...STUDIO_OPS_INACTION,
z: 1,
}));
assert.equal(result.reason, "collision-stall");
});
it("hits the invalid-interaction limit", () => {
const result = reach("train-la-overcast-inspection", 2, () => ({
...STUDIO_OPS_INACTION,
interact: true,
}));
assert.equal(result.reason, "wrong-interaction-limit");
// Exactly at the limit rather than somewhere after it.
assert.equal(result.steps, 8);
});
it("exhausts the studio's energy reserve", () => {
// Everything on and the car plugged into a post that draws from the same
// reserve. This is the terminal the `energyReservePct` observation exists
// to make visible; without it the failure would be unattributable.
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
const result = reach(definition.id, 3, () => ({
...STUDIO_OPS_INACTION,
speakerVolume: 1,
speakerPlay: true,
vehiclePrecondition: true,
vehicleCharge: true,
}));
assert.equal(result.reason, "battery-depleted", definition.id);
}
});
it("misses the departure", () => {
const result = reach("dev-sf-windy-evening-delivery", 1, () => STUDIO_OPS_INACTION);
assert.equal(result.reason, "departure-missed");
assert.equal(result.steps, 1000);
});
it("declares every one of them except the goal as a safety terminal", () => {
assert.deepEqual([...STUDIO_OPS_MANIFEST.safetyTerminals], [
"collision-stall",
"wrong-interaction-limit",
"battery-depleted",
"departure-missed",
]);
});
});
describe("studio-ops snapshot, trace and replay", () => {
it("continues bit-exactly from a mid-episode snapshot on every scenario", () => {
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
const environment = new StudioOpsEnvironment();
let observation = environment.reset(311, definition.id).observation;
for (let index = 0; index < 25; index += 1) {
observation = environment.step(scripted(observation)).observation;
}
const checkpoint = environment.snapshot();
const action = scripted(observation);
const expected = environment.step(action);
const restored = environment.restore(checkpoint);
assert.equal(restored.info.step, checkpoint.step, definition.id);
// Deep-equal on the whole transition: observation, reward, every
// component, both flags and the state checksum.
assert.deepEqual(environment.step(action), expected, definition.id);
}
});
it("replays a trace to the identical final checksum and cumulative reward", () => {
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
const environment = new StudioOpsEnvironment();
let observation = environment.reset(311, definition.id).observation;
for (let index = 0; index < 80; index += 1) {
const result = environment.step(scripted(observation));
observation = result.observation;
if (result.terminated || result.truncated) break;
}
const trace = environment.trace();
assert.equal(arenaChecksum({ ...trace, checksum: undefined }), trace.checksum);
const replay = new StudioOpsEnvironment().replay(trace);
assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, definition.id);
assert.equal(replay.cumulativeReward, trace.cumulativeReward, definition.id);
assert.equal(replay.steps, trace.steps.length, definition.id);
}
});
/** Twelve scripted steps, and the environment left mid-episode. */
function midEpisode(): StudioOpsEnvironment {
const environment = new StudioOpsEnvironment();
let observation = environment.reset(19, "train-la-hot-afternoon-studio").observation;
for (let index = 0; index < 12; index += 1) {
observation = environment.step(scripted(observation)).observation;
}
return environment;
}
it("throws on a tampered snapshot, and refuses to keep running afterwards", () => {
const environment = midEpisode();
const snapshot = environment.snapshot();
assert.throws(
() => environment.restore({ ...snapshot, cumulativeReward: snapshot.cumulativeReward + 1 }),
/checksum mismatch/,
);
// Re-signed, so only the source pins can catch it.
const reserved = structuredClone(snapshot.simulation);
reserved.energyReserveKWh = -1;
const { checksum: _snapshotChecksum, ...snapshotCore } = {
...snapshot,
simulation: reserved,
};
assert.throws(
() => environment.restore({ ...snapshotCore, checksum: arenaChecksum(snapshotCore) }),
/simulation snapshot is invalid/,
);
// And the environment is now un-reset rather than half-restored: the
// episode bookkeeping was written before the simulation payload was
// rejected, so continuing would produce a mixture of two episodes.
assert.throws(() => environment.trace(), /must be reset/);
assert.throws(() => environment.snapshot(), /must be reset/);
// A fresh reset brings it back.
assert.equal(environment.reset(19, "train-la-hot-afternoon-studio").info.step, 0);
});
it("throws on a tampered trace", () => {
const environment = midEpisode();
const trace = environment.trace();
assert.throws(
() => new StudioOpsEnvironment().replay({
...trace,
cumulativeReward: trace.cumulativeReward + 1,
}),
/checksum mismatch/,
);
// A re-signed reward claim: the envelope's own checksum verifies, so the
// only thing standing between this and an accepted rollout is that `replay`
// recomputes the reward and compares it. This is the cheat the whole
// envelope exists to refuse.
const inflated = trace.steps.map((frame, index) =>
index === 4 ? { ...frame, reward: frame.reward + 1 } : frame,
);
const { checksum: _rewardChecksum, ...rewardCore } = { ...trace, steps: inflated };
assert.throws(
() => new StudioOpsEnvironment().replay({
...rewardCore,
checksum: arenaChecksum(rewardCore),
}),
/diverged at step 5/,
);
// And a re-signed action swap, which is refused wherever it first shows —
// at the frame whose checksum no longer matches, or at the final state if
// the swapped action happened to change nothing until the end.
const swapped = trace.steps.map((frame, index) =>
index === 4 ? { ...frame, action: { ...frame.action, speakerPlay: true } } : frame,
);
const { checksum: _traceChecksum, ...traceCore } = { ...trace, steps: swapped };
assert.throws(
() => new StudioOpsEnvironment().replay({
...traceCore,
checksum: arenaChecksum(traceCore),
}),
/diverged at step|final state mismatch/,
);
});
it("reproduces an episode exactly from the same seed, twice", () => {
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
const first = rollout(new StudioOpsEnvironment(), scripted, {
seed: 88,
scenario: definition.id,
});
const second = rollout(new StudioOpsEnvironment(), scripted, {
seed: 88,
scenario: definition.id,
});
assert.deepEqual(first, second, definition.id);
}
});
});
describe("studio-ops couples its variables rather than stacking five tasks", () => {
it("charges more for the same energy under more cloud, strictly", () => {
// The reward's own implementation, over a fine grid: `advanceSimulation`
// calls exactly this function with exactly these arguments.
for (const loadKw of [0.42, 2.4, 13.9, 152]) {
let previous = 0;
for (let cloud = 0; cloud <= 1.0001; cloud += 0.02) {
const penalty = studioOpsEnergyPenalty(loadKw, cloud);
assert.ok(penalty < 0, `${loadKw}@${cloud}`);
if (cloud > 0) {
assert.ok(
Math.abs(penalty) > Math.abs(previous),
`magnitude did not rise at cloud=${cloud}, load=${loadKw}`,
);
}
previous = penalty;
}
}
// And it is monotone in the load as well, which is the other half of the
// claim that this is an energy price and not a weather penalty.
assert.ok(
Math.abs(studioOpsEnergyPenalty(150, 0.5)) > Math.abs(studioOpsEnergyPenalty(2, 0.5)),
);
});
it("charges more under an overcast sky end to end, even against a heavier load", () => {
// Stronger than "all else equal": the hot LA afternoon draws strictly more
// power than the overcast one (a 13 K climate gap against a 4 K one) and is
// still charged less, because the cloud term dominates the load difference.
const energyAt = (scenarioId: string): { energy: number; cloud: number } => {
const environment = new StudioOpsEnvironment();
environment.reset(1, scenarioId);
const result = environment.step(STUDIO_OPS_INACTION);
return {
energy: (result.rewardComponents as StudioOpsReward).energy,
cloud: (result.observation as Observation).cloudCover,
};
};
const clear = energyAt("train-la-hot-afternoon-studio");
const overcast = energyAt("train-la-overcast-inspection");
assert.ok(clear.cloud < 0.25, `clear cloud=${clear.cloud}`);
assert.ok(overcast.cloud > 0.7, `overcast cloud=${overcast.cloud}`);
assert.ok(
Math.abs(overcast.energy) > Math.abs(clear.energy),
`${overcast.energy} vs ${clear.energy}`,
);
});
it("charges more for playback the closer an aircraft is, and nothing when silent", () => {
const base = {
speakerPlaying: true,
speakerVolume: 0.6,
windKph: 5,
micLive: false,
deskOccupied: false,
};
let previous = 0;
// Walking the aircraft in from beyond audible range to directly overhead.
for (let slant = 5000; slant >= 0; slant -= 100) {
const penalty = studioOpsNoisePenalty({ ...base, nearestAircraftSlantM: slant });
if (slant < 5000) {
assert.ok(penalty < previous, `did not worsen at slant=${slant}`);
}
previous = penalty;
}
assert.ok(previous < 0);
// Beyond the overhead range it is flat, not negative: an aircraft that
// cannot be heard costs nothing.
assert.equal(studioOpsNoisePenalty({ ...base, nearestAircraftSlantM: 9000 }), 0);
// And a silent speaker cannot be ruined by anything at all.
assert.equal(
studioOpsNoisePenalty({ ...base, speakerPlaying: false, nearestAircraftSlantM: 0 }),
0,
);
assert.equal(
studioOpsNoisePenalty({ ...base, speakerVolume: 0, nearestAircraftSlantM: 0 }),
0,
);
});
it("adds wind and microphone bleed to the same penalty", () => {
const quiet = {
speakerPlaying: true,
speakerVolume: 0.5,
nearestAircraftSlantM: 40_000,
windKph: 5,
micLive: false,
deskOccupied: false,
};
assert.equal(studioOpsNoisePenalty(quiet), 0);
assert.ok(studioOpsNoisePenalty({ ...quiet, windKph: 80 }) < 0);
assert.ok(studioOpsNoisePenalty({ ...quiet, micLive: true, deskOccupied: true }) < 0);
// Bleed needs both: a live microphone in an empty room is not on the take.
assert.equal(studioOpsNoisePenalty({ ...quiet, micLive: true }), 0);
});
it("computes the noise component from the sky and wind it publishes", () => {
// The wiring, end to end: whatever the observation says the sky and the
// wind are doing is what the penalty was computed from. A regression that
// read a stale step's weather would break here and nowhere else.
const environment = new StudioOpsEnvironment();
let observation = environment.reset(31, "train-la-hot-afternoon-studio")
.observation as Observation;
let sawOverhead = false;
for (let index = 0; index < 700; index += 1) {
const result = environment.step({
...STUDIO_OPS_INACTION,
micMute: true,
speakerVolume: 0.5,
speakerPlay: true,
});
observation = result.observation as Observation;
const expected = studioOpsNoisePenalty({
speakerPlaying: observation.speakerPlaying,
speakerVolume: observation.speakerVolume,
nearestAircraftSlantM: observation.nearestAircraftSlantM,
windKph: observation.windKph,
micLive: observation.micPowered && !observation.micMuted,
deskOccupied: observation.deskOccupied,
});
assert.equal((result.rewardComponents as StudioOpsReward).noise, expected, `step ${index}`);
if (observation.aircraftOverheadCount > 0) sawOverhead = true;
if (result.terminated || result.truncated) break;
}
assert.ok(sawOverhead, "no aircraft came overhead; the assertion above proved nothing");
});
it("couples the microphone to the robot's job rather than to a schedule", () => {
// The non-negotiable property, observable: the desk in front of
// `sf-desk-mic` is occupied exactly while the robot is working the station
// that stands at it, and a mic left live through the rest of the episode is
// charged for it.
const environment = new StudioOpsEnvironment();
let observation = environment.reset(1, "train-sf-clear-morning-desk-check")
.observation as Observation;
let occupied = 0;
let wasted = 0;
let ready = 0;
for (let index = 0; index < 1200; index += 1) {
const result = environment.step({ ...STUDIO_OPS_INACTION, ...scripted(observation), micMute: false });
observation = result.observation as Observation;
const components = result.rewardComponents as StudioOpsReward;
if (observation.deskOccupied) occupied += 1;
if (components.audioWaste < 0) wasted += 1;
if (components.audioReady > 0) ready += 1;
if (result.terminated || result.truncated) break;
}
assert.ok(occupied > 0, "the robot never reached the desk its microphone serves");
assert.ok(ready >= occupied, "a live mic at an occupied desk must be paid for");
assert.ok(wasted > 0, "a live mic at an empty desk must be charged for");
});
it("stops charging the mic the moment it is muted", () => {
const environment = new StudioOpsEnvironment();
let observation = environment.reset(1, "train-la-overcast-inspection")
.observation as Observation;
const hot = environment.step({ ...STUDIO_OPS_INACTION, micMute: false });
assert.equal((hot.rewardComponents as StudioOpsReward).audioWaste < 0, true);
observation = hot.observation as Observation;
assert.equal(observation.deskOccupied, false);
const muted = environment.step({ ...STUDIO_OPS_INACTION, micMute: true });
assert.equal((muted.rewardComponents as StudioOpsReward).audioWaste, 0);
});
it("shapes the vehicle with a bounded potential that cannot be farmed", () => {
// Potential-based: plugging and unplugging round-trips to zero rather than
// paying twice, because the term is the *change* in a bounded readiness.
const required = 61.6;
assert.equal(studioVehicleReadiness(0, 21, required), 0.5);
assert.equal(studioVehicleReadiness(required, 21, required), 1);
assert.ok(studioVehicleReadiness(61, 21, required) < studioVehicleReadiness(61.5, 21, required));
assert.ok(studioVehicleReadiness(61, 30, required) < studioVehicleReadiness(61, 22, required));
// Bounded on both sides, so the shaping cannot diverge.
for (const soc of [-10, 0, 50, 500]) {
for (const cabin of [-40, 21, 90]) {
const value = studioVehicleReadiness(soc, cabin, required);
assert.ok(value >= 0 && value <= 1, `${soc}/${cabin}`);
}
}
});
});
describe("studio-ops weather and sky are scenario parameters, deterministically evolved", () => {
const parameters = STUDIO_OPS_SCENARIOS.resolve(7, "dev-sf-windy-evening-delivery").parameters;
it("is a pure function of the scenario and the elapsed time", () => {
for (const seconds of [0, 13.7, 60, 119.9]) {
assert.deepEqual(
studioWeatherAt(parameters, seconds),
studioWeatherAt(parameters, seconds),
);
}
assert.deepEqual(studioOverflights(parameters), studioOverflights(parameters));
});
it("actually moves inside one episode, in every field a policy can read", () => {
const samples = [0, 20, 40, 60, 80, 100, 119].map((s) => studioWeatherAt(parameters, s));
for (const field of ["cloudCover", "precipitation", "windKph", "windDirDeg"] as const) {
const values = new Set(samples.map((sample) => sample[field]));
assert.ok(values.size > 1, `${field} never changed`);
}
const sky = [0, 30, 60, 90, 119].map((s) => studioSkyAt(studioOverflights(parameters), s));
assert.ok(new Set(sky.map((entry) => entry.nearestSlantM)).size > 1);
});
it("keeps every reading inside the range its own space declares", () => {
for (let seconds = 0; seconds <= 120; seconds += 0.5) {
const weather = studioWeatherAt(parameters, seconds);
assert.ok(weather.cloudCover >= 0 && weather.cloudCover <= 1);
assert.ok(weather.precipitation >= 0 && weather.precipitation <= 1);
assert.ok(weather.windKph >= 0);
assert.ok(weather.windDirDeg >= 0 && weather.windDirDeg < 360);
assert.ok(weather.visibilityKm >= 0.2);
}
});
it("never claims a profile was observed when it was invented", () => {
// Every shipped scenario is a fixture, and the flag says so. The field
// exists for an operator who freezes a real observation into one.
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
const resolved = STUDIO_OPS_SCENARIOS.resolve(3, definition.id);
assert.equal(resolved.parameters.weatherReported, false, definition.id);
const environment = new StudioOpsEnvironment();
const observation = environment.reset(3, definition.id).observation as Observation;
assert.equal(observation.weatherReported, false, definition.id);
}
});
it("puts the sky and the weather in the scenario hash, where a trace can carry them", () => {
const a = STUDIO_OPS_SCENARIOS.resolve(3, "train-la-overcast-inspection");
const b = STUDIO_OPS_SCENARIOS.resolve(4, "train-la-overcast-inspection");
assert.notEqual(a.hash, b.hash);
assert.notEqual(a.parameters.aircraftScheduleSeed, b.parameters.aircraftScheduleSeed);
assert.notEqual(a.parameters.cloudPhase, b.parameters.cloudPhase);
// The jitter must not turn a named profile into a different one.
assert.ok(a.parameters.cloudCoverBase > 0.85 && b.parameters.cloudCoverBase > 0.85);
});
});
describe("studio-ops quantises everything a transcendental touched", () => {
it("reports no observation finer than the quantum", () => {
// The cross-runtime defence, checked where it has to hold: if any of these
// carried full double precision, a verifier on other hardware could reject
// an honest rollout over the last bit of a sine.
const environment = new StudioOpsEnvironment();
let observation = environment.reset(2, "train-la-hot-afternoon-studio")
.observation as Observation;
const fields = [
"sunAltitudeDeg", "sunAzimuthDeg", "hourOfDay", "cloudCover", "precipitation",
"visibilityKm", "windKph", "windDirDeg", "nearestAircraftSlantM",
"vehicleSocPct", "vehicleCabinC", "energyReservePct",
] as const;
for (let index = 0; index < 400; index += 1) {
for (const field of fields) {
const value = observation[field];
assert.equal(value, quantizeObservable(value), `${field} at step ${index}`);
}
const result = environment.step(scripted(observation));
observation = result.observation as Observation;
if (result.terminated || result.truncated) break;
}
});
it("puts no Date anywhere near the checksum", () => {
// `canonicalJson` throws on one, so this is a live proof rather than a
// convention: the snapshot survives being hashed.
const environment = new StudioOpsEnvironment();
environment.reset(2, "train-sf-clear-morning-desk-check");
environment.step(STUDIO_OPS_INACTION);
const snapshot = environment.snapshot();
assert.match(arenaChecksum(snapshot.simulation), /^fnv1a64:/);
assert.equal(typeof JSON.parse(JSON.stringify(snapshot.simulation)), "object");
});
});
describe("studio-ops wraps the simulators the renderer drives", () => {
it("simulates exactly the devices the shipped packs declare and Plan resolved", () => {
// Not a headless copy of the device list: the ids the scenarios name are
// the pack's own, and they are the ones `Plan` accepted.
const plans = {
"lumbridge-hq": new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false }),
"mateo-court": new Plan(MATEO_COURT, { depth: "public", warn: false }),
};
for (const definition of STUDIO_OPS_SCENARIOS.definitions) {
const plan = plans[definition.parameters.officeId];
const mic = plan.device(definition.parameters.micId);
const speaker = plan.device(definition.parameters.speakerId);
assert.ok(mic, `${definition.id} names a microphone the plan did not resolve`);
assert.ok(speaker, `${definition.id} names a speaker the plan did not resolve`);
assert.equal(mic.kind, "mic");
assert.equal(speaker.kind, "speaker");
assert.equal(mic.provenance, "simulated");
assert.match(mic.disclosure.toLowerCase(), /simulat/);
}
});
it("names the same simulator stack in its manifest as it imports", () => {
assert.equal(STUDIO_OPS_MANIFEST.id, "studio-ops-v1");
for (const fragment of [
"Plan",
"robotActivity",
"createSimulatedDevices",
"createSimulatedVehicleTelemetry",
"solarPosition",
]) {
assert.ok(STUDIO_OPS_MANIFEST.simulator.includes(fragment), fragment);
}
});
it("observes the robot job, the hardware, the weather, the car and the sky at once", () => {
// The point of the environment, as a shape assertion: forty-four fields
// across five groups, none of them constant across the catalogue.
const environment = new StudioOpsEnvironment();
const observation = environment.reset(1, "dev-la-marine-layer-loft-delivery")
.observation as Observation;
assert.equal(Object.keys(observation).length, STUDIO_OPS_MANIFEST.observationFields.length);
assert.deepEqual(
Object.keys(observation).sort(),
[...STUDIO_OPS_MANIFEST.observationFields].sort(),
);
assert.equal(observation.officeId, "mateo-court");
assert.equal(observation.levelId, "level-2");
assert.equal(observation.micPowered, true);
assert.equal(observation.energyReservePct, 100);
assert.equal(observation.stepsToDeparture, 1000);
});
it("keeps the loft scenario's desk unreachable, on purpose", () => {
// Every LA device is on level 1 and this job runs on level 2, so the
// correct play is to mute and get on with it. A policy that has only seen a
// reachable desk has not learned the difference between "unmute when
// somebody arrives" and "unmute".
const environment = new StudioOpsEnvironment();
let observation = environment.reset(1, "dev-la-marine-layer-loft-delivery")
.observation as Observation;
for (let index = 0; index < 400; index += 1) {
const result = environment.step(scripted(observation));
observation = result.observation as Observation;
assert.equal(observation.deskOccupied, false, `step ${index}`);
if (result.terminated || result.truncated) break;
}
});
});
describe("studio-ops device commands settle instead of churning", () => {
it("stops re-commanding a setpoint the instrument has already reached", () => {
// A gain of 12.005 dB is reported back as 12.01, so an action compared
// against the reading would disagree with itself forever and be charged a
// churn cost on every step for holding a control still. `normalizeAction`
// rounds to the precision the instrument reports, which makes the setpoint
// a fixed point.
const environment = new StudioOpsEnvironment();
environment.reset(1, "train-la-overcast-inspection");
const action: StudioOpsAction = {
...STUDIO_OPS_INACTION,
micGain: 12.005,
micMute: true,
speakerVolume: 0.2225,
};
const first = environment.step(action);
const settled = environment.step(action);
const again = environment.step(action);
const controlOf = (result: typeof first): number =>
(result.rewardComponents as StudioOpsReward).control;
assert.ok(controlOf(first) < 0, "the first step really does issue commands");
assert.equal(controlOf(settled), controlOf(again));
assert.equal(controlOf(settled), 0);
const observation = again.observation as Observation;
assert.equal(observation.micGainDb, 12.01);
assert.equal(observation.speakerVolume, 0.223);
});
});
+5
View File
@@ -0,0 +1,5 @@
# Test home for the `assets` workstream.
#
# Each build workstream owns its own subdirectory so eight builders can add
# suites in parallel without ever colliding on a path. `npm test` picks these
# up through the widened `src/test/**/*.test.ts` glob in package.json.
+219
View File
@@ -0,0 +1,219 @@
/**
* The two pieces of device hardware, and the three promises the layers above
* them are built on.
*
* `src/interiors/devices.ts` looks a device's LED up **by name**, `plan.ts`
* lays a device out from its **footprint** without building it, and every office
* in the product is expected to look the same on every reload. None of those
* three is visible from inside the builder, and all three break silently: a
* renamed sub-object gives you a mic whose mute light never changes, a footprint
* in centimetres gives you a microphone the size of a filing cabinet, and a
* `Math.random` slipping into a builder gives you a world that reshuffles itself
* between visits.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { createAssetContext, kit } from "../../assets/kit.ts";
import { MaterialRegistry } from "../../assets/materials.ts";
import { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "../../assets/office/index.ts";
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
function meshes(root: THREE.Object3D): THREE.Mesh[] {
const found: THREE.Mesh[] = [];
root.traverse((object) => {
if (object instanceof THREE.Mesh) found.push(object);
});
return found;
}
function encodeGeometry(root: THREE.Object3D): string {
root.updateMatrixWorld(true);
const parts: string[] = [];
for (const mesh of meshes(root)) {
const position = mesh.geometry.getAttribute("position");
let checksum = 0;
for (let i = 0; i < position.count; i++) {
// Quantised to a tenth of a millimetre. Comparing raw floats would make
// this fail on a different machine's `Math.sin`, which is not the thing
// being tested.
checksum =
(checksum * 31 +
Math.round(position.getX(i) * 1e4) +
Math.round(position.getY(i) * 1e4) * 7 +
Math.round(position.getZ(i) * 1e4) * 13) |
0;
}
parts.push(`${mesh.name}:${position.count}:${checksum}`);
}
return parts.join("|");
}
function disposeObject(root: THREE.Object3D): void {
for (const mesh of meshes(root)) mesh.geometry.dispose();
}
describe("device hardware assets", () => {
it("registers both devices under the parseable `<ns>:device.<kind>.<placement>` id", () => {
assert.deepEqual([...DEVICE_ASSET_IDS], [
"tera:device.mic.desk",
"tera:device.speaker.desk",
]);
for (const id of DEVICE_ASSET_IDS) {
assert.equal(kit.has(id), true, `${id} is not registered`);
// The shape `deviceKindOfAssetId()` parses. A device asset whose id does
// not match this is dropped as "not device hardware" with no error, so the
// pattern is worth pinning here rather than discovering in a plan's
// `problems` array.
assert.match(id, /^[a-z0-9-]+:device\.(mic|speaker)\.[a-z0-9-]+$/);
}
});
it("is authored at desk scale, in metres", () => {
for (const id of DEVICE_ASSET_IDS) {
const footprint = kit.footprintOf(id);
for (const [name, value] of [
["width", footprint.width],
["depth", footprint.depth],
["height", footprint.height],
] as const) {
assert.ok(
Number.isFinite(value) && value >= 0.02 && value <= 0.6,
`${id} ${name} is ${value}, which is not a desk object in metres`,
);
}
}
});
it("encloses its own geometry in the footprint it advertises", () => {
const materials = new MaterialRegistry({ quality: "low" });
try {
for (const id of DEVICE_ASSET_IDS) {
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const footprint = kit.footprintOf(id);
// A millimetre of slack, because a footprint is a layout number and not
// a measurement of the mesh — but only a millimetre, because `Plan`
// spaces things by it and a device that overhangs its own footprint is a
// device that ends up inside a monitor.
assert.ok(size.x <= footprint.width + 0.001, `${id} is ${size.x} wide`);
assert.ok(size.z <= footprint.depth + 0.001, `${id} is ${size.z} deep`);
assert.ok(box.max.y <= footprint.height + 0.001, `${id} reaches ${box.max.y}`);
assert.ok(box.min.y >= -0.001, `${id} starts below the floor at ${box.min.y}`);
disposeObject(object);
}
} finally {
materials.dispose();
}
});
it("exposes a sub-object named `indicator` holding the LED and nothing else", () => {
const materials = new MaterialRegistry({ quality: "high" });
const restore = withStubCanvas();
try {
for (const id of DEVICE_ASSET_IDS) {
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
const indicator = object.getObjectByName(DEVICE_INDICATOR_NAME);
assert.ok(indicator, `${id} has no "${DEVICE_INDICATOR_NAME}" sub-object`);
const led = meshes(indicator);
assert.ok(led.length > 0, `${id} indicator holds no geometry`);
for (const mesh of led) {
const material = mesh.material as THREE.Material;
assert.equal(
material.name,
"deviceIndicator",
`${id} indicator carries ${material.name}, which the device layer would not be able to tint safely`,
);
// An LED is smaller than a shadow-map texel and has nothing to cast.
assert.equal(mesh.castShadow, false);
assert.equal(mesh.receiveShadow, false);
}
// And the hardware must NOT be in there: the device layer replaces the
// material on everything under this name, so a housing that ended up
// inside it would light up with the LED.
const hardware = meshes(object).filter((mesh) => !led.includes(mesh));
assert.ok(hardware.length > 0, `${id} has no hardware outside its indicator`);
for (const mesh of hardware) {
assert.notEqual((mesh.material as THREE.Material).name, "deviceIndicator");
}
disposeObject(object);
}
} finally {
restore();
materials.dispose();
}
});
it("builds byte-identical geometry for the same seed", () => {
const materials = new MaterialRegistry({ quality: "low" });
try {
for (const id of DEVICE_ASSET_IDS) {
const first = kit.build(id, createAssetContext({ materials, rand: seeded() }));
const second = kit.build(id, createAssetContext({ materials, rand: seeded() }));
assert.equal(encodeGeometry(first), encodeGeometry(second), `${id} is not deterministic`);
disposeObject(first);
disposeObject(second);
}
} finally {
materials.dispose();
}
});
it("keeps every material in one primitive class, so nothing is silently dropped", () => {
// `mergeGeometries` refuses a mixture of indexed and non-indexed inputs and
// `MeshBin` treats the refusal as "skip this material" — which is not an
// error, it is a speaker with no cabinet. The symptom is a *missing* mesh,
// so the assertion is on the count of materials that survived.
const materials = new MaterialRegistry({ quality: "high" });
const restore = withStubCanvas();
try {
const expected: Record<string, number> = {
"tera:device.mic.desk": 4,
"tera:device.speaker.desk": 5,
};
for (const id of DEVICE_ASSET_IDS) {
const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
const names = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
assert.equal(
names.size,
expected[id],
`${id} came back with ${names.size} materials (${[...names].join(", ")}) — a dropped one means a merge was refused`,
);
disposeObject(object);
}
} finally {
restore();
materials.dispose();
}
});
it("gives the grille and the shell the roles the device layer expects", () => {
const materials = new MaterialRegistry({ quality: "high" });
const restore = withStubCanvas();
try {
const speaker = kit.build(
"tera:device.speaker.desk",
createAssetContext({ materials, rand: seeded() }),
);
const names = new Set(meshes(speaker).map((m) => (m.material as THREE.Material).name));
assert.ok(names.has("deviceShell"), "the cabinet is missing its moulded-housing role");
assert.ok(names.has("deviceMesh"), "the grille is missing its perforated role");
// The grille has to be double-sided or the gaps between the slats show
// nothing behind them, which is the whole reason it is geometry.
const grille = meshes(speaker).find(
(m) => (m.material as THREE.Material).name === "deviceMesh",
);
assert.ok(grille);
assert.equal((grille.material as THREE.Material).side, THREE.DoubleSide);
disposeObject(speaker);
} finally {
restore();
materials.dispose();
}
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* The smallest `document.createElement("canvas")` that makes `TextureBin` draw.
*
* `TextureBin.get` returns `null` when there is no canvas to draw on, which is
* the right behaviour under Node and is exactly what makes the *material*
* assertions in this directory impossible without a stub: a `screenContent`
* material built under `node --test` has a null `map` for a reason that has
* nothing to do with whether the binding is correct.
*
* So this installs a context that records nothing and rasterises nothing. It
* exists only so that `new THREE.CanvasTexture(canvas)` has a canvas, and the
* assertions that follow are about *which map is bound to which slot*, never
* about pixels. `src/test/render/textureMaps.test.ts` is where the drawings
* themselves are pinned, and duplicating that here would be two files asserting
* one thing.
*
* Deliberately not auto-installing on import: a module with a side effect on
* `globalThis` that fires on import is the kind of thing that makes one test
* file's behaviour depend on another's import order.
*/
interface StubCanvas {
width: number;
height: number;
getContext(id: string): unknown;
}
function stubContext(): unknown {
const noop = (): void => {};
return {
set fillStyle(_v: unknown) {},
get fillStyle(): string {
return "";
},
set strokeStyle(_v: unknown) {},
set lineWidth(_v: number) {},
set lineCap(_v: string) {},
set lineJoin(_v: string) {},
set globalAlpha(_v: number) {},
set globalCompositeOperation(_v: string) {},
set font(_v: string) {},
set textAlign(_v: string) {},
set textBaseline(_v: string) {},
set filter(_v: string) {},
save: noop,
restore: noop,
translate: noop,
rotate: noop,
scale: noop,
clip: noop,
fillRect: noop,
clearRect: noop,
strokeRect: noop,
beginPath: noop,
closePath: noop,
moveTo: noop,
lineTo: noop,
arc: noop,
arcTo: noop,
ellipse: noop,
rect: noop,
quadraticCurveTo: noop,
bezierCurveTo: noop,
fill: noop,
stroke: noop,
fillText: noop,
createLinearGradient: () => ({ addColorStop: noop }),
createRadialGradient: () => ({ addColorStop: noop }),
getImageData: (_x: number, _y: number, w: number, h: number) => ({
data: new Uint8ClampedArray(Math.max(1, w * h * 4)).fill(255),
width: w,
height: h,
}),
putImageData: noop,
};
}
/**
* Install the stub and return the undo. Call the undo in a `finally`: leaving a
* fake `document` on `globalThis` changes how every module loaded afterwards
* decides whether it is in a browser.
*/
export function withStubCanvas(): () => void {
const global = globalThis as { document?: unknown };
const had = "document" in global;
const previous = global.document;
global.document = {
createElement(tag: string): StubCanvas {
if (tag !== "canvas") throw new Error(`unexpected element <${tag}>`);
return {
width: 0,
height: 0,
getContext: () => stubContext(),
};
},
};
return () => {
if (had) global.document = previous;
else delete global.document;
};
}
/** A deterministic PRNG, so "same seed, same geometry" is testable at all. */
export function seeded(seed = 0x12345678): () => number {
let value = seed >>> 0;
return () => {
value = (Math.imul(value, 1664525) + 1013904223) >>> 0;
return value / 0x1_0000_0000;
};
}
+217
View File
@@ -0,0 +1,217 @@
/**
* The whole catalogue, checked for the two failures that do not raise anything.
*
* `src/test/officeHabitat.test.ts` already covers the seven habitat ids by
* name. This file covers **every registered asset**, including the ones that
* have not been written yet, and it exists because the two ways an asset breaks
* in this library are both silent:
*
* 1. **A material gets dropped.** `mergeGeometries` refuses a mixture of indexed
* and non-indexed geometry, `MeshBin` treats the refusal as "skip this
* material", and the result is not an exception it is a bench with no top,
* or a speaker with no cabinet. The symptom is a *missing* mesh, which is
* only visible against an expectation. So this asserts that no asset comes
* back with fewer distinct materials than it asked the registry for.
* 2. **A footprint stops matching its mesh.** `Plan` lays a room out from
* `footprintOf` without ever building the asset, so a footprint that
* under-reports is a prop halfway through a wall and a footprint that
* over-reports is a room that will not pack. Nothing checks the two against
* each other except this.
*
* The chamfer pass is what made the first of these urgent: turning a desktop
* from `box()` into `roundedBoxOf()` changes the primitive class of the whole
* `deskSurface` material, and getting that wrong on any of the five slabs it was
* applied to would have shipped a desk with no top and thrown nothing.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { createAssetContext, kit } from "../../assets/kit.ts";
import { MaterialRegistry } from "../../assets/materials.ts";
import { OFFICE_ASSETS } from "../../assets/office/index.ts";
import { seeded, withStubCanvas } from "./fakeCanvas.ts";
/**
* The registry's own count. Asserted rather than derived so that adding an asset
* is a deliberate two-line change and removing one cannot happen by accident
* `index.ts`'s header quotes this number, and a header that quietly disagrees
* with the code is worse than no header.
*/
const CATALOGUE_SIZE = 40;
function meshes(root: THREE.Object3D): THREE.Mesh[] {
const found: THREE.Mesh[] = [];
root.traverse((object) => {
if (object instanceof THREE.Mesh) found.push(object);
});
return found;
}
function disposeObject(root: THREE.Object3D): void {
for (const mesh of meshes(root)) mesh.geometry.dispose();
}
/**
* A registry that records which roles an asset actually asked for, so a dropped
* material can be told apart from a material the builder never wanted.
*/
class CountingRegistry extends MaterialRegistry {
readonly requested = new Set<string>();
override get(role: Parameters<MaterialRegistry["get"]>[0]): ReturnType<MaterialRegistry["get"]> {
const material = super.get(role);
this.requested.add(material.name);
return material;
}
override tinted(
role: Parameters<MaterialRegistry["tinted"]>[0],
color: number,
): ReturnType<MaterialRegistry["tinted"]> {
const material = super.tinted(role, color);
this.requested.add(material.name);
return material;
}
override variant(
role: Parameters<MaterialRegistry["variant"]>[0],
index: number,
color?: number,
): ReturnType<MaterialRegistry["variant"]> {
const material = super.variant(role, index, color);
this.requested.add(material.name);
return material;
}
}
describe("the office catalogue as a whole", () => {
it("registers exactly the assets `index.ts` says it does", () => {
assert.equal(OFFICE_ASSETS.length, CATALOGUE_SIZE);
const ids = OFFICE_ASSETS.map((def) => def.id);
assert.equal(new Set(ids).size, ids.length, "two assets share an id");
for (const id of ids) {
assert.equal(kit.has(id), true, `${id} is exported but not registered`);
assert.match(id, /^tera:/, `${id} is not in the tera namespace`);
}
});
it("keeps every material it asked for — nothing is silently dropped in the merge", () => {
const restore = withStubCanvas();
for (const def of OFFICE_ASSETS) {
const materials = new CountingRegistry({ quality: "high" });
try {
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
const drawn = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
const missing = [...materials.requested].filter((name) => !drawn.has(name));
assert.deepEqual(
missing,
[],
`${def.id} asked for ${missing.join(", ")} and drew nothing in it — a merge was refused, which means an indexed part and an extrusion ended up in the same material`,
);
disposeObject(object);
} finally {
materials.dispose();
}
}
restore();
});
it("advertises a footprint that encloses its own mesh", () => {
const materials = new MaterialRegistry({ quality: "low" });
try {
for (const def of OFFICE_ASSETS) {
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const footprint = kit.footprintOf(def.id);
// 20 mm of slack, which is a finger's width. `Plan` spaces rooms with
// these numbers, so a prop that overhangs its own footprint by more than
// that is a prop that ends up inside a wall.
assert.ok(
size.x <= footprint.width + 0.02,
`${def.id} is ${size.x.toFixed(3)} wide against a stated ${footprint.width}`,
);
assert.ok(
size.z <= footprint.depth + 0.02,
`${def.id} is ${size.z.toFixed(3)} deep against a stated ${footprint.depth}`,
);
assert.ok(
box.max.y <= footprint.height + 0.02,
`${def.id} reaches ${box.max.y.toFixed(3)} against a stated ${footprint.height}`,
);
disposeObject(object);
}
} finally {
materials.dispose();
}
});
it("keeps the two ceiling fittings, and only those two, hanging below their origin", () => {
// `light.pendant` and `light.troffer` are the library's only exceptions to
// "origin on the floor" (`common.ts`): their datum is the mounting plane and
// all their geometry is at `y ≤ 0`, so a pack writes `elevation: 2.9` and
// gets a lamp at 2.9 m. Every other asset — the floor lamp and the softbox
// included — stands on the floor, and an asset that quietly adopts the
// ceiling convention would sink through it.
const ceilingHung = new Set(["tera:light.pendant", "tera:light.troffer"]);
const materials = new MaterialRegistry({ quality: "low" });
try {
for (const def of OFFICE_ASSETS) {
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
const box = new THREE.Box3().setFromObject(object);
if (ceilingHung.has(def.id)) {
assert.ok(box.max.y <= 0.001, `${def.id} has geometry above its mounting plane`);
} else {
assert.ok(box.min.y >= -0.02, `${def.id} starts ${box.min.y} below the floor`);
}
disposeObject(object);
}
} finally {
materials.dispose();
}
});
it("builds no light source anywhere in the catalogue", () => {
// CONTRACT.md §4: Atmosphere is the sole light owner. Sixteen fittings each
// carrying a `PointLight` is both the wrong owner and, past about four
// shadow-casting lights, the end of the frame budget.
const materials = new MaterialRegistry({ quality: "low" });
try {
for (const def of OFFICE_ASSETS) {
const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
object.traverse((child) => {
assert.equal(child instanceof THREE.Light, false, `${def.id} constructs a light`);
});
disposeObject(object);
}
} finally {
materials.dispose();
}
});
it("is deterministic for the same seed, across the whole catalogue", () => {
const materials = new MaterialRegistry({ quality: "low" });
try {
for (const def of OFFICE_ASSETS) {
const first = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
const second = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
const encode = (root: THREE.Object3D): string => {
const box = new THREE.Box3().setFromObject(root);
const counts = meshes(root)
.map((m) => `${m.name}:${m.geometry.getAttribute("position").count}`)
.join(",");
return `${counts}|${[...box.min.toArray(), ...box.max.toArray()]
.map((v) => v.toFixed(6))
.join(",")}`;
};
assert.equal(encode(first), encode(second), `${def.id} is not deterministic`);
disposeObject(first);
disposeObject(second);
}
} finally {
materials.dispose();
}
});
});

Some files were not shown because too many files have changed in this diff Show More