1
0

fix: close California play and asset acceptance gaps

This commit is contained in:
2026-08-11 22:49:37 -07:00
parent d4859b33c6
commit dd18c6775d
12 changed files with 267 additions and 126 deletions
+54 -64
View File
@@ -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
View File
@@ -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 1015 Hz for nearby dynamic actors; media never rides - Delta snapshots around 1015 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, M0M7 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.
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }
+12 -4
View File
@@ -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,
+17
View File
@@ -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]);
+8
View File
@@ -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);
+34
View File
@@ -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,
+32
View File
@@ -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",
+10 -2
View File
@@ -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,
+8 -1
View File
@@ -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));