1
0

feat: complete actor handoff and piloted aircraft presence

This commit is contained in:
2026-08-11 22:17:06 -07:00
parent 655c383061
commit 3326d2e6d0
20 changed files with 907 additions and 41 deletions
+24 -5
View File
@@ -34,16 +34,22 @@ ahead; realtime/media must not jump ahead of the actor and access contracts.
## M0 — contracts and provenance ## M0 — contracts and provenance
Status: **in progress**. Status: **implemented**.
- Tera/Spaces is the renderer; `lumbridgecorp` is the control plane. - Tera/Spaces is the renderer; `lumbridgecorp` is the control plane.
- Apache-2.0 remains the outbound code license. - Apache-2.0 remains the outbound code license.
- Every copied asset/data item gets a pinned source, license, hash, and intake - Every copied asset/data item gets a pinned source, license, hash, and intake
note. Original procedural assets record their design lineage. note. Original procedural assets record their design lineage.
- Add an automated asset-manifest gate, dependency license allowlist, and SBOM. - `PROVENANCE.json` plus `npm run provenance` hash-gates every tracked
distribution artifact, rejects undeclared copied items and remote/bundled
fonts, and records original procedural/data lineage.
- `npm run licenses` enforces the reviewed dependency-license allowlist and
registry integrity pins; `npm run sbom` emits SPDX 2.3 from the lockfile.
Exit gate: a clean clone builds and tests without private services or binary Exit gate: a clean clone builds and tests without private services or binary
art; every shipped dependency, route, font, and asset has recorded provenance. runtime art; every shipped dependency, route, font, and asset has recorded
provenance. The only tracked raster art is the two generated social cards in
`public/`, both source-reproducible and SHA-256 pinned in the manifest.
## M1 — California roads and passive Model X traffic ## M1 — California roads and passive Model X traffic
@@ -78,8 +84,9 @@ tunneling, or tab-resume jumps; a recorded input trace replays identically.
## M3 — walkable offices and actors ## M3 — walkable offices and actors
Status: **collision/controller foundation and procedural actor assets Status: **implemented and integrated**. City actors and office walkers share the
implemented; office camera/input integration and identity handoff pending**. 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. - First-/third-person walker against the existing `Plan.blocked` wall segments.
- Door traversal and city ↔ office identity handoff. - Door traversal and city ↔ office identity handoff.
@@ -93,6 +100,13 @@ 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**.
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 realtime session service; do not merge it into the control plane.
- 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.
@@ -128,6 +142,11 @@ textures; bandwidth adaptation and safe public placeholders work.
## M7 — playable aircraft ## M7 — playable aircraft
Status: **solo and hosted multiplayer play are implemented**. The original
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 Keep today's aircraft ambient until vehicle, actor, camera, and network
abstractions are proven. A later `PlayableAircraft` reuses flight-source data for abstractions are proven. A later `PlayableAircraft` reuses flight-source data for
the world but owns a separate controller and authority model. the world but owns a separate controller and authority model.
+4
View File
@@ -85,6 +85,10 @@ THIRD-PARTY DEPENDENCIES
three.js — MIT License — Copyright (c) 2010-2026 three.js authors three.js — MIT License — Copyright (c) 2010-2026 three.js authors
https://github.com/mrdoob/three.js https://github.com/mrdoob/three.js
satellite.js — MIT License — Copyright (C) 2013 Shashwat Kandadai,
UCSC Jack Baskin School of Engineering
https://github.com/shashwatak/satellite-js
Build tooling (Vite, TypeScript, esbuild) is a development dependency only and Build tooling (Vite, TypeScript, esbuild) is a development dependency only and
is not redistributed in built output. is not redistributed in built output.
+86
View File
@@ -0,0 +1,86 @@
{
"schemaVersion": 1,
"projectLicense": "Apache-2.0",
"policies": {
"copiedItems": "Every copied item requires an immutable upstream source, exact upstream path, SPDX license, SHA-256, intake date, and review note.",
"fonts": "System font stacks only; no bundled font files or remote font services.",
"runtimeArt": "Runtime meshes and textures are generated from reviewable source. Binary art is not permitted under src/.",
"researchReferences": "Evaluated references that were not copied are recorded in ASSET_RESEARCH.md and are not distributed artifacts."
},
"distributedArtifacts": [
{
"path": "public/og-office.png",
"kind": "generated-social-card",
"origin": "repository-generated",
"license": "Apache-2.0",
"sha256": "5906c7ef22175307f18984525ae5c7bd7fe6f40eb2652a850d3cb44b44ba8228",
"generator": "scripts/brand-assets/capture.mjs",
"inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"],
"intakeDate": "2026-08-06",
"intakeNote": "Generated from this repository's built office scene and local card template by the documented capture workflow; no external image was copied."
},
{
"path": "public/og-tera.png",
"kind": "generated-social-card",
"origin": "repository-generated",
"license": "Apache-2.0",
"sha256": "decfd9b70c0416baf47b0484040079c7f2aaa7bb8a60cf5add7a3069eac05277",
"generator": "scripts/brand-assets/capture.mjs",
"inputs": ["scripts/brand-assets/capture.mjs", "scripts/brand-assets/og.html"],
"intakeDate": "2026-08-06",
"intakeNote": "Generated from this repository's built California scene and local card template by the documented capture workflow; no external image was copied."
}
],
"proceduralLineage": [
{
"path": "src/assets/",
"kind": "procedural-vehicles-actors-office-kit-and-textures",
"origin": "original",
"sourceLicense": "Apache-2.0",
"outputLicense": "CC0-1.0",
"lineage": "ASSET_RESEARCH.md and src/assets/LICENSE-ART document the original primitive grammar, design references, and artistic-output dedication."
},
{
"path": "src/aircraft/asset.ts",
"kind": "procedural-aircraft",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original code-composed aircraft silhouette; repository originality and aircraft-data boundaries are documented in NOTICE."
},
{
"path": "src/engine/",
"kind": "procedural-map-environment-buildings-and-data-glyphs",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original cached unit-primitive rendering grammar documented in ASSET_RESEARCH.md and ARCHITECTURE.md; live data is converted to original glyph geometry rather than bundled as art."
},
{
"path": "src/interiors/",
"kind": "procedural-office-shells-and-presentation",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original office shell and scene composition over the validated authored packs; geometry is built at runtime from reviewable source and primitive assets."
},
{
"path": "src/cities/",
"kind": "authored-city-geography",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original hand-traced city geometry with the non-OSM data boundary documented in NOTICE and source headers."
},
{
"path": "src/offices/",
"kind": "authored-office-packs",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Repository-authored office plans; each pack carries explicit author and license metadata under the validated office-pack contract."
},
{
"path": "src/transport/california.ts",
"kind": "authored-road-and-route-data",
"origin": "original",
"sourceLicense": "Apache-2.0",
"lineage": "Original coarse simulation graph; every node, segment, route, and anchor carries a provenance field validated by tests."
}
]
}
+4 -1
View File
@@ -115,7 +115,10 @@ Two rules, and they are not stylistic:
- **Do not commit logos or brand assets.** They are trademarks, not code. - **Do not commit logos or brand assets.** They are trademarks, not code.
See [ARCHITECTURE.md](ARCHITECTURE.md) §3 for the full reasoning, and See [ARCHITECTURE.md](ARCHITECTURE.md) §3 for the full reasoning, and
[NOTICE](NOTICE) for the attribution and data-provenance statement. [NOTICE](NOTICE) for the attribution and data-provenance statement, and
[PROVENANCE.json](PROVENANCE.json) for the machine-checked shipped-artifact and
original procedural-lineage ledger. Run `npm run provenance`, `npm run licenses`,
and `npm run sbom` before accepting assets or dependencies.
## Aircraft ## Aircraft
+1
View File
@@ -11,6 +11,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs",
"licenses": "node scripts/check-dependency-licenses.mjs", "licenses": "node scripts/check-dependency-licenses.mjs",
"sbom": "npm sbom --package-lock-only --sbom-format=spdx", "sbom": "npm sbom --package-lock-only --sbom-format=spdx",
"test": "node --test \"src/test/*.test.ts\"", "test": "node --test \"src/test/*.test.ts\"",
+8
View File
@@ -48,6 +48,14 @@ for (const [path, entry] of Object.entries(lock.packages ?? {})) {
if (typeof resolved === "string" && /^(?:git\+|github:|gitlab:|bitbucket:)/i.test(resolved)) { if (typeof resolved === "string" && /^(?:git\+|github:|gitlab:|bitbucket:)/i.test(resolved)) {
problems.push(`${path}: source dependency ${resolved} is not a registry artifact`); problems.push(`${path}: source dependency ${resolved} is not a registry artifact`);
} }
if (path.startsWith("node_modules/") && entry.link !== true) {
if (typeof resolved !== "string" || !/^https:\/\/registry\.npmjs\.org\//.test(resolved)) {
problems.push(`${path}: dependency is not pinned to the npm registry in package-lock.json`);
}
if (typeof entry.integrity !== "string" || !/^sha(?:256|384|512)-/.test(entry.integrity)) {
problems.push(`${path}: dependency has no recognized lockfile integrity hash`);
}
}
} }
if (problems.length > 0) { if (problems.length > 0) {
+6
View File
@@ -44,6 +44,9 @@ const DENIED_EXTENSIONS = new Set([
".jpg", ".jpg",
".jpeg", ".jpeg",
".webp", ".webp",
".gif",
".svg",
".ico",
".hdr", ".hdr",
".exr", ".exr",
".ttf", ".ttf",
@@ -52,6 +55,9 @@ const DENIED_EXTENSIONS = new Set([
".woff2", ".woff2",
".mp3", ".mp3",
".wav", ".wav",
".ogg",
".mp4",
".webm",
".fbx", ".fbx",
".obj", ".obj",
]); ]);
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* Distribution provenance gate.
*
* Every tracked media/model/font artifact must be listed exactly once in
* PROVENANCE.json and match its recorded SHA-256. Original generated artifacts
* must name a tracked generator and lineage note. A copied item additionally
* needs an immutable upstream revision, exact upstream path, and intake record.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, extname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const manifestPath = resolve(root, "PROVENANCE.json");
const ARTIFACT_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".ico",
".glb", ".gltf", ".fbx", ".obj", ".hdr", ".exr",
".ttf", ".otf", ".woff", ".woff2",
".mp3", ".wav", ".ogg", ".mp4", ".webm",
]);
const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".woff", ".woff2"]);
const ALLOWED_ARTIFACT_LICENSES = new Set([
"Apache-2.0", "BSD-3-Clause", "CC0-1.0", "CC-BY-4.0", "ISC", "MIT",
]);
const problems = [];
function trackedFiles() {
try {
return execFileSync("git", ["ls-files", "-z"], {
cwd: root,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
}).split("\0").filter(Boolean);
} catch {
console.error("check-provenance: FAIL — run this gate inside a git checkout.");
process.exit(1);
}
}
function nonEmpty(value) {
return typeof value === "string" && value.trim().length > 0;
}
function validDate(value) {
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
}
function hash(path) {
return createHash("sha256").update(readFileSync(resolve(root, path))).digest("hex");
}
function pathIsCovered(path, tracked) {
if (path.endsWith("/")) return [...tracked].some((candidate) => candidate.startsWith(path));
return tracked.has(path);
}
let manifest;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch (error) {
console.error(`check-provenance: FAIL — cannot parse PROVENANCE.json (${String(error)}).`);
process.exit(1);
}
const tracked = new Set(trackedFiles());
const artifacts = [...tracked].filter((path) => ARTIFACT_EXTENSIONS.has(extname(path).toLowerCase()));
const records = Array.isArray(manifest.distributedArtifacts) ? manifest.distributedArtifacts : [];
const recordCounts = new Map();
if (manifest.schemaVersion !== 1) problems.push("schemaVersion must be 1");
if (!nonEmpty(manifest.projectLicense)) problems.push("projectLicense is required");
for (const [index, record] of records.entries()) {
const at = `distributedArtifacts[${index}]`;
if (!nonEmpty(record?.path)) {
problems.push(`${at}: path is required`);
continue;
}
recordCounts.set(record.path, (recordCounts.get(record.path) ?? 0) + 1);
if (!tracked.has(record.path)) problems.push(`${at}: ${record.path} is not a tracked file`);
if (!ARTIFACT_EXTENSIONS.has(extname(record.path).toLowerCase())) {
problems.push(`${at}: ${record.path} is not a recognized distribution artifact`);
}
if (!nonEmpty(record.kind)) problems.push(`${at}: kind is required`);
if (!ALLOWED_ARTIFACT_LICENSES.has(record.license)) {
problems.push(`${at}: license ${String(record.license)} is not in the reviewed allowlist`);
}
if (!/^[a-f0-9]{64}$/.test(record.sha256 ?? "")) problems.push(`${at}: sha256 must be 64 lowercase hex characters`);
else if (tracked.has(record.path) && hash(record.path) !== record.sha256) problems.push(`${at}: SHA-256 mismatch for ${record.path}`);
if (!validDate(record.intakeDate)) problems.push(`${at}: intakeDate must be YYYY-MM-DD`);
if (!nonEmpty(record.intakeNote)) problems.push(`${at}: intakeNote is required`);
if (record.origin === "repository-generated") {
if (!nonEmpty(record.generator) || !tracked.has(record.generator)) {
problems.push(`${at}: repository-generated artifact requires a tracked generator`);
}
if (!Array.isArray(record.inputs) || record.inputs.length === 0 ||
record.inputs.some((input) => !nonEmpty(input) || !tracked.has(input))) {
problems.push(`${at}: repository-generated artifact requires non-empty tracked inputs`);
}
if (record.source !== undefined) problems.push(`${at}: repository-generated artifact must not claim an upstream source`);
} else if (record.origin === "copied") {
const source = record.source;
if (!nonEmpty(source?.url) || !/^https:\/\//.test(source.url)) problems.push(`${at}: copied item requires an HTTPS source URL`);
if (!nonEmpty(source?.upstreamPath)) problems.push(`${at}: copied item requires its exact upstreamPath`);
if (!nonEmpty(source?.revision) || !/^[a-f0-9]{40,64}$/i.test(source.revision)) {
problems.push(`${at}: copied item requires an immutable 40-64 hex upstream revision`);
} else if (nonEmpty(source?.url) && !source.url.toLowerCase().includes(source.revision.toLowerCase())) {
problems.push(`${at}: copied item source URL must be pinned to its recorded revision`);
}
} else {
problems.push(`${at}: origin must be repository-generated or copied`);
}
}
for (const artifact of artifacts) {
const count = recordCounts.get(artifact) ?? 0;
if (count !== 1) problems.push(`${artifact}: expected exactly one manifest record, found ${count}`);
}
for (const [path, count] of recordCounts) {
if (count !== 1) problems.push(`${path}: duplicate manifest records (${count})`);
}
const lineage = Array.isArray(manifest.proceduralLineage) ? manifest.proceduralLineage : [];
if (lineage.length === 0) problems.push("proceduralLineage must contain at least one original-source record");
for (const [index, record] of lineage.entries()) {
const at = `proceduralLineage[${index}]`;
if (!nonEmpty(record?.path) || !pathIsCovered(record.path, tracked)) problems.push(`${at}: path must identify tracked source`);
if (record?.origin !== "original") problems.push(`${at}: origin must be original`);
if (!nonEmpty(record?.kind)) problems.push(`${at}: kind is required`);
if (record?.sourceLicense !== "Apache-2.0") problems.push(`${at}: sourceLicense must be Apache-2.0`);
if (!nonEmpty(record?.lineage)) problems.push(`${at}: lineage is required`);
}
const trackedFonts = artifacts.filter((path) => FONT_EXTENSIONS.has(extname(path).toLowerCase()));
const codeFiles = [...tracked].filter((path) =>
path !== "scripts/check-provenance.mjs" && /\.(?:css|html|js|mjs|ts|tsx)$/.test(path)
);
for (const path of codeFiles) {
const text = readFileSync(resolve(root, path), "utf8");
if (/@font-face\b/i.test(text) || /fonts\.(?:googleapis|gstatic)\.com/i.test(text)) {
problems.push(`${path}: bundled/remote web fonts are forbidden by the system-font policy`);
}
}
if (problems.length > 0) {
console.error("\ncheck-provenance: FAIL\n");
for (const problem of problems) console.error(` ${problem}`);
console.error("");
process.exit(1);
}
console.log(`check-provenance: ok — ${artifacts.length} tracked distribution artifacts are hash-manifested.`);
console.log(` ${records.filter((record) => record.origin === "copied").length} copied items; ${lineage.length} original procedural/data lineage records.`);
console.log(` ${trackedFonts.length} bundled fonts; system-font/no-remote-font policy verified.`);
+27 -3
View File
@@ -37,6 +37,7 @@ export interface RealtimeServiceOptions {
rateWindowMs?: number; rateWindowMs?: number;
actorMotionLimits?: MotionLimits; actorMotionLimits?: MotionLimits;
vehicleMotionLimits?: MotionLimits; vehicleMotionLimits?: MotionLimits;
aircraftMotionLimits?: MotionLimits;
} }
export interface RealtimeJoinInput { export interface RealtimeJoinInput {
@@ -103,6 +104,7 @@ interface ResolvedOptions {
rateWindowMs: number; rateWindowMs: number;
actorMotionLimits: MotionLimits; actorMotionLimits: MotionLimits;
vehicleMotionLimits: MotionLimits; vehicleMotionLimits: MotionLimits;
aircraftMotionLimits: MotionLimits;
} }
export interface RealtimeService { export interface RealtimeService {
@@ -176,6 +178,13 @@ function resolveOptions(value: RealtimeServiceOptions): ResolvedOptions {
positionSlackM: 4, positionSlackM: 4,
maximumDeltaSeconds: 2, maximumDeltaSeconds: 2,
}, },
aircraftMotionLimits: value.aircraftMotionLimits ?? {
maximumHorizontalSpeedMps: 125,
maximumVerticalSpeedMps: 45,
maximumTurnRateDegPerSec: 180,
positionSlackM: 6,
maximumDeltaSeconds: 2,
},
}; };
} }
@@ -208,7 +217,9 @@ function clonePose(pose: EntityPoseSnapshot): EntityPoseSnapshot {
} }
function entityKey(pose: EntityPoseSnapshot): string { function entityKey(pose: EntityPoseSnapshot): string {
return pose.entity === "actor" ? `actor:${pose.actorId}` : `vehicle:${pose.vehicleId}`; if (pose.entity === "actor") return `actor:${pose.actorId}`;
if (pose.entity === "vehicle") return `vehicle:${pose.vehicleId}`;
return `aircraft:${pose.aircraftId}`;
} }
function poseInterestKey(pose: EntityPoseSnapshot): string | null { function poseInterestKey(pose: EntityPoseSnapshot): string | null {
@@ -569,8 +580,16 @@ export function createRealtimeService(options: RealtimeServiceOptions = {}): Rea
} }
if ( if (
(snapshot.entity === "actor" && snapshot.actorId !== session.actorId) || (snapshot.entity === "actor" && snapshot.actorId !== session.actorId) ||
(snapshot.entity === "vehicle" && snapshot.driverActorId !== session.actorId) (snapshot.entity === "vehicle" && snapshot.driverActorId !== session.actorId) ||
(snapshot.entity === "aircraft" && snapshot.pilotActorId !== session.actorId)
) return { ok: false, code: "ownership", message: "Realtime entity is not owned by this session." }; ) return { ok: false, code: "ownership", message: "Realtime entity is not owned by this session." };
if (snapshot.entity === "aircraft" && snapshot.pose.space !== "geographic") {
return {
ok: false,
code: "interest",
message: "Aircraft poses require an outdoor geographic interest.",
};
}
const localKey = poseInterestKey(snapshot); const localKey = poseInterestKey(snapshot);
if (localKey !== null && !session.interestKeys.has(localKey)) { if (localKey !== null && !session.interestKeys.has(localKey)) {
return { ok: false, code: "interest", message: "Realtime pose is outside the joined interest." }; return { ok: false, code: "interest", message: "Realtime pose is outside the joined interest." };
@@ -585,10 +604,15 @@ export function createRealtimeService(options: RealtimeServiceOptions = {}): Rea
const key = entityKey(snapshot); const key = entityKey(snapshot);
const previous = session.proposedPoses.get(key); const previous = session.proposedPoses.get(key);
if (previous) { if (previous) {
const motionLimits = snapshot.entity === "vehicle"
? settings.vehicleMotionLimits
: snapshot.entity === "aircraft"
? settings.aircraftMotionLimits
: settings.actorMotionLimits;
const motion = validateMotion( const motion = validateMotion(
previous, previous,
snapshot, snapshot,
snapshot.entity === "vehicle" ? settings.vehicleMotionLimits : settings.actorMotionLimits, motionLimits,
); );
if (!motion.ok) { if (!motion.ok) {
return { ok: false, code: "motion", message: `Realtime motion rejected: ${motion.reason}.` }; return { ok: false, code: "motion", message: `Realtime motion rejected: ${motion.reason}.` };
+89
View File
@@ -3,6 +3,7 @@ import { createHmac } from "node:crypto";
import { after, describe, it } from "node:test"; import { after, describe, it } from "node:test";
import type { import type {
ActorPoseSnapshot, ActorPoseSnapshot,
AircraftPoseSnapshot,
FloorInterest, FloorInterest,
InterestCell, InterestCell,
PoseDelta, PoseDelta,
@@ -102,6 +103,37 @@ function vehiclePose(
}; };
} }
function aircraftPose(
pilotActorId: string,
sequence: number,
timestampMs: number,
lat = 37.7,
): AircraftPoseSnapshot {
return {
entity: "aircraft",
aircraftId: `aircraft-${pilotActorId}`,
kind: "electric-vtail",
pilotActorId,
sequence,
timestampMs,
pose: {
space: "geographic",
lat,
lng: -122.4,
altitudeM: 1_500,
headingDeg: 320,
pitchDeg: 2,
},
velocity: { xMps: -40, yMps: 2, zMps: 48, yawDegPerSec: 2 },
rollDeg: -12,
throttle: 0.7,
rollInput: -0.25,
pitchInput: 0.1,
yawInput: 0,
fanRadians: 3,
};
}
function joined( function joined(
service: ReturnType<typeof createRealtimeService>, service: ReturnType<typeof createRealtimeService>,
actorId: string, actorId: string,
@@ -211,6 +243,63 @@ describe("authoritative realtime service", () => {
assert.equal(socalMessages.length, 0); assert.equal(socalMessages.length, 0);
}); });
it("converges two clients on an owned aircraft and rejects authority, space, and speed attacks", () => {
let now = 16_000;
const service = createRealtimeService({ now: () => now });
const bay = { kind: "city" as const, cityId: "bay-area" as const };
const pilot = joined(service, "opaque-pilot", [bay], "private-pilot-subject");
const observer = joined(service, "opaque-observer", [bay], "private-observer-subject");
const received: PoseDelta[] = [];
service.connect(observer.sessionId, observer.token, (message) => {
if (message.type === "pose-delta") received.push(message);
});
const first = service.submitPose(
pilot.sessionId,
pilot.token,
aircraftPose("opaque-pilot", 1, now),
);
assert.equal(first.ok, true);
assert.equal(received.length, 1);
assert.equal(received[0]?.updates[0]?.entity, "aircraft");
assert.equal(JSON.stringify(received[0]).includes("private-pilot-subject"), false);
const stolen = service.submitPose(
observer.sessionId,
observer.token,
aircraftPose("opaque-pilot", 1, now),
);
assert.equal(stolen.ok, false);
if (!stolen.ok) assert.equal(stolen.code, "ownership");
const local = aircraftPose("opaque-pilot", 2, now);
const invalidSpace = service.submitPose(pilot.sessionId, pilot.token, {
...local,
pose: { space: "local", cell: bay, xM: 0, yM: 0, zM: 0, headingDeg: 0, pitchDeg: 0 },
});
assert.equal(invalidSpace.ok, false);
if (!invalidSpace.ok) assert.equal(invalidSpace.code, "interest");
now += 1_000;
assert.equal(service.submitPose(
pilot.sessionId,
pilot.token,
aircraftPose("opaque-pilot", 2, now, 37.7005),
).ok, true);
now += 100;
const speedHack = service.submitPose(
pilot.sessionId,
pilot.token,
aircraftPose("opaque-pilot", 3, now, 38.7),
);
assert.equal(speedHack.ok, false);
if (!speedHack.ok) assert.equal(speedHack.code, "motion");
const lateObserver = joined(service, "opaque-late-observer", [bay]);
assert.equal(lateObserver.grant.initial.length, 1);
assert.equal(lateObserver.grant.initial[0]?.entity, "aircraft");
});
it("removes the previous entity when one session switches actor to vehicle", () => { it("removes the previous entity when one session switches actor to vehicle", () => {
let now = 17_000; let now = 17_000;
const service = createRealtimeService({ now: () => now }); const service = createRealtimeService({ now: () => now });
+4
View File
@@ -40,3 +40,7 @@ export {
type SceneAircraftOptions, type SceneAircraftOptions,
type SceneAircraftView, type SceneAircraftView,
} from "./sceneAircraft.ts"; } from "./sceneAircraft.ts";
export {
createAircraftPoseSnapshot,
type AircraftRealtimeIdentity,
} from "./realtime.ts";
+60
View File
@@ -0,0 +1,60 @@
/** Renderer-independent bridge from deterministic flight state to realtime protocol state. */
import type { AircraftPoseSnapshot } from "../realtime/types.ts";
import type { AircraftControllerSnapshot } from "./controller.ts";
export interface AircraftRealtimeIdentity {
aircraftId: string;
pilotActorId: string;
}
function identity(value: string, name: string): string {
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
throw new RangeError(`${name} must be a non-empty string of at most 256 characters`);
}
return value;
}
/**
* Converts one controller snapshot into the exact geographic wire contract.
* Geographic velocity axes are east/up/north; heading zero therefore travels
* north even though the Three.js presentation model faces local -Z.
*/
export function createAircraftPoseSnapshot(
identityValue: AircraftRealtimeIdentity,
state: AircraftControllerSnapshot,
sequence: number,
timestampMs: number,
): AircraftPoseSnapshot {
const heading = state.headingDeg * Math.PI / 180;
const pitch = state.pitchDeg * Math.PI / 180;
const horizontalSpeedMps = state.speedMps * Math.cos(pitch);
return {
entity: "aircraft",
aircraftId: identity(identityValue.aircraftId, "aircraftId"),
kind: "electric-vtail",
pilotActorId: identity(identityValue.pilotActorId, "pilotActorId"),
sequence,
timestampMs,
pose: {
space: "geographic",
lat: state.lat,
lng: state.lng,
altitudeM: state.altitudeM,
headingDeg: state.headingDeg,
pitchDeg: state.pitchDeg,
},
velocity: {
xMps: Math.sin(heading) * horizontalSpeedMps,
yMps: state.verticalSpeedMps,
zMps: Math.cos(heading) * horizontalSpeedMps,
yawDegPerSec: 0,
},
rollDeg: state.rollDeg,
throttle: state.throttle,
rollInput: state.rollInput,
pitchInput: state.pitchInput,
yawInput: state.yawInput,
fanRadians: state.fanRadians,
};
}
+51 -18
View File
@@ -83,6 +83,7 @@ import type { ActorIdentity } from "./actors/controller.ts";
import { SRGBColorSpace, VideoTexture } from "three"; import { SRGBColorSpace, VideoTexture } from "three";
import { import {
CALIFORNIA_AIR_ROUTE, CALIFORNIA_AIR_ROUTE,
createAircraftPoseSnapshot,
type AircraftActionSnapshot, type AircraftActionSnapshot,
} from "./aircraft/index.ts"; } from "./aircraft/index.ts";
import { import {
@@ -254,6 +255,8 @@ let cityId = "california";
*/ */
let wantedCity = "california"; let wantedCity = "california";
let office: OfficeScene | null = null; let office: OfficeScene | null = null;
/** The pack used by `office`; kept separate from the currently selected door. */
let builtOfficeId: string | null = null;
let inside = false; let inside = false;
let markers: Marker[] = SAMPLE_MARKERS; let markers: Marker[] = SAMPLE_MARKERS;
let realtimeClient: RealtimeClient | null = null; let realtimeClient: RealtimeClient | null = null;
@@ -265,6 +268,7 @@ let realtimePageActive = true;
/** Page-scoped wire identities; auth subject never enters the spatial protocol. */ /** Page-scoped wire identities; auth subject never enters the spatial protocol. */
const realtimeActorId = crypto.randomUUID(); const realtimeActorId = crypto.randomUUID();
const realtimeVehicleId = crypto.randomUUID(); const realtimeVehicleId = crypto.randomUUID();
const realtimeAircraftId = crypto.randomUUID();
/** /**
* The buildings you can walk into, as procedural glyphs on the city. * The buildings you can walk into, as procedural glyphs on the city.
@@ -773,13 +777,7 @@ async function mountCity(id: string) {
weatherWatch = null; weatherWatch = null;
poseEditor?.destroy(); poseEditor?.destroy();
poseEditor = null; poseEditor = null;
stopWatchingOccupancy(); disposeLoadedOffice();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
office = null;
officeAtmosphere = null;
inside = false; inside = false;
minimap?.dispose(); minimap?.dispose();
minimap = null; minimap = null;
@@ -1185,6 +1183,12 @@ function syncJourneyVehicle(now: number): void {
*/ */
async function enterOffice() { async function enterOffice() {
if (!city) return; if (!city) return;
// A city can have more than one authored door. Leaving one office parks its
// scene for a cheap re-entry, but clicking a different door must not reopen
// that cached building under the new office id. Tear down every resource
// owned by the old room before loading the requested one; the parked city
// actor, journey identity and local profile deliberately live elsewhere.
if (office && builtOfficeId !== officeId) disposeLoadedOffice();
if (!office) { if (!office) {
const built = await loadOffice(); const built = await loadOffice();
// The chunk arrived after the user had already left for the other city, or // The chunk arrived after the user had already left for the other city, or
@@ -1272,6 +1276,7 @@ async function enterOffice() {
: { onPlacePick: (place) => showDetail(place ? place.label : null) }), : { onPlacePick: (place) => showDetail(place ? place.label : null) }),
...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}), ...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}),
}); });
builtOfficeId = officeId;
office.onViewChange(() => renderLegend()); office.onViewChange(() => renderLegend());
officePlan = buildOfficePlan(createOfficeMinimap, office); officePlan = buildOfficePlan(createOfficeMinimap, office);
} }
@@ -1410,6 +1415,18 @@ function leaveOffice() {
renderLegend(); renderLegend();
} }
/** Dispose everything whose coordinates or subscriptions belong to one office. */
function disposeLoadedOffice(): void {
stopWatchingOccupancy();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
office = null;
builtOfficeId = null;
officeAtmosphere = null;
}
/** /**
* Fetch Spaces. * Fetch Spaces.
* *
@@ -1634,14 +1651,8 @@ async function switchOffice(id: string) {
const previous = officeId; const previous = officeId;
officeId = id; officeId = id;
// The roster belongs to the building you have left. // The roster, screen session, plan and scene all belong to the building left.
stopWatchingOccupancy(); disposeLoadedOffice();
disposeOfficeScreenUi();
officePlan?.dispose();
officePlan = null;
office?.dispose();
office = null;
officeAtmosphere = null;
entering = true; entering = true;
try { try {
@@ -2015,6 +2026,16 @@ function realtimePose(): EntityPoseSnapshot | null {
}; };
} }
const aircraft = city?.aircraftActive() ? city.aircraftState() : null;
if (aircraft) {
return createAircraftPoseSnapshot(
{ aircraftId: realtimeAircraftId, pilotActorId: realtimeActorId },
aircraft,
0,
timestampMs,
);
}
const vehicle = journey.vehicle !== null ? city?.vehicleState() : null; const vehicle = journey.vehicle !== null ? city?.vehicleState() : null;
if (vehicle) { if (vehicle) {
const heading = vehicle.headingDeg * Math.PI / 180; const heading = vehicle.headingDeg * Math.PI / 180;
@@ -2080,8 +2101,12 @@ function realtimePose(): EntityPoseSnapshot | null {
function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean { function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean {
if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId; if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId;
if (snapshot.entity === "vehicle") {
return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId; return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId;
} }
return snapshot.pilotActorId === realtimeClient?.state().actorId ||
snapshot.aircraftId === realtimeAircraftId;
}
function clearRealtimePeers(): void { function clearRealtimePeers(): void {
city?.clearRemoteEntities(); city?.clearRemoteEntities();
@@ -2107,7 +2132,11 @@ function applyRealtimeMessage(message: ServerRealtimeMessage): void {
} }
if (message.type === "pose-delta") { if (message.type === "pose-delta") {
for (const id of message.removedEntityIds) { for (const id of message.removedEntityIds) {
if (id !== `actor:${realtimeClient?.state().actorId}` && id !== `vehicle:${realtimeVehicleId}`) { if (
id !== `actor:${realtimeClient?.state().actorId}` &&
id !== `vehicle:${realtimeVehicleId}` &&
id !== `aircraft:${realtimeAircraftId}`
) {
target?.removeRemoteEntity(id); target?.removeRemoteEntity(id);
} }
} }
@@ -2804,9 +2833,13 @@ canvas.addEventListener("click", () => {
const marker = hoveredMarker; const marker = hoveredMarker;
if (!marker) return; if (!marker) return;
const id = officeIdOf(marker); const id = officeIdOf(marker);
if (id === null) return; if (id === null || entering) return;
entering = true;
officeId = id; officeId = id;
void building(`Opening ${marker.label}`, () => enterOffice()); void building(`Opening ${marker.label}`, () => enterOffice()).finally(() => {
entering = false;
renderLegend();
});
}); });
// ---- Panels, plan and overlays ---------------------------------------------- // ---- Panels, plan and overlays ----------------------------------------------
+3 -1
View File
@@ -96,7 +96,9 @@ function predictPose(pose: SpatialPose, velocity: PoseVelocity, seconds: number)
} }
function id(snapshot: EntityPoseSnapshot): string { function id(snapshot: EntityPoseSnapshot): string {
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`; if (snapshot.entity === "actor") return `actor:${snapshot.actorId}`;
if (snapshot.entity === "vehicle") return `vehicle:${snapshot.vehicleId}`;
return `aircraft:${snapshot.aircraftId}`;
} }
export class PoseInterpolationBuffer { export class PoseInterpolationBuffer {
+17 -2
View File
@@ -114,8 +114,8 @@ export function isEntityPoseSnapshot(value: unknown): value is EntityPoseSnapsho
(value.kind === "humanoid" || value.kind === "dog" || value.kind === "crow") (value.kind === "humanoid" || value.kind === "dog" || value.kind === "crow")
); );
} }
if (value.entity === "vehicle") {
return ( return (
value.entity === "vehicle" &&
string(value.vehicleId) && string(value.vehicleId) &&
value.kind === "model-x" && value.kind === "model-x" &&
(value.driverActorId === null || string(value.driverActorId)) && (value.driverActorId === null || string(value.driverActorId)) &&
@@ -123,6 +123,19 @@ export function isEntityPoseSnapshot(value: unknown): value is EntityPoseSnapsho
finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000 finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000
); );
} }
return (
value.entity === "aircraft" &&
string(value.aircraftId) &&
value.kind === "electric-vtail" &&
string(value.pilotActorId) &&
finite(value.rollDeg) && value.rollDeg >= -90 && value.rollDeg <= 90 &&
finite(value.throttle) && value.throttle >= 0 && value.throttle <= 1 &&
finite(value.rollInput) && value.rollInput >= -1 && value.rollInput <= 1 &&
finite(value.pitchInput) && value.pitchInput >= -1 && value.pitchInput <= 1 &&
finite(value.yawInput) && value.yawInput >= -1 && value.yawInput <= 1 &&
finite(value.fanRadians) && Math.abs(value.fanRadians) <= 1_000_000
);
}
function interests(value: unknown): value is readonly InterestCell[] { function interests(value: unknown): value is readonly InterestCell[] {
return Array.isArray(value) && value.length <= MAX_INTERESTS && value.every(isInterestCell); return Array.isArray(value) && value.length <= MAX_INTERESTS && value.every(isInterestCell);
@@ -208,7 +221,9 @@ export function validateServerRealtimeMessage(
} }
function entityId(snapshot: EntityPoseSnapshot): string { function entityId(snapshot: EntityPoseSnapshot): string {
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`; if (snapshot.entity === "actor") return `actor:${snapshot.actorId}`;
if (snapshot.entity === "vehicle") return `vehicle:${snapshot.vehicleId}`;
return `aircraft:${snapshot.aircraftId}`;
} }
function localCellKey(pose: SpatialPose): string | null { function localCellKey(pose: SpatialPose): string | null {
+54 -4
View File
@@ -33,6 +33,13 @@ import {
setModelXWheelRotation, setModelXWheelRotation,
type ModelXRig, type ModelXRig,
} from "../assets/vehicles/index.ts"; } from "../assets/vehicles/index.ts";
import {
buildElectricAircraft,
disposeElectricAircraft,
setAircraftControlSurfaces,
setAircraftFanRotation,
type ElectricAircraftRig,
} from "../aircraft/asset.ts";
import { PoseInterpolationBuffer } from "./interpolation.ts"; import { PoseInterpolationBuffer } from "./interpolation.ts";
import { isEntityPoseSnapshot } from "./protocol.ts"; import { isEntityPoseSnapshot } from "./protocol.ts";
import type { import type {
@@ -79,13 +86,15 @@ type PeerVisual =
| { kind: "humanoid"; rig: HumanoidRig } | { kind: "humanoid"; rig: HumanoidRig }
| { kind: "dog"; rig: DogRig } | { kind: "dog"; rig: DogRig }
| { kind: "crow"; rig: CrowRig } | { kind: "crow"; rig: CrowRig }
| { kind: "model-x"; rig: ModelXRig }; | { kind: "model-x"; rig: ModelXRig }
| { kind: "electric-vtail"; rig: ElectricAircraftRig };
interface Prototypes { interface Prototypes {
humanoid: HumanoidRig; humanoid: HumanoidRig;
dog: DogRig; dog: DogRig;
crow: CrowRig; crow: CrowRig;
vehicle: ModelXRig; vehicle: ModelXRig;
aircraft: ElectricAircraftRig;
} }
interface Peer { interface Peer {
@@ -101,7 +110,9 @@ interface Peer {
} }
export function scenePeerId(snapshot: EntityPoseSnapshot): string { export function scenePeerId(snapshot: EntityPoseSnapshot): string {
return snapshot.entity === "actor" ? `actor:${snapshot.actorId}` : `vehicle:${snapshot.vehicleId}`; if (snapshot.entity === "actor") return `actor:${snapshot.actorId}`;
if (snapshot.entity === "vehicle") return `vehicle:${snapshot.vehicleId}`;
return `aircraft:${snapshot.aircraftId}`;
} }
function poseSpaceKey(pose: SpatialPose): string { function poseSpaceKey(pose: SpatialPose): string {
@@ -130,10 +141,36 @@ function makePrototypes(): Prototypes {
dog: buildDog(), dog: buildDog(),
crow: buildCrow(), crow: buildCrow(),
vehicle, vehicle,
aircraft: buildElectricAircraft(),
};
}
function childGroup(root: THREE.Group, name: string): THREE.Group {
const value = root.getObjectByName(name);
if (!(value instanceof THREE.Group)) throw new Error(`aircraft prototype is missing ${name}`);
return value;
}
function cloneAircraft(prototype: ElectricAircraftRig): ElectricAircraftRig {
const root = prototype.root.clone(true);
return {
root,
leftAileron: childGroup(root, "electric-aircraft.aileron-left"),
rightAileron: childGroup(root, "electric-aircraft.aileron-right"),
leftVTail: childGroup(root, "electric-aircraft.v-tail-left"),
rightVTail: childGroup(root, "electric-aircraft.v-tail-right"),
fans: [
childGroup(root, "electric-aircraft.fan-left"),
childGroup(root, "electric-aircraft.fan-right"),
],
ownsMaterials: false,
}; };
} }
function cloneVisual(snapshot: EntityPoseSnapshot, prototypes: Prototypes): PeerVisual { function cloneVisual(snapshot: EntityPoseSnapshot, prototypes: Prototypes): PeerVisual {
if (snapshot.entity === "aircraft") {
return { kind: "electric-vtail", rig: cloneAircraft(prototypes.aircraft) };
}
if (snapshot.entity === "vehicle") { if (snapshot.entity === "vehicle") {
const rig = cloneModelX(prototypes.vehicle); const rig = cloneModelX(prototypes.vehicle);
rig.root.name = "generic-black-ev"; rig.root.name = "generic-black-ev";
@@ -157,7 +194,7 @@ function replaceVisual(peer: Peer, snapshot: EntityPoseSnapshot, prototypes: Pro
} }
function animateActor( function animateActor(
visual: Exclude<PeerVisual, { kind: "model-x" }>, visual: Extract<PeerVisual, { kind: "humanoid" | "dog" | "crow" }>,
sample: BufferedPoseSample, sample: BufferedPoseSample,
): void { ): void {
const speed = Math.hypot(sample.velocity.xMps, sample.velocity.zMps); const speed = Math.hypot(sample.velocity.xMps, sample.velocity.zMps);
@@ -262,7 +299,9 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers {
peer.root.rotation.set( peer.root.rotation.set(
THREE.MathUtils.degToRad(pose.pitchDeg), THREE.MathUtils.degToRad(pose.pitchDeg),
-THREE.MathUtils.degToRad(pose.headingDeg), -THREE.MathUtils.degToRad(pose.headingDeg),
0, peer.latest.entity === "aircraft"
? -THREE.MathUtils.degToRad(peer.latest.rollDeg)
: 0,
); );
if (peer.visual.kind === "model-x") { if (peer.visual.kind === "model-x") {
const latest = peer.latest.entity === "vehicle" ? peer.latest : null; const latest = peer.latest.entity === "vehicle" ? peer.latest : null;
@@ -272,6 +311,16 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers {
// wheel geometry rolls forward with negative local-X rotation. // wheel geometry rolls forward with negative local-X rotation.
setModelXWheelRotation(peer.visual.rig, -latest.wheelRadians); setModelXWheelRotation(peer.visual.rig, -latest.wheelRadians);
} }
} else if (peer.visual.kind === "electric-vtail") {
const latest = peer.latest.entity === "aircraft" ? peer.latest : null;
if (latest) {
setAircraftControlSurfaces(peer.visual.rig, {
roll: latest.rollInput,
pitch: latest.pitchInput,
yaw: latest.yawInput,
});
setAircraftFanRotation(peer.visual.rig, latest.fanRadians);
}
} else { } else {
animateActor(peer.visual, sample); animateActor(peer.visual, sample);
} }
@@ -353,6 +402,7 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers {
disposeDog(prototypes.dog); disposeDog(prototypes.dog);
disposeCrow(prototypes.crow); disposeCrow(prototypes.crow);
disposeModelX(prototypes.vehicle); disposeModelX(prototypes.vehicle);
disposeElectricAircraft(prototypes.aircraft);
disposed = true; disposed = true;
}, },
}; };
+16 -1
View File
@@ -2,6 +2,7 @@
export type RealtimeActorKind = "humanoid" | "dog" | "crow"; export type RealtimeActorKind = "humanoid" | "dog" | "crow";
export type RealtimeVehicleKind = "model-x"; export type RealtimeVehicleKind = "model-x";
export type RealtimeAircraftKind = "electric-vtail";
export type RealtimeMemberRole = "visitor" | "member" | "admin"; export type RealtimeMemberRole = "visitor" | "member" | "admin";
export type RealtimeRevocationReason = export type RealtimeRevocationReason =
| "signed-out" | "signed-out"
@@ -104,7 +105,21 @@ export interface VehiclePoseSnapshot extends EntityPoseSnapshotBase {
wheelRadians: number; wheelRadians: number;
} }
export type EntityPoseSnapshot = ActorPoseSnapshot | VehiclePoseSnapshot; /** Authoritative state needed to reproduce a piloted aircraft on another client. */
export interface AircraftPoseSnapshot extends EntityPoseSnapshotBase {
entity: "aircraft";
aircraftId: string;
kind: RealtimeAircraftKind;
pilotActorId: string;
rollDeg: number;
throttle: number;
rollInput: number;
pitchInput: number;
yawInput: number;
fanRadians: number;
}
export type EntityPoseSnapshot = ActorPoseSnapshot | VehiclePoseSnapshot | AircraftPoseSnapshot;
export interface JoinRequest { export interface JoinRequest {
type: "join-request"; type: "join-request";
+173
View File
@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { createSceneActor, type ActorIdentity } from "../actors/index.ts";
import { createOfficeWalker, type OfficeActorAppearance } from "../interiors/officeWalker.ts";
import { Plan } from "../interiors/plan.ts";
import type { Level, Office, Room } from "../interiors/types.ts";
import { createJourney, journeyReducer, type JourneyActor } from "../journey/index.ts";
import {
actorKindForPresence,
createDefaultLocalProfile,
resolveHumanoidAppearance,
} from "../profile/index.ts";
const FLOOR: Room = {
id: "floor",
name: "Floor",
floor: "floor" as never,
outline: [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 8 }, { x: 0, z: 8 }],
};
function officePlan(): Plan {
const level: Level = {
id: "ground",
name: "Ground",
elevation: 0,
wallHeight: 3,
wallThickness: 0.12,
floorplan: {
rooms: [FLOOR],
walls: [{
id: "dividing-wall",
from: { x: 5, z: 0 },
to: { x: 5, z: 8 },
openings: [{ kind: "door", start: 3.4, width: 1.2, sill: 0, head: 2.1 }],
}],
},
};
const office: Office = {
id: "handoff-office",
name: "Handoff Office",
levels: [level],
viewpoints: [],
};
return new Plan(office, { warn: false });
}
function materialColor(root: THREE.Object3D, meshName: string): string {
const mesh = root.getObjectByName(meshName);
assert.ok(mesh instanceof THREE.Mesh, `${meshName} exists`);
assert.ok(mesh.material instanceof THREE.MeshStandardMaterial, `${meshName} has a standard material`);
return `#${mesh.material.color.getHexString()}`;
}
describe("city and office actor acceptance handoff", () => {
it("hands an anonymous crow to an office dog, walks through doors, and returns to the same crow", () => {
const journeyActor: JourneyActor = {
id: "anonymous",
kind: "crow",
signedIn: false,
profile: { displayName: "Guest" },
};
const identity: ActorIdentity = {
id: journeyActor.id,
displayName: journeyActor.profile.displayName,
authenticated: false,
profile: {},
};
let journey = createJourney({ actor: journeyActor });
journey = journeyReducer(journey, { type: "navigate-to-city", city: "bay-area" });
assert.equal(actorKindForPresence(false, "outdoors"), "crow");
const cityActor = createSceneActor({
kind: "crow",
mode: "flight",
identity,
position: { x: 12, y: 30, z: -8 },
active: true,
fixedStepSeconds: 0.1,
minFlightAltitude: 5,
maxFlightAltitude: 60,
});
cityActor.setActions({ forward: 1, climb: 0.2 });
cityActor.tick(0.5);
cityActor.setActive(false);
const parkedCityState = cityActor.state();
journey = journeyReducer(journey, { type: "enter-office", officeId: "lumbridge-hq" });
assert.equal(journey.location.scale, "office");
assert.deepEqual(journey.actor, journeyActor, "entering never rewrites the serializable identity");
assert.equal(actorKindForPresence(false, "office"), "dog");
const walker = createOfficeWalker(officePlan(), {
levelId: "ground",
position: { x: 4, z: 4 },
actor: { kind: "anonymous-dog" },
active: true,
speed: 2,
fixedStep: 0.1,
});
walker.setAction({ x: 1, z: 0 });
for (let step = 0; step < 10; step += 1) walker.tick(0.1);
assert.ok(walker.state().position.x > 5.5, "the possessed dog passes through the authored door gap");
walker.reset({ levelId: "ground", position: { x: 4, z: 1 } });
walker.setAction({ x: 1, z: 0 });
for (let step = 0; step < 10; step += 1) walker.tick(0.1);
assert.ok(walker.state().position.x < 5, "the same controller cannot cross the solid wall");
journey = journeyReducer(journey, { type: "leave-office" });
assert.equal(journey.location.scale, "bay-area");
assert.deepEqual(journey.actor, journeyActor);
assert.deepEqual(cityActor.state(), parkedCityState, "the outdoor actor stays parked during the office visit");
assert.deepEqual(cityActor.state().identity, identity);
cityActor.setActive(true);
cityActor.setActions({ forward: 1 });
cityActor.tick(0.2);
assert.ok(cityActor.state().distanceM > parkedCityState.distanceM, "the returned crow is playable");
walker.dispose();
cityActor.dispose();
});
it("renders one signed-in humanoid appearance on both sides of the office door", () => {
const profile = createDefaultLocalProfile("member-acceptance", "Morgan");
const appearance = resolveHumanoidAppearance(profile.appearance);
const identity: ActorIdentity = {
id: "member-acceptance",
displayName: profile.displayName,
authenticated: true,
profile: {
appearance: {
skinTone: appearance.skinTone,
primaryColor: appearance.outfitColor,
accentColor: appearance.accentColor,
hairColor: appearance.hairColor,
bodyShape: appearance.bodyShape,
},
},
};
const officeAppearance: OfficeActorAppearance = {
kind: "humanoid",
skinTone: appearance.skinTone,
outfitColor: appearance.outfitColor,
accentColor: appearance.accentColor,
hairColor: appearance.hairColor,
bodyShape: appearance.bodyShape,
};
assert.equal(actorKindForPresence(true, "outdoors"), "humanoid");
assert.equal(actorKindForPresence(true, "office"), "humanoid");
const cityActor = createSceneActor({ kind: "humanoid", identity });
const walker = createOfficeWalker(officePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
actor: officeAppearance,
});
assert.equal(cityActor.state().identity.id, "member-acceptance");
assert.equal(cityActor.state().identity.displayName, "Morgan");
assert.ok(cityActor.root.getObjectByName("humanoid"));
assert.equal(walker.root.userData.actorType, "humanoid");
assert.equal(materialColor(cityActor.root, "humanoid.chest"), appearance.outfitColor);
assert.equal(materialColor(walker.root, "humanoid.chest"), appearance.outfitColor);
assert.equal(materialColor(cityActor.root, "humanoid.chest.accent"), appearance.accentColor);
assert.equal(materialColor(walker.root, "humanoid.chest.accent"), appearance.accentColor);
assert.equal(materialColor(cityActor.root, "humanoid.hair"), appearance.hairColor);
assert.equal(materialColor(walker.root, "humanoid.hair"), appearance.hairColor);
walker.dispose();
cityActor.dispose();
});
});
+78
View File
@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
AircraftController,
createAircraftPoseSnapshot,
} from "../aircraft/index.ts";
import {
PoseInterpolationBuffer,
isEntityPoseSnapshot,
} from "../realtime/index.ts";
describe("playable aircraft realtime bridge", () => {
it("publishes a strict geographic snapshot with deterministic flight axes", () => {
const controller = new AircraftController({
initialHeadingDeg: 90,
initialSpeedMps: 60,
initialAltitudeM: 1_500,
});
const state = controller.snapshot();
const snapshot = createAircraftPoseSnapshot(
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
state,
7,
10_000,
);
assert.equal(isEntityPoseSnapshot(snapshot), true);
assert.equal(snapshot.entity, "aircraft");
assert.equal(snapshot.pose.space, "geographic");
assert.ok(Math.abs(snapshot.velocity.xMps - state.speedMps) < 1e-10);
assert.ok(Math.abs(snapshot.velocity.zMps) < 1e-10);
assert.equal(snapshot.velocity.yMps, state.verticalSpeedMps);
});
it("feeds aircraft samples through the shared deterministic interpolation buffer", () => {
const controller = new AircraftController({ initialHeadingDeg: 0, initialSpeedMps: 50 });
const first = createAircraftPoseSnapshot(
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
controller.snapshot(),
1,
1_000,
);
controller.tick(1, { throttle: 1 });
const second = createAircraftPoseSnapshot(
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
controller.snapshot(),
2,
2_000,
);
const buffer = new PoseInterpolationBuffer({ interpolationDelayMs: 0 });
assert.equal(buffer.push(first), true);
assert.equal(buffer.push(second), true);
const sample = buffer.sample(1_500);
assert.equal(sample?.mode, "interpolated");
assert.equal(sample?.pose.space, "geographic");
if (sample?.pose.space === "geographic" && first.pose.space === "geographic" && second.pose.space === "geographic") {
assert.ok(sample.pose.lat >= Math.min(first.pose.lat, second.pose.lat));
assert.ok(sample.pose.lat <= Math.max(first.pose.lat, second.pose.lat));
}
});
it("rejects non-wire-safe identity and aircraft controls", () => {
const controller = new AircraftController();
assert.throws(() => createAircraftPoseSnapshot(
{ aircraftId: "", pilotActorId: "actor-page-1" },
controller.snapshot(),
1,
1_000,
), RangeError);
const valid = createAircraftPoseSnapshot(
{ aircraftId: "aircraft-page-1", pilotActorId: "actor-page-1" },
controller.snapshot(),
1,
1_000,
);
assert.equal(isEntityPoseSnapshot({ ...valid, throttle: 1.01 }), false);
assert.equal(isEntityPoseSnapshot({ ...valid, rollDeg: Number.NaN }), false);
});
});
+37
View File
@@ -5,6 +5,7 @@ import {
createScenePeers, createScenePeers,
scenePeerId, scenePeerId,
type ActorPoseSnapshot, type ActorPoseSnapshot,
type AircraftPoseSnapshot,
type EntityPoseSnapshot, type EntityPoseSnapshot,
type InterestCell, type InterestCell,
type ScenePeersOptions, type ScenePeersOptions,
@@ -48,6 +49,25 @@ function vehicle(sequence: number, timestampMs: number, lat: number): VehiclePos
}; };
} }
function aircraft(sequence: number, timestampMs: number, lat: number): AircraftPoseSnapshot {
return {
entity: "aircraft",
aircraftId: "evtol-1",
kind: "electric-vtail",
pilotActorId: "pilot-1",
sequence,
timestampMs,
pose: { space: "geographic", lat, lng: -121, altitudeM: 1_500, headingDeg: 30, pitchDeg: 5 },
velocity: { xMps: 30, yMps: 2, zMps: 52, yawDegPerSec: 4 },
rollDeg: 18,
throttle: 0.72,
rollInput: 0.4,
pitchInput: -0.2,
yawInput: 0.1,
fanRadians: 2.4,
};
}
function fixture(over: Partial<ScenePeersOptions> = {}) { function fixture(over: Partial<ScenePeersOptions> = {}) {
return createScenePeers({ return createScenePeers({
project: (lat, lng) => [lng * 2, -lat * 3], project: (lat, lng) => [lng * 2, -lat * 3],
@@ -120,6 +140,23 @@ describe("remote scene peers", () => {
peers.dispose(); peers.dispose();
}); });
it("renders a remote piloted aircraft with authoritative bank and rig state", () => {
const peers = fixture();
const snapshot = aircraft(1, 1_000, 37.7);
assert.equal(scenePeerId(snapshot), "aircraft:evtol-1");
assert.equal(peers.upsert(snapshot), true);
assert.equal(peers.tick(1_000), 1);
const root = peers.root.children[0] as THREE.Group;
assert.deepEqual(root.position.toArray(), [-242, 155, -113.10000000000001]);
assert.ok(Math.abs(root.rotation.x - 5 * Math.PI / 180) < 1e-12);
assert.ok(Math.abs(root.rotation.y + 30 * Math.PI / 180) < 1e-12);
assert.ok(Math.abs(root.rotation.z + 18 * Math.PI / 180) < 1e-12);
assert.ok(root.getObjectByName("electric-aircraft"));
assert.equal(root.getObjectByName("electric-aircraft.fan-left")?.rotation.z, 2.4);
assert.ok(Math.abs((root.getObjectByName("electric-aircraft.aileron-left")?.rotation.x ?? 0) - 0.18) < 1e-12);
peers.dispose();
});
it("shares prototype resources while kind changes retain the entity root", () => { it("shares prototype resources while kind changes retain the entity root", () => {
const peers = fixture(); const peers = fixture();
peers.upsert(actor("one", "humanoid", 1, 1_000, 0)); peers.upsert(actor("one", "humanoid", 1, 1_000, 0));