diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b2805ae..2f3f6a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -66,7 +66,7 @@ offices are the inside, and a tenant moves between them. ## 2. Layout ``` -lumbridge-simulate/ +tera/ ├── src/engine/ # renderer. Knows about terrain, blocks, streets, flights. │ ├── types.ts # City, District, Hill, Marker, FlightSource │ ├── world.ts # projection + cached heightfield, built per city @@ -83,8 +83,10 @@ lumbridge-simulate/ │ ├── california.ts # state/corridor scale; LA ↔ SF continuity │ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks │ └── socal.ts # LA / OC / Riverside -├── src/adapters/ # where outside data plugs in -│ └── workie.ts # Workie API -> Marker[] +├── src/realtime/ # strict game-state wire/client/interpolation adapters +├── 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 ``` @@ -102,8 +104,8 @@ knows nothing about terrain specifically. `transport` also stays outside the renderer. A `TransportPack` is plain JSON and `VehicleSimulation` returns plain geographic poses. Three.js enters only in the `roadTraffic` render layer, which projects poses through the active `World`. -That separation lets a future authoritative session server replay the same -route simulation without importing a browser or a GPU. +That separation lets the authoritative session service validate and replay the +same route state without importing a browser or a GPU. ### 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. The public Tera build remains zero-config and useful without any service. Hosted -presence, multiplayer, profiles, webcam faces, and media surfaces arrive through -adapters whose absence cannot stop the city, office, traffic, or sky from -running. This is the replacement path for the retired Phaser World 1; it is not -a migration of that renderer. +presence, multiplayer and remote media arrive through adapters whose absence +cannot stop the city, office, traffic, or sky from running. Local profiles and +webcam faces require no hosted service; webcam pixels never enter the realtime +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, 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 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 -shown. Game-state deltas never carry webcam or screen media; those remain -separate consent and authorization lifecycles. +shown. Game-state deltas never carry webcam or screen media. Webcam faces are +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. -- Logos are fetched **at runtime**, client-side, and cached in the browser. -- The repo carries a fetch script and zero logo files. `public/logos/` is - `.gitignore`d, and CI fails if anything lands there. +- The shipped application does not fetch or display third-party logos. +- The repo carries zero logo files. Self-hoster asset directories are ignored, + 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 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. -``` -Workie GET /api/live/markers (private, tailnet) -> status colours -Workie GET /api/public/markers (public, allowlist) -> sector colours - | - v - adapters/workie.ts -> Marker[] - | - v - engine/markers.ts -``` +Marker rows cross the strict public-shape and provenance allowlist before the +renderer sees them. The renderer receives only `Marker[]` and never pipeline or +tenant records. Realtime peers receive opaque page-scoped actor identifiers and +validated pose state, never auth subjects or profile faces. Screen media is +peer-to-peer: the API validates exact authored screen bindings and relays bounded +signaling, but it never receives media bytes, recordings or source locators. -The public endpoint goes through the **same `export-site.ts` field allowlist** -that already guards radar.karti.ai and work.karti.ai — the one that is -fail-closed and aborts a deploy rather than shipping an unknown field. No second -implementation of that gate. That was the argument for keeping `/live` inside -Workie originally, and it still holds — but only for the *data path*. The -renderer left; the gate stayed where it was. - -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. +The currently implemented hosted authorization is deployment-member plus global +admin. It is deliberately not described as tenant/office membership. A control- +plane membership provider must be added before private tenant offices share the +same deployment authority boundary. TURN is also optional and fail-closed; the +credential endpoint being implemented does not mean a public relay is deployed +or approved. --- @@ -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 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 -earning its keep. +That phase is now implemented: offices consume the same procedural library and +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; - delete Workie's `/live`. *(this commit)* -2. **Markers + simulated flights.** A demo worth showing, with no data pipeline - and no licence questions. *(this commit)* -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.** +The repository now ships California corridor, Bay Area and Southern California +boards; walkable offices; procedural actors, vehicles and aircraft; optional +authoritative realtime; local webcam faces; and separately authorized office +screen signaling. `BUILD_PLAN.md` is the milestone evidence ledger. -## 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 - straight from Gitea is simpler until someone outside asks for a registry. -2. **Licence for the asset library specifically.** Apache 2.0 covers code - cleanly; art is sometimes better served by CC0 or CC-BY so it can be reused - outside software. Worth deciding before the first mesh lands, because - relicensing art after contributors exist is painful. -3. **How much of World 1 moves at once.** The Phaser world is live and has real - tenants; the city view can ship at `/live` well before any office does. +Remaining questions are deployment questions, not missing renderer contracts: + +1. Which authoritative control-plane provider proves tenant/office membership? +2. When do operators approve public TURN/DNS/firewall/certificate changes after + relay-only and abuse-boundary acceptance? +3. Which Firefox/WebKit versions become supported browser-test targets? +4. Which additional worlds or optional asset packs enter through the same + provenance and performance gates? diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 7052f36..5323c6d 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -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 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. - Customizable procedural humanoid for signed-in members. - 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 -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 10 Hz publishing, strict motion validation, reconnect snapshots, and aggregate- only presence UI. Deterministic two-client and bounded-service acceptance tests cover convergence, late join, cell isolation, ownership, revocation, cleanup, 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, 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 on this socket. -Exit gate: two browsers converge within the declared latency; reconnect restores -the right cell; revocation ejects promptly; malformed and speed-hack state is -rejected; selected cell concurrency passes a repeatable load test. +Automated gate: two logical clients converge; reconnect restores the right cell; +revocation ejects promptly; malformed and speed-hack state is rejected; bounded +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 @@ -129,19 +136,24 @@ is torn down on Stop, track end, revocation, or page exit. - Generated profile face is the default. - Webcam face is opt-in and ephemeral: explicit contextual permission, active indicator, one-click stop, no recording/storage by default. -- Server-enforced visibility capabilities; unauthorized clients never receive - the private object or track. +- Webcam pixels are local to the current browser and are never placed on the + 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 -cannot subscribe; the full product works without a camera. +Exit gate: Stop, permission cancellation, track end, membership revocation and +`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 -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; presenters choose a tab/window through the browser prompt and have an immediate -kill switch. Signaling grants, TURN grants, video textures, late join, reconnect, -and revoke lifecycle are bounded and tested. Presenter encoding defaults to +kill switch. Signaling grants, conditional TURN credential issuance, video +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. - `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. 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 @@ -161,9 +178,8 @@ procedural electric V-tail has deterministic assisted/manual control, chase camera, California bounds, and an authoritative geographic wire adapter; remote clients render its bank, control surfaces, and fan phase from validated state. -Keep today's aircraft ambient until vehicle, actor, camera, and network -abstractions are proven. A later `PlayableAircraft` reuses flight-source data for -the world but owns a separate controller and authority model. +Ambient ADS-B rendering remains a separate source and authority path from the +playable aircraft controller. Exit gate: route/altitude bounds, camera and control handoff, multiplayer 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 the selected supported mobile tier. -- Hard budgets per scale for resident cells, triangles, draw calls, dynamic - actors, GPU memory, and media textures. +- Enforced browser budgets currently cover p95 frame interval, draw calls and + 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, frustum/distance culling, and explicit resource disposal. - Degrade shadows, traffic density, and far detail before input, access control, identity, or privacy enforcement. -## Immediate parallel build +## Release boundary and operator gates -The California driving vertical slice now proves the transport, vehicle, -controller, input, camera, and responsive UI contracts. Continue in these lanes: +M0–M7 describe the repository implementation, not an assertion that every +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 - transitions, and deterministic route-completion scenarios. -2. **Office:** integrate the walker with first-/third-person cameras, doors, - actor possession, and identity-preserving city ↔ office transitions. -3. **Assets:** add driver-view interior hints and animation/state adapters for - the existing humanoid, dog, and crow rigs; keep the code-only asset pipeline. -4. **Platform:** define versioned launch-grant, session, interest-zone, actor, - vehicle, and media-capability schemas plus their threat model—without coupling - the renderer to a hosted service. -5. **Quality:** add repeatable frame-time/draw-call budgets, screenshot baselines, - long-route and office soak tests, asset-manifest CI, and cross-browser input - coverage. +Before enabling hosted tenant use or advertising internet-grade screen sharing: + +1. Provide authoritative tenant/office membership; current realtime/media auth + proves only deployment membership. +2. Complete a real two-browser/two-network realtime and media acceptance run, + including revocation and reconnect. +3. Keep TURN ports and `TERA_ICE_URLS`/`TERA_TURN_SHARED_SECRET` disabled until + every mandatory gate in `deploy/coturn/README.md` passes. +4. Apply and verify the documented CSP and Permissions-Policy on both entry + hosts. Browser capture permission is contextual and does not replace the + application's explicit opt-in and visible Stop controls. +5. Treat narrower CSP allowlists, GPU/media memory budgets and Firefox/WebKit + browser automation as hardening/follow-up work, not already-passed gates. diff --git a/deploy/STATIC.md b/deploy/STATIC.md index c429217..3931c3e 100644 --- a/deploy/STATIC.md +++ b/deploy/STATIC.md @@ -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 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 Only needed for live weather, real ADS-B, or markers from an external source. diff --git a/deploy/coturn/README.md b/deploy/coturn/README.md index 8b4a6ec..ff5836e 100644 --- a/deploy/coturn/README.md +++ b/deploy/coturn/README.md @@ -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 service remains the separate loopback API described in `server/README.md`. -> **Mandatory public-enablement gate:** do not expose the TURN listeners or set -> `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET` in production until the deployed -> ICE endpoint requires an active, unexpired screen-signaling grant for the exact -> office/screen binding and authenticates that grant to the same signed-in -> subject. The current endpoint checks only global member authentication, which -> would let any signed-in member mint a general-purpose Internet relay credential. -> It is intentionally not approved for broad public TURN enablement. +> **Mandatory public-enablement gate:** the repository endpoint now requires an +> active, unexpired screen-signaling grant for the exact office/screen binding +> and authenticates it to the same signed-in subject. Do not infer that the live +> deployment has this build or that coturn is ready: prove the deployed cases in +> §4, then approve DNS/firewall/certificate/quota changes before exposing TURN +> listeners or setting `TERA_ICE_URLS` / `TERA_TURN_SHARED_SECRET`. 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 @@ -108,15 +107,14 @@ process argument. Clear the shell variable after both files are installed: unset TURN_SECRET ``` -The current authenticated `POST /api/v1/media/ice` endpoint validates only the -signed-in caller and returns a five-minute username/password generated with -coturn's REST scheme. That is insufficient authorization for a public relay. -Before enabling the environment above, the deployed request must also carry an -active screen-signaling credential and exact binding in its POST body. The server -must validate its token hash, subject, role, binding, lease and revocation state, -then rate-limit issuance by both subject and trusted client IP. The presenter or -viewer must join signaling before requesting ICE configuration. GET/query-string -credentials remain forbidden. +The repository `POST /api/v1/media/ice` contract carries an active screen- +signaling credential and exact binding in its POST body. The server validates +its token hash, subject, role, binding, lease and revocation state, then rate- +limits issuance independently by subject and trusted client IP. Before enabling +the environment above, prove that this exact behavior is running on the deployed +API; a source-tree test is not evidence that the host was upgraded. The presenter +or viewer must join signaling before requesting ICE configuration. GET/query- +string credentials remain forbidden. Coturn removes the expiry prefix from a REST username before applying `user-quota`. The suffix therefore must be a stable, opaque, session-participant diff --git a/src/aircraft/asset.ts b/src/aircraft/asset.ts index 47ca5ed..29aa3f2 100644 --- a/src/aircraft/asset.ts +++ b/src/aircraft/asset.ts @@ -89,7 +89,11 @@ function wingGeometry(span: number, rootChord: number, tipChord: number): THREE. ]); const geometry = new THREE.BufferGeometry(); 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(); return geometry; } @@ -106,9 +110,14 @@ function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMateria hub.rotation.x = Math.PI / 2; fan.add(hub); 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}`); - blade.position.y = 0.3; - blade.rotation.z = index * TWO_PI / 5; + // Place every blade on its own radial spoke. Rotating five differently + // 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); } root.add(fan); @@ -122,6 +131,9 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {} const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor); const root = new THREE.Group(); 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"); fuselage.rotation.x = Math.PI / 2; @@ -144,7 +156,9 @@ export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {} leftAileron.name = "electric-aircraft.aileron-left"; rightAileron.name = "electric-aircraft.aileron-right"; 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`)); root.add(joint); } diff --git a/src/aircraft/controller.ts b/src/aircraft/controller.ts index 52d6eb0..14115ac 100644 --- a/src/aircraft/controller.ts +++ b/src/aircraft/controller.ts @@ -168,15 +168,23 @@ function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): Californi return envelope; } -function checkedRoute(route: readonly AircraftWaypoint[] | undefined): readonly AircraftWaypoint[] { +function checkedRoute( + route: readonly AircraftWaypoint[] | undefined, + envelope: CaliforniaFlightEnvelope, +): readonly AircraftWaypoint[] { if (!route) return []; const ids = new Set(); return route.map((point) => { if ( typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) || !Number.isFinite(point.lat) || !Number.isFinite(point.lng) || - !Number.isFinite(point.altitudeM) - ) throw new RangeError("aircraft route waypoints must be finite with unique ids"); + !Number.isFinite(point.altitudeM) || + point.lat < envelope.minLat || point.lat > envelope.maxLat || + point.lng < envelope.minLng || point.lng > envelope.maxLng || + point.altitudeM < envelope.minAltitudeM || point.altitudeM > envelope.maxAltitudeM + ) throw new RangeError( + "aircraft route waypoints must be finite, unique, and inside the flight envelope", + ); ids.add(point.id); return { ...point }; }); @@ -200,7 +208,7 @@ function resolveOptions(value: AircraftControllerOptions): ResolvedOptions { initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)), initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps), mode: value.mode === "manual" ? "manual" : "assisted", - route: checkedRoute(value.route), + route: checkedRoute(value.route, envelope), envelope, minimumSpeedMps, maximumSpeedMps, diff --git a/src/assets/actors/dog.ts b/src/assets/actors/dog.ts index e0b5472..61f2095 100644 --- a/src/assets/actors/dog.ts +++ b/src/assets/actors/dog.ts @@ -104,6 +104,23 @@ export function buildDog(options: DogBuildOptions = {}): DogRig { 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) { const word = side < 0 ? "left" : "right"; const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]); diff --git a/src/test/actorAssets.test.ts b/src/test/actorAssets.test.ts index 75e3326..310ee5f 100644 --- a/src/test/actorAssets.test.ts +++ b/src/test/actorAssets.test.ts @@ -64,6 +64,14 @@ describe("procedural actor assets", () => { 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.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); assertSharedResources(rig.root, clone.root); diff --git a/src/test/aircraft.test.ts b/src/test/aircraft.test.ts index 0e10afa..77cf62a 100644 --- a/src/test/aircraft.test.ts +++ b/src/test/aircraft.test.ts @@ -24,6 +24,8 @@ describe("procedural electric aircraft", () => { it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => { const rig = buildElectricAircraft(); 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.ownsMaterials, true); 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.z > 7); 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); 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", () => { const rig = buildElectricAircraft(); setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 }); @@ -174,6 +202,12 @@ describe("aircraft controller", () => { assert.throws(() => new AircraftController({ route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }], }), /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({ envelope: { minLat: 40, diff --git a/src/test/vehicleController.test.ts b/src/test/vehicleController.test.ts index d66067a..6925fca 100644 --- a/src/test/vehicleController.test.ts +++ b/src/test/vehicleController.test.ts @@ -6,6 +6,7 @@ import { normalizeVehicleActions, replayVehicleInputs, } from "../transport/vehicleController.ts"; +import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts"; describe("vehicle controller", () => { it("normalizes arbitrary adapter input into safe device-neutral actions", () => { @@ -161,6 +162,37 @@ describe("vehicle controller", () => { 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", () => { const normal = new VehicleController(CALIFORNIA_TRANSPORT, { routeId: "la-sf-us-101", diff --git a/src/transport/vehicleController.ts b/src/transport/vehicleController.ts index e94f21c..a6c0739 100644 --- a/src/transport/vehicleController.ts +++ b/src/transport/vehicleController.ts @@ -72,7 +72,7 @@ export interface VehicleControllerState extends GeographicPoint { routeId: string; mode: VehicleControlMode; 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; progress: number; /** Signed offset from route centre; positive is to the driver's right. */ @@ -212,6 +212,9 @@ export class VehicleController { this.pack = pack; this.options = resolveOptions(options); this.path = buildRoutePath(pack, this.options.routeId); + if (options.initialDistanceM === undefined && this.options.direction === -1) { + this.options.initialDistanceM = this.path.lengthM; + } const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction); const point = offsetPoint(sample, 0); this.current = { @@ -257,7 +260,12 @@ export class VehicleController { /** Restore the configured spawn state and clear pending fractional time. */ reset(): void { 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( this.options.initialLateralOffsetM, -this.options.guardrailOffsetM, diff --git a/src/transport/vehicleSim.ts b/src/transport/vehicleSim.ts index 6fcd1d2..a4b419d 100644 --- a/src/transport/vehicleSim.ts +++ b/src/transport/vehicleSim.ts @@ -116,7 +116,14 @@ function wrap(value: number, modulus: number): number { } 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); 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));