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
```
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?
+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
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 1015 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:
M0M7 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.
+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
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.
+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
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
+18 -4
View File
@@ -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);
}
+12 -4
View File
@@ -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<string>();
return route.map((point) => {
if (
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
!Number.isFinite(point.altitudeM)
) 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,
+17
View File
@@ -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]);
+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.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);
+34
View File
@@ -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,
+32
View File
@@ -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",
+10 -2
View File
@@ -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,
+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 {
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));