diff --git a/index.html b/index.html
index d732b3f..7f324a6 100644
--- a/index.html
+++ b/index.html
@@ -446,6 +446,30 @@
}
.tier.god .who { color: var(--amber-ink); }
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
+ .profile-trigger {
+ min-height: 28px;
+ padding: 4px 8px;
+ color: var(--amber-ink);
+ background: rgba(242, 177, 52, 0.1);
+ border: 1px solid rgba(242, 177, 52, 0.3);
+ border-radius: var(--r-sm);
+ font: inherit;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ cursor: pointer;
+ }
+ .profile-trigger:hover { background: rgba(242, 177, 52, 0.2); }
+ .profile-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: grid;
+ place-items: center;
+ padding: var(--s4);
+ background: rgba(5, 8, 11, 0.68);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ }
/* ---- Bottom right: what you picked, and how to drive ------------------ */
.rail {
@@ -978,6 +1002,8 @@
+
+
diff --git a/src/aircraft/asset.ts b/src/aircraft/asset.ts
new file mode 100644
index 0000000..47ca5ed
--- /dev/null
+++ b/src/aircraft/asset.ts
@@ -0,0 +1,214 @@
+/** Original procedural electric V-tail aircraft, authored in metres and facing -Z. */
+
+import * as THREE from "three";
+
+export const ELECTRIC_AIRCRAFT_METRICS = Object.freeze({
+ length: 8.4,
+ wingspan: 11.8,
+ height: 2.45,
+});
+
+export interface ElectricAircraftMaterials {
+ body: THREE.Material;
+ accent: THREE.Material;
+ glass: THREE.Material;
+ dark: THREE.Material;
+}
+
+export interface ElectricAircraftBuildOptions {
+ materials?: ElectricAircraftMaterials;
+ bodyColor?: THREE.ColorRepresentation;
+}
+
+export interface ElectricAircraftRig {
+ root: THREE.Group;
+ leftAileron: THREE.Group;
+ rightAileron: THREE.Group;
+ leftVTail: THREE.Group;
+ rightVTail: THREE.Group;
+ fans: readonly THREE.Group[];
+ ownsMaterials: boolean;
+}
+
+export interface AircraftSurfacePose {
+ roll: number;
+ pitch: number;
+ yaw: number;
+}
+
+export function createElectricAircraftMaterials(
+ bodyColor: THREE.ColorRepresentation = 0xe9edf0,
+): ElectricAircraftMaterials {
+ return {
+ body: new THREE.MeshPhysicalMaterial({
+ name: "electric-aircraft.body",
+ color: bodyColor,
+ metalness: 0.35,
+ roughness: 0.3,
+ clearcoat: 0.8,
+ }),
+ accent: new THREE.MeshStandardMaterial({
+ name: "electric-aircraft.accent",
+ color: 0x178f83,
+ metalness: 0.45,
+ roughness: 0.32,
+ }),
+ glass: new THREE.MeshPhysicalMaterial({
+ name: "electric-aircraft.glass",
+ color: 0x19323c,
+ roughness: 0.12,
+ transparent: true,
+ opacity: 0.82,
+ }),
+ dark: new THREE.MeshStandardMaterial({
+ name: "electric-aircraft.dark",
+ color: 0x1a2022,
+ metalness: 0.7,
+ roughness: 0.34,
+ }),
+ };
+}
+
+function mesh(
+ geometry: THREE.BufferGeometry,
+ material: THREE.Material,
+ name: string,
+): THREE.Mesh {
+ const value = new THREE.Mesh(geometry, material);
+ value.name = name;
+ value.castShadow = true;
+ value.receiveShadow = true;
+ return value;
+}
+
+function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.BufferGeometry {
+ const half = span / 2;
+ const vertices = new Float32Array([
+ 0, 0, -rootChord / 2, half, 0, -tipChord / 2, half, 0, tipChord / 2,
+ 0, 0, rootChord / 2, -half, 0, tipChord / 2, -half, 0, -tipChord / 2,
+ ]);
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
+ geometry.setIndex([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5]);
+ geometry.computeVertexNormals();
+ return geometry;
+}
+
+function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMaterials): THREE.Group {
+ const fan = new THREE.Group();
+ fan.name = x < 0 ? "electric-aircraft.fan-left" : "electric-aircraft.fan-right";
+ fan.position.set(x, 0.02, -0.48);
+ const nacelle = mesh(new THREE.CapsuleGeometry(0.25, 0.72, 5, 10), materials.accent, `${fan.name}:nacelle`);
+ nacelle.rotation.x = Math.PI / 2;
+ nacelle.position.set(x, 0.02, -0.12);
+ root.add(nacelle);
+ const hub = mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.14, 12), materials.dark, `${fan.name}:hub`);
+ hub.rotation.x = Math.PI / 2;
+ fan.add(hub);
+ for (let index = 0; index < 5; index += 1) {
+ const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`);
+ blade.position.y = 0.3;
+ blade.rotation.z = index * TWO_PI / 5;
+ fan.add(blade);
+ }
+ root.add(fan);
+ return fan;
+}
+
+const TWO_PI = Math.PI * 2;
+
+export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}): ElectricAircraftRig {
+ const ownsMaterials = options.materials === undefined;
+ const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor);
+ const root = new THREE.Group();
+ root.name = "electric-aircraft";
+
+ const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage");
+ fuselage.rotation.x = Math.PI / 2;
+ fuselage.position.y = 0.42;
+ root.add(fuselage);
+ const nose = mesh(new THREE.ConeGeometry(0.64, 1.7, 18), materials.accent, "electric-aircraft:nose");
+ nose.rotation.x = -Math.PI / 2;
+ nose.position.set(0, 0.42, -3.8);
+ root.add(nose);
+ const canopy = mesh(new THREE.SphereGeometry(0.68, 16, 8), materials.glass, "electric-aircraft:canopy");
+ canopy.scale.set(0.8, 0.52, 1.55);
+ canopy.position.set(0, 1.02, -0.9);
+ root.add(canopy);
+ const wing = mesh(wingGeometry(11.8, 2.25, 0.72), materials.body, "electric-aircraft:wing");
+ wing.position.set(0, 0.46, 0.05);
+ root.add(wing);
+
+ const leftAileron = new THREE.Group();
+ const rightAileron = new THREE.Group();
+ leftAileron.name = "electric-aircraft.aileron-left";
+ rightAileron.name = "electric-aircraft.aileron-right";
+ for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) {
+ joint.position.set(x, 0.48, 0.72);
+ joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`));
+ root.add(joint);
+ }
+
+ const leftVTail = new THREE.Group();
+ const rightVTail = new THREE.Group();
+ leftVTail.name = "electric-aircraft.v-tail-left";
+ rightVTail.name = "electric-aircraft.v-tail-right";
+ for (const [joint, x, tilt] of [
+ [leftVTail, -0.32, -0.72],
+ [rightVTail, 0.32, 0.72],
+ ] as const) {
+ joint.position.set(x, 0.63, 3.2);
+ joint.rotation.z = tilt;
+ const surface = mesh(new THREE.BoxGeometry(0.1, 1.85, 1.15), materials.body, `${joint.name}:surface`);
+ surface.position.y = 0.72;
+ joint.add(surface);
+ root.add(joint);
+ }
+
+ const fans = [addFan(root, -2.1, materials), addFan(root, 2.1, materials)];
+ root.traverse((item) => {
+ if (item instanceof THREE.Mesh) {
+ item.geometry.computeBoundingBox();
+ item.geometry.computeBoundingSphere();
+ }
+ });
+ return { root, leftAileron, rightAileron, leftVTail, rightVTail, fans, ownsMaterials };
+}
+
+export function setAircraftControlSurfaces(
+ rig: ElectricAircraftRig,
+ pose: Partial,
+): void {
+ const roll = THREE.MathUtils.clamp(Number.isFinite(pose.roll) ? pose.roll ?? 0 : 0, -1, 1);
+ const pitch = THREE.MathUtils.clamp(Number.isFinite(pose.pitch) ? pose.pitch ?? 0 : 0, -1, 1);
+ const yaw = THREE.MathUtils.clamp(Number.isFinite(pose.yaw) ? pose.yaw ?? 0 : 0, -1, 1);
+ rig.leftAileron.rotation.x = roll * 0.45;
+ rig.rightAileron.rotation.x = -roll * 0.45;
+ rig.leftVTail.rotation.x = pitch * 0.34 + yaw * 0.22;
+ rig.rightVTail.rotation.x = pitch * 0.34 - yaw * 0.22;
+}
+
+export function setAircraftFanRotation(rig: ElectricAircraftRig, radians: number): void {
+ const safe = Number.isFinite(radians) ? radians : 0;
+ for (const fan of rig.fans) fan.rotation.z = safe;
+}
+
+export function advanceAircraftFans(rig: ElectricAircraftRig, radians: number): void {
+ const delta = Number.isFinite(radians) ? radians : 0;
+ for (const fan of rig.fans) fan.rotation.z = (fan.rotation.z + delta) % TWO_PI;
+}
+
+export function disposeElectricAircraft(rig: ElectricAircraftRig): void {
+ const geometries = new Set();
+ const materials = new Set();
+ rig.root.traverse((item) => {
+ if (!(item instanceof THREE.Mesh)) return;
+ geometries.add(item.geometry);
+ const values = Array.isArray(item.material) ? item.material : [item.material];
+ for (const material of values) materials.add(material);
+ });
+ for (const geometry of geometries) geometry.dispose();
+ if (rig.ownsMaterials) for (const material of materials) material.dispose();
+ rig.root.removeFromParent();
+ rig.root.clear();
+}
diff --git a/src/aircraft/controller.ts b/src/aircraft/controller.ts
new file mode 100644
index 0000000..52d6eb0
--- /dev/null
+++ b/src/aircraft/controller.ts
@@ -0,0 +1,433 @@
+/** Deterministic, renderer-independent fixed-wing flight over California. */
+
+const EARTH_RADIUS_M = 6_371_000;
+const TWO_PI = Math.PI * 2;
+
+export type AircraftControlMode = "assisted" | "manual";
+export type AircraftModeRequest = "none" | AircraftControlMode;
+
+export interface AircraftGeographicPoint {
+ lat: number;
+ lng: number;
+}
+
+export interface AircraftWaypoint extends AircraftGeographicPoint {
+ id: string;
+ altitudeM: number;
+}
+
+export interface AircraftActionSnapshot {
+ throttle: number;
+ yaw: number;
+ pitch: number;
+ roll: number;
+ modeRequest: AircraftModeRequest;
+ reset: boolean;
+}
+
+export const NEUTRAL_AIRCRAFT_ACTIONS: Readonly = Object.freeze({
+ throttle: 0,
+ yaw: 0,
+ pitch: 0,
+ roll: 0,
+ modeRequest: "none",
+ reset: false,
+});
+
+export interface CaliforniaFlightEnvelope {
+ minLat: number;
+ maxLat: number;
+ minLng: number;
+ maxLng: number;
+ minAltitudeM: number;
+ maxAltitudeM: number;
+}
+
+export const DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE: Readonly =
+ Object.freeze({
+ minLat: 32.4,
+ maxLat: 42.1,
+ minLng: -124.6,
+ maxLng: -114.0,
+ minAltitudeM: 75,
+ maxAltitudeM: 6_000,
+ });
+
+export interface AircraftControllerOptions {
+ initialPosition?: Partial;
+ initialAltitudeM?: number;
+ initialHeadingDeg?: number;
+ initialSpeedMps?: number;
+ mode?: AircraftControlMode;
+ route?: readonly AircraftWaypoint[];
+ envelope?: CaliforniaFlightEnvelope;
+ minimumSpeedMps?: number;
+ maximumSpeedMps?: number;
+ assistedCruiseMps?: number;
+ assistedAltitudeM?: number;
+ fixedStepSeconds?: number;
+ maxFrameDeltaSeconds?: number;
+}
+
+export interface AircraftControllerState extends AircraftGeographicPoint {
+ altitudeM: number;
+ headingDeg: number;
+ pitchDeg: number;
+ rollDeg: number;
+ speedMps: number;
+ verticalSpeedMps: number;
+ mode: AircraftControlMode;
+ routeWaypointIndex: number;
+ routeWaypointId: string | null;
+ throttle: number;
+ yawInput: number;
+ pitchInput: number;
+ rollInput: number;
+ fanRadians: number;
+ envelopeContact: boolean;
+ elapsedSteps: number;
+}
+
+export interface AircraftControllerSnapshot extends AircraftControllerState {}
+
+export interface TimedAircraftInputFrame {
+ steps: number;
+ actions?: Partial;
+}
+
+export interface AircraftReplayResult {
+ trajectory: readonly AircraftControllerSnapshot[];
+ final: AircraftControllerSnapshot;
+}
+
+export interface AircraftCameraPose {
+ position: AircraftGeographicPoint & { altitudeM: number };
+ target: AircraftGeographicPoint & { altitudeM: number };
+ rollDeg: number;
+}
+
+interface ResolvedOptions {
+ initialPosition: AircraftGeographicPoint;
+ initialAltitudeM: number;
+ initialHeadingDeg: number;
+ initialSpeedMps: number;
+ mode: AircraftControlMode;
+ route: readonly AircraftWaypoint[];
+ envelope: CaliforniaFlightEnvelope;
+ minimumSpeedMps: number;
+ maximumSpeedMps: number;
+ assistedCruiseMps: number;
+ assistedAltitudeM: number;
+ fixedStepSeconds: number;
+ maxFrameDeltaSeconds: number;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+function finiteOr(value: number | undefined, fallback: number): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
+}
+
+function moveToward(value: number, target: number, maximumDelta: number): number {
+ if (value < target) return Math.min(value + maximumDelta, target);
+ if (value > target) return Math.max(value - maximumDelta, target);
+ return value;
+}
+
+function wrapDegrees(value: number): number {
+ return ((value % 360) + 360) % 360;
+}
+
+function signedAngleDegrees(from: number, to: number): number {
+ return ((to - from + 540) % 360) - 180;
+}
+
+function distanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
+ const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
+ const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
+ const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
+ return Math.hypot(north, east);
+}
+
+function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
+ const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
+ return wrapDegrees(Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI);
+}
+
+function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): CaliforniaFlightEnvelope {
+ const envelope = { ...(value ?? DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE) };
+ if (
+ !Number.isFinite(envelope.minLat) || !Number.isFinite(envelope.maxLat) ||
+ !Number.isFinite(envelope.minLng) || !Number.isFinite(envelope.maxLng) ||
+ !Number.isFinite(envelope.minAltitudeM) || !Number.isFinite(envelope.maxAltitudeM) ||
+ envelope.minLat >= envelope.maxLat || envelope.minLng >= envelope.maxLng ||
+ envelope.minAltitudeM >= envelope.maxAltitudeM
+ ) throw new RangeError("aircraft flight envelope must be finite and ordered");
+ return envelope;
+}
+
+function checkedRoute(route: readonly AircraftWaypoint[] | undefined): readonly AircraftWaypoint[] {
+ if (!route) return [];
+ const ids = new Set();
+ return route.map((point) => {
+ if (
+ typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
+ !Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
+ !Number.isFinite(point.altitudeM)
+ ) throw new RangeError("aircraft route waypoints must be finite with unique ids");
+ ids.add(point.id);
+ return { ...point };
+ });
+}
+
+function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
+ const envelope = checkedEnvelope(value.envelope);
+ const minimumSpeedMps = clamp(finiteOr(value.minimumSpeedMps, 20), 5, 100);
+ const maximumSpeedMps = clamp(finiteOr(value.maximumSpeedMps, 95), minimumSpeedMps, 250);
+ const assistedAltitudeM = clamp(
+ finiteOr(value.assistedAltitudeM, 1_500),
+ envelope.minAltitudeM,
+ envelope.maxAltitudeM,
+ );
+ return {
+ initialPosition: {
+ lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat),
+ lng: clamp(finiteOr(value.initialPosition?.lng, -118.2437), envelope.minLng, envelope.maxLng),
+ },
+ initialAltitudeM: clamp(finiteOr(value.initialAltitudeM, assistedAltitudeM), envelope.minAltitudeM, envelope.maxAltitudeM),
+ initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
+ initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
+ mode: value.mode === "manual" ? "manual" : "assisted",
+ route: checkedRoute(value.route),
+ envelope,
+ minimumSpeedMps,
+ maximumSpeedMps,
+ assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps),
+ assistedAltitudeM,
+ fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
+ maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1),
+ };
+}
+
+export function normalizeAircraftActions(
+ value: Partial | undefined,
+): AircraftActionSnapshot {
+ return {
+ throttle: clamp(finiteOr(value?.throttle, 0), 0, 1),
+ yaw: clamp(finiteOr(value?.yaw, 0), -1, 1),
+ pitch: clamp(finiteOr(value?.pitch, 0), -1, 1),
+ roll: clamp(finiteOr(value?.roll, 0), -1, 1),
+ modeRequest: value?.modeRequest === "manual" || value?.modeRequest === "assisted"
+ ? value.modeRequest
+ : "none",
+ reset: value?.reset === true,
+ };
+}
+
+function hasManualIntent(actions: AircraftActionSnapshot): boolean {
+ return (
+ actions.modeRequest === "manual" || actions.throttle > 0.02 ||
+ Math.abs(actions.yaw) > 0.06 || Math.abs(actions.pitch) > 0.06 || Math.abs(actions.roll) > 0.06
+ );
+}
+
+export class AircraftController {
+ private readonly options: ResolvedOptions;
+ private accumulator = 0;
+ private readonly current: AircraftControllerState;
+
+ constructor(options: AircraftControllerOptions = {}) {
+ this.options = resolveOptions(options);
+ this.current = {
+ ...this.options.initialPosition,
+ altitudeM: this.options.initialAltitudeM,
+ headingDeg: this.options.initialHeadingDeg,
+ pitchDeg: 0,
+ rollDeg: 0,
+ speedMps: this.options.initialSpeedMps,
+ verticalSpeedMps: 0,
+ mode: this.options.mode,
+ routeWaypointIndex: 0,
+ routeWaypointId: null,
+ throttle: 0,
+ yawInput: 0,
+ pitchInput: 0,
+ rollInput: 0,
+ fanRadians: 0,
+ envelopeContact: false,
+ elapsedSteps: 0,
+ };
+ this.reset();
+ }
+
+ fixedStepSeconds(): number {
+ return this.options.fixedStepSeconds;
+ }
+
+ state(): Readonly {
+ return this.current;
+ }
+
+ snapshot(): AircraftControllerSnapshot {
+ return { ...this.current };
+ }
+
+ reset(): void {
+ this.accumulator = 0;
+ Object.assign(this.current, this.options.initialPosition, {
+ altitudeM: this.options.initialAltitudeM,
+ headingDeg: this.options.initialHeadingDeg,
+ pitchDeg: 0,
+ rollDeg: 0,
+ speedMps: this.options.initialSpeedMps,
+ verticalSpeedMps: 0,
+ mode: this.options.mode,
+ routeWaypointIndex: 0,
+ routeWaypointId: this.options.route[0]?.id ?? null,
+ throttle: 0,
+ yawInput: 0,
+ pitchInput: 0,
+ rollInput: 0,
+ fanRadians: 0,
+ envelopeContact: false,
+ elapsedSteps: 0,
+ });
+ }
+
+ tick(deltaSeconds: number, actions: Partial = NEUTRAL_AIRCRAFT_ACTIONS): number {
+ if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
+ const normalized = normalizeAircraftActions(actions);
+ if (normalized.reset) {
+ this.reset();
+ return 0;
+ }
+ this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
+ let steps = 0;
+ while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
+ this.stepNormalized(normalized);
+ this.accumulator -= this.options.fixedStepSeconds;
+ steps += 1;
+ }
+ return steps;
+ }
+
+ stepFixed(actions: Partial = NEUTRAL_AIRCRAFT_ACTIONS): void {
+ const normalized = normalizeAircraftActions(actions);
+ if (normalized.reset) this.reset();
+ else this.stepNormalized(normalized);
+ }
+
+ private stepNormalized(actions: AircraftActionSnapshot): void {
+ const dt = this.options.fixedStepSeconds;
+ const manual = hasManualIntent(actions);
+ if (manual) this.current.mode = "manual";
+ else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
+
+ let throttle = actions.throttle;
+ let yaw = actions.yaw;
+ let pitch = actions.pitch;
+ let roll = actions.roll;
+ if (this.current.mode === "assisted") {
+ throttle = clamp(0.5 + (this.options.assistedCruiseMps - this.current.speedMps) / 18, 0, 1);
+ const target = this.options.route[this.current.routeWaypointIndex];
+ if (target && distanceM(this.current, target) < 3_000 && this.options.route.length > 1) {
+ this.current.routeWaypointIndex = (this.current.routeWaypointIndex + 1) % this.options.route.length;
+ }
+ const waypoint = this.options.route[this.current.routeWaypointIndex];
+ this.current.routeWaypointId = waypoint?.id ?? null;
+ const desiredHeading = waypoint ? bearingDeg(this.current, waypoint) : this.options.initialHeadingDeg;
+ const headingError = signedAngleDegrees(this.current.headingDeg, desiredHeading);
+ roll = clamp(headingError / 38, -1, 1);
+ yaw = clamp(headingError / 90, -0.45, 0.45);
+ const altitudeTarget = waypoint?.altitudeM ?? this.options.assistedAltitudeM;
+ pitch = clamp((altitudeTarget - this.current.altitudeM) / 350, -0.65, 0.65);
+ }
+
+ this.current.throttle = moveToward(this.current.throttle, throttle, 0.8 * dt);
+ this.current.yawInput = moveToward(this.current.yawInput, yaw, 2.5 * dt);
+ this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt);
+ this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt);
+
+ const targetRoll = this.current.rollInput * 58;
+ const targetPitch = this.current.pitchInput * 22;
+ this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt);
+ this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt);
+ const thrust = this.current.throttle * 8.5;
+ const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075;
+ this.current.speedMps = clamp(
+ this.current.speedMps + (thrust - drag) * dt,
+ this.options.minimumSpeedMps,
+ this.options.maximumSpeedMps,
+ );
+ const bankTurn = Math.sin(this.current.rollDeg * Math.PI / 180) * 24;
+ this.current.headingDeg = wrapDegrees(
+ this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt,
+ );
+ this.current.verticalSpeedMps = Math.sin(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
+
+ const heading = this.current.headingDeg * Math.PI / 180;
+ const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
+ const northM = Math.cos(heading) * horizontalSpeed * dt;
+ const eastM = Math.sin(heading) * horizontalSpeed * dt;
+ const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI;
+ const nextLng = this.current.lng + eastM /
+ (EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI;
+ const nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
+ const envelope = this.options.envelope;
+ this.current.envelopeContact =
+ nextLat < envelope.minLat || nextLat > envelope.maxLat ||
+ nextLng < envelope.minLng || nextLng > envelope.maxLng ||
+ nextAltitude < envelope.minAltitudeM || nextAltitude > envelope.maxAltitudeM;
+ this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat);
+ this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng);
+ this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM);
+ if (this.current.envelopeContact) {
+ this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92);
+ this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt);
+ this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt);
+ }
+ this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI;
+ this.current.elapsedSteps += 1;
+ }
+}
+
+export function replayAircraftInputs(
+ options: AircraftControllerOptions,
+ frames: readonly TimedAircraftInputFrame[],
+): AircraftReplayResult {
+ const controller = new AircraftController(options);
+ const trajectory: AircraftControllerSnapshot[] = [controller.snapshot()];
+ for (const frame of frames) {
+ const steps = Number.isFinite(frame.steps) ? Math.max(0, Math.floor(frame.steps)) : 0;
+ const held = normalizeAircraftActions(frame.actions);
+ for (let index = 0; index < steps; index += 1) {
+ controller.stepFixed(index === 0 ? held : { ...held, modeRequest: "none", reset: false });
+ trajectory.push(controller.snapshot());
+ }
+ }
+ return { trajectory, final: trajectory.at(-1) ?? controller.snapshot() };
+}
+
+/** Renderer-neutral geographic chase camera derived from an authoritative pose. */
+export function aircraftChaseCameraPose(
+ state: Readonly,
+ distanceBehindM = 24,
+ heightAboveM = 8,
+ lookAheadM = 35,
+): AircraftCameraPose {
+ const heading = state.headingDeg * Math.PI / 180;
+ const geographicOffset = (northM: number, eastM: number): AircraftGeographicPoint => ({
+ lat: state.lat + northM / EARTH_RADIUS_M * 180 / Math.PI,
+ lng: state.lng + eastM /
+ (EARTH_RADIUS_M * Math.max(0.01, Math.cos(state.lat * Math.PI / 180))) * 180 / Math.PI,
+ });
+ const behind = geographicOffset(-Math.cos(heading) * distanceBehindM, -Math.sin(heading) * distanceBehindM);
+ const ahead = geographicOffset(Math.cos(heading) * lookAheadM, Math.sin(heading) * lookAheadM);
+ return {
+ position: { ...behind, altitudeM: state.altitudeM + heightAboveM },
+ target: { ...ahead, altitudeM: state.altitudeM + state.verticalSpeedMps * 0.4 },
+ rollDeg: state.rollDeg * 0.2,
+ };
+}
diff --git a/src/aircraft/index.ts b/src/aircraft/index.ts
new file mode 100644
index 0000000..713f9cf
--- /dev/null
+++ b/src/aircraft/index.ts
@@ -0,0 +1,33 @@
+export {
+ AircraftController,
+ DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE,
+ NEUTRAL_AIRCRAFT_ACTIONS,
+ aircraftChaseCameraPose,
+ normalizeAircraftActions,
+ replayAircraftInputs,
+ type AircraftActionSnapshot,
+ type AircraftCameraPose,
+ type AircraftControlMode,
+ type AircraftControllerOptions,
+ type AircraftControllerSnapshot,
+ type AircraftControllerState,
+ type AircraftGeographicPoint,
+ type AircraftModeRequest,
+ type AircraftReplayResult,
+ type AircraftWaypoint,
+ type CaliforniaFlightEnvelope,
+ type TimedAircraftInputFrame,
+} from "./controller.ts";
+export {
+ ELECTRIC_AIRCRAFT_METRICS,
+ advanceAircraftFans,
+ buildElectricAircraft,
+ createElectricAircraftMaterials,
+ disposeElectricAircraft,
+ setAircraftControlSurfaces,
+ setAircraftFanRotation,
+ type AircraftSurfacePose,
+ type ElectricAircraftBuildOptions,
+ type ElectricAircraftMaterials,
+ type ElectricAircraftRig,
+} from "./asset.ts";
diff --git a/src/engine/scene.ts b/src/engine/scene.ts
index 6edc843..49d4890 100644
--- a/src/engine/scene.ts
+++ b/src/engine/scene.ts
@@ -65,7 +65,11 @@ import type {
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
-import type { ActorActionSnapshot, ActorControllerSnapshot } from "../actors/controller.ts";
+import type {
+ ActorActionSnapshot,
+ ActorControllerSnapshot,
+ ActorIdentity,
+} from "../actors/controller.ts";
export interface SceneOptions {
city: City;
@@ -183,6 +187,8 @@ export interface SceneHandle {
vehicleCamera(): VehicleCameraMode | null;
setActorActions(actions: Partial): void;
actorState(): Readonly | null;
+ /** Replace the visible local actor profile without disturbing its position or controls. */
+ setActorIdentity(identity: ActorIdentity): void;
setActorActive(active: boolean): void;
actorActive(): boolean;
setMarkers(markers: Marker[]): void;
@@ -580,6 +586,7 @@ export async function createScene(
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setActorActions: (actions) => { sceneActor?.setActions(actions); },
actorState: () => sceneActor?.state() ?? null,
+ setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
setActorActive(active) {
sceneActor?.setActive(active);
if (active) roadTraffic?.setFollowing(false);
diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts
index 07b87f1..ebcbbeb 100644
--- a/src/interiors/officeScene.ts
+++ b/src/interiors/officeScene.ts
@@ -87,6 +87,12 @@ import {
type OfficeWalker,
type OfficeWalkerOptions,
} from "./officeWalker.ts";
+import {
+ createOfficeMediaPresentation,
+ type MediaSurfaceDescriptor,
+ type MediaSurfaceGrant,
+ type OfficeMediaPresentation,
+} from "../media/presentation.ts";
import {
createRobotLayer,
type RobotLayer,
@@ -238,6 +244,11 @@ export interface OfficeScene extends StageScene {
depth: Depth;
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
walker: OfficeWalker | null;
+ /** Authored monitor/display props; contains no source locators. */
+ listMediaSurfaces(): MediaSurfaceDescriptor[];
+ /** Bind only a caller-owned texture plus the result of server authorization and opt-in. */
+ bindMediaSurface(screenId: string, grant: MediaSurfaceGrant, texture: THREE.VideoTexture): boolean;
+ clearMediaSurface(screenId: string): boolean;
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
views: View[];
flyTo(viewId: string): void;
@@ -474,6 +485,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}),
});
+ const mediaSurfaces: OfficeMediaPresentation = createOfficeMediaPresentation(plan, furnishings.group);
+ scene.add(mediaSurfaces.group);
/**
* The ceiling, made switchable.
@@ -765,6 +778,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
plan,
depth,
walker: officeWalker,
+ listMediaSurfaces: () => mediaSurfaces.list(),
+ bindMediaSurface: (screenId, grant, texture) => mediaSurfaces.bind(screenId, grant, texture),
+ clearMediaSurface: (screenId) => mediaSurfaces.clear(screenId),
views,
// A public office anchors nothing, because it has nobody to anchor. The
// empty map is this scene's own rather than a shared module-level one: an
@@ -831,6 +847,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
luminaires.tick(dt);
},
dispose() {
+ mediaSurfaces.dispose();
officeWalker?.dispose();
robots?.dispose();
luminaires.dispose();
diff --git a/src/main.ts b/src/main.ts
index f79017e..853f6ee 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -71,12 +71,15 @@ import {
} from "./journey/index.ts";
import {
actorKindForPresence,
+ createProfileEditor,
createDefaultLocalProfile,
loadLocalProfile,
resolveHumanoidAppearance,
saveLocalProfile,
type LocalProfile,
+ type ProfileEditor,
} from "./profile/index.ts";
+import type { ActorIdentity } from "./actors/controller.ts";
/**
* Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk.
@@ -133,6 +136,26 @@ function humanoidAppearance() {
return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null;
}
+function signedInActorIdentity(profile = localProfile): ActorIdentity {
+ const appearance = profile ? resolveHumanoidAppearance(profile.appearance) : null;
+ return {
+ id: access.subject ?? "anonymous",
+ displayName: profile?.displayName ?? access.subject ?? "Guest",
+ authenticated: access.subject !== null,
+ profile: {
+ appearance: appearance
+ ? {
+ skinTone: appearance.skinTone,
+ primaryColor: appearance.outfitColor,
+ accentColor: appearance.accentColor,
+ hairColor: appearance.hairColor,
+ bodyShape: appearance.bodyShape,
+ }
+ : { primaryColor: "#151a20", accentColor: "#f2b134" },
+ },
+ };
+}
+
function dispatchJourney(event: JourneyEvent): boolean {
const next = journeyReducer(journey, event);
if (next === journey) return false;
@@ -816,27 +839,14 @@ async function mountCity(id: string) {
city: entry.city,
actor: {
kind: actorKindForPresence(access.subject !== null, "outdoors"),
- identity: {
- id: access.subject ?? "anonymous",
- displayName: access.subject ?? "Guest",
- authenticated: access.subject !== null,
- profile: {
- appearance: access.subject === null
- ? { primaryColor: "#11151a", accentColor: "#f2b134" }
- : (() => {
- const appearance = humanoidAppearance();
- return appearance
- ? {
- skinTone: appearance.skinTone,
- primaryColor: appearance.outfitColor,
- accentColor: appearance.accentColor,
- hairColor: appearance.hairColor,
- bodyShape: appearance.bodyShape,
- }
- : { primaryColor: "#151a20", accentColor: "#f2b134" };
- })(),
- },
- },
+ identity: access.subject === null
+ ? {
+ id: "anonymous",
+ displayName: "Guest",
+ authenticated: false,
+ profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } },
+ }
+ : signedInActorIdentity(),
mode: access.subject === null ? "flight" : "ground",
position: access.subject === null ? { y: 1_200 } : { y: 0 },
minFlightAltitude: 20,
@@ -1483,6 +1493,8 @@ const driveHint = document.querySelector("#drive-hint");
const walkButton = document.querySelector("#walk");
const walkControls = document.querySelector("#walk-controls");
const walkHint = document.querySelector("#walk-hint");
+const profileOverlay = document.querySelector("#profile-overlay");
+let profileEditor: ProfileEditor | null = null;
function showDetail(text: string | null) {
const card = document.querySelector("#detail");
@@ -1777,12 +1789,20 @@ function renderCredits() {
function renderOfficeBadge() {
if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public";
+ const mediaSurfaces = inside && office !== null && office.depth === "full"
+ ? office.listMediaSurfaces()
+ : [];
// The fabricated-occupancy caption used to be here too and is now on
// `#source` — see `renderSource`. This badge keeps the message that is a call
// to action rather than a disclosure, because that one belongs beside the
// office controls and survives being missed; the other one does not.
- officeBadge.hidden = !publicOffice;
- if (!publicOffice) return;
+ officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0;
+ if (!publicOffice) {
+ if (mediaSurfaces.length === 0) return;
+ const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
+ officeBadge.textContent = `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
+ return;
+ }
officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "),
);
@@ -1823,8 +1843,16 @@ function renderTierBadge() {
if (access.subject !== null) {
const who = document.createElement("span");
who.className = "who";
- who.textContent = access.subject;
+ who.textContent = localProfile?.displayName ?? access.subject;
tierBadge.append(who);
+ if (localProfile) {
+ const customize = document.createElement("button");
+ customize.type = "button";
+ customize.className = "profile-trigger";
+ customize.textContent = "Character";
+ customize.addEventListener("click", openProfileEditor);
+ tierBadge.append(customize);
+ }
} else if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
@@ -1834,6 +1862,68 @@ function renderTierBadge() {
tierBadge.hidden = false;
}
+function applyProfilePreview(profile: LocalProfile): void {
+ if (access.subject === null || inside) return;
+ city?.setActorIdentity(signedInActorIdentity(profile));
+}
+
+async function rebuildOfficeActor(): Promise {
+ if (!inside || !office) return;
+ const wasWalking = office.walker?.active() ?? false;
+ stopWatchingOccupancy();
+ officePlan?.dispose();
+ officePlan = null;
+ office.dispose();
+ office = null;
+ officeAtmosphere = null;
+ await building("Updating your character…", () => enterOffice());
+ const rebuilt = office as OfficeScene | null;
+ if (wasWalking) rebuilt?.walker?.setActive(true);
+ renderLegend();
+}
+
+function ensureProfileEditor(): ProfileEditor | null {
+ if (profileEditor) return profileEditor;
+ if (!profileOverlay || !localProfile || access.subject === null) return null;
+ profileEditor = createProfileEditor({
+ container: profileOverlay,
+ profile: localProfile,
+ identityId: access.subject,
+ onPreview: applyProfilePreview,
+ onSave(profile) {
+ localProfile = profile;
+ saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, profile);
+ dispatchJourney({
+ type: "sign-in-actor-swap",
+ actor: {
+ id: access.subject ?? "anonymous",
+ kind: "humanoid",
+ signedIn: true,
+ profile: { displayName: profile.displayName, color: humanoidAppearance()?.accentColor },
+ },
+ });
+ city?.setActorIdentity(signedInActorIdentity(profile));
+ renderTierBadge();
+ profileOverlay.hidden = true;
+ if (inside) void rebuildOfficeActor();
+ },
+ onCancel() {
+ profileOverlay.hidden = true;
+ },
+ });
+ profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
+ return profileEditor;
+}
+
+function openProfileEditor(): void {
+ if (!localProfile || !profileOverlay) return;
+ const editor = ensureProfileEditor();
+ if (!editor) return;
+ editor.update(localProfile);
+ profileOverlay.hidden = false;
+ editor.open();
+}
+
// ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
@@ -2257,6 +2347,7 @@ window.addEventListener("keydown", (event) => {
const target = event.target;
if (
target instanceof HTMLInputElement ||
+ target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
diff --git a/src/media/index.ts b/src/media/index.ts
index b77274d..05f1485 100644
--- a/src/media/index.ts
+++ b/src/media/index.ts
@@ -11,3 +11,9 @@ export {
type MediaSurfaceBinding,
type MediaSurfaceLifecycle,
} from "./lifecycle.ts";
+export {
+ createOfficeMediaPresentation,
+ type MediaSurfaceDescriptor,
+ type MediaSurfaceGrant,
+ type OfficeMediaPresentation,
+} from "./presentation.ts";
diff --git a/src/media/presentation.ts b/src/media/presentation.ts
new file mode 100644
index 0000000..57d629e
--- /dev/null
+++ b/src/media/presentation.ts
@@ -0,0 +1,163 @@
+/**
+ * Render hooks for authored office screens.
+ *
+ * Screen faces remain batched in `Furnishings`; this layer adds one thin overlay
+ * only for each interactive monitor/display. Each overlay has an independent
+ * material, so replacing one screen never splits every other furniture batch.
+ * It consumes an already-authorized caller-owned `VideoTexture`, never a source
+ * locator, URL, stream, or identity record.
+ */
+
+import * as THREE from "three";
+import type { Plan } from "../interiors/plan.ts";
+
+const SCREEN_KINDS = new Set(["tera:screen.monitor", "tera:screen.wall-display"]);
+const PLACEHOLDER_COLOR = 0x101820;
+
+export interface MediaSurfaceDescriptor {
+ screenId: string;
+ officeId: string;
+ levelId: string;
+ roomId: string | null;
+ kind: string;
+ bound: boolean;
+}
+
+/** Minimal output of server authorization; source locators never enter this layer. */
+export interface MediaSurfaceGrant {
+ canView: boolean;
+ optedIn: boolean;
+}
+
+export interface OfficeMediaPresentation {
+ group: THREE.Group;
+ list(): MediaSurfaceDescriptor[];
+ bind(screenId: string, grant: MediaSurfaceGrant, texture: THREE.VideoTexture): boolean;
+ clear(screenId: string): boolean;
+ dispose(): void;
+}
+
+interface Surface {
+ descriptor: MediaSurfaceDescriptor;
+ mesh: THREE.Mesh;
+ material: THREE.MeshBasicMaterial;
+ /** Caller-owned. Kept only while bound and never disposed here. */
+ texture: THREE.VideoTexture | null;
+}
+
+export function createOfficeMediaPresentation(
+ plan: Plan,
+ furnishings: THREE.Object3D,
+): OfficeMediaPresentation {
+ const group = new THREE.Group();
+ group.name = "office-media-surfaces";
+ const surfaces = new Map();
+ let disposed = false;
+
+ furnishings.traverse((object) => {
+ if (!(object instanceof THREE.InstancedMesh)) return;
+ const separator = object.name.indexOf(":screenDisplay");
+ if (separator < 0) return;
+ const kind = object.name.slice(0, separator);
+ if (!SCREEN_KINDS.has(kind)) return;
+ const ids = object.userData.props as string[] | undefined;
+ if (!ids || ids.length !== object.count) return;
+
+ for (let index = 0; index < object.count; index += 1) {
+ const screenId = ids[index];
+ if (!screenId || surfaces.has(screenId)) continue;
+ const prop = plan.prop(screenId);
+ if (!prop) continue;
+ const level = plan.level(prop.levelId);
+ if (!level) continue;
+
+ const material = placeholderMaterial(screenId);
+ const mesh = new THREE.Mesh(object.geometry, material);
+ mesh.name = `media-surface:${screenId}`;
+ mesh.matrixAutoUpdate = false;
+ object.getMatrixAt(index, mesh.matrix);
+ mesh.matrixWorldNeedsUpdate = true;
+ mesh.castShadow = false;
+ mesh.receiveShadow = false;
+ mesh.renderOrder = 1;
+ const roomId = plan.roomAt(prop.levelId, prop.position)?.id ?? null;
+ const descriptor: MediaSurfaceDescriptor = {
+ screenId,
+ officeId: plan.office.id,
+ levelId: prop.levelId,
+ roomId,
+ kind,
+ bound: false,
+ };
+ mesh.userData.mediaSurface = { ...descriptor };
+ group.add(mesh);
+ surfaces.set(screenId, { descriptor, mesh, material, texture: null });
+ }
+ });
+
+ function clearSurface(surface: Surface): void {
+ if (surface.material.map !== null) {
+ surface.material.map = null;
+ surface.material.color.setHex(PLACEHOLDER_COLOR);
+ surface.material.needsUpdate = true;
+ }
+ surface.texture = null;
+ surface.descriptor.bound = false;
+ surface.mesh.userData.mediaSurface = { ...surface.descriptor };
+ }
+
+ return {
+ group,
+ list() {
+ return [...surfaces.values()].map(({ descriptor }) => ({ ...descriptor }));
+ },
+ bind(screenId, grant, texture) {
+ if (disposed) return false;
+ const surface = surfaces.get(screenId);
+ if (!surface) return false;
+ if (!grant.canView || !grant.optedIn) {
+ clearSurface(surface);
+ return false;
+ }
+ surface.texture = texture;
+ surface.material.map = texture;
+ surface.material.color.setHex(0xffffff);
+ surface.material.needsUpdate = true;
+ surface.descriptor.bound = true;
+ surface.mesh.userData.mediaSurface = { ...surface.descriptor };
+ return true;
+ },
+ clear(screenId) {
+ if (disposed) return false;
+ const surface = surfaces.get(screenId);
+ if (!surface) return false;
+ clearSurface(surface);
+ return true;
+ },
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ for (const surface of surfaces.values()) {
+ clearSurface(surface);
+ // Geometry belongs to the original furniture batch; the small overlay
+ // owns only its cloned material.
+ surface.material.dispose();
+ surface.mesh.removeFromParent();
+ }
+ surfaces.clear();
+ group.clear();
+ },
+ };
+}
+
+function placeholderMaterial(screenId: string): THREE.MeshBasicMaterial {
+ return new THREE.MeshBasicMaterial({
+ name: `media-placeholder:${screenId}`,
+ color: PLACEHOLDER_COLOR,
+ toneMapped: false,
+ polygonOffset: true,
+ polygonOffsetFactor: -1,
+ polygonOffsetUnits: -1,
+ });
+}
+
diff --git a/src/profile/editor.ts b/src/profile/editor.ts
new file mode 100644
index 0000000..9d72589
--- /dev/null
+++ b/src/profile/editor.ts
@@ -0,0 +1,427 @@
+/**
+ * Accessible, dependency-free profile editor mounted into a caller-owned node.
+ *
+ * It emits JSON profile data for a separate preview renderer. It creates no
+ * canvas, Three.js object, webcam, storage, URL, or network request. User text
+ * is assigned only through input `.value` and `textContent`; never `innerHTML`.
+ */
+import {
+ ACCENTS,
+ BODY_SHAPES,
+ HAIR_COLORS,
+ OUTFITS,
+ SKIN_TONES,
+ createDefaultLocalProfile,
+ isLocalProfile,
+ type HumanoidAppearanceChoices,
+ type LocalProfile,
+} from "./model.ts";
+
+export interface ProfileEditorOptions {
+ container: HTMLElement;
+ profile: LocalProfile;
+ /** Used only to regenerate deterministic appearance defaults; never rendered or emitted. */
+ identityId: string;
+ onPreview?: (profile: LocalProfile) => void;
+ onSave?: (profile: LocalProfile) => void;
+ onCancel?: () => void;
+}
+
+export interface ProfileEditorState {
+ open: boolean;
+ dirty: boolean;
+ valid: boolean;
+ /** Last saved/caller-supplied profile. */
+ profile: LocalProfile;
+ /** Current form values. May have an invalid display name while `valid` is false. */
+ draft: LocalProfile;
+}
+
+export interface ProfileEditor {
+ /** Stable dialog node for host layout or integration tests. */
+ root: HTMLElement;
+ state(): ProfileEditorState;
+ /** Replace both committed and draft data through strict profile validation. */
+ update(profile: LocalProfile): ProfileEditorState;
+ open(): ProfileEditorState;
+ /** Hide without committing or discarding the draft. */
+ close(): ProfileEditorState;
+ dispose(): void;
+}
+
+type AppearanceField = keyof HumanoidAppearanceChoices;
+
+const PROFILE_EDITOR_STYLES = `
+.tera-profile-editor {
+ width: min(420px, calc(100vw - 32px));
+ max-height: min(720px, calc(100dvh - 32px));
+ overflow: auto;
+ color: var(--ink, rgba(255,255,255,.82));
+ background: var(--glass-strong, rgba(9,13,18,.9));
+ border: 1px solid var(--hairline, rgba(255,255,255,.13));
+ border-radius: var(--r, 8px);
+ box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.45));
+ backdrop-filter: var(--blur, blur(14px));
+ padding: var(--s5, 24px);
+ font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
+}
+.tera-profile-editor__title { margin: 0 0 4px; color: white; font: 600 16px/1.3 inherit; }
+.tera-profile-editor__intro { margin: 0 0 20px; color: var(--ink-2, rgba(255,255,255,.62)); }
+.tera-profile-editor__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.tera-profile-editor__field { display: grid; gap: 6px; min-width: 0; }
+.tera-profile-editor__field--wide { grid-column: 1 / -1; }
+.tera-profile-editor__label { color: var(--ink, rgba(255,255,255,.82)); }
+.tera-profile-editor__input {
+ width: 100%; min-height: 40px; padding: 8px 10px; color: white;
+ background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
+ border-radius: var(--r-sm, 5px); font: inherit;
+}
+.tera-profile-editor__input:hover { border-color: rgba(255,255,255,.3); }
+.tera-profile-editor__input:focus-visible, .tera-profile-editor__button:focus-visible {
+ outline: 2px solid var(--amber, #f2b134); outline-offset: 2px;
+}
+.tera-profile-editor__input[aria-invalid="true"] { border-color: #ff8b80; }
+.tera-profile-editor__status { min-height: 18px; margin: 12px 0 0; color: #ffb2aa; }
+.tera-profile-editor__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 16px; }
+.tera-profile-editor__button {
+ min-height: 40px; padding: 8px 14px; color: var(--ink, white); cursor: pointer;
+ background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
+ border-radius: var(--r-sm, 5px); font: inherit;
+}
+.tera-profile-editor__button:hover:not(:disabled) { background: rgba(255,255,255,.12); }
+.tera-profile-editor__button--primary { color: #171109; background: var(--amber, #f2b134); border-color: transparent; font-weight: 700; }
+.tera-profile-editor__button--primary:hover:not(:disabled) { background: var(--amber-lit, #ffc555); }
+.tera-profile-editor__button:disabled { cursor: not-allowed; opacity: .42; }
+@media (max-width: 460px) { .tera-profile-editor__grid { grid-template-columns: 1fr; } .tera-profile-editor__field--wide { grid-column: auto; } }
+@media (prefers-reduced-motion: reduce) { .tera-profile-editor * { scroll-behavior: auto !important; } }
+`;
+
+const FIELD_OPTIONS: Readonly> = {
+ skinTone: SKIN_TONES,
+ outfit: OUTFITS,
+ accent: ACCENTS,
+ hair: HAIR_COLORS,
+ bodyShape: BODY_SHAPES,
+};
+
+const FIELD_LABELS: Readonly> = {
+ skinTone: "Skin tone",
+ outfit: "Outfit",
+ accent: "Accent",
+ hair: "Hair",
+ bodyShape: "Body shape",
+};
+
+let editorSequence = 0;
+
+function clone(profile: LocalProfile): LocalProfile {
+ return { ...profile, appearance: { ...profile.appearance } };
+}
+
+function presentChoice(value: string): string {
+ return value.replace(/(^|-)([a-z])/g, (_match, separator: string, letter: string) => `${separator}${letter.toUpperCase()}`);
+}
+
+function validDisplayName(value: string): boolean {
+ return (
+ value === value.trim() &&
+ value.length >= 1 &&
+ value.length <= 64 &&
+ !/[\u0000-\u001f\u007f]/u.test(value)
+ );
+}
+
+function sameProfile(a: LocalProfile, b: LocalProfile): boolean {
+ return (
+ a.version === b.version &&
+ a.displayName === b.displayName &&
+ a.appearance.skinTone === b.appearance.skinTone &&
+ a.appearance.outfit === b.appearance.outfit &&
+ a.appearance.accent === b.appearance.accent &&
+ a.appearance.hair === b.appearance.hair &&
+ a.appearance.bodyShape === b.appearance.bodyShape
+ );
+}
+
+function focusable(value: Element | null): value is HTMLElement {
+ return value !== null && typeof (value as HTMLElement).focus === "function";
+}
+
+export function createProfileEditor(options: ProfileEditorOptions): ProfileEditor {
+ if (!options.container || typeof options.container.append !== "function") {
+ throw new RangeError("profile editor: container must be an HTMLElement");
+ }
+ if (!isLocalProfile(options.profile)) throw new RangeError("profile editor: invalid profile");
+ if (typeof options.identityId !== "string" || options.identityId.length === 0) {
+ throw new RangeError("profile editor: identity id is required");
+ }
+
+ const doc = options.container.ownerDocument;
+ const number = ++editorSequence;
+ const titleId = `tera-profile-title-${number}`;
+ const descriptionId = `tera-profile-description-${number}`;
+ const statusId = `tera-profile-status-${number}`;
+ const root = doc.createElement("section");
+ root.className = "tera-profile-editor";
+ root.setAttribute("role", "dialog");
+ root.setAttribute("aria-modal", "true");
+ root.setAttribute("aria-labelledby", titleId);
+ root.setAttribute("aria-describedby", `${descriptionId} ${statusId}`);
+ root.setAttribute("aria-hidden", "true");
+ root.hidden = true;
+
+ const style = doc.createElement("style");
+ style.setAttribute("data-tera-profile-editor-style", "");
+ style.textContent = PROFILE_EDITOR_STYLES;
+ root.append(style);
+
+ const title = doc.createElement("h2");
+ title.id = titleId;
+ title.className = "tera-profile-editor__title";
+ title.textContent = "Your Tera character";
+ root.append(title);
+
+ const intro = doc.createElement("p");
+ intro.id = descriptionId;
+ intro.className = "tera-profile-editor__intro";
+ intro.textContent = "Choose how your procedural humanoid appears. Preview stays local to this page.";
+ root.append(intro);
+
+ const form = doc.createElement("form");
+ form.setAttribute("novalidate", "");
+ const grid = doc.createElement("div");
+ grid.className = "tera-profile-editor__grid";
+ form.append(grid);
+
+ const nameField = doc.createElement("div");
+ nameField.className = "tera-profile-editor__field tera-profile-editor__field--wide";
+ const nameLabel = doc.createElement("label");
+ const nameId = `tera-profile-name-${number}`;
+ nameLabel.className = "tera-profile-editor__label";
+ nameLabel.setAttribute("for", nameId);
+ nameLabel.textContent = "Display name";
+ const nameInput = doc.createElement("input");
+ nameInput.id = nameId;
+ nameInput.className = "tera-profile-editor__input";
+ nameInput.setAttribute("data-field", "displayName");
+ nameInput.setAttribute("aria-describedby", statusId);
+ nameInput.type = "text";
+ nameInput.maxLength = 64;
+ nameInput.autocomplete = "name";
+ nameField.append(nameLabel, nameInput);
+ grid.append(nameField);
+
+ const selects = {} as Record;
+ const appearanceFields = Object.keys(FIELD_OPTIONS) as AppearanceField[];
+ for (const field of appearanceFields) {
+ const wrapper = doc.createElement("div");
+ wrapper.className = "tera-profile-editor__field";
+ const label = doc.createElement("label");
+ const id = `tera-profile-${field}-${number}`;
+ label.className = "tera-profile-editor__label";
+ label.setAttribute("for", id);
+ label.textContent = FIELD_LABELS[field];
+ const select = doc.createElement("select");
+ select.id = id;
+ select.className = "tera-profile-editor__input";
+ select.setAttribute("data-field", field);
+ for (const choice of FIELD_OPTIONS[field]) {
+ const option = doc.createElement("option");
+ option.value = choice;
+ option.textContent = presentChoice(choice);
+ select.append(option);
+ }
+ selects[field] = select;
+ wrapper.append(label, select);
+ grid.append(wrapper);
+ }
+
+ const status = doc.createElement("p");
+ status.id = statusId;
+ status.className = "tera-profile-editor__status";
+ status.setAttribute("role", "status");
+ status.setAttribute("aria-live", "polite");
+ form.append(status);
+
+ const actions = doc.createElement("div");
+ actions.className = "tera-profile-editor__actions";
+ const resetButton = button(doc, "Reset appearance", "reset");
+ const cancelButton = button(doc, "Cancel", "cancel");
+ const saveButton = button(doc, "Save profile", "save", true);
+ actions.append(resetButton, cancelButton, saveButton);
+ form.append(actions);
+ root.append(form);
+ options.container.append(root);
+
+ const focusOrder: HTMLElement[] = [nameInput, ...appearanceFields.map((field) => selects[field]), resetButton, cancelButton, saveButton];
+ let committed = clone(options.profile);
+ let draft = clone(options.profile);
+ let isOpen = false;
+ let disposed = false;
+ let returnFocus: HTMLElement | null = null;
+
+ function valid(): boolean {
+ return validDisplayName(draft.displayName) && isLocalProfile(draft);
+ }
+
+ function snapshot(): ProfileEditorState {
+ return {
+ open: isOpen,
+ dirty: !sameProfile(committed, draft),
+ valid: valid(),
+ profile: clone(committed),
+ draft: clone(draft),
+ };
+ }
+
+ function render(): void {
+ nameInput.value = draft.displayName;
+ for (const field of appearanceFields) selects[field].value = draft.appearance[field];
+ const isValid = valid();
+ nameInput.setAttribute("aria-invalid", isValid ? "false" : "true");
+ saveButton.disabled = !isValid;
+ status.textContent = isValid ? "" : "Enter a name from 1 to 64 characters without leading or trailing spaces.";
+ }
+
+ function emitPreview(): void {
+ if (valid()) options.onPreview?.(clone(draft));
+ }
+
+ function assignField(field: AppearanceField, value: string): void {
+ if (!(FIELD_OPTIONS[field] as readonly string[]).includes(value)) return;
+ draft = { ...draft, appearance: { ...draft.appearance, [field]: value } } as LocalProfile;
+ render();
+ emitPreview();
+ }
+
+ function save(): void {
+ if (!valid()) {
+ render();
+ nameInput.focus();
+ return;
+ }
+ committed = clone(draft);
+ options.onSave?.(clone(committed));
+ close();
+ }
+
+ function cancel(): void {
+ draft = clone(committed);
+ render();
+ emitPreview();
+ options.onCancel?.();
+ close();
+ }
+
+ function close(): ProfileEditorState {
+ if (!isOpen || disposed) return snapshot();
+ isOpen = false;
+ root.hidden = true;
+ root.setAttribute("aria-hidden", "true");
+ const target = returnFocus;
+ returnFocus = null;
+ target?.focus();
+ return snapshot();
+ }
+
+ nameInput.addEventListener("input", () => {
+ if (disposed) return;
+ draft = { ...draft, displayName: nameInput.value };
+ render();
+ emitPreview();
+ });
+ for (const field of appearanceFields) {
+ selects[field].addEventListener("change", () => {
+ if (!disposed) assignField(field, selects[field].value);
+ });
+ }
+ resetButton.addEventListener("click", () => {
+ if (disposed) return;
+ const defaults = createDefaultLocalProfile(options.identityId, committed.displayName);
+ draft = { ...defaults, displayName: draft.displayName };
+ render();
+ emitPreview();
+ });
+ cancelButton.addEventListener("click", () => {
+ if (!disposed) cancel();
+ });
+ saveButton.addEventListener("click", () => {
+ if (!disposed) save();
+ });
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ if (!disposed) save();
+ });
+ root.addEventListener("keydown", (event) => {
+ if (disposed || !isOpen) return;
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancel();
+ return;
+ }
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ save();
+ return;
+ }
+ if (event.key !== "Tab") return;
+ const enabled = focusOrder.filter((element) => !((element as HTMLButtonElement).disabled));
+ const first = enabled[0];
+ const last = enabled[enabled.length - 1];
+ if (!first || !last) return;
+ if (event.shiftKey && doc.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && doc.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ });
+
+ render();
+
+ return {
+ root,
+ state: snapshot,
+ update(profile) {
+ if (disposed) return snapshot();
+ if (!isLocalProfile(profile)) throw new RangeError("profile editor: invalid profile");
+ committed = clone(profile);
+ draft = clone(profile);
+ render();
+ emitPreview();
+ return snapshot();
+ },
+ open() {
+ if (disposed || isOpen) return snapshot();
+ isOpen = true;
+ root.hidden = false;
+ root.setAttribute("aria-hidden", "false");
+ returnFocus = focusable(doc.activeElement) ? doc.activeElement : null;
+ nameInput.focus();
+ emitPreview();
+ return snapshot();
+ },
+ close,
+ dispose() {
+ if (disposed) return;
+ if (isOpen) close();
+ disposed = true;
+ root.remove();
+ },
+ };
+}
+
+function button(
+ doc: Document,
+ label: string,
+ action: "reset" | "cancel" | "save",
+ primary = false,
+): HTMLButtonElement {
+ const element = doc.createElement("button");
+ element.type = "button";
+ element.className = `tera-profile-editor__button${primary ? " tera-profile-editor__button--primary" : ""}`;
+ element.setAttribute("data-action", action);
+ element.textContent = label;
+ return element;
+}
diff --git a/src/profile/index.ts b/src/profile/index.ts
index 45794d5..f7d176f 100644
--- a/src/profile/index.ts
+++ b/src/profile/index.ts
@@ -49,3 +49,19 @@ export {
type WebcamFaceConsentStatus,
type WebcamFaceConsentTransition,
} from "./webcamConsent.ts";
+
+export {
+ createProfileEditor,
+ type ProfileEditor,
+ type ProfileEditorOptions,
+ type ProfileEditorState,
+} from "./editor.ts";
+
+export {
+ createWebcamFaceTexture,
+ type WebcamFaceTextureAdapter,
+ type WebcamFaceTextureBinding,
+ type WebcamFaceTextureOptions,
+ type WebcamFaceTextureState,
+ type WebcamFaceTextureStatus,
+} from "./webcamFaceTexture.ts";
diff --git a/src/profile/webcamFaceTexture.ts b/src/profile/webcamFaceTexture.ts
new file mode 100644
index 0000000..0edec50
--- /dev/null
+++ b/src/profile/webcamFaceTexture.ts
@@ -0,0 +1,153 @@
+/**
+ * Ephemeral webcam-face texture binding behind explicit consent.
+ *
+ * This adapter has no acquisition path: no `getUserMedia`, URL, fetch, canvas,
+ * recorder, upload, or storage API. A caller that already owns a stream and a
+ * video element may bind them only while the existing consent controller says
+ * active. The adapter owns the resulting Three.js texture and nothing else.
+ */
+
+import * as THREE from "three";
+import type { WebcamFaceConsentController } from "./webcamConsent.ts";
+
+export type WebcamFaceTextureStatus = "off" | "active" | "disposed";
+
+export interface WebcamFaceTextureState {
+ status: WebcamFaceTextureStatus;
+ active: boolean;
+ /** UI must show this whenever live webcam pixels are available to the scene. */
+ indicatorVisible: boolean;
+ consentRevision: number;
+ readonly ephemeral: true;
+ readonly persistence: "none";
+ readonly recording: false;
+ readonly uploading: false;
+}
+
+export interface WebcamFaceTextureBinding {
+ /** Caller-owned and never stopped. */
+ stream: MediaStream;
+ /** Caller-owned and never removed, played, paused, or disposed. */
+ video: HTMLVideoElement;
+}
+
+export interface WebcamFaceTextureAdapter {
+ state(): WebcamFaceTextureState;
+ /**
+ * Creates one adapter-owned `VideoTexture`. The caller must already have
+ * attached the stream to the video and started playback after user consent.
+ */
+ bind(binding: WebcamFaceTextureBinding): THREE.VideoTexture;
+ /** Returns null and clears immediately when consent is no longer active. */
+ texture(): THREE.VideoTexture | null;
+ /** Explicitly reconcile a consent transition and return the new state. */
+ sync(): WebcamFaceTextureState;
+ clear(): WebcamFaceTextureState;
+ dispose(): void;
+}
+
+export interface WebcamFaceTextureOptions {
+ consent: WebcamFaceConsentController;
+ /** Active-indicator hook; fires only when the visible state changes. */
+ onIndicatorChange?: (state: WebcamFaceTextureState) => void;
+}
+
+export function createWebcamFaceTexture(options: WebcamFaceTextureOptions): WebcamFaceTextureAdapter {
+ if (!options.consent || typeof options.consent.state !== "function") {
+ throw new RangeError("webcam face texture: consent controller is required");
+ }
+
+ let texture: THREE.VideoTexture | null = null;
+ let disposed = false;
+ let lastIndicator = false;
+
+ function snapshot(): WebcamFaceTextureState {
+ const consent = options.consent.state();
+ const active = !disposed && texture !== null && consent.status === "active" && consent.consentGranted;
+ return {
+ status: disposed ? "disposed" : active ? "active" : "off",
+ active,
+ indicatorVisible: active,
+ consentRevision: consent.revision,
+ ephemeral: true,
+ persistence: "none",
+ recording: false,
+ uploading: false,
+ };
+ }
+
+ function announce(): WebcamFaceTextureState {
+ const state = snapshot();
+ if (state.indicatorVisible !== lastIndicator) {
+ lastIndicator = state.indicatorVisible;
+ options.onIndicatorChange?.({ ...state });
+ }
+ return state;
+ }
+
+ function release(): void {
+ if (!texture) return;
+ // `VideoTexture.dispose()` releases only renderer resources. It does not
+ // stop tracks or dispose/mutate the caller's video element.
+ texture.dispose();
+ texture = null;
+ }
+
+ function reconcile(): WebcamFaceTextureState {
+ const consent = options.consent.state();
+ if (texture && (consent.status !== "active" || !consent.consentGranted)) release();
+ return announce();
+ }
+
+ return {
+ state: reconcile,
+ bind(binding) {
+ if (disposed) throw new Error("webcam face texture: adapter is disposed");
+ const consent = options.consent.state();
+ if (consent.status !== "active" || !consent.consentGranted) {
+ throw new Error("webcam face texture: explicit active consent is required");
+ }
+ validateBinding(binding);
+ release();
+ texture = new THREE.VideoTexture(binding.video);
+ texture.name = "ephemeral-webcam-face";
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.generateMipmaps = false;
+ announce();
+ return texture;
+ },
+ texture() {
+ reconcile();
+ return texture;
+ },
+ sync: reconcile,
+ clear() {
+ if (!disposed) release();
+ return announce();
+ },
+ dispose() {
+ if (disposed) return;
+ release();
+ disposed = true;
+ announce();
+ },
+ };
+}
+
+function validateBinding(binding: WebcamFaceTextureBinding): void {
+ if (!binding || typeof binding !== "object") throw new TypeError("webcam face texture: binding is required");
+ const stream = binding.stream as MediaStream | undefined;
+ const video = binding.video as HTMLVideoElement | undefined;
+ if (!stream || typeof stream.getVideoTracks !== "function") {
+ throw new TypeError("webcam face texture: caller-owned MediaStream is required");
+ }
+ const tracks = stream.getVideoTracks();
+ if (tracks.length === 0) throw new TypeError("webcam face texture: stream has no video track");
+ if (!video || typeof video !== "object" || !("srcObject" in video)) {
+ throw new TypeError("webcam face texture: caller-owned HTMLVideoElement is required");
+ }
+ if (video.srcObject !== stream) {
+ throw new TypeError("webcam face texture: caller must attach the supplied stream to the video first");
+ }
+}
+
diff --git a/src/test/aircraft.test.ts b/src/test/aircraft.test.ts
new file mode 100644
index 0000000..0e10afa
--- /dev/null
+++ b/src/test/aircraft.test.ts
@@ -0,0 +1,188 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import * as THREE from "three";
+import {
+ AircraftController,
+ ELECTRIC_AIRCRAFT_METRICS,
+ aircraftChaseCameraPose,
+ advanceAircraftFans,
+ buildElectricAircraft,
+ createElectricAircraftMaterials,
+ disposeElectricAircraft,
+ normalizeAircraftActions,
+ replayAircraftInputs,
+ setAircraftControlSurfaces,
+ setAircraftFanRotation,
+} from "../aircraft/index.ts";
+
+const ROUTE = [
+ { id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_300 },
+ { id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_700 },
+] as const;
+
+describe("procedural electric aircraft", () => {
+ it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
+ const rig = buildElectricAircraft();
+ assert.equal(rig.root.name, "electric-aircraft");
+ assert.equal(rig.fans.length, 2);
+ assert.equal(rig.ownsMaterials, true);
+ assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
+ const bounds = new THREE.Box3().setFromObject(rig.root);
+ const size = bounds.getSize(new THREE.Vector3());
+ assert.ok(size.x > 10);
+ assert.ok(size.z > 7);
+ assert.ok(size.y > 1.5);
+ disposeElectricAircraft(rig);
+ assert.equal(rig.root.children.length, 0);
+ });
+
+ it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
+ const rig = buildElectricAircraft();
+ setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
+ assert.ok(rig.leftAileron.rotation.x > 0);
+ assert.ok(rig.rightAileron.rotation.x < 0);
+ assert.notEqual(rig.leftVTail.rotation.x, rig.rightVTail.rotation.x);
+ setAircraftFanRotation(rig, 1);
+ advanceAircraftFans(rig, 0.5);
+ assert.equal(rig.fans[0]?.rotation.z, 1.5);
+ assert.equal(rig.fans[1]?.rotation.z, 1.5);
+ disposeElectricAircraft(rig);
+ });
+
+ it("does not dispose caller-owned materials", () => {
+ const materials = createElectricAircraftMaterials();
+ let disposals = 0;
+ for (const material of Object.values(materials)) {
+ material.addEventListener("dispose", () => { disposals += 1; });
+ }
+ const rig = buildElectricAircraft({ materials });
+ assert.equal(rig.ownsMaterials, false);
+ disposeElectricAircraft(rig);
+ assert.equal(disposals, 0);
+ for (const material of Object.values(materials)) material.dispose();
+ });
+});
+
+describe("aircraft controller", () => {
+ it("normalizes device-neutral flight axes", () => {
+ assert.deepEqual(normalizeAircraftActions({
+ throttle: 5,
+ yaw: -4,
+ pitch: Number.NaN,
+ roll: 2,
+ modeRequest: "manual",
+ reset: true,
+ }), {
+ throttle: 1,
+ yaw: -1,
+ pitch: 0,
+ roll: 1,
+ modeRequest: "manual",
+ reset: true,
+ });
+ });
+
+ it("follows route and altitude deterministically in assisted mode", () => {
+ const options = {
+ route: ROUTE,
+ initialPosition: ROUTE[0],
+ initialHeadingDeg: 0,
+ initialAltitudeM: 900,
+ assistedAltitudeM: 1_300,
+ };
+ const a = new AircraftController(options);
+ const b = new AircraftController(options);
+ for (let index = 0; index < 600; index += 1) {
+ a.stepFixed();
+ b.stepFixed();
+ }
+ assert.deepEqual(a.snapshot(), b.snapshot());
+ assert.equal(a.state().mode, "assisted");
+ assert.equal(a.state().routeWaypointId, "san-francisco");
+ assert.ok(a.state().altitudeM > 900);
+ assert.ok(a.state().headingDeg > 180, "aircraft turns northwest toward San Francisco");
+ });
+
+ it("takes manual input immediately and resumes assistance without a state jump", () => {
+ const controller = new AircraftController({ route: ROUTE });
+ for (let index = 0; index < 120; index += 1) {
+ controller.stepFixed({ throttle: 0.8, roll: 0.7, pitch: -0.3 });
+ }
+ assert.equal(controller.state().mode, "manual");
+ const before = controller.snapshot();
+ controller.stepFixed({ modeRequest: "assisted", roll: 0.8 });
+ assert.equal(controller.state().mode, "manual", "manual controls beat simultaneous resume");
+ controller.stepFixed({ modeRequest: "assisted" });
+ assert.equal(controller.state().mode, "assisted");
+ assert.ok(Math.abs(controller.state().rollDeg - before.rollDeg) < 3);
+ assert.ok(Math.abs(controller.state().pitchDeg - before.pitchDeg) < 3);
+ });
+
+ it("enforces geographic, altitude, and speed boundaries", () => {
+ const controller = new AircraftController({
+ mode: "manual",
+ initialPosition: { lat: 100, lng: -200 },
+ initialAltitudeM: 100_000,
+ initialSpeedMps: 1_000,
+ maximumSpeedMps: 80,
+ });
+ assert.equal(controller.state().lat, 42.1);
+ assert.equal(controller.state().lng, -124.6);
+ assert.equal(controller.state().altitudeM, 6_000);
+ assert.equal(controller.state().speedMps, 80);
+ for (let index = 0; index < 180; index += 1) {
+ controller.stepFixed({ throttle: 1, pitch: 1, roll: 1 });
+ }
+ assert.ok(controller.state().lat >= 32.4 && controller.state().lat <= 42.1);
+ assert.ok(controller.state().lng >= -124.6 && controller.state().lng <= -114);
+ assert.ok(controller.state().altitudeM >= 75 && controller.state().altitudeM <= 6_000);
+ assert.ok(controller.state().speedMps <= 80);
+ assert.equal(controller.state().envelopeContact, true);
+ });
+
+ it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => {
+ const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const;
+ const controller = new AircraftController(options);
+ const spawn = controller.snapshot();
+ assert.ok(controller.tick(600) <= 15);
+ controller.stepFixed({ throttle: 1, roll: -1 });
+ controller.stepFixed({ reset: true });
+ assert.deepEqual(controller.snapshot(), spawn);
+
+ const frames = [
+ { steps: 90, actions: { throttle: 0.9, roll: 0.4 } },
+ { steps: 1, actions: { modeRequest: "assisted" as const } },
+ { steps: 180 },
+ ];
+ assert.deepEqual(
+ replayAircraftInputs(options, frames),
+ replayAircraftInputs(options, frames),
+ );
+ });
+
+ it("derives a finite geographic chase camera behind the aircraft", () => {
+ const controller = new AircraftController({ initialHeadingDeg: 0 });
+ const camera = aircraftChaseCameraPose(controller.state());
+ assert.ok(camera.position.lat < controller.state().lat);
+ assert.ok(camera.target.lat > controller.state().lat);
+ assert.ok(camera.position.altitudeM > controller.state().altitudeM);
+ assert.ok(Object.values(camera.position).every(Number.isFinite));
+ assert.ok(Object.values(camera.target).every(Number.isFinite));
+ });
+
+ it("rejects malformed route and envelope configuration", () => {
+ assert.throws(() => new AircraftController({
+ route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
+ }), /waypoints/);
+ assert.throws(() => new AircraftController({
+ envelope: {
+ minLat: 40,
+ maxLat: 30,
+ minLng: -124,
+ maxLng: -114,
+ minAltitudeM: 10,
+ maxAltitudeM: 100,
+ },
+ }), /envelope/);
+ });
+});
diff --git a/src/test/mediaPresentation.test.ts b/src/test/mediaPresentation.test.ts
new file mode 100644
index 0000000..92ba2b6
--- /dev/null
+++ b/src/test/mediaPresentation.test.ts
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import * as THREE from "three";
+import { MaterialRegistry } from "../assets/materials.ts";
+import "../assets/office/index.ts";
+import { createFurnishings } from "../interiors/furnish.ts";
+import { Plan } from "../interiors/plan.ts";
+import type { Level, Office, Room } from "../interiors/types.ts";
+import { createOfficeMediaPresentation } from "../media/presentation.ts";
+
+const ROOM: Room = {
+ id: "room",
+ name: "Room",
+ floor: "floor" as never,
+ outline: [{ x: 0, z: 0 }, { x: 8, z: 0 }, { x: 8, z: 6 }, { x: 0, z: 6 }],
+};
+
+function fixture(depth: "full" | "public" = "full") {
+ const level: Level = {
+ id: "ground",
+ name: "Ground",
+ elevation: 0,
+ wallHeight: 3,
+ wallThickness: 0.1,
+ floorplan: { rooms: [ROOM], walls: [], props: [
+ { id: "wall-display", kind: "tera:screen.wall-display", position: { x: 2, z: 2 }, rotation: 0 },
+ { id: "desk-monitor", kind: "tera:screen.monitor", position: { x: 4, z: 2 }, rotation: 0, elevation: 0.73 },
+ { id: "ordinary-desk", kind: "tera:desk.workstation", position: { x: 6, z: 2 }, rotation: 0 },
+ {
+ id: "private-display",
+ kind: "tera:screen.wall-display",
+ position: { x: 2, z: 4 },
+ rotation: 0,
+ audience: "private",
+ },
+ ] },
+ };
+ const office: Office = { id: "media-office", name: "Media Office", levels: [level], viewpoints: [] };
+ const plan = new Plan(office, { depth, warn: false });
+ const materials = new MaterialRegistry({ quality: "low" });
+ const furnishings = createFurnishings(plan, { materials });
+ const presentation = createOfficeMediaPresentation(plan, furnishings.group);
+ return { plan, materials, furnishings, presentation };
+}
+
+describe("procedural office media presentation", () => {
+ it("discovers only authored screen props and keeps exact prop ids and rooms", () => {
+ const f = fixture();
+ const listed = f.presentation.list();
+ assert.deepEqual(listed.map((item) => item.screenId).sort(), ["desk-monitor", "private-display", "wall-display"]);
+ assert.ok(listed.every((item) => item.officeId === "media-office" && item.roomId === "room"));
+ assert.ok(!listed.some((item) => item.screenId === "ordinary-desk"));
+ assert.equal(f.presentation.group.children.length, 3);
+ f.presentation.dispose();
+ f.furnishings.dispose();
+ f.materials.dispose();
+ });
+
+ it("keeps public depth private-prop-free by discovering only the resolved plan", () => {
+ const f = fixture("public");
+ assert.deepEqual(f.presentation.list().map((item) => item.screenId).sort(), ["desk-monitor", "wall-display"]);
+ f.presentation.dispose();
+ f.furnishings.dispose();
+ f.materials.dispose();
+ });
+
+ it("requires authorization plus opt-in and returns to a safe placeholder", () => {
+ const f = fixture();
+ let textureDisposals = 0;
+ const texture = new THREE.VideoTexture({} as HTMLVideoElement);
+ texture.dispose = () => { textureDisposals += 1; };
+ assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: false }, texture), false);
+ assert.equal(f.presentation.list().find((item) => item.screenId === "wall-display")?.bound, false);
+ assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: true }, texture), true);
+ const mesh = f.presentation.group.getObjectByName("media-surface:wall-display") as THREE.Mesh;
+ assert.equal((mesh.material as THREE.MeshBasicMaterial).map, texture);
+ assert.equal(f.presentation.clear("wall-display"), true);
+ assert.equal((mesh.material as THREE.MeshBasicMaterial).map, null);
+ assert.equal(textureDisposals, 0);
+ f.presentation.dispose();
+ assert.equal(textureDisposals, 0, "caller-owned texture was disposed");
+ f.furnishings.dispose();
+ f.materials.dispose();
+ });
+
+ it("returns defensive descriptors and contains unknown/disposed operations", () => {
+ const f = fixture();
+ const leaked = f.presentation.list()[0];
+ assert.ok(leaked);
+ leaked.screenId = "changed";
+ assert.notEqual(f.presentation.list()[0]?.screenId, "changed");
+ const texture = new THREE.VideoTexture({} as HTMLVideoElement);
+ assert.equal(f.presentation.bind("missing", { canView: true, optedIn: true }, texture), false);
+ f.presentation.dispose();
+ f.presentation.dispose();
+ assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: true }, texture), false);
+ assert.equal(f.presentation.clear("wall-display"), false);
+ f.furnishings.dispose();
+ f.materials.dispose();
+ texture.dispose();
+ });
+});
diff --git a/src/test/profileEditor.test.ts b/src/test/profileEditor.test.ts
new file mode 100644
index 0000000..ae5fc00
--- /dev/null
+++ b/src/test/profileEditor.test.ts
@@ -0,0 +1,241 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ ACCENTS,
+ BODY_SHAPES,
+ HAIR_COLORS,
+ OUTFITS,
+ SKIN_TONES,
+ createDefaultLocalProfile,
+ createProfileEditor,
+ type LocalProfile,
+} from "../profile/index.ts";
+
+type Listener = (event: FakeEvent) => void;
+
+class FakeEvent {
+ defaultPrevented = false;
+ readonly type: string;
+ readonly target: FakeElement;
+ readonly key: string;
+ readonly ctrlKey: boolean;
+ readonly metaKey: boolean;
+ readonly shiftKey: boolean;
+ constructor(
+ type: string,
+ target: FakeElement,
+ key = "",
+ ctrlKey = false,
+ metaKey = false,
+ shiftKey = false,
+ ) {
+ this.type = type;
+ this.target = target;
+ this.key = key;
+ this.ctrlKey = ctrlKey;
+ this.metaKey = metaKey;
+ this.shiftKey = shiftKey;
+ }
+ preventDefault(): void { this.defaultPrevented = true; }
+}
+
+class FakeElement {
+ readonly children: FakeElement[] = [];
+ readonly attributes = new Map();
+ readonly listeners = new Map();
+ parentElement: FakeElement | null = null;
+ className = "";
+ textContent = "";
+ hidden = false;
+ value = "";
+ id = "";
+ type = "";
+ maxLength = 0;
+ autocomplete = "";
+ disabled = false;
+ readonly ownerDocument: FakeDocument;
+ readonly tagName: string;
+
+ constructor(ownerDocument: FakeDocument, tagName: string) {
+ this.ownerDocument = ownerDocument;
+ this.tagName = tagName;
+ }
+
+ append(...nodes: FakeElement[]): void {
+ for (const node of nodes) {
+ node.parentElement = this;
+ this.children.push(node);
+ }
+ }
+ setAttribute(name: string, value: string): void { this.attributes.set(name, value); }
+ getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; }
+ addEventListener(type: string, listener: Listener): void {
+ const found = this.listeners.get(type);
+ if (found) found.push(listener);
+ else this.listeners.set(type, [listener]);
+ }
+ dispatch(type: string, init: Partial> = {}): FakeEvent {
+ const event = new FakeEvent(type, this, init.key, init.ctrlKey, init.metaKey, init.shiftKey);
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
+ return event;
+ }
+ focus(): void { this.ownerDocument.activeElement = this; }
+ remove(): void {
+ if (!this.parentElement) return;
+ const index = this.parentElement.children.indexOf(this);
+ if (index >= 0) this.parentElement.children.splice(index, 1);
+ this.parentElement = null;
+ }
+ find(attribute: string, value: string): FakeElement {
+ if (this.attributes.get(attribute) === value) return this;
+ for (const child of this.children) {
+ try { return child.find(attribute, value); } catch { /* continue */ }
+ }
+ throw new Error(`missing [${attribute}=${value}]`);
+ }
+ descendants(tagName: string): FakeElement[] {
+ const result: FakeElement[] = [];
+ for (const child of this.children) {
+ if (child.tagName === tagName.toUpperCase()) result.push(child);
+ result.push(...child.descendants(tagName));
+ }
+ return result;
+ }
+}
+
+class FakeDocument {
+ activeElement: FakeElement | null = null;
+ createElement(tagName: string): FakeElement { return new FakeElement(this, tagName.toUpperCase()); }
+}
+
+function setup(profile = createDefaultLocalProfile("member-22", "Avery")) {
+ const document = new FakeDocument();
+ const container = document.createElement("div");
+ const previews: LocalProfile[] = [];
+ const saves: LocalProfile[] = [];
+ let cancels = 0;
+ const editor = createProfileEditor({
+ container: container as unknown as HTMLElement,
+ profile,
+ identityId: "member-22",
+ onPreview: (next) => previews.push(next),
+ onSave: (next) => saves.push(next),
+ onCancel: () => cancels++,
+ });
+ return { document, container, editor, previews, saves, cancels: () => cancels };
+}
+
+describe("profile editor DOM adapter", () => {
+ it("builds a closed labelled dialog with every enumerated appearance choice", () => {
+ const { container, editor } = setup();
+ const root = editor.root as unknown as FakeElement;
+ assert.equal(container.children.length, 1);
+ assert.equal(root.hidden, true);
+ assert.equal(root.getAttribute("role"), "dialog");
+ assert.equal(root.getAttribute("aria-modal"), "true");
+ assert.ok(root.getAttribute("aria-labelledby"));
+ assert.equal(root.find("data-field", "displayName").getAttribute("aria-describedby"), root.descendants("p")[1]?.id);
+ assert.equal(root.find("data-field", "skinTone").children.length, SKIN_TONES.length);
+ assert.equal(root.find("data-field", "outfit").children.length, OUTFITS.length);
+ assert.equal(root.find("data-field", "accent").children.length, ACCENTS.length);
+ assert.equal(root.find("data-field", "hair").children.length, HAIR_COLORS.length);
+ assert.equal(root.find("data-field", "bodyShape").children.length, BODY_SHAPES.length);
+ assert.equal(editor.state().open, false);
+ });
+
+ it("opens with focus, edits preview data, saves, and restores invoker focus", () => {
+ const { document, editor, previews, saves } = setup();
+ const trigger = document.createElement("button");
+ trigger.focus();
+ editor.open();
+ const root = editor.root as unknown as FakeElement;
+ const name = root.find("data-field", "displayName");
+ assert.equal(document.activeElement, name);
+ assert.equal(root.getAttribute("aria-hidden"), "false");
+
+ name.value = "Avery Chen";
+ name.dispatch("input");
+ const outfit = root.find("data-field", "outfit");
+ outfit.value = "sage";
+ outfit.dispatch("change");
+ assert.equal(editor.state().draft.displayName, "Avery Chen");
+ assert.equal(editor.state().draft.appearance.outfit, "sage");
+ assert.equal(editor.state().dirty, true);
+ assert.equal(previews.at(-1)?.appearance.outfit, "sage");
+
+ root.find("data-action", "save").dispatch("click");
+ assert.equal(saves.length, 1);
+ assert.equal(saves[0]?.displayName, "Avery Chen");
+ assert.equal(editor.state().open, false);
+ assert.equal(document.activeElement, trigger);
+ });
+
+ it("blocks invalid names and exposes accessible validation", () => {
+ const { document, editor, saves } = setup();
+ editor.open();
+ const root = editor.root as unknown as FakeElement;
+ const name = root.find("data-field", "displayName");
+ const save = root.find("data-action", "save");
+ name.value = " ";
+ name.dispatch("input");
+ assert.equal(editor.state().valid, false);
+ assert.equal(name.getAttribute("aria-invalid"), "true");
+ assert.equal(save.disabled, true);
+ // Form submit covers Enter/assistive submit even though a disabled click cannot fire in a browser.
+ root.descendants("form")[0]?.dispatch("submit");
+ assert.equal(saves.length, 0);
+ assert.equal(document.activeElement, name);
+ });
+
+ it("resets appearance deterministically and Escape cancels the draft", () => {
+ const profile = createDefaultLocalProfile("some-other-id", "River");
+ const { editor, previews, cancels } = setup(profile);
+ editor.open();
+ const root = editor.root as unknown as FakeElement;
+ const outfit = root.find("data-field", "outfit");
+ outfit.value = outfit.value === "ink" ? "clay" : "ink";
+ outfit.dispatch("change");
+ const name = root.find("data-field", "displayName");
+ name.value = "River Two";
+ name.dispatch("input");
+ root.find("data-action", "reset").dispatch("click");
+ assert.deepEqual(editor.state().draft.appearance, createDefaultLocalProfile("member-22", "River").appearance);
+ assert.equal(editor.state().draft.displayName, "River Two");
+
+ const event = root.dispatch("keydown", { key: "Escape" });
+ assert.equal(event.defaultPrevented, true);
+ assert.equal(cancels(), 1);
+ assert.deepEqual(editor.state().draft, profile);
+ assert.deepEqual(previews.at(-1), profile);
+ });
+
+ it("traps keyboard focus and supports command-enter save", () => {
+ const { document, editor, saves } = setup();
+ editor.open();
+ const root = editor.root as unknown as FakeElement;
+ const first = root.find("data-field", "displayName");
+ const last = root.find("data-action", "save");
+ last.focus();
+ assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
+ assert.equal(document.activeElement, first);
+ first.focus();
+ assert.equal(root.dispatch("keydown", { key: "Tab", shiftKey: true }).defaultPrevented, true);
+ assert.equal(document.activeElement, last);
+ assert.equal(root.dispatch("keydown", { key: "Enter", ctrlKey: true }).defaultPrevented, true);
+ assert.equal(saves.length, 1);
+ });
+
+ it("strictly updates caller data, then disposes without leaving DOM", () => {
+ const { container, editor, previews } = setup();
+ const next = createDefaultLocalProfile("new-person", "Morgan");
+ const updated = editor.update(next);
+ assert.deepEqual(updated.profile, next);
+ assert.deepEqual(updated.draft, next);
+ assert.deepEqual(previews.at(-1), next);
+ assert.throws(() => editor.update({ ...next, displayName: "" }), RangeError);
+ editor.open();
+ editor.dispose();
+ editor.dispose();
+ assert.equal(container.children.length, 0);
+ });
+});
diff --git a/src/test/webcamFaceTexture.test.ts b/src/test/webcamFaceTexture.test.ts
new file mode 100644
index 0000000..0e5eade
--- /dev/null
+++ b/src/test/webcamFaceTexture.test.ts
@@ -0,0 +1,108 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ createWebcamFaceConsent,
+ createWebcamFaceTexture,
+ type WebcamFaceTextureState,
+} from "../profile/index.ts";
+
+function media() {
+ let stops = 0;
+ let pauses = 0;
+ const track = {
+ kind: "video",
+ stop: () => { stops += 1; },
+ } as unknown as MediaStreamTrack;
+ const stream = {
+ getVideoTracks: () => [track],
+ getTracks: () => [track],
+ } as unknown as MediaStream;
+ const video = {
+ srcObject: stream,
+ pause: () => { pauses += 1; },
+ } as unknown as HTMLVideoElement;
+ return { stream, video, stops: () => stops, pauses: () => pauses };
+}
+
+describe("ephemeral webcam face texture", () => {
+ it("is off by default and refuses media before explicit active consent", () => {
+ const consent = createWebcamFaceConsent();
+ const adapter = createWebcamFaceTexture({ consent });
+ const input = media();
+ assert.deepEqual(adapter.state(), {
+ status: "off",
+ active: false,
+ indicatorVisible: false,
+ consentRevision: 0,
+ ephemeral: true,
+ persistence: "none",
+ recording: false,
+ uploading: false,
+ });
+ assert.throws(() => adapter.bind(input), /active consent/);
+ assert.equal(adapter.texture(), null);
+ adapter.dispose();
+ });
+
+ it("accepts only caller-attached video after consent and owns only its texture", () => {
+ const consent = createWebcamFaceConsent();
+ consent.requestStart();
+ consent.start(true);
+ const input = media();
+ const wrongVideo = { srcObject: null } as HTMLVideoElement;
+ const adapter = createWebcamFaceTexture({ consent });
+ assert.throws(() => adapter.bind({ stream: input.stream, video: wrongVideo }), /attach/);
+ const texture = adapter.bind(input);
+ let textureDisposals = 0;
+ const dispose = texture.dispose.bind(texture);
+ texture.dispose = () => { textureDisposals += 1; dispose(); };
+ assert.equal(adapter.texture(), texture);
+ assert.equal(adapter.state().active, true);
+ adapter.clear();
+ assert.equal(textureDisposals, 1);
+ assert.equal(adapter.texture(), null);
+ assert.equal(input.stops(), 0, "caller-owned track was stopped");
+ assert.equal(input.pauses(), 0, "caller-owned video was paused");
+ adapter.dispose();
+ });
+
+ it("clears on stop/revoke and publishes defensive active-indicator transitions", () => {
+ const consent = createWebcamFaceConsent();
+ consent.requestStart();
+ consent.start(true);
+ const input = media();
+ const indicators: WebcamFaceTextureState[] = [];
+ const adapter = createWebcamFaceTexture({
+ consent,
+ onIndicatorChange: (state) => {
+ state.status = "disposed";
+ indicators.push({ ...state });
+ },
+ });
+ adapter.bind(input);
+ assert.equal(adapter.state().status, "active", "callback mutation escaped into adapter");
+ consent.stop();
+ assert.equal(adapter.sync().active, false);
+ assert.equal(adapter.texture(), null);
+ assert.equal(input.stops(), 0);
+ assert.deepEqual(indicators.map((state) => state.indicatorVisible), [true, false]);
+ adapter.dispose();
+ });
+
+ it("is idempotent after disposal and rejects reuse", () => {
+ const consent = createWebcamFaceConsent();
+ consent.requestStart();
+ consent.start(true);
+ const input = media();
+ const adapter = createWebcamFaceTexture({ consent });
+ adapter.bind(input);
+ adapter.dispose();
+ adapter.dispose();
+ assert.equal(adapter.state().status, "disposed");
+ assert.equal(adapter.texture(), null);
+ assert.throws(() => adapter.bind(input), /disposed/);
+ assert.equal(input.stops(), 0);
+ assert.equal(input.pauses(), 0);
+ });
+});
+