Move maintained Tera evaluations to private source
arena-environments / validate (3.11) (push) Successful in 1m14s
arena-environments / validate (3.12) (push) Successful in 1m15s

This commit is contained in:
2026-08-25 13:08:23 -07:00
parent 61939676a5
commit 4836d3e644
43 changed files with 36 additions and 10505 deletions
+4
View File
@@ -54,6 +54,10 @@ probe is what caught it — see the table below, and the git history.
| [`grand-exchange`](environments/grand_exchange) | Read price and volume history for a basket of items and place limit orders, executed against the next thirty ticks. | One or two items per basket are a random walk, not a mean-reverting one — the widest-swinging lines on the board and the ones with no anchor to revert to — and you are not told how many there are, so counting is not a substitute for the shape statistic. And liquidity in gp per tick is uncorrelated with price, so the fattest visible margins sit where the purse cannot go. |
| [`grand-exchange-live`](environments/grand_exchange_live) | The same basket, traded while the window is running: eight looks over sixty ticks, with stock, offers standing between looks, and a purse that sale proceeds come back to. | Looking is not free. Re-quoting an item that already carries an offer freezes the **whole** book for one to four ticks, an amended offer restarts at the back of the fill queue, and the per-item buy limit is cumulative over the window rather than per offer — so the policy that re-quotes on every look scores 0.631 against a reference that looks four times and scores 1.000. |
Tera world-simulation environments are maintained in the private Tera
repository. Arena keeps the generic, Apache-2.0 environment work above; it does
not ship current proprietary Tera source. See [the source boundary](docs/TERA_BOUNDARY.md).
## What the probes measure
Reward for the degenerate strategies and for an oracle, from `uv run python probe.py`:
-34
View File
@@ -1,34 +0,0 @@
# tera-crow-nav — fly a crow to a waypoint on sixteen replies.
# See environments/tera_spatial for what it measures and where these two integers
# came from (`measure_hold.py`, not judgement).
#
# uv run --project environments/tera_spatial eval @ configs/tera_crow_nav.toml \
# --model brain-qwen38-dspark \
# --client.base-url http://100.127.247.67:8001/v1 \
# --client.api-key-var SPARK_API_KEY --no-push --no-rich -c 8
#
# `node` >= 22.18 must be on PATH: the environment is TypeScript and is executed,
# not translated.
num_tasks = 16
num_rollouts = 2
[env.taskset]
id = "tera-crow-nav"
num_tasks = 16
max_turns = 16 # replies. Sixteen is where the ceiling first becomes reachable.
max_hold = 12 # steps one reply is held for. 16 x 12 = 192 against a 300-step cap,
# so what binds is the turn budget and never the simulator's own.
default_hold = 6 # what an unreadable reply costs. Never zero.
seed_base = 115
[env.agent.harness]
id = "null" # one completion per turn, no tools; the env programs the loop
[env.agent.runtime]
# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from
# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits
# `SystemExit: not authenticated with prime` before a single rollout, having already created
# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than
# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the
# trace and the harness is `null`.
type = "subprocess"
+3 -1
View File
@@ -12,7 +12,9 @@ harness, trainer, runtime marketplace, or environment hub.
explicit training handoff and artifact rendering.
Lumbridge's unique work is the environment: task distributions, interactive
state, rewards, probes, held-out gates, Tera bridges, and result interpretation.
state, rewards, probes, held-out gates, and result interpretation. Tera bridges
are developed with Tera because they package proprietary simulator source; they
use the same upstream Verifiers contract without living in this public repo.
Generic rollout and training machinery stays upstream. A Hub publication must
exclude secrets, private task splits, customer data, and proprietary Tera source.
+21
View File
@@ -0,0 +1,21 @@
# Tera source boundary
Arena is public Apache-2.0 environment work. Tera is Lumbridge's proprietary
world simulator and is developed in the private `lumbridge-private/tera`
repository.
The `tera_spatial` bridge and `tera-crow-nav` taskset were previously committed
to Arena. Those historical revisions remain available under the license granted
when they were published; deleting current files does not revoke that grant or
rewrite git history. As of 2026-08-25, maintained bridge code, vendored simulator
closures, Tera tasksets, and new Tera releases live only with private Tera.
The boundary is about source ownership, not rebuilding upstream machinery:
- Arena and private Tera both depend on Prime Intellect Verifiers for the
environment/harness contract.
- Forge delegates training to Prime-RL across a subprocess boundary.
- No Lumbridge repository should implement its own generic rollout harness,
trainer, environment hub, or GPU marketplace.
- Publishing an Arena environment to Prime Hub is a separate owner-approved
action. Nothing in this repository publishes automatically.
-197
View File
@@ -1,197 +0,0 @@
# tera-spatial
Arena's bridge to Tera's four renderer-independent spatial environments.
The environments themselves live in the `tera` repository, in TypeScript, as
`tera.arena/v1`. They are not reimplemented here and they never will be. This
package vendors their import closure, runs it in a resident `node` worker, and
transports results. The rule the whole thing is built around:
> **Never recompute in Python a number that came out of TypeScript.**
Rewards, per-step state checksums, scenario materialisation and the four
baseline returns that every reward is normalised against are all computed once,
inside Tera, and only ever carried across the pipe.
## What is here
| Path | What it is |
|---|---|
| `tera_spatial/worker.mjs` | the resident NDJSON worker: `reset`, `step`, `snapshot`, `restore`, `trace`, `replay`, `oracle`, `checksum` |
| `tera_spatial/bridge.py` | `TeraWorker` / `TeraEpisode` — transport, and nothing else |
| `tera_spatial/vendor/tera/` | the 23-file, 283 KB import closure of `src/arena/index.ts` |
| `tera_spatial/hashes.py` | SHA-256 of all 23, generated; the worker refuses to start if the tree has drifted |
| `tera_spatial/closure.py` | the import walk both the sync script and the gate use |
| `scripts/sync_tera.py` | reproduces the vendoring from a `tera` checkout |
| `tests/test_replay.py` | the replay gate |
| `tera_spatial/spatial.py` | the shared half of a spatial taskset: the resident worker, the reply grammar, the episode loop, the reward |
| `tera_crow_nav/` | the first taskset — `tera-crow-nav`, fly a crow to a waypoint on sixteen replies |
| `measure_hold.py` | the hold and turn-budget sweep the two integers came from |
| `tests/test_crow_nav.py` | the taskset's gate: the grammar, the reward, and a flight flown off the rendered panel |
`tera-office-nav` and `tera-california-flight` follow. `tera-drive-101` ships
labelled a bridge-correctness environment or not at all: its return is monotone in
throttle and any positive constant scores about 0.95 of the baseline, which is a
number with no house rule 4 behind it.
## The crux: a language model cannot emit sixty control vectors a second
These are continuous-control environments and the agent is text. So a reply is a
DECISION, not a frame: one control vector plus the number of simulation steps to
hold it for, and then the model is shown where the vehicle ended up.
The two integers that decide everything — how many replies, and how long one may
be held — are measured. `measure_hold.py` flies Tera's own scripted baseline at
every refresh rate over sixteen scenario x seed episodes:
```
hold return worst steps calls goal turns hold return goal
1 5.484 5.059 66.1 66.1 100% 1 12 0.031 0%
2 5.484 5.059 66.1 33.2 100% 2 12 0.317 0%
3 5.484 5.060 66.1 22.4 100% 4 12 1.039 0%
5 5.479 5.056 66.1 13.5 100% 6 11 1.700 0%
8 4.906 0.416 97.6 12.6 94% 8 11 3.420 44%
12 4.401 -0.467 127.6 11.2 88% 10 8 4.595 75%
16 3.643 -0.577 153.4 10.1 75% 12 7 4.974 88%
24 1.392 -0.689 280.1 12.1 44% 14 6 5.409 100%
32 -2.807 -2.923 300.0 10.0 0% 16 5 5.479 100%
64 -3.137 -3.658 238.3 4.0 0% 20 4 5.483 100%
300 -3.764 -4.906 149.7 1.0 0% 32 3 5.484 100%
inaction floor: -4.161 crow-nav-v1, 16 episodes per row
```
(the two tables `measure_hold.py` prints, set side by side; rows at hold 48, 100
and 150 are dropped for width.)
The left table is house rule 4's premise: **open-loop fails outright.** One
decision held for the whole flight returns -3.764 against an inaction floor of
-4.161 and reaches the waypoint none of the time — a bird that sets a course and
leaves does no better than a bird that does nothing. A plan is not a policy in a
world with momentum, which is why the grammar is one vector per reply and not a
program.
The right table is the conclusion, and it is the reason `max_turns = 16`: the
budget BINDS all the way up to it. At eight turns the best possible constant hold
reaches the waypoint 44% of the time, at twelve 88%, and only at fourteen does it
reach every one. Sixteen is the first budget at which the ceiling is reachable,
and it is one turn of slack, not fifteen.
`max_hold = 12` is the longest hold at which the baseline still reaches most
waypoints. `16 x 12 = 192` against a 300-step simulator cap, so what binds is the
turn budget and never the simulator's own.
## The rewards
Two, both products, both from numbers TypeScript computed:
| | |
|---|---|
| `flight` (0.70) | the episode's return against nine tenths of what Tera's scripted controller returned on the same scenario at the same seed |
| `economy` (0.30) | that quality, multiplied by having arrived and by how directly |
`flight` is smooth because goal attainment is already the biggest term inside it —
the simulator pays +3 of a ~5.5 return for reaching the waypoint — so a near-miss
scores about four tenths and a wander scores zero.
⚠️ **Goal attainment multiplies and is never a term of its own.** It is already
inside the return as the success bonus, and a separate additive `gate` beside the
sum double-counts it. That is the exact failure `probe.py` records having paid
for. Efficiency multiplies too: a flight that never arrived cannot be quick about
arriving, and standing beside `flight` it would pay a crash for being fast.
`gate`, `quality`, `step_ratio`, `ts_return`, `hold_mean`, `malformed_turns` and
`replay_ok` are recorded and never rewarded.
**`replay_ok` is the bridge's claim asserted once per episode**, not once per
commit: every graded flight is replayed in a fresh TypeScript environment and the
metric is whether it reached the same FNV-1a-64 checksum, the same step count and
the same return. A divergence is recorded, never raised — an episode that flew is
data, and losing it to an assertion would hide the thing the metric exists to
surface.
## Running it
```bash
uv run --project environments/tera_spatial eval @ configs/tera_crow_nav.toml \
--model brain-qwen38-dspark \
--client.base-url http://100.127.247.67:8001/v1 \
--client.api-key-var SPARK_API_KEY \
--no-push --no-rich -c 8 -o outputs/run-<stamp>/tera-crow-nav
```
⚠️ `eval` exits 0 even when every rollout errors. Count non-empty `rewards`; see
`docs/FIRST_EVAL.md`.
## What it measured
`outputs/run-20260821-1627/tera-crow-nav`, 16 tasks x 2 rollouts, model
`brain-qwen38-dspark` on spark-1, 12 minutes at `-c 8`. **32 episodes attempted,
32 scored, 0 errored, 0 provider errors.**
| | |
|---|---|
| `reward_mean_attempted` | **0.4603** |
| `flight` (0.70) | 0.4894 |
| `economy` (0.30) | 0.3926 |
| waypoint reached | 14 of 32 |
| flight-envelope contact | 2 of 32 |
| out of turns, still flying | 16 of 32 |
| malformed replies | **0** of 412 |
| `replay_ok` | **1.000 on all 32** |
Three things in that table are worth more than the headline.
**The turn budget binds, in the run and not only in the sweep.** Turns used were
7, 8, 9, 10, 12, 14 and 16 — mean 12.9, and **sixteen of the thirty-two rollouts
spent every reply they had** and were still airborne when the budget ran out. The
one that scored zero on `train-east-crosswind` did so by orbiting: 128 steps
flown, yaw -90°, and 52.8 m still between it and the waypoint on its last reply.
**The gate carries a gradient.** Goal attainment fired on 43.75% of rollouts.
Across the first seven Arena environments a `gate` component scored exactly 0.000,
max 0.000, in four of them — a quarter to a third of the reward mass with nothing
in it. Here it discriminates, and per-scenario it discriminates sharply:
`train-north-climb` 0.863, `dev-west-return` 0.728, `dev-south-descent` 0.250,
`train-east-crosswind` 0.000.
**`eval.log` and `traces.jsonl` agree.** The mean over the log's own 32 `reward=`
lines is 0.4603, the same number as `sum(score x weight)` over the file. That is
not free: rewards recorded after `interaction.close()` land in the file and not in
the log, and the first cut of this env printed `reward=0.000` for an episode that
scored 1.000. Grade before closing.
## The gate
```bash
uv run --project environments/tera_spatial \
python -m unittest discover -s environments/tera_spatial/tests -v
```
32 episodes — 4 environments x 4 public scenarios x 2 seeds — each driven from
Python one JSON step at a time, then replayed in a fresh TypeScript environment.
All 32 must reach an identical FNV-1a-64 checksum. Then seven forgeries per
environment, each tried twice: raw, where the envelope checksum catches it, and
**re-sealed with a checksum Tera itself recomputed**, where only `replay()`
re-running the simulator can. All must be rejected.
`test_crow_nav.py` adds fifteen more. The reward's floor and ceiling are flown
rather than asserted from a fixture, and the strongest of them is the rendering's
own claim: a policy that reads **nothing but the rendered panel** — the same
characters the model is shown, parsed back out of them with a regex — reaches all
four waypoints inside the shipped budget. An observation encoding that stops
carrying enough to fly on stops passing that test.
`node` >= 22.18 is required — the vendored sources are raw `.ts` and are
type-stripped, not compiled. Set `TERA_NODE` to point at a specific binary.
## Re-vendoring
```bash
uv run python scripts/sync_tera.py --tera ~/repos/gitea/tera # copy + regenerate hashes
uv run python scripts/sync_tera.py --check # CI: is the tree clean?
```
The closure is walked, not declared, so a new import in `tera` travels with it.
A bare specifier is a hard failure: the wheel has no `node_modules` and a
simulator that needs one is not a simulator that ships.
-160
View File
@@ -1,160 +0,0 @@
"""How long a crow can be flown blind — the measurement the turn budget is set from.
A language model does not emit sixty control vectors a second. It emits a few
dozen replies in a whole episode. So a spatial taskset has to decide how many
simulation steps one reply is held for, and that decision is the environment:
hold too little and the model cannot reach the waypoint inside any budget worth
running; hold too much and the episode is open-loop, which is to say the turn
budget does not bind and rule 4 has nothing to say.
This file measures the frontier rather than guessing at it. It flies Tera's own
scripted baseline — the reward denominator, unmodified — at hold lengths from 1
to the whole episode, and reports for each one the return, the steps, the
decisions and whether the waypoint was reached. Only the refresh rate changes;
the policy, the scenario and the seed do not. The number that matters is where
the return falls off, because that is the boundary between "the model is
flying" and "the model set a course and left".
uv run --no-project python environments/tera_spatial/measure_hold.py
uv run --no-project python environments/tera_spatial/measure_hold.py --env drive-101-v1
Nothing here is a reward and nothing here runs a model. It is the evidence for
two integers in `configs/tera_crow_nav.toml`.
"""
from __future__ import annotations
import argparse
import statistics
from collections.abc import Sequence
from tera_spatial import ENVIRONMENT_IDS, TeraWorker
HOLDS = (1, 2, 3, 5, 8, 12, 16, 24, 32, 48, 64, 100, 150, 300)
SEEDS = (115, 2718, 40_902, 7_331)
BUDGETS = (1, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32)
def sweep(
worker: TeraWorker, env_id: str, holds: Sequence[int], seeds: Sequence[int]
) -> list[dict[str, float]]:
"""One row per hold length, averaged over every public scenario at every seed."""
manifest = next(m for m in worker.manifests() if m["id"] == env_id)
scenarios = [
(split, scenario_id)
for split in ("train", "dev")
for scenario_id in manifest["scenarioIds"][split]
]
rows = []
for hold in holds:
returns, steps, decisions, reached = [], [], [], 0
for split, scenario_id in scenarios:
for seed in seeds:
result = worker.oracle(
env_id, seed, {"split": split, "id": scenario_id}, hold=hold
)
returns.append(result["cumulativeReward"])
steps.append(result["steps"])
decisions.append(result["decisions"])
reached += result["terminalReason"] == "goal"
rows.append(
{
"hold": hold,
"mean_return": statistics.fmean(returns),
"worst_return": min(returns),
"mean_steps": statistics.fmean(steps),
"mean_decisions": statistics.fmean(decisions),
"goal_rate": reached / len(returns),
"episodes": len(returns),
}
)
return rows
def budget_ladder(
worker: TeraWorker,
env_id: str,
turns: Sequence[int],
max_hold: int,
seeds: Sequence[int],
) -> list[dict[str, float]]:
"""The best the baseline can do under a turn budget — the rule-4 ladder.
For each budget the baseline is flown at every legal hold length and the best
result is kept, with the episode cut off at `turns x hold` steps because that
is all the budget buys. A budget that does not change the answer is a budget
that does not bind, and this table is where that shows.
"""
manifest = next(m for m in worker.manifests() if m["id"] == env_id)
scenarios = [
(split, scenario_id)
for split in ("train", "dev")
for scenario_id in manifest["scenarioIds"][split]
]
rows = []
for budget in turns:
best = None
for hold in range(1, max_hold + 1):
returns, reached = [], 0
for split, scenario_id in scenarios:
for seed in seeds:
result = worker.oracle(
env_id,
seed,
{"split": split, "id": scenario_id},
max_steps=budget * hold,
hold=hold,
)
returns.append(result["cumulativeReward"])
reached += result["terminalReason"] == "goal"
mean = statistics.fmean(returns)
if best is None or mean > best["mean_return"]:
best = {
"turns": budget,
"hold": hold,
"mean_return": mean,
"worst_return": min(returns),
"goal_rate": reached / len(returns),
}
rows.append(best)
return rows
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--env", default="crow-nav-v1", choices=ENVIRONMENT_IDS)
parser.add_argument("--seeds", type=int, nargs="+", default=list(SEEDS))
parser.add_argument("--max-hold", type=int, default=12)
args = parser.parse_args()
with TeraWorker() as worker:
rows = sweep(worker, args.env, HOLDS, args.seeds)
floor = worker.oracle(args.env, args.seeds[0], {"split": "train"}, policy="inaction")
ladder = budget_ladder(worker, args.env, BUDGETS, args.max_hold, args.seeds)
print(f"{args.env} — Tera's scripted baseline, flown at one decision every `hold` steps")
print(f"{'hold':>5} {'return':>9} {'worst':>9} {'steps':>7} {'calls':>7} {'goal':>6}")
for row in rows:
print(
f"{row['hold']:>5} {row['mean_return']:>9.3f} {row['worst_return']:>9.3f} "
f"{row['mean_steps']:>7.1f} {row['mean_decisions']:>7.1f} {row['goal_rate']:>6.0%}"
)
print(
f"\ninaction floor (seed {args.seeds[0]}, train): "
f"{floor['cumulativeReward']:.3f} over {floor['steps']} steps "
f"({floor['terminalReason']})"
)
print(f"{rows[0]['episodes']} episodes per row.")
print(f"\nUnder a turn budget, hold capped at {args.max_hold} — best constant hold per budget")
print(f"{'turns':>6} {'hold':>5} {'return':>9} {'worst':>9} {'goal':>6}")
for row in ladder:
print(
f"{row['turns']:>6} {row['hold']:>5} {row['mean_return']:>9.3f} "
f"{row['worst_return']:>9.3f} {row['goal_rate']:>6.0%}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-25
View File
@@ -1,25 +0,0 @@
[project]
name = "tera-spatial"
version = "0.1.0"
description = "tera-spatial — Arena's bridge to Tera's four renderer-independent spatial environments, executed in TypeScript and graded in Python, and the tasksets that sit on it."
requires-python = ">=3.11"
dependencies = ["verifiers==0.3.1"]
# One directory, several tasksets: the environments share a vendored simulator and
# a single worker, so splitting them into four wheels would ship the same 283 KB
# of TypeScript four times. Discovery reads this key instead of assuming one wheel
# is one taskset.
#
# Only the ones that exist are listed. `tera-office-nav` and `tera-california-flight`
# follow; `tera-drive-101` ships labelled a bridge-correctness environment or not at
# all, because its return is monotone in throttle and any positive constant scores
# ~0.95 of the baseline — a number with no house rule 4 behind it.
[tool.arena]
tasksets = ["tera-crow-nav"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["tera_spatial", "tera_crow_nav"]
@@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""Vendor the Tera arena TypeScript closure into this package.
Tera is a TypeScript repository with no Python toolchain, and this package is a
Python wheel. The bridge's rule is that a number the model is graded on is
computed *once*, in TypeScript, and only ever transported into Python — so the
TypeScript has to travel with the wheel rather than be resolved at runtime from
a checkout that may not exist on the machine doing the eval.
What travels is the transitive import closure of `src/arena/index.ts` and
nothing else: 23 files, no bare specifiers, no Three.js, no DOM. That closure is
walked here rather than declared, so a new import in `tera` is picked up the
next time somebody runs this instead of silently going missing.
uv run python scripts/sync_tera.py --tera ~/repos/gitea/tera
Writes `tera_spatial/vendor/tera/<path>` for every file in the closure and
regenerates `tera_spatial/hashes.py`. Both are committed. `hashes.py` is what
`test_replay.py` checks the vendored tree against, so an edit to a vendored
file that does not come back through this script fails the gate.
"""
from __future__ import annotations
import argparse
import hashlib
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tera_spatial.closure import ENTRY, walk # noqa: E402
def sha256(path: Path) -> str:
return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
def main(argv: list[str] | None = None) -> int:
package = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tera", type=Path, default=Path.home() / "repos/gitea/tera")
parser.add_argument("--check", action="store_true", help="verify only; write nothing")
args = parser.parse_args(argv)
root = args.tera.expanduser().resolve()
if not (root / ENTRY).is_file():
print(f"not a tera checkout: {root}", file=sys.stderr)
return 2
files, bare = walk(root)
if bare:
# A bare specifier means the closure needs `node_modules`, and the wheel
# would ship a bridge that only runs next to a `npm install`.
print("closure is not self-contained:", file=sys.stderr)
for entry in bare:
print(f" {entry}", file=sys.stderr)
return 1
vendor = package / "tera_spatial" / "vendor" / "tera"
if args.check:
stale = [rel for rel in files if not (vendor / rel).is_file()]
if stale:
print("missing from vendor:", *stale, sep="\n ", file=sys.stderr)
return 1
else:
if vendor.exists():
shutil.rmtree(vendor)
for rel in files:
destination = vendor / rel
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(root / rel, destination)
digests = {rel: sha256(vendor / rel) for rel in files}
total = sum((vendor / rel).stat().st_size for rel in files)
aggregate = hashlib.sha256()
for rel in files:
aggregate.update(rel.encode())
aggregate.update(b"\0")
aggregate.update((vendor / rel).read_bytes())
aggregate.update(b"\0")
body = _render(digests, total, "sha256:" + aggregate.hexdigest())
target = package / "tera_spatial" / "hashes.py"
if args.check:
if target.read_text(encoding="utf-8") != body:
print("hashes.py is stale; re-run scripts/sync_tera.py", file=sys.stderr)
return 1
print(f"vendored closure ok: {len(files)} files, {total} bytes")
return 0
target.write_text(body, encoding="utf-8")
print(f"vendored {len(files)} files ({total} bytes) from {root}")
return 0
def _render(digests: dict[str, str], total: int, aggregate: str) -> str:
lines = [
'"""SHA-256 of every vendored Tera source file. Generated — do not edit.',
"",
"Regenerate with `scripts/sync_tera.py`. `verify()` is called by the replay",
"gate: the checksums a trace carries are only worth something if the code that",
"produced them is the code on disk.",
'"""',
"",
"from __future__ import annotations",
"",
"import hashlib",
"from pathlib import Path",
"",
f"CLOSURE_ENTRY = {ENTRY!r}",
f"CLOSURE_BYTES = {total}",
f"CLOSURE_SHA256 = {aggregate!r}",
"",
"VENDORED_FILES: dict[str, str] = {",
]
lines += [f" {rel!r}: {digest!r}," for rel, digest in digests.items()]
lines += [
"}",
"",
"VENDOR_ROOT = Path(__file__).resolve().parent / \"vendor\" / \"tera\"",
"",
"",
"def verify() -> list[str]:",
' """Return a list of complaints about the vendored tree. Empty means clean."""',
" problems: list[str] = []",
" for relative, expected in VENDORED_FILES.items():",
" path = VENDOR_ROOT / relative",
" if not path.is_file():",
" problems.append(f\"missing: {relative}\")",
" continue",
' actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()',
" if actual != expected:",
' problems.append(f"modified: {relative} ({actual} != {expected})")',
" for path in sorted(VENDOR_ROOT.rglob(\"*\")):",
" if path.is_file():",
" relative = path.relative_to(VENDOR_ROOT).as_posix()",
" if relative not in VENDORED_FILES:",
' problems.append(f"unrecorded: {relative}")',
" return problems",
"",
]
return "\n".join(lines)
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,18 +0,0 @@
"""tera-crow-nav — the first Tera spatial taskset, over the NDJSON bridge.
The taskset id resolves to this module name, so both plugin classes are exported
from here: the `Taskset` the run loads its tasks from, and the `Env` that programs
the flight loop host-side. `TeraSpatialEnv` lives in `tera_spatial` because the
loop is the same for every spatial environment; what makes this one crow-nav is
`CrowNavTaskset`.
"""
from tera_crow_nav.taskset import CrowNavConfig, CrowNavTaskset
from tera_spatial.spatial import TeraSpatialEnv, TeraSpatialEnvConfig
__all__ = [
"CrowNavConfig",
"CrowNavTaskset",
"TeraSpatialEnv",
"TeraSpatialEnvConfig",
]
@@ -1,211 +0,0 @@
"""tera-crow-nav: fly a crow to a waypoint on sixteen replies.
Tera's `crow-nav-v1` is a three-dimensional waypoint problem over the same flight
controller the renderer flies — momentum, a bank-coupled turn rate, a finite
flight-energy budget, thermals, drag that rises with the square of airspeed, and
an envelope at 2 m and 40 m that ends the flight if you touch it. The simulator
runs at 10 Hz for up to 300 steps. A language model does not fly at 10 Hz.
So a reply is a decision, not a frame: one control vector plus the number of
steps to hold it for. Sixteen replies, twelve steps each at most.
**Those two integers are the environment**, and they are measured. `measure_hold.py`
flies Tera's own scripted baseline at every refresh rate over sixteen scenario x
seed episodes:
hold return goal turns best hold return goal
1 5.484 100% 1 12 0.031 0%
5 5.479 100% 4 12 1.039 0%
8 4.906 94% 8 11 3.420 44%
12 4.401 88% 12 7 4.974 88%
24 1.392 44% 14 6 5.409 100%
32 -2.807 0% 16 5 5.479 100%
300 -3.764 0% 20 4 5.483 100%
Read the left table for house rule 4's premise and the right one for its
conclusion. Open-loop fails outright — one decision for the whole flight returns
-3.764 against an inaction floor of -4.161, which is to say a bird that sets a
course and leaves does no better than a bird that does nothing. And the budget
BINDS all the way to sixteen: at eight turns the best possible constant hold
reaches the waypoint 44% of the time, at twelve 88%, and only at fourteen does it
reach every one. Sixteen is the first budget at which the ceiling is reachable and
fifteen turns of slack is not on offer.
Two rewards, both products:
flight the episode's return against nine tenths of what Tera's scripted
controller returned on the same scenario at the same seed. Smooth:
reaching the waypoint is already the biggest term inside it — the
simulator pays +3 of a ~5.5 return for arriving — so a near-miss
scores about four tenths and a wander scores zero.
economy that same quality, multiplied by having arrived and by how directly.
Goal attainment MULTIPLIES and is never a term of its own; standing
beside the sum it would double-count a bonus the return already
contains. Efficiency multiplies too, because a flight that never
arrived cannot be quick about arriving.
Minimum-power inaction returns -4.161 and scores exactly 0.000 on both. The
scripted controller flown inside this budget scores 1.000 on both. `gate`,
`quality`, `step_ratio` and `replay_ok` are recorded and never rewarded.
"""
from __future__ import annotations
import math
from typing import Any, ClassVar
import verifiers.v1 as vf
from tera_spatial.spatial import (
SpatialConfig,
SpatialData,
SpatialTask,
TeraSpatialTaskset,
TurnView,
)
MISSION = """You are flying a crow to a waypoint.
The simulator advances in fixed steps of {step:g} s. You do not fly it step by step:
each reply is ONE set of control positions plus a `hold`, and the crow holds those
positions for that many steps before you are shown where it ended up.
You get {turns} replies. A reply may be held for 1 to {max_hold} steps.
That is {budget} steps of flight in total if you use every one of them.
The controls, each a number from -1 to 1:
forward airspeed demand. -1 is minimum power (about 4 m/s), +1 is full
(about 16 m/s). Flapping hard drains flight energy, and a tired crow
has less speed and climb authority than a rested one.
turn rate of turn, up to about 1.75 rad/s at full deflection and full
speed. The crow banks into it, which adds a little more.
pitch nose attitude, up to about 0.72 rad. Pitch trades airspeed for
height and back; it is not the climb control.
climb powered vertical demand, up to about 5 m/s at full deflection.
glide true or false. Gliding recovers flight energy and holds about
7.5 m/s, but you give up powered climb while you do it.
The world:
The waypoint counts as reached within {radius:g} m in three dimensions, and pays a
large bonus when you get there.
You must stay between {floor:g} m and {ceiling:g} m altitude and inside +/-{bound:g} m in x and z.
Touching any of those ends the flight immediately and costs more than the
waypoint was worth.
Every step costs a little time, and power, turn, pitch and climb demand each
cost a little more. Pointing away from the waypoint costs a little.
Reply with ONE ```json code block and nothing else that matters:
```json
{{"forward": 0.5, "turn": 0.0, "pitch": 0.0, "climb": 0.0, "glide": false, "hold": 6}}
```
Any control you leave out keeps the value it already had. A reply with no readable
JSON object holds your last controls for {default_hold} steps and costs you the turn — there
is no way to pause."""
def _degrees(radians: float) -> float:
return math.degrees(radians)
def _wrap(value: float) -> float:
return (value + math.pi) % (2 * math.pi) - math.pi
class CrowNavConfig(SpatialConfig):
"""Crow-nav's knobs are the shared ones. The defaults are the measured ones and
a config that changes `max_turns` or `max_hold` is changing the environment, not
tuning it — see the two tables in this module's docstring."""
class CrowNavTaskset(TeraSpatialTaskset, vf.Taskset[SpatialTask, CrowNavConfig]):
"""Sixteen waypoint flights over the four public crow scenarios.
The generic form is not decoration: verifiers v1 resolves a taskset's config
through `__orig_bases__`, so `vf.Taskset[SpatialTask, CrowNavConfig]` in the
bases is the whole of the wiring. There is no `CONFIG` classvar to set, and a
taskset that omits this reads every per-environment setting off the base config
and silently ignores the TOML.
"""
ENV_ID: ClassVar[str] = "crow-nav-v1"
def mission(self, manifest: dict[str, Any], data: SpatialData) -> str:
return MISSION.format(
step=manifest["fixedStepSeconds"],
turns=data.max_turns,
max_hold=data.max_hold,
default_hold=data.default_hold,
budget=data.max_turns * data.max_hold,
radius=2.5,
floor=2,
ceiling=40,
bound=120,
)
def board(self, view: TurnView) -> str:
"""The state vector, rendered as a panel a reader could fly on.
Every number here is the environment's own published observation or plain
arithmetic over it: `distanceToGoalM` and the deltas are fields on the
manifest, and the bearing is `atan2` of two of them against a third. That
trigonometry is given away deliberately. What crow-nav measures is whether a
model can fly a body with momentum to a point inside a turn budget, not
whether it can do `atan2` in its head with thinking switched off — and the
scripted controller the reward is normalised against reads exactly these
fields, so nothing here moves the denominator.
What it does NOT contain is anything the simulator has not already
published: this is a dict that crossed a JSON pipe, with no handle in it to
walk back to the model.
"""
o = view.observation
bearing = _degrees(_wrap(math.atan2(-o["deltaX"], -o["deltaZ"]) - o["yaw"]))
contact = o["altitudeBoundContact"]
seconds = view.steps * 0.1
lines = [
f"turn {view.turn} of {view.turn + view.turns_left - 1}"
f" · step {view.steps} of {view.budget}"
f" · {seconds:.1f} s flown",
"",
f"position x {o['x']:>8.1f} y {o['y']:>6.1f} z {o['z']:>8.1f}"
f" altitude {o['y']:.1f} m of 240",
f"attitude yaw {_degrees(o['yaw']):>6.0f}° pitch {_degrees(o['pitch']):>4.0f}°"
f" speed {o['speedMps']:.1f} m/s vertical {o['verticalSpeedMps']:+.1f} m/s",
f"waypoint x {o['goalX']:>8.1f} y {o['goalY']:>6.1f} z {o['goalZ']:>8.1f}",
f" {o['distanceToGoalM']:.1f} m away · {o['deltaY']:+.1f} m of height to make up"
f" · bearing {bearing:+.0f}° (a positive turn closes it)",
f"envelope {'clear' if contact == 'none' else contact + ' altitude bound — you are on it'}",
]
if view.last is None:
lines.append("controls at rest; you have not flown yet")
else:
action = view.last.action
lines.append(
"you flew "
+ " ".join(
f"{name} {_show(action.get(name, 0))}"
for name in ("forward", "turn", "pitch", "climb", "glide")
)
+ f" held {view.last.hold} step{'s' if view.last.hold != 1 else ''}"
)
if view.last.problem:
lines.append(f"{view.last.problem}, so those were held again")
lines.append("")
lines.append(
f"{view.turns_left} repl{'y' if view.turns_left == 1 else 'ies'} left, "
f"{view.budget - view.steps} steps of flight. Your controls?"
)
return "\n".join(lines)
def _show(value: Any) -> str:
if isinstance(value, bool):
return "yes" if value else "no"
return f"{float(value):+.2f}"
__all__ = ["CrowNavConfig", "CrowNavTaskset"]
@@ -1,19 +0,0 @@
"""tera-spatial — the bridge from Arena's Python harness to Tera's TypeScript arena.
Four spatial environments (`drive-101-v1`, `office-nav-v1`, `crow-nav-v1`,
`california-flight-v1`) live in the `tera` repository as renderer-independent
TypeScript. This package vendors their import closure, executes it in a resident
`node` worker, and never reimplements any of it. The tasksets that sit on top of
this bridge land after it.
"""
from tera_spatial.bridge import TeraEpisode, TeraError, TeraWorker
ENVIRONMENT_IDS = (
"drive-101-v1",
"office-nav-v1",
"crow-nav-v1",
"california-flight-v1",
)
__all__ = ["ENVIRONMENT_IDS", "TeraEpisode", "TeraError", "TeraWorker"]
@@ -1,249 +0,0 @@
"""The Python side of the Tera bridge.
One resident `node` process, NDJSON over its stdio, and a rule: **never
recompute in Python a number that came out of TypeScript.** Rewards, checksums,
scenario materialisation and the baseline denominators are all computed once by
the vendored `tera.arena/v1` code and only ever transported here. Python's job
is transport and policy, never simulation.
with TeraWorker() as worker:
env = worker.open("crow-nav-v1", seed=115, scenario={"split": "train"})
env.step({"forward": 1.0, "yaw": 0.0, "climb": 0.2})
trace = env.trace()
worker.replay("crow-nav-v1", trace) # raises TeraError on divergence
The worker starts in ~92 ms and answers a round trip in ~19 us, so it is opened
once per eval process and shared by every rollout in it. It is
deliberately not an HTTP service: there is no port to collide, nothing to
authenticate, and no way to be talking to a build other than the one in this
wheel.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import threading
from pathlib import Path
from typing import Any
from tera_spatial import hashes
WORKER = Path(__file__).resolve().parent / "worker.mjs"
#: Node type-strips raw `.ts` natively from 22.6 (behind a flag) and 23.6 (on by
#: default). Below that the vendored closure will not import at all, and the
#: failure mode is a confusing syntax error rather than a version complaint.
MINIMUM_NODE = (22, 18, 0)
class TeraError(RuntimeError):
"""An error raised inside TypeScript and carried across the pipe.
A tampered trace, a stepped-past-terminal episode and an unknown scenario id
all arrive this way. They are the worker behaving correctly, so they are an
exception here and not a dead subprocess.
"""
def __init__(self, message: str, error_type: str = "Error") -> None:
super().__init__(message)
self.error_type = error_type
def node_executable() -> str:
node = os.environ.get("TERA_NODE") or shutil.which("node")
if not node:
raise RuntimeError(
"the Tera bridge needs `node` on PATH (or TERA_NODE set); the vendored "
"environments are TypeScript and are executed, not translated"
)
return node
def node_version(node: str | None = None) -> tuple[int, ...]:
raw = subprocess.run(
[node or node_executable(), "--version"], capture_output=True, text=True, check=True
).stdout.strip()
return tuple(int(part) for part in raw.lstrip("v").split(".")[:3])
class TeraWorker:
"""A resident `node` process speaking NDJSON.
Not thread-safe by accident — a lock serialises the request/response pairs,
because the protocol is one line in and one line out and interleaving two
callers would hand each the other's answer.
"""
def __init__(self, *, node: str | None = None, verify_vendor: bool = True) -> None:
if verify_vendor:
problems = hashes.verify()
if problems:
raise RuntimeError(
"vendored Tera sources do not match hashes.py; re-run "
"scripts/sync_tera.py:\n " + "\n ".join(problems)
)
self._node = node or node_executable()
version = node_version(self._node)
if version < MINIMUM_NODE:
raise RuntimeError(
f"node {'.'.join(map(str, version))} cannot type-strip TypeScript; "
f"the bridge needs >= {'.'.join(map(str, MINIMUM_NODE))}"
)
self._lock = threading.Lock()
self._counter = 0
self._handles = 0
self._process = subprocess.Popen(
[self._node, str(WORKER)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
ready = self._readline()
if not ready.get("ok"):
raise RuntimeError(f"tera worker failed to start: {ready}")
# -- protocol ---------------------------------------------------------
def _readline(self) -> dict[str, Any]:
assert self._process.stdout is not None
line = self._process.stdout.readline()
if not line:
stderr = self._process.stderr.read() if self._process.stderr else ""
raise RuntimeError(f"tera worker exited ({self._process.poll()}): {stderr.strip()}")
return json.loads(line)
def request(self, op: str, **payload: Any) -> Any:
with self._lock:
self._counter += 1
request_id = self._counter
assert self._process.stdin is not None
self._process.stdin.write(json.dumps({"id": request_id, "op": op, **payload}) + "\n")
self._process.stdin.flush()
response = self._readline()
if response.get("id") != request_id:
raise RuntimeError(f"tera worker response out of order: {response}")
if not response.get("ok"):
raise TeraError(response.get("error", "unknown"), response.get("errorType", "Error"))
return response["result"]
# -- environments -----------------------------------------------------
def manifests(self) -> list[dict[str, Any]]:
return self.request("manifests")["manifests"]
def source_hashes(self) -> dict[str, dict[str, str]]:
return self.request("sourceHashes")["sourceHashes"]
def checksum(self, value: Any) -> str:
"""Tera's canonical FNV-1a-64, computed by Tera.
Python has no business owning a second implementation of this — the
checksum is the thing the two runtimes have to agree about.
"""
return self.request("checksum", value=value)["checksum"]
def open(self, env_id: str, seed: int, scenario: str | dict[str, Any]) -> TeraEpisode:
self._handles += 1
handle = f"h{self._handles}"
result = self.request("reset", handle=handle, env=env_id, seed=seed, scenario=scenario)
return TeraEpisode(self, handle, env_id, result)
def replay(self, env_id: str, trace: dict[str, Any]) -> dict[str, Any]:
"""Re-execute a trace in a fresh environment. Raises `TeraError` on any divergence."""
return self.request("replay", env=env_id, trace=trace)
def oracle(
self,
env_id: str,
seed: int,
scenario: str | dict[str, Any],
policy: str = "scripted",
max_steps: int | None = None,
hold: int = 1,
) -> dict[str, Any]:
"""Run one of the baselines Tera exports, inside Tera.
The scripted return is the denominator every reward here is normalised
against, so it is not a thing to translate: `src/arena/index.ts` already
exports all four policies and the worker calls them.
`hold` is how many simulation steps one decision is held for. It is 1 for
the denominator — the baseline flying at the simulator's own rate — and
larger when the question is what that same policy scores at a language
model's refresh rate. See `measure_hold.py`.
"""
return self.request(
"oracle",
env=env_id,
seed=seed,
scenario=scenario,
policy=policy,
maxSteps=max_steps,
hold=hold,
)
# -- lifecycle --------------------------------------------------------
def close(self) -> None:
if self._process.poll() is None:
try:
assert self._process.stdin is not None
self._process.stdin.close()
except (BrokenPipeError, ValueError):
pass
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait()
def __enter__(self) -> TeraWorker:
return self
def __exit__(self, *_: object) -> None:
self.close()
class TeraEpisode:
"""One live episode inside the worker, addressed by handle."""
def __init__(
self, worker: TeraWorker, handle: str, env_id: str, reset: dict[str, Any]
) -> None:
self._worker = worker
self._handle = handle
self.env_id = env_id
self.observation = reset["observation"]
self.info = reset["info"]
def step(self, action: dict[str, Any]) -> dict[str, Any]:
result = self._worker.request("step", handle=self._handle, action=action)
self.observation = result["observation"]
self.info = result["info"]
return result
def snapshot(self) -> dict[str, Any]:
return self._worker.request("snapshot", handle=self._handle)["snapshot"]
def restore(self, snapshot: dict[str, Any]) -> dict[str, Any]:
result = self._worker.request("restore", handle=self._handle, snapshot=snapshot)
self.observation = result["observation"]
self.info = result["info"]
return result
def trace(self) -> dict[str, Any]:
return self._worker.request("trace", handle=self._handle)["trace"]
def close(self) -> None:
self._worker.request("close", handle=self._handle)
def __enter__(self) -> TeraEpisode:
return self
def __exit__(self, *_: object) -> None:
self.close()
@@ -1,59 +0,0 @@
"""Walking the TypeScript import closure, in Python.
Both the vendoring script and the replay gate need the same answer to the same
question — *which files does `src/arena/index.ts` actually pull in, and does it
pull in anything from `node_modules`?* — so the walk lives here and neither of
them declares a file list by hand. A list you maintain is a list that goes stale
the first time somebody adds an import in `tera`.
Regex over the source rather than a parser: the closure is 23 hand-written ESM
files with explicit `.ts` specifiers and no `export * from` indirection, and a
parser would be a dependency for no extra truth. The `--check` mode of
`scripts/sync_tera.py` is what catches it if that ever stops being true.
"""
from __future__ import annotations
import posixpath
import re
from pathlib import Path
ENTRY = "src/arena/index.ts"
_STATIC = re.compile(r"""(?:^|\n)\s*(?:import|export)\b[\s\S]*?\sfrom\s+["']([^"']+)["']""")
_DYNAMIC = re.compile(r"""\bimport\s*\(\s*["']([^"']+)["']\s*\)""")
def walk(root: Path, entry: str = ENTRY) -> tuple[list[str], list[str]]:
"""Transitive relative-import closure of `entry` under `root`.
Returns `(files, bare)`. Anything in `bare` is a specifier that would need
`node_modules` at runtime, which the vendored wheel does not have.
"""
seen: set[str] = set()
bare: set[str] = set()
stack = [entry]
while stack:
relative = stack.pop()
if relative in seen:
continue
seen.add(relative)
source = (root / relative).read_text(encoding="utf-8")
for pattern in (_STATIC, _DYNAMIC):
for specifier in pattern.findall(source):
if not specifier.startswith("."):
bare.add(f"{relative} -> {specifier}")
continue
stack.append(
posixpath.normpath(posixpath.join(posixpath.dirname(relative), specifier))
)
return sorted(seen), sorted(bare)
def vendored_root() -> Path:
return Path(__file__).resolve().parent / "vendor" / "tera"
def bare_specifiers() -> list[str]:
"""Specifiers in the *vendored* tree that would need an install to resolve."""
return walk(vendored_root())[1]
@@ -1,62 +0,0 @@
"""SHA-256 of every vendored Tera source file. Generated — do not edit.
Regenerate with `scripts/sync_tera.py`. `verify()` is called by the replay
gate: the checksums a trace carries are only worth something if the code that
produced them is the code on disk.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
CLOSURE_ENTRY = 'src/arena/index.ts'
CLOSURE_BYTES = 282684
CLOSURE_SHA256 = 'sha256:f833df14c0cc751f50c67e7005c9724b647e7716a28f084d4fad63a802fdda19'
VENDORED_FILES: dict[str, str] = {
'src/actors/controller.ts': 'sha256:45e7651af8041a9422b492885a360d96822594ea52bb7b7afce5fe9af8635d22',
'src/aircraft/controller.ts': 'sha256:1a702cb9f6c301731e5df1216346d56e26b00bfb73b1e4f7bde1ed09f81b6ebe',
'src/arena/base.ts': 'sha256:e52e40bf41f10a28a68a5fac33e2dc6ce54c42c53f8a48aa4a512a15c053979c',
'src/arena/californiaFlight.ts': 'sha256:5b8b124b1217f20826b3995bda927374aa24fb805bd2a5836234870cbdf09072',
'src/arena/checksum.ts': 'sha256:c070bebd3456af4e3354a44bb3adea08e6213608203bd4406c400c3329307d14',
'src/arena/crowNav.ts': 'sha256:9ebef2f39783f89a85460c333a3ce7bb3c02f834ba3edb5febe828d15b12b08c',
'src/arena/drive101.ts': 'sha256:33e7d74dca8d9c7ceb0aecccbb76c42a6546c8fc990ce700c1230cc463b4ca48',
'src/arena/index.ts': 'sha256:dfe54d981f7dfb5a59c26799b3dd16475467f29c4fdc586c6ea884bc58340c3c',
'src/arena/officeNav.ts': 'sha256:ac51827841c58829f0ea6b16e2486f9233a4653dcc7fb0a694fc19a36c0e46cb',
'src/arena/random.ts': 'sha256:c0716ff67aa966d4f4e0d597edf06f1a54b7dd400988a3d2f28afb4003c22cce',
'src/arena/scenarios.ts': 'sha256:e1e4f61b618400c8caeea3374e2e7ed49f37cdb71c25a97b979ef1436596ca9c',
'src/arena/sourceHashes.ts': 'sha256:148d3b95fdd28f477ec71bf27dec0954a6673c03e892e0b146bc1c7f27eb5fe3',
'src/arena/types.ts': 'sha256:e599f1876460547f1a375fb3843ab3c3af8c982799e7cb18cac64ff4694458ab',
'src/engine/types.ts': 'sha256:0aec7e03740f413709420753dbca599750500aa34997a1692e1d718b567b3dc6',
'src/interiors/plan.ts': 'sha256:d4dd89dc7781c85c8bb75026a3c49c8f964f3632049b96fb6b73f9f27275223b',
'src/interiors/types.ts': 'sha256:1eae1e28252f1b5360e1ea617d04ab084a138b8511e9c18df75a356b14309953',
'src/interiors/walker.ts': 'sha256:b0fb9e41f615ddd2bfe5552fe07a86664b3d9cc63d9dcc5f8fa4f8ef6499a241',
'src/offices/frontier-valley.ts': 'sha256:0a22b187241659fae484ae7491cd7f5d51bcd876b2a99425d8c4fda78764707b',
'src/offices/sites.ts': 'sha256:14cad3b0d3d9161f543782642d92a67ae4efeb4a35354d483dbdb279b56ff345',
'src/transport/california.ts': 'sha256:42fe3bf0fb53e791ecf600f8076fd9d49e849638fab12018d5a23f630e8b41b4',
'src/transport/types.ts': 'sha256:de5b1030f85f4d17bb4c670ebbeac21fdaafe434f5c11c7157726e4c018e9f48',
'src/transport/vehicleController.ts': 'sha256:7896b0cbed5ba5235fe01368842419b57d07c4e6fb1b8b293db8a31974ccc863',
'src/transport/vehicleSim.ts': 'sha256:3ffbb67cbef9a0a117385598e67d5ebe643ee3744f19909cc9972a1611808fff',
}
VENDOR_ROOT = Path(__file__).resolve().parent / "vendor" / "tera"
def verify() -> list[str]:
"""Return a list of complaints about the vendored tree. Empty means clean."""
problems: list[str] = []
for relative, expected in VENDORED_FILES.items():
path = VENDOR_ROOT / relative
if not path.is_file():
problems.append(f"missing: {relative}")
continue
actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
if actual != expected:
problems.append(f"modified: {relative} ({actual} != {expected})")
for path in sorted(VENDOR_ROOT.rglob("*")):
if path.is_file():
relative = path.relative_to(VENDOR_ROOT).as_posix()
if relative not in VENDORED_FILES:
problems.append(f"unrecorded: {relative}")
return problems
@@ -1,578 +0,0 @@
"""The part of a Tera spatial taskset that is not about crows.
A language model cannot emit sixty control vectors a second, and pretending
otherwise is the only real design question these environments pose. The answer
this file implements: **one control vector per reply, held for a number of
simulation steps the model itself chooses.** A turn is a decision, not a frame,
and the two integers that decide everything — how many turns, and how long one
of them may be held — are measured, not guessed. `measure_hold.py` is that
measurement.
The alternative, a short program executed open-loop, was rejected by the same
measurement: Tera's own scripted baseline flown at one decision every 32 steps
scores -2.807 against 5.484 at every frame, and reaches the waypoint 0% of the
time. A plan is not a policy in a world with momentum.
What lives here is everything a second and third spatial taskset will want
unchanged: the resident worker, the reply grammar, the episode loop, and the
reward mapping. What does not live here is any rendering or any prompt — those
are the environment, and each one writes its own on `TeraSpatialTaskset`.
Two rules the file exists to keep:
never recompute in Python a number that came out of TypeScript
the return, the per-step checksums and the denominator all arrive from the
worker. `oracle_return` is Tera's own scripted baseline, run by Tera.
the model sees the observation and nothing behind it
the dict handed to a renderer is the environment's published observation,
already across a JSON pipe. There is no live handle in it to walk back to
the simulator, which is the leak `grand-exchange-live` found in its own
per-turn view: a `market` attribute that carried the whole held-out series
and paid a foresight policy +9.4% over the oracle.
"""
from __future__ import annotations
import asyncio
import atexit
import json
import math
import re
import threading
from dataclasses import dataclass, field
from typing import Any, ClassVar
from pydantic import Field
import verifiers.v1 as vf
from tera_spatial.bridge import TeraEpisode, TeraError, TeraWorker
_BLOCK = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
#: How much of the baseline's return counts as full marks. The plateau around a
#: proportional controller and everything above it all score 1.000; the same nine
#: tenths `grand-exchange` uses, for the same reason — a denominator you have to
#: match exactly is an imitation score, not a capability one.
BAND = 0.90
# --- the resident worker ---------------------------------------------------
_worker: TeraWorker | None = None
_worker_lock = threading.Lock()
def shared_worker() -> TeraWorker:
"""The one `node` process this eval talks to.
It costs ~92 ms to start and ~19 us per round trip, so it is opened once and
shared: a worker per rollout would spend more time importing TypeScript than
simulating. `TeraWorker.request` serialises itself, and every caller here
reaches it through `asyncio.to_thread`, so concurrent episodes queue on the
pipe instead of on the event loop.
"""
global _worker
with _worker_lock:
if _worker is None:
_worker = TeraWorker()
atexit.register(_worker.close)
return _worker
# --- the reply grammar -----------------------------------------------------
@dataclass(frozen=True)
class Command:
"""One reply, as the simulator will receive it."""
action: dict[str, Any]
hold: int
problem: str | None = None
"""What was unreadable about the reply, in words the model will be shown. None
when the reply was fine."""
def parse_command(
reply: str,
*,
fields: tuple[str, ...],
max_hold: int,
default_hold: int,
previous: Command | None,
inaction: dict[str, Any],
) -> Command:
"""The last JSON object in the reply, read against the manifest's action fields.
Every field is optional and every one that is missing keeps whatever the last
command flew — a crow does not stop being a crow because a reply was short.
`hold` is clipped into `[1, max_hold]`.
An unreadable reply is not an error and does not raise: it flies the previous
command for `default_hold` steps and says so on the next observation. Silence
is the one thing it must not be, because a reply that costs nothing is a reply
the turn budget does not bind. Raising instead would turn a formatting slip
into a crashed rollout, which scores nothing at all rather than scoring badly.
`json.loads` is not strict JSON — it accepts `Infinity` and `NaN` as bare
literals and overflows `1e309` to `inf` — so non-finite numbers are dropped
rather than carried into a reward. `RecursionError` and `OverflowError` are
not `ValueError`, and both are reachable from a reply a model can emit.
"""
carried = dict(previous.action) if previous is not None else dict(inaction)
blocks = _BLOCK.findall(reply or "")
raw = blocks[-1] if blocks else (reply or "")
try:
parsed = json.loads(raw.strip())
except (ValueError, RecursionError):
return Command(carried, default_hold, "your last reply held no JSON object")
if not isinstance(parsed, dict):
return Command(carried, default_hold, "your last reply held no JSON object")
action = dict(carried)
read = 0
for name in fields:
if name not in parsed:
continue
value = parsed[name]
if isinstance(value, bool):
action[name] = value
read += 1
elif isinstance(value, (int, float)):
try:
if math.isfinite(value):
action[name] = float(value)
read += 1
except OverflowError:
continue
hold = default_hold
raw_hold = parsed.get("hold")
if isinstance(raw_hold, (int, float)) and not isinstance(raw_hold, bool):
try:
if math.isfinite(raw_hold):
hold = max(1, min(max_hold, int(raw_hold)))
except (OverflowError, ValueError):
hold = default_hold
if read == 0:
return Command(action, hold, "your JSON named none of the controls")
return Command(action, hold)
# --- the episode -----------------------------------------------------------
@dataclass
class Flight:
"""One episode inside the worker, plus what Python is allowed to remember of it.
Every number on this object came out of TypeScript. Nothing here adds two
rewards together or decides that an episode ended — `terminal_reason` is the
simulator's word, carried.
"""
episode: TeraEpisode
max_turns: int
budget: int
observation: dict[str, Any] = field(default_factory=dict)
ts_return: float = 0.0
steps: int = 0
turns: int = 0
malformed: int = 0
holds: list[int] = field(default_factory=list)
terminal_reason: str | None = None
done: bool = False
last: Command | None = None
def fly(self, command: Command) -> None:
"""Hold one command for its steps, stopping the moment the simulator does."""
self.turns += 1
self.holds.append(command.hold)
self.malformed += command.problem is not None
self.last = command
for _ in range(min(command.hold, self.budget - self.steps)):
result = self.episode.step(command.action)
self.steps += 1
self.ts_return += result["reward"]
self.observation = result["observation"]
if result["terminated"] or result["truncated"]:
self.terminal_reason = result["info"]["terminalReason"]
self.done = True
return
if self.steps >= self.budget or self.turns >= self.max_turns:
# Out of budget, not out of world: the episode is unfinished and the
# simulator has said nothing. `terminal_reason` stays None, which is
# what makes `gate` zero.
self.done = True
# --- the taskset and the env -----------------------------------------------
class SpatialData(vf.TaskData):
"""What rebuilds the episode, and nothing the model is not shown.
The scenario is public and the seed reproduces it exactly, so there is no
held-out corpus to leak here — what the model does not see is the next
hundredth of a second, which no field can carry. `oracle_return` is the
denominator, not the answer: it is what Tera's scripted controller scored on
this scenario, and a model that knew the number would still have to fly.
"""
env_id: str
seed: int
scenario_id: str
split: str
max_turns: int
max_hold: int
default_hold: int
band: float
oracle_return: float
oracle_steps: int
class SpatialTask(vf.Task[SpatialData]):
pass
class SpatialConfig(vf.TasksetConfig):
num_tasks: int = Field(16, ge=1)
max_turns: int = Field(16, ge=1)
"""Replies the model gets. Measured, not chosen: `measure_hold.py` puts the
scripted baseline at 0.031 return and 0% goals on one turn, 3.420 and 44% on
eight, and 5.479 and 100% on sixteen — against 5.484 at every frame. Sixteen
is where the ceiling becomes reachable and one turn below it is where it is
not, which is house rule 4 in two integers."""
max_hold: int = Field(12, ge=1)
"""Simulation steps one reply may be held for. Twelve is the longest hold at
which the baseline still reaches most waypoints (88%); at sixteen it is 75%
and at thirty-two it is none. `max_turns x max_hold` is 192 against a 300-step
simulator cap, so the budget that binds is the turn budget and never the
simulator's own."""
default_hold: int = Field(6, ge=1)
"""What an unreadable reply is held for. Not zero: a reply that costs no steps
is a free turn, and a free turn is a budget that does not bind."""
band: float = Field(BAND, gt=0.0, le=1.0)
splits: list[str] = Field(default_factory=lambda: ["train", "dev"])
seed_base: int = Field(115, ge=0)
@dataclass(frozen=True)
class TurnView:
"""Everything a renderer may read. Deliberately a plain dict of floats and no
handle: whatever is in here is what the model sees, so nothing that is not
already across the JSON pipe can get in by accident."""
observation: dict[str, Any]
turn: int
turns_left: int
steps: int
budget: int
max_hold: int
last: Command | None
terminal_reason: str | None = None
class TeraSpatialTaskset(vf.Taskset[SpatialTask, SpatialConfig]):
"""Tasks over one Tera environment: a scenario, a seed, and a denominator.
Subclasses pin `ENV_ID` and write the two things that ARE the environment as
far as a language model is concerned — the mission it is briefed with, and how
a state vector is rendered as something a reader can fly on. Everything else
is the same for every spatial environment and is settled here.
"""
ENV_ID: ClassVar[str] = ""
def mission(self, manifest: dict[str, Any], data: SpatialData) -> str:
"""The system prompt: the vehicle, the controls, the grammar, the budget."""
raise NotImplementedError
def board(self, view: TurnView) -> str:
"""One turn's observation, rendered."""
raise NotImplementedError
# -- the shared half ----------------------------------------------------
def scenarios(self, manifest: dict[str, Any]) -> list[tuple[str, str]]:
return [
(split, scenario_id)
for split in self.config.splits
for scenario_id in manifest["scenarioIds"].get(split, ())
]
def load(self) -> list[SpatialTask]:
worker = shared_worker()
manifest = next(m for m in worker.manifests() if m["id"] == self.ENV_ID)
rota = self.scenarios(manifest)
if not rota:
raise ValueError(
f"{self.ENV_ID} has no scenarios in splits {self.config.splits!r}"
)
tasks = []
for i in range(self.config.num_tasks):
split, scenario_id = rota[i % len(rota)]
seed = self.config.seed_base + i
request = {"split": split, "id": scenario_id}
# The denominator is Tera's, run by Tera, on this exact scenario. A
# Python port of the four exported baselines would put the number every
# reward is divided by on the far side of a hand translation.
oracle = worker.oracle(self.ENV_ID, seed, request)
start = worker.open(self.ENV_ID, seed, request)
observation = start.observation
start.close()
data = SpatialData(
idx=i,
name=f"{scenario_id}@{seed}",
env_id=self.ENV_ID,
seed=seed,
scenario_id=scenario_id,
split=split,
max_turns=self.config.max_turns,
max_hold=self.config.max_hold,
default_hold=self.config.default_hold,
band=self.config.band,
oracle_return=float(oracle["cumulativeReward"]),
oracle_steps=int(oracle["steps"]),
)
view = TurnView(
observation=observation,
turn=1,
turns_left=self.config.max_turns,
steps=0,
budget=self.config.max_turns * self.config.max_hold,
max_hold=self.config.max_hold,
last=None,
)
tasks.append(
SpatialTask(
data.model_copy(
update={
"prompt": self.board(view),
"system_prompt": self.mission(manifest, data),
}
),
self.config.task,
)
)
return tasks
class TeraSpatialEnvConfig(vf.EnvConfig):
"""One seat, named `agent`.
The name is the config key: `EnvConfig` is `extra="forbid"`, so a role called
anything else parses `[env.agent.harness]` as an unknown field and the run does
not load. It matches `SingleAgentEnvConfig` on purpose — every Arena config
already addresses `--env.agent.*`.
"""
agent: vf.AgentConfig = vf.AgentConfig()
class TeraSpatialEnv(vf.Env[TeraSpatialEnvConfig]):
"""Fly one episode, one reply at a time, and grade what the simulator recorded.
The prompt is the first observation, so the model speaks first and the opening
reply is taken with a bare `turn()`. Every later turn hands it the state that
its own last command produced. A `terminated` Segment — a context limit, a
harness that gave up — ends the exchange mid-flight; the episode is still
graded, on the steps it actually flew.
"""
async def run(self, task: vf.Task, agents: vf.Agents) -> None:
data: SpatialData = task.data # type: ignore[assignment]
taskset: TeraSpatialTaskset = self.taskset # type: ignore[assignment]
worker = shared_worker()
manifest = next(m for m in worker.manifests() if m["id"] == data.env_id)
fields = tuple(manifest["actionFields"])
inaction = _inaction(manifest)
request = {"split": data.split, "id": data.scenario_id}
episode = await asyncio.to_thread(
worker.open, data.env_id, data.seed, request
)
flight = Flight(
episode=episode,
max_turns=data.max_turns,
budget=data.max_turns * data.max_hold,
observation=episode.observation,
)
try:
async with agents.agent.interaction(task) as interaction:
segment = await interaction.turn()
while not segment.terminated and not flight.done:
command = parse_command(
segment.last_reply,
fields=fields,
max_hold=data.max_hold,
default_hold=data.default_hold,
previous=flight.last,
inaction=inaction,
)
await asyncio.to_thread(flight.fly, command)
if flight.done:
break
view = TurnView(
observation=flight.observation,
turn=flight.turns + 1,
turns_left=data.max_turns - flight.turns,
steps=flight.steps,
budget=flight.budget,
max_hold=data.max_hold,
last=command,
)
segment = await interaction.turn(taskset.board(view))
# Graded before the interaction closes, not after. Closing is what
# finishes the rollout and prints its `reward=` line, and a reward
# recorded on the far side of that lands in `traces.jsonl` but not
# in the log — which would make `eval.log` and the file disagree
# about the same episode, and the runbook cross-checks one against
# the other.
envelope = await asyncio.to_thread(episode.trace)
rewards, metrics, info = await asyncio.to_thread(
score, data, flight, envelope, worker
)
trace = interaction.trace
for name, (value, weight) in rewards.items():
trace.record_reward(name, value, weight)
trace.record_metrics(metrics)
trace.info["tera"] = info
finally:
with _quiet():
await asyncio.to_thread(episode.close)
def _inaction(manifest: dict[str, Any]) -> dict[str, Any]:
"""A neutral action in the manifest's own field names. Only ever the seed for
turn one's carry-forward — the environment's published inaction baseline is a
different thing and belongs to `probe.py`, not to a fallback."""
return {name: 0.0 for name in manifest["actionFields"]}
class _quiet:
"""Closing a handle must not lose an episode that already flew."""
def __enter__(self) -> None:
return None
def __exit__(self, exc_type, *_: object) -> bool:
return exc_type is not None and issubclass(exc_type, (TeraError, OSError))
def score(
data: SpatialData,
flight: Flight,
envelope: dict[str, Any],
worker: TeraWorker,
) -> tuple[dict[str, tuple[float, float]], dict[str, float], dict[str, Any]]:
"""Two rewards, both products, from numbers TypeScript computed.
Returns `(rewards, metrics, info)` rather than writing to a trace, so the
arithmetic can be exercised without an agent, a model or a rollout — the
reward is the part of an environment that has to be right when nothing is
watching.
`flight` is what the episode returned against nine tenths of what Tera's own
controller returned on the same scenario. It is smooth, and goal attainment is
already the largest term inside it — the simulator pays +3 of a ~5.5 return for
reaching the waypoint — so a near-miss scores about four tenths and a wander
scores nothing.
`economy` is that same quality multiplied by having arrived and by how directly.
Goal attainment MULTIPLIES here and appears nowhere as a term of its own: the
success bonus is already inside the return, and adding it again beside the sum
is the double count `probe.py` records having paid for. Efficiency multiplies
too, because time-to-goal is not a thing a flight that never arrived can be
good at — as a term standing beside `flight` it would pay a crash for being
quick.
Floor and ceiling: minimum-power inaction returns about -4.2, which clips to
exactly 0.000 on both. The scripted baseline flown inside this budget returns
5.479 against a 4.936 target and arrives in the baseline's own step count,
which is 1.000 on both.
"""
target = data.band * data.oracle_return
quality = 0.0 if target <= 0 else max(0.0, min(1.0, flight.ts_return / target))
gate = 1.0 if flight.terminal_reason == "goal" else 0.0
steps = max(flight.steps, 1)
step_ratio = max(0.0, min(1.0, data.oracle_steps / max(steps, data.oracle_steps)))
rewards = {
"flight": (quality, 0.70),
"economy": (gate * quality * step_ratio, 0.30),
}
metrics = {
"gate": gate,
"quality": quality,
"step_ratio": step_ratio,
"ts_return": flight.ts_return,
"oracle_return": data.oracle_return,
"oracle_steps": float(data.oracle_steps),
"steps": float(flight.steps),
"turns_used": float(flight.turns),
"turns_granted": float(data.max_turns),
"hold_mean": (
sum(flight.holds) / len(flight.holds) if flight.holds else 0.0
),
"malformed_turns": float(flight.malformed),
"safety_contact": float(
flight.terminal_reason is not None
and flight.terminal_reason not in ("goal", "max-steps")
),
# The bridge's whole claim, asserted once per episode rather than once
# per commit: the return that just became a reward is a return the
# simulator will reproduce from the actions alone.
"replay_ok": float(_replays(worker, data.env_id, envelope, flight)),
}
info = {
"env_id": data.env_id,
"scenario_id": data.scenario_id,
"split": data.split,
"seed": data.seed,
"terminal_reason": flight.terminal_reason,
"final_state_checksum": envelope["finalStateChecksum"],
"env_hash": envelope["envHash"],
"scenario_hash": envelope["scenarioHash"],
}
return rewards, metrics, info
def _replays(
worker: TeraWorker, env_id: str, envelope: dict[str, Any], flight: Flight
) -> bool:
"""Re-run the trace in a fresh environment and require the same last bit.
A divergence is recorded, never raised: an episode that flew is data, and
losing it to an assertion would hide the very thing this metric exists to
surface. `probe.py` is where a zero here becomes an exit code.
"""
try:
replayed = worker.replay(env_id, envelope)
except TeraError:
return False
return (
replayed["finalStateChecksum"] == envelope["finalStateChecksum"]
and replayed["steps"] == flight.steps
and abs(replayed["cumulativeReward"] - flight.ts_return) < 1e-12
)
__all__ = [
"BAND",
"Command",
"Flight",
"SpatialConfig",
"SpatialData",
"SpatialTask",
"TeraSpatialEnv",
"TeraSpatialEnvConfig",
"TeraSpatialTaskset",
"TurnView",
"parse_command",
"score",
"shared_worker",
]
@@ -1,820 +0,0 @@
/**
* Renderer-independent playable actor simulation.
*
* DOM, touch and gamepad adapters reduce their input to `ActorActionSnapshot`.
* This state machine then advances on a deterministic fixed clock and publishes
* metre-scale transforms that any Three.js rig can consume. At yaw zero actors
* face -Z, matching the procedural assets under `assets/actors`.
*/
const TWO_PI = Math.PI * 2;
export type ActorKind = "humanoid" | "dog" | "crow";
export type ActorMode = "ground" | "flight";
export type ActorModeRequest = "none" | ActorMode;
export type ActorKindRequest = "none" | ActorKind;
export type CrowFlightPoseState = "flap" | "glide" | "bank" | "tuck" | "perch";
export interface CrowWind {
/** Positive X blows east/right in the actor's metre-space adapter. */
xMps: number;
/** Positive Z blows toward the actor's rear at yaw zero. */
zMps: number;
}
export interface CrowThermal {
x: number;
z: number;
radiusM: number;
/** Maximum deterministic updraft at the core. */
liftMps: number;
}
/** JSON-safe appearance fields. URLs are references; render resources stay outside simulation. */
export interface ActorProfile {
handle?: string;
pronouns?: string;
faceImageUrl?: string;
appearance?: {
skinTone?: string;
primaryColor?: string;
accentColor?: string;
hairColor?: string;
bodyShape?: "slim" | "average" | "broad";
};
}
/** Identity follows the player when their visible actor kind changes. */
export interface ActorIdentity {
id: string;
displayName: string;
authenticated: boolean;
profile: ActorProfile;
}
/** Device-neutral held inputs and one-shot requests. All axes normalize to [-1, 1]. */
export interface ActorActionSnapshot {
/** Ground forward/back; flight airspeed demand. */
forward: number;
/** Ground strafe. Ignored in flight. */
right: number;
/** Yaw left/right. */
turn: number;
/** Crow nose-up/nose-down request. */
pitch: number;
/** Crow vertical thrust independent of its nose. */
climb: number;
sprint: boolean;
glide: boolean;
modeRequest: ActorModeRequest;
kindRequest: ActorKindRequest;
reset: boolean;
}
export const NEUTRAL_ACTOR_ACTIONS: Readonly<ActorActionSnapshot> = Object.freeze({
forward: 0,
right: 0,
turn: 0,
pitch: 0,
climb: 0,
sprint: false,
glide: false,
modeRequest: "none",
kindRequest: "none",
reset: false,
});
export interface ActorPosition {
x: number;
y: number;
z: number;
}
export interface ActorHorizontalBounds {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
export interface ActorControllerOptions {
kind: ActorKind;
identity: ActorIdentity;
position?: Partial<ActorPosition>;
yaw?: number;
/** Used only when spawning a crow in flight. */
pitch?: number;
mode?: ActorMode;
groundY?: number;
minFlightAltitude?: number;
maxFlightAltitude?: number;
walkSpeedMps?: number;
runSpeedMps?: number;
dogSpeedScale?: number;
groundTurnRateRadPerSecond?: number;
flightTurnRateRadPerSecond?: number;
maximumFlightPitchRad?: number;
minimumFlightSpeedMps?: number;
maximumFlightSpeedMps?: number;
glideSpeedMps?: number;
maximumClimbSpeedMps?: number;
/** Constant air-mass velocity. Defaults to still air. */
crowWind?: Partial<CrowWind>;
/** Bounded deterministic radial updrafts in actor metre space. */
crowThermals?: readonly CrowThermal[];
/** Normalized reserve used by powered flapping. Defaults to one. */
crowInitialEnergy?: number;
/** Reserve spent per second at a representative powered cruise. */
crowEnergyDrainPerSecond?: number;
/** Reserve recovered per second while gliding or perched. */
crowEnergyRecoveryPerSecond?: number;
/** Optional rectangle for a board or office adapter without collision geometry. */
horizontalBounds?: ActorHorizontalBounds;
fixedStepSeconds?: number;
/** Caps catch-up after a sleeping tab. */
maxFrameDeltaSeconds?: number;
}
export interface ActorControllerState extends ActorPosition {
kind: ActorKind;
mode: ActorMode;
identity: Readonly<ActorIdentity>;
yaw: number;
pitch: number;
speedMps: number;
verticalSpeedMps: number;
/** Renderer request for a cyclic walk/flap pose, wrapped to [0, 2π). */
posePhase: number;
/** Renderer request for pose intensity, in [0, 1]. */
poseAmount: number;
gliding: boolean;
/** Signed visual/turn bank in radians. */
roll: number;
/** Normalized powered-flight reserve in [0, 1]. */
flightEnergy: number;
/** Aerodynamic and thermal vertical contribution before pilot climb. */
liftMps: number;
thermalLiftMps: number;
windXMps: number;
windZMps: number;
crowPose: CrowFlightPoseState;
perched: boolean;
altitudeBoundContact: "none" | "minimum" | "maximum";
distanceM: number;
elapsedSteps: number;
}
export interface ActorControllerSnapshot extends Omit<ActorControllerState, "identity"> {
identity: ActorIdentity;
}
export interface TimedActorInputFrame {
steps: number;
actions?: Partial<ActorActionSnapshot>;
}
export interface ActorReplayResult {
trajectory: readonly ActorControllerSnapshot[];
final: ActorControllerSnapshot;
}
interface ResolvedOptions {
kind: ActorKind;
identity: Readonly<ActorIdentity>;
position: ActorPosition;
yaw: number;
pitch: number;
mode: ActorMode;
groundY: number;
minFlightAltitude: number;
maxFlightAltitude: number;
walkSpeedMps: number;
runSpeedMps: number;
dogSpeedScale: number;
groundTurnRateRadPerSecond: number;
flightTurnRateRadPerSecond: number;
maximumFlightPitchRad: number;
minimumFlightSpeedMps: number;
maximumFlightSpeedMps: number;
glideSpeedMps: number;
maximumClimbSpeedMps: number;
crowWind: CrowWind;
crowThermals: readonly CrowThermal[];
crowInitialEnergy: number;
crowEnergyDrainPerSecond: number;
crowEnergyRecoveryPerSecond: number;
horizontalBounds?: ActorHorizontalBounds;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function positive(value: number | undefined, fallback: number, name: string): number {
const result = finiteOr(value, fallback);
if (!(result > 0)) throw new RangeError(`${name} must be finite and positive`);
return result;
}
function wrapAngle(value: number): number {
return ((value + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI;
}
function wrapPhase(value: number): number {
return ((value % TWO_PI) + TWO_PI) % TWO_PI;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
function validKind(value: unknown): value is ActorKind {
return value === "humanoid" || value === "dog" || value === "crow";
}
function copyProfile(profile: ActorProfile): ActorProfile {
const appearance = profile.appearance
? {
...(profile.appearance.skinTone === undefined ? {} : { skinTone: profile.appearance.skinTone }),
...(profile.appearance.primaryColor === undefined ? {} : { primaryColor: profile.appearance.primaryColor }),
...(profile.appearance.accentColor === undefined ? {} : { accentColor: profile.appearance.accentColor }),
...(profile.appearance.hairColor === undefined ? {} : { hairColor: profile.appearance.hairColor }),
...(profile.appearance.bodyShape === undefined ? {} : { bodyShape: profile.appearance.bodyShape }),
}
: undefined;
return {
...(profile.handle === undefined ? {} : { handle: profile.handle }),
...(profile.pronouns === undefined ? {} : { pronouns: profile.pronouns }),
...(profile.faceImageUrl === undefined ? {} : { faceImageUrl: profile.faceImageUrl }),
...(appearance === undefined ? {} : { appearance }),
};
}
function checkedIdentity(identity: ActorIdentity): Readonly<ActorIdentity> {
if (!identity || typeof identity.id !== "string" || identity.id.length === 0) {
throw new RangeError("actor identity must have a non-empty string id");
}
if (typeof identity.displayName !== "string" || typeof identity.authenticated !== "boolean") {
throw new RangeError("actor identity must contain a display name and authentication flag");
}
const profile = identity.profile ?? {};
const strings = [
profile.handle,
profile.pronouns,
profile.faceImageUrl,
profile.appearance?.skinTone,
profile.appearance?.primaryColor,
profile.appearance?.accentColor,
profile.appearance?.hairColor,
];
if (strings.some((item) => item !== undefined && typeof item !== "string")) {
throw new RangeError("actor profile fields must be strings");
}
if (
profile.appearance?.bodyShape !== undefined &&
profile.appearance.bodyShape !== "slim" &&
profile.appearance.bodyShape !== "average" &&
profile.appearance.bodyShape !== "broad"
) throw new RangeError("actor body shape is invalid");
const copy: ActorIdentity = {
id: identity.id,
displayName: identity.displayName,
authenticated: identity.authenticated,
profile: copyProfile(profile),
};
if (copy.profile.appearance) Object.freeze(copy.profile.appearance);
Object.freeze(copy.profile);
return Object.freeze(copy);
}
function copyIdentity(identity: Readonly<ActorIdentity>): ActorIdentity {
return {
id: identity.id,
displayName: identity.displayName,
authenticated: identity.authenticated,
profile: copyProfile(identity.profile),
};
}
function checkedBounds(bounds: ActorHorizontalBounds | undefined): ActorHorizontalBounds | undefined {
if (!bounds) return undefined;
if (
!Number.isFinite(bounds.minX) ||
!Number.isFinite(bounds.maxX) ||
!Number.isFinite(bounds.minZ) ||
!Number.isFinite(bounds.maxZ) ||
bounds.minX >= bounds.maxX ||
bounds.minZ >= bounds.maxZ
) {
throw new RangeError("actor horizontal bounds must be finite and ordered");
}
return { ...bounds };
}
function checkedCrowWind(value: Partial<CrowWind> | undefined): CrowWind {
const xMps = finiteOr(value?.xMps, 0);
const zMps = finiteOr(value?.zMps, 0);
// A malformed adapter cannot inject hurricane-scale displacement into an
// authoritative actor step. The generous cap still covers severe weather.
return { xMps: clamp(xMps, -60, 60), zMps: clamp(zMps, -60, 60) };
}
function checkedCrowThermals(value: readonly CrowThermal[] | undefined): readonly CrowThermal[] {
if (!value) return Object.freeze([]);
if (value.length > 32) throw new RangeError("crowThermals supports at most 32 updrafts");
const checked = value.map((thermal) => {
if (
!Number.isFinite(thermal?.x) || !Number.isFinite(thermal?.z) ||
!Number.isFinite(thermal?.radiusM) || thermal.radiusM <= 0 ||
!Number.isFinite(thermal?.liftMps) || thermal.liftMps < 0
) throw new RangeError("crow thermal fields must be finite with positive radius and non-negative lift");
return Object.freeze({
x: thermal.x,
z: thermal.z,
radiusM: clamp(thermal.radiusM, 0.5, 10_000),
liftMps: clamp(thermal.liftMps, 0, 20),
});
});
return Object.freeze(checked);
}
/** Clamp malformed adapter input before it can poison authoritative state. */
export function normalizeActorActions(
actions: Partial<ActorActionSnapshot> | undefined,
): ActorActionSnapshot {
let forward = clamp(finiteOr(actions?.forward, 0), -1, 1);
let right = clamp(finiteOr(actions?.right, 0), -1, 1);
const groundLength = Math.hypot(forward, right);
if (groundLength > 1) {
forward /= groundLength;
right /= groundLength;
}
const mode = actions?.modeRequest;
const kind = actions?.kindRequest;
return {
forward,
right,
turn: clamp(finiteOr(actions?.turn, 0), -1, 1),
pitch: clamp(finiteOr(actions?.pitch, 0), -1, 1),
climb: clamp(finiteOr(actions?.climb, 0), -1, 1),
sprint: actions?.sprint === true,
glide: actions?.glide === true,
modeRequest: mode === "ground" || mode === "flight" ? mode : "none",
kindRequest: validKind(kind) ? kind : "none",
reset: actions?.reset === true,
};
}
function resolveOptions(options: ActorControllerOptions): ResolvedOptions {
if (!validKind(options.kind)) throw new RangeError("unknown actor kind");
const groundY = finiteOr(options.groundY, 0);
const minFlightAltitude = positive(options.minFlightAltitude, 0.75, "minFlightAltitude");
const maxFlightAltitude = positive(options.maxFlightAltitude, 120, "maxFlightAltitude");
if (maxFlightAltitude <= minFlightAltitude) {
throw new RangeError("maxFlightAltitude must exceed minFlightAltitude");
}
const mode: ActorMode = options.kind === "crow" && options.mode === "flight" ? "flight" : "ground";
const position = {
x: finiteOr(options.position?.x, 0),
y: mode === "flight"
? clamp(finiteOr(options.position?.y, groundY + minFlightAltitude), groundY + minFlightAltitude, groundY + maxFlightAltitude)
: groundY,
z: finiteOr(options.position?.z, 0),
};
const maximumFlightSpeedMps = positive(options.maximumFlightSpeedMps, 16, "maximumFlightSpeedMps");
const minimumFlightSpeedMps = positive(options.minimumFlightSpeedMps, 4, "minimumFlightSpeedMps");
if (maximumFlightSpeedMps < minimumFlightSpeedMps) {
throw new RangeError("maximumFlightSpeedMps must not be below minimumFlightSpeedMps");
}
const bounds = checkedBounds(options.horizontalBounds);
if (bounds) {
position.x = clamp(position.x, bounds.minX, bounds.maxX);
position.z = clamp(position.z, bounds.minZ, bounds.maxZ);
}
return {
kind: options.kind,
identity: checkedIdentity(options.identity),
position,
yaw: wrapAngle(finiteOr(options.yaw, 0)),
pitch: mode === "flight" ? finiteOr(options.pitch, 0) : 0,
mode,
groundY,
minFlightAltitude,
maxFlightAltitude,
walkSpeedMps: positive(options.walkSpeedMps, 1.7, "walkSpeedMps"),
runSpeedMps: positive(options.runSpeedMps, 4.5, "runSpeedMps"),
dogSpeedScale: positive(options.dogSpeedScale, 1.25, "dogSpeedScale"),
groundTurnRateRadPerSecond: positive(options.groundTurnRateRadPerSecond, 2.8, "groundTurnRateRadPerSecond"),
flightTurnRateRadPerSecond: positive(options.flightTurnRateRadPerSecond, 1.75, "flightTurnRateRadPerSecond"),
maximumFlightPitchRad: clamp(positive(options.maximumFlightPitchRad, 0.72, "maximumFlightPitchRad"), 0.1, Math.PI / 2 - 0.05),
minimumFlightSpeedMps,
maximumFlightSpeedMps,
glideSpeedMps: clamp(positive(options.glideSpeedMps, 7.5, "glideSpeedMps"), minimumFlightSpeedMps, maximumFlightSpeedMps),
maximumClimbSpeedMps: positive(options.maximumClimbSpeedMps, 5, "maximumClimbSpeedMps"),
crowWind: checkedCrowWind(options.crowWind),
crowThermals: checkedCrowThermals(options.crowThermals),
crowInitialEnergy: clamp(finiteOr(options.crowInitialEnergy, 1), 0, 1),
crowEnergyDrainPerSecond: clamp(
positive(options.crowEnergyDrainPerSecond, 0.006, "crowEnergyDrainPerSecond"),
0.0001,
1,
),
crowEnergyRecoveryPerSecond: clamp(
positive(options.crowEnergyRecoveryPerSecond, 0.012, "crowEnergyRecoveryPerSecond"),
0.0001,
1,
),
horizontalBounds: bounds,
fixedStepSeconds: clamp(positive(options.fixedStepSeconds, 1 / 60, "fixedStepSeconds"), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(positive(options.maxFrameDeltaSeconds, 0.25, "maxFrameDeltaSeconds"), 0.05, 1),
};
}
export class ActorController {
private readonly options: ResolvedOptions;
private readonly current: ActorControllerState;
private accumulator = 0;
constructor(options: ActorControllerOptions) {
this.options = resolveOptions(options);
this.options.pitch = clamp(this.options.pitch, -this.options.maximumFlightPitchRad, this.options.maximumFlightPitchRad);
this.current = {
kind: this.options.kind,
mode: this.options.mode,
identity: this.options.identity,
...this.options.position,
yaw: this.options.yaw,
pitch: this.options.pitch,
speedMps: 0,
verticalSpeedMps: 0,
posePhase: 0,
poseAmount: 0,
gliding: false,
roll: 0,
flightEnergy: this.options.crowInitialEnergy,
liftMps: 0,
thermalLiftMps: 0,
windXMps: this.options.crowWind.xMps,
windZMps: this.options.crowWind.zMps,
crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
perched: this.options.kind === "crow" && this.options.mode === "ground",
altitudeBoundContact: "none",
distanceM: 0,
elapsedSteps: 0,
};
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
/** Stable allocation-free view. Treat nested identity as read-only and frozen. */
state(): Readonly<ActorControllerState> {
return this.current;
}
/** Detached JSON-safe state for persistence, networking and tests. */
snapshot(): ActorControllerSnapshot {
return { ...this.current, identity: copyIdentity(this.current.identity) };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: ActorControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
if (
!numeric || !validKind(snapshot.kind) ||
(snapshot.mode !== "ground" && snapshot.mode !== "flight") ||
(snapshot.altitudeBoundContact !== "none" &&
snapshot.altitudeBoundContact !== "minimum" && snapshot.altitudeBoundContact !== "maximum") ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("actor snapshot is incompatible or invalid");
Object.assign(this.current, snapshot, { identity: checkedIdentity(snapshot.identity) });
this.applyHorizontalBounds();
if (this.current.mode === "flight" && this.current.kind === "crow") this.applyAltitudeBounds();
this.accumulator = 0;
}
/** Replace profile/identity without changing kind, pose or position. */
setIdentity(identity: ActorIdentity): void {
this.current.identity = checkedIdentity(identity);
}
/** Change visible actor while preserving identity and finite world position. */
setActorKind(kind: ActorKind): void {
if (!validKind(kind)) throw new RangeError("unknown actor kind");
if (kind === this.current.kind) return;
this.current.kind = kind;
this.current.speedMps = 0;
this.current.verticalSpeedMps = 0;
this.current.pitch = 0;
this.current.poseAmount = 0;
this.current.gliding = false;
this.current.roll = 0;
this.current.liftMps = 0;
this.current.thermalLiftMps = 0;
this.current.crowPose = kind === "crow" && this.current.mode === "ground" ? "perch" : "flap";
this.current.perched = kind === "crow" && this.current.mode === "ground";
this.current.altitudeBoundContact = "none";
if (kind !== "crow") this.land();
}
/** Request a locomotion mode without consuming a simulation step. Non-crows cannot fly. */
setMode(mode: ActorMode): void {
if (mode === "flight" && this.current.kind === "crow") this.takeOff();
else this.land();
}
/** Restore the original spawn, identity and kind and clear fractional time. */
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.position, {
kind: this.options.kind,
mode: this.options.mode,
identity: this.options.identity,
yaw: this.options.yaw,
pitch: this.options.pitch,
speedMps: 0,
verticalSpeedMps: 0,
posePhase: 0,
poseAmount: 0,
gliding: false,
roll: 0,
flightEnergy: this.options.crowInitialEnergy,
liftMps: 0,
thermalLiftMps: 0,
windXMps: this.options.crowWind.xMps,
windZMps: this.options.crowWind.zMps,
crowPose: this.options.mode === "flight" && this.options.kind === "crow" ? "glide" : "perch",
perched: this.options.kind === "crow" && this.options.mode === "ground",
altitudeBoundContact: "none",
distanceM: 0,
elapsedSteps: 0,
});
}
tick(deltaSeconds: number, actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeActorActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
stepFixed(actions: Partial<ActorActionSnapshot> = NEUTRAL_ACTOR_ACTIONS): void {
const normalized = normalizeActorActions(actions);
if (normalized.reset) {
this.reset();
return;
}
this.stepNormalized(normalized);
}
private stepNormalized(actions: ActorActionSnapshot): void {
if (actions.kindRequest !== "none") this.setActorKind(actions.kindRequest);
if (actions.modeRequest === "ground") this.land();
else if (actions.modeRequest === "flight" && this.current.kind === "crow") this.takeOff();
if (this.current.mode === "flight" && this.current.kind === "crow") this.stepFlight(actions);
else this.stepGround(actions);
this.current.elapsedSteps += 1;
}
private stepGround(actions: ActorActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
this.current.yaw = wrapAngle(
this.current.yaw + actions.turn * this.options.groundTurnRateRadPerSecond * dt,
);
const input = Math.hypot(actions.forward, actions.right);
const kindScale = this.current.kind === "dog" ? this.options.dogSpeedScale : 1;
const maximum = (actions.sprint ? this.options.runSpeedMps : this.options.walkSpeedMps) * kindScale;
const speed = maximum * input;
const dx = (actions.right * Math.cos(this.current.yaw) - actions.forward * Math.sin(this.current.yaw)) * maximum * dt;
const dz = (-actions.right * Math.sin(this.current.yaw) - actions.forward * Math.cos(this.current.yaw)) * maximum * dt;
const beforeX = this.current.x;
const beforeZ = this.current.z;
this.current.x += dx;
this.current.z += dz;
this.applyHorizontalBounds();
const moved = Math.hypot(this.current.x - beforeX, this.current.z - beforeZ);
this.current.distanceM += moved;
this.current.speedMps = speed;
this.current.verticalSpeedMps = 0;
this.current.y = this.options.groundY;
this.current.pitch = 0;
this.current.gliding = false;
this.current.roll = moveToward(this.current.roll, 0, 7 * dt);
this.current.liftMps = 0;
this.current.thermalLiftMps = 0;
this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
this.current.perched = this.current.kind === "crow";
this.current.flightEnergy = clamp(
this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * 2.5 * dt,
0,
1,
);
this.current.altitudeBoundContact = "none";
this.current.poseAmount = input;
const strideLength = this.current.kind === "dog" ? 0.54 : 0.78;
this.current.posePhase = wrapPhase(this.current.posePhase + (moved / strideLength) * TWO_PI);
}
private stepFlight(actions: ActorActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
const idleSoaring =
!actions.glide && Math.abs(actions.forward) < 0.02 && Math.abs(actions.turn) < 0.02 &&
Math.abs(actions.pitch) < 0.02 && Math.abs(actions.climb) < 0.02;
const speedFraction = clamp(
this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001),
0,
2,
);
const targetRoll = actions.turn * 0.62;
this.current.roll = moveToward(this.current.roll, targetRoll, 2.7 * dt);
this.current.yaw = wrapAngle(
this.current.yaw +
(
actions.turn * this.options.flightTurnRateRadPerSecond * (0.68 + speedFraction * 0.22) +
Math.sin(this.current.roll) * 0.34
) * dt,
);
const targetPitch = actions.pitch * this.options.maximumFlightPitchRad;
this.current.pitch = moveToward(this.current.pitch, targetPitch, 1.9 * dt);
const throttle = (actions.forward + 1) / 2;
const tucking = actions.glide && actions.forward < -0.6 && actions.pitch < -0.1;
const recovering = actions.glide;
if (recovering) {
this.current.flightEnergy = clamp(
this.current.flightEnergy + this.options.crowEnergyRecoveryPerSecond * dt,
0,
1,
);
} else {
const effort = 0.35 + throttle * 0.65 + Math.max(0, actions.climb) * 0.55;
this.current.flightEnergy = clamp(
this.current.flightEnergy - this.options.crowEnergyDrainPerSecond * effort * dt,
0,
1,
);
}
const energyAuthority = 0.52 + this.current.flightEnergy * 0.48;
const poweredTarget = this.options.minimumFlightSpeedMps +
(this.options.maximumFlightSpeedMps - this.options.minimumFlightSpeedMps) * throttle * energyAuthority;
const targetSpeed = tucking
? Math.min(this.options.maximumFlightSpeedMps, this.options.glideSpeedMps * 1.45)
: actions.glide ? this.options.glideSpeedMps : poweredTarget;
const response = actions.glide ? (tucking ? 3.1 : 2.2) : 5.5 * energyAuthority;
this.current.speedMps = moveToward(this.current.speedMps, targetSpeed, response * dt);
// Parasitic drag rises with the square of airspeed. It is small enough that
// a healthy powered bird holds its requested target but makes a tucked dive
// finite instead of a perpetual acceleration source.
const dragMps2 = 0.0045 * this.current.speedMps * this.current.speedMps;
this.current.speedMps = clamp(
this.current.speedMps - dragMps2 * dt * (actions.glide ? 0.7 : 0.25),
0,
this.options.maximumFlightSpeedMps,
);
let thermalLiftMps = 0;
for (const thermal of this.options.crowThermals) {
const normalizedDistance = Math.hypot(
this.current.x - thermal.x,
this.current.z - thermal.z,
) / thermal.radiusM;
if (normalizedDistance >= 1) continue;
const falloff = 1 - normalizedDistance;
thermalLiftMps += thermal.liftMps * falloff * falloff;
}
this.current.thermalLiftMps = clamp(thermalLiftMps, 0, this.options.maximumClimbSpeedMps * 1.5);
const liftRatio = this.current.speedMps / Math.max(this.options.glideSpeedMps, 0.001);
const wingLiftMps = (liftRatio * liftRatio - 0.92) * (actions.glide ? 0.72 : 0.38);
const flapLiftMps = actions.glide ? 0 : (0.18 + throttle * 0.46) * energyAuthority;
const tuckPenaltyMps = tucking ? 1.35 : 0;
this.current.liftMps = wingLiftMps + flapLiftMps + this.current.thermalLiftMps - tuckPenaltyMps;
const pitchLift = Math.sin(this.current.pitch) * this.current.speedMps;
const baselineSink = actions.glide ? (tucking ? 0.95 : 0.62) : 0.18;
const targetVertical =
pitchLift +
actions.climb * this.options.maximumClimbSpeedMps * energyAuthority +
this.current.liftMps -
baselineSink;
this.current.verticalSpeedMps = moveToward(
this.current.verticalSpeedMps,
clamp(
targetVertical,
-this.options.maximumClimbSpeedMps * 1.35,
this.options.maximumClimbSpeedMps + this.current.thermalLiftMps,
),
8 * dt,
);
const horizontalSpeed = this.current.speedMps * Math.cos(this.current.pitch);
const beforeX = this.current.x;
const beforeY = this.current.y;
const beforeZ = this.current.z;
this.current.x += (-Math.sin(this.current.yaw) * horizontalSpeed + this.options.crowWind.xMps) * dt;
this.current.z += (-Math.cos(this.current.yaw) * horizontalSpeed + this.options.crowWind.zMps) * dt;
this.current.y += this.current.verticalSpeedMps * dt;
this.applyHorizontalBounds();
this.applyAltitudeBounds();
this.current.distanceM += Math.hypot(
this.current.x - beforeX,
this.current.y - beforeY,
this.current.z - beforeZ,
);
this.current.gliding = actions.glide;
this.current.perched = false;
this.current.crowPose = tucking
? "tuck"
: actions.glide
? Math.abs(this.current.roll) > 0.16 ? "bank" : "glide"
: idleSoaring ? "glide" : "flap";
this.current.poseAmount = actions.glide || idleSoaring ? (tucking ? 0.42 : 0.72) : 0.55 + throttle * 0.45;
if (!actions.glide) {
// A tired bird loses cadence before it loses basic control authority.
const flapRate = 3.5 + throttle * (2.2 + energyAuthority * 1.8);
this.current.posePhase = wrapPhase(this.current.posePhase + flapRate * TWO_PI * dt);
}
}
private land(): void {
this.current.mode = "ground";
this.current.y = this.options.groundY;
this.current.pitch = 0;
this.current.roll = 0;
this.current.verticalSpeedMps = 0;
this.current.liftMps = 0;
this.current.thermalLiftMps = 0;
this.current.gliding = false;
this.current.crowPose = this.current.kind === "crow" ? "perch" : "flap";
this.current.perched = this.current.kind === "crow";
this.current.altitudeBoundContact = "none";
}
private takeOff(): void {
this.current.mode = "flight";
this.current.y = Math.max(this.current.y, this.options.groundY + this.options.minFlightAltitude);
this.current.crowPose = "flap";
this.current.perched = false;
this.current.altitudeBoundContact = "none";
}
private applyHorizontalBounds(): void {
const bounds = this.options.horizontalBounds;
if (!bounds) return;
this.current.x = clamp(this.current.x, bounds.minX, bounds.maxX);
this.current.z = clamp(this.current.z, bounds.minZ, bounds.maxZ);
}
private applyAltitudeBounds(): void {
const minimum = this.options.groundY + this.options.minFlightAltitude;
const maximum = this.options.groundY + this.options.maxFlightAltitude;
if (this.current.y <= minimum) {
this.current.y = minimum;
this.current.verticalSpeedMps = Math.max(0, this.current.verticalSpeedMps);
this.current.altitudeBoundContact = "minimum";
} else if (this.current.y >= maximum) {
this.current.y = maximum;
this.current.verticalSpeedMps = Math.min(0, this.current.verticalSpeedMps);
this.current.altitudeBoundContact = "maximum";
} else {
this.current.altitudeBoundContact = "none";
}
}
}
/** Execute an exact, allocation-friendly input recording for tests or playback. */
export function replayActorInputs(
options: ActorControllerOptions,
frames: readonly TimedActorInputFrame[],
): ActorReplayResult {
const controller = new ActorController(options);
const trajectory: ActorControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Number.isSafeInteger(frame.steps) && frame.steps > 0 ? frame.steps : 0;
for (let index = 0; index < steps; index += 1) {
controller.stepFixed(frame.actions);
trajectory.push(controller.snapshot());
}
}
return { trajectory, final: controller.snapshot() };
}
@@ -1,595 +0,0 @@
/** Deterministic, renderer-independent fixed-wing flight over California. */
const EARTH_RADIUS_M = 6_371_000;
const TWO_PI = Math.PI * 2;
export type AircraftControlMode = "assisted" | "manual";
export type AircraftModeRequest = "none" | AircraftControlMode;
export interface AircraftGeographicPoint {
lat: number;
lng: number;
}
export interface AircraftWaypoint extends AircraftGeographicPoint {
id: string;
altitudeM: number;
}
export interface AircraftActionSnapshot {
throttle: number;
yaw: number;
pitch: number;
roll: number;
modeRequest: AircraftModeRequest;
reset: boolean;
}
export const NEUTRAL_AIRCRAFT_ACTIONS: Readonly<AircraftActionSnapshot> = Object.freeze({
throttle: 0,
yaw: 0,
pitch: 0,
roll: 0,
modeRequest: "none",
reset: false,
});
export interface CaliforniaFlightEnvelope {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
minAltitudeM: number;
maxAltitudeM: number;
}
export const DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE: Readonly<CaliforniaFlightEnvelope> =
Object.freeze({
minLat: 32.4,
maxLat: 42.1,
minLng: -124.6,
maxLng: -114.0,
minAltitudeM: 75,
maxAltitudeM: 6_000,
});
export interface AircraftControllerOptions {
initialPosition?: Partial<AircraftGeographicPoint>;
initialAltitudeM?: number;
initialHeadingDeg?: number;
initialSpeedMps?: number;
mode?: AircraftControlMode;
route?: readonly AircraftWaypoint[];
envelope?: CaliforniaFlightEnvelope;
minimumSpeedMps?: number;
maximumSpeedMps?: number;
assistedCruiseMps?: number;
assistedAltitudeM?: number;
/** Deterministic scenario wind, positive north/east in metres per second. */
windNorthMps?: number;
windEastMps?: number;
/** Deterministic sinusoidal gust amplitude. Zero disables turbulence. */
turbulenceMps?: number;
stallSpeedMps?: number;
batteryCapacityWh?: number;
/** Terrain/runway elevation callback used for ground contact and landing. */
terrainElevationM?: (lat: number, lng: number) => number;
fixedStepSeconds?: number;
maxFrameDeltaSeconds?: number;
}
export interface AircraftControllerState extends AircraftGeographicPoint {
altitudeM: number;
headingDeg: number;
pitchDeg: number;
rollDeg: number;
speedMps: number;
verticalSpeedMps: number;
mode: AircraftControlMode;
routeWaypointIndex: number;
routeWaypointId: string | null;
throttle: number;
yawInput: number;
pitchInput: number;
rollInput: number;
fanRadians: number;
angleOfAttackDeg: number;
liftCoefficient: number;
loadFactorG: number;
stalled: boolean;
windNorthMps: number;
windEastMps: number;
batteryWh: number;
energyUsedWh: number;
groundClearanceM: number;
onGround: boolean;
hardLanding: boolean;
envelopeContact: boolean;
elapsedSteps: number;
}
export interface AircraftControllerSnapshot extends AircraftControllerState {}
export interface TimedAircraftInputFrame {
steps: number;
actions?: Partial<AircraftActionSnapshot>;
}
export interface AircraftReplayResult {
trajectory: readonly AircraftControllerSnapshot[];
final: AircraftControllerSnapshot;
}
export interface AircraftCameraPose {
position: AircraftGeographicPoint & { altitudeM: number };
target: AircraftGeographicPoint & { altitudeM: number };
rollDeg: number;
}
interface ResolvedOptions {
initialPosition: AircraftGeographicPoint;
initialAltitudeM: number;
initialHeadingDeg: number;
initialSpeedMps: number;
mode: AircraftControlMode;
route: readonly AircraftWaypoint[];
envelope: CaliforniaFlightEnvelope;
minimumSpeedMps: number;
maximumSpeedMps: number;
assistedCruiseMps: number;
assistedAltitudeM: number;
windNorthMps: number;
windEastMps: number;
turbulenceMps: number;
stallSpeedMps: number;
batteryCapacityWh: number;
terrainElevationM: (lat: number, lng: number) => number;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
function wrapDegrees(value: number): number {
return ((value % 360) + 360) % 360;
}
function signedAngleDegrees(from: number, to: number): number {
return ((to - from + 540) % 360) - 180;
}
function distanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
return Math.hypot(north, east);
}
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
return wrapDegrees(Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI);
}
function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): CaliforniaFlightEnvelope {
const envelope = { ...(value ?? DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE) };
if (
!Number.isFinite(envelope.minLat) || !Number.isFinite(envelope.maxLat) ||
!Number.isFinite(envelope.minLng) || !Number.isFinite(envelope.maxLng) ||
!Number.isFinite(envelope.minAltitudeM) || !Number.isFinite(envelope.maxAltitudeM) ||
envelope.minLat >= envelope.maxLat || envelope.minLng >= envelope.maxLng ||
envelope.minAltitudeM >= envelope.maxAltitudeM
) throw new RangeError("aircraft flight envelope must be finite and ordered");
return envelope;
}
function checkedRoute(
route: readonly AircraftWaypoint[] | undefined,
envelope: CaliforniaFlightEnvelope,
): readonly AircraftWaypoint[] {
if (!route) return [];
const ids = new Set<string>();
return route.map((point) => {
if (
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
!Number.isFinite(point.altitudeM) ||
point.lat < envelope.minLat || point.lat > envelope.maxLat ||
point.lng < envelope.minLng || point.lng > envelope.maxLng ||
point.altitudeM < envelope.minAltitudeM || point.altitudeM > envelope.maxAltitudeM
) throw new RangeError(
"aircraft route waypoints must be finite, unique, and inside the flight envelope",
);
ids.add(point.id);
return { ...point };
});
}
function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
const envelope = checkedEnvelope(value.envelope);
const minimumSpeedMps = clamp(finiteOr(value.minimumSpeedMps, 20), 5, 100);
const maximumSpeedMps = clamp(finiteOr(value.maximumSpeedMps, 95), minimumSpeedMps, 250);
const assistedAltitudeM = clamp(
finiteOr(value.assistedAltitudeM, 1_500),
envelope.minAltitudeM,
envelope.maxAltitudeM,
);
const stallSpeedMps = clamp(
finiteOr(value.stallSpeedMps, Math.max(18, minimumSpeedMps + 3)),
minimumSpeedMps,
maximumSpeedMps * 0.8,
);
const terrainElevationM = value.terrainElevationM ?? (() => envelope.minAltitudeM);
if (typeof terrainElevationM !== "function") {
throw new RangeError("aircraft terrainElevationM must be a function");
}
return {
initialPosition: {
lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat),
lng: clamp(finiteOr(value.initialPosition?.lng, -118.2437), envelope.minLng, envelope.maxLng),
},
initialAltitudeM: clamp(finiteOr(value.initialAltitudeM, assistedAltitudeM), envelope.minAltitudeM, envelope.maxAltitudeM),
initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
mode: value.mode === "manual" ? "manual" : "assisted",
route: checkedRoute(value.route, envelope),
envelope,
minimumSpeedMps,
maximumSpeedMps,
assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps),
assistedAltitudeM,
windNorthMps: clamp(finiteOr(value.windNorthMps, 0), -80, 80),
windEastMps: clamp(finiteOr(value.windEastMps, 0), -80, 80),
turbulenceMps: clamp(finiteOr(value.turbulenceMps, 0), 0, 30),
stallSpeedMps,
batteryCapacityWh: clamp(finiteOr(value.batteryCapacityWh, 54_000), 1_000, 500_000),
terrainElevationM,
fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1),
};
}
export function normalizeAircraftActions(
value: Partial<AircraftActionSnapshot> | undefined,
): AircraftActionSnapshot {
return {
throttle: clamp(finiteOr(value?.throttle, 0), 0, 1),
yaw: clamp(finiteOr(value?.yaw, 0), -1, 1),
pitch: clamp(finiteOr(value?.pitch, 0), -1, 1),
roll: clamp(finiteOr(value?.roll, 0), -1, 1),
modeRequest: value?.modeRequest === "manual" || value?.modeRequest === "assisted"
? value.modeRequest
: "none",
reset: value?.reset === true,
};
}
function hasManualIntent(actions: AircraftActionSnapshot): boolean {
return (
actions.modeRequest === "manual" || actions.throttle > 0.02 ||
Math.abs(actions.yaw) > 0.06 || Math.abs(actions.pitch) > 0.06 || Math.abs(actions.roll) > 0.06
);
}
export class AircraftController {
private readonly options: ResolvedOptions;
private accumulator = 0;
private readonly current: AircraftControllerState;
constructor(options: AircraftControllerOptions = {}) {
this.options = resolveOptions(options);
this.current = {
...this.options.initialPosition,
altitudeM: this.options.initialAltitudeM,
headingDeg: this.options.initialHeadingDeg,
pitchDeg: 0,
rollDeg: 0,
speedMps: this.options.initialSpeedMps,
verticalSpeedMps: 0,
mode: this.options.mode,
routeWaypointIndex: 0,
routeWaypointId: null,
throttle: 0,
yawInput: 0,
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
angleOfAttackDeg: 0,
liftCoefficient: 1,
loadFactorG: 1,
stalled: false,
windNorthMps: this.options.windNorthMps,
windEastMps: this.options.windEastMps,
batteryWh: this.options.batteryCapacityWh,
energyUsedWh: 0,
groundClearanceM: 0,
onGround: false,
hardLanding: false,
envelopeContact: false,
elapsedSteps: 0,
};
this.reset();
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
state(): Readonly<AircraftControllerState> {
return this.current;
}
snapshot(): AircraftControllerSnapshot {
return { ...this.current };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: AircraftControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
const envelope = this.options.envelope;
if (
!numeric || (snapshot.mode !== "manual" && snapshot.mode !== "assisted") ||
snapshot.lat < envelope.minLat || snapshot.lat > envelope.maxLat ||
snapshot.lng < envelope.minLng || snapshot.lng > envelope.maxLng ||
snapshot.altitudeM < envelope.minAltitudeM || snapshot.altitudeM > envelope.maxAltitudeM ||
snapshot.speedMps < this.options.minimumSpeedMps ||
snapshot.speedMps > this.options.maximumSpeedMps ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("aircraft snapshot is incompatible or invalid");
Object.assign(this.current, snapshot);
this.accumulator = 0;
}
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.initialPosition, {
altitudeM: this.options.initialAltitudeM,
headingDeg: this.options.initialHeadingDeg,
pitchDeg: 0,
rollDeg: 0,
speedMps: this.options.initialSpeedMps,
verticalSpeedMps: 0,
mode: this.options.mode,
routeWaypointIndex: 0,
routeWaypointId: this.options.route[0]?.id ?? null,
throttle: 0,
yawInput: 0,
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
angleOfAttackDeg: 0,
liftCoefficient: 1,
loadFactorG: 1,
stalled: false,
windNorthMps: this.options.windNorthMps,
windEastMps: this.options.windEastMps,
batteryWh: this.options.batteryCapacityWh,
energyUsedWh: 0,
groundClearanceM: Math.max(0, this.options.initialAltitudeM - this.groundElevationM(
this.options.initialPosition.lat,
this.options.initialPosition.lng,
)),
onGround: false,
hardLanding: false,
envelopeContact: false,
elapsedSteps: 0,
});
}
private groundElevationM(lat: number, lng: number): number {
const value = this.options.terrainElevationM(lat, lng);
if (!Number.isFinite(value)) {
throw new RangeError("aircraft terrainElevationM must return a finite elevation");
}
return clamp(value, this.options.envelope.minAltitudeM, this.options.envelope.maxAltitudeM);
}
tick(deltaSeconds: number, actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeAircraftActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
stepFixed(actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): void {
const normalized = normalizeAircraftActions(actions);
if (normalized.reset) this.reset();
else this.stepNormalized(normalized);
}
private stepNormalized(actions: AircraftActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
const manual = hasManualIntent(actions);
if (manual) this.current.mode = "manual";
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
let throttle = actions.throttle;
let yaw = actions.yaw;
let pitch = actions.pitch;
let roll = actions.roll;
if (this.current.mode === "assisted") {
throttle = clamp(0.5 + (this.options.assistedCruiseMps - this.current.speedMps) / 18, 0, 1);
const target = this.options.route[this.current.routeWaypointIndex];
if (target && distanceM(this.current, target) < 3_000 && this.options.route.length > 1) {
this.current.routeWaypointIndex = (this.current.routeWaypointIndex + 1) % this.options.route.length;
}
const waypoint = this.options.route[this.current.routeWaypointIndex];
this.current.routeWaypointId = waypoint?.id ?? null;
const desiredHeading = waypoint ? bearingDeg(this.current, waypoint) : this.options.initialHeadingDeg;
const headingError = signedAngleDegrees(this.current.headingDeg, desiredHeading);
roll = clamp(headingError / 38, -1, 1);
yaw = clamp(headingError / 90, -0.45, 0.45);
const altitudeTarget = waypoint?.altitudeM ?? this.options.assistedAltitudeM;
pitch = clamp((altitudeTarget - this.current.altitudeM) / 350, -0.65, 0.65);
}
this.current.throttle = moveToward(this.current.throttle, throttle, 0.8 * dt);
this.current.yawInput = moveToward(this.current.yawInput, yaw, 2.5 * dt);
this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt);
this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt);
const gustPhase = this.current.elapsedSteps * dt * 0.73;
const gustNorth = Math.sin(gustPhase) * this.options.turbulenceMps;
const gustEast = Math.sin(gustPhase * 0.61 + 1.7) * this.options.turbulenceMps * 0.72;
this.current.windNorthMps = this.options.windNorthMps + gustNorth;
this.current.windEastMps = this.options.windEastMps + gustEast;
const targetRoll = this.current.rollInput * 58 + gustEast * 0.22;
const targetPitch = this.current.pitchInput * 22 + gustNorth * 0.08;
this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt);
const flightPathDeg = Math.atan2(
this.current.verticalSpeedMps,
Math.max(1, this.current.speedMps),
) * 180 / Math.PI;
this.current.angleOfAttackDeg = this.current.pitchDeg - flightPathDeg;
this.current.liftCoefficient = clamp(1 + this.current.angleOfAttackDeg * 0.055, 0.05, 1.6);
this.current.stalled = this.current.speedMps < this.options.stallSpeedMps ||
Math.abs(this.current.angleOfAttackDeg) > 19;
const stallDrag = this.current.stalled ? 3.8 : 0;
const batteryFactor = clamp(this.current.batteryWh / Math.max(1, this.options.batteryCapacityWh * 0.08), 0, 1);
const thrust = this.current.throttle * 8.5 * batteryFactor;
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075 + stallDrag;
this.current.speedMps = clamp(
this.current.speedMps + (thrust - drag) * dt,
this.options.minimumSpeedMps,
this.options.maximumSpeedMps,
);
const bankTurn = Math.sin(this.current.rollDeg * Math.PI / 180) * 24;
this.current.headingDeg = wrapDegrees(
this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt,
);
const liftAuthority = clamp(
this.current.liftCoefficient * (this.current.speedMps / Math.max(1, this.options.assistedCruiseMps)),
0.08,
1.35,
);
const commandedVerticalSpeed = Math.sin(this.current.pitchDeg * Math.PI / 180) *
this.current.speedMps * liftAuthority;
const stallSinkMps = this.current.stalled
? clamp((this.options.stallSpeedMps - this.current.speedMps) * 0.7 + 2.5, 2.5, 14)
: 0;
this.current.verticalSpeedMps = moveToward(
this.current.verticalSpeedMps,
commandedVerticalSpeed - stallSinkMps,
(this.current.stalled ? 8 : 5) * dt,
);
this.current.loadFactorG = clamp(
liftAuthority / Math.max(0.25, Math.cos(this.current.rollDeg * Math.PI / 180)),
0,
3.5,
);
const heading = this.current.headingDeg * Math.PI / 180;
const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const northM = (Math.cos(heading) * horizontalSpeed + this.current.windNorthMps) * dt;
const eastM = (Math.sin(heading) * horizontalSpeed + this.current.windEastMps) * dt;
const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI;
const nextLng = this.current.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI;
let nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
const groundElevationM = this.groundElevationM(nextLat, nextLng);
const wasVerticalSpeedMps = this.current.verticalSpeedMps;
this.current.onGround = nextAltitude <= groundElevationM + 0.75;
this.current.hardLanding = this.current.onGround && wasVerticalSpeedMps < -4.5;
if (this.current.onGround) {
nextAltitude = groundElevationM;
this.current.verticalSpeedMps = 0;
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 70 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 45 * dt);
if (this.current.throttle < 0.55) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps - 4 * dt);
} else if (
this.current.speedMps > this.options.stallSpeedMps * 1.08 &&
this.current.pitchInput > 0.2
) {
this.current.onGround = false;
nextAltitude = groundElevationM + 0.8;
this.current.verticalSpeedMps = 0.8;
}
}
const envelope = this.options.envelope;
this.current.envelopeContact =
nextLat < envelope.minLat || nextLat > envelope.maxLat ||
nextLng < envelope.minLng || nextLng > envelope.maxLng ||
nextAltitude < envelope.minAltitudeM || nextAltitude > envelope.maxAltitudeM;
this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat);
this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng);
this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM);
this.current.groundClearanceM = Math.max(0, this.current.altitudeM - groundElevationM);
if (this.current.envelopeContact) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt);
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt);
}
this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI;
const electricalPowerW = 8_000 + this.current.throttle * 122_000 +
Math.abs(this.current.verticalSpeedMps) * 420;
const usedWh = Math.min(this.current.batteryWh, electricalPowerW * dt / 3_600);
this.current.batteryWh -= usedWh;
this.current.energyUsedWh += usedWh;
this.current.elapsedSteps += 1;
}
}
export function replayAircraftInputs(
options: AircraftControllerOptions,
frames: readonly TimedAircraftInputFrame[],
): AircraftReplayResult {
const controller = new AircraftController(options);
const trajectory: AircraftControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Number.isFinite(frame.steps) ? Math.max(0, Math.floor(frame.steps)) : 0;
const held = normalizeAircraftActions(frame.actions);
for (let index = 0; index < steps; index += 1) {
controller.stepFixed(index === 0 ? held : { ...held, modeRequest: "none", reset: false });
trajectory.push(controller.snapshot());
}
}
return { trajectory, final: trajectory.at(-1) ?? controller.snapshot() };
}
/** Renderer-neutral geographic chase camera derived from an authoritative pose. */
export function aircraftChaseCameraPose(
state: Readonly<AircraftControllerState>,
distanceBehindM = 24,
heightAboveM = 8,
lookAheadM = 35,
): AircraftCameraPose {
const heading = state.headingDeg * Math.PI / 180;
const geographicOffset = (northM: number, eastM: number): AircraftGeographicPoint => ({
lat: state.lat + northM / EARTH_RADIUS_M * 180 / Math.PI,
lng: state.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(state.lat * Math.PI / 180))) * 180 / Math.PI,
});
const behind = geographicOffset(-Math.cos(heading) * distanceBehindM, -Math.sin(heading) * distanceBehindM);
const ahead = geographicOffset(Math.cos(heading) * lookAheadM, Math.sin(heading) * lookAheadM);
return {
position: { ...behind, altitudeM: state.altitudeM + heightAboveM },
target: { ...ahead, altitudeM: state.altitudeM + state.verticalSpeedMps * 0.4 },
rollDeg: state.rollDeg * 0.2,
};
}
@@ -1,273 +0,0 @@
import { arenaChecksum } from "./checksum.ts";
import type { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaEnvironment,
type ArenaInfo,
type ArenaManifest,
type ArenaReplayResult,
type ArenaResetResult,
type ArenaScenario,
type ArenaScenarioRequest,
type ArenaSnapshot,
type ArenaSourceHashes,
type ArenaStepResult,
type ArenaTraceEnvelope,
type ArenaTraceStep,
} from "./types.ts";
export interface SimulationTransition<O, R extends Record<string, number>> {
observation: O;
rewardComponents: R;
terminated?: boolean;
terminalReason?: string;
}
/** Shared episode, checksum, snapshot and replay semantics for concrete environments. */
export abstract class BaseArenaEnvironment<
A,
O,
R extends Record<string, number>,
S,
P extends object,
> implements ArenaEnvironment<A, O, R, S> {
abstract readonly manifest: ArenaManifest;
protected abstract readonly registry: ArenaScenarioRegistry<P>;
protected abstract readonly sourceHashes: ArenaSourceHashes;
private scenario: ArenaScenario<P> | null = null;
private stepIndex = 0;
private cumulativeReward = 0;
private terminated = false;
private truncated = false;
private terminalReason: string | null = null;
private initialStateChecksum = "";
private frames: ArenaTraceStep<A, R>[] = [];
protected abstract resetSimulation(scenario: ArenaScenario<P>): O;
protected abstract normalizeAction(action: A): A;
protected abstract advanceSimulation(action: A): SimulationTransition<O, R>;
protected abstract simulationSnapshot(): S;
protected abstract restoreSimulation(snapshot: S): O;
reset(seed: number, request: string | ArenaScenarioRequest): ArenaResetResult<O> {
this.scenario = this.registry.resolve(seed, request);
this.stepIndex = 0;
this.cumulativeReward = 0;
this.terminated = false;
this.truncated = false;
this.terminalReason = null;
this.frames = [];
const observation = this.resetSimulation(this.scenario);
this.initialStateChecksum = arenaChecksum(this.statePayload());
return { observation, info: this.info() };
}
step(rawAction: A): ArenaStepResult<O, R> {
this.requireReset();
if (this.terminated || this.truncated) {
throw new Error("arena episode is complete; call reset before step");
}
const action = this.normalizeAction(rawAction);
const transition = this.advanceSimulation(action);
const values = Object.values(transition.rewardComponents);
if (values.some((value) => !Number.isFinite(value))) {
throw new Error("arena reward components must be finite");
}
const reward = values.reduce((sum, value) => sum + value, 0);
this.stepIndex += 1;
this.cumulativeReward += reward;
this.terminated = transition.terminated === true;
this.truncated = !this.terminated && this.stepIndex >= this.manifest.maxSteps;
this.terminalReason = transition.terminalReason ?? (this.truncated ? "max-steps" : null);
const stateChecksum = arenaChecksum(this.statePayload());
this.frames.push({
index: this.stepIndex,
action: structuredClone(action),
reward,
rewardComponents: structuredClone(transition.rewardComponents),
terminated: this.terminated,
truncated: this.truncated,
terminalReason: this.terminalReason,
stateChecksum,
});
return {
observation: transition.observation,
reward,
rewardComponents: structuredClone(transition.rewardComponents),
terminated: this.terminated,
truncated: this.truncated,
info: this.info(stateChecksum),
};
}
snapshot(): ArenaSnapshot<S> {
const scenario = this.requireReset();
const core = {
apiVersion: ARENA_API_VERSION,
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
seed: scenario.seed,
scenarioId: scenario.id,
scenarioSplit: scenario.split,
scenarioHash: scenario.hash,
step: this.stepIndex,
cumulativeReward: this.cumulativeReward,
terminated: this.terminated,
truncated: this.truncated,
terminalReason: this.terminalReason,
simulation: structuredClone(this.simulationSnapshot()),
};
return { ...core, checksum: arenaChecksum(core) };
}
restore(snapshot: ArenaSnapshot<S>): ArenaResetResult<O> {
const { checksum, ...core } = snapshot;
if (arenaChecksum(core) !== checksum) throw new Error("arena snapshot checksum mismatch");
if (
snapshot.apiVersion !== ARENA_API_VERSION || snapshot.envId !== this.manifest.id ||
snapshot.envVersion !== this.manifest.version || snapshot.envHash !== this.envHash()
) throw new Error("arena snapshot is incompatible with this environment");
if (
!Number.isSafeInteger(snapshot.step) || snapshot.step < 0 ||
snapshot.step > this.manifest.maxSteps || !Number.isFinite(snapshot.cumulativeReward) ||
typeof snapshot.terminated !== "boolean" || typeof snapshot.truncated !== "boolean" ||
(snapshot.terminated && snapshot.truncated) ||
(snapshot.terminalReason !== null && typeof snapshot.terminalReason !== "string")
) throw new Error("arena snapshot episode state is invalid");
const resolved = this.registry.resolve(snapshot.seed, {
split: snapshot.scenarioSplit,
id: snapshot.scenarioId,
});
if (resolved.hash !== snapshot.scenarioHash) throw new Error("arena scenario hash mismatch");
this.scenario = resolved;
this.stepIndex = snapshot.step;
this.cumulativeReward = snapshot.cumulativeReward;
this.terminated = snapshot.terminated;
this.truncated = snapshot.truncated;
this.terminalReason = snapshot.terminalReason;
this.frames = [];
const observation = this.restoreSimulation(structuredClone(snapshot.simulation));
this.initialStateChecksum = arenaChecksum(this.statePayload());
return { observation, info: this.info() };
}
trace(): ArenaTraceEnvelope<A, R> {
const scenario = this.requireReset();
const core = {
apiVersion: ARENA_API_VERSION,
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
scenarioId: scenario.id,
scenarioSplit: scenario.split,
scenarioHash: scenario.hash,
sourceHashes: this.sourceHashes,
seed: scenario.seed,
initialStateChecksum: this.initialStateChecksum,
steps: structuredClone(this.frames),
finalStateChecksum: arenaChecksum(this.statePayload()),
cumulativeReward: this.cumulativeReward,
};
return { ...core, checksum: arenaChecksum(core) };
}
replay(trace: ArenaTraceEnvelope<A, R>): ArenaReplayResult<O> {
const { checksum, ...core } = trace;
if (arenaChecksum(core) !== checksum) throw new Error("arena trace checksum mismatch");
if (
trace.apiVersion !== ARENA_API_VERSION || trace.envId !== this.manifest.id ||
trace.envVersion !== this.manifest.version ||
trace.envHash !== this.envHash() || arenaChecksum(trace.sourceHashes) !== arenaChecksum(this.sourceHashes)
) throw new Error("arena trace is incompatible with this environment");
if (
!Array.isArray(trace.steps) || trace.steps.length > this.manifest.maxSteps ||
!Number.isFinite(trace.cumulativeReward)
) throw new Error("arena trace episode state is invalid");
let reset = this.reset(trace.seed, { split: trace.scenarioSplit, id: trace.scenarioId });
if (trace.scenarioHash !== reset.info.scenarioHash || trace.initialStateChecksum !== reset.info.stateChecksum) {
throw new Error("arena trace initial state mismatch");
}
let observation = reset.observation;
for (let offset = 0; offset < trace.steps.length; offset += 1) {
const expected = trace.steps[offset]!;
if (
expected.index !== offset + 1 || !Number.isFinite(expected.reward) ||
typeof expected.terminated !== "boolean" || typeof expected.truncated !== "boolean" ||
(expected.terminated && expected.truncated)
) throw new Error(`arena trace frame ${offset + 1} is invalid`);
const actual = this.step(expected.action);
observation = actual.observation;
if (
actual.info.stateChecksum !== expected.stateChecksum || actual.reward !== expected.reward ||
actual.terminated !== expected.terminated || actual.truncated !== expected.truncated ||
arenaChecksum(actual.rewardComponents) !== arenaChecksum(expected.rewardComponents)
) throw new Error(`arena trace diverged at step ${expected.index}`);
}
const replayed = this.trace();
if (
replayed.finalStateChecksum !== trace.finalStateChecksum ||
replayed.cumulativeReward !== trace.cumulativeReward
) throw new Error("arena trace final state mismatch");
return {
observation,
steps: this.stepIndex,
cumulativeReward: this.cumulativeReward,
finalStateChecksum: replayed.finalStateChecksum,
};
}
protected currentScenario(): ArenaScenario<P> {
return this.requireReset();
}
protected currentStep(): number {
return this.stepIndex;
}
private requireReset(): ArenaScenario<P> {
if (!this.scenario) throw new Error("arena environment must be reset before use");
return this.scenario;
}
private envHash(): string {
return arenaChecksum(this.manifest);
}
private statePayload(): object {
const scenario = this.requireReset();
return {
envId: this.manifest.id,
envVersion: this.manifest.version,
seed: scenario.seed,
scenarioHash: scenario.hash,
step: this.stepIndex,
cumulativeReward: this.cumulativeReward,
terminated: this.terminated,
truncated: this.truncated,
terminalReason: this.terminalReason,
simulation: this.simulationSnapshot(),
};
}
private info(checksum = arenaChecksum(this.statePayload())): ArenaInfo {
const scenario = this.requireReset();
return {
apiVersion: ARENA_API_VERSION,
envId: this.manifest.id,
envVersion: this.manifest.version,
envHash: this.envHash(),
scenarioId: scenario.id,
scenarioSplit: scenario.split,
scenarioHash: scenario.hash,
simulatorHash: this.sourceHashes.simulator,
environmentSourceHash: this.sourceHashes.environment,
seed: scenario.seed,
step: this.stepIndex,
maxSteps: this.manifest.maxSteps,
terminalReason: this.terminalReason,
stateChecksum: checksum,
};
}
}
@@ -1,315 +0,0 @@
import {
AircraftController,
type AircraftControllerSnapshot,
type AircraftGeographicPoint,
} from "../aircraft/controller.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface CaliforniaFlightAction {
throttle: number;
yaw: number;
pitch: number;
roll: number;
}
export interface CaliforniaFlightObservation {
lat: number;
lng: number;
altitudeM: number;
headingDeg: number;
pitchDeg: number;
rollDeg: number;
speedMps: number;
verticalSpeedMps: number;
goalLat: number;
goalLng: number;
goalAltitudeM: number;
distanceToGoalM: number;
bearingToGoalDeg: number;
altitudeErrorM: number;
envelopeContact: boolean;
}
export type CaliforniaFlightReward = Record<
"progress" | "success" | "time" | "altitude" | "control" | "safety",
number
>;
interface FlightScenarioParameters {
startLat: number;
startLng: number;
startAltitudeM: number;
startHeadingDeg: number;
goalLat: number;
goalLng: number;
goalAltitudeM: number;
}
interface FlightSimulationSnapshot {
controller: AircraftControllerSnapshot;
previousGoalDistanceM: number;
}
const EARTH_RADIUS_M = 6_371_000;
const FIXED_STEP = 0.1;
const MAX_STEPS = 500;
const SUCCESS_RADIUS_M = 90;
const DEFINITIONS = [
{
id: "train-la-east-leg",
split: "train" as const,
parameters: {
startLat: 34.0522, startLng: -118.2437, startAltitudeM: 1_200, startHeadingDeg: 0,
goalLat: 34.0522, goalLng: -118.2325, goalAltitudeM: 1_200,
},
},
{
id: "train-bay-west-climb",
split: "train" as const,
parameters: {
startLat: 37.70, startLng: -122.30, startAltitudeM: 1_350, startHeadingDeg: 15,
goalLat: 37.70, goalLng: -122.313, goalAltitudeM: 1_450,
},
},
{
id: "dev-socal-southeast",
split: "dev" as const,
parameters: {
startLat: 34.20, startLng: -118.40, startAltitudeM: 1_500, startHeadingDeg: 330,
goalLat: 34.194, goalLng: -118.391, goalAltitudeM: 1_380,
},
},
{
id: "dev-bay-northeast",
split: "dev" as const,
parameters: {
startLat: 37.62, startLng: -122.35, startAltitudeM: 1_100, startHeadingDeg: 280,
goalLat: 37.628, goalLng: -122.341, goalAltitudeM: 1_260,
},
},
] as const;
export const CALIFORNIA_FLIGHT_SCENARIOS = new ArenaScenarioRegistry<FlightScenarioParameters>(
"california-flight-v1",
DEFINITIONS,
(base, random) => {
const northJitter = random.between(-0.00015, 0.00015);
const eastJitter = random.between(-0.00015, 0.00015);
return {
...base,
startLat: base.startLat + northJitter,
startLng: base.startLng + eastJitter,
startAltitudeM: base.startAltitudeM + random.between(-12, 12),
startHeadingDeg: base.startHeadingDeg + random.between(-3, 3),
goalLat: base.goalLat - northJitter,
goalLng: base.goalLng - eastJitter,
goalAltitudeM: base.goalAltitudeM + random.between(-12, 12),
};
},
);
export const CALIFORNIA_FLIGHT_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "california-flight-v1",
version: 1,
title: "California electric-flight waypoint",
description: "Manual fixed-wing waypoint control over Tera's renderer-neutral aircraft simulator.",
simulator: "AircraftController",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["throttle", "yaw", "pitch", "roll"],
observationFields: [
"lat", "lng", "altitudeM", "headingDeg", "pitchDeg", "rollDeg", "speedMps",
"verticalSpeedMps", "goalLat", "goalLng", "goalAltitudeM", "distanceToGoalM",
"bearingToGoalDeg", "altitudeErrorM", "envelopeContact",
],
rewardComponents: {
progress: "Reduction in three-dimensional waypoint distance.",
success: "Sparse arrival bonus.",
time: "Per-step pressure; straight-ahead inaction misses lateral goals.",
altitude: "Counterweight on altitude error.",
control: "Small cost on throttle and surface demand.",
safety: "Terminal California flight-envelope penalty.",
},
safetyTerminals: ["flight-envelope-contact"],
scenarioIds: {
train: CALIFORNIA_FLIGHT_SCENARIOS.ids("train"),
dev: CALIFORNIA_FLIGHT_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "Zero surfaces and throttle fly straight past the lateral waypoint and remain below zero return.",
scripted: "Proportional bearing, bank, yaw and altitude guidance completes every public scenario.",
},
});
export const CALIFORNIA_FLIGHT_INACTION: Readonly<CaliforniaFlightAction> = Object.freeze({
throttle: 0, yaw: 0, pitch: 0, roll: 0,
});
function horizontalDistanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = (a.lat + b.lat) / 2 * Math.PI / 180;
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
return Math.hypot(north, east);
}
function distance3dM(
a: AircraftGeographicPoint & { altitudeM: number },
b: AircraftGeographicPoint & { altitudeM: number },
): number {
return Math.hypot(horizontalDistanceM(a, b), b.altitudeM - a.altitudeM);
}
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = (a.lat + b.lat) / 2 * Math.PI / 180;
return (Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI + 360) % 360;
}
function signedAngleDeg(from: number, to: number): number {
return ((to - from + 540) % 360) - 180;
}
export function californiaFlightScriptedBaseline(
observation: CaliforniaFlightObservation,
): CaliforniaFlightAction {
const headingError = signedAngleDeg(observation.headingDeg, observation.bearingToGoalDeg);
return {
throttle: 0.62,
yaw: Math.max(-0.6, Math.min(0.6, headingError / 80)),
pitch: Math.max(-0.7, Math.min(0.7, observation.altitudeErrorM / 220)),
roll: Math.max(-1, Math.min(1, headingError / 42)),
};
}
export class CaliforniaFlightEnvironment extends BaseArenaEnvironment<
CaliforniaFlightAction,
CaliforniaFlightObservation,
CaliforniaFlightReward,
FlightSimulationSnapshot,
FlightScenarioParameters
> {
readonly manifest = CALIFORNIA_FLIGHT_MANIFEST;
protected readonly registry = CALIFORNIA_FLIGHT_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["california-flight-v1"]!;
private controller: AircraftController | null = null;
private previousGoalDistanceM = 0;
protected resetSimulation(scenario: ArenaScenario<FlightScenarioParameters>): CaliforniaFlightObservation {
const parameters = scenario.parameters;
const altitudeMin = Math.min(parameters.startAltitudeM, parameters.goalAltitudeM);
const altitudeMax = Math.max(parameters.startAltitudeM, parameters.goalAltitudeM);
this.controller = new AircraftController({
initialPosition: { lat: parameters.startLat, lng: parameters.startLng },
initialAltitudeM: parameters.startAltitudeM,
initialHeadingDeg: parameters.startHeadingDeg,
initialSpeedMps: 38,
mode: "manual",
fixedStepSeconds: FIXED_STEP,
envelope: {
minLat: Math.min(parameters.startLat, parameters.goalLat) - 0.02,
maxLat: Math.max(parameters.startLat, parameters.goalLat) + 0.02,
minLng: Math.min(parameters.startLng, parameters.goalLng) - 0.02,
maxLng: Math.max(parameters.startLng, parameters.goalLng) + 0.02,
minAltitudeM: altitudeMin - 260,
maxAltitudeM: altitudeMax + 260,
},
});
this.previousGoalDistanceM = this.goalDistance();
return this.observation();
}
protected normalizeAction(action: CaliforniaFlightAction): CaliforniaFlightAction {
const axis = (value: number) => Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
return {
throttle: Math.max(0, Math.min(1, Number.isFinite(action?.throttle) ? action.throttle : 0)),
yaw: axis(action?.yaw),
pitch: axis(action?.pitch),
roll: axis(action?.roll),
};
}
protected advanceSimulation(
action: CaliforniaFlightAction,
): SimulationTransition<CaliforniaFlightObservation, CaliforniaFlightReward> {
const controller = this.requireController();
controller.stepFixed({ ...action, modeRequest: "manual", reset: false });
const state = controller.state();
const distance = this.goalDistance();
const progress = this.previousGoalDistanceM - distance;
this.previousGoalDistanceM = distance;
const success = distance <= SUCCESS_RADIUS_M;
const safety = state.envelopeContact;
const altitudeError = this.currentScenario().parameters.goalAltitudeM - state.altitudeM;
return {
observation: this.observation(),
rewardComponents: {
progress: progress * 0.0025,
success: success ? 3.5 : 0,
time: -0.006,
altitude: -0.000002 * altitudeError ** 2,
control: -0.0005 * (action.throttle ** 2 + action.yaw ** 2 +
action.pitch ** 2 + action.roll ** 2),
safety: safety ? -4 : 0,
},
terminated: success || safety,
terminalReason: success ? "goal" : safety ? "flight-envelope-contact" : undefined,
};
}
protected simulationSnapshot(): FlightSimulationSnapshot {
return {
controller: this.requireController().snapshot(),
previousGoalDistanceM: this.previousGoalDistanceM,
};
}
protected restoreSimulation(snapshot: FlightSimulationSnapshot): CaliforniaFlightObservation {
this.resetSimulation(this.currentScenario());
this.requireController().restore(snapshot.controller);
this.previousGoalDistanceM = snapshot.previousGoalDistanceM;
return this.observation();
}
private observation(): CaliforniaFlightObservation {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return {
lat: state.lat,
lng: state.lng,
altitudeM: state.altitudeM,
headingDeg: state.headingDeg,
pitchDeg: state.pitchDeg,
rollDeg: state.rollDeg,
speedMps: state.speedMps,
verticalSpeedMps: state.verticalSpeedMps,
goalLat: goal.goalLat,
goalLng: goal.goalLng,
goalAltitudeM: goal.goalAltitudeM,
distanceToGoalM: this.goalDistance(),
bearingToGoalDeg: bearingDeg(state, { lat: goal.goalLat, lng: goal.goalLng }),
altitudeErrorM: goal.goalAltitudeM - state.altitudeM,
envelopeContact: state.envelopeContact,
};
}
private goalDistance(): number {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return distance3dM(state, {
lat: goal.goalLat, lng: goal.goalLng, altitudeM: goal.goalAltitudeM,
});
}
private requireController(): AircraftController {
if (!this.controller) throw new Error("flight environment has not been reset");
return this.controller;
}
}
@@ -1,44 +0,0 @@
/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */
function canonical(value: unknown, stack: Set<object>): string {
if (value === null) return "null";
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
return Object.is(value, -0) ? "0" : JSON.stringify(value);
}
if (Array.isArray(value)) {
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
stack.add(value);
const result = `[${value.map((entry) => canonical(entry, stack)).join(",")}]`;
stack.delete(value);
return result;
}
if (typeof value === "object") {
if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
stack.add(value);
const record = value as Record<string, unknown>;
const entries = Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(record[key], stack)}`);
stack.delete(value);
return `{${entries.join(",")}}`;
}
throw new TypeError(`arena checksums do not accept ${typeof value}`);
}
export function canonicalJson(value: unknown): string {
return canonical(value, new Set());
}
/** 64-bit FNV-1a over UTF-8, encoded with an algorithm prefix. */
export function arenaChecksum(value: unknown): string {
const bytes = new TextEncoder().encode(canonicalJson(value));
let hash = 0xcbf29ce484222325n;
for (const byte of bytes) {
hash ^= BigInt(byte);
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
}
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
}
@@ -1,285 +0,0 @@
import {
ActorController,
type ActorControllerSnapshot,
} from "../actors/controller.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface CrowNavAction {
forward: number;
turn: number;
pitch: number;
climb: number;
glide: boolean;
}
export interface CrowNavObservation {
x: number;
y: number;
z: number;
yaw: number;
pitch: number;
speedMps: number;
verticalSpeedMps: number;
goalX: number;
goalY: number;
goalZ: number;
deltaX: number;
deltaY: number;
deltaZ: number;
distanceToGoalM: number;
altitudeBoundContact: "none" | "minimum" | "maximum";
}
export type CrowNavReward = Record<
"progress" | "success" | "time" | "energy" | "heading" | "safety",
number
>;
interface CrowScenarioParameters {
startX: number;
startY: number;
startZ: number;
startYaw: number;
goalX: number;
goalY: number;
goalZ: number;
}
interface CrowSimulationSnapshot {
controller: ActorControllerSnapshot;
previousGoalDistanceM: number;
}
const FIXED_STEP = 0.1;
const MAX_STEPS = 300;
const SUCCESS_RADIUS_M = 2.5;
const BOUNDS = Object.freeze({ minX: -120, maxX: 120, minZ: -120, maxZ: 120 });
const DEFINITIONS = [
{
id: "train-east-crosswind",
split: "train" as const,
parameters: { startX: 0, startY: 12, startZ: 0, startYaw: 0, goalX: 60, goalY: 12, goalZ: 0 },
},
{
id: "train-north-climb",
split: "train" as const,
parameters: { startX: 5, startY: 10, startZ: 20, startYaw: -1, goalX: 5, goalY: 16, goalZ: -48 },
},
{
id: "dev-west-return",
split: "dev" as const,
parameters: { startX: 25, startY: 15, startZ: -10, startYaw: 0.4, goalX: -48, goalY: 13, goalZ: -10 },
},
{
id: "dev-south-descent",
split: "dev" as const,
parameters: { startX: -15, startY: 18, startZ: -40, startYaw: -0.8, goalX: -5, goalY: 11, goalZ: 38 },
},
] as const;
export const CROW_NAV_SCENARIOS = new ArenaScenarioRegistry<CrowScenarioParameters>(
"crow-nav-v1",
DEFINITIONS,
(base, random) => ({
...base,
startX: base.startX + random.between(-1, 1),
startY: base.startY + random.between(-0.4, 0.4),
startZ: base.startZ + random.between(-1, 1),
startYaw: base.startYaw + random.between(-0.08, 0.08),
goalX: base.goalX + random.between(-1, 1),
goalY: base.goalY + random.between(-0.4, 0.4),
goalZ: base.goalZ + random.between(-1, 1),
}),
);
export const CROW_NAV_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "crow-nav-v1",
version: 1,
title: "Crow waypoint navigation",
description: "Three-dimensional waypoint control over Tera's deterministic crow flight controller.",
simulator: "ActorController(kind=crow, mode=flight)",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["forward", "turn", "pitch", "climb", "glide"],
observationFields: [
"x", "y", "z", "yaw", "pitch", "speedMps", "verticalSpeedMps",
"goalX", "goalY", "goalZ", "deltaX", "deltaY", "deltaZ",
"distanceToGoalM", "altitudeBoundContact",
],
rewardComponents: {
progress: "Reduction in 3D distance to the waypoint.",
success: "Sparse waypoint completion bonus.",
time: "Per-step pressure that makes minimum-power inaction negative.",
energy: "Counterweight on powered speed and control demand.",
heading: "Small cost for pointing away from the target.",
safety: "Terminal flight-envelope contact penalty.",
},
safetyTerminals: ["flight-envelope-contact"],
scenarioIds: {
train: CROW_NAV_SCENARIOS.ids("train"),
dev: CROW_NAV_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "Minimum-power straight flight (forward=-1) misses the lateral waypoint and stays below zero return.",
scripted: "Proportional yaw and climb guidance reaches every public waypoint.",
},
});
export const CROW_NAV_INACTION: Readonly<CrowNavAction> = Object.freeze({
forward: -1, turn: 0, pitch: 0, climb: 0.03, glide: false,
});
function wrapAngle(value: number): number {
return ((value + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
}
export function crowNavScriptedBaseline(observation: CrowNavObservation): CrowNavAction {
const desiredYaw = Math.atan2(-observation.deltaX, -observation.deltaZ);
const headingError = wrapAngle(desiredYaw - observation.yaw);
return {
forward: 0.45,
turn: Math.max(-1, Math.min(1, headingError / 0.55)),
pitch: 0,
climb: Math.max(-0.8, Math.min(0.8, observation.deltaY / 10 - 0.2)),
glide: false,
};
}
export class CrowNavEnvironment extends BaseArenaEnvironment<
CrowNavAction,
CrowNavObservation,
CrowNavReward,
CrowSimulationSnapshot,
CrowScenarioParameters
> {
readonly manifest = CROW_NAV_MANIFEST;
protected readonly registry = CROW_NAV_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["crow-nav-v1"]!;
private controller: ActorController | null = null;
private previousGoalDistanceM = 0;
protected resetSimulation(scenario: ArenaScenario<CrowScenarioParameters>): CrowNavObservation {
const parameters = scenario.parameters;
this.controller = new ActorController({
kind: "crow",
mode: "flight",
identity: {
id: "arena-crow", displayName: "Arena Crow", authenticated: false, profile: {},
},
position: { x: parameters.startX, y: parameters.startY, z: parameters.startZ },
yaw: parameters.startYaw,
groundY: 0,
minFlightAltitude: 2,
maxFlightAltitude: 40,
horizontalBounds: BOUNDS,
fixedStepSeconds: FIXED_STEP,
});
this.previousGoalDistanceM = this.goalDistance();
return this.observation();
}
protected normalizeAction(action: CrowNavAction): CrowNavAction {
const axis = (value: number) => Math.max(-1, Math.min(1, Number.isFinite(value) ? value : 0));
return {
forward: axis(action?.forward),
turn: axis(action?.turn),
pitch: axis(action?.pitch),
climb: axis(action?.climb),
glide: action?.glide === true,
};
}
protected advanceSimulation(action: CrowNavAction): SimulationTransition<CrowNavObservation, CrowNavReward> {
const controller = this.requireController();
controller.stepFixed({
...action,
right: 0,
sprint: false,
modeRequest: "none",
kindRequest: "none",
reset: false,
});
const state = controller.state();
const distance = this.goalDistance();
const progress = this.previousGoalDistanceM - distance;
this.previousGoalDistanceM = distance;
const success = distance <= SUCCESS_RADIUS_M;
const horizontalContact =
state.x <= BOUNDS.minX || state.x >= BOUNDS.maxX ||
state.z <= BOUNDS.minZ || state.z >= BOUNDS.maxZ;
const safety = horizontalContact || state.altitudeBoundContact !== "none";
const observation = this.observation();
const desiredYaw = Math.atan2(-observation.deltaX, -observation.deltaZ);
const headingError = Math.abs(wrapAngle(desiredYaw - observation.yaw));
return {
observation,
rewardComponents: {
progress: progress * 0.045,
success: success ? 3 : 0,
time: -0.008,
energy: -0.001 * ((action.forward + 1) / 2 + Math.abs(action.turn) +
Math.abs(action.pitch) + Math.abs(action.climb)),
heading: -0.0008 * (headingError / Math.PI) ** 2,
safety: safety ? -3 : 0,
},
terminated: success || safety,
terminalReason: success ? "goal" : safety ? "flight-envelope-contact" : undefined,
};
}
protected simulationSnapshot(): CrowSimulationSnapshot {
return {
controller: this.requireController().snapshot(),
previousGoalDistanceM: this.previousGoalDistanceM,
};
}
protected restoreSimulation(snapshot: CrowSimulationSnapshot): CrowNavObservation {
this.resetSimulation(this.currentScenario());
this.requireController().restore(snapshot.controller);
this.previousGoalDistanceM = snapshot.previousGoalDistanceM;
return this.observation();
}
private observation(): CrowNavObservation {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return {
x: state.x,
y: state.y,
z: state.z,
yaw: state.yaw,
pitch: state.pitch,
speedMps: state.speedMps,
verticalSpeedMps: state.verticalSpeedMps,
goalX: goal.goalX,
goalY: goal.goalY,
goalZ: goal.goalZ,
deltaX: goal.goalX - state.x,
deltaY: goal.goalY - state.y,
deltaZ: goal.goalZ - state.z,
distanceToGoalM: this.goalDistance(),
altitudeBoundContact: state.altitudeBoundContact,
};
}
private goalDistance(): number {
const state = this.requireController().state();
const goal = this.currentScenario().parameters;
return Math.hypot(goal.goalX - state.x, goal.goalY - state.y, goal.goalZ - state.z);
}
private requireController(): ActorController {
if (!this.controller) throw new Error("crow environment has not been reset");
return this.controller;
}
}
@@ -1,253 +0,0 @@
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
import {
VehicleController,
type VehicleControllerSnapshot,
} from "../transport/vehicleController.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface DriveAction {
throttle: number;
brake: number;
steering: number;
handbrake: boolean;
}
export interface DriveObservation {
routeId: string;
progressM: number;
remainingM: number;
lateralOffsetM: number;
speedMps: number;
speedLimitMps: number;
steering: number;
guardrailContact: boolean;
roadName: string;
}
export type DriveReward = Record<
"progress" | "success" | "time" | "lane" | "speed" | "control" | "safety",
number
>;
interface DriveScenarioParameters {
routeId: string;
initialDistanceM: number;
initialLateralOffsetM: number;
initialSpeedMps: number;
targetTravelM: number;
}
interface DriveSimulationSnapshot {
controller: VehicleControllerSnapshot;
travelledM: number;
previousDistanceM: number;
}
const FIXED_STEP = 1 / 30;
const MAX_STEPS = 360;
const TRAVEL_SCALE = 10;
const DEFINITIONS = [
{
id: "train-us101-ventura",
split: "train" as const,
parameters: {
routeId: "la-sf-us-101", initialDistanceM: 25_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 850,
},
},
{
id: "train-i5-grapevine",
split: "train" as const,
parameters: {
routeId: "la-sf-i-5", initialDistanceM: 55_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 900,
},
},
{
id: "dev-us101-salinas",
split: "dev" as const,
parameters: {
routeId: "la-sf-us-101", initialDistanceM: 390_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 950,
},
},
{
id: "dev-i5-bay-approach",
split: "dev" as const,
parameters: {
routeId: "la-sf-i-5", initialDistanceM: 470_000,
initialLateralOffsetM: 0, initialSpeedMps: 4, targetTravelM: 800,
},
},
] as const;
export const DRIVE_101_SCENARIOS = new ArenaScenarioRegistry<DriveScenarioParameters>(
"drive-101-v1",
DEFINITIONS,
(base, random) => ({
...base,
initialDistanceM: base.initialDistanceM + random.between(-750, 750),
initialLateralOffsetM: random.between(-0.15, 0.15),
initialSpeedMps: base.initialSpeedMps + random.between(-0.4, 0.4),
targetTravelM: base.targetTravelM + random.between(-40, 40),
}),
);
export const DRIVE_101_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "drive-101-v1",
version: 1,
title: "California corridor driving",
description: "Manual route-relative driving on Tera's authored US-101 and I-5 plans.",
simulator: "VehicleController + CALIFORNIA_TRANSPORT",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["throttle", "brake", "steering", "handbrake"],
observationFields: [
"routeId", "progressM", "remainingM", "lateralOffsetM", "speedMps",
"speedLimitMps", "steering", "guardrailContact", "roadName",
],
rewardComponents: {
progress: "Forward route progress, normalized by the episode target.",
success: "Sparse completion bonus.",
time: "Per-step pressure; a stopped or coasting policy has a negative floor.",
lane: "Continuous lane-centering cost.",
speed: "Cost for exceeding 110% of the authored road limit.",
control: "Small counterweight on abrupt or conflicting control demand.",
safety: "Terminal guardrail-contact penalty.",
},
safetyTerminals: ["guardrail-contact"],
scenarioIds: {
train: DRIVE_101_SCENARIOS.ids("train"),
dev: DRIVE_101_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "Zero controls coast below the target and incur the time floor.",
scripted: "throttle=0.72, steering=0 completes every public scenario without contact.",
},
});
export const DRIVE_INACTION: Readonly<DriveAction> = Object.freeze({
throttle: 0, brake: 0, steering: 0, handbrake: false,
});
export function driveScriptedBaseline(): DriveAction {
return { throttle: 0.72, brake: 0, steering: 0, handbrake: false };
}
export class Drive101Environment extends BaseArenaEnvironment<
DriveAction,
DriveObservation,
DriveReward,
DriveSimulationSnapshot,
DriveScenarioParameters
> {
readonly manifest = DRIVE_101_MANIFEST;
protected readonly registry = DRIVE_101_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["drive-101-v1"]!;
private controller: VehicleController | null = null;
private travelledM = 0;
private previousDistanceM = 0;
protected resetSimulation(scenario: ArenaScenario<DriveScenarioParameters>): DriveObservation {
const parameters = scenario.parameters;
this.controller = new VehicleController(CALIFORNIA_TRANSPORT, {
routeId: parameters.routeId,
mode: "manual",
initialDistanceM: parameters.initialDistanceM,
initialLateralOffsetM: parameters.initialLateralOffsetM,
initialSpeedMps: parameters.initialSpeedMps,
fixedStepSeconds: FIXED_STEP,
maximumSpeedMps: 42,
guardrailOffsetM: 4.5,
travelScale: TRAVEL_SCALE,
});
this.travelledM = 0;
this.previousDistanceM = this.controller.state().distanceM;
return this.observation();
}
protected normalizeAction(action: DriveAction): DriveAction {
const finite = (value: number) => Number.isFinite(value) ? value : 0;
return {
throttle: Math.max(0, Math.min(1, finite(action?.throttle))),
brake: Math.max(0, Math.min(1, finite(action?.brake))),
steering: Math.max(-1, Math.min(1, finite(action?.steering))),
handbrake: action?.handbrake === true,
};
}
protected advanceSimulation(action: DriveAction): SimulationTransition<DriveObservation, DriveReward> {
const controller = this.requireController();
controller.stepFixed({ ...action, modeRequest: "manual", reset: false });
const state = controller.state();
const delta = Math.max(0, state.distanceM - this.previousDistanceM);
this.previousDistanceM = state.distanceM;
this.travelledM += delta;
const target = this.currentScenario().parameters.targetTravelM;
const success = this.travelledM >= target;
const limitMps = state.speedLimitMph * 0.44704;
const overspeed = Math.max(0, state.speedMps - limitMps * 1.1);
const safety = state.guardrailContact;
return {
observation: this.observation(),
rewardComponents: {
progress: delta / target * 2,
success: success ? 3 : 0,
time: -0.004,
lane: -0.002 * (state.lateralOffsetM / 2.5) ** 2,
speed: -0.004 * overspeed ** 2,
control: -0.0005 * (action.steering ** 2 + action.brake ** 2 +
(action.throttle > 0 && action.brake > 0 ? 1 : 0)),
safety: safety ? -3 : 0,
},
terminated: success || safety,
terminalReason: success ? "goal" : safety ? "guardrail-contact" : undefined,
};
}
protected simulationSnapshot(): DriveSimulationSnapshot {
return {
controller: this.requireController().snapshot(),
travelledM: this.travelledM,
previousDistanceM: this.previousDistanceM,
};
}
protected restoreSimulation(snapshot: DriveSimulationSnapshot): DriveObservation {
this.resetSimulation(this.currentScenario());
this.requireController().restore(snapshot.controller);
this.travelledM = snapshot.travelledM;
this.previousDistanceM = snapshot.previousDistanceM;
return this.observation();
}
private observation(): DriveObservation {
const state = this.requireController().state();
const target = this.currentScenario().parameters.targetTravelM;
return {
routeId: state.routeId,
progressM: this.travelledM,
remainingM: Math.max(0, target - this.travelledM),
lateralOffsetM: state.lateralOffsetM,
speedMps: state.speedMps,
speedLimitMps: state.speedLimitMph * 0.44704,
steering: state.steering,
guardrailContact: state.guardrailContact,
roadName: state.roadName,
};
}
private requireController(): VehicleController {
if (!this.controller) throw new Error("drive environment has not been reset");
return this.controller;
}
}
@@ -1,79 +0,0 @@
export { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
export { arenaChecksum, canonicalJson } from "./checksum.ts";
export { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
export {
ArenaScenarioRegistry,
type ArenaScenarioDefinition,
type ScenarioSampler,
} from "./scenarios.ts";
export { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
export {
ARENA_API_VERSION,
type ArenaEnvironment,
type ArenaInfo,
type ArenaManifest,
type ArenaReplayResult,
type ArenaResetResult,
type ArenaScenario,
type ArenaScenarioRequest,
type ArenaSnapshot,
type ArenaSourceHashes,
type ArenaSplit,
type ArenaStepResult,
type ArenaTraceEnvelope,
type ArenaTraceStep,
} from "./types.ts";
export {
DRIVE_101_MANIFEST,
DRIVE_101_SCENARIOS,
DRIVE_INACTION,
Drive101Environment,
driveScriptedBaseline,
type DriveAction,
type DriveObservation,
type DriveReward,
} from "./drive101.ts";
export {
OFFICE_NAV_INACTION,
OFFICE_NAV_MANIFEST,
OFFICE_NAV_SCENARIOS,
OfficeNavEnvironment,
officeNavScriptedBaseline,
type OfficeNavAction,
type OfficeNavObservation,
type OfficeNavReward,
} from "./officeNav.ts";
export {
CROW_NAV_INACTION,
CROW_NAV_MANIFEST,
CROW_NAV_SCENARIOS,
CrowNavEnvironment,
crowNavScriptedBaseline,
type CrowNavAction,
type CrowNavObservation,
type CrowNavReward,
} from "./crowNav.ts";
export {
CALIFORNIA_FLIGHT_INACTION,
CALIFORNIA_FLIGHT_MANIFEST,
CALIFORNIA_FLIGHT_SCENARIOS,
CaliforniaFlightEnvironment,
californiaFlightScriptedBaseline,
type CaliforniaFlightAction,
type CaliforniaFlightObservation,
type CaliforniaFlightReward,
} from "./californiaFlight.ts";
import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
import { DRIVE_101_MANIFEST } from "./drive101.ts";
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
/** Machine-readable public environment catalogue. */
export const ARENA_MANIFESTS = Object.freeze([
DRIVE_101_MANIFEST,
OFFICE_NAV_MANIFEST,
CROW_NAV_MANIFEST,
CALIFORNIA_FLIGHT_MANIFEST,
]);
@@ -1,250 +0,0 @@
import { Plan } from "../interiors/plan.ts";
import {
createWalker,
type WalkerController,
type WalkerState,
} from "../interiors/walker.ts";
import { FRONTIER_VALLEY } from "../offices/frontier-valley.ts";
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
import { ArenaScenarioRegistry } from "./scenarios.ts";
import {
ARENA_API_VERSION,
type ArenaManifest,
type ArenaScenario,
} from "./types.ts";
export interface OfficeNavAction {
x: number;
z: number;
}
export interface OfficeNavObservation {
levelId: string;
x: number;
z: number;
goalX: number;
goalZ: number;
deltaX: number;
deltaZ: number;
distanceToGoalM: number;
travelledM: number;
blockedStreak: number;
}
export type OfficeNavReward = Record<
"progress" | "success" | "time" | "control" | "collision" | "safety",
number
>;
interface OfficeScenarioParameters {
levelId: string;
startX: number;
startZ: number;
goalX: number;
goalZ: number;
}
interface OfficeSimulationSnapshot {
walker: WalkerState;
previousGoalDistanceM: number;
blockedStreak: number;
}
const FIXED_STEP = 0.1;
const MAX_STEPS = 160;
const SUCCESS_RADIUS_M = 0.45;
const PLAN = new Plan(FRONTIER_VALLEY, { depth: "public", warn: false });
const DEFINITIONS = [
{
id: "train-hangar-crossing",
split: "train" as const,
parameters: { levelId: "level-1", startX: 20, startZ: 18, goalX: 34, goalZ: 18 },
},
{
id: "train-galley-aisle",
split: "train" as const,
parameters: { levelId: "level-1", startX: 18, startZ: 21, goalX: 18, goalZ: 27 },
},
{
id: "dev-apron-approach",
split: "dev" as const,
parameters: { levelId: "level-1", startX: 36, startZ: 19, goalX: 46, goalZ: 19 },
},
{
id: "dev-south-aisle",
split: "dev" as const,
parameters: { levelId: "level-1", startX: 22, startZ: 20, goalX: 32, goalZ: 25 },
},
] as const;
export const OFFICE_NAV_SCENARIOS = new ArenaScenarioRegistry<OfficeScenarioParameters>(
"office-nav-v1",
DEFINITIONS,
(base, random) => {
const offsetX = random.between(-0.08, 0.08);
const offsetZ = random.between(-0.08, 0.08);
return {
...base,
startX: base.startX + offsetX,
startZ: base.startZ + offsetZ,
goalX: base.goalX - offsetX,
goalZ: base.goalZ - offsetZ,
};
},
);
export const OFFICE_NAV_MANIFEST: ArenaManifest = Object.freeze({
apiVersion: ARENA_API_VERSION,
id: "office-nav-v1",
version: 1,
title: "Frontier Valley office navigation",
description: "Headless navigation through the resolved public office plan and exact wall collision.",
simulator: "Plan(FRONTIER_VALLEY) + createWalker",
fixedStepSeconds: FIXED_STEP,
maxSteps: MAX_STEPS,
actionFields: ["x", "z"],
observationFields: [
"levelId", "x", "z", "goalX", "goalZ", "deltaX", "deltaZ",
"distanceToGoalM", "travelledM", "blockedStreak",
],
rewardComponents: {
progress: "Reduction in Euclidean distance to the goal.",
success: "Sparse arrival bonus.",
time: "Negative inaction floor and path-efficiency pressure.",
control: "Small cost on action magnitude.",
collision: "Cost when commanded travel is blocked by the resolved plan.",
safety: "Terminal repeated-collision penalty.",
},
safetyTerminals: ["collision-stall"],
scenarioIds: {
train: OFFICE_NAV_SCENARIOS.ids("train"),
dev: OFFICE_NAV_SCENARIOS.ids("dev"),
},
baselines: {
inaction: "x=0,z=0 reaches no goal and accumulates the time floor.",
scripted: "A normalized direct-to-goal vector completes all collision-clear public scenarios.",
},
});
export const OFFICE_NAV_INACTION: Readonly<OfficeNavAction> = Object.freeze({ x: 0, z: 0 });
export function officeNavScriptedBaseline(observation: OfficeNavObservation): OfficeNavAction {
const length = Math.hypot(observation.deltaX, observation.deltaZ);
if (length === 0) return { x: 0, z: 0 };
return { x: observation.deltaX / length, z: observation.deltaZ / length };
}
export class OfficeNavEnvironment extends BaseArenaEnvironment<
OfficeNavAction,
OfficeNavObservation,
OfficeNavReward,
OfficeSimulationSnapshot,
OfficeScenarioParameters
> {
readonly manifest = OFFICE_NAV_MANIFEST;
protected readonly registry = OFFICE_NAV_SCENARIOS;
protected readonly sourceHashes = ARENA_SOURCE_HASHES["office-nav-v1"]!;
private walker: WalkerController | null = null;
private previousGoalDistanceM = 0;
private blockedStreak = 0;
protected resetSimulation(scenario: ArenaScenario<OfficeScenarioParameters>): OfficeNavObservation {
const parameters = scenario.parameters;
this.walker = createWalker(PLAN, {
levelId: parameters.levelId,
position: { x: parameters.startX, z: parameters.startZ },
speed: 2,
fixedStep: FIXED_STEP,
maxCatchUpSteps: 1,
});
this.previousGoalDistanceM = this.goalDistance();
this.blockedStreak = 0;
return this.observation();
}
protected normalizeAction(action: OfficeNavAction): OfficeNavAction {
const x = Number.isFinite(action?.x) ? action.x : 0;
const z = Number.isFinite(action?.z) ? action.z : 0;
const length = Math.hypot(x, z);
return length > 1 ? { x: x / length, z: z / length } : { x, z };
}
protected advanceSimulation(
action: OfficeNavAction,
): SimulationTransition<OfficeNavObservation, OfficeNavReward> {
const walker = this.requireWalker();
const before = walker.state();
const after = walker.tick(FIXED_STEP, action);
const moved = Math.hypot(
after.position.x - before.position.x,
after.position.z - before.position.z,
);
const demand = Math.hypot(action.x, action.z);
const blocked = demand > 0.2 && moved < demand * 2 * FIXED_STEP * 0.2;
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
const distance = this.goalDistance();
const progress = this.previousGoalDistanceM - distance;
this.previousGoalDistanceM = distance;
const success = distance <= SUCCESS_RADIUS_M;
const failure = this.blockedStreak >= 8;
return {
observation: this.observation(),
rewardComponents: {
progress: progress * 0.22,
success: success ? 2.5 : 0,
time: -0.01,
control: -0.001 * demand ** 2,
collision: blocked ? -0.08 : 0,
safety: failure ? -1.5 : 0,
},
terminated: success || failure,
terminalReason: success ? "goal" : failure ? "collision-stall" : undefined,
};
}
protected simulationSnapshot(): OfficeSimulationSnapshot {
return {
walker: this.requireWalker().state(),
previousGoalDistanceM: this.previousGoalDistanceM,
blockedStreak: this.blockedStreak,
};
}
protected restoreSimulation(snapshot: OfficeSimulationSnapshot): OfficeNavObservation {
this.resetSimulation(this.currentScenario());
this.requireWalker().restore(snapshot.walker);
this.previousGoalDistanceM = snapshot.previousGoalDistanceM;
this.blockedStreak = snapshot.blockedStreak;
return this.observation();
}
private observation(): OfficeNavObservation {
const state = this.requireWalker().state();
const goal = this.currentScenario().parameters;
return {
levelId: state.levelId,
x: state.position.x,
z: state.position.z,
goalX: goal.goalX,
goalZ: goal.goalZ,
deltaX: goal.goalX - state.position.x,
deltaZ: goal.goalZ - state.position.z,
distanceToGoalM: this.goalDistance(),
travelledM: state.distance,
blockedStreak: this.blockedStreak,
};
}
private goalDistance(): number {
const state = this.requireWalker().state();
const goal = this.currentScenario().parameters;
return Math.hypot(goal.goalX - state.position.x, goal.goalZ - state.position.z);
}
private requireWalker(): WalkerController {
if (!this.walker) throw new Error("office environment has not been reset");
return this.walker;
}
}
@@ -1,35 +0,0 @@
/** Seed normalization and deterministic PRNG used only for scenario materialization. */
export function normalizeArenaSeed(value: number): number {
if (!Number.isSafeInteger(value)) throw new RangeError("arena seed must be a safe integer");
return value >>> 0;
}
export class ArenaRandom {
private state: number;
constructor(seed: number) {
this.state = normalizeArenaSeed(seed) || 0x6d2b79f5;
}
next(): number {
let value = this.state += 0x6d2b79f5;
value = Math.imul(value ^ value >>> 15, value | 1);
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
this.state = value >>> 0;
return ((value ^ value >>> 14) >>> 0) / 4_294_967_296;
}
between(min: number, max: number): number {
return min + (max - min) * this.next();
}
}
export function deriveArenaSeed(seed: number, label: string): number {
let value = normalizeArenaSeed(seed) ^ 0x811c9dc5;
for (let index = 0; index < label.length; index += 1) {
value ^= label.charCodeAt(index);
value = Math.imul(value, 0x01000193) >>> 0;
}
return value;
}
@@ -1,75 +0,0 @@
import { arenaChecksum } from "./checksum.ts";
import { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
import type { ArenaScenario, ArenaScenarioRequest, ArenaSplit } from "./types.ts";
export interface ArenaScenarioDefinition<P extends object> {
id: string;
split: ArenaSplit;
parameters: P;
}
export type ScenarioSampler<P extends object> = (parameters: Readonly<P>, random: ArenaRandom) => P;
/** Immutable public scenario catalogue with seeded, reproducible materialization. */
export class ArenaScenarioRegistry<P extends object> {
readonly envId: string;
readonly definitions: readonly ArenaScenarioDefinition<P>[];
private readonly byId = new Map<string, ArenaScenarioDefinition<P>>();
private readonly sampler: ScenarioSampler<P>;
constructor(
envId: string,
definitions: readonly ArenaScenarioDefinition<P>[],
sampler: ScenarioSampler<P>,
) {
if (definitions.length === 0) throw new RangeError("arena scenario registry cannot be empty");
this.envId = envId;
this.definitions = definitions.map((definition) => ({
...definition,
parameters: structuredClone(definition.parameters),
}));
this.sampler = sampler;
for (const definition of this.definitions) {
if (!definition.id || this.byId.has(definition.id)) {
throw new RangeError(`duplicate or empty scenario id: ${definition.id}`);
}
this.byId.set(definition.id, definition);
}
for (const split of ["train", "dev"] as const) {
if (!this.definitions.some((definition) => definition.split === split)) {
throw new RangeError(`${envId} must expose at least one ${split} scenario`);
}
}
}
ids(split: ArenaSplit): readonly string[] {
return this.definitions
.filter((definition) => definition.split === split)
.map((definition) => definition.id);
}
resolve(seedValue: number, request: string | ArenaScenarioRequest): ArenaScenario<P> {
const seed = normalizeArenaSeed(seedValue);
let definition: ArenaScenarioDefinition<P> | undefined;
if (typeof request === "string") {
definition = this.byId.get(request);
} else if (request.id !== undefined) {
definition = this.byId.get(request.id);
if (definition?.split !== request.split) definition = undefined;
} else {
const candidates = this.definitions.filter((entry) => entry.split === request.split);
definition = candidates[seed % candidates.length];
}
if (!definition) throw new RangeError(`unknown ${this.envId} scenario`);
const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`));
const parameters = this.sampler(definition.parameters, random);
const hash = arenaChecksum({
envId: this.envId,
id: definition.id,
split: definition.split,
seed,
parameters,
});
return { id: definition.id, split: definition.split, seed, parameters, hash };
}
}
@@ -1,24 +0,0 @@
import type { ArenaSourceHashes } from "./types.ts";
/**
* SHA-256 values are generated from committed source files by
* `npm run arena:source-hashes`. Placeholder values are replaced before commit.
*/
export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> = Object.freeze({
"drive-101-v1": {
environment: "sha256:a8f3bb5c04985215a2b98b75dd2ce82a13b794b9f4933536c5821a49cf1f8125",
simulator: "sha256:be24cacb480279e88c64e72803d2a8a94db1b084312b64da55050d2ca95c90af",
},
"office-nav-v1": {
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
},
"crow-nav-v1": {
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
},
"california-flight-v1": {
environment: "sha256:8833a25d5da376278ae56da1056ae7fa20ed243b8de0b3ef1c10d5317afbcb3f",
simulator: "sha256:997aa7c63779ae77af44d584758f55b6679836305115aef5e13f207232ec4d6f",
},
});
@@ -1,146 +0,0 @@
/** Renderer-independent, JSON-safe reinforcement-learning contract. */
export const ARENA_API_VERSION = "tera.arena/v1" as const;
export type ArenaSplit = "train" | "dev";
export interface ArenaScenarioRequest {
/** Public splits only. Held-out/private evaluation belongs outside this package. */
split: ArenaSplit;
/** Omit to choose deterministically from the requested split using the seed. */
id?: string;
}
export interface ArenaScenario<P extends object = Record<string, number | string | boolean>> {
id: string;
split: ArenaSplit;
seed: number;
parameters: P;
hash: string;
}
export interface ArenaSourceHashes {
/** Hash of the environment implementation source, pinned and checked in CI. */
environment: string;
/** Hash of the renderer-independent controller/plan sources the environment wraps. */
simulator: string;
}
export interface ArenaInfo {
apiVersion: typeof ARENA_API_VERSION;
envId: string;
envVersion: number;
envHash: string;
scenarioId: string;
scenarioSplit: ArenaSplit;
scenarioHash: string;
simulatorHash: string;
environmentSourceHash: string;
seed: number;
step: number;
maxSteps: number;
terminalReason: string | null;
stateChecksum: string;
}
export interface ArenaResetResult<O> {
observation: O;
info: ArenaInfo;
}
export interface ArenaStepResult<O, R extends Record<string, number>> {
observation: O;
reward: number;
rewardComponents: R;
terminated: boolean;
truncated: boolean;
info: ArenaInfo;
}
export interface ArenaManifest {
apiVersion: typeof ARENA_API_VERSION;
id: string;
version: number;
title: string;
description: string;
simulator: string;
fixedStepSeconds: number;
maxSteps: number;
actionFields: readonly string[];
observationFields: readonly string[];
rewardComponents: Readonly<Record<string, string>>;
safetyTerminals: readonly string[];
scenarioIds: Readonly<Record<ArenaSplit, readonly string[]>>;
baselines: {
inaction: string;
scripted: string;
};
}
export interface ArenaSnapshot<S> {
apiVersion: typeof ARENA_API_VERSION;
envId: string;
envVersion: number;
envHash: string;
seed: number;
scenarioId: string;
scenarioSplit: ArenaSplit;
scenarioHash: string;
step: number;
cumulativeReward: number;
terminated: boolean;
truncated: boolean;
terminalReason: string | null;
simulation: S;
checksum: string;
}
export interface ArenaTraceStep<A, R extends Record<string, number>> {
index: number;
action: A;
reward: number;
rewardComponents: R;
terminated: boolean;
truncated: boolean;
terminalReason: string | null;
stateChecksum: string;
}
export interface ArenaTraceEnvelope<A, R extends Record<string, number>> {
apiVersion: typeof ARENA_API_VERSION;
envId: string;
envVersion: number;
envHash: string;
scenarioId: string;
scenarioSplit: ArenaSplit;
scenarioHash: string;
sourceHashes: ArenaSourceHashes;
seed: number;
initialStateChecksum: string;
steps: readonly ArenaTraceStep<A, R>[];
finalStateChecksum: string;
cumulativeReward: number;
checksum: string;
}
export interface ArenaReplayResult<O> {
observation: O;
steps: number;
cumulativeReward: number;
finalStateChecksum: string;
}
export interface ArenaEnvironment<
A,
O,
R extends Record<string, number>,
S,
> {
readonly manifest: ArenaManifest;
reset(seed: number, scenario: string | ArenaScenarioRequest): ArenaResetResult<O>;
step(action: A): ArenaStepResult<O, R>;
snapshot(): ArenaSnapshot<S>;
restore(snapshot: ArenaSnapshot<S>): ArenaResetResult<O>;
trace(): ArenaTraceEnvelope<A, R>;
replay(trace: ArenaTraceEnvelope<A, R>): ArenaReplayResult<O>;
}
@@ -1,360 +0,0 @@
/**
* The contract between the engine and everything else.
*
* The engine renders a `City` and a list of `Marker`s. It does not know what a
* marker *is* not that markers are companies, not that a red one means a
* rejection. That mapping lives in an adapter, outside this package, which is
* what lets one renderer serve a private career map, a public sector map, and
* whatever anyone else builds, without any of them being a fork.
*
* See ARCHITECTURE.md §3.3.
*/
/** `[latitude, longitude]`, always in that order. */
export type LatLng = [number, number];
// ---- Geography ------------------------------------------------------------
/**
* A hill, as a radial peak summed into the heightfield.
*
* `elevation` is metres above sea level at the summit. `radius` is roughly
* where the hill meets the flats, in degrees of latitude.
*/
export interface Hill {
name: string;
lat: number;
lng: number;
elevation: number;
radius: number;
}
/**
* Where buildings go, how tall, and on what street grid.
*
* `gridAngle` is the district's street bearing in radians. It is per-district
* rather than per-city because that is the fact on the ground in San Francisco:
* the grid north of Market and the grid south of it are 46° out of true, and
* reproducing that is most of what makes the city recognisable from above.
*/
export interface District {
id: string;
name: string;
polygon: LatLng[];
/** Street bearing, radians clockwise from true north. */
gridAngle: number;
minHeight: number;
maxHeight: number;
/** Chance a given lot gets a tower rather than a low-rise. */
towerChance: number;
/** Facade palette key; see `blocks.ts`. */
palette: "downtown" | "residential" | "industrial";
/** Fraction of lots that get built on at all. Defaults to 0.88. */
coverage?: number;
}
/** A building placed by hand because the eye goes looking for it. */
export interface Landmark {
name: string;
lat: number;
lng: number;
/** Roof height in metres. */
height: number;
/** Half-width in degrees of longitude. */
footprint: number;
shape: "box" | "pyramid" | "tower" | "cylinder";
color?: number;
label?: boolean;
}
export interface Bridge {
name: string;
/** Deck centreline. Both ends should run onto land. */
path: LatLng[];
towers: LatLng[];
towerHeight: number;
deckHeight: number;
/** Suspension sag as a fraction of tower height. */
sag: number;
color: number;
}
export interface Road {
path: LatLng[];
width: number;
kind: "street" | "freeway";
}
/**
* A named destination, as the interface knows it: what the legend prints and
* what `flyTo` is keyed on.
*
* Split out of `Chapter` because an office has exactly the same idea a short
* list of places you can jump to but positions them in metres, not in
* latitude and longitude. Only this half of a chapter is shared; the pose is
* not. `number` and `description` are optional here and required on `Chapter`,
* because a city's chapters are a numbered tour with a sentence each and an
* office's views are usually just "Reception" and "The desk bay".
*/
export interface View {
id: string;
label: string;
shortLabel: string;
number?: string;
description?: string;
}
/** A camera destination, and a sentence about why it is on the map. */
export interface Chapter extends View {
number: string;
description: string;
focus: {
lat: number;
lng: number;
distance: number;
height: number;
rotation: number;
};
}
/**
* A rectangle rendered at fine terrain resolution.
*
* SF declares one covering the whole city and behaves as if this did not exist.
* LA needs six DTLA, Santa Monica, Culver, Irvine, Pasadena, downtown
* Riverside with the basin between them coarse, because LA/OC/Riverside is
* roughly fourteen times SF's area and a uniform 45 m lattice over it would be
* 4.6M points. See ARCHITECTURE.md §5.
*/
export interface FocusRegion {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}
/**
* Everything the engine needs to draw a place. Pure data a city pack must
* contain no code, so that adding one is a contribution anybody can review.
*/
export interface City {
id: string;
name: string;
/** Map centre, and the origin of scene space. */
center: { lat: number; lng: number };
/** Scene bounds. Everything outside this is open water or off-frame. */
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
/**
* Degrees to scene units, for latitude. Longitude is derived as
* `latScale * cos(center.lat)` so the place keeps its true proportions.
*/
latScale: number;
/**
* How much taller than life the vertical is. Terrain and buildings share it,
* so they stay honest relative to each other.
*/
verticalExaggeration: number;
/** Ground-cell size inside a focus region, in degrees. */
cellLat: number;
cellLng: number;
/** Multiplier applied to cell size outside every focus region. 1 = uniform. */
coarseFactor?: number;
focusRegions?: FocusRegion[];
/** Distance from open water, in degrees, over which relief ramps to zero. */
coastFalloff: number;
landmasses: LatLng[][];
parks: LatLng[][];
inlandWater: LatLng[][];
hills: Hill[];
districts: District[];
landmarks: Landmark[];
bridges: Bridge[];
roads: Road[];
chapters: Chapter[];
/** Palette overrides; every field is optional. */
palette?: Partial<ScenePalette>;
}
export interface ScenePalette {
skyTop: number;
skyHorizon: number;
sea: number;
lake: number;
shore: number;
sand: number;
flats: number;
upland: number;
park: number;
parkHigh: number;
}
// ---- Lighting -------------------------------------------------------------
/**
* Everything the light rig needs, as plain numbers.
*
* This is a *state*, not an observation: `Environment` `{ time, sun, weather }`
* is what the world is doing, and a `LightingState` is what that means for the
* rig. `Atmosphere` owns the conversion and is the only thing allowed to make
* one; a scene applies it and never writes back. Two modules both constructing
* and mutating the same three lights is the failure this shape exists to
* prevent. See CONTRACT.md §4.
*
* Colours are `0xrrggbb`, matching `ScenePalette` and three.js.
*/
export interface LightingState {
sun: {
/**
* Unit vector from the scene toward the sun. Distance is deliberately
* absent: how far away to place the light is a fact about the scale of the
* scene, and the sun does not know whether it is shining on 94 m per unit
* or on 1 m per unit.
*/
direction: [number, number, number];
color: number;
intensity: number;
};
hemisphere: { sky: number; ground: number; intensity: number };
ambient: { color: number; intensity: number };
/**
* Background gradient, or `null` to leave the background alone which is
* what an interior wants, since it has walls and no horizon.
*/
sky: { top: number; horizon: number } | null;
/** `null` for no fog at all. An office gets none. */
fog: { color: number; near: number; far: number } | null;
}
// ---- Markers --------------------------------------------------------------
/**
* A thing worth pointing at, minus where it is.
*
* `colorKey` is deliberately opaque to the engine it indexes into a palette
* the caller supplies. The engine will not learn what "rejected" means.
*
* This is the half that survives a change of coordinate system: a pin on a city
* at 37.79 N, -122.40 E and a pin on a desk 4.2 m along the east wall are the
* same kind of thing to everything downstream of the geometry, so an office can
* carry its own positions and still hand a `Pin` to the same detail card.
*/
export interface Pin {
id: string;
label: string;
colorKey: string;
/** Optional href for the detail card. */
url?: string;
/** Optional one-liner for the detail card. */
blurb?: string;
}
/**
* A small, map-scale building drawn in place of a pin.
*
* This is deliberately a glyph rather than an architectural model. A city is
* normally viewed from kilometres away, so loading a façade kit with one mesh
* per window buys triangles nobody can see and gives up the one-draw-call city
* that `blocks.ts` works hard to preserve. The glyph keeps the useful grammar
* a ground floor, repeated bays, a roof line and a deterministic silhouette
* and expresses it in a handful of procedural meshes.
*
* Metres are used here because these values describe a real building even
* though the city scene does not: `World` converts horizontal metres with
* `metresPerUnit` and vertical metres with the city's exaggeration.
*/
export interface BuildingGlyph {
kind: "building";
width: number;
depth: number;
height: number;
/** Approximate occupied floors; used to choose the façade rhythm. */
storeys: number;
/** Compass bearing of local Z, degrees clockwise from true north. */
heading: number;
/** The silhouette family, not a tenant or product category. */
profile: "tower" | "hangar" | "courtyard" | "block";
/** Stable variation for bay widths and lit panes. */
seed?: number;
/** Neutral shell colour. The marker palette still supplies the door/accent. */
bodyColor?: number;
}
/** A `Pin` placed on a city, in degrees. */
export interface Marker extends Pin {
lat: number;
lng: number;
/**
* False when the position is a placeholder rather than a real address.
* Rendered distinctly, because inventing a location on a map whose premise
* is that it is real is worse than admitting the gap.
*/
located?: boolean;
/** Optional map-scale representation. Omit it for the ordinary pin. */
glyph?: BuildingGlyph;
}
/** Caller-supplied `colorKey` -> colour. */
export type MarkerPalette = Record<string, number>;
// ---- Flights --------------------------------------------------------------
export interface Aircraft {
id: string;
lat: number;
lng: number;
/** Barometric altitude in metres. */
altitude: number;
/** Degrees clockwise from true north. */
heading: number;
callsign?: string;
}
/**
* Where aircraft come from.
*
* An interface rather than a client because the obvious source FlightRadar24
* cannot ship in an Apache-2.0 repo: their terms forbid scraping and forbid
* redistributing the data. This package ships a simulator and open community
* sources; anything commercial is an adapter in a private deployment. See
* ARCHITECTURE.md §4.
*/
export interface FlightSource {
/** Current traffic. Called on a timer; must be cheap and must not throw. */
poll(): Promise<Aircraft[]> | Aircraft[];
/** Seconds between polls. */
interval: number;
dispose?(): void;
}
// ---- Satellites -----------------------------------------------------------
/**
* Which constellation a satellite belongs to, as far as anyone looking up cares.
*
* A **display bucket and not a taxonomy**: no orbital regime, no operator, no
* launch date. `engine/satellites.ts` picks a colour from it and nothing else
* reads it, and a field that carried more would be a field that got wrong.
*
* `starlink` is broken out from `comms` because it is the reason the layer
* exists it is the constellation people can see with their eyes, in a train,
* forty minutes after sunset. `other` is not a failure; most of the catalogue is
* other.
*
* This lives here rather than in `server/wire.ts` for the same reason `Marker`
* does: the renderer owns the vocabulary and the wire carries it, so a body off
* the network is handed to the engine as-is with no adapter in between.
*/
export type SatelliteGroup =
| "starlink"
| "comms"
| "navigation"
| "station"
| "weather"
| "other";
File diff suppressed because it is too large Load Diff
@@ -1,644 +0,0 @@
/**
* The office contract what an office pack is allowed to say.
*
* This is the interiors half of `engine/types.ts`, and it obeys the same rule:
* the engine renders what an `Office` describes and takes no position on what it
* means. It does not know that a room is a room because people meet in it, that
* `zone: "eng"` is a team, or that anybody is sitting anywhere. See
* ARCHITECTURE.md §3.3 and CONTRACT.md §2.
*
* **Everything here is strictly JSON-serialisable.** No functions, no classes,
* no getters, no `THREE` types, no `Date`. A pack hand-written as a `.ts` module
* and a pack arriving as a `.json` body over HTTP have to be literally the same
* thing the moment one of them can carry a callback, the other stops being a
* pack and starts being a second format nobody maintains.
*
* `Plan` (`src/interiors/plan.ts`) is the only thing that turns an `Office` into
* geometry. Its output wall runs, collision segments, resolved placements is
* a build product and is deliberately not authorable here.
*
* ### Coordinates and units
*
* Offices are authored in **metres, 1 unit = 1 m**, which is also how assets are
* authored (CONTRACT.md §3). This is not the city's scale and cannot be: SF puts
* one scene unit at ~94 m with 3.6x vertical exaggeration, which is why an
* office gets its own `THREE.Scene`.
*
* The floor is the **XZ plane** with **+Y up**, three.js's convention. Plan view
* throughout this file means looking down at that plane with +X to the right and
* +Z down the page.
*/
import type { BuildingGlyph, Pin, View } from "../engine/types.ts";
// ---- Geometry -------------------------------------------------------------
/**
* A point on the floor plane, in metres.
*
* Named fields rather than a `[number, number]` tuple unlike the city's
* `LatLng`, where the pair has an obvious reading, `[4, 6]` in an office gives a
* reader no way to tell whether the second number is depth or height. The field
* is called `z` precisely so that the answer is on the page.
*/
export interface Point2 {
x: number;
z: number;
}
/**
* A polygon, in plan.
*
* Do **not** repeat the first point at the end; the outline is implicitly
* closed. Author them counter-clockwise in plan view. `Plan` re-winds anything
* that arrives the other way round rather than rendering a black hole, so this
* is a style rule and not a trap.
*/
export type Outline = Point2[];
/**
* Yaw about the +Y axis, in radians. Zero faces **-Z**, and the angle increases
* counter-clockwise seen from above.
*
* That is exactly three.js's `object.rotation.y`, and it is stated in those
* terms on purpose. A plan-space "degrees clockwise from north" angle which is
* what `District.gridAngle` and `Aircraft.heading` use, because for a city it
* reads better would need a sign flip on the way into the scene, and a sign
* flip that lives in one place is a sign flip that eventually gets applied
* twice. Nothing converts this.
*/
export type Yaw = number;
// ---- Identifiers ----------------------------------------------------------
/**
* A namespaced asset id, like `"tera:desk.workstation"`.
*
* **`src/assets/kit.ts` is the authority** on what ids exist and what they
* build; this alias exists so that the office contract does not import the mesh
* library. It is the same type by the same name in both places one string, one
* meaning not two ideas that collided. Interiors is data; assets is code that
* turns data into geometry, and data should not depend on it to be parsed,
* validated or stored.
*
* The `tera:` namespace is the one this repo ships. A self-hoster registers
* `acme:desk.standing` with `overrides: "tera:desk.workstation"` and reskins the
* reference office without forking it. An id with no registration resolves to a
* placeholder box rather than throwing, because an office pack with one typo in
* it should still open.
*/
export type AssetId = string;
/**
* A material id for a floor, wall or ceiling finish, like `"tera:carpet.loop"`.
*
* Same arrangement as `AssetId`, one level down: `src/assets/materials.ts` holds
* the `MaterialRegistry` and the closed `SurfaceRole` union it is keyed on, and
* this alias is the loose string an authored pack carries. The name differs from
* `SurfaceRole` deliberately a type name exported twice meaning two different
* things is the exact failure CONTRACT.md §4 was written to stop.
*
* There is no per-instance tint here, unlike `Prop.colorKey`. One blue meeting
* room is a *material* register `acme:paint.blue` and point the wall at it
* whereas one red chair in a row of grey ones is genuinely an instance. Giving
* surfaces a tint key as well would duplicate the override mechanism that
* already exists and leave two ways to answer the same question.
*/
export type SurfaceId = string;
// ---- Audience -------------------------------------------------------------
/**
* Who a piece of a pack is built for.
*
* An office has two audiences now. `office.lumbridgecorp.com` is a front door
* anyone can walk up to, and the same building signed in is the one with the
* people in it. Marking a room, a prop, a bank, a seat, a zone or a viewpoint
* `"private"` says: this exists for the second audience and not the first, and
* a public build must never construct it.
*
* Absent means `"public"`. Every pack written before this field existed keeps
* working, and a pack that never thinks about it never has to.
*
* ### It is not built, rather than built and hidden
*
* `Plan` drops private items during resolution, so a public build has no
* `PropPlacement`, no `ResolvedSeat` and no mesh for them at all. Building them
* and setting `visible = false` would leave every one of them in
* `scene.traverse`, in the devtools scene graph and in a `JSON.stringify` of the
* plan a data leak dressed as a privacy feature. See `PlanOptions.depth` in
* `plan.ts`, which is where the drop happens.
*
* ### Walls have no audience, and cannot get one
*
* A wall is the difference between a floor plan and a floor, and it is what the
* collision pass is made of. A building whose partitions come and go with who is
* looking at it is two different buildings, and the walk-mode collider would be
* describing whichever one you were not in. Mark what stands in the room. A
* `Room` *can* be marked, but a private room takes its floor slab and its
* ceiling with it and leaves a hole in the plan, so that is nearly always the
* wrong field to reach for mark the contents.
*
* ### This is a UI tier and it is not a security boundary
*
* **A pack is bundled into the static build, so everything in it is public by
* construction**, whatever this field says. The file is in the JavaScript;
* anyone who wants the private half can read it out of the bundle in ten
* seconds. What the field buys is that an anonymous visitor is not *shown* the
* parts of a building that are nobody's business. That is a product decision
* worth making, and it is not the same act as withholding them.
*
* The thing that is genuinely private is `Presence` who is in today and where
* they sit and it is private because it never appears in a pack at all. It
* arrives from an API over authentication, and **the API is what refuses an
* anonymous caller**. Nothing on this side of the wire can enforce that. A pack
* that puts something actually secret behind `audience: "private"` has published
* it, and the reason this paragraph is here is so that nobody discovers that
* later.
*/
export type Audience = "public" | "private";
// ---- The office -----------------------------------------------------------
/**
* One building's interior: the whole authored pack, and the thing a self-hoster
* copies to make their own.
*
* An office contains no people. `Presence` is runtime data that arrives
* separately and binds by seat id see the note on that type, which is the
* single most important paragraph in this file.
*/
export interface Office {
id: string;
name: string;
/**
* Ground floor first. An office with one storey declares one level; nothing
* else in the format changes.
*/
levels: Level[];
/**
* Named camera poses. `viewpoints[0]` is where you arrive, so put reception
* or whatever the pack wants a first impression to be at the front.
*/
viewpoints: Viewpoint[];
/**
* Where on the earth this building stands, if it stands anywhere.
*
* Optional, and its absence is a supported state rather than a gap: a pack
* with no `site` renders exactly as every pack did before this field existed,
* under the fixed interior rig. That matters because the format's promise is
* that you can author a floor plan without an account, a key or a coordinate.
*
* What it buys when you do supply it is the sun. `interiors/types.ts` used to
* say flatly that an office has no orientation on the earth, and that was
* true and also the thing standing between an office and real daylight: you
* cannot put the sun in the right place without knowing both where the
* building is and which way it faces.
*/
site?: OfficeSite;
meta?: OfficeMeta;
}
/**
* A building's address in the world, in the four numbers that change what you
* see out of the window.
*
* Deliberately not a street address. Nothing here is geocoded, nothing is looked
* up, and no network call can be made from any of it see CONTRACT.md §8 for
* why a coordinate's provenance is a licensing question in this repo. These are
* numbers a pack author types, the same as every other number in a pack.
*/
export interface OfficeSite {
/** Degrees north. */
lat: number;
/** Degrees east. */
lng: number;
/**
* How far this pack's level-0 floor sits above **the ground outside**, in
* metres. Not above sea level.
*
* The renderer's question is "how high up am I", not "how tall is the tower",
* and this is the number that answers it: it is exactly where the horizon
* goes. An office on the 48th floor of a downtown tower is a couple of hundred
* metres here and a shed on reclaimed land is three, and that single
* difference is most of what makes the two feel like different places.
*/
elevation: number;
/**
* The compass bearing, in degrees clockwise from true north, that the pack's
* **Z direction** points along.
*
* `0` means the pack's "north" really is north, which is what the reference
* pack's comments have always assumed while being careful to say it was only a
* convenience. A building rotated to face the street sets this, and the sun
* then comes through the windows it actually comes through.
*
* Degrees clockwise from north, like `District.gridAngle` and unlike `Yaw`
* a bearing reads better for a thing on a map, and radians-counter-clockwise
* reads better for a thing in a scene graph. The conversion happens once, at
* the point the two meet.
*/
heading: number;
/**
* What to call the place, for a caption. `null` or absent where the building
* has no name worth printing.
*/
label?: string;
/**
* The public silhouette the city can draw before this office pack is loaded.
*
* Optional for the same reason `site` is optional: an office does not need a
* world address to render, and a sited office does not need to claim that its
* exterior is known. When present, `offices/sites.ts` hands this plain data to
* the generic marker layer; the city still never imports an office pack.
*/
exterior?: BuildingGlyph;
}
/**
* Provenance for a pack, and nothing the renderer reads.
*
* Optional, but a pack meant to be shared should fill in `author` and `license`.
* The art in this repo is Apache-2.0 with the artistic output additionally
* dedicated under CC0-1.0 (CONTRACT.md §3.1); a pack built elsewhere is under
* whatever its author says here, and saying nothing helps nobody.
*/
export interface OfficeMeta {
description?: string;
author?: string;
/** SPDX identifier where there is one, e.g. `"CC0-1.0"`. */
license?: string;
version?: string;
/** ISO-8601 date string. A string, not a `Date` — this has to survive JSON. */
updated?: string;
}
/**
* One storey.
*
* The three `wall*` fields are the storey's defaults, not a constraint: an
* individual `Wall` overrides any of them. They live here because a floor of
* forty walls that are all 3 m of painted plasterboard should say so once, and
* the interesting wall the 1.4 m partition around the desk bay should be the
* one that stands out in the source.
*/
export interface Level {
id: string;
name: string;
/** Floor slab height above the office origin, in metres. Ground is `0`. */
elevation: number;
/** Storey height: the default top of a wall, measured from this floor. */
wallHeight: number;
/** Default wall thickness in metres. `Plan` uses 0.12 when this is absent. */
wallThickness?: number;
/** Default wall finish. */
wallSurface?: SurfaceId;
floorplan: Floorplan;
}
/**
* Everything on one storey.
*
* `rooms` and `walls` are required because a level without them is not a level;
* the rest are optional because a bare lobby genuinely has no desk banks, and a
* pack arriving over HTTP will drop empty arrays. Consumers read the optional
* ones as `?? []`.
*/
export interface Floorplan {
rooms: Room[];
walls: Wall[];
props?: Prop[];
deskBanks?: DeskBank[];
seats?: Seat[];
zones?: Zone[];
}
// ---- Rooms ----------------------------------------------------------------
/**
* A floor slab with a name.
*
* **A room implies no walls.** This is the load-bearing half of CONTRACT.md §2:
* rooms are surfaces, walls are a separate explicit list, and the two are not
* derived from each other. Deriving walls from shared room edges sounds tidy
* until it needs float-equality dedup to decide whether two rooms touch, and
* then it is a source of gaps that only appear in one build out of ten.
*
* Rooms may overlap and may leave gaps. An open-plan floor is one big room with
* a handful of walls standing on it.
*/
export interface Room {
id: string;
name: string;
outline: Outline;
floor: SurfaceId;
/**
* Omit for the level default. Explicit `null` means **no ceiling at all**
* an atrium, a double-height void, or a cutaway you want to look down into.
*/
ceiling?: RoomCeiling | null;
/**
* See `Audience`. Absent means public. A private room takes its floor slab and
* its ceiling with it and leaves a hole in the plan, which is almost never
* what is wanted mark the props in the room instead.
*/
audience?: Audience;
}
/** A ceiling override for one room. Both fields fall back to the level. */
export interface RoomCeiling {
/** Metres above this level's floor. */
height?: number;
surface?: SurfaceId;
}
// ---- Walls and openings ---------------------------------------------------
/**
* A single straight wall segment, from `from` to `to`, centred on that line.
*
* Walls are an explicit list rather than something inferred from room edges, and
* this is the decision the rest of the interiors code is built on. The pass that
* splits a wall around its openings has to run anyway to produce the solid runs
* you can see; running it once produces the **walk-mode collision segments for
* free**, with the gaps in exactly the places you can walk through. Any other
* arrangement keeps two lists in sync by hand.
*
* A wall belongs to no room. It stands where it is put.
*/
export interface Wall {
id: string;
from: Point2;
to: Point2;
/** Metres. Falls back to the level's `wallThickness`. */
thickness?: number;
/** Metres above this level's floor. Falls back to the level's `wallHeight`. */
height?: number;
surface?: SurfaceId;
/** Doors, windows and arches punched out of this wall. Order is irrelevant. */
openings?: Opening[];
}
export type OpeningKind = "door" | "window" | "arch";
/**
* A hole in a wall, described as a 1-D interval along it.
*
* `start` is measured **from the wall's `from` end**, along the wall, in metres;
* `width` runs on from there. That is the whole of the horizontal placement
* an opening has no position of its own and cannot drift off its wall, which is
* the point of expressing it this way.
*
* Doors and windows are openings, never placeable assets. There is no
* `tera:shell.door`: shipping both a door prop and a door-shaped hole would put
* every opening in the scene twice, or worse, because it is invisible until
* somebody walks through a wall leave the collider with no gap where the door
* is. `Plan` hands each solid run to a parameterised `wallRun` part, and the
* frame, leaf or glazing is drawn by the opening itself.
*
* Typical values, in metres: a door is `sill: 0, head: 2.1`; a window is
* `sill: 0.9, head: 2.2`; an arch is `sill: 0, head: 2.4`. They are required
* rather than defaulted by kind, because a data contract with hidden per-kind
* defaults is one where the numbers you read are not the numbers you get.
*/
export interface Opening {
kind: OpeningKind;
/** Metres along the wall from the `from` end to the near edge of the hole. */
start: number;
/** Metres. Must be positive, and must fit inside the wall. */
width: number;
/** Bottom of the hole, metres above this level's floor. */
sill: number;
/** Top of the hole, metres above this level's floor. */
head: number;
}
// ---- Props ----------------------------------------------------------------
/**
* One instance of one asset, placed.
*
* `kind` is an `AssetId` because the prop registry and the asset registry are
* the same registry (CONTRACT.md §3) there is no separate table of things you
* are allowed to put in a room.
*/
export interface Prop {
id: string;
kind: AssetId;
/** Where it stands, on the floor plane. */
position: Point2;
/** See `Yaw`. */
rotation: Yaw;
/**
* Metres above this level's floor. Omitted means standing on it, which is
* true of nearly everything; a wall-mounted screen or a monitor on a desktop
* says so here.
*/
elevation?: number;
/**
* Uniform scale, or per-axis. Use sparingly an asset that is wanted at
* another size is usually better registered as its own id.
*/
scale?: number | [number, number, number];
/**
* An opaque palette key, resolved by the caller's palette exactly as
* `Pin.colorKey` is. The engine will not learn that `"focus"` means a quiet
* booth or that red means anything at all.
*/
colorKey?: string;
/**
* The id of a `Seat` this prop belongs to the chair pulled up to `eng-04`.
*
* Purely an address. The prop is still positioned by its own `position`;
* binding it to a seat is what lets an occupancy layer dim the empty chairs
* without knowing which mesh is which.
*/
seat?: string;
/** See `Audience`. Absent means public. */
audience?: Audience;
}
/**
* A row or grid of identical desks, declared once.
*
* `Plan` expands one of these into props and seats. It exists for a plain
* reason: the reference office is about 180 props, and 180 hand-written literals
* is not a file anybody edits twice. A bank of twelve is six lines here.
*
* The grid is laid out in the bank's own frame `columns` run along its local
* +X, `rows` step along its local +Z and then rotated by `rotation` about
* `origin`, which is the centre of station (1, 1).
*
* ### Generated ids
*
* These are part of the contract, because a `Presence` binds to a seat id and a
* pack author has to be able to predict what it will be without running the
* expansion. Stations are numbered from 1, along each row and then down the
* rows, and the number is zero-padded to two digits:
*
* - seat `${seatPrefix ?? id}-01`, `-02`,
* - desk prop `${id}-desk-01`, chair prop `${id}-chair-01`
*
* So a bank with `id: "eng"` and no `seatPrefix` gives you `eng-04` as the
* fourth seat, which is the id the private occupancy API is expected to know.
*/
export interface DeskBank {
id: string;
/** The asset placed at every station. */
desk: AssetId;
/** Placed at every seat, if given. */
chair?: AssetId;
/** Centre of the first station, before rotation. */
origin: Point2;
/** Orientation of the whole bank. See `Yaw`. */
rotation: Yaw;
/** Stations across, along the bank's local +X. At least 1. */
columns: number;
/** Rows deep, along the bank's local +Z. At least 1. */
rows: number;
/** Centre-to-centre spacing between columns, in metres. */
pitch: number;
/** Centre-to-centre spacing between rows, in metres. Defaults to `pitch`. */
rowPitch?: number;
/**
* When true, consecutive rows face each other rather than all facing the same
* way bench seating, where two rows share a run of desktop. This is the
* difference between an office that looks laid out and one that looks like a
* spreadsheet, which is why it is here rather than left to the author to fake
* with two banks.
*/
facingRows?: boolean;
/** Metres from the desk centre to the seat, on the seated side. */
seatOffset?: number;
/** Pose for every seat in the bank. Defaults to `"sit"`. */
pose?: SeatPose;
/** Overrides the bank `id` as the seat-id prefix. */
seatPrefix?: string;
/**
* See `Audience`. Absent means public, and it covers the whole expansion: a
* private bank generates no desks, no chairs and no seats, so there is nothing
* left for a presence to bind to.
*/
audience?: Audience;
}
// ---- Seats and zones ------------------------------------------------------
export type SeatPose = "sit" | "stand";
/**
* A place a person can be.
*
* **Seats are addresses.** A seat is not a chair and not a person; it is a
* stable name for a spot on the floor, so that something outside this repo can
* say "eng-04" and mean somewhere without ever being told a coordinate. Ids
* should be stable across pack edits for the same reason street numbers are.
*/
export interface Seat {
id: string;
position: Point2;
/** Which way an occupant looks. See `Yaw`. */
facing: Yaw;
pose: SeatPose;
/**
* See `Audience`. Absent means public. A private seat is not resolved at
* public depth, so a `Presence` naming it is dropped the same way one naming a
* seat that does not exist is which is the answer you want, since at public
* depth there is no presence layer to drop it into either.
*/
audience?: Audience;
}
/**
* A named region of floor.
*
* A zone has no behaviour and no effect on geometry. It is a label on an area
* a team's corner, a quiet zone, a phone-booth cluster that a consuming app
* can highlight, filter or count against. Whether membership of a zone *means*
* anything is not the engine's business, which is why `colorKey` is opaque and
* there is no `kind` field.
*/
export interface Zone {
id: string;
name: string;
outline: Outline;
/** Opaque palette key, resolved by the caller. */
colorKey?: string;
/**
* See `Audience`. Absent means public. A zone is a label on an area and a
* label is exactly the sort of thing that turns out to be organisational
* "Engineering" says who sits there so this is the field a pack reaches for
* most.
*/
audience?: Audience;
}
// ---- Viewpoints -----------------------------------------------------------
/**
* A named camera pose the office analogue of a city `Chapter`.
*
* It shares `View` with the city rather than redeclaring it, because the half
* that the interface cares about what the legend prints, what `flyTo` is keyed
* on is identical, and only the pose differs: a chapter focuses on a latitude
* and longitude, a viewpoint focuses on a point in metres on a particular level.
*/
export interface Viewpoint extends View {
levelId: string;
focus: {
/** What the camera looks at, on the floor plane. */
at: Point2;
/** Metres from the target to the camera. */
distance: number;
/** Metres above this level's floor, for both target and camera height. */
height: number;
/** Camera azimuth about the target. See `Yaw`. */
rotation: Yaw;
};
/**
* See `Audience`. Absent means public.
*
* Use it sparingly and think first. A viewpoint is a promise printed in a
* legend, and a visitor told there are five and shown three has been lied to;
* a private viewpoint disappears from `views` entirely rather than leaving a
* dead button, but the honest fix is usually to reframe the shot rather than
* to withhold it. Mark one private only when the *pose itself* is the
* disclosure a camera two metres from the whiteboard in the board room.
*/
audience?: Audience;
}
// ---- Presence -------------------------------------------------------------
/**
* Somebody at a seat.
*
* **A `Presence` binds to a `seatId` and never to a coordinate, and it never
* appears in an office pack.** This is the whole trick, and it is the marker
* rule one level in.
*
* The pack knows where seat `eng-04` is. A private API knows who is sitting in
* it. Neither knows the other, so occupancy can be private data behind
* authentication names, faces, who is in today while the office geometry
* stays public, open-source and copyable by anyone. If a presence carried an
* `{x, z}`, then publishing the geometry and publishing the people would be the
* same act, and one of them could never be published at all.
*
* It extends `Pin` for the same reason `Marker` does: a thing worth pointing at,
* with a label and an opaque colour key, that a detail card can render without
* caring whether it was placed by latitude or by seat id.
*/
export interface Presence extends Pin {
seatId: string;
}
@@ -1,353 +0,0 @@
/**
* Renderer-independent walking over a resolved office plan.
*
* Input is a direction on the office floor plane, not keys or stick events.
* The controller advances on a fixed clock, sweeps the walker's circular
* footprint against the exact collision segments produced by `Plan`, and
* projects blocked motion along a wall so diagonal input slides instead of
* stopping. Doors need no special case: the wall resolver has already left a
* gap in `LevelPlan.collision` for every passable opening.
*/
import type { Bounds, LevelPlan, Segment } from "./plan.ts";
import type { Point2 } from "./types.ts";
const EPSILON = 1e-8;
const BISECTION_STEPS = 24;
const SLIDE_PASSES = 3;
export const DEFAULT_WALKER_RADIUS = 0.3;
export const DEFAULT_WALKER_SPEED = 1.6;
export const DEFAULT_FIXED_STEP = 1 / 60;
export const DEFAULT_MAX_CATCH_UP_STEPS = 8;
/** A world-space direction on the office floor plane. */
export interface WalkerAction {
x: number;
z: number;
}
export interface WalkerSpawn {
levelId: string;
position: Point2;
}
export interface WalkerOptions extends WalkerSpawn {
/** Initial unit direction; defaults to north / local -Z. */
facing?: Point2;
/** Circular footprint radius, in metres. */
radius?: number;
/** Metres per second at full input. */
speed?: number;
/** Simulation seconds per movement step. */
fixedStep?: number;
/** Prevents a resumed/backgrounded tab from running an unbounded backlog. */
maxCatchUpSteps?: number;
}
export interface WalkerState {
levelId: string;
position: Point2;
/** Last non-zero normalized action; useful as a renderer-facing heading. */
facing: Point2;
/** Total successfully travelled distance, in metres, since the last reset. */
distance: number;
}
/** The small part of `Plan` movement depends on. A test or server can implement it too. */
export interface WalkerPlan {
level(id: string): Pick<LevelPlan, "bounds" | "collision"> | null;
blocked(levelId: string, from: Point2, to: Point2, radius?: number): boolean;
}
export interface WalkerController {
/** A defensive snapshot: callers cannot corrupt the simulation's finite state. */
state(): WalkerState;
/** Add real time; zero or more fixed simulation steps may run. */
tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
/** Return to the original spawn, or atomically adopt another valid spawn. */
reset(spawn?: WalkerSpawn): WalkerState;
/** Restore a trusted JSON snapshot without changing the configured spawn. */
restore(snapshot: WalkerState): WalkerState;
}
/**
* Clamp arbitrary planar input to the unit disc. Non-finite input means idle;
* letting one bad gamepad sample become NaN would otherwise poison every frame.
*/
export function normalizeWalkerAction(action: WalkerAction): WalkerAction {
if (!finitePoint(action)) return { x: 0, z: 0 };
const length = Math.hypot(action.x, action.z);
if (length <= 1) return { x: action.x, z: action.z };
return { x: action.x / length, z: action.z / length };
}
export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerController {
const radius = positive(options.radius ?? DEFAULT_WALKER_RADIUS, "radius");
const speed = positive(options.speed ?? DEFAULT_WALKER_SPEED, "speed");
const fixedStep = positive(options.fixedStep ?? DEFAULT_FIXED_STEP, "fixedStep");
const maxCatchUpSteps = integer(options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS);
let spawn = checkedSpawn(plan, options, radius);
let position = copy(spawn.position);
const initialFacing = normalizedFacing(options.facing);
let facing: Point2 = copy(initialFacing);
let distance = 0;
let accumulator = 0;
function snapshot(): WalkerState {
return {
levelId: spawn.levelId,
position: copy(position),
facing: copy(facing),
distance,
};
}
function reset(next = spawn): WalkerState {
spawn = checkedSpawn(plan, next, radius);
position = copy(spawn.position);
facing = copy(initialFacing);
distance = 0;
accumulator = 0;
return snapshot();
}
function tick(elapsedSeconds: number, rawAction: WalkerAction): WalkerState {
// The internals are private, but this also makes the recovery policy clear
// if a future refactor exposes a mutable transport/state object.
if (!finitePoint(position) || !validPosition(plan, spawn.levelId, position, radius)) reset();
if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();
const action = normalizeWalkerAction(rawAction);
if (Math.hypot(action.x, action.z) > EPSILON) facing = copy(action);
const maxBacklog = fixedStep * maxCatchUpSteps;
accumulator = Math.min(maxBacklog, accumulator + elapsedSeconds);
let steps = 0;
while (accumulator + EPSILON >= fixedStep && steps < maxCatchUpSteps) {
accumulator -= fixedStep;
if (accumulator < 0) accumulator = 0;
steps += 1;
const amount = speed * fixedStep;
const before = position;
position = moveWithSliding(plan, spawn.levelId, position, {
x: action.x * amount,
z: action.z * amount,
}, radius);
distance += Math.hypot(position.x - before.x, position.z - before.z);
}
return snapshot();
}
function restore(next: WalkerState): WalkerState {
if (
next.levelId !== spawn.levelId || !finitePoint(next.position) || !finitePoint(next.facing) ||
!Number.isFinite(next.distance) || next.distance < 0 ||
!validPosition(plan, next.levelId, next.position, radius)
) throw new RangeError("walker snapshot is incompatible or invalid");
position = copy(next.position);
facing = normalizedFacing(next.facing);
distance = next.distance;
accumulator = 0;
return snapshot();
}
return { state: snapshot, tick, reset, restore };
}
function normalizedFacing(value: Point2 | undefined): Point2 {
if (!value || !finitePoint(value)) return { x: 0, z: -1 };
const length = Math.hypot(value.x, value.z);
return length > EPSILON ? { x: value.x / length, z: value.z / length } : { x: 0, z: -1 };
}
function moveWithSliding(
plan: WalkerPlan,
levelId: string,
start: Point2,
displacement: Point2,
radius: number,
): Point2 {
const level = plan.level(levelId);
if (!level) return copy(start);
// A configured high speed still cannot tunnel: no sweep is longer than half
// a radius. `Plan.blocked` is swept too; the subdivision primarily makes a
// corner followed by a slide behave consistently.
const length = Math.hypot(displacement.x, displacement.z);
const slices = Math.max(1, Math.ceil(length / Math.max(radius * 0.5, 0.01)));
const slice = { x: displacement.x / slices, z: displacement.z / slices };
let at = copy(start);
for (let index = 0; index < slices; index += 1) {
at = moveSlice(plan, levelId, level.bounds, level.collision, at, slice, radius);
}
return at;
}
function moveSlice(
plan: WalkerPlan,
levelId: string,
bounds: Bounds,
segments: readonly Segment[],
start: Point2,
initial: Point2,
radius: number,
): Point2 {
let at = copy(start);
let remaining = copy(initial);
for (let pass = 0; pass < SLIDE_PASSES; pass += 1) {
if (Math.hypot(remaining.x, remaining.z) <= EPSILON) break;
const target = bounded(add(at, remaining), bounds, radius);
const attempted = { x: target.x - at.x, z: target.z - at.z };
if (Math.hypot(attempted.x, attempted.z) <= EPSILON) break;
if (!plan.blocked(levelId, at, target, radius)) {
at = target;
break;
}
const fraction = clearFraction(plan, levelId, at, attempted, radius);
if (fraction > 0) at = add(at, scale(attempted, fraction));
const left = scale(attempted, 1 - fraction);
const wall = nearestBlockingSegment(at, add(at, left), segments, radius);
if (!wall) break;
const wx = wall.to.x - wall.from.x;
const wz = wall.to.z - wall.from.z;
const wallLength = Math.hypot(wx, wz);
if (wallLength <= EPSILON) break;
const tx = wx / wallLength;
const tz = wz / wallLength;
const along = left.x * tx + left.z * tz;
remaining = { x: tx * along, z: tz * along };
}
return at;
}
/** Largest prefix of a blocked displacement whose whole swept capsule is clear. */
function clearFraction(
plan: WalkerPlan,
levelId: string,
start: Point2,
displacement: Point2,
radius: number,
): number {
let low = 0;
let high = 1;
for (let index = 0; index < BISECTION_STEPS; index += 1) {
const middle = (low + high) / 2;
if (plan.blocked(levelId, start, add(start, scale(displacement, middle)), radius)) high = middle;
else low = middle;
}
// Stay microscopically on the clear side so the projected slide does not
// begin inside the wall because of a last-bit rounding difference.
return Math.max(0, low - 1e-7);
}
function nearestBlockingSegment(
from: Point2,
to: Point2,
segments: readonly Segment[],
radius: number,
): Segment | null {
let nearest: Segment | null = null;
let best = Infinity;
for (const segment of segments) {
const distance = segmentDistance(from, to, segment.from, segment.to);
const clearance = radius + segment.thickness / 2;
if (distance >= clearance + 1e-6 || distance >= best) continue;
best = distance;
nearest = segment;
}
return nearest;
}
function checkedSpawn(plan: WalkerPlan, spawn: WalkerSpawn, radius: number): WalkerSpawn {
if (!spawn.levelId || !finitePoint(spawn.position)) {
throw new RangeError("walker spawn must name a level and contain finite coordinates");
}
if (!validPosition(plan, spawn.levelId, spawn.position, radius)) {
throw new RangeError("walker spawn must be inside the level bounds and clear of walls");
}
return { levelId: spawn.levelId, position: copy(spawn.position) };
}
function validPosition(plan: WalkerPlan, levelId: string, point: Point2, radius: number): boolean {
const level = plan.level(levelId);
return level !== null && inside(point, level.bounds, radius) && !plan.blocked(levelId, point, point, radius);
}
function inside(point: Point2, bounds: Bounds, radius: number): boolean {
return (
point.x >= bounds.minX + radius && point.x <= bounds.maxX - radius &&
point.z >= bounds.minZ + radius && point.z <= bounds.maxZ - radius
);
}
function bounded(point: Point2, bounds: Bounds, radius: number): Point2 {
return {
x: Math.min(bounds.maxX - radius, Math.max(bounds.minX + radius, point.x)),
z: Math.min(bounds.maxZ - radius, Math.max(bounds.minZ + radius, point.z)),
};
}
function positive(value: number, name: string): number {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value;
}
function integer(value: number): number {
if (!Number.isInteger(value) || value < 1) throw new RangeError("maxCatchUpSteps must be a positive integer");
return value;
}
function finitePoint(point: Point2): boolean {
return Number.isFinite(point.x) && Number.isFinite(point.z);
}
function copy(point: Point2): Point2 {
return { x: point.x, z: point.z };
}
function add(a: Point2, b: Point2): Point2 {
return { x: a.x + b.x, z: a.z + b.z };
}
function scale(point: Point2, amount: number): Point2 {
return { x: point.x * amount, z: point.z * amount };
}
function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number {
if (segmentsCross(a1, a2, b1, b2)) return 0;
return Math.min(
pointSegmentDistance(a1, b1, b2),
pointSegmentDistance(a2, b1, b2),
pointSegmentDistance(b1, a1, a2),
pointSegmentDistance(b2, a1, a2),
);
}
function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
const ab1 = cross(a1, a2, b1);
const ab2 = cross(a1, a2, b2);
const ba1 = cross(b1, b2, a1);
const ba2 = cross(b1, b2, a2);
// Proper crossing only. Collinear, disjoint segments must fall through to
// endpoint distance; treating every collinear pair as a crossing would make
// a walker sliding parallel to a distant wall collide with it.
return ab1 * ab2 < 0 && ba1 * ba2 < 0;
}
function cross(a: Point2, b: Point2, point: Point2): number {
return (b.x - a.x) * (point.z - a.z) - (b.z - a.z) * (point.x - a.x);
}
function pointSegmentDistance(point: Point2, a: Point2, b: Point2): number {
const dx = b.x - a.x;
const dz = b.z - a.z;
const lengthSquared = dx * dx + dz * dz;
if (lengthSquared <= EPSILON) return Math.hypot(point.x - a.x, point.z - a.z);
const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.z - a.z) * dz) / lengthSquared));
return Math.hypot(point.x - (a.x + t * dx), point.z - (a.z + t * dz));
}
@@ -1,687 +0,0 @@
/**
* Frontier Valley a startup in a hangar at Alameda Point.
*
* The second office pack, and it exists to prove the format describes more than
* one kind of building. `lumbridge-hq.ts` is the dev kit: a corridor, fifteen
* rooms, a grid, everything a commercial floor plate has. This is the opposite
* building in every respect that matters, and the contrast is the point.
*
* ### Why a hangar, and why here
*
* Alameda Point is the former Naval Air Station Alameda, decommissioned in 1997:
* a mile of runway, a seaplane lagoon, and a row of enormous steel-framed
* hangars that have spent the last quarter century being rented to distilleries,
* film crews and companies that need a very large room cheaply. Putting a
* startup in one is not a conceit it is the single most characteristic thing
* that happens on that piece of land.
*
* It also gives the site field something to say. `lumbridge-hq` is 188 m up a
* tower with the horizon far below it; this is **four metres above reclaimed
* ground on a flat island**, looking across the estuary at the city that the
* other office is inside. Same engine, same clock, same sun and the two feel
* nothing like each other, which is the whole argument for `OfficeSite`.
*
* ### One level, one room, and a mezzanine that is furniture
*
* A hangar is a single volume. There is no corridor here because there is
* nothing to connect: the meeting rooms are freestanding boxes dropped on the
* slab, with their own low lids, and everything else is open floor. That is
* expressible in this format without a single new feature, which is worth
* knowing the format was written against a cellular office and turns out to
* describe a shed just as well.
*
* ### The coordinate frame
*
* Metres, `1 unit = 1 m`, floor on the XZ plane with +Y up. Origin at the
* north-west corner. +X east, +Z south, so in plan view +Z runs down the page.
* The one difference from the reference pack is that here "north" is a genuine
* claim: `site.heading` is 0, because the hangars at Alameda Point really are
* laid out square to the compass along the old runways.
*/
import type {
AssetId,
DeskBank,
Level,
Office,
Opening,
Outline,
Point2,
Prop,
Room,
Seat,
Viewpoint,
Wall,
Yaw,
Zone,
} from "../interiors/types.ts";
import { FRONTIER_VALLEY_SITE } from "./sites.ts";
// ---- The shed -------------------------------------------------------------
/**
* A hangar, in three numbers.
*
* 54 x 30 m is a small one by Alameda standards the surviving hangars on the
* seaplane lagoon run to twice this but it is a plausible sublet, and it is
* already three times the floor area of the reference office's desk floor with
* nothing standing in it.
*/
const WIDTH = 54.0;
const DEPTH = 30.0;
/**
* Nine metres to the underside of the trusses.
*
* This is the number that makes it a hangar rather than a warehouse-themed
* office. A commercial storey is 2.8 m; at 9 m the roof is somewhere you look
* *up* at, the meeting boxes read as objects standing in a room rather than as
* rooms carved out of one, and a mezzanine is a thing you can put under it.
*/
const RIDGE = 9.0;
/** The freestanding boxes get their own low lids. A booth in a shed is a box. */
const BOX_CEILING = 3.0;
const EXT_THICKNESS = 0.35;
const INT_THICKNESS = 0.12;
const EXT_FACE = EXT_THICKNESS / 2;
const INT_FACE = INT_THICKNESS / 2;
const DISPLAY_OFFSET = 0.07;
const BOARD_OFFSET = 0.06;
/**
* The mezzanine deck.
*
* It is a `Level`, and getting there took one wrong turn worth recording. A deck
* inside a single volume reads like furniture, so it was first authored as a
* `Room` on the ground floor with everything on it carrying `elevation: 4.4`.
* That is not expressible: a `Room` is a floor *finish* and has no height, so
* the deck's slab lay flat on the concrete while its chairs floated four and a
* half metres over it, its glass balustrade fenced off a patch of ground-floor
* slab, and anybody sitting at `mezz-01` stood on the concrete underneath their
* own chair.
*
* A `Level` is the only thing in this format that carries a floor height, so the
* deck is one with a storey height of `RIDGE - MEZZ_HEIGHT`, because what is
* above it is the same roof.
*/
const MEZZ_W = 38.0;
const MEZZ_N = 20.4;
const MEZZ_HEIGHT = 4.4;
// The three freestanding boxes along the west end.
const BOX_1_W = 3.0;
const BOX_1_E = 10.2;
const BOX_2_W = 11.4;
const BOX_2_E = 17.4;
const BOX_N = 3.0;
const BOX_S = 9.6;
// ---- Yaw ------------------------------------------------------------------
const NORTH: Yaw = 0;
const EAST: Yaw = -Math.PI / 2;
const SOUTH: Yaw = Math.PI;
const WEST: Yaw = Math.PI / 2;
// ---- Assets ---------------------------------------------------------------
const DESK: AssetId = "tera:desk.workstation";
const PEDESTAL: AssetId = "tera:desk.pedestal";
const TASK_CHAIR: AssetId = "tera:seat.task-chair";
const LOUNGE_CHAIR: AssetId = "tera:seat.lounge";
const MEETING_TABLE: AssetId = "tera:table.meeting";
const SIDE_TABLE: AssetId = "tera:table.side";
const SHELF: AssetId = "tera:storage.shelf";
const LOCKER: AssetId = "tera:storage.locker";
const DISPLAY: AssetId = "tera:screen.wall-display";
const PLANT: AssetId = "tera:plant.potted";
const TREE: AssetId = "tera:plant.tall";
const PENDANT: AssetId = "tera:light.pendant";
const RUG: AssetId = "tera:rug";
const WHITEBOARD: AssetId = "tera:whiteboard";
const CONCRETE = "tera:concrete.polished";
const WOOD = "tera:wood.plank";
const CARPET_ACCENT = "tera:carpetAccent.broadloom";
const PAINT = "tera:paint.matt";
const ACCENT_PAINT = "tera:plasterAccent.deep";
const GLASS = "tera:glass.curtain";
const STEEL = "tera:steel.panel";
const FELT = "tera:felt.acoustic";
// ---- Helpers --------------------------------------------------------------
function rect(x0: number, z0: number, x1: number, z1: number): Outline {
return [
{ x: x0, z: z0 },
{ x: x0, z: z1 },
{ x: x1, z: z1 },
{ x: x1, z: z0 },
];
}
function grid(x0: number, z0: number, columns: number, rows: number, dx: number, dz: number): Point2[] {
const points: Point2[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < columns; c++) points.push({ x: x0 + c * dx, z: z0 + r * dz });
}
return points;
}
function scatter(
prefix: string,
kind: AssetId,
points: Point2[],
opts: { rotation?: Yaw; elevation?: number; colorKey?: string } = {},
): Prop[] {
return points.map((position, i) => ({
id: `${prefix}-${String(i + 1).padStart(2, "0")}`,
kind,
position,
rotation: opts.rotation ?? NORTH,
elevation: opts.elevation,
colorKey: opts.colorKey,
}));
}
function doorway(start: number, width = 0.9, head = 2.1): Opening {
return { kind: "door", start, width, sill: 0, head };
}
function pane(start: number, width: number, sill = 0.9, head = 2.2): Opening {
return { kind: "window", start, width, sill, head };
}
function archway(start: number, width: number, head = 2.4): Opening {
return { kind: "arch", start, width, sill: 0, head };
}
// ---- Rooms ----------------------------------------------------------------
const ROOMS: Room[] = [
/**
* The slab. One room, fifty-four by thirty, notched around nothing.
*
* Every other "room" in this pack sits on top of this one as a different floor
* finish, and they are authored *after* it so `Plan.roomAt` resolves them
* first. That is the one ordering rule in the format and it is doing real work
* here: in the reference office the floor is a jigsaw of abutting slabs, and
* here it is one slab with rugs on it.
*/
{
id: "floor",
name: "The Floor",
outline: rect(0, 0, WIDTH, DEPTH),
floor: CONCRETE,
ceiling: null,
},
{
id: "box-standup",
name: "Standup",
outline: rect(BOX_1_W, BOX_N, BOX_1_E, BOX_S),
floor: CARPET_ACCENT,
ceiling: { height: BOX_CEILING, surface: FELT },
},
{
id: "box-quiet",
name: "The Quiet Box",
outline: rect(BOX_2_W, BOX_N, BOX_2_E, BOX_S),
floor: CARPET_ACCENT,
ceiling: { height: BOX_CEILING, surface: FELT },
},
{
id: "workshop",
name: "The Bench",
// Against the east gable, where the big door is. A hardware startup in a
// hangar puts the thing it is building next to the way out.
outline: rect(42.0, 3.0, WIDTH - 0.4, 16.0),
floor: CONCRETE,
ceiling: null,
},
{
id: "galley",
name: "The Galley",
outline: rect(3.0, 22.0, 16.0, DEPTH - 0.4),
floor: WOOD,
ceiling: null,
},
];
// ---- Walls ----------------------------------------------------------------
const WALLS: Wall[] = [
// -- The envelope, nine metres of it --------------------------------------
{
id: "gable-west",
from: { x: 0, z: 0 },
to: { x: 0, z: DEPTH },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
openings: [doorway(13.6, 1.8, 2.4), pane(4.0, 6.0, 3.6, 7.2), pane(19.0, 6.0, 3.6, 7.2)],
},
{
id: "gable-east",
from: { x: WIDTH, z: 0 },
to: { x: WIDTH, z: DEPTH },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
// The hangar door. Twelve metres of it, full height, because that is what a
// hangar is — and it is an `arch` rather than a `door` so the collider
// knows you can walk out onto the apron through it.
openings: [archway(6.0, 12.0, 6.4)],
},
{
id: "eave-north",
from: { x: 0, z: 0 },
to: { x: WIDTH, z: 0 },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
// Clerestory glazing high on the north side: the light a shed actually
// wants, and the sill is well above head height so none of it is a doorway.
openings: [
pane(4.0, 9.0, 5.4, 8.2),
pane(16.0, 9.0, 5.4, 8.2),
pane(28.0, 9.0, 5.4, 8.2),
pane(40.0, 9.0, 5.4, 8.2),
],
},
{
id: "eave-south",
from: { x: 0, z: DEPTH },
to: { x: WIDTH, z: DEPTH },
thickness: EXT_THICKNESS,
height: RIDGE,
surface: STEEL,
// The lagoon side, and the only wall with glazing you can see out of at
// standing height. This is the view the whole building is arranged around.
openings: [
pane(3.0, 10.0, 0.9, 3.4),
pane(16.0, 10.0, 0.9, 3.4),
pane(30.0, 8.0, 0.9, 3.4),
doorway(44.0, 1.8, 2.4),
],
},
// -- The freestanding boxes -----------------------------------------------
//
// Three metres tall inside a nine-metre volume, which is what makes them read
// as objects on the floor. Their fourth sides are open — a box you can see
// into is a box people use.
{
id: "standup-north",
from: { x: BOX_1_W, z: BOX_N },
to: { x: BOX_1_E, z: BOX_N },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "standup-west",
from: { x: BOX_1_W, z: BOX_N },
to: { x: BOX_1_W, z: BOX_S },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "standup-south",
from: { x: BOX_1_W, z: BOX_S },
to: { x: BOX_1_E, z: BOX_S },
height: BOX_CEILING,
surface: GLASS,
openings: [archway(2.4, 2.4, 2.4)],
},
{
id: "quiet-north",
from: { x: BOX_2_W, z: BOX_N },
to: { x: BOX_2_E, z: BOX_N },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "quiet-east",
from: { x: BOX_2_E, z: BOX_N },
to: { x: BOX_2_E, z: BOX_S },
height: BOX_CEILING,
surface: ACCENT_PAINT,
},
{
id: "quiet-south",
from: { x: BOX_2_W, z: BOX_S },
to: { x: BOX_2_E, z: BOX_S },
height: BOX_CEILING,
surface: GLASS,
openings: [doorway(2.6, 0.9, 2.1)],
},
];
// ---- Desks ----------------------------------------------------------------
/**
* Two long benches down the middle of the shed, and nothing against a wall.
*
* A hangar's walls are its least valuable surface they are steel, they are
* cold, and the good light comes from above. So the desks sit in the volume and
* the edges are left for the things that want an edge.
*/
const DESK_BANKS: DeskBank[] = [
{
id: "fv",
desk: DESK,
chair: TASK_CHAIR,
origin: { x: 22.0, z: 5.2 },
rotation: 0,
columns: 7,
rows: 2,
pitch: 1.7,
rowPitch: 0.85,
facingRows: true,
},
{
id: "fv-b",
desk: DESK,
chair: TASK_CHAIR,
origin: { x: 22.0, z: 10.4 },
rotation: 0,
columns: 7,
rows: 2,
pitch: 1.7,
rowPitch: 0.85,
facingRows: true,
seatPrefix: "fv-b",
},
];
const SEATS: Seat[] = [
{ id: "standup-01", position: { x: 5.0, z: 5.6 }, facing: EAST, pose: "stand" },
{ id: "standup-02", position: { x: 8.2, z: 5.6 }, facing: WEST, pose: "stand" },
{ id: "standup-03", position: { x: 6.6, z: 4.4 }, facing: SOUTH, pose: "stand" },
{ id: "quietbox-01", position: { x: 13.0, z: 5.4 }, facing: EAST, pose: "sit" },
{ id: "quietbox-02", position: { x: 15.8, z: 5.4 }, facing: WEST, pose: "sit" },
{ id: "galley-01", position: { x: 6.0, z: 25.0 }, facing: SOUTH, pose: "stand" },
{ id: "galley-02", position: { x: 7.6, z: 25.0 }, facing: SOUTH, pose: "stand" },
{ id: "galley-03", position: { x: 9.2, z: 25.0 }, facing: SOUTH, pose: "stand" },
// On the table's south long side, not on the tabletop: the table is centred on
// z 27.0 and is 1.2 m deep, so a seat at 27.0 was inside it.
{ id: "galley-04", position: { x: 12.4, z: 28.0 }, facing: NORTH, pose: "sit" },
{ id: "galley-05", position: { x: 14.0, z: 28.0 }, facing: NORTH, pose: "sit" },
{ id: "bench-01", position: { x: 44.4, z: 6.0 }, facing: EAST, pose: "stand" },
{ id: "bench-02", position: { x: 44.4, z: 8.4 }, facing: EAST, pose: "stand" },
{ id: "bench-03", position: { x: 44.4, z: 10.8 }, facing: EAST, pose: "stand" },
];
// ---- Props ----------------------------------------------------------------
const PROPS: Prop[] = [
// -- The desk floor -------------------------------------------------------
...scatter("fv-ped", PEDESTAL, grid(22.0, 6.8, 7, 1, 1.7, 0)),
...scatter("fv-ped-b", PEDESTAL, grid(22.0, 12.0, 7, 1, 1.7, 0)),
/**
* The lights, hung from the trusses at seven metres.
*
* The one place this pack really needs the ceiling-fixture exception: a
* pendant is authored with its origin at the mounting plane and its geometry
* below, so 7.2 is a real height in a 9 m shed and not an offset from a lid
* that does not exist.
*/
...scatter("truss-light", PENDANT, grid(8.0, 5.0, 6, 4, 9.0, 7.0), { elevation: 7.2 }),
// -- The boxes ------------------------------------------------------------
{ id: "standup-board", kind: WHITEBOARD, position: { x: 6.6, z: BOX_N + INT_FACE + BOARD_OFFSET }, rotation: NORTH, elevation: 1.0 },
{ id: "standup-rug", kind: RUG, position: { x: 6.6, z: 6.3 }, rotation: NORTH, colorKey: "team" },
{ id: "quiet-table", kind: MEETING_TABLE, position: { x: 14.4, z: 5.4 }, rotation: NORTH },
...scatter("quiet-chair", TASK_CHAIR, [
{ x: 13.0, z: 5.4 },
{ x: 15.8, z: 5.4 },
]),
{ id: "quiet-display", kind: DISPLAY, position: { x: 14.4, z: BOX_N + INT_FACE + DISPLAY_OFFSET }, rotation: NORTH, elevation: 1.15 },
// -- The bench ------------------------------------------------------------
// Four, starting at z 1.1: the gable is solid only for z 0..6, and the hangar
// door's arch runs from 6.0 to 18.0 — a five-shelf run from 4.0 put three of
// them standing in the open doorway.
...scatter("bench-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 1.1, 1, 4, 0, 1.2), { rotation: EAST }),
{ id: "bench-board", kind: WHITEBOARD, position: { x: 43.0, z: 3.0 + BOARD_OFFSET }, rotation: NORTH, elevation: 1.0 },
...scatter("bench-locker", LOCKER, grid(42.4, 13.0, 3, 1, 1.1, 0), { rotation: WEST }),
// -- The galley -----------------------------------------------------------
{ id: "galley-rug", kind: RUG, position: { x: 9.0, z: 26.4 }, rotation: NORTH, colorKey: "social" },
{ id: "galley-table", kind: MEETING_TABLE, position: { x: 13.2, z: 27.0 }, rotation: NORTH },
...scatter("galley-lounge", LOUNGE_CHAIR, [
{ x: 6.0, z: 26.8 },
{ x: 8.4, z: 26.8 },
{ x: 10.8, z: 26.8 },
]),
{ id: "galley-side", kind: SIDE_TABLE, position: { x: 7.2, z: 25.8 }, rotation: NORTH },
...scatter("galley-plant", TREE, [
{ x: 4.0, z: 23.4 },
{ x: 15.0, z: 23.4 },
]),
// -- The floor, kept mostly empty -----------------------------------------
//
// Three trees and nothing else. The shed's argument is the volume, and a
// fifty-four-metre room with forty objects in it is a fifty-four-metre room
// you cannot see across.
...scatter("floor-tree", TREE, [
{ x: 20.0, z: 20.0 },
{ x: 30.0, z: 24.0 },
{ x: 36.0, z: 4.0 },
]),
...scatter("floor-plant", PLANT, [
{ x: 19.6, z: 3.2 },
{ x: 34.0, z: 14.0 },
]),
];
// ---- Zones ----------------------------------------------------------------
const ZONES: Zone[] = [
{ id: "zone-build", name: "Build", outline: rect(42.0, 3.0, WIDTH - 0.4, 16.0), colorKey: "team" },
{ id: "zone-galley", name: "Galley", outline: rect(3.0, 22.0, 16.0, DEPTH - 0.4), colorKey: "social" },
];
// ---- Viewpoints -----------------------------------------------------------
const VIEWPOINTS: Viewpoint[] = [
{
id: "hangar",
number: "01",
label: "The Hangar",
shortLabel: "Hangar",
levelId: "level-1",
focus: { at: { x: 27, z: 15 }, distance: 52, height: 26, rotation: 0.6 },
description:
"Fifty-four metres by thirty, nine to the trusses, one room. Everything Frontier Valley has is on this slab or on the deck at the far end.",
},
{
id: "benches",
number: "02",
label: "The Benches",
shortLabel: "Benches",
levelId: "level-1",
focus: { at: { x: 27.4, z: 8.0 }, distance: 15, height: 4.6, rotation: 0.4 },
description:
"Twenty-eight seats in two runs down the middle of the floor, under the trusses. Nothing is against a wall — in a shed the walls are the worst surface in the building.",
},
{
id: "big-door",
number: "03",
label: "The Big Door",
shortLabel: "Big Door",
levelId: "level-1",
// Low and looking east, straight out through the twelve-metre opening.
focus: { at: { x: 46, z: 9.5 }, distance: 18, height: 3.0, rotation: 1.5 },
description:
"Twelve metres of hangar door, open onto the apron, with the bench inside it. The reason a company rents one of these rather than a floor of an office block.",
},
{
id: "mezzanine",
number: "04",
label: "The Mezzanine",
shortLabel: "Mezzanine",
levelId: "level-mezz",
// `height` is metres above *this* level's floor, so 3.0 here is standing on
// the deck rather than 3 m off the concrete.
focus: { at: { x: 46, z: 25 }, distance: 13, height: 3.0, rotation: 2.4 },
description:
"A deck four and a half metres up with another four and a half above it. Its own level, because a level is the only thing in this format that carries a floor height — and without one its chairs floated over bare concrete.",
},
{
id: "galley",
number: "05",
label: "The Galley",
shortLabel: "Galley",
levelId: "level-1",
focus: { at: { x: 9.5, z: 26 }, distance: 12, height: 3.2, rotation: 3.4 },
description:
"The lagoon side, and the only glazing you can see out of standing up. The whole social end of the building is arranged around one row of windows.",
},
];
// ---- The pack -------------------------------------------------------------
// ---- The mezzanine, as its own level --------------------------------------
//
// Authored in its own floor's frame, with the deck at zero. `Plan` adds
// `MEZZ_HEIGHT` to every coordinate here exactly once.
const MEZZ_ROOMS: Room[] = [
{
id: "mezzanine",
name: "The Mezzanine",
outline: rect(MEZZ_W, MEZZ_N, WIDTH - 0.4, DEPTH - 0.4),
floor: WOOD,
ceiling: null,
},
];
/**
* The balustrade, and as in the reference pack's gallery a **wall** and not
* a prop, because `Plan` derives the walk collider from the wall list and the
* drop is 4.4 m onto polished concrete. No openings: that is what a balustrade
* is.
*/
const MEZZ_WALLS: Wall[] = [
{
id: "mezz-west",
from: { x: MEZZ_W, z: MEZZ_N },
to: { x: MEZZ_W, z: DEPTH - 0.4 },
height: 1.1,
surface: GLASS,
},
{
id: "mezz-north",
from: { x: MEZZ_W, z: MEZZ_N },
to: { x: WIDTH - 0.4, z: MEZZ_N },
height: 1.1,
surface: GLASS,
},
];
const MEZZ_SEATS: Seat[] = [
{ id: "mezz-01", position: { x: 44.0, z: 23.0 }, facing: NORTH, pose: "sit" },
{ id: "mezz-02", position: { x: 46.4, z: 23.0 }, facing: NORTH, pose: "sit" },
{ id: "mezz-03", position: { x: 48.8, z: 23.0 }, facing: NORTH, pose: "sit" },
];
const MEZZ_PROPS: Prop[] = [
{ id: "mezz-table", kind: MEETING_TABLE, position: { x: 46.4, z: 24.2 }, rotation: NORTH },
...scatter("mezz-chair", TASK_CHAIR, [
{ x: 44.0, z: 23.0 },
{ x: 46.4, z: 23.0 },
{ x: 48.8, z: 23.0 },
]),
...scatter("mezz-lounge", LOUNGE_CHAIR, [
{ x: 44.0, z: 27.4 },
{ x: 47.0, z: 27.4 },
], { rotation: SOUTH }),
{ id: "mezz-rug", kind: RUG, position: { x: 45.5, z: 27.4 }, rotation: NORTH, colorKey: "social" },
// Half-depth plus a 25 mm reveal off the wall face, which is how the reference
// pack stands the same asset against a wall — `EXT_FACE + 0.2` is the shelf's
// *clearance* and leaves it floating 0.42 m out in the room.
...scatter("mezz-shelf", SHELF, grid(WIDTH - EXT_FACE - 0.2, 22.0, 1, 3, 0, 1.2), {
rotation: EAST,
}),
];
const MEZZ_ZONES: Zone[] = [
{ id: "zone-mezz", name: "Mezzanine", outline: rect(MEZZ_W, MEZZ_N, WIDTH - 0.4, DEPTH - 0.4), colorKey: "social" },
];
const LEVEL_MEZZ: Level = {
id: "level-mezz",
name: "The Mezzanine",
elevation: MEZZ_HEIGHT,
// What is above the deck is the same roof, so its storey height is whatever is
// left of the shed.
wallHeight: RIDGE - MEZZ_HEIGHT,
wallThickness: INT_THICKNESS,
wallSurface: PAINT,
floorplan: {
rooms: MEZZ_ROOMS,
walls: MEZZ_WALLS,
props: MEZZ_PROPS,
seats: MEZZ_SEATS,
zones: MEZZ_ZONES,
},
};
const LEVEL_1: Level = {
id: "level-1",
name: "The Slab",
elevation: 0,
wallHeight: RIDGE,
wallThickness: INT_THICKNESS,
wallSurface: PAINT,
floorplan: {
rooms: ROOMS,
walls: WALLS,
props: PROPS,
deskBanks: DESK_BANKS,
seats: SEATS,
zones: ZONES,
},
};
export const FRONTIER_VALLEY: Office = {
id: "frontier-valley",
name: "Frontier Valley",
levels: [LEVEL_1, LEVEL_MEZZ],
viewpoints: VIEWPOINTS,
/**
* Alameda Point, on the estuary side of Alameda Island.
*
* Typed by hand from the street grid of the former Naval Air Station, not
* geocoded CONTRACT.md §8. Four metres of elevation because this is
* reclaimed flat ground and the slab is barely above it, which is the exact
* opposite of `lumbridge-hq`'s 188 m and is the whole point of having both.
*
* `heading: 0` is a real claim rather than the reference pack's convenience:
* the hangars here are laid out square to the old runways, which run close
* enough to northsouth that calling the clerestory wall "north" is true.
* So the high glazing really does take north light and the lagoon really is
* to the south.
*/
site: FRONTIER_VALLEY_SITE,
meta: {
description:
"A startup in a hangar at Alameda Point: one room, fifty-four by thirty, nine metres to the trusses. The second pack, and the one that shows the format describes a shed as well as it describes a corridor.",
author: "Lumbridge",
license: "CC0-1.0",
version: "1.0.0",
updated: "2026-08-07",
},
};
export default FRONTIER_VALLEY;
@@ -1,107 +0,0 @@
/**
* Where the shipped buildings stand, separately from the buildings themselves.
*
* An `Office` pack carries its own `site`, and that is still the authority a
* pack handed over HTTP by a self-hoster brings its site with it and never
* touches this file. What this file exists for is the one question the *city*
* asks, which a pack cannot answer without being loaded: **where are the
* buildings I could walk into?**
*
* The office packs are deliberately lazy chunks. `lumbridge-hq` alone is 25 kB
* of furniture and floor plan, and the whole point of `loadOffice`'s dynamic
* import is that a visitor who only ever looks at the city never pays for it.
* But the city wants to draw a marker on the two buildings the moment the board
* appears, which is long before anybody has opened a door so importing a pack
* to read four numbers off it would put the entire catalogue back in the entry
* chunk and undo the split.
*
* Hence a tiny eagerly-imported module holding just the coordinates, and each
* pack importing its own site **from here** rather than declaring it inline. One
* source of truth, and the direction of the dependency is the safe one: the
* small thing does not know about the large one.
*/
import type { OfficeSite } from "../interiors/types.ts";
/**
* High in a Transbay tower. See `lumbridge-hq.ts` for what the four numbers mean
* and why `heading` is the one that decides whether the sun ever gets in.
*/
export const LUMBRIDGE_HQ_SITE: OfficeSite = {
lat: 37.7897,
lng: -122.3972,
elevation: 188,
heading: 205,
label: "Transbay, San Francisco",
exterior: {
kind: "building",
width: 48,
depth: 42,
height: 326,
storeys: 61,
heading: 205,
profile: "tower",
seed: 115,
bodyColor: 0x8799a8,
},
};
/** A hangar on the old naval air station. See `frontier-valley.ts`. */
export const FRONTIER_VALLEY_SITE: OfficeSite = {
lat: 37.7756,
lng: -122.3186,
elevation: 4,
heading: 0,
label: "Alameda Point",
exterior: {
kind: "building",
width: 54,
depth: 30,
height: 11,
storeys: 2,
heading: 0,
profile: "hangar",
seed: 2718,
bodyColor: 0x899397,
},
};
/**
* A courtyard block in the Arts District, square to the pueblo grid rather than
* to the compass which is the whole reason `heading` is a field. See
* `mateo-court.ts` for where the numbers come from: 1.2 m is a loading dock, and
* 36° is the 1781 survey the downtown street grid still follows.
*/
export const MATEO_COURT_SITE: OfficeSite = {
lat: 34.0395,
lng: -118.2288,
elevation: 1.2,
heading: 36,
label: "Arts District, Los Angeles",
exterior: {
kind: "building",
width: 36,
depth: 26,
height: 9,
storeys: 2,
heading: 36,
profile: "courtyard",
seed: 1781,
bodyColor: 0xa87960,
},
};
/**
* Every building this build can walk into, for the city to point at.
*
* `id` matches the pack's own `Office.id` and the `OFFICES` table in `main.ts`,
* which is what lets a click on a marker resolve to a door. Keeping the three in
* step is not enforced by the type system; it is enforced by there being three of
* them and by `office.test.ts` asserting that each pack's site is the very
* object named here identity, not equality, so a restated coordinate fails.
*/
export const OFFICE_SITES: { id: string; name: string; site: OfficeSite }[] = [
{ id: "lumbridge-hq", name: "Lumbridge HQ", site: LUMBRIDGE_HQ_SITE },
{ id: "frontier-valley", name: "Frontier Valley", site: FRONTIER_VALLEY_SITE },
{ id: "mateo-court", name: "Mateo Court", site: MATEO_COURT_SITE },
];
@@ -1,380 +0,0 @@
/**
* The first California transport corridor: two coarse LA-to-Bay itineraries.
*
* This is simulation geometry, not a navigation dataset. Coordinates were
* placed by hand at recognisable cities and junctions, with long road sections
* represented by one straight edge. In particular, I-5 does not enter San
* Francisco: that itinerary names its real Bay approach over I-580 and I-80.
*/
import type {
TransportAnchor,
TransportNode,
TransportPack,
TransportRoute,
TransportSegment,
} from "./types.ts";
const AUTHORED_NODE =
"Original coarse waypoint authored by the Tera project from general California geography; approximate and not for navigation.";
const AUTHORED_ROAD =
"Original coarse simulation segment authored by the Tera project; road identity and representative speed/lanes are approximate, not live navigation data.";
const INTERNAL_OFFICE =
"Transition coordinate mirrors Tera's authored office site catalogue; it is project data, not copied map geometry.";
export const CALIFORNIA_TRANSPORT_NODES: readonly TransportNode[] = [
{
id: "los-angeles",
label: "Los Angeles",
kind: "terminus",
position: { lat: 34.0522, lng: -118.2437 },
provenance: AUTHORED_NODE,
},
// US-101: the coast and Salinas Valley approach.
{
id: "ventura",
label: "Ventura",
kind: "waypoint",
position: { lat: 34.2805, lng: -119.2945 },
provenance: AUTHORED_NODE,
},
{
id: "santa-barbara",
label: "Santa Barbara",
kind: "waypoint",
position: { lat: 34.4208, lng: -119.6982 },
provenance: AUTHORED_NODE,
},
{
id: "santa-maria",
label: "Santa Maria",
kind: "waypoint",
position: { lat: 34.953, lng: -120.4357 },
provenance: AUTHORED_NODE,
},
{
id: "san-luis-obispo",
label: "San Luis Obispo",
kind: "waypoint",
position: { lat: 35.2828, lng: -120.6596 },
provenance: AUTHORED_NODE,
},
{
id: "paso-robles",
label: "Paso Robles",
kind: "waypoint",
position: { lat: 35.626, lng: -120.691 },
provenance: AUTHORED_NODE,
},
{
id: "king-city",
label: "King City",
kind: "waypoint",
position: { lat: 36.2127, lng: -121.126 },
provenance: AUTHORED_NODE,
},
{
id: "salinas",
label: "Salinas",
kind: "waypoint",
position: { lat: 36.6777, lng: -121.6555 },
provenance: AUTHORED_NODE,
},
{
id: "gilroy",
label: "Gilroy",
kind: "waypoint",
position: { lat: 37.0058, lng: -121.5683 },
provenance: AUTHORED_NODE,
},
{
id: "san-jose",
label: "San Jose",
kind: "junction",
position: { lat: 37.3382, lng: -121.8863 },
provenance: AUTHORED_NODE,
},
{
id: "redwood-city",
label: "Redwood City",
kind: "waypoint",
position: { lat: 37.4852, lng: -122.2364 },
provenance: AUTHORED_NODE,
},
// I-5: Central Valley, followed by an explicitly named Bay connector.
{
id: "santa-clarita",
label: "Santa Clarita",
kind: "waypoint",
position: { lat: 34.3917, lng: -118.5426 },
provenance: AUTHORED_NODE,
},
{
id: "grapevine",
label: "Grapevine",
kind: "waypoint",
position: { lat: 34.9416, lng: -118.929 },
provenance: AUTHORED_NODE,
},
{
id: "lost-hills",
label: "Lost Hills",
kind: "waypoint",
position: { lat: 35.6166, lng: -119.6943 },
provenance: AUTHORED_NODE,
},
{
id: "coalinga-interchange",
label: "Coalinga / SR-198",
kind: "junction",
position: { lat: 36.253, lng: -120.237 },
provenance: AUTHORED_NODE,
},
{
id: "santa-nella",
label: "Santa Nella",
kind: "junction",
position: { lat: 37.102, lng: -121.016 },
provenance: AUTHORED_NODE,
},
{
id: "tracy",
label: "Tracy",
kind: "junction",
position: { lat: 37.7397, lng: -121.4252 },
provenance: AUTHORED_NODE,
},
{
id: "altamont-pass",
label: "Altamont Pass",
kind: "waypoint",
position: { lat: 37.696, lng: -121.686 },
provenance: AUTHORED_NODE,
},
{
id: "dublin",
label: "Dublin",
kind: "waypoint",
position: { lat: 37.7022, lng: -121.9358 },
provenance: AUTHORED_NODE,
},
{
id: "oakland",
label: "Oakland",
kind: "junction",
position: { lat: 37.8044, lng: -122.2712 },
provenance: AUTHORED_NODE,
},
{
id: "bay-bridge",
label: "San Francisco-Oakland Bay Bridge",
kind: "junction",
position: { lat: 37.7983, lng: -122.3778 },
provenance: AUTHORED_NODE,
},
{
id: "san-francisco",
label: "San Francisco",
kind: "terminus",
position: { lat: 37.7749, lng: -122.4194 },
provenance: AUTHORED_NODE,
},
];
export const CALIFORNIA_TRANSPORT_SEGMENTS: readonly TransportSegment[] = [
// US-101 route.
["us101-la-ventura", "los-angeles", "ventura", 65, 3],
["us101-ventura-santa-barbara", "ventura", "santa-barbara", 65, 2],
["us101-santa-barbara-santa-maria", "santa-barbara", "santa-maria", 65, 2],
["us101-santa-maria-san-luis-obispo", "santa-maria", "san-luis-obispo", 65, 2],
["us101-san-luis-obispo-paso-robles", "san-luis-obispo", "paso-robles", 65, 2],
["us101-paso-robles-king-city", "paso-robles", "king-city", 65, 2],
["us101-king-city-salinas", "king-city", "salinas", 65, 2],
["us101-salinas-gilroy", "salinas", "gilroy", 65, 2],
["us101-gilroy-san-jose", "gilroy", "san-jose", 65, 3],
["us101-san-jose-redwood-city", "san-jose", "redwood-city", 65, 4],
["us101-redwood-city-san-francisco", "redwood-city", "san-francisco", 65, 4],
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
id: id as string,
fromNodeId: fromNodeId as string,
toNodeId: toNodeId as string,
roadName: "US-101",
kind: "us-highway" as const,
speedLimitMph: speedLimitMph as number,
lanesPerDirection: lanesPerDirection as number,
provenance: AUTHORED_ROAD,
}));
const INTERSTATE_SEGMENTS: readonly TransportSegment[] = [
["i5-la-santa-clarita", "los-angeles", "santa-clarita", 65, 4],
["i5-santa-clarita-grapevine", "santa-clarita", "grapevine", 65, 3],
["i5-grapevine-lost-hills", "grapevine", "lost-hills", 70, 2],
["i5-lost-hills-coalinga", "lost-hills", "coalinga-interchange", 70, 2],
["i5-coalinga-santa-nella", "coalinga-interchange", "santa-nella", 70, 2],
["i5-santa-nella-tracy", "santa-nella", "tracy", 70, 3],
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
id: id as string,
fromNodeId: fromNodeId as string,
toNodeId: toNodeId as string,
roadName: "I-5",
kind: "interstate" as const,
speedLimitMph: speedLimitMph as number,
lanesPerDirection: lanesPerDirection as number,
provenance: AUTHORED_ROAD,
}));
const BAY_CONNECTOR_SEGMENTS: readonly TransportSegment[] = [
{
id: "i580-tracy-altamont",
fromNodeId: "tracy",
toNodeId: "altamont-pass",
roadName: "I-580",
kind: "connector",
speedLimitMph: 65,
lanesPerDirection: 3,
provenance: AUTHORED_ROAD,
},
{
id: "i580-altamont-dublin",
fromNodeId: "altamont-pass",
toNodeId: "dublin",
roadName: "I-580",
kind: "connector",
speedLimitMph: 65,
lanesPerDirection: 4,
provenance: AUTHORED_ROAD,
},
{
id: "i580-dublin-oakland",
fromNodeId: "dublin",
toNodeId: "oakland",
roadName: "I-580",
kind: "connector",
speedLimitMph: 65,
lanesPerDirection: 4,
provenance: AUTHORED_ROAD,
},
{
id: "i80-oakland-bay-bridge",
fromNodeId: "oakland",
toNodeId: "bay-bridge",
roadName: "I-80 / Bay Bridge",
kind: "connector",
speedLimitMph: 50,
lanesPerDirection: 5,
provenance: AUTHORED_ROAD,
},
{
id: "i80-bay-bridge-san-francisco",
fromNodeId: "bay-bridge",
toNodeId: "san-francisco",
roadName: "I-80",
kind: "connector",
speedLimitMph: 50,
lanesPerDirection: 5,
provenance: AUTHORED_ROAD,
},
];
export const CALIFORNIA_I5_SEGMENTS: readonly TransportSegment[] = [
...INTERSTATE_SEGMENTS,
...BAY_CONNECTOR_SEGMENTS,
];
const US_101_SEGMENT_IDS = CALIFORNIA_TRANSPORT_SEGMENTS.map((segment) => segment.id);
const I_5_SEGMENT_IDS = CALIFORNIA_I5_SEGMENTS.map((segment) => segment.id);
export const CALIFORNIA_TRANSPORT_ROUTES: readonly TransportRoute[] = [
{
id: "la-sf-us-101",
label: "Los Angeles to San Francisco via US-101",
description: "The coastal and Salinas Valley route through Santa Barbara and San Jose.",
fromNodeId: "los-angeles",
toNodeId: "san-francisco",
segmentIds: US_101_SEGMENT_IDS,
provenance: AUTHORED_ROAD,
},
{
id: "la-sf-i-5",
label: "Los Angeles to San Francisco via I-5, I-580, and I-80",
description:
"The Central Valley route, leaving I-5 at Tracy for I-580 and I-80 across the Bay Bridge.",
fromNodeId: "los-angeles",
toNodeId: "san-francisco",
segmentIds: I_5_SEGMENT_IDS,
provenance: AUTHORED_ROAD,
},
];
export const CALIFORNIA_TRANSPORT_ANCHORS: readonly TransportAnchor[] = [
{
id: "city-socal",
kind: "city",
cityId: "socal",
label: "Southern California",
nodeId: "los-angeles",
position: { lat: 34.0522, lng: -118.2437 },
provenance: AUTHORED_NODE,
},
{
id: "city-sf",
kind: "city",
cityId: "sf",
label: "San Francisco",
nodeId: "san-francisco",
position: { lat: 37.7749, lng: -122.4194 },
provenance: AUTHORED_NODE,
},
{
id: "office-mateo-court",
kind: "office",
cityId: "socal",
officeId: "mateo-court",
label: "Mateo Court",
nodeId: "los-angeles",
position: { lat: 34.0395, lng: -118.2288 },
provenance: INTERNAL_OFFICE,
},
{
id: "office-lumbridge-hq",
kind: "office",
cityId: "sf",
officeId: "lumbridge-hq",
label: "Lumbridge HQ",
nodeId: "san-francisco",
position: { lat: 37.7897, lng: -122.3972 },
provenance: INTERNAL_OFFICE,
},
{
id: "office-frontier-valley",
kind: "office",
cityId: "sf",
officeId: "frontier-valley",
label: "Frontier Valley",
nodeId: "oakland",
position: { lat: 37.7756, lng: -122.3186 },
provenance: INTERNAL_OFFICE,
},
];
/** All corridor data in one JSON-safe object. */
export const CALIFORNIA_TRANSPORT: TransportPack = {
id: "california-la-bay",
name: "California: Los Angeles to the Bay",
schemaVersion: 1,
description:
"A coarse statewide simulation corridor connecting Tera's Southern California and San Francisco scenes.",
nodes: CALIFORNIA_TRANSPORT_NODES,
segments: [...CALIFORNIA_TRANSPORT_SEGMENTS, ...CALIFORNIA_I5_SEGMENTS],
routes: CALIFORNIA_TRANSPORT_ROUTES,
anchors: CALIFORNIA_TRANSPORT_ANCHORS,
provenance: [
"Original manually authored Tera project data; no third-party map geometry is embedded.",
"Coordinates, lane counts, and speed envelopes are coarse simulation inputs and must not be used for navigation.",
"Office transition coordinates mirror the repository's own src/offices/sites.ts catalogue.",
],
};
export default CALIFORNIA_TRANSPORT;
@@ -1,94 +0,0 @@
/**
* Serializable contracts for transport packs.
*
* These types deliberately contain data only: no classes, dates, maps, or
* three.js values. A pack can therefore cross an HTTP boundary, live in a
* Worker, or be recorded for a deterministic replay without translation.
*/
/** A WGS84-like geographic position in decimal degrees. */
export interface GeographicPoint {
lat: number;
lng: number;
}
/** The road system responsible for a segment. */
export type RoadKind = "interstate" | "us-highway" | "connector";
/** Why a node exists in the deliberately sparse corridor graph. */
export type TransportNodeKind = "terminus" | "waypoint" | "junction";
export interface TransportNode {
id: string;
label: string;
kind: TransportNodeKind;
position: GeographicPoint;
/** Human-readable origin and accuracy note for this authored coordinate. */
provenance: string;
}
/**
* A directed driveable edge. Reverse itineraries traverse the same edge in
* reverse; geometry is intentionally not duplicated for each direction.
*/
export interface TransportSegment {
id: string;
fromNodeId: string;
toNodeId: string;
/** The name a route badge or itinerary should show. */
roadName: string;
kind: RoadKind;
/** Coarse simulation envelope, not live traffic or navigation advice. */
speedLimitMph: number;
/** Number of through lanes in one direction at the representative section. */
lanesPerDirection: number;
provenance: string;
}
/** A named, ordered itinerary through the segment graph. */
export interface TransportRoute {
id: string;
label: string;
description: string;
fromNodeId: string;
toNodeId: string;
segmentIds: readonly string[];
provenance: string;
}
interface AnchorBase {
id: string;
label: string;
/** Nearest corridor node used to enter or leave the large-scale simulation. */
nodeId: string;
/** Exact transition marker; it need not lie on the corridor centreline. */
position: GeographicPoint;
provenance: string;
}
export interface CityTransportAnchor extends AnchorBase {
kind: "city";
cityId: string;
}
export interface OfficeTransportAnchor extends AnchorBase {
kind: "office";
cityId: string;
officeId: string;
}
export type TransportAnchor = CityTransportAnchor | OfficeTransportAnchor;
/** A complete coarse world graph and its links to finer Tera scenes. */
export interface TransportPack {
id: string;
name: string;
schemaVersion: 1;
description: string;
nodes: readonly TransportNode[];
segments: readonly TransportSegment[];
routes: readonly TransportRoute[];
anchors: readonly TransportAnchor[];
/** Pack-wide authorship and fitness-for-purpose notices. */
provenance: readonly string[];
}
@@ -1,542 +0,0 @@
/**
* Renderer-independent solo vehicle controls for a route-relative simulation.
*
* Inputs are normalized action snapshots rather than DOM events, so keyboards,
* gamepads, touch controls, remote clients, and recorded replays all drive the
* same deterministic fixed-step state machine.
*/
import type { GeographicPoint, TransportPack } from "./types.ts";
import {
buildRoutePath,
sampleRoute,
type RoutePath,
type RouteSample,
} from "./vehicleSim.ts";
const MPH_TO_MPS = 0.44704;
const EARTH_RADIUS_M = 6_371_000;
const TWO_PI = Math.PI * 2;
export type VehicleControlMode = "assisted" | "manual";
export type VehicleModeRequest = "none" | VehicleControlMode;
/** Device-neutral actions sampled for one rendered or fixed simulation frame. */
export interface VehicleActionSnapshot {
/** Accelerator position in the inclusive range [0, 1]. */
throttle: number;
/** Service brake position in the inclusive range [0, 1]. */
brake: number;
/** Steering input where -1 is full left and 1 is full right. */
steering: number;
handbrake: boolean;
/** One-shot mode request. Meaningful even when all analogue axes are neutral. */
modeRequest: VehicleModeRequest;
/** One-shot request to restore the configured initial state. */
reset: boolean;
}
export const NEUTRAL_VEHICLE_ACTIONS: Readonly<VehicleActionSnapshot> = Object.freeze({
throttle: 0,
brake: 0,
steering: 0,
handbrake: false,
modeRequest: "none",
reset: false,
});
export interface VehicleControllerOptions {
routeId: string;
mode?: VehicleControlMode;
direction?: 1 | -1;
initialDistanceM?: number;
initialLateralOffsetM?: number;
initialSpeedMps?: number;
/** Defaults to 60 Hz and is clamped to a safe simulation range. */
fixedStepSeconds?: number;
/** Caps catch-up after a sleeping tab. Defaults to 0.25 seconds. */
maxFrameDeltaSeconds?: number;
maximumSpeedMps?: number;
assistedCruiseRatio?: number;
guardrailOffsetM?: number;
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.
*/
travelScale?: number;
}
export interface VehicleControllerState extends GeographicPoint {
routeId: string;
mode: VehicleControlMode;
direction: 1 | -1;
/** Distance from the route's declared start. A restored endpoint may equal route length. */
distanceM: number;
progress: number;
/** Signed offset from route centre; positive is to the driver's right. */
lateralOffsetM: number;
speedMps: number;
/** Smoothed normalized steering position, independent of input device. */
steering: number;
routeHeadingDeg: number;
headingDeg: number;
segmentId: string;
roadName: string;
speedLimitMph: number;
wheelRadians: number;
guardrailContact: boolean;
/** Signed realized acceleration and jerk from the deterministic fixed step. */
longitudinalAccelerationMps2: number;
jerkMps3: number;
/** 1 is centred/stable, 0 is at the configured road edge. */
laneKeepingScore: number;
leadGapM: number | null;
leadSpeedMps: number | null;
timeHeadwaySeconds: number | null;
/** Normalized [0,1] closing/gap risk estimate. */
collisionRisk: number;
trafficIntervention: boolean;
elapsedSteps: number;
}
export interface VehicleTrafficContext {
leadGapM: number | null;
leadSpeedMps: number | null;
}
export interface VehicleControllerSnapshot extends VehicleControllerState {}
/** A held input snapshot and its exact duration in fixed simulation steps. */
export interface TimedVehicleInputFrame {
steps: number;
actions?: Partial<VehicleActionSnapshot>;
}
export interface VehicleReplayResult {
/** Initial state followed by one snapshot after every simulated step. */
trajectory: readonly VehicleControllerSnapshot[];
final: VehicleControllerSnapshot;
}
interface ResolvedOptions {
routeId: string;
mode: VehicleControlMode;
direction: 1 | -1;
initialDistanceM: number;
initialLateralOffsetM: number;
initialSpeedMps: number;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
maximumSpeedMps: number;
assistedCruiseRatio: number;
guardrailOffsetM: number;
wheelRadiusM: number;
travelScale: number;
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function wrap(value: number, modulus: number): number {
return ((value % modulus) + modulus) % modulus;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
/** Clamp and sanitize input from any adapter before it reaches simulation. */
export function normalizeVehicleActions(
actions: Partial<VehicleActionSnapshot> | undefined,
): VehicleActionSnapshot {
const modeRequest = actions?.modeRequest;
return {
throttle: clamp(finiteOr(actions?.throttle, 0), 0, 1),
brake: clamp(finiteOr(actions?.brake, 0), 0, 1),
steering: clamp(finiteOr(actions?.steering, 0), -1, 1),
handbrake: actions?.handbrake === true,
modeRequest: modeRequest === "manual" || modeRequest === "assisted" ? modeRequest : "none",
reset: actions?.reset === true,
};
}
function resolveOptions(options: VehicleControllerOptions): ResolvedOptions {
return {
routeId: options.routeId,
mode: options.mode === "manual" ? "manual" : "assisted",
direction: options.direction === -1 ? -1 : 1,
initialDistanceM: finiteOr(options.initialDistanceM, 0),
initialLateralOffsetM: finiteOr(options.initialLateralOffsetM, 0),
initialSpeedMps: Math.max(0, finiteOr(options.initialSpeedMps, 0)),
fixedStepSeconds: clamp(finiteOr(options.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(options.maxFrameDeltaSeconds, 0.25), 0.05, 1),
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),
travelScale: clamp(finiteOr(options.travelScale, 1), 1, 10_000),
};
}
function hasManualIntent(actions: VehicleActionSnapshot): boolean {
return (
actions.modeRequest === "manual" ||
actions.handbrake ||
actions.throttle > 0.02 ||
actions.brake > 0.02 ||
Math.abs(actions.steering) > 0.08
);
}
function offsetPoint(sample: RouteSample, lateralOffsetM: number): GeographicPoint {
const heading = (sample.headingDeg * Math.PI) / 180;
// Right-hand normal to a compass bearing: south for eastbound, east for northbound.
const northM = -Math.sin(heading) * lateralOffsetM;
const eastM = Math.cos(heading) * lateralOffsetM;
const latitudeRadians = (sample.lat * Math.PI) / 180;
return {
lat: sample.lat + (northM / EARTH_RADIUS_M) * (180 / Math.PI),
lng:
sample.lng +
(eastM / (EARTH_RADIUS_M * Math.max(0.01, Math.cos(latitudeRadians)))) * (180 / Math.PI),
};
}
/**
* Deterministic route-relative driving state machine.
*
* `tick` adapts render time to a fixed clock. `stepFixed` is the authoritative
* primitive for tests, networking, and replay and always advances exactly once.
*/
export class VehicleController {
private readonly pack: TransportPack;
private readonly options: ResolvedOptions;
private path: RoutePath;
private accumulator = 0;
private traffic: VehicleTrafficContext = { leadGapM: null, leadSpeedMps: null };
private readonly current: VehicleControllerState;
constructor(pack: TransportPack, options: VehicleControllerOptions) {
this.pack = pack;
this.options = resolveOptions(options);
this.path = buildRoutePath(pack, this.options.routeId);
if (options.initialDistanceM === undefined && this.options.direction === -1) {
this.options.initialDistanceM = this.path.lengthM;
}
const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction);
const point = offsetPoint(sample, 0);
this.current = {
...point,
routeId: this.path.route.id,
mode: this.options.mode,
direction: this.options.direction,
distanceM: 0,
progress: 0,
lateralOffsetM: 0,
speedMps: 0,
steering: 0,
routeHeadingDeg: sample.headingDeg,
headingDeg: sample.headingDeg,
segmentId: sample.segmentId,
roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph,
wheelRadians: 0,
guardrailContact: false,
longitudinalAccelerationMps2: 0,
jerkMps3: 0,
laneKeepingScore: 1,
leadGapM: null,
leadSpeedMps: null,
timeHeadwaySeconds: null,
collisionRisk: 0,
trafficIntervention: false,
elapsedSteps: 0,
};
this.reset();
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
routeId(): string {
return this.path.route.id;
}
/** Stable state object for allocation-free polling. Treat it as read-only. */
state(): Readonly<VehicleControllerState> {
return this.current;
}
/** Detached state suitable for logs, network frames, and equality assertions. */
snapshot(): VehicleControllerSnapshot {
return { ...this.current };
}
/** Restore a trusted JSON snapshot for deterministic environment checkpointing. */
restore(snapshot: VehicleControllerSnapshot): void {
const numeric = Object.entries(snapshot)
.filter(([, value]) => typeof value === "number")
.every(([, value]) => Number.isFinite(value));
if (
!numeric || snapshot.routeId !== this.path.route.id ||
snapshot.direction !== this.options.direction ||
(snapshot.mode !== "manual" && snapshot.mode !== "assisted") ||
snapshot.distanceM < 0 || snapshot.distanceM > this.path.lengthM ||
Math.abs(snapshot.lateralOffsetM) > this.options.guardrailOffsetM ||
snapshot.speedMps < 0 || snapshot.speedMps > this.options.maximumSpeedMps ||
!Number.isSafeInteger(snapshot.elapsedSteps) || snapshot.elapsedSteps < 0
) throw new RangeError("vehicle snapshot is incompatible or invalid");
Object.assign(this.current, snapshot);
this.traffic = {
leadGapM: snapshot.leadGapM,
leadSpeedMps: snapshot.leadSpeedMps,
};
this.accumulator = 0;
}
/** Supply a renderer/simulation-neutral nearest-lead observation. */
setTrafficContext(context: Partial<VehicleTrafficContext> | null): void {
const gap = context?.leadGapM;
const speed = context?.leadSpeedMps;
this.traffic = {
leadGapM: typeof gap === "number" && Number.isFinite(gap) && gap >= 0 ? gap : null,
leadSpeedMps: typeof speed === "number" && Number.isFinite(speed) && speed >= 0 ? speed : null,
};
}
/** Restore the configured spawn state and clear pending fractional time. */
reset(): void {
this.accumulator = 0;
const wrappedDistanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
const distanceM = wrappedDistanceM === 0 &&
this.options.initialDistanceM > 0 &&
this.options.initialDistanceM <= this.path.lengthM
? this.path.lengthM
: wrappedDistanceM;
const lateralOffsetM = clamp(
this.options.initialLateralOffsetM,
-this.options.guardrailOffsetM,
this.options.guardrailOffsetM,
);
const speedMps = clamp(this.options.initialSpeedMps, 0, this.options.maximumSpeedMps);
const sample = sampleRoute(this.path, distanceM, this.options.direction);
const point = offsetPoint(sample, lateralOffsetM);
Object.assign(this.current, point, {
routeId: this.path.route.id,
mode: this.options.mode,
direction: this.options.direction,
distanceM,
progress: distanceM / this.path.lengthM,
lateralOffsetM,
speedMps,
steering: 0,
routeHeadingDeg: sample.headingDeg,
headingDeg: sample.headingDeg,
segmentId: sample.segmentId,
roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph,
wheelRadians: 0,
guardrailContact: false,
longitudinalAccelerationMps2: 0,
jerkMps3: 0,
laneKeepingScore: 1 - Math.abs(lateralOffsetM) / this.options.guardrailOffsetM,
leadGapM: this.traffic.leadGapM,
leadSpeedMps: this.traffic.leadSpeedMps,
timeHeadwaySeconds: null,
collisionRisk: 0,
trafficIntervention: false,
elapsedSteps: 0,
});
}
/**
* Change corridor as an explicit reset. Progress may optionally be preserved,
* which is useful for switching route variants without retaining stale metres.
*/
setRoute(routeId: string, preserveProgress = false): void {
if (routeId === this.path.route.id) return;
const previousProgress = this.current.progress;
this.path = buildRoutePath(this.pack, routeId);
this.options.routeId = routeId;
this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0;
this.traffic = { leadGapM: null, leadSpeedMps: null };
this.reset();
}
/** Advance rendered seconds and return the number of fixed steps executed. */
tick(
deltaSeconds: number,
actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS,
): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeVehicleActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
/** Advance exactly one authoritative simulation step. */
stepFixed(actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS): void {
const normalized = normalizeVehicleActions(actions);
if (normalized.reset) {
this.reset();
return;
}
this.stepNormalized(normalized);
}
private stepNormalized(actions: VehicleActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
const previousSpeedMps = this.current.speedMps;
const previousAcceleration = this.current.longitudinalAccelerationMps2;
const manualIntent = hasManualIntent(actions);
// Direct human input always wins, including over a simultaneous request to
// resume assistance. A neutral assisted request can re-engage on the next step.
if (manualIntent) this.current.mode = "manual";
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
let throttle = actions.throttle;
let brake = actions.brake;
let steeringTarget = actions.steering;
if (this.current.mode === "assisted") {
const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio;
let targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps);
const safeGapM = Math.max(10, this.current.speedMps * 1.8);
if (this.traffic.leadGapM !== null && this.traffic.leadSpeedMps !== null) {
targetSpeed = Math.min(
targetSpeed,
Math.max(0, this.traffic.leadSpeedMps + (this.traffic.leadGapM - safeGapM) * 0.55),
);
}
const speedError = targetSpeed - this.current.speedMps;
throttle = clamp(speedError / 5, 0, 1);
brake = clamp(-speedError / 7, 0, 1);
steeringTarget = clamp(-this.current.lateralOffsetM / 2.4, -1, 1);
}
this.current.steering = moveToward(this.current.steering, steeringTarget, 3.8 * dt);
const aeroDrag = this.current.speedMps * this.current.speedMps * 0.0018;
const rollingDrag = this.current.speedMps > 0 ? 0.12 : 0;
const engineFade = 1 - 0.55 * (this.current.speedMps / this.options.maximumSpeedMps);
const acceleration =
throttle * 5.4 * Math.max(0.2, engineFade) -
brake * 9.5 -
(actions.handbrake ? 13 : 0) -
aeroDrag -
rollingDrag;
this.current.speedMps = clamp(
this.current.speedMps + acceleration * dt,
0,
this.options.maximumSpeedMps,
);
const previousDistance = this.current.distanceM;
const physicalTravelled = this.current.speedMps * dt;
const routeTravelled = physicalTravelled * this.options.travelScale;
this.current.distanceM = wrap(
previousDistance + routeTravelled * this.current.direction,
this.path.lengthM,
);
let proposedLateral =
this.current.lateralOffsetM + this.current.steering * this.current.speedMps * 0.2 * dt;
if (this.current.mode === "assisted") {
// Assistance damps the final few centimetres without an abrupt lane snap.
proposedLateral *= Math.exp(-0.35 * dt);
}
this.current.guardrailContact = Math.abs(proposedLateral) > this.options.guardrailOffsetM;
if (this.current.guardrailContact) {
proposedLateral = clamp(
proposedLateral,
-this.options.guardrailOffsetM,
this.options.guardrailOffsetM,
);
this.current.speedMps = Math.min(this.current.speedMps * 0.78, 12);
this.current.steering *= 0.35;
}
this.current.lateralOffsetM = proposedLateral;
const sample = sampleRoute(this.path, this.current.distanceM, this.current.direction);
const point = offsetPoint(sample, this.current.lateralOffsetM);
const wheelDelta = physicalTravelled / this.options.wheelRadiusM;
const realizedAcceleration = (this.current.speedMps - previousSpeedMps) / dt;
const leadGapM = this.traffic.leadGapM;
const leadSpeedMps = this.traffic.leadSpeedMps;
const timeHeadwaySeconds = leadGapM === null || this.current.speedMps < 0.1
? null
: leadGapM / this.current.speedMps;
const closingSpeedMps = leadSpeedMps === null ? 0 : Math.max(0, this.current.speedMps - leadSpeedMps);
const timeToCollision = leadGapM === null || closingSpeedMps < 0.1
? Number.POSITIVE_INFINITY
: leadGapM / closingSpeedMps;
const gapRisk = leadGapM === null ? 0 : clamp(1 - leadGapM / Math.max(12, this.current.speedMps * 2), 0, 1);
const collisionRisk = Math.max(gapRisk, clamp(1 - timeToCollision / 6, 0, 1));
Object.assign(this.current, point, {
progress: this.current.distanceM / this.path.lengthM,
routeHeadingDeg: sample.headingDeg,
headingDeg: sample.headingDeg + this.current.steering * 9,
segmentId: sample.segmentId,
roadName: sample.roadName,
speedLimitMph: sample.speedLimitMph,
wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI),
longitudinalAccelerationMps2: realizedAcceleration,
jerkMps3: (realizedAcceleration - previousAcceleration) / dt,
laneKeepingScore: clamp(1 - Math.abs(this.current.lateralOffsetM) / this.options.guardrailOffsetM, 0, 1),
leadGapM,
leadSpeedMps,
timeHeadwaySeconds,
collisionRisk,
trafficIntervention:
this.current.mode === "assisted" &&
leadGapM !== null &&
leadGapM < Math.max(12, this.current.speedMps * 2.1),
elapsedSteps: this.current.elapsedSteps + 1,
});
}
}
/** Execute an exact, renderer-independent input recording. */
export function replayVehicleInputs(
pack: TransportPack,
options: VehicleControllerOptions,
frames: readonly TimedVehicleInputFrame[],
): VehicleReplayResult {
const controller = new VehicleController(pack, options);
const trajectory: VehicleControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Math.max(0, Math.floor(finiteOr(frame.steps, 0)));
const actions = normalizeVehicleActions(frame.actions);
for (let index = 0; index < steps; index += 1) {
// Reset and mode requests are edge-triggered at the start of a timed frame.
controller.stepFixed(
index === 0
? actions
: { ...actions, modeRequest: "none", reset: false },
);
trajectory.push(controller.snapshot());
}
}
const final = trajectory.at(-1) ?? controller.snapshot();
return { trajectory, final };
}
@@ -1,287 +0,0 @@
/**
* Deterministic road traffic over a serializable `TransportPack`.
*
* The simulation owns route progress, never render objects. It advances on a
* fixed clock and exposes plain geographic poses, so a city scene can project
* them through `World` while a server or replay runner can use the same code
* without three.js. Long background-tab deltas are capped rather than replayed
* as a burst; traffic resumes smoothly instead of teleporting through a route.
*/
import type {
GeographicPoint,
TransportPack,
TransportRoute,
TransportSegment,
} from "./types.ts";
const EARTH_RADIUS_M = 6_371_000;
const MPH_TO_MPS = 0.44704;
const FIXED_STEP = 1 / 20;
const MAX_FRAME_DELTA = 0.25;
export interface RouteLeg {
segment: TransportSegment;
from: GeographicPoint;
to: GeographicPoint;
lengthM: number;
startM: number;
endM: number;
}
export interface RoutePath {
route: TransportRoute;
legs: readonly RouteLeg[];
lengthM: number;
}
export interface RouteSample extends GeographicPoint {
/** Compass bearing in degrees clockwise from true north. */
headingDeg: number;
segmentId: string;
roadName: string;
speedLimitMph: number;
}
export interface VehiclePose extends RouteSample {
id: string;
routeId: string;
/** 0 is the median-side lane; positive values move toward the shoulder. */
lane: number;
direction: 1 | -1;
speedMps: number;
distanceM: number;
progress: number;
wheelRadians: number;
}
export interface VehicleSimulationOptions {
routeId: string;
count?: number;
seed?: number;
/** Time compression for the statewide board. Defaults to 900x. */
timeScale?: number;
}
export interface LeadVehicleObservation {
id: string;
gapM: number;
speedMps: number;
lane: number;
}
interface VehicleState {
pose: VehiclePose;
cruise: number;
}
function radians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/** Equirectangular distance; sub-metre agreement is unnecessary at this scale. */
export function distanceMetres(a: GeographicPoint, b: GeographicPoint): number {
const meanLat = radians((a.lat + b.lat) / 2);
const dy = radians(b.lat - a.lat);
const dx = radians(b.lng - a.lng) * Math.cos(meanLat);
return Math.hypot(dx, dy) * EARTH_RADIUS_M;
}
function bearing(a: GeographicPoint, b: GeographicPoint): number {
const meanLat = radians((a.lat + b.lat) / 2);
const north = b.lat - a.lat;
const east = (b.lng - a.lng) * Math.cos(meanLat);
return (Math.atan2(east, north) * 180) / Math.PI;
}
export function buildRoutePath(pack: TransportPack, routeId: string): RoutePath {
const route = pack.routes.find((candidate) => candidate.id === routeId);
if (!route) throw new Error(`transport: unknown route "${routeId}"`);
const nodes = new Map(pack.nodes.map((node) => [node.id, node]));
const segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
const legs: RouteLeg[] = [];
let cursor = 0;
for (const id of route.segmentIds) {
const segment = segments.get(id);
if (!segment) throw new Error(`transport: route "${routeId}" references missing segment "${id}"`);
const from = nodes.get(segment.fromNodeId)?.position;
const to = nodes.get(segment.toNodeId)?.position;
if (!from || !to) throw new Error(`transport: segment "${id}" references a missing node`);
const lengthM = distanceMetres(from, to);
if (!(lengthM > 0)) throw new Error(`transport: segment "${id}" has no length`);
legs.push({ segment, from, to, lengthM, startM: cursor, endM: cursor + lengthM });
cursor += lengthM;
}
if (legs.length === 0) throw new Error(`transport: route "${routeId}" is empty`);
return { route, legs, lengthM: cursor };
}
function wrap(value: number, modulus: number): number {
return ((value % modulus) + modulus) % modulus;
}
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
const wrapped = wrap(distanceM, path.lengthM);
// Preserve the authored endpoint when a caller deliberately samples an
// exact positive route length. Simulation steps store their already-wrapped
// zero and still loop normally; restored journey progress=1 must remain at
// San Francisco instead of teleporting to Los Angeles before play resumes.
const travelled = wrapped === 0 && distanceM > 0 && distanceM <= path.lengthM
? path.lengthM
: wrapped;
const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1);
if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`);
const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
const from = direction === 1 ? leg.from : leg.to;
const to = direction === 1 ? leg.to : leg.from;
// `distanceM` is always measured from the route's declared start. Reverse
// traffic advances that scalar downward, so its geographic interpolation is
// still `t`; only its bearing is reversed. Mirroring `t` here makes a
// southbound car move north while visually facing south.
const u = t;
return {
lat: leg.from.lat + (leg.to.lat - leg.from.lat) * u,
lng: leg.from.lng + (leg.to.lng - leg.from.lng) * u,
headingDeg: bearing(from, to),
segmentId: leg.segment.id,
roadName: leg.segment.roadName,
speedLimitMph: leg.segment.speedLimitMph,
};
}
/** Small reproducible generator; simulation results never depend on `Math.random()`. */
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
};
}
export class VehicleSimulation {
private readonly pack: TransportPack;
private path: RoutePath;
private readonly count: number;
private readonly seed: number;
private readonly timeScale: number;
private readonly segments: ReadonlyMap<string, TransportSegment>;
private accumulator = 0;
private states: VehicleState[] = [];
private poseView: VehiclePose[] = [];
constructor(pack: TransportPack, options: VehicleSimulationOptions) {
this.pack = pack;
this.path = buildRoutePath(pack, options.routeId);
this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12)));
this.seed = options.seed ?? 115;
this.timeScale = Math.max(1, options.timeScale ?? 900);
this.segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
this.reset();
}
routeId(): string {
return this.path.route.id;
}
setRoute(routeId: string): void {
if (routeId === this.path.route.id) return;
this.path = buildRoutePath(this.pack, routeId);
this.accumulator = 0;
this.reset();
}
private reset(): void {
const rand = seeded(this.seed ^ hash(this.path.route.id));
this.states = Array.from({ length: this.count }, (_, index) => {
// The first vehicle is the northbound follow-camera hero. Every third
// background vehicle after it runs south so both carriageways stay alive.
const direction: 1 | -1 = index > 0 && index % 3 === 0 ? -1 : 1;
const distanceM = ((index + rand() * 0.6) / this.count) * this.path.lengthM;
const sample = sampleRoute(this.path, distanceM, direction);
const cruise = 0.86 + rand() * 0.12;
const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
return {
cruise,
pose: {
...sample,
id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`,
routeId: this.path.route.id,
lane: index % Math.max(2, Math.min(3, laneCount)),
direction,
speedMps,
distanceM,
progress: distanceM / this.path.lengthM,
wheelRadians: 0,
},
};
});
// Keep one stable array for render consumers. The pose objects within it
// are already mutated in place by `step`, so a 60 fps scene should not pay
// for a fresh wrapper array on every frame.
this.poseView = this.states.map((state) => state.pose);
}
/** Advance by rendered seconds; internally every state change is a 20 Hz step. */
tick(dt: number): void {
if (!Number.isFinite(dt) || dt <= 0) return;
this.accumulator += Math.min(dt, MAX_FRAME_DELTA);
while (this.accumulator >= FIXED_STEP) {
this.step(FIXED_STEP);
this.accumulator -= FIXED_STEP;
}
}
private step(dt: number): void {
for (const state of this.states) {
const previous = state.pose;
const signed = previous.speedMps * this.timeScale * dt * previous.direction;
const distanceM = wrap(previous.distanceM + signed, this.path.lengthM);
const sample = sampleRoute(this.path, distanceM, previous.direction);
const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise;
const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
Object.assign(previous, sample, {
lane: Math.min(previous.lane, Math.max(1, laneCount - 1)),
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),
});
}
}
/** Stable objects, mutated in place; render layers may retain references. */
poses(): readonly VehiclePose[] {
return this.poseView;
}
/** Nearest same-lane vehicle ahead, suitable for deterministic ACC input. */
leadVehicle(distanceM: number, direction: 1 | -1, lane = 0): LeadVehicleObservation | null {
let nearest: LeadVehicleObservation | null = null;
for (let index = 1; index < this.states.length; index += 1) {
const pose = this.states[index]?.pose;
if (!pose || pose.direction !== direction || pose.lane !== lane) continue;
const gapM = direction === 1
? wrap(pose.distanceM - distanceM, this.path.lengthM)
: wrap(distanceM - pose.distanceM, this.path.lengthM);
if (gapM < 0.5 || (nearest && gapM >= nearest.gapM)) continue;
nearest = { id: pose.id, gapM, speedMps: pose.speedMps, lane: pose.lane };
}
return nearest;
}
}
function hash(value: string): number {
let out = 2_166_136_261;
for (let index = 0; index < value.length; index += 1) {
out ^= value.charCodeAt(index);
out = Math.imul(out, 16_777_619);
}
return out >>> 0;
}
@@ -1,210 +0,0 @@
#!/usr/bin/env node
// A long-lived NDJSON worker over stdio, wrapping the vendored `tera.arena/v1`
// environments.
//
// Why a resident worker rather than a process per rollout or an HTTP server:
// measured on amd-server, `node` needs ~92 ms to type-strip and import the
// closure, a round trip then costs ~19 us and a step ~106 us. Paying the 92 ms
// once per eval instead of once per rollout is the whole difference, and stdio
// has no port, no auth surface and no way to be talking to somebody else's
// build.
//
// Protocol: one JSON object per line in, one per line out.
// -> {"id": 1, "op": "reset", "env": "crow-nav-v1", "seed": 115,
// "scenario": {"split": "train"}}
// <- {"id": 1, "ok": true, "result": {...}}
// <- {"id": 1, "ok": false, "error": "..."}
//
// Every number in a result came out of TypeScript. Nothing here recomputes a
// reward, a checksum or a baseline in another language — including the oracle,
// which calls the baselines `src/arena/index.ts` already exports rather than
// reimplementing them where they could drift.
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
const arena = await import(
new URL("./vendor/tera/src/arena/index.ts", import.meta.url).href
);
const ENVIRONMENTS = {
"drive-101-v1": {
Environment: arena.Drive101Environment,
scripted: arena.driveScriptedBaseline,
inaction: arena.DRIVE_INACTION,
},
"office-nav-v1": {
Environment: arena.OfficeNavEnvironment,
scripted: arena.officeNavScriptedBaseline,
inaction: arena.OFFICE_NAV_INACTION,
},
"crow-nav-v1": {
Environment: arena.CrowNavEnvironment,
scripted: arena.crowNavScriptedBaseline,
inaction: arena.CROW_NAV_INACTION,
},
"california-flight-v1": {
Environment: arena.CaliforniaFlightEnvironment,
scripted: arena.californiaFlightScriptedBaseline,
inaction: arena.CALIFORNIA_FLIGHT_INACTION,
},
};
function registration(envId) {
const entry = ENVIRONMENTS[envId];
if (!entry) throw new Error(`unknown environment: ${envId}`);
return entry;
}
/** Live episodes, keyed by a handle the caller chooses. */
const handles = new Map();
function held(handle) {
const env = handles.get(handle);
if (!env) throw new Error(`unknown handle: ${handle}`);
return env;
}
/**
* Run a whole episode inside TypeScript under one of the exported baselines.
*
* This is the reward denominator. Reimplementing these four policies in Python
* would put the number every environment is normalised against on the far side
* of a hand translation so the loop lives here and Python receives a result.
*
* `hold` is how many simulation steps one decision is held for. At 1 the policy
* sees every frame, which is the denominator. Above 1 it is the same policy
* flying with a language model's refresh rate, and the sweep of returns over
* `hold` is the evidence for how large a turn budget the taskset has to grant.
*/
function rollBaseline(envId, seed, scenario, policy, maxSteps, hold) {
const { Environment, scripted, inaction } = registration(envId);
const env = new Environment();
const start = env.reset(seed, scenario);
const cap = maxSteps ?? env.manifest.maxSteps;
const every = Math.max(1, Math.trunc(hold ?? 1));
let observation = start.observation;
let cumulativeReward = 0;
let steps = 0;
let decisions = 0;
let terminated = false;
let truncated = false;
let terminalReason = null;
let action = null;
const components = {};
while (steps < cap) {
if (steps % every === 0) {
action = policy === "inaction" ? inaction : scripted(observation);
decisions += 1;
}
const result = env.step(action);
observation = result.observation;
cumulativeReward += result.reward;
steps += 1;
for (const [name, value] of Object.entries(result.rewardComponents)) {
components[name] = (components[name] ?? 0) + value;
}
terminated = result.terminated;
truncated = result.truncated;
terminalReason = result.info.terminalReason;
if (terminated || truncated) break;
}
return {
envId,
policy,
seed,
scenario,
hold: every,
decisions,
steps,
cumulativeReward,
rewardComponents: components,
terminated,
truncated,
terminalReason,
observation,
trace: env.trace(),
};
}
const OPS = {
ping: () => ({ pong: true }),
manifests: () => ({ manifests: arena.ARENA_MANIFESTS }),
sourceHashes: () => ({ sourceHashes: arena.ARENA_SOURCE_HASHES }),
// Tera's own canonical FNV-1a-64. Exposed so a caller can re-seal an envelope
// it has edited — which is how the replay gate proves that `replay()` catches
// a *self-consistent* forgery, not merely a broken checksum.
checksum: ({ value }) => ({ checksum: arena.arenaChecksum(value) }),
reset: ({ handle, env: envId, seed, scenario }) => {
const { Environment } = registration(envId);
const env = new Environment();
const result = env.reset(seed, scenario);
handles.set(handle, env);
return result;
},
step: ({ handle, action }) => held(handle).step(action),
snapshot: ({ handle }) => ({ snapshot: held(handle).snapshot() }),
restore: ({ handle, snapshot }) => held(handle).restore(snapshot),
trace: ({ handle }) => ({ trace: held(handle).trace() }),
// A fresh environment every time: replaying into the instance that produced
// the trace would let residual state answer the question the gate is asking.
replay: ({ env: envId, trace }) => {
const { Environment } = registration(envId);
return new Environment().replay(trace);
},
oracle: ({ env: envId, seed, scenario, policy = "scripted", maxSteps = null, hold = 1 }) =>
rollBaseline(envId, seed, scenario, policy, maxSteps, hold),
close: ({ handle }) => ({ closed: handles.delete(handle) }),
shutdown: () => {
queueMicrotask(() => process.exit(0));
return { shutdown: true };
},
};
const out = process.stdout;
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
lines.on("line", (line) => {
const text = line.trim();
if (!text) return;
let request;
try {
request = JSON.parse(text);
} catch (error) {
out.write(JSON.stringify({ id: null, ok: false, error: `bad request json: ${error.message}` }) + "\n");
return;
}
const id = request.id ?? null;
try {
const op = OPS[request.op];
if (!op) throw new Error(`unknown op: ${request.op}`);
out.write(JSON.stringify({ id, ok: true, result: op(request) }) + "\n");
} catch (error) {
// Environment errors — a tampered trace, a stepped-past-terminal episode —
// are the worker's normal output, not a crash. They cross as `ok: false`.
out.write(JSON.stringify({
id,
ok: false,
error: String(error && error.message ? error.message : error),
errorType: error?.constructor?.name ?? "Error",
}) + "\n");
}
});
lines.on("close", () => process.exit(0));
if (process.argv[1] === fileURLToPath(import.meta.url)) {
out.write(JSON.stringify({ id: null, ok: true, result: { ready: true, pid: process.pid } }) + "\n");
}
@@ -1 +0,0 @@
"""Tests for tera-spatial."""
@@ -1,305 +0,0 @@
"""The taskset's own gate: the grammar, the arithmetic, and one flight flown for real.
`test_replay.py` proves the bridge carries a simulation faithfully. This file
proves the three things built on top of it are right when no model is watching:
1. **The reply grammar.** A reply is the only thing a model controls, so every way
of getting it wrong has to land somewhere sensible and none of them may raise
a formatting slip that crashes a rollout scores nothing at all rather than
scoring badly.
2. **The reward.** Floor, ceiling and the two products, computed against episodes
this file actually flies rather than against a fixture.
3. **The rendering.** The strongest claim a spatial observation can make is that
it is enough to fly on, so the test flies on it: a policy that reads nothing
but the rendered panel the same characters the model is shown, parsed back
out of them reaches the waypoint inside the shipped budget.
uv run --project environments/tera_spatial python -m unittest discover \\
-s environments/tera_spatial/tests -v
"""
from __future__ import annotations
import json
import math
import re
import unittest
from tera_crow_nav.taskset import CrowNavConfig, CrowNavTaskset
from tera_spatial import TeraWorker
from tera_spatial.spatial import (
Command,
Flight,
SpatialData,
TurnView,
parse_command,
score,
)
FIELDS = ("forward", "turn", "pitch", "climb", "glide")
INACTION = dict.fromkeys(FIELDS, 0.0)
MINIMUM_POWER = {"forward": -1.0, "turn": 0.0, "pitch": 0.0, "climb": 0.03, "glide": False}
_DISTANCE = re.compile(r"^\s+([-\d.]+) m away .* bearing ([+-]\d+)", re.MULTILINE)
_HEIGHT = re.compile(r"([+-][\d.]+) m of height to make up")
def read_the_board(board: str) -> tuple[float, float, float]:
"""Distance, bearing in degrees and height to make up — from the panel text only.
Deliberately parsed out of the rendered characters rather than taken from the
observation dict. If this function can fly the crow, the rendering carries
enough; if the rendering ever stops carrying enough, this stops passing.
"""
away, bearing = _DISTANCE.search(board).groups()
(height,) = _HEIGHT.search(board).groups()
return float(away), float(bearing), float(height)
def reader_policy(board: str) -> str:
"""A reply, in the grammar, from a reader who has seen only the panel.
Proportional guidance and nothing clever: point at the waypoint, trim the
height, and shorten the hold as it closes, because a crow at 12 m/s covers
12 m in one held second and the waypoint is 2.5 m wide.
"""
away, bearing, height = read_the_board(board)
hold = 8 if away > 40 else 4 if away > 12 else 2
return json.dumps(
{
"forward": 0.45,
"turn": max(-1.0, min(1.0, math.radians(bearing) / 0.55)),
"pitch": 0.0,
"climb": max(-0.8, min(0.8, height / 10 - 0.2)),
"glide": False,
"hold": hold,
}
)
class GrammarTests(unittest.TestCase):
"""Every reply a model can emit, and where it lands."""
def parse(self, reply: str, previous: Command | None = None) -> Command:
return parse_command(
reply,
fields=FIELDS,
max_hold=12,
default_hold=6,
previous=previous,
inaction=INACTION,
)
def test_a_fenced_block_is_read(self) -> None:
command = self.parse('```json\n{"forward": 0.5, "turn": -1, "glide": true, "hold": 4}\n```')
self.assertIsNone(command.problem)
self.assertEqual(command.hold, 4)
self.assertEqual(command.action["forward"], 0.5)
self.assertEqual(command.action["turn"], -1.0)
self.assertIs(command.action["glide"], True)
def test_the_last_block_wins(self) -> None:
"""A model that thinks out loud in JSON is answering with its last one."""
command = self.parse(
'```json\n{"forward": 0.1}\n```\nno, better:\n```json\n{"forward": 0.9}\n```'
)
self.assertEqual(command.action["forward"], 0.9)
def test_a_bare_object_is_read(self) -> None:
self.assertEqual(self.parse('{"climb": 0.3}').action["climb"], 0.3)
def test_omitted_controls_keep_flying(self) -> None:
previous = Command({"forward": 0.8, "turn": 0.2, "pitch": 0.0, "climb": 0.1, "glide": False}, 6)
command = self.parse('{"turn": -0.5}', previous)
self.assertEqual(command.action["forward"], 0.8)
self.assertEqual(command.action["turn"], -0.5)
def test_an_unreadable_reply_costs_the_default_hold(self) -> None:
"""Silence is the one thing a reply must not be. A turn that costs no
simulation steps is a free turn, and a free turn is a budget that does not
bind which is house rule 4 undone by a parser."""
previous = Command({**INACTION, "forward": 0.7}, 5)
for reply in ("", "I think we should climb.", "```json\n{not json}\n```", "[1, 2, 3]"):
with self.subTest(reply=reply):
command = self.parse(reply, previous)
self.assertIsNotNone(command.problem)
self.assertEqual(command.hold, 6)
self.assertEqual(command.action, previous.action)
def test_json_that_names_no_control_is_flagged_and_still_flies(self) -> None:
command = self.parse('{"thinking": "left a bit"}')
self.assertEqual(command.problem, "your JSON named none of the controls")
self.assertEqual(command.action, INACTION)
def test_hold_is_clipped_never_raised(self) -> None:
for raw, expected in ((0, 1), (-9, 1), (999, 12), (3.7, 3)):
with self.subTest(hold=raw):
self.assertEqual(self.parse(json.dumps({"forward": 0, "hold": raw})).hold, expected)
def test_non_finite_numbers_are_dropped_not_carried(self) -> None:
"""`json.loads` accepts bare Infinity and NaN and overflows 1e309 to inf.
Either one reaching a reward is a corrupted training signal rather than a
bad answer, and both are reachable from a reply a model can emit."""
previous = Command({**INACTION, "forward": 0.4}, 6)
command = self.parse('{"forward": NaN, "turn": Infinity, "climb": 1e309, "hold": NaN}', previous)
self.assertEqual(command.action["forward"], 0.4)
self.assertEqual(command.action["turn"], 0.0)
self.assertEqual(command.hold, 6)
for value in command.action.values():
if isinstance(value, float):
self.assertTrue(math.isfinite(value))
def test_twenty_thousand_nested_arrays_do_not_crash_the_decoder(self) -> None:
self.assertIsNotNone(self.parse("[" * 20_000).problem)
class FlightTests(unittest.TestCase):
"""One scenario, flown three ways, graded on what the simulator recorded."""
worker: TeraWorker
@classmethod
def setUpClass(cls) -> None:
cls.worker = TeraWorker()
cls.taskset = CrowNavTaskset(CrowNavConfig(id="tera-crow-nav", num_tasks=4))
cls.tasks = list(cls.taskset)
@classmethod
def tearDownClass(cls) -> None:
cls.worker.close()
def fly(self, data: SpatialData, replies) -> tuple[Flight, dict]:
"""Drive a whole episode from Python, exactly as the env does."""
episode = self.worker.open(
data.env_id, data.seed, {"split": data.split, "id": data.scenario_id}
)
flight = Flight(
episode=episode,
max_turns=data.max_turns,
budget=data.max_turns * data.max_hold,
observation=episode.observation,
)
view = TurnView(
observation=flight.observation,
turn=1,
turns_left=data.max_turns,
steps=0,
budget=flight.budget,
max_hold=data.max_hold,
last=None,
)
while not flight.done:
command = parse_command(
replies(self.taskset.board(view)),
fields=FIELDS,
max_hold=data.max_hold,
default_hold=data.default_hold,
previous=flight.last,
inaction=INACTION,
)
flight.fly(command)
view = TurnView(
observation=flight.observation,
turn=flight.turns + 1,
turns_left=data.max_turns - flight.turns,
steps=flight.steps,
budget=flight.budget,
max_hold=data.max_hold,
last=command,
)
envelope = episode.trace()
episode.close()
return flight, envelope
def test_a_reader_of_the_panel_reaches_every_waypoint(self) -> None:
"""The rendering's real claim: it is enough to fly on.
Nothing in `reader_policy` sees the observation dict it sees the same
characters the model is shown and parses them back out. Four scenarios,
four waypoints, inside the shipped budget.
"""
for task in self.tasks:
with self.subTest(task=task.data.name):
flight, envelope = self.fly(task.data, reader_policy)
self.assertEqual(flight.terminal_reason, "goal")
self.assertLessEqual(flight.turns, task.data.max_turns)
rewards, metrics, info = score(task.data, flight, envelope, self.worker)
self.assertEqual(metrics["gate"], 1.0)
self.assertEqual(metrics["replay_ok"], 1.0)
self.assertGreater(rewards["flight"][0], 0.0)
self.assertEqual(info["terminal_reason"], "goal")
def test_minimum_power_scores_exactly_zero(self) -> None:
"""House rule 3's floor. A crow that will not fly does not fly, and the
reward for not flying is 0.000 and not 0.001 the clip is against a
negative return, so there is nothing to round."""
data = self.tasks[0].data
flight, envelope = self.fly(data, lambda _board: json.dumps({**MINIMUM_POWER, "hold": 12}))
rewards, metrics, _ = score(data, flight, envelope, self.worker)
self.assertNotEqual(flight.terminal_reason, "goal")
self.assertLess(metrics["ts_return"], 0.0)
self.assertEqual(rewards["flight"][0], 0.0)
self.assertEqual(rewards["economy"][0], 0.0)
def test_a_silent_model_still_spends_its_budget(self) -> None:
"""Sixteen unreadable replies cost sixteen turns and ninety-six steps."""
data = self.tasks[0].data
flight, _ = self.fly(data, lambda _board: "I would rather not.")
self.assertEqual(flight.turns, data.max_turns)
self.assertEqual(flight.malformed, data.max_turns)
self.assertEqual(flight.steps, data.max_turns * data.default_hold)
def test_the_ceiling_is_reachable_inside_the_budget(self) -> None:
"""House rule 3's ceiling, taken from Tera and not from Python.
The scripted controller the same function the denominator is measured
with flown at a hold this taskset permits, cut off at the turns it
grants, must still clear the band. If it cannot, the budget is not tight,
it is impossible.
"""
data = self.tasks[0].data
best = self.worker.oracle(
data.env_id,
data.seed,
{"split": data.split, "id": data.scenario_id},
max_steps=data.max_turns * 5,
hold=5,
)
self.assertEqual(best["terminalReason"], "goal")
self.assertGreaterEqual(
best["cumulativeReward"] / (data.band * data.oracle_return), 1.0
)
def test_the_turn_budget_binds(self) -> None:
"""House rule 4, in the shipped engine rather than in a spreadsheet.
The same policy at the same refresh rate, allowed one turn and allowed
sixteen. If the two agree, the budget buys nothing and crow-nav has no
rule-4 claim to make.
"""
data = self.tasks[0].data
request = {"split": data.split, "id": data.scenario_id}
one = self.worker.oracle(data.env_id, data.seed, request, max_steps=12, hold=12)
many = self.worker.oracle(data.env_id, data.seed, request, max_steps=16 * 5, hold=5)
self.assertNotEqual(one["terminalReason"], "goal")
self.assertEqual(many["terminalReason"], "goal")
self.assertGreater(many["cumulativeReward"] - one["cumulativeReward"], 4.0)
def test_a_tampered_trace_fails_the_episode_replay(self) -> None:
"""`replay_ok` is not decoration: it is the bridge's claim, asserted once
per episode. An envelope whose reward was edited and re-sealed with a
checksum Tera itself computed still has to fail."""
data = self.tasks[0].data
flight, envelope = self.fly(data, reader_policy)
forged = json.loads(json.dumps(envelope))
forged["steps"][0]["reward"] += 1.0
forged["cumulativeReward"] += 1.0
forged["checksum"] = self.worker.checksum(
{k: v for k, v in forged.items() if k != "checksum"}
)
_, metrics, _ = score(data, flight, forged, self.worker)
self.assertEqual(metrics["replay_ok"], 0.0)
if __name__ == "__main__":
unittest.main()
@@ -1,269 +0,0 @@
"""The bridge's correctness gate.
The whole claim this package makes is: *a trace produced by driving Tera from
Python replays, inside Tera, to a bit-identical FNV-1a-64 checksum and a trace
that has been edited by so much as one float does not.* If that holds, a reward
that reaches Python is a reward that TypeScript actually produced, and Arena can
grade a spatial rollout without a second implementation of the simulator to
disagree with the first.
So this file is not a smoke test. It exercises **every** public scenario at two
seeds 4 environments x 4 scenarios x 2 seeds = 32 episodes and for each one:
1. drives the episode a step at a time from Python, using actions that made the
round trip through `json`, and checks every per-step checksum against the
trace Tera produced under its own baseline;
2. replays the Python-driven trace in a *fresh* environment;
3. replays it again after a `json.dumps`/`json.loads` cycle, because the wire
format is the thing under test and a float that does not survive Python's
repr is a silent divergence;
4. tampers with it seven ways and requires every one to be rejected.
Run:
uv run python -m unittest discover -s environments/tera_spatial/tests -v
"""
from __future__ import annotations
import copy
import json
import time
import unittest
from tera_spatial import ENVIRONMENT_IDS, TeraError, TeraWorker
from tera_spatial import hashes
from tera_spatial.closure import bare_specifiers
SEEDS = (115, 2718)
def scenario_seed_matrix(worker: TeraWorker) -> list[tuple[str, str, str, int]]:
"""(env id, scenario id, split, seed) for every public scenario at every seed."""
matrix: list[tuple[str, str, str, int]] = []
for manifest in worker.manifests():
for split in ("train", "dev"):
for scenario_id in manifest["scenarioIds"][split]:
for seed in SEEDS:
matrix.append((manifest["id"], scenario_id, split, seed))
return matrix
class VendoredSourceTests(unittest.TestCase):
"""The checksums in a trace are only worth something if the code is the pinned code."""
def test_vendored_tree_matches_recorded_hashes(self) -> None:
self.assertEqual(hashes.verify(), [])
def test_closure_is_the_expected_shape(self) -> None:
# 23 files and 283 KB is the measured closure of `src/arena/index.ts`. A
# change here is not necessarily wrong, but it is never incidental.
self.assertEqual(len(hashes.VENDORED_FILES), 23)
self.assertEqual(hashes.CLOSURE_BYTES, 282_684)
def test_closure_has_no_bare_specifiers(self) -> None:
"""No `node_modules`: the wheel must run the simulator with nothing installed."""
self.assertEqual(bare_specifiers(), [])
class ReplayGateTests(unittest.TestCase):
"""Python drives, TypeScript simulates, and the two agree to the last bit."""
worker: TeraWorker
@classmethod
def setUpClass(cls) -> None:
started = time.perf_counter()
cls.worker = TeraWorker()
cls.startup_seconds = time.perf_counter() - started
@classmethod
def tearDownClass(cls) -> None:
cls.worker.close()
def test_worker_exposes_the_four_environments(self) -> None:
ids = [manifest["id"] for manifest in self.worker.manifests()]
self.assertEqual(sorted(ids), sorted(ENVIRONMENT_IDS))
def test_every_scenario_and_seed_round_trips_and_replays(self) -> None:
matrix = scenario_seed_matrix(self.worker)
self.assertEqual(len(matrix), 32, "4 environments x 4 scenarios x 2 seeds")
for env_id, scenario_id, split, seed in matrix:
with self.subTest(env=env_id, scenario=scenario_id, seed=seed):
self._assert_round_trip(env_id, scenario_id, split, seed)
def _assert_round_trip(self, env_id: str, scenario_id: str, split: str, seed: int) -> None:
# The reference episode runs entirely inside TypeScript, under the
# baseline `src/arena/index.ts` exports. Its trace is the ground truth.
reference = self.worker.oracle(env_id, seed, scenario_id, policy="scripted")
oracle_trace = reference["trace"]
self.assertGreater(len(oracle_trace["steps"]), 0)
# Now drive the same episode from Python, one step at a time, with every
# action having crossed the pipe as JSON. This is the direction that
# matters: it is how a rollout will actually run.
with self.worker.open(env_id, seed, scenario_id) as episode:
self.assertEqual(
episode.info["stateChecksum"], oracle_trace["initialStateChecksum"]
)
for frame in oracle_trace["steps"]:
result = episode.step(json.loads(json.dumps(frame["action"])))
self.assertEqual(result["info"]["stateChecksum"], frame["stateChecksum"])
self.assertEqual(result["reward"], frame["reward"])
self.assertEqual(result["terminated"], frame["terminated"])
self.assertEqual(result["truncated"], frame["truncated"])
driven = episode.trace()
# Python drove it; the envelope checksum says TypeScript agrees it is the
# same episode.
self.assertEqual(driven["checksum"], oracle_trace["checksum"])
self.assertEqual(driven["finalStateChecksum"], oracle_trace["finalStateChecksum"])
self.assertEqual(driven["cumulativeReward"], oracle_trace["cumulativeReward"])
# And the wire format survives Python's float repr. `json` is the only
# thing between the two runtimes, so it is part of the claim.
wired = json.loads(json.dumps(driven))
self.assertEqual(wired["checksum"], oracle_trace["checksum"])
replayed = self.worker.replay(env_id, wired)
self.assertEqual(replayed["finalStateChecksum"], oracle_trace["finalStateChecksum"])
self.assertEqual(replayed["cumulativeReward"], oracle_trace["cumulativeReward"])
self.assertEqual(replayed["steps"], len(oracle_trace["steps"]))
def test_snapshot_restore_continues_an_episode_exactly(self) -> None:
for env_id in ENVIRONMENT_IDS:
with self.subTest(env=env_id):
reference = self.worker.oracle(env_id, 115, {"split": "train"})
frames = reference["trace"]["steps"]
cut = max(1, len(frames) // 2)
with self.worker.open(env_id, 115, {"split": "train"}) as episode:
for frame in frames[:cut]:
episode.step(frame["action"])
snapshot = json.loads(json.dumps(episode.snapshot()))
with self.worker.open(env_id, 115, {"split": "train"}) as resumed:
restored = resumed.restore(snapshot)
self.assertEqual(
restored["info"]["stateChecksum"], frames[cut - 1]["stateChecksum"]
)
for frame in frames[cut:]:
result = resumed.step(frame["action"])
self.assertEqual(
result["info"]["stateChecksum"], frame["stateChecksum"]
)
def test_a_broken_seal_is_rejected(self) -> None:
"""Seven edits, none of them re-sealed. The envelope checksum catches all seven."""
for env_id in ENVIRONMENT_IDS:
clean = self.worker.oracle(env_id, 115, {"split": "train"})["trace"]
self.worker.replay(env_id, copy.deepcopy(clean)) # the control: it replays
for name, tamper in _TAMPERS.items():
with self.subTest(env=env_id, tamper=name):
forged = copy.deepcopy(clean)
tamper(forged)
with self.assertRaises(TeraError) as caught:
self.worker.replay(env_id, forged)
self.assertIn("arena", str(caught.exception))
def test_a_resealed_forgery_is_rejected(self) -> None:
"""The edit that a checksum alone cannot catch.
Every tamper above breaks the envelope seal, so on its own the previous
test only proves the seal works. The interesting adversary edits the
trace *and recomputes the checksum* a rollout reporting a reward it
did not earn would look exactly like this. Catching it is `replay()`
re-running the simulator and comparing, which is the actual claim.
"""
for env_id in ENVIRONMENT_IDS:
clean = self.worker.oracle(env_id, 115, {"split": "train"})["trace"]
for name, tamper in _TAMPERS.items():
if name == "envelope-checksum":
continue # re-sealing it is just the clean trace again
with self.subTest(env=env_id, tamper=name):
forged = self._reseal(copy.deepcopy(clean), tamper)
# The seal is now valid — prove it, or the test is trivially
# passing for the wrong reason.
core = {k: v for k, v in forged.items() if k != "checksum"}
self.assertEqual(forged["checksum"], self.worker.checksum(core))
with self.assertRaises(TeraError) as caught:
self.worker.replay(env_id, forged)
self.assertIn("arena", str(caught.exception))
def _reseal(self, trace: dict, tamper) -> dict:
tamper(trace)
core = {key: value for key, value in trace.items() if key != "checksum"}
trace["checksum"] = self.worker.checksum(core)
return trace
def test_stepping_a_finished_episode_is_an_error(self) -> None:
reference = self.worker.oracle("crow-nav-v1", 115, {"split": "train"})
with self.worker.open("crow-nav-v1", 115, {"split": "train"}) as episode:
for frame in reference["trace"]["steps"]:
episode.step(frame["action"])
with self.assertRaises(TeraError):
episode.step(reference["trace"]["steps"][-1]["action"])
def test_an_unknown_scenario_is_an_error(self) -> None:
with self.assertRaises(TeraError):
self.worker.open("crow-nav-v1", 115, "dev-there-is-no-such-place")
def test_baselines_come_from_typescript_and_separate(self) -> None:
"""The oracle op exists so the reward denominator is never a Python port.
Inaction below zero, scripted above it, is Tera's own documented claim;
checking it here is checking that the bridge is calling the real
baselines and not, say, handing both policies the same action.
"""
for env_id in ENVIRONMENT_IDS:
with self.subTest(env=env_id):
scripted = self.worker.oracle(env_id, 115, {"split": "train"}, "scripted")
inaction = self.worker.oracle(env_id, 115, {"split": "train"}, "inaction")
self.assertGreater(scripted["cumulativeReward"], inaction["cumulativeReward"])
self.assertLess(inaction["cumulativeReward"], 0.0)
self.assertGreater(scripted["cumulativeReward"], 0.0)
def _bump_reward(trace: dict) -> None:
trace["steps"][0]["reward"] += 1.0
def _bump_cumulative(trace: dict) -> None:
trace["cumulativeReward"] += 1.0
def _swap_action(trace: dict) -> None:
action = trace["steps"][0]["action"]
key = sorted(k for k, v in action.items() if isinstance(v, (int, float)))[0]
action[key] = float(action[key]) + 0.5
def _forge_state_checksum(trace: dict) -> None:
trace["steps"][0]["stateChecksum"] = "fnv1a64:0000000000000000"
def _forge_envelope_checksum(trace: dict) -> None:
trace["checksum"] = "fnv1a64:0000000000000000"
def _drop_last_step(trace: dict) -> None:
# Truncation alone keeps every surviving frame honest, so only the final
# state and the cumulative reward can catch it.
trace["steps"] = trace["steps"][:-1]
def _forge_source_hashes(trace: dict) -> None:
trace["sourceHashes"]["simulator"] = "sha256:" + "0" * 64
_TAMPERS = {
"reward": _bump_reward,
"cumulative-reward": _bump_cumulative,
"action": _swap_action,
"state-checksum": _forge_state_checksum,
"envelope-checksum": _forge_envelope_checksum,
"dropped-step": _drop_last_step,
"source-hashes": _forge_source_hashes,
}
if __name__ == "__main__":
unittest.main()
+8 -1
View File
@@ -92,8 +92,15 @@ class RootProbeIntegrationTests(unittest.TestCase):
self.assertEqual(agent["harness"]["id"], "null")
self.assertEqual(agent["runtime"]["type"], "subprocess")
tracked = subprocess.run(
["git", "ls-files", "environments"],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
).stdout.splitlines()
self.assertEqual(
list((ROOT / "environments").glob("**/harness.py")),
[path for path in tracked if path.endswith("/harness.py")],
[],
msg="Arena environments must use Verifiers harnesses, not ship a parallel one",
)