feat: complete actor handoff and piloted aircraft presence
This commit is contained in:
+24
-5
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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\"",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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.`);
|
||||||
@@ -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}.` };
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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,7 +2101,11 @@ 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 {
|
||||||
@@ -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 ----------------------------------------------
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -114,14 +114,27 @@ 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)) &&
|
||||||
finite(value.steering) && value.steering >= -1 && value.steering <= 1 &&
|
finite(value.steering) && value.steering >= -1 && value.steering <= 1 &&
|
||||||
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[] {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user