1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/ARENA.md
T
karti db074e9cf7 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>
2026-08-21 19:44:24 -07:00

19 KiB

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 — 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
drive-101-v1 VehicleController + California transport pack complete a short US-101 or I-5 route leg guardrail contact
office-nav-v1 Plan(FRONTIER_VALLEY) + createWalker reach a collision-clear office waypoint repeated collision stall
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 below.

Contract

import {
  Drive101Environment,
  driveScriptedBaseline,
} from "@lumbridge/tera/arena";

const env = new Drive101Environment();
env.reset(115, { split: "train" });

for (;;) {
  const result = env.step(driveScriptedBaseline());
  if (result.terminated || result.truncated) break;
}

const checkpoint = env.snapshot(); // JSON-safe and checksummed
env.restore(checkpoint);

const trace = env.trace();          // actions, component rewards and per-step checksums
new Drive101Environment().replay(trace); // throws at the first divergence

Every environment implements tera.arena/v1:

  • reset(seed, scenario) accepts an exact public scenario id or { split: "train" | "dev", id? }. Omitting id selects a scenario from the requested split deterministically from the uint32-normalized seed.
  • step(action) returns an observation, total reward, named reward components, Gym-style terminated/truncated, and info.
  • info pins the semantic environment manifest, materialized scenario, simulator sources, environment sources, seed, step and state checksum.
  • snapshot/restore round-trip controller state exactly. Snapshots from another version, scenario, or modified payload are rejected.
  • trace is a canonical, checksummed action/reward/state envelope. replay verifies the initial state, every transition, the cumulative reward and final state. The checksum is FNV-1a-64 over canonical UTF-8 JSON; source pins are SHA-256 values checked from the repository by npm run arena:source-hashes.

Stepping a completed episode is an error. A task outcome sets terminated; only the manifest's step cap sets truncated with reason max-steps.

Scenarios and evaluation boundary

Train and dev ids are public, explicit, and disjoint in each manifest. Seeded jitter is applied only when a scenario is materialized and is included in its hash. This package intentionally contains no “private” split: a private eval published in the client package is not private. Evaluation operators should hold out their scenario definitions and instantiate the same contract in their own package or service.

ARENA_MANIFESTS is the machine-readable catalogue. Each manifest declares its 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.

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 ReadonlyMaps.

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 success compete with time, control/energy, lane/altitude/heading, collision and safety costs. A policy therefore cannot collect unbounded reward by circling, vibrating controls, leaning on a wall, or remaining still.

The tests execute every train and dev scenario at two seeds and require:

  1. the documented inaction action to finish below zero without reaching a goal;
  2. the exported scripted baseline to terminate at the goal with positive return;
  3. scripted return to exceed inaction return;
  4. all safety terminal classes to be reachable;
  5. seeded reset, snapshot continuation, trace replay and tamper rejection to be exact.

These are smoke-proof baselines, not optimal policies. They exist to make reward regressions and impossible tasks fail in CI before any training budget is spent.

office-jobs-v1 observes only current job facts and the current route waypoint; 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 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:

npm ci
npm run arena:source-hashes
npm test
npm run typecheck
npm run build
npm run provenance
npm run licenses