Move maintained Tera evaluations to private source
This commit is contained in:
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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 2–40",
|
||||
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() };
|
||||
}
|
||||
-595
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
-315
@@ -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));
|
||||
}
|
||||
-687
@@ -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 north–south 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 },
|
||||
];
|
||||
-380
@@ -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[];
|
||||
}
|
||||
-542
@@ -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 };
|
||||
}
|
||||
-287
@@ -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()
|
||||
Reference in New Issue
Block a user