# 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](#studio-ops-v1) below.

## Contract

```ts
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.

```ts
import {
  ARENA_ENVIRONMENTS,
  flattenObservation,
  observationWidth,
  rollout,
  structureAction,
} from "@lumbridge/tera/arena";

const env = ARENA_ENVIRONMENTS["studio-ops-v1"]();
const { observation } = env.reset(115, { split: "train" });

flattenObservation("studio-ops-v1", observation).length === observationWidth("studio-ops-v1");
env.step(structureAction("studio-ops-v1", policyOutputVector));

const { total, steps, final } = rollout(env, (obs) => myPolicy(obs), {
  seed: 115,
  scenario: { split: "dev" },
});
```

| `kind` | Slots | Encoding |
|---|---|---|
| `float` | 1 | the **clamped raw value**, with `low`/`high` published so a trainer can normalise it its own way |
| `bool` | 1 | `0` or `1` |
| `enum` | one per `values` | one-hot; a value outside the vocabulary is all zeros, which reads as "none of these" rather than colliding with the first member |
| `id` | 1 | a stable hash in `[0, 1)` for an open vocabulary — a change detector, not something to learn from |

`low` and `high` are *declared bounds*, not guarantees. Quietly wrong bounds
should show up as a saturated input rather than as a silently rescaled one,
which is why floats are not normalised here.

`ARENA_ENVIRONMENTS` is the env-id → constructor registry the catalogue was
missing: a harness handed `"drive-101-v1"` off a config file previously had to
keep its own switch. Constructors rather than instances, because an episode is
state and two rollouts in flight need two objects.

`rollout(env, policy, { seed, scenario, maxSteps })` is the loop every consumer
— including this repository's own test file — used to write by hand.

## Determinism across machines, and the arithmetic it forbids

Traces are verified by exact equality, and a verifier runs on hardware the
producer never saw. `Math.sin`, `Math.atan2`, `Math.pow` and `Math.hypot` are
not required by IEEE-754 or ECMA-262 to be correctly rounded — only `+`, `-`,
`*`, `/` and `Math.sqrt` are — so two conforming engines can disagree in the
last place. `studio-ops-v1` was the first environment to put a solar position
and a set of slant ranges into that comparison, and an unguarded float there is
the worst failure this package can have: silent, indistinguishable from fraud,
and only ever somebody else's problem.

Two defences, and both are needed:

1. `canonicalJson` quantises every non-integer it hashes to
   `ARENA_CHECKSUM_DECIMALS` (9) places, and `replay` compares rewards the same
   way rather than with `!==`.
2. The environment quantises at the point a transcendental is *called*, so the
   rounded value is the one that propagates — `quantizeObservable` in
   `studioOps.ts`. Rounding only at the checksum cannot rescue a simulation that
   has already accumulated a divergence, because the difference then grows with
   every step instead of staying in the last place.

`canonicalJson` also **throws** on a `Map`, a `Set`, a `Date`, a typed array or
any other non-plain object. It used to read their own enumerable keys — of which
those have none — and emit `{}`, so a populated `Map` checksummed identically to
an empty object and to every other `Map`. `ResolvedRobotOperations` already
holds `ReadonlyMap`s.

`restore()` now also checks `sourceHashes`. `envHash` covers the *manifest*,
which is semantics and does not move when the physics under it does: edit a
walker's collision epsilon, leave the manifest alone, and a snapshot taken
before the edit used to restore cleanly and resume into a different simulation.
`replay` always guarded this; a checkpoint is exactly as dangerous as a trace.

## Reward and baseline policy

Rewards are counterweighted rather than progress-only. Progress and sparse
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:

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