diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index b443df6..5f97ba5 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -34,16 +34,22 @@ ahead; realtime/media must not jump ahead of the actor and access contracts. ## M0 — contracts and provenance -Status: **in progress**. +Status: **implemented**. - Tera/Spaces is the renderer; `lumbridgecorp` is the control plane. - Apache-2.0 remains the outbound code license. - Every copied asset/data item gets a pinned source, license, hash, and intake 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 -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 @@ -78,8 +84,9 @@ tunneling, or tab-resume jumps; a recorded input trace replays identically. ## M3 — walkable offices and actors -Status: **collision/controller foundation and procedural actor assets -implemented; office camera/input integration and identity handoff pending**. +Status: **implemented and integrated**. City actors and office walkers share the +same profile-derived appearance and journey identity; anonymous visitors change +from crow outdoors to dog indoors, while signed-in members remain humanoid. - First-/third-person walker against the existing `Plan.blocked` wall segments. - Door traversal and city ↔ office identity handoff. @@ -93,6 +100,13 @@ soak produces no stuck or out-of-bounds actor. ## 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. - Server-authoritative actor/vehicle poses, input validation, interpolation, 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 +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 abstractions are proven. A later `PlayableAircraft` reuses flight-source data for the world but owns a separate controller and authority model. diff --git a/NOTICE b/NOTICE index 1eb4a2d..0cff6bc 100644 --- a/NOTICE +++ b/NOTICE @@ -85,6 +85,10 @@ THIRD-PARTY DEPENDENCIES three.js — MIT License — Copyright (c) 2010-2026 three.js authors 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 is not redistributed in built output. diff --git a/PROVENANCE.json b/PROVENANCE.json new file mode 100644 index 0000000..0c82d30 --- /dev/null +++ b/PROVENANCE.json @@ -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." + } + ] +} diff --git a/README.md b/README.md index e3e49a1..100b29f 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,10 @@ Two rules, and they are not stylistic: - **Do not commit logos or brand assets.** They are trademarks, not code. 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 diff --git a/package.json b/package.json index 73fbef1..1c316eb 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "provenance": "node scripts/check-provenance.mjs && node scripts/check-no-binaries.mjs", "licenses": "node scripts/check-dependency-licenses.mjs", "sbom": "npm sbom --package-lock-only --sbom-format=spdx", "test": "node --test \"src/test/*.test.ts\"", diff --git a/scripts/check-dependency-licenses.mjs b/scripts/check-dependency-licenses.mjs index c102c1c..2a088ff 100644 --- a/scripts/check-dependency-licenses.mjs +++ b/scripts/check-dependency-licenses.mjs @@ -48,6 +48,14 @@ for (const [path, entry] of Object.entries(lock.packages ?? {})) { if (typeof resolved === "string" && /^(?:git\+|github:|gitlab:|bitbucket:)/i.test(resolved)) { 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) { diff --git a/scripts/check-no-binaries.mjs b/scripts/check-no-binaries.mjs index 19b55c6..4040e20 100644 --- a/scripts/check-no-binaries.mjs +++ b/scripts/check-no-binaries.mjs @@ -44,6 +44,9 @@ const DENIED_EXTENSIONS = new Set([ ".jpg", ".jpeg", ".webp", + ".gif", + ".svg", + ".ico", ".hdr", ".exr", ".ttf", @@ -52,6 +55,9 @@ const DENIED_EXTENSIONS = new Set([ ".woff2", ".mp3", ".wav", + ".ogg", + ".mp4", + ".webm", ".fbx", ".obj", ]); diff --git a/scripts/check-provenance.mjs b/scripts/check-provenance.mjs new file mode 100644 index 0000000..8c266ad --- /dev/null +++ b/scripts/check-provenance.mjs @@ -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.`); diff --git a/server/src/realtime/service.ts b/server/src/realtime/service.ts index 9fc149a..851fd55 100644 --- a/server/src/realtime/service.ts +++ b/server/src/realtime/service.ts @@ -37,6 +37,7 @@ export interface RealtimeServiceOptions { rateWindowMs?: number; actorMotionLimits?: MotionLimits; vehicleMotionLimits?: MotionLimits; + aircraftMotionLimits?: MotionLimits; } export interface RealtimeJoinInput { @@ -103,6 +104,7 @@ interface ResolvedOptions { rateWindowMs: number; actorMotionLimits: MotionLimits; vehicleMotionLimits: MotionLimits; + aircraftMotionLimits: MotionLimits; } export interface RealtimeService { @@ -176,6 +178,13 @@ function resolveOptions(value: RealtimeServiceOptions): ResolvedOptions { positionSlackM: 4, 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 { - 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 { @@ -569,8 +580,16 @@ export function createRealtimeService(options: RealtimeServiceOptions = {}): Rea } if ( (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." }; + 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); if (localKey !== null && !session.interestKeys.has(localKey)) { 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 previous = session.proposedPoses.get(key); if (previous) { + const motionLimits = snapshot.entity === "vehicle" + ? settings.vehicleMotionLimits + : snapshot.entity === "aircraft" + ? settings.aircraftMotionLimits + : settings.actorMotionLimits; const motion = validateMotion( previous, snapshot, - snapshot.entity === "vehicle" ? settings.vehicleMotionLimits : settings.actorMotionLimits, + motionLimits, ); if (!motion.ok) { return { ok: false, code: "motion", message: `Realtime motion rejected: ${motion.reason}.` }; diff --git a/server/src/test/realtime.test.ts b/server/src/test/realtime.test.ts index fca3da1..195027f 100644 --- a/server/src/test/realtime.test.ts +++ b/server/src/test/realtime.test.ts @@ -3,6 +3,7 @@ import { createHmac } from "node:crypto"; import { after, describe, it } from "node:test"; import type { ActorPoseSnapshot, + AircraftPoseSnapshot, FloorInterest, InterestCell, 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( service: ReturnType, actorId: string, @@ -211,6 +243,63 @@ describe("authoritative realtime service", () => { 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", () => { let now = 17_000; const service = createRealtimeService({ now: () => now }); diff --git a/src/aircraft/index.ts b/src/aircraft/index.ts index fef3961..bc21238 100644 --- a/src/aircraft/index.ts +++ b/src/aircraft/index.ts @@ -40,3 +40,7 @@ export { type SceneAircraftOptions, type SceneAircraftView, } from "./sceneAircraft.ts"; +export { + createAircraftPoseSnapshot, + type AircraftRealtimeIdentity, +} from "./realtime.ts"; diff --git a/src/aircraft/realtime.ts b/src/aircraft/realtime.ts new file mode 100644 index 0000000..adef6e1 --- /dev/null +++ b/src/aircraft/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, + }; +} diff --git a/src/main.ts b/src/main.ts index c79c57a..8069ddf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -83,6 +83,7 @@ import type { ActorIdentity } from "./actors/controller.ts"; import { SRGBColorSpace, VideoTexture } from "three"; import { CALIFORNIA_AIR_ROUTE, + createAircraftPoseSnapshot, type AircraftActionSnapshot, } from "./aircraft/index.ts"; import { @@ -254,6 +255,8 @@ let cityId = "california"; */ let wantedCity = "california"; 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 markers: Marker[] = SAMPLE_MARKERS; let realtimeClient: RealtimeClient | null = null; @@ -265,6 +268,7 @@ let realtimePageActive = true; /** Page-scoped wire identities; auth subject never enters the spatial protocol. */ const realtimeActorId = crypto.randomUUID(); const realtimeVehicleId = crypto.randomUUID(); +const realtimeAircraftId = crypto.randomUUID(); /** * The buildings you can walk into, as procedural glyphs on the city. @@ -773,13 +777,7 @@ async function mountCity(id: string) { weatherWatch = null; poseEditor?.destroy(); poseEditor = null; - stopWatchingOccupancy(); - disposeOfficeScreenUi(); - officePlan?.dispose(); - officePlan = null; - office?.dispose(); - office = null; - officeAtmosphere = null; + disposeLoadedOffice(); inside = false; minimap?.dispose(); minimap = null; @@ -1185,6 +1183,12 @@ function syncJourneyVehicle(now: number): void { */ async function enterOffice() { 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) { const built = await loadOffice(); // 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) }), ...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}), }); + builtOfficeId = officeId; office.onViewChange(() => renderLegend()); officePlan = buildOfficePlan(createOfficeMinimap, office); } @@ -1410,6 +1415,18 @@ function leaveOffice() { 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. * @@ -1634,14 +1651,8 @@ async function switchOffice(id: string) { const previous = officeId; officeId = id; - // The roster belongs to the building you have left. - stopWatchingOccupancy(); - disposeOfficeScreenUi(); - officePlan?.dispose(); - officePlan = null; - office?.dispose(); - office = null; - officeAtmosphere = null; + // The roster, screen session, plan and scene all belong to the building left. + disposeLoadedOffice(); entering = true; 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; if (vehicle) { const heading = vehicle.headingDeg * Math.PI / 180; @@ -2080,7 +2101,11 @@ function realtimePose(): EntityPoseSnapshot | null { function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean { if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId; - return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId; + if (snapshot.entity === "vehicle") { + return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId; + } + return snapshot.pilotActorId === realtimeClient?.state().actorId || + snapshot.aircraftId === realtimeAircraftId; } function clearRealtimePeers(): void { @@ -2107,7 +2132,11 @@ function applyRealtimeMessage(message: ServerRealtimeMessage): void { } if (message.type === "pose-delta") { 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); } } @@ -2804,9 +2833,13 @@ canvas.addEventListener("click", () => { const marker = hoveredMarker; if (!marker) return; const id = officeIdOf(marker); - if (id === null) return; + if (id === null || entering) return; + entering = true; officeId = id; - void building(`Opening ${marker.label}…`, () => enterOffice()); + void building(`Opening ${marker.label}…`, () => enterOffice()).finally(() => { + entering = false; + renderLegend(); + }); }); // ---- Panels, plan and overlays ---------------------------------------------- diff --git a/src/realtime/interpolation.ts b/src/realtime/interpolation.ts index 8b1438d..d036c67 100644 --- a/src/realtime/interpolation.ts +++ b/src/realtime/interpolation.ts @@ -96,7 +96,9 @@ function predictPose(pose: SpatialPose, velocity: PoseVelocity, seconds: number) } 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 { diff --git a/src/realtime/protocol.ts b/src/realtime/protocol.ts index 72efc82..84102af 100644 --- a/src/realtime/protocol.ts +++ b/src/realtime/protocol.ts @@ -114,13 +114,26 @@ export function isEntityPoseSnapshot(value: unknown): value is EntityPoseSnapsho (value.kind === "humanoid" || value.kind === "dog" || value.kind === "crow") ); } + if (value.entity === "vehicle") { + return ( + string(value.vehicleId) && + value.kind === "model-x" && + (value.driverActorId === null || string(value.driverActorId)) && + finite(value.steering) && value.steering >= -1 && value.steering <= 1 && + finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000 + ); + } return ( - value.entity === "vehicle" && - string(value.vehicleId) && - value.kind === "model-x" && - (value.driverActorId === null || string(value.driverActorId)) && - finite(value.steering) && value.steering >= -1 && value.steering <= 1 && - finite(value.wheelRadians) && Math.abs(value.wheelRadians) <= 1_000_000 + 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 ); } @@ -208,7 +221,9 @@ export function validateServerRealtimeMessage( } 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 { diff --git a/src/realtime/scenePeers.ts b/src/realtime/scenePeers.ts index 49e9bfd..276e733 100644 --- a/src/realtime/scenePeers.ts +++ b/src/realtime/scenePeers.ts @@ -33,6 +33,13 @@ import { setModelXWheelRotation, type ModelXRig, } from "../assets/vehicles/index.ts"; +import { + buildElectricAircraft, + disposeElectricAircraft, + setAircraftControlSurfaces, + setAircraftFanRotation, + type ElectricAircraftRig, +} from "../aircraft/asset.ts"; import { PoseInterpolationBuffer } from "./interpolation.ts"; import { isEntityPoseSnapshot } from "./protocol.ts"; import type { @@ -79,13 +86,15 @@ type PeerVisual = | { kind: "humanoid"; rig: HumanoidRig } | { kind: "dog"; rig: DogRig } | { kind: "crow"; rig: CrowRig } - | { kind: "model-x"; rig: ModelXRig }; + | { kind: "model-x"; rig: ModelXRig } + | { kind: "electric-vtail"; rig: ElectricAircraftRig }; interface Prototypes { humanoid: HumanoidRig; dog: DogRig; crow: CrowRig; vehicle: ModelXRig; + aircraft: ElectricAircraftRig; } interface Peer { @@ -101,7 +110,9 @@ interface Peer { } 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 { @@ -130,10 +141,36 @@ function makePrototypes(): Prototypes { dog: buildDog(), crow: buildCrow(), 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 { + if (snapshot.entity === "aircraft") { + return { kind: "electric-vtail", rig: cloneAircraft(prototypes.aircraft) }; + } if (snapshot.entity === "vehicle") { const rig = cloneModelX(prototypes.vehicle); rig.root.name = "generic-black-ev"; @@ -157,7 +194,7 @@ function replaceVisual(peer: Peer, snapshot: EntityPoseSnapshot, prototypes: Pro } function animateActor( - visual: Exclude, + visual: Extract, sample: BufferedPoseSample, ): void { const speed = Math.hypot(sample.velocity.xMps, sample.velocity.zMps); @@ -262,7 +299,9 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers { peer.root.rotation.set( THREE.MathUtils.degToRad(pose.pitchDeg), -THREE.MathUtils.degToRad(pose.headingDeg), - 0, + peer.latest.entity === "aircraft" + ? -THREE.MathUtils.degToRad(peer.latest.rollDeg) + : 0, ); if (peer.visual.kind === "model-x") { 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. 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 { animateActor(peer.visual, sample); } @@ -353,6 +402,7 @@ export function createScenePeers(options: ScenePeersOptions): ScenePeers { disposeDog(prototypes.dog); disposeCrow(prototypes.crow); disposeModelX(prototypes.vehicle); + disposeElectricAircraft(prototypes.aircraft); disposed = true; }, }; diff --git a/src/realtime/types.ts b/src/realtime/types.ts index 42a83fb..3652727 100644 --- a/src/realtime/types.ts +++ b/src/realtime/types.ts @@ -2,6 +2,7 @@ export type RealtimeActorKind = "humanoid" | "dog" | "crow"; export type RealtimeVehicleKind = "model-x"; +export type RealtimeAircraftKind = "electric-vtail"; export type RealtimeMemberRole = "visitor" | "member" | "admin"; export type RealtimeRevocationReason = | "signed-out" @@ -104,7 +105,21 @@ export interface VehiclePoseSnapshot extends EntityPoseSnapshotBase { 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 { type: "join-request"; diff --git a/src/test/actorOfficeHandoff.test.ts b/src/test/actorOfficeHandoff.test.ts new file mode 100644 index 0000000..261f8fa --- /dev/null +++ b/src/test/actorOfficeHandoff.test.ts @@ -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(); + }); +}); diff --git a/src/test/aircraftRealtime.test.ts b/src/test/aircraftRealtime.test.ts new file mode 100644 index 0000000..fde1c08 --- /dev/null +++ b/src/test/aircraftRealtime.test.ts @@ -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); + }); +}); diff --git a/src/test/scenePeers.test.ts b/src/test/scenePeers.test.ts index 1a20abc..4c96fa1 100644 --- a/src/test/scenePeers.test.ts +++ b/src/test/scenePeers.test.ts @@ -5,6 +5,7 @@ import { createScenePeers, scenePeerId, type ActorPoseSnapshot, + type AircraftPoseSnapshot, type EntityPoseSnapshot, type InterestCell, 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 = {}) { return createScenePeers({ project: (lat, lng) => [lng * 2, -lat * 3], @@ -120,6 +140,23 @@ describe("remote scene peers", () => { 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", () => { const peers = fixture(); peers.upsert(actor("one", "humanoid", 1, 1_000, 0));