diff --git a/README.md b/README.md index ceee73a..2625112 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ probe is what caught it — see the table below, and the git history. | [`grand-exchange`](environments/grand_exchange) | Read price and volume history for a basket of items and place limit orders, executed against the next thirty ticks. | One or two items per basket are a random walk, not a mean-reverting one — the widest-swinging lines on the board and the ones with no anchor to revert to — and you are not told how many there are, so counting is not a substitute for the shape statistic. And liquidity in gp per tick is uncorrelated with price, so the fattest visible margins sit where the purse cannot go. | | [`grand-exchange-live`](environments/grand_exchange_live) | The same basket, traded while the window is running: eight looks over sixty ticks, with stock, offers standing between looks, and a purse that sale proceeds come back to. | Looking is not free. Re-quoting an item that already carries an offer freezes the **whole** book for one to four ticks, an amended offer restarts at the back of the fill queue, and the per-item buy limit is cumulative over the window rather than per offer — so the policy that re-quotes on every look scores 0.631 against a reference that looks four times and scores 1.000. | +Tera world-simulation environments are maintained in the private Tera +repository. Arena keeps the generic, Apache-2.0 environment work above; it does +not ship current proprietary Tera source. See [the source boundary](docs/TERA_BOUNDARY.md). + ## What the probes measure Reward for the degenerate strategies and for an oracle, from `uv run python probe.py`: diff --git a/configs/tera_crow_nav.toml b/configs/tera_crow_nav.toml deleted file mode 100644 index c8ae856..0000000 --- a/configs/tera_crow_nav.toml +++ /dev/null @@ -1,34 +0,0 @@ -# tera-crow-nav — fly a crow to a waypoint on sixteen replies. -# See environments/tera_spatial for what it measures and where these two integers -# came from (`measure_hold.py`, not judgement). -# -# uv run --project environments/tera_spatial eval @ configs/tera_crow_nav.toml \ -# --model brain-qwen38-dspark \ -# --client.base-url http://100.127.247.67:8001/v1 \ -# --client.api-key-var SPARK_API_KEY --no-push --no-rich -c 8 -# -# `node` >= 22.18 must be on PATH: the environment is TypeScript and is executed, -# not translated. -num_tasks = 16 -num_rollouts = 2 - -[env.taskset] -id = "tera-crow-nav" -num_tasks = 16 -max_turns = 16 # replies. Sixteen is where the ceiling first becomes reachable. -max_hold = 12 # steps one reply is held for. 16 x 12 = 192 against a 300-step cap, - # so what binds is the turn budget and never the simulator's own. -default_hold = 6 # what an unreadable reply costs. Never zero. -seed_base = 115 - -[env.agent.harness] -id = "null" # one completion per turn, no tools; the env programs the loop - -[env.agent.runtime] -# ⚠️ REQUIRED since verifiers 0.3.1, which changed the default agent runtime from -# `subprocess` to `prime` — a REMOTE sandbox. Without this the run exits -# `SystemExit: not authenticated with prime` before a single rollout, having already created -# its output directory and a zero-line traces.jsonl, so it reads as a failed eval rather than -# an unauthenticated one. Nothing here needs a sandbox: the reward is a pure function of the -# trace and the harness is `null`. -type = "subprocess" diff --git a/docs/PRIME_FIRST.md b/docs/PRIME_FIRST.md index 4b7a7af..c20762d 100644 --- a/docs/PRIME_FIRST.md +++ b/docs/PRIME_FIRST.md @@ -12,7 +12,9 @@ harness, trainer, runtime marketplace, or environment hub. explicit training handoff and artifact rendering. Lumbridge's unique work is the environment: task distributions, interactive -state, rewards, probes, held-out gates, Tera bridges, and result interpretation. +state, rewards, probes, held-out gates, and result interpretation. Tera bridges +are developed with Tera because they package proprietary simulator source; they +use the same upstream Verifiers contract without living in this public repo. Generic rollout and training machinery stays upstream. A Hub publication must exclude secrets, private task splits, customer data, and proprietary Tera source. diff --git a/docs/TERA_BOUNDARY.md b/docs/TERA_BOUNDARY.md new file mode 100644 index 0000000..63f1e76 --- /dev/null +++ b/docs/TERA_BOUNDARY.md @@ -0,0 +1,21 @@ +# Tera source boundary + +Arena is public Apache-2.0 environment work. Tera is Lumbridge's proprietary +world simulator and is developed in the private `lumbridge-private/tera` +repository. + +The `tera_spatial` bridge and `tera-crow-nav` taskset were previously committed +to Arena. Those historical revisions remain available under the license granted +when they were published; deleting current files does not revoke that grant or +rewrite git history. As of 2026-08-25, maintained bridge code, vendored simulator +closures, Tera tasksets, and new Tera releases live only with private Tera. + +The boundary is about source ownership, not rebuilding upstream machinery: + +- Arena and private Tera both depend on Prime Intellect Verifiers for the + environment/harness contract. +- Forge delegates training to Prime-RL across a subprocess boundary. +- No Lumbridge repository should implement its own generic rollout harness, + trainer, environment hub, or GPU marketplace. +- Publishing an Arena environment to Prime Hub is a separate owner-approved + action. Nothing in this repository publishes automatically. diff --git a/environments/tera_spatial/README.md b/environments/tera_spatial/README.md deleted file mode 100644 index c680827..0000000 --- a/environments/tera_spatial/README.md +++ /dev/null @@ -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-/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. diff --git a/environments/tera_spatial/measure_hold.py b/environments/tera_spatial/measure_hold.py deleted file mode 100644 index 200d048..0000000 --- a/environments/tera_spatial/measure_hold.py +++ /dev/null @@ -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()) diff --git a/environments/tera_spatial/pyproject.toml b/environments/tera_spatial/pyproject.toml deleted file mode 100644 index ff0ef2f..0000000 --- a/environments/tera_spatial/pyproject.toml +++ /dev/null @@ -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"] diff --git a/environments/tera_spatial/scripts/sync_tera.py b/environments/tera_spatial/scripts/sync_tera.py deleted file mode 100644 index d49d852..0000000 --- a/environments/tera_spatial/scripts/sync_tera.py +++ /dev/null @@ -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/` 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()) diff --git a/environments/tera_spatial/tera_crow_nav/__init__.py b/environments/tera_spatial/tera_crow_nav/__init__.py deleted file mode 100644 index 3caa522..0000000 --- a/environments/tera_spatial/tera_crow_nav/__init__.py +++ /dev/null @@ -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", -] diff --git a/environments/tera_spatial/tera_crow_nav/taskset.py b/environments/tera_spatial/tera_crow_nav/taskset.py deleted file mode 100644 index ff051d4..0000000 --- a/environments/tera_spatial/tera_crow_nav/taskset.py +++ /dev/null @@ -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"] diff --git a/environments/tera_spatial/tera_spatial/__init__.py b/environments/tera_spatial/tera_spatial/__init__.py deleted file mode 100644 index 3608f52..0000000 --- a/environments/tera_spatial/tera_spatial/__init__.py +++ /dev/null @@ -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"] diff --git a/environments/tera_spatial/tera_spatial/bridge.py b/environments/tera_spatial/tera_spatial/bridge.py deleted file mode 100644 index 3a33626..0000000 --- a/environments/tera_spatial/tera_spatial/bridge.py +++ /dev/null @@ -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() diff --git a/environments/tera_spatial/tera_spatial/closure.py b/environments/tera_spatial/tera_spatial/closure.py deleted file mode 100644 index 865aeb1..0000000 --- a/environments/tera_spatial/tera_spatial/closure.py +++ /dev/null @@ -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] diff --git a/environments/tera_spatial/tera_spatial/hashes.py b/environments/tera_spatial/tera_spatial/hashes.py deleted file mode 100644 index 8b46d4d..0000000 --- a/environments/tera_spatial/tera_spatial/hashes.py +++ /dev/null @@ -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 diff --git a/environments/tera_spatial/tera_spatial/spatial.py b/environments/tera_spatial/tera_spatial/spatial.py deleted file mode 100644 index 1e2ca0f..0000000 --- a/environments/tera_spatial/tera_spatial/spatial.py +++ /dev/null @@ -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", -] diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/actors/controller.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/actors/controller.ts deleted file mode 100644 index 4a62577..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/actors/controller.ts +++ /dev/null @@ -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 = 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; - 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; - /** 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; - 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 { - identity: ActorIdentity; -} - -export interface TimedActorInputFrame { - steps: number; - actions?: Partial; -} - -export interface ActorReplayResult { - trajectory: readonly ActorControllerSnapshot[]; - final: ActorControllerSnapshot; -} - -interface ResolvedOptions { - kind: ActorKind; - identity: Readonly; - 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 { - 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 { - 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 | 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 | 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 { - 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 = 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 = 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() }; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/aircraft/controller.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/aircraft/controller.ts deleted file mode 100644 index 1c2a158..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/aircraft/controller.ts +++ /dev/null @@ -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 = 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 = - Object.freeze({ - minLat: 32.4, - maxLat: 42.1, - minLng: -124.6, - maxLng: -114.0, - minAltitudeM: 75, - maxAltitudeM: 6_000, - }); - -export interface AircraftControllerOptions { - initialPosition?: Partial; - 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; -} - -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(); - 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 | 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 { - 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 = 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 = 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, - 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, - }; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/base.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/base.ts deleted file mode 100644 index 5645665..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/base.ts +++ /dev/null @@ -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> { - 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, - S, - P extends object, -> implements ArenaEnvironment { - abstract readonly manifest: ArenaManifest; - protected abstract readonly registry: ArenaScenarioRegistry

