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
+383
View File
@@ -0,0 +1,383 @@
/**
* The parked car outside the front door, described without a renderer.
*
* `engine/officeExterior.ts` builds the apron and the vehicle in three.js. This
* file is everything about that scene that is *arithmetic* rather than
* geometry: how big a bay has to be for a given car, how far off square a real
* car parks, which lamp is lit for a given telemetry reading, and the controller
* settings that make a metre-scale vehicle behave like a vehicle rather than
* like a map symbol.
*
* ### Why the split is worth a file
*
* Two reasons, and both of them have bitten this repo before.
*
* The first is testability. A rule like "the charge lamp is green while
* charging and cool white once full" is one line of logic and eight lines of
* mesh plumbing, and if they live together the only way to test the line is to
* build a scene. `exteriorVehicleAppearance` is a pure function of a
* `VehicleTelemetryState`, so the mapping a viewer actually reads is asserted
* directly, and `officeExterior.ts` is left with nothing in it but geometry.
*
* The second is arithmetic that should never be restated. The bay is sized
* *from the car* rather than from a pair of authored numbers, so a wider car
* later gets a wider bay for free instead of getting a wider car in a bay it no
* longer fits — the same reference-not-restatement rule `offices/sites.ts`
* follows for coordinates.
*
* Nothing here imports three.js, the DOM, or the network.
*/
import type { VehicleControllerOptions } from "./vehicleController.ts";
import { VEHICLE_WHEEL_RADIUS_M } from "./vehicleSim.ts";
import type { VehicleTelemetryState } from "./vehicleTelemetry.ts";
// ---- Metre scale ----------------------------------------------------------
/**
* What `VehicleController` needs to behave at 1 unit = 1 m.
*
* `travelScale` is the whole point and the controller documents it as exactly
* this dial: the freeway board compresses route progress by 900 so that a
* playable drive crosses California in minutes rather than in a working day,
* and a car on a forecourt does not want any compression at all. The other
* three follow from the first — at travelScale 1 the speeds and offsets in the
* state are real metres in a real room, so a 58 m/s ceiling and a 5.4 m
* guardrail are a motorway's numbers standing in a car park.
*
* There is no forked controller, and there should not be one. A second state
* machine with the same responsibilities and different constants is how two
* subtly different definitions of "assisted" end up shipping in one product.
*
* **This preset resolves no collisions.** `stepNormalized` resolves none today
* and this changes nothing about that; a metre-scale car that has to negotiate
* kerbs, bollards and walkers is a separate project and is explicitly out of
* scope for this one.
*/
export const METRE_SCALE_VEHICLE_OPTIONS = {
travelScale: 1,
/** 50 km/h. A service road and a forecourt, not an interstate. */
maximumSpeedMps: 13.9,
/** Half the width of a two-lane apron road, so the edge is where the kerb is. */
guardrailOffsetM: 3.2,
/** Assistance loafs at a third of the posted limit; nothing here is a corridor. */
assistedCruiseRatio: 0.35,
/** See `VEHICLE_WHEEL_RADIUS_M` — the asset's wheel, not a rounded guess at it. */
wheelRadiusM: VEHICLE_WHEEL_RADIUS_M,
} as const;
/**
* Controller options for a metre-scale drive on `routeId`.
*
* Overrides are applied last so a caller can raise the ceiling for a wider road
* without restating the other four.
*/
export function metreScaleVehicleOptions(
routeId: string,
overrides: Partial<VehicleControllerOptions> = {},
): VehicleControllerOptions {
return { routeId, ...METRE_SCALE_VEHICLE_OPTIONS, ...overrides };
}
// ---- The apron ------------------------------------------------------------
/**
* What kind of ground the building's front door opens onto.
*
* `street` is a kerb, a marked bay and a strip of carriageway: the normal case,
* and what `mateo-court` (1.2 m above Mateo Street) and `frontier-valley` (4 m
* above an airfield apron) both have.
*
* `deck` is the answer to the question `offices/sites.ts` deliberately left
* open. `lumbridge-hq` is authored 188 m up a Transbay tower and its arrival
* anchor is "the kerb of the podium", because the pack frame is the only frame
* a pack has. Drawing a public street there would be a lie about a building
* that has none at that height, so an elevated site gets a podium deck instead:
* the same marked bay and the same charge post, standing on a paved deck with a
* low upstand and no carriageway running off it.
*
* The vertical question that note deferred is settled the same way, and by
* `ExteriorArrival`'s own wording rather than by a new rule: `levelId` names
* "the storey whose floor this stall is measured from", so the apron stands on
* that floor. The tower's car is on the podium at level 1, not on Folsom Street
* 188 m below it.
*/
export type ApronKind = "street" | "deck";
/**
* Above this site elevation there is no street outside the door.
*
* Thirty metres is about ten storeys — comfortably above anything with a kerb
* and comfortably below anything that could be mistaken for one. Both numbers
* either side of it in the shipped packs (4 m and 188 m) are nowhere near it,
* which is the property a threshold like this wants.
*/
export const APRON_STREET_MAX_ELEVATION_M = 30;
export function apronKindFor(siteElevationM: number): ApronKind {
return Number.isFinite(siteElevationM) && siteElevationM > APRON_STREET_MAX_ELEVATION_M
? "deck"
: "street";
}
/** Just enough of a vehicle to size a bay for it. */
export interface VehicleFootprint {
/** Metres along the vehicle's own forward axis. */
length: number;
/** Metres across it, mirrors included. */
width: number;
}
/** Every dimension `officeExterior.ts` needs, in metres, all derived. */
export interface ApronMetrics {
kind: ApronKind;
/** Thickness of the paved slab. The vehicle stands on top of it. */
padThickness: number;
padWidth: number;
padDepth: number;
/** The painted bay the vehicle sits in. */
stallWidth: number;
stallLength: number;
/** Width of a painted line. */
lineWidth: number;
kerbHeight: number;
kerbDepth: number;
/** Charge post, in the bay's own frame: X is the vehicle's left. */
postWidth: number;
postDepth: number;
postHeight: number;
postOffsetX: number;
postOffsetZ: number;
/** How far the status lamps sit up the post. */
lampHeight: number;
lampSize: number;
}
/**
* Size a bay around a vehicle.
*
* The clearances are the ones a real marked bay uses: about 450 mm each side to
* open a door against, and half a metre fore and aft so the painted rectangle
* reads as a bay rather than as a box drawn round a car. Everything else is
* measured off those two numbers, so there is exactly one place to change if
* the vehicle changes.
*/
export function apronMetrics(vehicle: VehicleFootprint, kind: ApronKind): ApronMetrics {
const width = Math.max(1.2, vehicle.width);
const length = Math.max(2.4, vehicle.length);
const stallWidth = width + 0.9;
const stallLength = length + 1.0;
return {
kind,
padThickness: 0.06,
// A shoulder wide enough to walk round the car on, and deep enough that the
// bay is not floating in the middle of nothing at an oblique camera.
padWidth: stallWidth + 1.8,
padDepth: stallLength + (kind === "street" ? 3.4 : 1.6),
stallWidth,
stallLength,
lineWidth: 0.1,
// A street kerb is a full 135 mm step; a podium deck gets a low upstand,
// because nothing is going to drive up onto a deck 188 m in the air.
kerbHeight: kind === "street" ? 0.135 : 0.09,
kerbDepth: 0.3,
postWidth: 0.3,
postDepth: 0.2,
postHeight: 1.28,
// Beside the vehicle's left rear quarter, which is where the charge port
// is, so the cable has a plausible run rather than crossing the car.
postOffsetX: -(stallWidth / 2 + 0.5),
postOffsetZ: stallLength / 2 - 0.9,
lampHeight: 1.02,
lampSize: 0.062,
};
}
// ---- Where the car actually stands ---------------------------------------
/** A pose in the pack's own plan frame. `yaw` is `object.rotation.y`. */
export interface ParkPose {
x: number;
z: number;
yaw: number;
}
/**
* How far off square a parked car is allowed to be.
*
* Nobody parks on the line. A car dead-centre in its bay at exactly the bay's
* angle is the single clearest tell that a scene was generated, and it costs
* one call to `rand` to fix. The bounds are deliberately small enough that the
* result is still unambiguously *in* the bay — a tenth of a metre and a degree
* — and small enough that a test can assert the anchor is honoured to within
* half a metre and two degrees no matter what generator is handed in, including
* `Math.random`.
*/
export const PARK_JITTER = {
/** Metres across the bay. */
lateralM: 0.11,
/** Metres along it. */
longitudinalM: 0.08,
/** Radians. About 1.0°. */
yawRad: 0.018,
} as const;
/**
* Place a vehicle in its bay, slightly imperfectly.
*
* The jitter is applied in the *bay's* frame rather than the plan's, so a bay
* at 90° gets a car nudged along its own length rather than sideways across it.
*/
export function parkPose(
arrival: { position: { x: number; z: number }; rotation: number },
rand: () => number,
): ParkPose {
const lateral = (rand() * 2 - 1) * PARK_JITTER.lateralM;
const longitudinal = (rand() * 2 - 1) * PARK_JITTER.longitudinalM;
const yaw = arrival.rotation + (rand() * 2 - 1) * PARK_JITTER.yawRad;
// Yaw zero faces Z (see `interiors/types.ts`), so forward is (sin, cos)
// and the vehicle's right is (cos, sin). Both are the three.js sense, which
// is why nothing here converts an angle.
const forwardX = -Math.sin(arrival.rotation);
const forwardZ = -Math.cos(arrival.rotation);
const rightX = Math.cos(arrival.rotation);
const rightZ = -Math.sin(arrival.rotation);
return {
x: arrival.position.x + rightX * lateral + forwardX * longitudinal,
z: arrival.position.z + rightZ * lateral + forwardZ * longitudinal,
yaw,
};
}
// ---- Telemetry as something you can see ----------------------------------
/** One lamp, as a colour and how hard it is driven. */
export interface LampReading {
/** 0xRRGGBB. */
color: number;
/** 0 (dark) to 1 (fully lit). */
intensity: number;
}
export interface ExteriorVehicleAppearance {
/** The charge post's lamp and the vehicle's charge-port ring. */
charge: LampReading;
climate: LampReading;
lock: LampReading;
/** Emissive strength of the cabin interior, 0..1. */
cabinGlow: number;
/** How full the post's charge bar reads, 0..1. */
chargeFraction: number;
}
/** Lamp colours. Named because three of them are used twice. */
const LAMP_OFF = 0x2a3236;
const LAMP_CHARGING = 0x46d07a;
const LAMP_FULL = 0x9fd8ff;
const LAMP_COOLING = 0x58c8e8;
const LAMP_HEATING = 0xe0964a;
const LAMP_SETTLED = 0x7fbf8a;
const LAMP_STANDBY = 0xd8a13a;
const LAMP_OPEN = 0xe8eef0;
/**
* The comfort band climate control is trying to hold the cabin inside.
*
* The state carries no setpoint — `VehicleTelemetryState` is the *observation*
* a real API returns and a setpoint is a setting — so the lamp reads the band
* rather than the target. That is also what a person standing next to the car
* can tell: it is cooling, it is heating, or it has got there.
*/
const COMFORT_MIN_C = 18;
const COMFORT_MAX_C = 24;
function clamp01(value: number): number {
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
}
/**
* How many distinct brightnesses a lamp is allowed to have.
*
* This is a *cache* constraint rather than an aesthetic one, and it is the
* reason `lampTint` exists at all. An indicator in this repo is a material —
* `materials.tinted("deviceIndicator", colour)` reaches both `color` and
* `emissive`, so a lamp changes state by changing material, not by changing a
* uniform on a shared one. That registry cache is keyed on the colour, so a
* charge lamp whose brightness tracked the state of charge continuously would
* mint a new material every step and never free one.
*
* Five steps is more than a lamp read from three metres away resolves, and it
* bounds the whole exterior at forty cache entries in the worst case — of which
* a running scene touches about six.
*/
export const LAMP_INTENSITY_STEPS = 5;
/**
* Fold a lamp's intensity into its colour, quantised.
*
* Multiplying each channel is not a physically-motivated dimming curve; it is
* the one that survives the tone mapping applied downstream, because
* `deviceIndicator` drives `emissive` as well as `color` and ACES compresses the
* top of the range rather than clipping it.
*/
export function lampTint(reading: LampReading): number {
const steps = LAMP_INTENSITY_STEPS - 1;
const level = Math.round(clamp01(reading.intensity) * steps) / steps;
const r = Math.round(((reading.color >> 16) & 0xff) * level);
const g = Math.round(((reading.color >> 8) & 0xff) * level);
const b = Math.round((reading.color & 0xff) * level);
return (r << 16) | (g << 8) | b;
}
/**
* Turn one telemetry observation into the handful of scalars the exterior
* renders.
*
* Pure, total, and deliberately the only place the mapping exists: a viewer
* standing on the apron reads the car's state entirely off these five values,
* and a test can assert what they will see without building a scene.
*
* Note what is *not* modelled: a flashing lamp. `apply()` is called on a change
* of state rather than every frame, so anything that animates would need a
* clock in the exterior layer, and a charge lamp that pulses is worth less than
* a charge lamp that is honest about the state of charge — which is what
* `chargeFraction` is for.
*/
export function exteriorVehicleAppearance(
telemetry: VehicleTelemetryState,
): ExteriorVehicleAppearance {
const socPct = clamp01(telemetry.socPct / 100) * 100;
const full = socPct >= 99.5;
const charge: LampReading = telemetry.pluggedIn
? full
? { color: LAMP_FULL, intensity: 1 }
// Ramps up as the pack fills, so a glance at the post tells you roughly
// how far along it is even before you read the bar.
: { color: LAMP_CHARGING, intensity: 0.45 + 0.55 * clamp01(socPct / 100) }
: { color: LAMP_OFF, intensity: 0.12 };
let climate: LampReading = { color: LAMP_OFF, intensity: 0.1 };
if (telemetry.climateOn) {
const cabinC = Number.isFinite(telemetry.cabinC) ? telemetry.cabinC : COMFORT_MIN_C;
if (cabinC > COMFORT_MAX_C) {
climate = { color: LAMP_COOLING, intensity: clamp01(0.55 + (cabinC - COMFORT_MAX_C) / 20) };
} else if (cabinC < COMFORT_MIN_C) {
climate = { color: LAMP_HEATING, intensity: clamp01(0.55 + (COMFORT_MIN_C - cabinC) / 20) };
} else {
climate = { color: LAMP_SETTLED, intensity: 0.5 };
}
}
// An unlocked car has its interior and marker lamps up; a locked one shows a
// dim standby. This is the reading a person actually uses to tell whether a
// car is theirs to open, which is why it gets a lamp of its own rather than
// being folded into the cabin glow.
const lock: LampReading = telemetry.locked
? { color: LAMP_STANDBY, intensity: 0.22 }
: { color: LAMP_OPEN, intensity: 0.85 };
const cabinGlow = telemetry.locked ? (telemetry.climateOn ? 0.35 : 0) : 0.9;
return { charge, climate, lock, cabinGlow, chargeFraction: clamp01(socPct / 100) };
}
+23 -1
View File
@@ -8,6 +8,7 @@
import type { GeographicPoint, TransportPack } from "./types.ts";
import {
VEHICLE_WHEEL_RADIUS_M,
buildRoutePath,
sampleRoute,
type RoutePath,
@@ -59,11 +60,32 @@ export interface VehicleControllerOptions {
maximumSpeedMps?: number;
assistedCruiseRatio?: number;
guardrailOffsetM?: number;
/**
* Rolling radius, metres. Defaults to `VEHICLE_WHEEL_RADIUS_M`, which is the
* radius of the wheel the renderer actually draws — see the note on that
* constant for why the number lives in `vehicleSim.ts` and what happens when
* it disagrees with the asset.
*/
wheelRadiusM?: number;
/**
* Multiplies longitudinal route progress without changing acceleration or
* steering response. State-scale boards use compression; metre-scale roads
* leave this at 1. Defaults to 1.
*
* This is the dial that makes one controller serve both scales, and it is
* worth being precise about what it does and does not touch. Only the
* distance advanced along the route is multiplied. Speed, acceleration, jerk,
* lateral offset, guardrail contact and wheel rotation are all still in real
* metres and real seconds, so a car at 900 crosses California in minutes
* while its wheels turn at the rate 24 m/s implies and its lane keeping is
* scored against a real 5.4 m carriageway.
*
* `transport/exteriorVehicle.ts` publishes `METRE_SCALE_VEHICLE_OPTIONS` —
* this at 1, with the ceiling, guardrail and cruise ratio that follow from
* it — so a forecourt does not have to rediscover the other three. There is
* deliberately no second controller: two state machines with the same
* responsibilities and different constants is how two definitions of
* "assisted" end up shipping in one product.
*/
travelScale?: number;
}
@@ -182,7 +204,7 @@ function resolveOptions(options: VehicleControllerOptions): ResolvedOptions {
maximumSpeedMps: clamp(finiteOr(options.maximumSpeedMps, 58), 5, 100),
assistedCruiseRatio: clamp(finiteOr(options.assistedCruiseRatio, 0.92), 0.25, 1.1),
guardrailOffsetM: clamp(finiteOr(options.guardrailOffsetM, 5.4), 1, 20),
wheelRadiusM: clamp(finiteOr(options.wheelRadiusM, 0.36), 0.1, 1),
wheelRadiusM: clamp(finiteOr(options.wheelRadiusM, VEHICLE_WHEEL_RADIUS_M), 0.1, 1),
travelScale: clamp(finiteOr(options.travelScale, 1), 1, 10_000),
};
}
+28 -2
View File
@@ -18,6 +18,30 @@ import type {
const EARTH_RADIUS_M = 6_371_000;
const MPH_TO_MPS = 0.44704;
const FIXED_STEP = 1 / 20;
/**
* The rolling radius of the vehicle every simulation in this directory drives,
* in metres.
*
* It lives here — in the leaf module of `src/transport/`, imported by the
* controller and by this file's own pose integrator — because it is the one
* physical constant two independent simulations both need and neither owns.
*
* **It is not authored twice.** `MODEL_X_METRICS.wheelRadius` in
* `assets/vehicles/modelX.ts` is the number of record, and this is the same
* number restated on the far side of a boundary `src/transport/` cannot cross:
* the asset imports three.js and nothing under `transport/` may. So the
* agreement is enforced where every other cross-boundary agreement in this repo
* is enforced — by a test. `src/test/vehicle/metreScale.test.ts` fails if the
* two ever drift apart.
*
* This used to be `0.36`, hard-coded in two places and described as "a
* representative tyre radius". It was 11 % small against the wheel the renderer
* actually draws, which at 94 m to the scene unit is invisible and on a
* forecourt at 1 m to the unit is a car whose wheels visibly spin faster than
* the ground it covers.
*/
export const VEHICLE_WHEEL_RADIUS_M = 0.405;
const MAX_FRAME_DELTA = 0.25;
export interface RouteLeg {
@@ -250,8 +274,10 @@ export class VehicleSimulation {
speedMps,
distanceM,
progress: distanceM / this.path.lengthM,
// 0.36 m is a representative Model X tyre radius.
wheelRadians: wrap(previous.wheelRadians + (Math.abs(signed) / 0.36), Math.PI * 2),
wheelRadians: wrap(
previous.wheelRadians + Math.abs(signed) / VEHICLE_WHEEL_RADIUS_M,
Math.PI * 2,
),
});
}
}
+571
View File
@@ -0,0 +1,571 @@
/**
* What the car outside the studio knows about itself.
*
* A parked vehicle is a *state feed*, not a simulation of driving: state of
* charge, cabin temperature, whether it is plugged in, whether it is locked,
* whether climate is running, how far it will go and how far it has gone. Every
* real vehicle API in existence returns roughly that list, and the exterior
* layer renders roughly that list — a charge lamp, a warm cabin, a locked car.
*
* ### The seam is the deliverable
*
* Lumbridge has the real vehicle APIs and will connect them later. Guessing at
* their schema now would be worth nothing and would be wrong; what is worth
* something is the shape of the hole they drop into. So this file publishes an
* interface — {@link VehicleTelemetrySource} — with a deterministic simulator
* and an empty source behind it, and a real adapter becomes a third
* implementation of the same five methods rather than a change to anything that
* reads them. That is the identical trade `src/adapters/` makes for weather and
* flights, and `src/devices/` makes for the mic and the speaker.
*
* ### Why the physics is fixed-step and reads no clock
*
* The arena wraps this module directly (`src/arena/studioOps.ts`), and the whole
* argument for the arena being honest is that it drives the *same* simulator the
* renderer drives rather than a headless reimplementation of it. That imposes
* two rules, and they are why `stepFixed` exists at all:
*
* - **No wall clock inside `stepFixed`.** `observedAt` is `epochMs` plus the
* elapsed simulated milliseconds. A `Date.now()` in here would make `replay()`
* impossible and would make two runs of the same seed differ.
* - **No parameter properties.** Node's type stripping refuses a module using
* `constructor(private readonly x: T)` outright (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`),
* and the arena imports this file under exactly that runtime. There are no
* classes in here at all, which is the simplest way to keep the promise.
*
* ### The cross-variable coupling, which is the point
*
* `ambientC` is drivable from the live weather observation
* ({@link SimulatedVehicleTelemetry.setAmbientC}), and it reaches the battery by
* two independent paths: a hot or cold ambient derates the pack's range
* directly, and it widens the gap climate control has to close, which costs
* kilowatts. A 38 °C afternoon in the Arts District therefore genuinely leaves
* the car with fewer kilometres than the same state of charge does in
* February — which is the sort of coupling that makes a multi-variable
* environment more than five independent ones sharing a step function.
*
* Nothing here imports three.js, the DOM, or the network.
*/
/**
* Where the vehicle is, if anybody actually knows.
*
* Optional, and `null` from both sources in this file, on purpose. A simulator
* that invented a GPS fix would be publishing a *coordinate nobody observed*
* under the same field a real API fills with somebody's actual position, and
* the distance between those two things is the whole of the disclosure
* discipline this repo applies to presence data. The parked demo car's position
* is the pack's authored `arrival` anchor — a fact about the building, not an
* observation about the vehicle — so it lives in `interiors/types.ts` and not
* here. The field exists so a real adapter has somewhere honest to put a fix.
*/
export interface VehicleLocation {
lat: number;
lng: number;
}
/**
* One observation of the vehicle.
*
* Strictly JSON-serialisable, for the same reason `DeviceState` is: it crosses
* a wire, it goes in a snapshot, and it is compared for equality in a replay.
*/
export interface VehicleTelemetryState {
/** State of charge, 0..100. */
socPct: number;
/** Cabin air temperature in degrees Celsius. */
cabinC: number;
pluggedIn: boolean;
locked: boolean;
climateOn: boolean;
/** Estimated remaining range, derated for ambient. See `rangeFor`. */
rangeKm: number;
odometerKm: number;
/** Milliseconds since the epoch. Simulated, never `Date.now()`. */
observedAt: number;
/** True when nobody observed this — always true for everything in this file. */
synthetic: boolean;
/** See {@link VehicleLocation}. `null` from every source here. */
location?: VehicleLocation | null;
}
/**
* The ops a caller may send.
*
* `trip` is the odd one and is worth naming: it is how the odometer moves. A
* parked car's odometer does not change by itself, and a simulator that drifted
* it upward would be inventing journeys, so distance arrives from whoever
* actually moved the car — `VehicleController`, or an arena policy — in metres
* travelled since the last report. Nothing in the exterior layer sends it,
* because nothing in the exterior layer drives.
*/
export type VehicleTelemetryCommandOp = "lock" | "charge" | "climate" | "climateSetpointC" | "trip";
export interface VehicleTelemetryCommand {
op: VehicleTelemetryCommandOp;
/** Boolean for `lock`/`charge`/`climate`; a number for the other two. */
value: boolean | number;
}
/**
* A vehicle state feed.
*
* Exactly five members, and deliberately the same five `createSimulatedDevices`
* publishes, so a consumer that can drive one simulator can drive the other.
*/
export interface VehicleTelemetrySource {
/** A fresh, detached observation. See the note in `createSimulatedVehicleTelemetry`. */
current(): VehicleTelemetryState;
command(cmd: VehicleTelemetryCommand): void;
stepFixed(): void;
snapshot(): unknown;
restore(s: unknown): void;
}
/**
* The simulator, which knows one thing the interface does not: what the weather
* is doing.
*
* `setAmbientC` is additive rather than part of {@link VehicleTelemetrySource}
* because a real vehicle API *reports* the outside temperature and would have
* nothing to do with a setter. It is the same shape `createSimulatedDevices`
* uses for `setOccupancy`, and for the same reason: the environment writing into
* a simulation is not a command to the thing being simulated.
*/
export interface SimulatedVehicleTelemetry extends VehicleTelemetrySource {
/**
* Publish the outside air temperature, in degrees Celsius, from whatever
* weather observation the caller has. Takes effect on the next `stepFixed`.
*/
setAmbientC(celsius: number): void;
}
export interface SimulatedVehicleTelemetryOptions {
seed: number;
fixedStepSeconds: number;
ambientC: number;
/**
* Epoch milliseconds the first observation is stamped with. Defaults to 0 so
* a test's expected sequence is a plain list of numbers rather than a
* function of when it ran.
*/
epochMs?: number;
/** Overrides the seeded starting charge. 0..100. */
initialSocPct?: number;
/** Overrides the seeded starting odometer, in kilometres. */
initialOdometerKm?: number;
}
// ---- Physical constants ---------------------------------------------------
//
// Every number below is a rounded, public, order-of-magnitude figure for a
// large electric crossover. None of them is a measurement of a specific
// vehicle and none of them is anyone's specification sheet: the asset this
// drives is `assets/vehicles/modelX.ts`, which ARCHITECTURE.md §3.1 requires to
// be unbadged, and the telemetry that stands next to it is held to the same
// standard. What matters for the environment is that the *relationships* are
// right — that charging tapers, that heat costs range, that climate costs
// charge — and those are properties of chemistry rather than of a brand.
/** Usable pack energy, kWh. */
const USABLE_CAPACITY_KWH = 95;
/** Range at 100 % in mild conditions, km. Derated by `rangeFor`. */
const NOMINAL_RANGE_KM = 560;
/**
* Peak charge power at the apron post, kW.
*
* The apron the exterior layer builds carries a DC post rather than a wall box,
* which is what makes the taper visible at all: an 11 kW home charger is flat
* from 0 to 100 % and would make the interesting half of this file dead code.
*/
const MAX_CHARGE_KW = 150;
/**
* Where the taper begins, and how fast it falls, in percentage points.
*
* A lithium pack cannot take full current near the top: past roughly half
* charge the constant-current phase gives way to constant-voltage and the
* accepted power falls away. The floor stops the last percent taking an
* afternoon.
*/
const TAPER_START_PCT = 55;
const TAPER_SPAN_PCT = 50;
const TAPER_FLOOR = 0.08;
/** What the car draws just being awake, kW. */
const PARASITIC_KW = 0.32;
/** Climate draw: a fixed fan-and-compressor cost plus work proportional to the gap. */
const HVAC_BASE_KW = 0.55;
const HVAC_KW_PER_KELVIN = 0.22;
const HVAC_MAX_KW = 6.5;
/**
* How fast a sealed cabin equalises with the outside, per second.
*
* A ten-minute time constant. Slower than a room because a car is a small
* volume behind a lot of glass, and faster than a building because it has
* almost no thermal mass.
*/
const CABIN_RELAX_PER_SECOND = 1 / 600;
/** How fast climate control closes the gap to setpoint, per second. */
const HVAC_PULL_PER_SECOND = 1 / 210;
/** Comfort setpoint the car preconditions to, and the range it will accept. */
const DEFAULT_SETPOINT_C = 21;
const MIN_SETPOINT_C = 15;
const MAX_SETPOINT_C = 28;
/** The ambient the pack is happiest at, and how fast range falls away from it. */
const IDEAL_AMBIENT_C = 21;
const RANGE_DERATE_PER_KELVIN = 0.011;
const RANGE_DERATE_FLOOR = 0.62;
/**
* Compressor duty ripple while climate is running.
*
* A climate system is not a resistor: it cycles, and the instantaneous draw
* wanders around its mean by a fifth or so. This is the only place randomness
* touches the physics, it is bounded strictly above zero, and it is drawn from
* the seeded generator so a replay reproduces it exactly.
*/
const DUTY_MIN = 0.86;
const DUTY_SPAN = 0.28;
const SNAPSHOT_VERSION = 1;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
/** A seeded PRNG whose entire state is one uint32, so a snapshot can carry it. */
function mulberry32Step(state: number): { state: number; value: number } {
const a = (state + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return { state: a, value: ((t ^ (t >>> 14)) >>> 0) / 4294967296 };
}
/**
* The fraction of {@link MAX_CHARGE_KW} the pack will accept at this charge.
*
* Monotonically non-increasing in `socPct`, which is the property the
* environment depends on and the one a test pins: the rise above 80 % is
* strictly slower than the rise below 60 %, because that is the single most
* visible fact about charging an electric car and an agent that plans a
* departure has to learn it.
*/
export function chargeTaper(socPct: number): number {
if (socPct <= TAPER_START_PCT) return 1;
return clamp(1 - (socPct - TAPER_START_PCT) / TAPER_SPAN_PCT, TAPER_FLOOR, 1);
}
/**
* Estimated range for a charge and an outside temperature.
*
* Symmetric about {@link IDEAL_AMBIENT_C} because both ends of the thermometer
* cost the same kind of energy for different reasons — a cold pack has higher
* internal resistance, a hot one spends the difference on cooling.
*/
export function rangeFor(socPct: number, ambientC: number): number {
const derate = clamp(
1 - Math.abs(ambientC - IDEAL_AMBIENT_C) * RANGE_DERATE_PER_KELVIN,
RANGE_DERATE_FLOOR,
1,
);
return (clamp(socPct, 0, 100) / 100) * NOMINAL_RANGE_KM * derate;
}
/**
* Clamp and vet a command from any adapter before it reaches the simulation,
* returning `null` for anything it refuses.
*
* The same contract `normalizeDeviceCommand` publishes: `null` means the caller
* should do nothing rather than the caller should show an error. A UI that sent
* `{ op: "climate", value: 3 }` has a bug, and the honest response to a bug in
* a control panel is an inert control, not a mutated vehicle.
*/
export function normalizeVehicleTelemetryCommand(
command: VehicleTelemetryCommand | null | undefined,
): VehicleTelemetryCommand | null {
if (!command || typeof command !== "object") return null;
const { op, value } = command;
switch (op) {
case "lock":
case "charge":
case "climate":
return typeof value === "boolean" ? { op, value } : null;
case "climateSetpointC":
return typeof value === "number" && Number.isFinite(value)
? { op, value: clamp(value, MIN_SETPOINT_C, MAX_SETPOINT_C) }
: null;
case "trip":
// Metres, and never negative: an odometer does not run backwards, and a
// negative trip is the shape a sign error arrives in.
return typeof value === "number" && Number.isFinite(value) && value >= 0
? { op, value }
: null;
default:
return null;
}
}
interface SimulationState {
elapsedSteps: number;
prng: number;
socPct: number;
cabinC: number;
pluggedIn: boolean;
locked: boolean;
climateOn: boolean;
setpointC: number;
odometerKm: number;
}
/**
* A deterministic fixed-step vehicle state feed.
*
* Same seed and same command sequence gives the same state sequence, on any
* machine, forever — which is what lets a rollout be replayed and a snapshot be
* resumed. `current()` returns a **fresh** object every call rather than a
* stable one, because the first thing every caller does is collect a sequence
* of them and compare it, and a stable object turns that into a list of a
* thousand aliases of the final state.
*/
export function createSimulatedVehicleTelemetry(
options: SimulatedVehicleTelemetryOptions,
): SimulatedVehicleTelemetry {
const dt = clamp(finiteOr(options.fixedStepSeconds, 0.1), 1 / 240, 10);
const epochMs = finiteOr(options.epochMs, 0);
let ambientC = clamp(finiteOr(options.ambientC, IDEAL_AMBIENT_C), -40, 60);
// The seed is consumed once, here, and never again outside `stepFixed`. Two
// studios seeded differently therefore have visibly different cars parked
// outside them — one nearly full and plugged in, one half empty and not —
// without either of them being authored.
let seeded = (options.seed | 0) >>> 0;
const draw = (): number => {
const next = mulberry32Step(seeded);
seeded = next.state;
return next.value;
};
const startSoc = draw();
const startCabinOffset = draw();
const startOdometer = draw();
const startPlugged = draw();
const state: SimulationState = {
elapsedSteps: 0,
prng: seeded,
socPct: clamp(finiteOr(options.initialSocPct, 38 + startSoc * 44), 0, 100),
cabinC: ambientC + (startCabinOffset * 6 - 3),
pluggedIn: startPlugged < 0.6,
locked: true,
climateOn: false,
setpointC: DEFAULT_SETPOINT_C,
odometerKm: Math.max(0, finiteOr(options.initialOdometerKm, 4_000 + Math.floor(startOdometer * 46_000))),
};
function nextRandom(): number {
const next = mulberry32Step(state.prng);
state.prng = next.state;
return next.value;
}
return {
current(): VehicleTelemetryState {
return {
socPct: state.socPct,
cabinC: state.cabinC,
pluggedIn: state.pluggedIn,
locked: state.locked,
climateOn: state.climateOn,
rangeKm: rangeFor(state.socPct, ambientC),
odometerKm: state.odometerKm,
// Integer step count times the step, rather than an accumulator, so
// 36,000 steps of 0.1 s is exactly an hour and not 3,599.9997 seconds.
observedAt: epochMs + state.elapsedSteps * dt * 1000,
synthetic: true,
location: null,
};
},
command(cmd: VehicleTelemetryCommand): void {
const normalized = normalizeVehicleTelemetryCommand(cmd);
if (!normalized) return;
// `value` is a `boolean | number` and the op does not narrow it, so each
// arm re-checks the type it wants. That is not belt and braces: it is the
// one place a hand-built command from a console can arrive, and a
// `climateOn` set to the number 1 renders as "on" and snapshots as garbage.
const { value } = normalized;
switch (normalized.op) {
case "lock":
if (typeof value === "boolean") state.locked = value;
break;
case "charge":
if (typeof value === "boolean") state.pluggedIn = value;
break;
case "climate":
if (typeof value === "boolean") state.climateOn = value;
break;
case "climateSetpointC":
if (typeof value === "number") state.setpointC = value;
break;
case "trip": {
if (typeof value !== "number") break;
// Metres in, kilometres on the dial. The energy for the distance is
// charged at the *derated* efficiency, so the same lap of the block
// costs more on a hot afternoon than it does at dawn.
const km = value / 1000;
const perKm = USABLE_CAPACITY_KWH / Math.max(1, rangeFor(100, ambientC));
state.odometerKm += km;
state.socPct = clamp(state.socPct - (km * perKm * 100) / USABLE_CAPACITY_KWH, 0, 100);
break;
}
}
},
stepFixed(): void {
let climateKw = 0;
if (state.climateOn) {
const gapK = Math.abs(state.setpointC - state.cabinC);
const duty = DUTY_MIN + nextRandom() * DUTY_SPAN;
climateKw = Math.min(HVAC_MAX_KW, HVAC_BASE_KW + gapK * HVAC_KW_PER_KELVIN) * duty;
}
const houseLoadKw = climateKw + PARASITIC_KW;
// A full pack on a live post does not discharge and then top itself back
// up: the post carries the house load directly, which is why you can
// precondition a plugged-in car for an hour and find it still at 100 %.
//
// This is not a rounding detail. Without it the state of charge
// oscillates around the cap forever — 100, 99.9995, 100 — every step, and
// the exterior's charge lamp flickers between "charging" and "full" for
// as long as the tab is open. The taper is evaluated against the charge
// the step *started* at, which is also what a charger does.
const chargeKw = !state.pluggedIn
? 0
: state.socPct < 100
? MAX_CHARGE_KW * chargeTaper(state.socPct)
: houseLoadKw;
const netKw = chargeKw - houseLoadKw;
state.socPct = clamp(
state.socPct + ((netKw * (dt / 3600)) / USABLE_CAPACITY_KWH) * 100,
0,
100,
);
// The cabin always leaks toward outside; climate control pulls against
// that leak rather than replacing it, which is why a 38 °C afternoon
// never quite reaches the setpoint and keeps costing kilowatts trying.
let cabin = state.cabinC + (ambientC - state.cabinC) * CABIN_RELAX_PER_SECOND * dt;
if (state.climateOn) {
cabin += (state.setpointC - cabin) * HVAC_PULL_PER_SECOND * dt;
}
state.cabinC = cabin;
state.elapsedSteps += 1;
},
setAmbientC(celsius: number): void {
ambientC = clamp(finiteOr(celsius, ambientC), -40, 60);
},
snapshot(): unknown {
return {
v: SNAPSHOT_VERSION,
elapsedSteps: state.elapsedSteps,
prng: state.prng,
socPct: state.socPct,
cabinC: state.cabinC,
pluggedIn: state.pluggedIn,
locked: state.locked,
climateOn: state.climateOn,
setpointC: state.setpointC,
odometerKm: state.odometerKm,
ambientC,
};
},
/**
* Adopt a snapshot, or ignore it.
*
* Deliberately never throws, and deliberately never half-applies. This is
* the same call `createSimulatedDevices` makes and it is made for the same
* reason: a snapshot arrives from a file, a wire or another process, and a
* throw inside a scene restore is a dead tab with a stack trace in the
* console. The arena does not rely on this for integrity — it pins the
* environment with its own checksum and source hashes — so the honest
* behaviour here is to leave the simulation exactly as it was.
*/
restore(snapshot: unknown): void {
if (!snapshot || typeof snapshot !== "object") return;
const s = snapshot as Record<string, unknown>;
if (s.v !== SNAPSHOT_VERSION) return;
if (
typeof s.elapsedSteps !== "number" || !Number.isSafeInteger(s.elapsedSteps) ||
s.elapsedSteps < 0 ||
typeof s.prng !== "number" || !Number.isSafeInteger(s.prng) ||
typeof s.socPct !== "number" || !Number.isFinite(s.socPct) ||
typeof s.cabinC !== "number" || !Number.isFinite(s.cabinC) ||
typeof s.setpointC !== "number" || !Number.isFinite(s.setpointC) ||
typeof s.odometerKm !== "number" || !Number.isFinite(s.odometerKm) ||
typeof s.ambientC !== "number" || !Number.isFinite(s.ambientC) ||
typeof s.pluggedIn !== "boolean" || typeof s.locked !== "boolean" ||
typeof s.climateOn !== "boolean"
) return;
state.elapsedSteps = s.elapsedSteps;
state.prng = s.prng >>> 0;
state.socPct = clamp(s.socPct, 0, 100);
state.cabinC = s.cabinC;
state.pluggedIn = s.pluggedIn;
state.locked = s.locked;
state.climateOn = s.climateOn;
state.setpointC = clamp(s.setpointC, MIN_SETPOINT_C, MAX_SETPOINT_C);
state.odometerKm = Math.max(0, s.odometerKm);
ambientC = clamp(s.ambientC, -40, 60);
},
};
}
/**
* A source that observes nothing.
*
* Not an error state and not an empty panel: every reading is at rest and
* `synthetic` is true, so an instrument bound to this renders as an instrument
* with nothing on it rather than disappearing. That is the same call
* `src/devices/adapter.ts` makes when a deployment refuses — the shape of the
* thing on screen should not change depending on whether a feed answered — and
* it is what a self-hoster with no vehicle API sees.
*
* Every number in the returned state is a placeholder. None of them is an
* observation, which is exactly what `synthetic: true` is there to say.
*/
export function createNullVehicleTelemetry(): VehicleTelemetrySource {
const resting = (): VehicleTelemetryState => ({
socPct: 0,
cabinC: 0,
pluggedIn: false,
locked: true,
climateOn: false,
rangeKm: 0,
odometerKm: 0,
observedAt: 0,
synthetic: true,
location: null,
});
return {
current: resting,
command: () => {},
stepFixed: () => {},
snapshot: () => ({ v: SNAPSHOT_VERSION, kind: "null" }),
restore: () => {},
};
}