fix: close California play and asset acceptance gaps
This commit is contained in:
+54
-64
@@ -66,7 +66,7 @@ offices are the inside, and a tenant moves between them.
|
|||||||
## 2. Layout
|
## 2. Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
lumbridge-simulate/
|
tera/
|
||||||
├── src/engine/ # renderer. Knows about terrain, blocks, streets, flights.
|
├── src/engine/ # renderer. Knows about terrain, blocks, streets, flights.
|
||||||
│ ├── types.ts # City, District, Hill, Marker, FlightSource
|
│ ├── types.ts # City, District, Hill, Marker, FlightSource
|
||||||
│ ├── world.ts # projection + cached heightfield, built per city
|
│ ├── world.ts # projection + cached heightfield, built per city
|
||||||
@@ -83,8 +83,10 @@ lumbridge-simulate/
|
|||||||
│ ├── california.ts # state/corridor scale; LA ↔ SF continuity
|
│ ├── california.ts # state/corridor scale; LA ↔ SF continuity
|
||||||
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
|
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
|
||||||
│ └── socal.ts # LA / OC / Riverside
|
│ └── socal.ts # LA / OC / Riverside
|
||||||
├── src/adapters/ # where outside data plugs in
|
├── src/realtime/ # strict game-state wire/client/interpolation adapters
|
||||||
│ └── workie.ts # Workie API -> Marker[]
|
├── src/media/ # separate screen signaling, ICE and texture lifecycles
|
||||||
|
├── src/profile/ # local profile plus ephemeral webcam consent/capture
|
||||||
|
├── src/adapters/ # generic HTTP and sample-data boundaries
|
||||||
└── src/main.ts # the standalone demo app
|
└── src/main.ts # the standalone demo app
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -102,8 +104,8 @@ knows nothing about terrain specifically.
|
|||||||
`transport` also stays outside the renderer. A `TransportPack` is plain JSON and
|
`transport` also stays outside the renderer. A `TransportPack` is plain JSON and
|
||||||
`VehicleSimulation` returns plain geographic poses. Three.js enters only in the
|
`VehicleSimulation` returns plain geographic poses. Three.js enters only in the
|
||||||
`roadTraffic` render layer, which projects poses through the active `World`.
|
`roadTraffic` render layer, which projects poses through the active `World`.
|
||||||
That separation lets a future authoritative session server replay the same
|
That separation lets the authoritative session service validate and replay the
|
||||||
route simulation without importing a browser or a GPU.
|
same route state without importing a browser or a GPU.
|
||||||
|
|
||||||
### 2.1 Product/control-plane boundary
|
### 2.1 Product/control-plane boundary
|
||||||
|
|
||||||
@@ -113,10 +115,11 @@ membership, billing, provisioning, and short-lived launch grants. It must not
|
|||||||
grow a second world renderer or own frame-by-frame simulation.
|
grow a second world renderer or own frame-by-frame simulation.
|
||||||
|
|
||||||
The public Tera build remains zero-config and useful without any service. Hosted
|
The public Tera build remains zero-config and useful without any service. Hosted
|
||||||
presence, multiplayer, profiles, webcam faces, and media surfaces arrive through
|
presence, multiplayer and remote media arrive through adapters whose absence
|
||||||
adapters whose absence cannot stop the city, office, traffic, or sky from
|
cannot stop the city, office, traffic, or sky from running. Local profiles and
|
||||||
running. This is the replacement path for the retired Phaser World 1; it is not
|
webcam faces require no hosted service; webcam pixels never enter the realtime
|
||||||
a migration of that renderer.
|
or office-screen transports. This is the replacement path for the retired
|
||||||
|
Phaser World 1; it is not a migration of that renderer.
|
||||||
|
|
||||||
The first hosted adapter is now concrete: the Fastify API owns short-lived,
|
The first hosted adapter is now concrete: the Fastify API owns short-lived,
|
||||||
in-memory authoritative presence sessions and streams validated deltas over
|
in-memory authoritative presence sessions and streams validated deltas over
|
||||||
@@ -125,8 +128,9 @@ the browser transport, interpolation buffer, procedural peer renderer and small
|
|||||||
status UI are separate adapters. A city or full-depth office opts into remote
|
status UI are separate adapters. A city or full-depth office opts into remote
|
||||||
peers explicitly, while an anonymous/public scene does not even allocate their
|
peers explicitly, while an anonymous/public scene does not even allocate their
|
||||||
assets. Interest changes clear the old coordinate frame before a new one is
|
assets. Interest changes clear the old coordinate frame before a new one is
|
||||||
shown. Game-state deltas never carry webcam or screen media; those remain
|
shown. Game-state deltas never carry webcam or screen media. Webcam faces are
|
||||||
separate consent and authorization lifecycles.
|
local-only; office screens use a separate consent, signaling and authorization
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,9 +147,10 @@ relicense no matter how the file got here, and an Apache-2.0 repo containing
|
|||||||
|
|
||||||
The NYC atlas ships 47 of these in `public/logos/`. We do not.
|
The NYC atlas ships 47 of these in `public/logos/`. We do not.
|
||||||
|
|
||||||
- Logos are fetched **at runtime**, client-side, and cached in the browser.
|
- The shipped application does not fetch or display third-party logos.
|
||||||
- The repo carries a fetch script and zero logo files. `public/logos/` is
|
- The repo carries zero logo files. Self-hoster asset directories are ignored,
|
||||||
`.gitignore`d, and CI fails if anything lands there.
|
and the provenance/no-binary gates prevent a logo-shaped binary from quietly
|
||||||
|
becoming part of the procedural source library.
|
||||||
- `NOTICE` carries the standard "trademarks are the property of their
|
- `NOTICE` carries the standard "trademarks are the property of their
|
||||||
respective owners; their use here is nominative" line.
|
respective owners; their use here is nominative" line.
|
||||||
|
|
||||||
@@ -294,35 +299,26 @@ somebody reaches for the door rather than by everybody at boot.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. How Workie feeds it
|
## 6. Hosted data and authority boundaries
|
||||||
|
|
||||||
Workie stays the system of record for companies. Tera never gets a database.
|
Tera has no database requirement. The optional Fastify API supplies bounded
|
||||||
|
weather, flight, satellite, marker, office, realtime and screen-signaling
|
||||||
|
adapters behind same-origin `/api/v1/*`; a static clone continues with authored
|
||||||
|
geography and simulation when that API is absent.
|
||||||
|
|
||||||
```
|
Marker rows cross the strict public-shape and provenance allowlist before the
|
||||||
Workie GET /api/live/markers (private, tailnet) -> status colours
|
renderer sees them. The renderer receives only `Marker[]` and never pipeline or
|
||||||
Workie GET /api/public/markers (public, allowlist) -> sector colours
|
tenant records. Realtime peers receive opaque page-scoped actor identifiers and
|
||||||
|
|
validated pose state, never auth subjects or profile faces. Screen media is
|
||||||
v
|
peer-to-peer: the API validates exact authored screen bindings and relays bounded
|
||||||
adapters/workie.ts -> Marker[]
|
signaling, but it never receives media bytes, recordings or source locators.
|
||||||
|
|
|
||||||
v
|
|
||||||
engine/markers.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The public endpoint goes through the **same `export-site.ts` field allowlist**
|
The currently implemented hosted authorization is deployment-member plus global
|
||||||
that already guards radar.karti.ai and work.karti.ai — the one that is
|
admin. It is deliberately not described as tenant/office membership. A control-
|
||||||
fail-closed and aborts a deploy rather than shipping an unknown field. No second
|
plane membership provider must be added before private tenant offices share the
|
||||||
implementation of that gate. That was the argument for keeping `/live` inside
|
same deployment authority boundary. TURN is also optional and fail-closed; the
|
||||||
Workie originally, and it still holds — but only for the *data path*. The
|
credential endpoint being implemented does not mean a public relay is deployed
|
||||||
renderer left; the gate stayed where it was.
|
or approved.
|
||||||
|
|
||||||
Self-hosters get neither endpoint and do not need one: `setMarkers()` takes an
|
|
||||||
array, and where it comes from is the deployment's business.
|
|
||||||
|
|
||||||
`lumbridgecorp.com/live` is a static page, so it calls a small
|
|
||||||
`lumbridge-simulate` service behind the existing Caddy `handle /api/*` — the
|
|
||||||
same pattern `lumbridge-intake.service` already uses on cloud-2 — for flights
|
|
||||||
and for proxying the public marker feed.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -364,33 +360,27 @@ friends) are the shape to aim at. The difference Spaces is going for is that thi
|
|||||||
one is Apache 2.0 and self-hostable — you can run your own world on your own
|
one is Apache 2.0 and self-hostable — you can run your own world on your own
|
||||||
hardware, which is the same claim `lumbridge-compute` makes about compute.
|
hardware, which is the same claim `lumbridge-compute` makes about compute.
|
||||||
|
|
||||||
This is the next phase after the city lands, and it is where `src/assets` starts
|
That phase is now implemented: offices consume the same procedural library and
|
||||||
earning its keep.
|
retain the city scene behind a metre-scale interior scene swap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Order of work
|
## 8. Current release topology
|
||||||
|
|
||||||
1. **Port** SF out of Workie into `engine` + `cities/sf`, parameterised by city;
|
The repository now ships California corridor, Bay Area and Southern California
|
||||||
delete Workie's `/live`. *(this commit)*
|
boards; walkable offices; procedural actors, vehicles and aircraft; optional
|
||||||
2. **Markers + simulated flights.** A demo worth showing, with no data pipeline
|
authoritative realtime; local webcam faces; and separately authorized office
|
||||||
and no licence questions. *(this commit)*
|
screen signaling. `BUILD_PLAN.md` is the milestone evidence ledger.
|
||||||
3. **Mount at `lumbridgecorp.com/live`**, with a small `lumbridge-simulate`
|
|
||||||
service behind Caddy for flights.
|
|
||||||
4. **Interiors + the asset library** — the office view, original assets, and
|
|
||||||
World 1 moving off Phaser. This is the one that unlocks the platform.
|
|
||||||
5. **Workie adapter**, once its geocoding pipeline lands. Workie gets an API,
|
|
||||||
not a renderer.
|
|
||||||
6. **`cities/la`** — LA / OC / Riverside. Needs `focusRegions` from §5 first.
|
|
||||||
7. **NYC.**
|
|
||||||
|
|
||||||
## 9. Open questions
|
The source code is Apache-2.0. `src/assets/LICENSE-ART` additionally dedicates
|
||||||
|
the artistic output of the procedural asset library under CC0-1.0.
|
||||||
|
`PROVENANCE.json`, the dependency allowlist and SPDX SBOM gate the distribution.
|
||||||
|
|
||||||
1. **Package name.** `@lumbridge/simulate` implies an npm publish; consuming
|
Remaining questions are deployment questions, not missing renderer contracts:
|
||||||
straight from Gitea is simpler until someone outside asks for a registry.
|
|
||||||
2. **Licence for the asset library specifically.** Apache 2.0 covers code
|
1. Which authoritative control-plane provider proves tenant/office membership?
|
||||||
cleanly; art is sometimes better served by CC0 or CC-BY so it can be reused
|
2. When do operators approve public TURN/DNS/firewall/certificate changes after
|
||||||
outside software. Worth deciding before the first mesh lands, because
|
relay-only and abuse-boundary acceptance?
|
||||||
relicensing art after contributors exist is painful.
|
3. Which Firefox/WebKit versions become supported browser-test targets?
|
||||||
3. **How much of World 1 moves at once.** The Phaser world is live and has real
|
4. Which additional worlds or optional asset packs enter through the same
|
||||||
tenants; the city view can ship at `/live` well before any office does.
|
provenance and performance gates?
|
||||||
|
|||||||
+55
-35
@@ -88,7 +88,8 @@ Status: **implemented and integrated**. City actors and office walkers share the
|
|||||||
same profile-derived appearance and journey identity; anonymous visitors change
|
same profile-derived appearance and journey identity; anonymous visitors change
|
||||||
from crow outdoors to dog indoors, while signed-in members remain humanoid.
|
from crow outdoors to dog indoors, while signed-in members remain humanoid.
|
||||||
|
|
||||||
- First-/third-person walker against the existing `Plan.blocked` wall segments.
|
- Third-person walker against the existing `Plan.blocked` wall segments. A
|
||||||
|
first-person office camera is not implemented or required for this release.
|
||||||
- Door traversal and city ↔ office identity handoff.
|
- Door traversal and city ↔ office identity handoff.
|
||||||
- Customizable procedural humanoid for signed-in members.
|
- Customizable procedural humanoid for signed-in members.
|
||||||
- Anonymous office visitor is a dog; anonymous Tera visitor is a crow.
|
- Anonymous office visitor is a dog; anonymous Tera visitor is a crow.
|
||||||
@@ -100,23 +101,29 @@ soak produces no stuck or out-of-bounds actor.
|
|||||||
|
|
||||||
## M4 — authoritative multiplayer
|
## M4 — authoritative multiplayer
|
||||||
|
|
||||||
Status: **core hosted actor, vehicle, and aircraft presence is implemented**.
|
Status: **core hosted actor, vehicle, and aircraft presence is implemented and
|
||||||
|
covered by deterministic client/service tests; live two-browser acceptance is
|
||||||
|
still an operator QA gate**.
|
||||||
The service uses short-lived rotating grants, exact interest cells, bounded
|
The service uses short-lived rotating grants, exact interest cells, bounded
|
||||||
10 Hz publishing, strict motion validation, reconnect snapshots, and aggregate-
|
10 Hz publishing, strict motion validation, reconnect snapshots, and aggregate-
|
||||||
only presence UI. Deterministic two-client and bounded-service acceptance tests
|
only presence UI. Deterministic two-client and bounded-service acceptance tests
|
||||||
cover convergence, late join, cell isolation, ownership, revocation, cleanup,
|
cover convergence, late join, cell isolation, ownership, revocation, cleanup,
|
||||||
and speed-hack rejection.
|
and speed-hack rejection.
|
||||||
|
|
||||||
- Dedicated realtime session service; do not merge it into the control plane.
|
- Dedicated in-memory realtime module inside the optional Tera Fastify API;
|
||||||
|
game-state and media signaling remain separate services and protocols.
|
||||||
- Server-authoritative actor/vehicle poses, input validation, interpolation,
|
- Server-authoritative actor/vehicle poses, input validation, interpolation,
|
||||||
reconnect, and interest zones keyed by Tera cell / Office / Floor / Room.
|
reconnect, and interest zones keyed by Tera cell / Office / Floor / Room.
|
||||||
- `lumbridgecorp` issues short-lived launch grants and revalidates membership.
|
- The Tera API issues short-lived rotating session grants after the configured
|
||||||
|
deployment auth adapter resolves a signed-in subject.
|
||||||
- Delta snapshots around 10–15 Hz for nearby dynamic actors; media never rides
|
- Delta snapshots around 10–15 Hz for nearby dynamic actors; media never rides
|
||||||
on this socket.
|
on this socket.
|
||||||
|
|
||||||
Exit gate: two browsers converge within the declared latency; reconnect restores
|
Automated gate: two logical clients converge; reconnect restores the right cell;
|
||||||
the right cell; revocation ejects promptly; malformed and speed-hack state is
|
revocation ejects promptly; malformed and speed-hack state is rejected; bounded
|
||||||
rejected; selected cell concurrency passes a repeatable load test.
|
service soak/capacity tests pass. A real two-browser, two-network convergence and
|
||||||
|
revocation smoke remains required before calling a particular hosted deployment
|
||||||
|
production-ready.
|
||||||
|
|
||||||
## M5 — profile and webcam faces
|
## M5 — profile and webcam faces
|
||||||
|
|
||||||
@@ -129,19 +136,24 @@ is torn down on Stop, track end, revocation, or page exit.
|
|||||||
- Generated profile face is the default.
|
- Generated profile face is the default.
|
||||||
- Webcam face is opt-in and ephemeral: explicit contextual permission, active
|
- Webcam face is opt-in and ephemeral: explicit contextual permission, active
|
||||||
indicator, one-click stop, no recording/storage by default.
|
indicator, one-click stop, no recording/storage by default.
|
||||||
- Server-enforced visibility capabilities; unauthorized clients never receive
|
- Webcam pixels are local to the current browser and are never placed on the
|
||||||
the private object or track.
|
realtime or office-screen signaling paths. Remote webcam-face publication has
|
||||||
|
no implementation in this release.
|
||||||
|
|
||||||
Exit gate: revocation and tab close stop every track; anonymous/unapproved peers
|
Exit gate: Stop, permission cancellation, track end, membership revocation and
|
||||||
cannot subscribe; the full product works without a camera.
|
`pagehide` stop every app-owned track; anonymous users receive no camera control;
|
||||||
|
the full product works without a camera. There is no webcam subscription API for
|
||||||
|
an unauthorized peer to reach.
|
||||||
|
|
||||||
## M6 — office media surfaces
|
## M6 — office media surfaces
|
||||||
|
|
||||||
Status: **implemented and integrated for peer-to-peer hosted sharing**. Authored
|
Status: **implemented and integrated for bounded peer-to-peer hosted sharing at
|
||||||
|
the application layer; internet-grade relay deployment is not enabled**. Authored
|
||||||
Three.js monitors remain dark until an authorized viewer explicitly opts in;
|
Three.js monitors remain dark until an authorized viewer explicitly opts in;
|
||||||
presenters choose a tab/window through the browser prompt and have an immediate
|
presenters choose a tab/window through the browser prompt and have an immediate
|
||||||
kill switch. Signaling grants, TURN grants, video textures, late join, reconnect,
|
kill switch. Signaling grants, conditional TURN credential issuance, video
|
||||||
and revoke lifecycle are bounded and tested. Presenter encoding defaults to
|
textures, late join, reconnect, and revoke lifecycle are bounded and tested.
|
||||||
|
Presenter encoding defaults to
|
||||||
1280×720 at 15 fps and 1.5 Mbps with capability-safe browser fallback.
|
1280×720 at 15 fps and 1.5 Mbps with capability-safe browser fallback.
|
||||||
|
|
||||||
- `MediaSurface` records identify office/room/screen, ACL, source, and state.
|
- `MediaSurface` records identify office/room/screen, ACL, source, and state.
|
||||||
@@ -152,7 +164,12 @@ and revoke lifecycle are bounded and tested. Presenter encoding defaults to
|
|||||||
presenter kill switch, late join and reconnect.
|
presenter kill switch, late join and reconnect.
|
||||||
|
|
||||||
Exit gate: no unauthorized subscription; revoke ends tracks and disposes GPU
|
Exit gate: no unauthorized subscription; revoke ends tracks and disposes GPU
|
||||||
textures; bandwidth adaptation and safe public placeholders work.
|
textures; bandwidth adaptation and safe public placeholders work. The current
|
||||||
|
server authorization boundary is a signed-in deployment member, not tenant or
|
||||||
|
office membership. Tenant-isolated use requires an authoritative office-
|
||||||
|
membership provider. Public TURN remains disabled until the operator completes
|
||||||
|
the relay-only, cross-network, firewall, certificate, quota and revocation
|
||||||
|
acceptance plan in `deploy/coturn/README.md`.
|
||||||
|
|
||||||
## M7 — playable aircraft
|
## M7 — playable aircraft
|
||||||
|
|
||||||
@@ -161,9 +178,8 @@ procedural electric V-tail has deterministic assisted/manual control, chase
|
|||||||
camera, California bounds, and an authoritative geographic wire adapter; remote
|
camera, California bounds, and an authoritative geographic wire adapter; remote
|
||||||
clients render its bank, control surfaces, and fan phase from validated state.
|
clients render its bank, control surfaces, and fan phase from validated state.
|
||||||
|
|
||||||
Keep today's aircraft ambient until vehicle, actor, camera, and network
|
Ambient ADS-B rendering remains a separate source and authority path from the
|
||||||
abstractions are proven. A later `PlayableAircraft` reuses flight-source data for
|
playable aircraft controller.
|
||||||
the world but owns a separate controller and authority model.
|
|
||||||
|
|
||||||
Exit gate: route/altitude bounds, camera and control handoff, multiplayer
|
Exit gate: route/altitude bounds, camera and control handoff, multiplayer
|
||||||
validation, and no regression to ambient live ADS-B rendering.
|
validation, and no regression to ambient live ADS-B rendering.
|
||||||
@@ -183,27 +199,31 @@ budgets are not raised.
|
|||||||
|
|
||||||
- Named benchmark scenes: p95 frame at or below 16.7 ms desktop and 33.3 ms on
|
- Named benchmark scenes: p95 frame at or below 16.7 ms desktop and 33.3 ms on
|
||||||
the selected supported mobile tier.
|
the selected supported mobile tier.
|
||||||
- Hard budgets per scale for resident cells, triangles, draw calls, dynamic
|
- Enforced browser budgets currently cover p95 frame interval, draw calls and
|
||||||
actors, GPU memory, and media textures.
|
triangles. Resident-cell, dynamic-actor, GPU-memory and live-media-texture
|
||||||
|
budgets remain future instrumentation and are not release claims.
|
||||||
- Fixed simulation tick separated from rendering; instancing, LOD, pooling,
|
- Fixed simulation tick separated from rendering; instancing, LOD, pooling,
|
||||||
frustum/distance culling, and explicit resource disposal.
|
frustum/distance culling, and explicit resource disposal.
|
||||||
- Degrade shadows, traffic density, and far detail before input, access control,
|
- Degrade shadows, traffic density, and far detail before input, access control,
|
||||||
identity, or privacy enforcement.
|
identity, or privacy enforcement.
|
||||||
|
|
||||||
## Immediate parallel build
|
## Release boundary and operator gates
|
||||||
|
|
||||||
The California driving vertical slice now proves the transport, vehicle,
|
M0–M7 describe the repository implementation, not an assertion that every
|
||||||
controller, input, camera, and responsive UI contracts. Continue in these lanes:
|
optional hosted facility is enabled on every deployment. The static/public
|
||||||
|
California and Office experience, solo actors/vehicles/aircraft, procedural art,
|
||||||
|
and local media placeholders remain the release baseline.
|
||||||
|
|
||||||
1. **World:** corridor cell streaming, origin rebasing, city/office destination
|
Before enabling hosted tenant use or advertising internet-grade screen sharing:
|
||||||
transitions, and deterministic route-completion scenarios.
|
|
||||||
2. **Office:** integrate the walker with first-/third-person cameras, doors,
|
1. Provide authoritative tenant/office membership; current realtime/media auth
|
||||||
actor possession, and identity-preserving city ↔ office transitions.
|
proves only deployment membership.
|
||||||
3. **Assets:** add driver-view interior hints and animation/state adapters for
|
2. Complete a real two-browser/two-network realtime and media acceptance run,
|
||||||
the existing humanoid, dog, and crow rigs; keep the code-only asset pipeline.
|
including revocation and reconnect.
|
||||||
4. **Platform:** define versioned launch-grant, session, interest-zone, actor,
|
3. Keep TURN ports and `TERA_ICE_URLS`/`TERA_TURN_SHARED_SECRET` disabled until
|
||||||
vehicle, and media-capability schemas plus their threat model—without coupling
|
every mandatory gate in `deploy/coturn/README.md` passes.
|
||||||
the renderer to a hosted service.
|
4. Apply and verify the documented CSP and Permissions-Policy on both entry
|
||||||
5. **Quality:** add repeatable frame-time/draw-call budgets, screenshot baselines,
|
hosts. Browser capture permission is contextual and does not replace the
|
||||||
long-route and office soak tests, asset-manifest CI, and cross-browser input
|
application's explicit opt-in and visible Stop controls.
|
||||||
coverage.
|
5. Treat narrower CSP allowlists, GPU/media memory budgets and Firefox/WebKit
|
||||||
|
browser automation as hardening/follow-up work, not already-passed gates.
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ face and every office screen placeholder intact. The CSP needs no camera/media
|
|||||||
host exception: webcam and display tracks are caller-owned `MediaStream`s, not
|
host exception: webcam and display tracks are caller-owned `MediaStream`s, not
|
||||||
network media URLs, and signaling remains under same-origin `connect-src`.
|
network media URLs, and signaling remains under same-origin `connect-src`.
|
||||||
|
|
||||||
|
The production policy may contain deployment-specific sources, but every one is
|
||||||
|
an operator-owned exception to this repository minimum. Audit and remove stale
|
||||||
|
font/CDN/identity origins; do not cargo-cult a broader live header back into this
|
||||||
|
template. A same-origin Tera build needs no Google Fonts or jsDelivr source.
|
||||||
|
|
||||||
## Serving the API too
|
## Serving the API too
|
||||||
|
|
||||||
Only needed for live weather, real ADS-B, or markers from an external source.
|
Only needed for live weather, real ADS-B, or markers from an external source.
|
||||||
|
|||||||
+14
-16
@@ -9,13 +9,12 @@ symmetric NATs and restrictive networks; it relays encrypted WebRTC packets and
|
|||||||
never receives application signaling, screen URLs or recordings. The signaling
|
never receives application signaling, screen URLs or recordings. The signaling
|
||||||
service remains the separate loopback API described in `server/README.md`.
|
service remains the separate loopback API described in `server/README.md`.
|
||||||
|
|
||||||
> **Mandatory public-enablement gate:** do not expose the TURN listeners or set
|
> **Mandatory public-enablement gate:** the repository endpoint now requires an
|
||||||
> `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET` in production until the deployed
|
> active, unexpired screen-signaling grant for the exact office/screen binding
|
||||||
> ICE endpoint requires an active, unexpired screen-signaling grant for the exact
|
> and authenticates it to the same signed-in subject. Do not infer that the live
|
||||||
> office/screen binding and authenticates that grant to the same signed-in
|
> deployment has this build or that coturn is ready: prove the deployed cases in
|
||||||
> subject. The current endpoint checks only global member authentication, which
|
> §4, then approve DNS/firewall/certificate/quota changes before exposing TURN
|
||||||
> would let any signed-in member mint a general-purpose Internet relay credential.
|
> listeners or setting `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET`.
|
||||||
> It is intentionally not approved for broad public TURN enablement.
|
|
||||||
|
|
||||||
The production host audited on 2026-08-11 has private VNIC `10.0.0.2`, public
|
The production host audited on 2026-08-11 has private VNIC `10.0.0.2`, public
|
||||||
IPv4 `170.9.14.61`, and an existing `turn.lumbridgecorp.com` A record. Reconfirm
|
IPv4 `170.9.14.61`, and an existing `turn.lumbridgecorp.com` A record. Reconfirm
|
||||||
@@ -108,15 +107,14 @@ process argument. Clear the shell variable after both files are installed:
|
|||||||
unset TURN_SECRET
|
unset TURN_SECRET
|
||||||
```
|
```
|
||||||
|
|
||||||
The current authenticated `POST /api/v1/media/ice` endpoint validates only the
|
The repository `POST /api/v1/media/ice` contract carries an active screen-
|
||||||
signed-in caller and returns a five-minute username/password generated with
|
signaling credential and exact binding in its POST body. The server validates
|
||||||
coturn's REST scheme. That is insufficient authorization for a public relay.
|
its token hash, subject, role, binding, lease and revocation state, then rate-
|
||||||
Before enabling the environment above, the deployed request must also carry an
|
limits issuance independently by subject and trusted client IP. Before enabling
|
||||||
active screen-signaling credential and exact binding in its POST body. The server
|
the environment above, prove that this exact behavior is running on the deployed
|
||||||
must validate its token hash, subject, role, binding, lease and revocation state,
|
API; a source-tree test is not evidence that the host was upgraded. The presenter
|
||||||
then rate-limit issuance by both subject and trusted client IP. The presenter or
|
or viewer must join signaling before requesting ICE configuration. GET/query-
|
||||||
viewer must join signaling before requesting ICE configuration. GET/query-string
|
string credentials remain forbidden.
|
||||||
credentials remain forbidden.
|
|
||||||
|
|
||||||
Coturn removes the expiry prefix from a REST username before applying
|
Coturn removes the expiry prefix from a REST username before applying
|
||||||
`user-quota`. The suffix therefore must be a stable, opaque, session-participant
|
`user-quota`. The suffix therefore must be a stable, opaque, session-participant
|
||||||
|
|||||||
+18
-4
@@ -89,7 +89,11 @@ function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.
|
|||||||
]);
|
]);
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
|
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
|
||||||
geometry.setIndex([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5]);
|
// Counter-clockwise from above. The reverse winding points the generated
|
||||||
|
// normals down, so Three.js culls the whole wing from the chase/flyover view
|
||||||
|
// while still drawing its shadow — leaving only two apparently floating
|
||||||
|
// ailerons around the fuselage.
|
||||||
|
geometry.setIndex([0, 2, 1, 0, 3, 2, 0, 4, 3, 0, 5, 4]);
|
||||||
geometry.computeVertexNormals();
|
geometry.computeVertexNormals();
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
@@ -106,9 +110,14 @@ function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMateria
|
|||||||
hub.rotation.x = Math.PI / 2;
|
hub.rotation.x = Math.PI / 2;
|
||||||
fan.add(hub);
|
fan.add(hub);
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
const angle = index * TWO_PI / 5;
|
||||||
const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`);
|
const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`);
|
||||||
blade.position.y = 0.3;
|
// Place every blade on its own radial spoke. Rotating five differently
|
||||||
blade.rotation.z = index * TWO_PI / 5;
|
// oriented rectangles around the same off-centre point makes a lopsided
|
||||||
|
// paddle; matching centre and local +Y to this angle produces a balanced
|
||||||
|
// rotor whose group can spin continuously around the hub.
|
||||||
|
blade.position.set(-Math.sin(angle) * 0.3, Math.cos(angle) * 0.3, 0);
|
||||||
|
blade.rotation.z = angle;
|
||||||
fan.add(blade);
|
fan.add(blade);
|
||||||
}
|
}
|
||||||
root.add(fan);
|
root.add(fan);
|
||||||
@@ -122,6 +131,9 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}
|
|||||||
const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor);
|
const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor);
|
||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.name = "electric-aircraft";
|
root.name = "electric-aircraft";
|
||||||
|
root.userData.kind = "aircraft";
|
||||||
|
root.userData.aircraftModel = "electric-vtail";
|
||||||
|
root.userData.forwardAxis = "-Z";
|
||||||
|
|
||||||
const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage");
|
const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage");
|
||||||
fuselage.rotation.x = Math.PI / 2;
|
fuselage.rotation.x = Math.PI / 2;
|
||||||
@@ -144,7 +156,9 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}
|
|||||||
leftAileron.name = "electric-aircraft.aileron-left";
|
leftAileron.name = "electric-aircraft.aileron-left";
|
||||||
rightAileron.name = "electric-aircraft.aileron-right";
|
rightAileron.name = "electric-aircraft.aileron-right";
|
||||||
for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) {
|
for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) {
|
||||||
joint.position.set(x, 0.48, 0.72);
|
// On the tapered wing's trailing edge. At 0.72 the outer leading corner
|
||||||
|
// sat behind the tip chord and the bright surface read as a floating bar.
|
||||||
|
joint.position.set(x, 0.48, 0.58);
|
||||||
joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`));
|
joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`));
|
||||||
root.add(joint);
|
root.add(joint);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,15 +168,23 @@ function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): Californi
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkedRoute(route: readonly AircraftWaypoint[] | undefined): readonly AircraftWaypoint[] {
|
function checkedRoute(
|
||||||
|
route: readonly AircraftWaypoint[] | undefined,
|
||||||
|
envelope: CaliforniaFlightEnvelope,
|
||||||
|
): readonly AircraftWaypoint[] {
|
||||||
if (!route) return [];
|
if (!route) return [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
return route.map((point) => {
|
return route.map((point) => {
|
||||||
if (
|
if (
|
||||||
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
|
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
|
||||||
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
|
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
|
||||||
!Number.isFinite(point.altitudeM)
|
!Number.isFinite(point.altitudeM) ||
|
||||||
) throw new RangeError("aircraft route waypoints must be finite with unique ids");
|
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);
|
ids.add(point.id);
|
||||||
return { ...point };
|
return { ...point };
|
||||||
});
|
});
|
||||||
@@ -200,7 +208,7 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
|
|||||||
initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
|
initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
|
||||||
initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
|
initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
|
||||||
mode: value.mode === "manual" ? "manual" : "assisted",
|
mode: value.mode === "manual" ? "manual" : "assisted",
|
||||||
route: checkedRoute(value.route),
|
route: checkedRoute(value.route, envelope),
|
||||||
envelope,
|
envelope,
|
||||||
minimumSpeedMps,
|
minimumSpeedMps,
|
||||||
maximumSpeedMps,
|
maximumSpeedMps,
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ export function buildDog(options: DogBuildOptions = {}): DogRig {
|
|||||||
scale: [1, 0.82, 1],
|
scale: [1, 0.82, 1],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Slightly proud of the skull so the chase camera gets a readable gaze
|
||||||
|
// instead of a blank mask. The tiny warm catchlights remain visible against
|
||||||
|
// every supported coat without introducing another material or texture.
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
const word = side < 0 ? "left" : "right";
|
||||||
|
head.add(
|
||||||
|
actorMesh(`dog.eye.${word}`, new THREE.SphereGeometry(0.023, 8, 6), m.nose, {
|
||||||
|
position: [side * 0.075, 0.025, -0.128],
|
||||||
|
scale: [0.85, 1, 0.58],
|
||||||
|
receiveShadow: false,
|
||||||
|
}),
|
||||||
|
actorMesh(`dog.eye-catchlight.${word}`, new THREE.SphereGeometry(0.006, 6, 4), m.markings, {
|
||||||
|
position: [side * 0.079, 0.031, -0.145],
|
||||||
|
receiveShadow: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const side of [-1, 1] as const) {
|
for (const side of [-1, 1] as const) {
|
||||||
const word = side < 0 ? "left" : "right";
|
const word = side < 0 ? "left" : "right";
|
||||||
const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]);
|
const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]);
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ describe("procedural actor assets", () => {
|
|||||||
assert.ok(size.y <= DOG_METRICS.height + 0.08, `height ${size.y}`);
|
assert.ok(size.y <= DOG_METRICS.height + 0.08, `height ${size.y}`);
|
||||||
assert.ok(size.z >= DOG_METRICS.length - 0.1, `length ${size.z}`);
|
assert.ok(size.z >= DOG_METRICS.length - 0.1, `length ${size.z}`);
|
||||||
assert.equal(rig.root.userData.actorType, "anonymous-dog");
|
assert.equal(rig.root.userData.actorType, "anonymous-dog");
|
||||||
|
for (const side of ["left", "right"] as const) {
|
||||||
|
const eye = rig.root.getObjectByName(`dog.eye.${side}`);
|
||||||
|
const catchlight = rig.root.getObjectByName(`dog.eye-catchlight.${side}`);
|
||||||
|
assert.ok(eye instanceof THREE.Mesh);
|
||||||
|
assert.ok(catchlight instanceof THREE.Mesh);
|
||||||
|
assert.ok(eye.getWorldPosition(new THREE.Vector3()).z < 0, `${side} eye faces -Z`);
|
||||||
|
assert.ok(catchlight.getWorldPosition(new THREE.Vector3()).z < eye.getWorldPosition(new THREE.Vector3()).z);
|
||||||
|
}
|
||||||
|
|
||||||
const clone = cloneDog(rig);
|
const clone = cloneDog(rig);
|
||||||
assertSharedResources(rig.root, clone.root);
|
assertSharedResources(rig.root, clone.root);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ describe("procedural electric aircraft", () => {
|
|||||||
it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
|
it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
|
||||||
const rig = buildElectricAircraft();
|
const rig = buildElectricAircraft();
|
||||||
assert.equal(rig.root.name, "electric-aircraft");
|
assert.equal(rig.root.name, "electric-aircraft");
|
||||||
|
assert.equal(rig.root.userData.forwardAxis, "-Z");
|
||||||
|
assert.equal(rig.root.userData.aircraftModel, "electric-vtail");
|
||||||
assert.equal(rig.fans.length, 2);
|
assert.equal(rig.fans.length, 2);
|
||||||
assert.equal(rig.ownsMaterials, true);
|
assert.equal(rig.ownsMaterials, true);
|
||||||
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
|
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
|
||||||
@@ -32,10 +34,36 @@ describe("procedural electric aircraft", () => {
|
|||||||
assert.ok(size.x > 10);
|
assert.ok(size.x > 10);
|
||||||
assert.ok(size.z > 7);
|
assert.ok(size.z > 7);
|
||||||
assert.ok(size.y > 1.5);
|
assert.ok(size.y > 1.5);
|
||||||
|
assert.ok(rig.leftAileron.position.z <= 0.6, "left aileron remains attached to tapered trailing edge");
|
||||||
|
assert.ok(rig.rightAileron.position.z <= 0.6, "right aileron remains attached to tapered trailing edge");
|
||||||
|
const wing = rig.root.getObjectByName("electric-aircraft:wing");
|
||||||
|
assert.ok(wing instanceof THREE.Mesh);
|
||||||
|
const normals = wing.geometry.getAttribute("normal");
|
||||||
|
assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).every((y) => y > 0),
|
||||||
|
"wing front faces point toward the chase/flyover camera");
|
||||||
disposeElectricAircraft(rig);
|
disposeElectricAircraft(rig);
|
||||||
assert.equal(rig.root.children.length, 0);
|
assert.equal(rig.root.children.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("distributes every fan blade evenly around its hub", () => {
|
||||||
|
const rig = buildElectricAircraft();
|
||||||
|
for (const fan of rig.fans) {
|
||||||
|
const blades = fan.children.filter((child) => child.name.includes(":blade-"));
|
||||||
|
assert.equal(blades.length, 5);
|
||||||
|
const centroid = blades.reduce(
|
||||||
|
(sum, blade) => sum.add(blade.position),
|
||||||
|
new THREE.Vector3(),
|
||||||
|
).multiplyScalar(1 / blades.length);
|
||||||
|
assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`);
|
||||||
|
for (const blade of blades) {
|
||||||
|
assert.ok(Math.abs(blade.position.length() - 0.3) < 1e-12);
|
||||||
|
const radial = new THREE.Vector3(0, 1, 0).applyQuaternion(blade.quaternion);
|
||||||
|
assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disposeElectricAircraft(rig);
|
||||||
|
});
|
||||||
|
|
||||||
it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
|
it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
|
||||||
const rig = buildElectricAircraft();
|
const rig = buildElectricAircraft();
|
||||||
setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
|
setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
|
||||||
@@ -174,6 +202,12 @@ describe("aircraft controller", () => {
|
|||||||
assert.throws(() => new AircraftController({
|
assert.throws(() => new AircraftController({
|
||||||
route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
|
route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
|
||||||
}), /waypoints/);
|
}), /waypoints/);
|
||||||
|
assert.throws(() => new AircraftController({
|
||||||
|
route: [{ id: "outside-california", lat: 45, lng: -118, altitudeM: 1_000 }],
|
||||||
|
}), /waypoints/);
|
||||||
|
assert.throws(() => new AircraftController({
|
||||||
|
route: [{ id: "above-envelope", lat: 37, lng: -122, altitudeM: 8_000 }],
|
||||||
|
}), /waypoints/);
|
||||||
assert.throws(() => new AircraftController({
|
assert.throws(() => new AircraftController({
|
||||||
envelope: {
|
envelope: {
|
||||||
minLat: 40,
|
minLat: 40,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
normalizeVehicleActions,
|
normalizeVehicleActions,
|
||||||
replayVehicleInputs,
|
replayVehicleInputs,
|
||||||
} from "../transport/vehicleController.ts";
|
} from "../transport/vehicleController.ts";
|
||||||
|
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
|
||||||
|
|
||||||
describe("vehicle controller", () => {
|
describe("vehicle controller", () => {
|
||||||
it("normalizes arbitrary adapter input into safe device-neutral actions", () => {
|
it("normalizes arbitrary adapter input into safe device-neutral actions", () => {
|
||||||
@@ -161,6 +162,37 @@ describe("vehicle controller", () => {
|
|||||||
assert.equal(controller.state().elapsedSteps, 0);
|
assert.equal(controller.state().elapsedSteps, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the authored Bay endpoint across completion restore and reverse handoff", () => {
|
||||||
|
const route = buildRoutePath(CALIFORNIA_TRANSPORT, "la-sf-us-101");
|
||||||
|
const endpoint = sampleRoute(route, route.lengthM);
|
||||||
|
assert.ok(Math.abs(endpoint.lat - 37.7749) < 1e-9);
|
||||||
|
assert.ok(Math.abs(endpoint.lng - -122.4194) < 1e-9);
|
||||||
|
|
||||||
|
const restored = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
initialDistanceM: route.lengthM,
|
||||||
|
});
|
||||||
|
assert.equal(restored.state().progress, 1);
|
||||||
|
assert.ok(Math.abs(restored.state().lat - endpoint.lat) < 1e-12);
|
||||||
|
assert.ok(Math.abs(restored.state().lng - endpoint.lng) < 1e-12);
|
||||||
|
|
||||||
|
restored.setRoute("la-sf-i-5", true);
|
||||||
|
assert.equal(restored.state().progress, 1);
|
||||||
|
assert.ok(Math.abs(restored.state().lat - 37.7749) < 1e-9);
|
||||||
|
assert.ok(Math.abs(restored.state().lng - -122.4194) < 1e-9);
|
||||||
|
|
||||||
|
const southbound = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-i-5",
|
||||||
|
direction: -1,
|
||||||
|
initialSpeedMps: 20,
|
||||||
|
});
|
||||||
|
assert.equal(southbound.state().progress, 1);
|
||||||
|
const before = southbound.state().distanceM;
|
||||||
|
southbound.stepFixed({ throttle: 0.5 });
|
||||||
|
assert.ok(southbound.state().distanceM < before);
|
||||||
|
assert.ok(southbound.state().progress < 1 && southbound.state().progress > 0.99);
|
||||||
|
});
|
||||||
|
|
||||||
it("compresses corridor progress without changing vehicle dynamics", () => {
|
it("compresses corridor progress without changing vehicle dynamics", () => {
|
||||||
const normal = new VehicleController(CALIFORNIA_TRANSPORT, {
|
const normal = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
routeId: "la-sf-us-101",
|
routeId: "la-sf-us-101",
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface VehicleControllerState extends GeographicPoint {
|
|||||||
routeId: string;
|
routeId: string;
|
||||||
mode: VehicleControlMode;
|
mode: VehicleControlMode;
|
||||||
direction: 1 | -1;
|
direction: 1 | -1;
|
||||||
/** Distance from the route's declared start, wrapped to its total length. */
|
/** Distance from the route's declared start. A restored endpoint may equal route length. */
|
||||||
distanceM: number;
|
distanceM: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
/** Signed offset from route centre; positive is to the driver's right. */
|
/** Signed offset from route centre; positive is to the driver's right. */
|
||||||
@@ -212,6 +212,9 @@ export class VehicleController {
|
|||||||
this.pack = pack;
|
this.pack = pack;
|
||||||
this.options = resolveOptions(options);
|
this.options = resolveOptions(options);
|
||||||
this.path = buildRoutePath(pack, this.options.routeId);
|
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 sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction);
|
||||||
const point = offsetPoint(sample, 0);
|
const point = offsetPoint(sample, 0);
|
||||||
this.current = {
|
this.current = {
|
||||||
@@ -257,7 +260,12 @@ export class VehicleController {
|
|||||||
/** Restore the configured spawn state and clear pending fractional time. */
|
/** Restore the configured spawn state and clear pending fractional time. */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.accumulator = 0;
|
this.accumulator = 0;
|
||||||
const distanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
|
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(
|
const lateralOffsetM = clamp(
|
||||||
this.options.initialLateralOffsetM,
|
this.options.initialLateralOffsetM,
|
||||||
-this.options.guardrailOffsetM,
|
-this.options.guardrailOffsetM,
|
||||||
|
|||||||
@@ -116,7 +116,14 @@ function wrap(value: number, modulus: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
|
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
|
||||||
const travelled = wrap(distanceM, path.lengthM);
|
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);
|
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`);
|
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 t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
|
||||||
|
|||||||
Reference in New Issue
Block a user