; - protected abstract readonly sourceHashes: ArenaSourceHashes; - - private scenario: ArenaScenario

| null = null; - private stepIndex = 0; - private cumulativeReward = 0; - private terminated = false; - private truncated = false; - private terminalReason: string | null = null; - private initialStateChecksum = ""; - private frames: ArenaTraceStep[] = []; - - protected abstract resetSimulation(scenario: ArenaScenario

): O; - protected abstract normalizeAction(action: A): A; - protected abstract advanceSimulation(action: A): SimulationTransition; - protected abstract simulationSnapshot(): S; - protected abstract restoreSimulation(snapshot: S): O; - - reset(seed: number, request: string | ArenaScenarioRequest): ArenaResetResult { - 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 { - 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 { - 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): ArenaResetResult { - 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 { - 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): ArenaReplayResult { - 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

{ - return this.requireReset(); - } - - protected currentStep(): number { - return this.stepIndex; - } - - private requireReset(): ArenaScenario

{ - 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, - }; - } -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/californiaFlight.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/californiaFlight.ts deleted file mode 100644 index 49ac17f..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/californiaFlight.ts +++ /dev/null @@ -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( - "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 = 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): 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 { - 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; - } -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/checksum.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/checksum.ts deleted file mode 100644 index f9c03b9..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/checksum.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Canonical JSON and a small cross-runtime checksum (no Node or Web APIs). */ - -function canonical(value: unknown, stack: Set): 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; - 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")}`; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/crowNav.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/crowNav.ts deleted file mode 100644 index 1d31271..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/crowNav.ts +++ /dev/null @@ -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( - "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 = 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): 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 { - 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; - } -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/drive101.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/drive101.ts deleted file mode 100644 index 6a3e030..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/drive101.ts +++ /dev/null @@ -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( - "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 = 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): 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 { - 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; - } -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/index.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/index.ts deleted file mode 100644 index d350b54..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/index.ts +++ /dev/null @@ -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, -]); diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/officeNav.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/officeNav.ts deleted file mode 100644 index 608bf01..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/officeNav.ts +++ /dev/null @@ -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( - "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 = 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): 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 { - 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; - } -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/random.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/random.ts deleted file mode 100644 index e7b8b54..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/random.ts +++ /dev/null @@ -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; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/scenarios.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/scenarios.ts deleted file mode 100644 index 07f9be7..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/scenarios.ts +++ /dev/null @@ -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

{ - id: string; - split: ArenaSplit; - parameters: P; -} - -export type ScenarioSampler

= (parameters: Readonly

, random: ArenaRandom) => P; - -/** Immutable public scenario catalogue with seeded, reproducible materialization. */ -export class ArenaScenarioRegistry

{ - readonly envId: string; - readonly definitions: readonly ArenaScenarioDefinition

[]; - private readonly byId = new Map>(); - private readonly sampler: ScenarioSampler

; - - constructor( - envId: string, - definitions: readonly ArenaScenarioDefinition

[], - sampler: ScenarioSampler

, - ) { - 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

{ - const seed = normalizeArenaSeed(seedValue); - let definition: ArenaScenarioDefinition

| 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 }; - } -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/sourceHashes.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/sourceHashes.ts deleted file mode 100644 index 2a6fbd0..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/sourceHashes.ts +++ /dev/null @@ -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> = 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", - }, -}); diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/types.ts deleted file mode 100644 index 3f8ed76..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/arena/types.ts +++ /dev/null @@ -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

> { - 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 { - observation: O; - info: ArenaInfo; -} - -export interface ArenaStepResult> { - 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>; - safetyTerminals: readonly string[]; - scenarioIds: Readonly>; - baselines: { - inaction: string; - scripted: string; - }; -} - -export interface ArenaSnapshot { - 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> { - index: number; - action: A; - reward: number; - rewardComponents: R; - terminated: boolean; - truncated: boolean; - terminalReason: string | null; - stateChecksum: string; -} - -export interface ArenaTraceEnvelope> { - 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[]; - finalStateChecksum: string; - cumulativeReward: number; - checksum: string; -} - -export interface ArenaReplayResult { - observation: O; - steps: number; - cumulativeReward: number; - finalStateChecksum: string; -} - -export interface ArenaEnvironment< - A, - O, - R extends Record, - S, -> { - readonly manifest: ArenaManifest; - reset(seed: number, scenario: string | ArenaScenarioRequest): ArenaResetResult; - step(action: A): ArenaStepResult; - snapshot(): ArenaSnapshot; - restore(snapshot: ArenaSnapshot): ArenaResetResult; - trace(): ArenaTraceEnvelope; - replay(trace: ArenaTraceEnvelope): ArenaReplayResult; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/engine/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/engine/types.ts deleted file mode 100644 index 51f806c..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/engine/types.ts +++ /dev/null @@ -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; -} - -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; - -// ---- 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[]; - /** 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"; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/plan.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/plan.ts deleted file mode 100644 index 16d814c..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/plan.ts +++ /dev/null @@ -1,1310 +0,0 @@ -/** - * An `Office` resolved into something the renderer and the walk controller can - * ask questions of: wall runs, collision segments, seats, props, zones and - * bounds. - * - * This is `World`'s opposite number, and it keeps the same discipline: build - * everything once in the constructor, then answer questions. An office pack is - * static data — nothing in it changes between frames — so anything that can be - * computed at load time should be, and every consumer should be reading the same - * answer rather than deriving its own. - * - * Like `World`, and unlike everything downstream of it, **`Plan` imports no - * three.js**. Its output is plain numbers and plain records. That keeps the - * splitting pass testable without a WebGL context, and it keeps the office - * contract (`types.ts`) and its resolution (this file) on the same side of the - * line from the mesh library that consumes them. - * - * ### The wall pass is the point of this file - * - * CONTRACT.md §2 chose an explicit wall list with 1-D openings over walls - * inferred from room edges, and the reason is here: splitting a wall around its - * openings has to happen anyway to draw the solid parts, and the *same* - * decomposition says where a walker can and cannot go. Two products, one pass, - * no second list to keep in sync. A lintel over a door is drawn and is not - * collided with; the apron under a window is drawn and *is* collided with; and - * neither of those facts is stated twice. - * - * ### Everything here is in office-world metres - * - * The authored contract measures heights "above this level's floor", because - * that is how a person describes a wall. A `LevelPlan` has already added - * `Level.elevation` to every `y`, `bottom`, `top`, `sill` and `head` it exposes. - * Mixing the two conventions in one build product is how a mezzanine ends up in - * the basement, so the conversion happens exactly once, here, and what comes out - * can be dropped into a single scene at the origin. - * - * ### It drops rather than throws - * - * A self-hoster authoring their first office will write a polygon that crosses - * itself, a door that runs off the end of its wall, and two things with the same - * id. Every one of those is reported through `problems` — and, in dev, through - * `console.warn` — and then the offending item is dropped or repaired. An - * exception with no context in the middle of a 180-prop pack tells the author - * nothing and loses the other 179. - * - * ### Depth: a public build does not build the private half - * - * `PlanOptions.depth` is the other reason something can be absent from the build - * product, and it is the one that is not an error. At `"public"` every item the - * pack marked `audience: "private"` is skipped here, in the resolution pass, - * before it is a placement and long before it is a mesh. That ordering is the - * whole point: a private prop that is built and then hidden is still in - * `scene.traverse`, in the devtools graph and in a `JSON.stringify` of this - * object, which is a data leak with a checkbox in front of it. Read the note on - * `Audience` in `types.ts` — including the paragraph saying this is a UI tier - * and not a security boundary, because a pack is bundled into the static build - * and is public whatever it is marked. - */ - -import type { - AssetId, - Audience, - DeskBank, - Level, - Office, - Opening, - OpeningKind, - Outline, - Point2, - Prop, - Room, - Seat, - SeatPose, - SurfaceId, - Viewpoint, - Wall, - Yaw, -} from "./types.ts"; - -/** Documented in `types.ts` as the fallback when a level names no thickness. */ -const DEFAULT_WALL_THICKNESS = 0.12; - -/** - * Desk centre to seat centre when a `DeskBank` does not say. A 0.75 m desk plus - * a chair pulled most of the way under it; far enough that the chair reads as a - * separate object, close enough that nobody looks like they are reaching. - */ -const DEFAULT_SEAT_OFFSET = 0.6; - -/** - * How much of a hole a walker needs before it counts as a way through, in metres - * above the floor. A door clears it, a window does not, and a serving hatch does - * not — which is the answer you want in all three cases without the format - * having to name any of them. - */ -const DEFAULT_WALK_HEIGHT = 1.1; - -/** Below this, two floats are the same number and a length is zero. */ -const EPS = 1e-6; - -// ---- Build products ------------------------------------------------------- - -/** An axis-aligned extent on the floor plane, in metres. */ -export interface Bounds { - minX: number; - maxX: number; - minZ: number; - maxZ: number; - width: number; - depth: number; - center: Point2; -} - -/** - * One solid piece of wall: a box `length` long, `thickness` deep and - * `top - bottom` tall, standing on the floor plane at `center` and turned by - * `yaw`. - * - * These map one-to-one onto `parts.wallRun(length, top - bottom, thickness)` - * placed at `{ x: center.x, y: bottom, z: center.z, yaw }` — that part runs - * along local +X with its base at y = 0, which is why the run reports a centre - * and a base rather than two endpoints. - */ -export interface WallRun { - wallId: string; - center: Point2; - length: number; - thickness: number; - /** Office-world metres, level elevation included. */ - bottom: number; - top: number; - yaw: Yaw; - surface: SurfaceId | undefined; - /** Distance along the wall from its `from` end, for anyone who wants it back in 1-D. */ - start: number; - end: number; - /** - * What this run is. A `solid` run spans the wall's full height; a `lintel` - * sits over an opening and an `apron` under one, and both name the opening so - * a reveal or a frame can be drawn against the same numbers. - */ - role: "solid" | "lintel" | "apron"; - openingId?: string; -} - -/** - * A hole in a wall, resolved out of its 1-D interval into a rectangle standing - * in the world. - * - * The opening keeps its own record because the leaf, frame or glazing is drawn - * by the opening rather than by the runs around it — see the note on `Opening` - * in `types.ts` about why there is no `tera:shell.door`. - */ -export interface ResolvedOpening { - /** `"${wallId}#0"`. Openings are not authored with ids; a mesh needs one. */ - id: string; - wallId: string; - kind: OpeningKind; - /** Centre of the hole on the floor plane. */ - center: Point2; - width: number; - /** Office-world metres, level elevation included. */ - sill: number; - head: number; - thickness: number; - yaw: Yaw; - start: number; - end: number; - /** True when a walker can pass through this hole. See `DEFAULT_WALK_HEIGHT`. */ - passable: boolean; -} - -/** - * A stretch of wall a walker cannot cross, as a centreline segment. - * - * Adjacent blocking stretches are merged, so a wall of five windows is one - * segment rather than eleven. The consumer inflates by `thickness / 2` plus - * whatever radius it gives the walker; `Plan.blocked()` does exactly that and is - * the reason this is a segment and not a box. - */ -export interface Segment { - wallId: string; - from: Point2; - to: Point2; - thickness: number; -} - -/** - * One prop, placed. The build input the mesh layer walks. - * - * This is CONTRACT.md §2's `Placement[]`, renamed. `src/assets/parts.ts` already - * exports a `Placement` meaning a part inside one asset's mesh, and exporting - * that name twice meaning two different things is the exact failure the contract - * exists to stop. This one is an asset instance in an office; that one is a box - * inside an asset. - */ -export interface PropPlacement { - id: string; - kind: AssetId; - levelId: string; - /** Office-world metres. `y` is the base of the prop, level elevation included. */ - position: { x: number; y: number; z: number }; - rotation: Yaw; - /** Always three numbers here, whatever the pack wrote. */ - scale: [number, number, number]; - colorKey: string | undefined; - /** The seat this prop belongs to, if it was bound to one. Purely an address. */ - seat: string | undefined; - /** Set when this prop came out of a `DeskBank` rather than being authored. */ - source: { bankId: string; station: number; part: "desk" | "chair" } | undefined; -} - -/** A seat, resolved onto a level. Still an address, now with a floor under it. */ -export interface ResolvedSeat { - id: string; - levelId: string; - position: Point2; - /** Office-world metres: the floor the seat stands on. */ - y: number; - facing: Yaw; - pose: SeatPose; - source: { bankId: string; station: number } | undefined; -} - -/** A room's floor slab, cleaned, re-wound and measured. */ -export interface ResolvedRoom { - id: string; - name: string; - levelId: string; - /** Counter-clockwise in plan view, no repeated closing point. */ - outline: Outline; - floor: SurfaceId; - /** `null` is an atrium: the pack said so explicitly. */ - ceiling: { height: number; surface: SurfaceId | undefined } | null; - /** Office-world metres: the top of the floor slab. */ - y: number; - bounds: Bounds; - /** Square metres. Always positive. */ - area: number; - centroid: Point2; -} - -/** A zone, cleaned and measured. Still means nothing to the engine. */ -export interface ResolvedZone { - id: string; - name: string; - levelId: string; - outline: Outline; - colorKey: string | undefined; - y: number; - bounds: Bounds; - area: number; - centroid: Point2; -} - -/** One storey, resolved. Everything in office-world metres. */ -export interface LevelPlan { - id: string; - name: string; - index: number; - /** Office-world metres: this storey's floor. */ - floorY: number; - wallHeight: number; - wallThickness: number; - wallSurface: SurfaceId | undefined; - rooms: readonly ResolvedRoom[]; - runs: readonly WallRun[]; - openings: readonly ResolvedOpening[]; - props: readonly PropPlacement[]; - seats: readonly ResolvedSeat[]; - zones: readonly ResolvedZone[]; - collision: readonly Segment[]; - bounds: Bounds; -} - -// ---- Problems ------------------------------------------------------------- - -/** - * One thing the pack got wrong, and what was done about it. - * - * Kept as data rather than only as a log line so that a pack can be checked in a - * test, or by a self-hoster's own tooling, without capturing `console`. - */ -export interface PlanProblem { - /** Where it was, as a path into the pack: `"levels[0].walls[3].openings[1]"`. */ - where: string; - message: string; - /** `dropped` means it is not in the build product; `repaired` means it was fixed in place. */ - action: "dropped" | "repaired"; -} - -/** How every pass reports. Threaded through rather than closed over, so the - * polygon and opening helpers can stay free functions. */ -type Report = (where: string, message: string, action: PlanProblem["action"]) => void; - -/** - * How much of a pack to resolve. - * - * The counterpart to `Audience` and deliberately not the same union: an item - * says who it is *for* (`"public"` or `"private"`), a build says how far in it - * *goes* (`"public"` or `"full"`). Spelling both with a shared two-member union - * would make `depth === audience` compile and mean nothing. - * - * It lives here rather than in `types.ts` because it is not something a pack can - * say. `types.ts` is the contract for authored data; this is an argument to the - * thing that reads it. - */ -export type Depth = "public" | "full"; - -/** Whether a build at `depth` includes an item the pack marked `audience`. */ -function included(depth: Depth, audience: Audience | undefined): boolean { - return depth === "full" || audience !== "private"; -} - -export interface PlanOptions { - /** - * `"full"` — the default — resolves the whole pack. `"public"` skips every - * item marked `audience: "private"`, which is how the office gets a - * not-signed-in version without a second pack to keep in step. See the header. - * - * One consequence worth knowing about: id collisions are detected against what - * was actually resolved, so a pack whose private half collides with its public - * half reports that problem at `"full"` and not at `"public"`. Validate a pack - * at full depth — that is the build the author is responsible for, and the - * public one is a subset of it. - */ - depth?: Depth; - /** - * How high a hole must clear for a walker to pass through it, in metres. - * Defaults to 1.1. - */ - walkHeight?: number; - /** - * Whether to `console.warn` each problem. Defaults to true under a dev build - * and false otherwise — an author wants to hear about the door that runs off - * the end of its wall, a visitor does not. `plan.problems` is populated either - * way. - */ - warn?: boolean; -} - -/** - * Vite replaces `import.meta.env.DEV` at build time; Node leaves `env` - * undefined, which is the answer we want there anyway. Read defensively so this - * module stays importable from a plain test runner. - */ -function devBuild(): boolean { - try { - return Boolean((import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV); - } catch { - return false; - } -} - -// ---- Plan ----------------------------------------------------------------- - -export class Plan { - readonly office: Office; - /** - * How much of the pack this is. Read it rather than inferring it from what is - * missing — an office with nothing marked private resolves identically at both - * depths, and that is the normal case rather than a suspicious one. - */ - readonly depth: Depth; - readonly levels: readonly LevelPlan[]; - /** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */ - readonly viewpoints: readonly Viewpoint[]; - /** Everything the validation pass dropped or repaired, in build order. */ - readonly problems: readonly PlanProblem[]; - /** The whole office, every level unioned. */ - readonly bounds: Bounds; - - private readonly walkHeight: number; - private readonly levelsById = new Map(); - private readonly seatsById = new Map(); - private readonly propsById = new Map(); - private readonly viewpointsById = new Map(); - - constructor(office: Office, options: PlanOptions = {}) { - this.office = office; - this.depth = options.depth ?? "full"; - this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT; - - const problems: PlanProblem[] = []; - const warn = options.warn ?? devBuild(); - const report: Report = (where, message, action) => { - problems.push({ where, message, action }); - if (warn) console.warn(`[office ${office.id}] ${where}: ${message} (${action})`); - }; - - // Ids are checked per kind and across the whole office, not per level. A - // seat id is what a `Presence` binds to and a prop id is what an occupancy - // layer dims, so both have to mean one thing in the building, not one thing - // per storey. - const seen = { - level: new Set(), - room: new Set(), - wall: new Set(), - prop: new Set(), - seat: new Set(), - zone: new Set(), - viewpoint: new Set(), - }; - - // `levels` and `viewpoints` are required by the type, but a pack arriving as - // JSON has been through no type checker at all, and a missing array should - // produce an empty office rather than a TypeError with a stack trace in it. - const levels: LevelPlan[] = []; - (office.levels ?? []).forEach((level, li) => { - const where = `levels[${li}]`; - if (seen.level.has(level.id)) { - report(where, `duplicate level id "${level.id}"`, "dropped"); - return; - } - seen.level.add(level.id); - const built = this.buildLevel(level, levels.length, where, seen, report); - levels.push(built); - this.levelsById.set(built.id, built); - for (const seat of built.seats) this.seatsById.set(seat.id, seat); - for (const prop of built.props) this.propsById.set(prop.id, prop); - }); - - // Prop-to-seat bindings are resolved last, because a prop on level 1 may - // legitimately name a seat declared on level 2 and the check would be a - // false positive if it ran during the level pass. - for (const level of levels) { - for (const prop of level.props) { - if (prop.seat !== undefined && !this.seatsById.has(prop.seat)) { - report( - `levels[${level.index}].props "${prop.id}"`, - `bound to unknown seat "${prop.seat}"`, - "repaired", - ); - prop.seat = undefined; - } - } - } - - const viewpoints: Viewpoint[] = []; - (office.viewpoints ?? []).forEach((viewpoint, vi) => { - const where = `viewpoints[${vi}]`; - if (seen.viewpoint.has(viewpoint.id)) { - report(where, `duplicate viewpoint id "${viewpoint.id}"`, "dropped"); - return; - } - if (!this.levelsById.has(viewpoint.levelId)) { - report(where, `on unknown level "${viewpoint.levelId}"`, "dropped"); - return; - } - // Not a problem, so not reported: a viewpoint the pack reserved for the - // signed-in building is absent from `viewpoints` at public depth, which - // means it is absent from the legend too rather than leaving a button that - // flies nowhere. - if (!included(this.depth, viewpoint.audience)) return; - seen.viewpoint.add(viewpoint.id); - viewpoints.push(viewpoint); - this.viewpointsById.set(viewpoint.id, viewpoint); - }); - - const extent = new Extent(); - for (const level of levels) extent.addBounds(level.bounds); - - this.levels = levels; - this.viewpoints = viewpoints; - this.problems = problems; - this.bounds = extent.finish(); - } - - // ---- Queries ------------------------------------------------------------ - - level(id: string): LevelPlan | null { - return this.levelsById.get(id) ?? null; - } - - seat(id: string): ResolvedSeat | null { - return this.seatsById.get(id) ?? null; - } - - prop(id: string): PropPlacement | null { - return this.propsById.get(id) ?? null; - } - - viewpoint(id: string): Viewpoint | null { - return this.viewpointsById.get(id) ?? null; - } - - /** Where you arrive. `viewpoints[0]`, or nothing if the pack declared none. */ - arrival(): Viewpoint | null { - return this.viewpoints[0] ?? null; - } - - /** Every seat in the building, in declaration order, banks expanded. */ - allSeats(): ResolvedSeat[] { - return [...this.seatsById.values()]; - } - - /** - * Which room a point is in. - * - * Later rooms win, because a pack lays the open floor down first and then puts - * the meeting rooms on top of it — the same order it would be drawn in, and - * the same order a reader of the source expects. - */ - roomAt(levelId: string, point: Point2): ResolvedRoom | null { - const level = this.levelsById.get(levelId); - if (!level) return null; - for (let i = level.rooms.length - 1; i >= 0; i--) { - const room = level.rooms[i]; - if (!room) continue; - if (point.x < room.bounds.minX || point.x > room.bounds.maxX) continue; - if (point.z < room.bounds.minZ || point.z > room.bounds.maxZ) continue; - if (pointInOutline(point, room.outline)) return room; - } - return null; - } - - collisionAt(levelId: string): readonly Segment[] { - return this.levelsById.get(levelId)?.collision ?? []; - } - - /** - * Whether a walker of `radius` moving from one point to another crosses a - * wall on this level. - * - * A capsule-versus-segment test rather than a point-in-box one, so a fast - * walker cannot tunnel through a 0.12 m partition between two frames. It lives - * here rather than in the controller because inflating the centreline by half - * the wall thickness is the sort of detail that gets forgotten in one of the - * three places that needs it. - */ - blocked(levelId: string, from: Point2, to: Point2, radius = 0.3): boolean { - for (const seg of this.collisionAt(levelId)) { - const clearance = radius + seg.thickness / 2; - if (segmentDistance(from, to, seg.from, seg.to) < clearance) return true; - } - return false; - } - - // ---- Build -------------------------------------------------------------- - - private buildLevel( - level: Level, - index: number, - where: string, - seen: Record<"room" | "wall" | "prop" | "seat" | "zone", Set>, - report: Report, - ): LevelPlan { - const floorY = level.elevation; - const wallThickness = level.wallThickness ?? DEFAULT_WALL_THICKNESS; - const floorplan = level.floorplan; - const extent = new Extent(); - - // Every one of the five passes below opens the same way: a private item at - // public depth is skipped before anything is resolved about it, so it never - // becomes a `ResolvedRoom`, a `PropPlacement` or a `ResolvedSeat` and there - // is nothing downstream for a mesh layer to build or a traversal to find. - // It is not reported — the pack is not wrong, it is being read at a depth - // that does not include it. - const rooms: ResolvedRoom[] = []; - (floorplan.rooms ?? []).forEach((room, ri) => { - const at = `${where}.rooms[${ri}]`; - if (!included(this.depth, room.audience)) return; - if (seen.room.has(room.id)) { - report(at, `duplicate room id "${room.id}"`, "dropped"); - return; - } - const outline = cleanOutline(room.outline, at, report); - if (!outline) return; - seen.room.add(room.id); - const bounds = outlineBounds(outline); - extent.addBounds(bounds); - rooms.push({ - id: room.id, - name: room.name, - levelId: level.id, - outline, - floor: room.floor, - ceiling: resolveCeiling(room, level, floorY), - y: floorY, - bounds, - area: Math.abs(shoelace(outline)) / 2, - centroid: centroidOf(outline), - }); - }); - - const runs: WallRun[] = []; - const openings: ResolvedOpening[] = []; - const collision: Segment[] = []; - (floorplan.walls ?? []).forEach((wall, wi) => { - const at = `${where}.walls[${wi}]`; - if (seen.wall.has(wall.id)) { - report(at, `duplicate wall id "${wall.id}"`, "dropped"); - return; - } - seen.wall.add(wall.id); - const split = this.splitWall(wall, level, wallThickness, floorY, at, report); - if (!split) return; - runs.push(...split.runs); - openings.push(...split.openings); - collision.push(...split.collision); - extent.add(wall.from); - extent.add(wall.to); - }); - - // Desk banks expand before authored props and seats so that an authored seat - // colliding with a generated one is reported against the hand-written line, - // which is the one whose author can do something about it. - const props: PropPlacement[] = []; - const seats: ResolvedSeat[] = []; - (floorplan.deskBanks ?? []).forEach((bank, bi) => { - const at = `${where}.deskBanks[${bi}]`; - if (!included(this.depth, bank.audience)) return; - this.expandBank(bank, level, floorY, at, seen, report, props, seats); - }); - - (floorplan.props ?? []).forEach((prop, pi) => { - const at = `${where}.props[${pi}]`; - if (!included(this.depth, prop.audience)) return; - if (seen.prop.has(prop.id)) { - report(at, `duplicate prop id "${prop.id}"`, "dropped"); - return; - } - seen.prop.add(prop.id); - props.push(placeProp(prop, level.id, floorY)); - }); - - (floorplan.seats ?? []).forEach((seat, si) => { - const at = `${where}.seats[${si}]`; - if (!included(this.depth, seat.audience)) return; - if (seen.seat.has(seat.id)) { - report(at, `duplicate seat id "${seat.id}"`, "dropped"); - return; - } - seen.seat.add(seat.id); - seats.push(placeSeat(seat, level.id, floorY)); - }); - - const zones: ResolvedZone[] = []; - (floorplan.zones ?? []).forEach((zone, zi) => { - const at = `${where}.zones[${zi}]`; - if (!included(this.depth, zone.audience)) return; - if (seen.zone.has(zone.id)) { - report(at, `duplicate zone id "${zone.id}"`, "dropped"); - return; - } - const outline = cleanOutline(zone.outline, at, report); - if (!outline) return; - seen.zone.add(zone.id); - const bounds = outlineBounds(outline); - extent.addBounds(bounds); - zones.push({ - id: zone.id, - name: zone.name, - levelId: level.id, - outline, - colorKey: zone.colorKey, - y: floorY, - bounds, - area: Math.abs(shoelace(outline)) / 2, - centroid: centroidOf(outline), - }); - }); - - // Props and seats join the extent last so that bank expansions are included - // too — the bounds of a floor whose only content is one desk bank should not - // come out as a point at the origin. - for (const prop of props) extent.add(prop.position); - for (const seat of seats) extent.add(seat.position); - - return { - id: level.id, - name: level.name, - index, - floorY, - wallHeight: level.wallHeight, - wallThickness, - wallSurface: level.wallSurface, - rooms, - runs, - openings, - props, - seats, - zones, - collision, - bounds: extent.finish(), - }; - } - - /** - * The pass CONTRACT.md §2 is built around: one wall, its openings sorted and - * validated, decomposed into intervals along its length. - * - * Each interval is either solid — one full-height run, and it blocks — or a - * hole, which contributes an apron below and a lintel above, and blocks only - * if the hole does not clear `walkHeight` from the floor. Blocking intervals - * are merged as they are walked, so a wall of five windows produces one - * collision segment rather than eleven. - */ - private splitWall( - wall: Wall, - level: Level, - levelThickness: number, - floorY: number, - where: string, - report: Report, - ): { runs: WallRun[]; openings: ResolvedOpening[]; collision: Segment[] } | null { - const dx = wall.to.x - wall.from.x; - const dz = wall.to.z - wall.from.z; - const length = Math.hypot(dx, dz); - if (length < EPS) { - report(where, `wall "${wall.id}" has zero length`, "dropped"); - return null; - } - - const ux = dx / length; - const uz = dz / length; - // A run's mesh lies along its local +X, which for yaw φ points at - // (cos φ, -sin φ) — three.js's rotation about +Y, as `Yaw` promises. The - // `+ 0` turns IEEE's negative zero back into zero: an east-west wall - // otherwise reports a yaw of `-0`, which renders identically and looks like - // a bug in every diff and every snapshot. - const yaw = Math.atan2(-uz, ux) + 0; - const height = wall.height ?? level.wallHeight; - const thickness = wall.thickness ?? levelThickness; - const surface = wall.surface ?? level.wallSurface; - const at = (u: number): Point2 => ({ x: wall.from.x + ux * u, z: wall.from.z + uz * u }); - - const holes = acceptOpenings(wall, length, height, where, report); - - const runs: WallRun[] = []; - const openings: ResolvedOpening[] = []; - const collision: Segment[] = []; - let blockStart: number | null = null; - - const pushRun = ( - u0: number, - u1: number, - bottom: number, - top: number, - role: WallRun["role"], - openingId?: string, - ): void => { - if (u1 - u0 < EPS || top - bottom < EPS) return; - runs.push({ - wallId: wall.id, - center: at((u0 + u1) / 2), - length: u1 - u0, - thickness, - bottom: floorY + bottom, - top: floorY + top, - yaw, - surface, - start: u0, - end: u1, - role, - openingId, - }); - }; - - /** - * Extend or close the run of blocked wall. `u0` is where the interval just - * decided about begins, which is also where an open interval ends — so a - * stretch of solid wall, a window and more solid wall arrives as one segment - * rather than three. - */ - const block = (u0: number, blocks: boolean): void => { - if (blocks) { - if (blockStart === null) blockStart = u0; - return; - } - const from = blockStart; - if (from !== null) { - collision.push({ wallId: wall.id, from: at(from), to: at(u0), thickness }); - blockStart = null; - } - }; - - let cursor = 0; - for (const hole of holes) { - pushRun(cursor, hole.start, 0, height, "solid"); - block(cursor, hole.start - cursor > EPS); - - // Numbered by the opening's position in the *authored* list, so that - // dropping a bad opening does not renumber its siblings and move whatever - // a mesh cached against the id. - const id = `${wall.id}#${hole.index}`; - pushRun(hole.start, hole.end, 0, hole.sill, "apron", id); - pushRun(hole.start, hole.end, hole.head, height, "lintel", id); - - const passable = hole.sill <= EPS && hole.head >= this.walkHeight - EPS; - block(hole.start, !passable); - - openings.push({ - id, - wallId: wall.id, - kind: hole.kind, - center: at((hole.start + hole.end) / 2), - width: hole.end - hole.start, - sill: floorY + hole.sill, - head: floorY + hole.head, - thickness, - yaw, - start: hole.start, - end: hole.end, - passable, - }); - cursor = hole.end; - } - - pushRun(cursor, length, 0, height, "solid"); - block(cursor, length - cursor > EPS); - // Close whatever was still blocking when the wall ran out. - const tail = blockStart; - if (tail !== null) { - collision.push({ wallId: wall.id, from: at(tail), to: at(length), thickness }); - } - - return { runs, openings, collision }; - } - - /** - * A `DeskBank` into props and seats. - * - * The generated ids are contractual — `types.ts` promises a pack author that - * the fourth station of bank `eng` is seat `eng-04`, because a `Presence` - * binds to that string and is written by hand somewhere else entirely. Nothing - * here may renumber. - */ - private expandBank( - bank: DeskBank, - level: Level, - floorY: number, - where: string, - seen: Record<"prop" | "seat", Set>, - report: Report, - props: PropPlacement[], - seats: ResolvedSeat[], - ): void { - const columns = Math.floor(bank.columns); - const rows = Math.floor(bank.rows); - if (!(columns >= 1) || !(rows >= 1)) { - report(where, `bank "${bank.id}" has ${bank.columns} x ${bank.rows} stations`, "dropped"); - return; - } - if (!(bank.pitch > 0)) { - report(where, `bank "${bank.id}" has a pitch of ${bank.pitch} m`, "dropped"); - return; - } - - const rowPitch = bank.rowPitch ?? bank.pitch; - const seatOffset = bank.seatOffset ?? DEFAULT_SEAT_OFFSET; - const pose = bank.pose ?? "sit"; - const prefix = bank.seatPrefix ?? bank.id; - const cos = Math.cos(bank.rotation); - const sin = Math.sin(bank.rotation); - - // Named arguments, because desk and chair differ in three of six fields and - // a positional call would eventually put a chair where a desk goes. - const pushProp = (p: { - id: string; - kind: AssetId; - at: Point2; - rotation: Yaw; - seat: string; - part: "desk" | "chair"; - station: number; - }): void => { - if (seen.prop.has(p.id)) { - report(where, `station ${p.station} generates the taken prop id "${p.id}"`, "dropped"); - return; - } - seen.prop.add(p.id); - props.push({ - id: p.id, - kind: p.kind, - levelId: level.id, - position: { x: p.at.x, y: floorY, z: p.at.z }, - rotation: p.rotation, - scale: [1, 1, 1], - colorKey: undefined, - seat: p.seat, - source: { bankId: bank.id, station: p.station, part: p.part }, - }); - }; - - for (let r = 0; r < rows; r++) { - // With `facingRows`, the first row of each pair turns to look back down - // the bank, which puts its desk between the two occupants and its chair on - // the outside — a bench, rather than two rows of people staring at the - // back of each other's heads. - const flipped = bank.facingRows === true && r % 2 === 0; - const facing = bank.rotation + (flipped ? Math.PI : 0); - // Where a seat sits relative to its desk: behind it, so that the occupant - // looks out over the desktop. That is the desk's local +Z, which for yaw φ - // points at (sin φ, cos φ). - const seatX = Math.sin(facing) * seatOffset; - const seatZ = Math.cos(facing) * seatOffset; - - for (let c = 0; c < columns; c++) { - const station = r * columns + c + 1; - const n = String(station).padStart(2, "0"); - const u = c * bank.pitch; - const v = r * rowPitch; - const x = bank.origin.x + u * cos + v * sin; - const z = bank.origin.z - u * sin + v * cos; - - const seatId = `${prefix}-${n}`; - if (seen.seat.has(seatId)) { - report(where, `station ${station} generates the taken seat id "${seatId}"`, "dropped"); - continue; - } - seen.seat.add(seatId); - seats.push({ - id: seatId, - levelId: level.id, - position: { x: x + seatX, z: z + seatZ }, - y: floorY, - facing, - pose, - source: { bankId: bank.id, station }, - }); - - pushProp({ - id: `${bank.id}-desk-${n}`, - kind: bank.desk, - at: { x, z }, - rotation: facing, - seat: seatId, - part: "desk", - station, - }); - - if (bank.chair !== undefined) { - pushProp({ - id: `${bank.id}-chair-${n}`, - kind: bank.chair, - at: { x: x + seatX, z: z + seatZ }, - rotation: facing, - seat: seatId, - part: "chair", - station, - }); - } - } - } - } -} - -// ---- Placement helpers ---------------------------------------------------- - -function placeProp(prop: Prop, levelId: string, floorY: number): PropPlacement { - const s = prop.scale ?? 1; - const scale: [number, number, number] = - typeof s === "number" ? [s, s, s] : [s[0], s[1], s[2]]; - return { - id: prop.id, - kind: prop.kind, - levelId, - position: { x: prop.position.x, y: floorY + (prop.elevation ?? 0), z: prop.position.z }, - rotation: prop.rotation, - scale, - colorKey: prop.colorKey, - seat: prop.seat, - source: undefined, - }; -} - -function placeSeat(seat: Seat, levelId: string, floorY: number): ResolvedSeat { - return { - id: seat.id, - levelId, - position: { x: seat.position.x, z: seat.position.z }, - y: floorY, - facing: seat.facing, - pose: seat.pose, - source: undefined, - }; -} - -/** - * A room's ceiling. `undefined` means the level's default, explicit `null` means - * there is not one. - * - * The surface is allowed to stay undefined: a level has a default *wall* finish - * but no default ceiling finish, and inventing one here would be worse than - * letting the material registry fall back to its own `ceilingTile` role — which - * is where that decision belongs. - */ -function resolveCeiling(room: Room, level: Level, floorY: number): ResolvedRoom["ceiling"] { - if (room.ceiling === null) return null; - return { - height: floorY + (room.ceiling?.height ?? level.wallHeight), - surface: room.ceiling?.surface, - }; -} - -/** An opening that survived validation. `index` is its position in the authored list. */ -interface AcceptedOpening { - index: number; - kind: OpeningKind; - start: number; - end: number; - sill: number; - head: number; -} - -/** - * The openings that survive, sorted along the wall and guaranteed not to - * overlap — which is what lets the splitting pass be a single left-to-right - * walk rather than an interval-tree problem. - */ -function acceptOpenings( - wall: Wall, - length: number, - height: number, - where: string, - report: Report, -): AcceptedOpening[] { - const indexed = (wall.openings ?? []).map((opening, index) => ({ opening, index })); - indexed.sort((a, b) => a.opening.start - b.opening.start); - - const kept: AcceptedOpening[] = []; - for (const { opening, index } of indexed) { - const at = `${where}.openings[${index}]`; - const accepted = normaliseOpening(opening, index, length, height, at, report); - if (!accepted) continue; - // Sorted by `start`, so only the previous survivor can be in the way. - const previous = kept[kept.length - 1]; - if (previous && accepted.start < previous.end - EPS) { - report(at, `overlaps the opening starting at ${previous.start.toFixed(2)} m`, "dropped"); - continue; - } - kept.push(accepted); - } - return kept; -} - -/** - * One opening, checked against its wall. - * - * The horizontal errors are fatal to the opening — a hole that runs off the end - * of a wall has no sensible repair, and clamping it would silently move a door. - * The vertical ones are clamped, because a head above the wall is unambiguously - * "all the way up" and a negative sill is unambiguously "on the floor". - */ -function normaliseOpening( - opening: Opening, - index: number, - length: number, - height: number, - where: string, - report: Report, -): AcceptedOpening | null { - if (!(opening.width > EPS)) { - report(where, `width is ${opening.width} m`, "dropped"); - return null; - } - if (opening.start < -EPS) { - report(where, `starts ${(-opening.start).toFixed(2)} m before its wall`, "dropped"); - return null; - } - const end = opening.start + opening.width; - if (end > length + EPS) { - report( - where, - `runs ${(end - length).toFixed(2)} m past the end of a ${length.toFixed(2)} m wall`, - "dropped", - ); - return null; - } - - let sill = opening.sill; - let head = opening.head; - if (sill < 0) { - report(where, `sill is ${sill} m; clamped to the floor`, "repaired"); - sill = 0; - } - if (head > height + EPS) { - report(where, `head is above a ${height.toFixed(2)} m wall; clamped`, "repaired"); - head = height; - } - if (head - sill < EPS) { - report(where, `head ${head} m is not above sill ${sill} m`, "dropped"); - return null; - } - - return { index, kind: opening.kind, start: opening.start, end, sill, head }; -} - -// ---- Polygons ------------------------------------------------------------- - -/** - * A usable outline, or nothing. - * - * Three repairs and two rejections. Repeated points collapse — including a - * repeated *first* point, which is the single most common thing a new author - * writes, because every other polygon format they have met wanted the ring - * closed by hand. Reversed winding is re-wound silently, as `types.ts` promises. - * Fewer than three distinct points, or an outline that crosses itself, is - * dropped: a self-intersecting floor slab triangulates into a black bowtie, and - * every downstream area, centroid and point-in-polygon answer about it is - * meaningless. - */ -function cleanOutline( - outline: Outline, - where: string, - report: Report, -): Outline | null { - const points: Point2[] = []; - for (const p of outline) { - if (!Number.isFinite(p.x) || !Number.isFinite(p.z)) { - report(where, `outline has a non-finite point`, "dropped"); - return null; - } - const last = points[points.length - 1]; - if (last && Math.abs(last.x - p.x) < EPS && Math.abs(last.z - p.z) < EPS) continue; - points.push({ x: p.x, z: p.z }); - } - const first = points[0]; - const last = points[points.length - 1]; - if (points.length > 1 && first && last && Math.abs(first.x - last.x) < EPS && Math.abs(first.z - last.z) < EPS) { - report(where, "outline repeats its first point; outlines are implicitly closed", "repaired"); - points.pop(); - } - - if (points.length < 3) { - report(where, `outline has ${points.length} distinct points`, "dropped"); - return null; - } - - const area = shoelace(points); - if (Math.abs(area) / 2 < EPS) { - report(where, "outline encloses no area", "dropped"); - return null; - } - if (selfIntersects(points)) { - report(where, "outline crosses itself", "dropped"); - return null; - } - - // Positive shoelace over (x, z) is *clockwise* in plan view, because +Z runs - // down the page. Counter-clockwise in plan view is also what a floor slab - // needs to triangulate into +Y-facing triangles, which is why the convention - // is worth normalising rather than merely documenting. - if (area > 0) points.reverse(); - return points; -} - -function shoelace(outline: Outline): number { - let sum = 0; - for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { - const a = outline[j]; - const b = outline[i]; - if (!a || !b) continue; - sum += a.x * b.z - b.x * a.z; - } - return sum; -} - -function centroidOf(outline: Outline): Point2 { - let cx = 0; - let cz = 0; - let twiceArea = 0; - for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { - const a = outline[j]; - const b = outline[i]; - if (!a || !b) continue; - const cross = a.x * b.z - b.x * a.z; - twiceArea += cross; - cx += (a.x + b.x) * cross; - cz += (a.z + b.z) * cross; - } - if (Math.abs(twiceArea) < EPS) { - // Degenerate rings never reach here, but a caller with its own outline - // might; the vertex mean is at least inside the hull. - let mx = 0; - let mz = 0; - for (const p of outline) { - mx += p.x; - mz += p.z; - } - const n = Math.max(1, outline.length); - return { x: mx / n, z: mz / n }; - } - return { x: cx / (3 * twiceArea), z: cz / (3 * twiceArea) }; -} - -/** - * Whether any two non-adjacent edges cross. - * - * O(n²), and deliberately so: an office room is a dozen points, a sweep-line is - * fifty lines of code that would be wrong in the collinear cases, and this runs - * once at load. - */ -function selfIntersects(outline: Outline): boolean { - const n = outline.length; - for (let i = 0; i < n; i++) { - const a1 = outline[i]; - const a2 = outline[(i + 1) % n]; - if (!a1 || !a2) continue; - for (let j = i + 1; j < n; j++) { - // Skip the shared-vertex pairs: consecutive edges always touch, and the - // last edge always touches the first. - if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue; - const b1 = outline[j]; - const b2 = outline[(j + 1) % n]; - if (!b1 || !b2) continue; - if (segmentsCross(a1, a2, b1, b2)) return true; - } - } - return false; -} - -function cross(o: Point2, a: Point2, b: Point2): number { - return (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x); -} - -/** Proper crossing only. Touching endpoints and collinear overlap do not count. */ -function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean { - const d1 = cross(a1, a2, b1); - const d2 = cross(a1, a2, b2); - const d3 = cross(b1, b2, a1); - const d4 = cross(b1, b2, a2); - return d1 * d2 < 0 && d3 * d4 < 0; -} - -/** Ray casting, in the XZ plane. The same algorithm `World` uses on lat/lng. */ -function pointInOutline(point: Point2, outline: Outline): boolean { - let inside = false; - for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) { - const a = outline[i]; - const b = outline[j]; - if (!a || !b) continue; - if (a.z > point.z !== b.z > point.z) { - const x = ((b.x - a.x) * (point.z - a.z)) / (b.z - a.z) + a.x; - if (point.x < x) inside = !inside; - } - } - return inside; -} - -// ---- Distance ------------------------------------------------------------- - -function pointToSegment(p: Point2, a: Point2, b: Point2): number { - const dx = b.x - a.x; - const dz = b.z - a.z; - const lenSq = dx * dx + dz * dz; - let t = lenSq < EPS ? 0 : ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq; - t = Math.max(0, Math.min(1, t)); - return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz)); -} - -/** - * Closest approach between two segments in 2D. - * - * Crossing segments are zero apart; otherwise the minimum is attained at one of - * the four endpoints, which is a well-known property of convex sets and much - * less error-prone than solving the parametric system. - */ -function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number { - if (segmentsCross(a1, a2, b1, b2)) return 0; - return Math.min( - pointToSegment(a1, b1, b2), - pointToSegment(a2, b1, b2), - pointToSegment(b1, a1, a2), - pointToSegment(b2, a1, a2), - ); -} - -// ---- Extents -------------------------------------------------------------- - -class Extent { - private minX = Infinity; - private maxX = -Infinity; - private minZ = Infinity; - private maxZ = -Infinity; - - add(p: Point2): void { - if (p.x < this.minX) this.minX = p.x; - if (p.x > this.maxX) this.maxX = p.x; - if (p.z < this.minZ) this.minZ = p.z; - if (p.z > this.maxZ) this.maxZ = p.z; - } - - addBounds(b: Bounds): void { - this.add({ x: b.minX, z: b.minZ }); - this.add({ x: b.maxX, z: b.maxZ }); - } - - finish(): Bounds { - if (!Number.isFinite(this.minX)) { - return { minX: 0, maxX: 0, minZ: 0, maxZ: 0, width: 0, depth: 0, center: { x: 0, z: 0 } }; - } - return { - minX: this.minX, - maxX: this.maxX, - minZ: this.minZ, - maxZ: this.maxZ, - width: this.maxX - this.minX, - depth: this.maxZ - this.minZ, - center: { x: (this.minX + this.maxX) / 2, z: (this.minZ + this.maxZ) / 2 }, - }; - } -} - -function outlineBounds(outline: Outline): Bounds { - const extent = new Extent(); - for (const p of outline) extent.add(p); - return extent.finish(); -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/types.ts deleted file mode 100644 index f863ed3..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/types.ts +++ /dev/null @@ -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; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/walker.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/walker.ts deleted file mode 100644 index 0a9774a..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/interiors/walker.ts +++ /dev/null @@ -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 | 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)); -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/frontier-valley.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/frontier-valley.ts deleted file mode 100644 index aa403b4..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/frontier-valley.ts +++ /dev/null @@ -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; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/sites.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/sites.ts deleted file mode 100644 index ca7f6cd..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/offices/sites.ts +++ /dev/null @@ -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 }, -]; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/california.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/california.ts deleted file mode 100644 index 2148fb1..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/california.ts +++ /dev/null @@ -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; diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/types.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/types.ts deleted file mode 100644 index b618117..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/types.ts +++ /dev/null @@ -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[]; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleController.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleController.ts deleted file mode 100644 index a6a5358..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleController.ts +++ /dev/null @@ -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 = 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; -} - -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 | 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 { - 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 | 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 = 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 = 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 }; -} diff --git a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleSim.ts b/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleSim.ts deleted file mode 100644 index 25b249f..0000000 --- a/environments/tera_spatial/tera_spatial/vendor/tera/src/transport/vehicleSim.ts +++ /dev/null @@ -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; - 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; -} diff --git a/environments/tera_spatial/tera_spatial/worker.mjs b/environments/tera_spatial/tera_spatial/worker.mjs deleted file mode 100644 index fea929a..0000000 --- a/environments/tera_spatial/tera_spatial/worker.mjs +++ /dev/null @@ -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"); -} diff --git a/environments/tera_spatial/tests/__init__.py b/environments/tera_spatial/tests/__init__.py deleted file mode 100644 index 35a49d1..0000000 --- a/environments/tera_spatial/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for tera-spatial.""" diff --git a/environments/tera_spatial/tests/test_crow_nav.py b/environments/tera_spatial/tests/test_crow_nav.py deleted file mode 100644 index f66b020..0000000 --- a/environments/tera_spatial/tests/test_crow_nav.py +++ /dev/null @@ -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() diff --git a/environments/tera_spatial/tests/test_replay.py b/environments/tera_spatial/tests/test_replay.py deleted file mode 100644 index abe5937..0000000 --- a/environments/tera_spatial/tests/test_replay.py +++ /dev/null @@ -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() diff --git a/tests/test_probe.py b/tests/test_probe.py index e3d4fc9..c0411c8 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -92,8 +92,15 @@ class RootProbeIntegrationTests(unittest.TestCase): self.assertEqual(agent["harness"]["id"], "null") self.assertEqual(agent["runtime"]["type"], "subprocess") + tracked = subprocess.run( + ["git", "ls-files", "environments"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() self.assertEqual( - list((ROOT / "environments").glob("**/harness.py")), + [path for path in tracked if path.endswith("/harness.py")], [], msg="Arena environments must use Verifiers harnesses, not ship a parallel one", )