1
0

feat: add playable flight and office screen sharing

This commit is contained in:
2026-08-11 19:46:47 -07:00
parent c0c7fcf974
commit 16dc85a6f8
13 changed files with 1355 additions and 45 deletions
+160
View File
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createOfficeScreenPanel, type MediaSurfaceDescriptor } from "../media/index.ts";
type Listener = (event: FakeEvent) => void;
class FakeEvent {
defaultPrevented = false;
readonly type: string;
readonly target: FakeElement;
readonly key: string;
readonly shiftKey: boolean;
constructor(
type: string,
target: FakeElement,
key = "",
shiftKey = false,
) {
this.type = type;
this.target = target;
this.key = key;
this.shiftKey = shiftKey;
}
preventDefault(): void { this.defaultPrevented = true; }
}
class FakeElement {
readonly children: FakeElement[] = [];
readonly attributes = new Map<string, string>();
readonly listeners = new Map<string, Listener[]>();
parentElement: FakeElement | null = null;
className = "";
textContent = "";
hidden = false;
disabled = false;
type = "";
id = "";
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 list = this.listeners.get(type); if (list) list.push(listener); else this.listeners.set(type, [listener]);
}
dispatch(type: string, init: { key?: string; shiftKey?: boolean } = {}): FakeEvent {
const event = new FakeEvent(type, this, init.key, 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 { /* next */ } }
throw new Error(`missing [${attribute}=${value}]`);
}
text(): string { return this.textContent + this.children.map((child) => child.text()).join(""); }
}
class FakeDocument {
activeElement: FakeElement | null = null;
createElement(tag: string): FakeElement { return new FakeElement(this, tag.toUpperCase()); }
}
const SURFACES: MediaSurfaceDescriptor[] = [
{ screenId: "lobby-monitor", officeId: "hq", levelId: "level-1", roomId: "Lobby", kind: "tera:screen.monitor", bound: false },
{ screenId: "commons-display", officeId: "hq", levelId: "level-2", roomId: "Commons", kind: "tera:screen.wall-display", bound: true },
];
function setup() {
const document = new FakeDocument();
const container = document.createElement("div");
const selected: string[] = [];
const shares: string[] = [];
const stops: string[] = [];
const opts: [string, boolean][] = [];
const panel = createOfficeScreenPanel({
container: container as unknown as HTMLElement,
surfaces: SURFACES,
onSelect: (surface) => selected.push(surface.screenId),
onRequestShare: (surface) => shares.push(surface.screenId),
onStopShare: (surface) => stops.push(surface.screenId),
onViewerOptIn: (surface, value) => opts.push([surface.screenId, value]),
});
return { document, container, panel, selected, shares, stops, opts };
}
describe("office screen manager panel", () => {
it("builds a closed labelled dialog with safe screen, room, and status text", () => {
const { container, panel } = setup();
const root = panel.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.match(root.text(), /lobby-monitor/);
assert.match(root.text(), /Lobby/);
assert.match(root.text(), /Media off/);
assert.match(root.text(), /Media active/);
});
it("selects, explicitly opts in, and emits share/stop intent without capture", () => {
const { panel, selected, shares, stops, opts } = setup();
panel.open();
const root = panel.root as unknown as FakeElement;
root.find("data-screen-id", "commons-display").dispatch("click");
root.find("data-action", "opt-in").dispatch("click");
root.find("data-action", "share").dispatch("click");
root.find("data-action", "stop").dispatch("click");
assert.deepEqual(selected, ["commons-display"]);
assert.deepEqual(opts, [["commons-display", true]]);
assert.deepEqual(shares, ["commons-display"]);
assert.deepEqual(stops, ["commons-display"]);
assert.deepEqual(panel.state().optedInScreenIds, ["commons-display"]);
assert.equal("mediaDevices" in panel, false);
});
it("updates defensively, removes stale consent, and represents an empty office", () => {
const { panel } = setup();
const root = panel.root as unknown as FakeElement;
root.find("data-action", "opt-in").dispatch("click");
const next = [{ ...SURFACES[1]!, screenId: "safe-text-<script>" }];
panel.update(next);
next[0]!.screenId = "mutated";
assert.equal(panel.state().selectedId, "safe-text-<script>");
assert.deepEqual(panel.state().optedInScreenIds, []);
assert.match(root.text(), /safe-text-<script>/);
panel.update([]);
assert.equal(panel.state().selectedId, null);
assert.match(root.text(), /No authored office screens/);
});
it("traps tab focus, closes on Escape, restores focus, and disposes idempotently", () => {
const { document, container, panel } = setup();
const trigger = document.createElement("button");
trigger.focus();
panel.open();
const root = panel.root as unknown as FakeElement;
const first = root.find("data-screen-id", "lobby-monitor");
const last = root.find("data-action", "close");
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: "Escape" }).defaultPrevented, true);
assert.equal(panel.state().open, false);
assert.equal(document.activeElement, trigger);
panel.dispose();
panel.dispose();
assert.equal(container.children.length, 0);
});
});
+66
View File
@@ -96,4 +96,70 @@ describe("office walker actor adapter", () => {
actor.dispose();
assert.equal(actor.root.parent, null);
});
it("refreshes appearance under a stable root without changing walker or camera state", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
speed: 1,
fixedStep: 0.1,
active: true,
camera: { distance: 4, height: 2.8, targetHeight: 1.1, lookAhead: 0.9 },
});
const root = actor.root;
actor.setAction({ x: 1, z: 0 });
actor.tick(0.3);
const before = actor.state();
const cameraBefore = actor.followPose();
const oldRig = root.children[0];
const after = actor.setAppearance({ kind: "anonymous-dog", coatColor: 0x222222 });
assert.equal(actor.root, root);
assert.equal(oldRig?.parent, null);
assert.equal(after.actor, "anonymous-dog");
assert.deepEqual(after.position, before.position);
assert.deepEqual(after.facing, before.facing);
assert.equal(after.distance, before.distance);
assert.equal(after.active, before.active);
assert.deepEqual(after.action, before.action);
assert.deepEqual(actor.followPose().position.toArray(), cameraBefore.position.toArray());
assert.deepEqual(actor.followPose().target.toArray(), cameraBefore.target.toArray());
assert.equal(root.userData.actorType, "anonymous-dog");
actor.dispose();
});
it("keeps humanoid face texture caller-owned across a skin refresh and clear", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
actor: { kind: "humanoid", outfitColor: 0x112233 },
});
const texture = new THREE.Texture();
let textureDisposals = 0;
texture.addEventListener("dispose", () => textureDisposals++);
assert.equal(actor.attachFaceTexture(texture), true);
const firstFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
const firstMaterial = firstFace.material as THREE.MeshBasicMaterial;
assert.equal(firstMaterial.map, texture);
actor.setAppearance({ kind: "humanoid", outfitColor: 0x334455, bodyShape: "broad" });
const secondFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
assert.notEqual(secondFace, firstFace);
assert.equal(firstMaterial.map, null);
assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, texture);
actor.clearFaceTexture();
assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, null);
actor.attachFaceTexture(texture);
actor.dispose();
assert.equal(textureDisposals, 0);
assert.equal((secondFace.material as THREE.MeshBasicMaterial).map, null);
});
it("does not accept a face texture while the office actor is anonymous", () => {
const actor = createOfficeWalker(makePlan(), {
levelId: "ground",
position: { x: 2, z: 2 },
actor: { kind: "anonymous-dog" },
});
assert.equal(actor.attachFaceTexture(new THREE.Texture()), false);
actor.dispose();
});
});
+51
View File
@@ -116,6 +116,57 @@ describe("playable city scene actor", () => {
actor.dispose();
});
it("refreshes humanoid identity around a caller-owned face texture", () => {
const actor = createSceneActor(options({ active: true }));
const texture = new THREE.Texture();
let textureDisposals = 0;
texture.addEventListener("dispose", () => textureDisposals++);
assert.equal(actor.attachFaceTexture(texture), true);
const firstFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
const firstMaterial = firstFace.material as THREE.MeshBasicMaterial;
assert.equal(firstMaterial.map, texture);
actor.setActions({ forward: 1 });
actor.tick(0.2);
const before = actor.state();
actor.setIdentity({
...MEMBER,
profile: { ...MEMBER.profile, appearance: { primaryColor: "#654321" } },
});
const nextFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
assert.notEqual(nextFace, firstFace);
assert.equal((nextFace.material as THREE.MeshBasicMaterial).map, texture);
assert.equal(firstMaterial.map, null, "released rig no longer retains caller texture");
assert.deepEqual(
{ x: actor.state().x, y: actor.state().y, z: actor.state().z, elapsedSteps: actor.state().elapsedSteps },
{ x: before.x, y: before.y, z: before.z, elapsedSteps: before.elapsedSteps },
);
actor.clearFaceTexture();
assert.equal((nextFace.material as THREE.MeshBasicMaterial).map, null);
actor.attachFaceTexture(texture);
actor.dispose();
assert.equal(textureDisposals, 0);
assert.equal((nextFace.material as THREE.MeshBasicMaterial).map, null);
});
it("refuses to attach a humanoid face to anonymous animal actors", () => {
const actor = createSceneActor(options());
const texture = new THREE.Texture();
actor.attachFaceTexture(texture);
const oldFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
const oldMaterial = oldFace.material as THREE.MeshBasicMaterial;
actor.switchActor("crow", undefined, "flight");
assert.equal(oldMaterial.map, null);
assert.equal(actor.attachFaceTexture(texture), false);
actor.switchActor("dog");
assert.equal(actor.attachFaceTexture(texture), false);
actor.switchActor("humanoid");
const newFace = actor.root.getObjectByName("humanoid.face") as THREE.Mesh;
assert.equal((newFace.material as THREE.MeshBasicMaterial).map, null, "animal role released the hidden face reference");
actor.dispose();
});
it("applies kind requests from the normalized action stream exactly once", () => {
const actor = createSceneActor(options({ active: true }));
actor.setActions({ kindRequest: "crow", modeRequest: "flight", forward: 0.4 });
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
CALIFORNIA_AIR_ROUTE,
createSceneAircraft,
type SceneAircraftOptions,
} from "../aircraft/index.ts";
function options(overrides: Partial<SceneAircraftOptions> = {}): SceneAircraftOptions {
return {
project: (lat, lng) => [(lng + 121) * 20, -(lat - 36) * 20],
groundAt: () => 2,
route: CALIFORNIA_AIR_ROUTE,
initialPosition: { lat: 34.0522, lng: -118.2437 },
initialAltitudeM: 1_000,
initialHeadingDeg: 0,
fixedStepSeconds: 0.1,
altitudeSceneUnitsPerMetre: 0.005,
visualSceneUnitsPerMetre: 0.1,
...overrides,
};
}
describe("playable scene aircraft", () => {
it("projects a stable root and stays inert until activated", () => {
const aircraft = createSceneAircraft(options());
const root = aircraft.root;
assert.equal(root.name, "playable-scene-aircraft");
assert.ok(root.getObjectByName("electric-aircraft"));
assert.deepEqual(root.position.toArray(), [( -118.2437 + 121) * 20, 7, -(34.0522 - 36) * 20]);
assert.equal(root.scale.x, 0.1);
aircraft.setActions({ throttle: 1, roll: 0.8 });
aircraft.tick(0.2);
assert.equal(aircraft.state().elapsedSteps, 0);
assert.equal(aircraft.root, root);
aircraft.setActive(true);
assert.equal(aircraft.actions().throttle, 0, "activation clears stale input");
aircraft.setActions({ throttle: 1, roll: 0.8 });
aircraft.tick(0.2);
assert.equal(aircraft.state().elapsedSteps, 2);
assert.equal(aircraft.state().mode, "manual");
assert.equal(aircraft.view.position, aircraft.view.position);
assert.deepEqual(aircraft.view.position.toArray(), root.position.toArray());
aircraft.dispose();
});
it("maps heading, pitch, and right-bank roll under Tera's -Z convention", () => {
const aircraft = createSceneAircraft(options({ active: true }));
aircraft.setActions({ throttle: 0.7, pitch: 1, roll: 1, yaw: 0.2 });
aircraft.tick(1);
const state = aircraft.state();
assert.equal(aircraft.root.rotation.order, "YXZ");
assert.ok(Math.abs(aircraft.root.rotation.x - state.pitchDeg * Math.PI / 180) < 1e-12);
assert.ok(Math.abs(aircraft.root.rotation.y + state.headingDeg * Math.PI / 180) < 1e-12);
assert.ok(Math.abs(aircraft.root.rotation.z + state.rollDeg * Math.PI / 180) < 1e-12);
assert.ok(aircraft.root.rotation.z < 0, "positive controller roll presents right wing down");
aircraft.dispose();
});
it("animates articulated surfaces and fans from authoritative state", () => {
const aircraft = createSceneAircraft(options({ active: true }));
const leftAileron = aircraft.root.getObjectByName("electric-aircraft.aileron-left");
const rightAileron = aircraft.root.getObjectByName("electric-aircraft.aileron-right");
const leftFan = aircraft.root.getObjectByName("electric-aircraft.fan-left");
assert.ok(leftAileron && rightAileron && leftFan);
aircraft.setActions({ throttle: 1, roll: 0.8, pitch: 0.4, yaw: -0.3 });
aircraft.tick(0.5);
assert.ok(leftAileron.rotation.x > 0);
assert.ok(rightAileron.rotation.x < 0);
assert.notEqual(leftFan.rotation.z, 0);
aircraft.dispose();
});
it("publishes defensive finite chase poses for north and east headings", () => {
const north = createSceneAircraft(options({ initialHeadingDeg: 0 }));
const northPose = north.followPose();
assert.ok(northPose.position.z > north.root.position.z, "camera is south/behind northbound flight");
assert.ok(northPose.target.z < north.root.position.z);
northPose.position.x = 999;
assert.notEqual(north.followPose().position.x, 999);
north.dispose();
const east = createSceneAircraft(options({ initialHeadingDeg: 90 }));
const eastPose = east.followPose();
assert.ok(eastPose.position.x < east.root.position.x);
assert.ok(eastPose.target.x > east.root.position.x);
assert.ok(eastPose.position.toArray().every(Number.isFinite));
assert.ok(eastPose.target.toArray().every(Number.isFinite));
east.dispose();
});
it("clears edge actions, resumes assistance, and resets controller plus rig", () => {
const aircraft = createSceneAircraft(options({ active: true }));
const spawn = aircraft.state();
aircraft.setActions({ throttle: 1, roll: 1, modeRequest: "manual", reset: false });
aircraft.tick(0.2);
assert.equal(aircraft.actions().modeRequest, "none");
assert.equal(aircraft.actions().throttle, 1);
aircraft.setActions({ modeRequest: "assisted" });
aircraft.tick(0.1);
assert.equal(aircraft.state().mode, "assisted");
aircraft.reset();
assert.deepEqual(aircraft.state(), spawn);
assert.deepEqual(aircraft.actions(), {
throttle: 0,
yaw: 0,
pitch: 0,
roll: 0,
modeRequest: "none",
reset: false,
});
aircraft.dispose();
});
it("disposes idempotently and refuses invalid projection/scaling", () => {
const aircraft = createSceneAircraft(options());
const parent = new THREE.Group();
parent.add(aircraft.root);
aircraft.dispose();
aircraft.dispose();
assert.equal(aircraft.root.parent, null);
const before = aircraft.state();
aircraft.tick(1);
assert.deepEqual(aircraft.state(), before);
assert.throws(() => createSceneAircraft(options({ visualSceneUnitsPerMetre: 0 })), RangeError);
assert.throws(() => createSceneAircraft(options({ project: () => [Infinity, 0] })), RangeError);
assert.throws(() => createSceneAircraft(options({ groundAt: () => Number.NaN })), RangeError);
});
});