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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user