211 lines
8.3 KiB
TypeScript
211 lines
8.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import * as THREE from "three";
|
|
import {
|
|
createScenePeers,
|
|
scenePeerId,
|
|
type ActorPoseSnapshot,
|
|
type AircraftPoseSnapshot,
|
|
type EntityPoseSnapshot,
|
|
type InterestCell,
|
|
type ScenePeersOptions,
|
|
type VehiclePoseSnapshot,
|
|
} from "../realtime/index.ts";
|
|
|
|
const VELOCITY = { xMps: 0, yMps: 0, zMps: 0, yawDegPerSec: 0 };
|
|
|
|
function actor(
|
|
id: string,
|
|
kind: "humanoid" | "dog" | "crow",
|
|
sequence: number,
|
|
timestampMs: number,
|
|
xM: number,
|
|
cell: InterestCell = { kind: "floor", officeId: "hq", floorId: "one" },
|
|
): ActorPoseSnapshot {
|
|
if (cell.kind === "california-tile") throw new Error("local poses cannot use tile cells");
|
|
return {
|
|
entity: "actor",
|
|
actorId: id,
|
|
kind,
|
|
sequence,
|
|
timestampMs,
|
|
pose: { space: "local", cell, xM, yM: 2, zM: 3, headingDeg: 0, pitchDeg: 0 },
|
|
velocity: { ...VELOCITY, xMps: 2 },
|
|
};
|
|
}
|
|
|
|
function vehicle(sequence: number, timestampMs: number, lat: number): VehiclePoseSnapshot {
|
|
return {
|
|
entity: "vehicle",
|
|
vehicleId: "ev-1",
|
|
kind: "model-x",
|
|
driverActorId: null,
|
|
sequence,
|
|
timestampMs,
|
|
pose: { space: "geographic", lat, lng: -120, altitudeM: 10, headingDeg: 90, pitchDeg: 2 },
|
|
velocity: { ...VELOCITY, xMps: 20 },
|
|
steering: 0.4,
|
|
wheelRadians: 3,
|
|
};
|
|
}
|
|
|
|
function aircraft(sequence: number, timestampMs: number, lat: number): AircraftPoseSnapshot {
|
|
return {
|
|
entity: "aircraft",
|
|
aircraftId: "evtol-1",
|
|
kind: "electric-vtail",
|
|
pilotActorId: "pilot-1",
|
|
sequence,
|
|
timestampMs,
|
|
pose: { space: "geographic", lat, lng: -121, altitudeM: 1_500, headingDeg: 30, pitchDeg: 5 },
|
|
velocity: { xMps: 30, yMps: 2, zMps: 52, yawDegPerSec: 4 },
|
|
rollDeg: 18,
|
|
throttle: 0.72,
|
|
rollInput: 0.4,
|
|
pitchInput: -0.2,
|
|
yawInput: 0.1,
|
|
fanRadians: 2.4,
|
|
};
|
|
}
|
|
|
|
function fixture(over: Partial<ScenePeersOptions> = {}) {
|
|
return createScenePeers({
|
|
project: (lat, lng) => [lng * 2, -lat * 3],
|
|
groundAt: () => 5,
|
|
geographicSceneUnitsPerMetre: 0.1,
|
|
localSceneUnitsPerMetre: 1,
|
|
interpolation: { interpolationDelayMs: 0, maximumExtrapolationMs: 0 },
|
|
...over,
|
|
});
|
|
}
|
|
|
|
function mesh(root: THREE.Object3D, name: string): THREE.Mesh {
|
|
const found = root.getObjectByName(name);
|
|
if (!(found instanceof THREE.Mesh)) throw new Error(`missing mesh ${name}`);
|
|
return found;
|
|
}
|
|
|
|
describe("remote scene peers", () => {
|
|
it("maps local poses directly and keeps one identity-free stable root per peer", () => {
|
|
const peers = fixture();
|
|
const first = actor("private-member-id", "humanoid", 1, 1_000, 1);
|
|
assert.equal(peers.upsert(first), true);
|
|
assert.deepEqual(peers.ids(), ["actor:private-member-id"]);
|
|
assert.equal(peers.count(), 1);
|
|
const root = peers.root.children[0] as THREE.Group;
|
|
assert.equal(root.name, "remote-peer");
|
|
assert.equal(JSON.stringify(root.userData).includes("private-member-id"), false);
|
|
assert.equal(root.getObjectByName("humanoid.face") instanceof THREE.Mesh, true);
|
|
assert.equal((mesh(root, "humanoid.face").material as THREE.MeshBasicMaterial).map, null);
|
|
peers.tick(1_000);
|
|
assert.deepEqual(root.position.toArray(), [1, 2, 3]);
|
|
|
|
assert.equal(peers.upsert(actor("private-member-id", "humanoid", 2, 2_000, 5)), true);
|
|
peers.tick(1_500);
|
|
assert.equal(peers.root.children[0], root);
|
|
assert.equal(root.position.x, 3);
|
|
assert.equal(peers.upsert(actor("private-member-id", "humanoid", 2, 2_100, 8)), false);
|
|
peers.dispose();
|
|
});
|
|
|
|
it("resets interpolation at local interest-cell boundaries without replacing the root", () => {
|
|
const peers = fixture();
|
|
peers.upsert(actor("a", "dog", 1, 1_000, 0));
|
|
peers.upsert(actor("a", "dog", 2, 2_000, 10));
|
|
peers.tick(1_500);
|
|
const root = peers.root.children[0] as THREE.Group;
|
|
assert.equal(root.position.x, 5);
|
|
const nextCell = { kind: "floor", officeId: "hq", floorId: "two" } as const;
|
|
assert.equal(peers.upsert(actor("a", "dog", 3, 3_000, 100, nextCell)), true);
|
|
peers.tick(2_500);
|
|
assert.equal(peers.root.children[0], root);
|
|
assert.equal(root.position.x, 100, "new coordinate frame is held, never mixed with old floor");
|
|
peers.dispose();
|
|
});
|
|
|
|
it("projects geographic vehicles onto caller terrain and renders a generic black EV", () => {
|
|
const peers = fixture();
|
|
const snapshot = vehicle(1, 1_000, 34);
|
|
assert.equal(scenePeerId(snapshot), "vehicle:ev-1");
|
|
peers.upsert(snapshot);
|
|
assert.equal(peers.tick(1_000), 1);
|
|
const root = peers.root.children[0] as THREE.Group;
|
|
assert.deepEqual(root.position.toArray(), [-240, 6, -102]);
|
|
assert.equal(root.scale.x, 0.1);
|
|
assert.ok(Math.abs(root.rotation.y + Math.PI / 2) < 1e-12);
|
|
assert.equal(root.getObjectByName("generic-black-ev")?.userData.vehicleModel, "generic-black-ev");
|
|
assert.equal(root.getObjectByName("frontLeft.spin")?.rotation.x, -snapshot.wheelRadians);
|
|
const paint = root.getObjectByName("model-x.body:model-x.paint") as THREE.Mesh;
|
|
assert.equal((paint.material as THREE.MeshStandardMaterial).color.getHex(), 0x050607);
|
|
peers.dispose();
|
|
});
|
|
|
|
it("renders a remote piloted aircraft with authoritative bank and rig state", () => {
|
|
const peers = fixture();
|
|
const snapshot = aircraft(1, 1_000, 37.7);
|
|
assert.equal(scenePeerId(snapshot), "aircraft:evtol-1");
|
|
assert.equal(peers.upsert(snapshot), true);
|
|
assert.equal(peers.tick(1_000), 1);
|
|
const root = peers.root.children[0] as THREE.Group;
|
|
assert.deepEqual(root.position.toArray(), [-242, 155, -113.10000000000001]);
|
|
assert.ok(Math.abs(root.rotation.x - 5 * Math.PI / 180) < 1e-12);
|
|
assert.ok(Math.abs(root.rotation.y + 30 * Math.PI / 180) < 1e-12);
|
|
assert.ok(Math.abs(root.rotation.z + 18 * Math.PI / 180) < 1e-12);
|
|
assert.ok(root.getObjectByName("electric-aircraft"));
|
|
assert.equal(root.getObjectByName("electric-aircraft.fan-left")?.rotation.z, 2.4);
|
|
assert.ok(Math.abs((root.getObjectByName("electric-aircraft.aileron-left")?.rotation.x ?? 0) - 0.18) < 1e-12);
|
|
peers.dispose();
|
|
});
|
|
|
|
it("shares prototype resources while kind changes retain the entity root", () => {
|
|
const peers = fixture();
|
|
peers.upsert(actor("one", "humanoid", 1, 1_000, 0));
|
|
peers.upsert(actor("two", "humanoid", 1, 1_000, 2));
|
|
const one = peers.root.children[0] as THREE.Group;
|
|
const two = peers.root.children[1] as THREE.Group;
|
|
assert.equal(mesh(one, "humanoid.chest").geometry, mesh(two, "humanoid.chest").geometry);
|
|
assert.equal(mesh(one, "humanoid.chest").material, mesh(two, "humanoid.chest").material);
|
|
const oldRig = one.children[0];
|
|
peers.upsert(actor("one", "crow", 2, 2_000, 1));
|
|
peers.tick(2_000);
|
|
assert.equal(peers.root.children[0], one);
|
|
assert.equal(oldRig?.parent, null);
|
|
assert.ok(one.getObjectByName("crow"));
|
|
peers.dispose();
|
|
});
|
|
|
|
it("bounds nearby peers and removes/clears without prematurely disposing shared assets", () => {
|
|
const peers = fixture({ maximumPeers: 2 });
|
|
peers.upsert(actor("one", "humanoid", 1, 1_000, 0));
|
|
peers.upsert(actor("two", "dog", 1, 1_000, 1));
|
|
assert.equal(peers.upsert(actor("three", "crow", 1, 1_000, 2)), false);
|
|
const firstPeer = peers.root.children[0];
|
|
if (!firstPeer) throw new Error("missing first peer");
|
|
const geometry = mesh(firstPeer, "humanoid.chest").geometry;
|
|
let disposals = 0;
|
|
geometry.addEventListener("dispose", () => disposals++);
|
|
assert.equal(peers.remove("actor:one"), true);
|
|
assert.equal(peers.remove("actor:one"), false);
|
|
assert.equal(disposals, 0, "removing one clone cannot release shared prototype resources");
|
|
peers.clear();
|
|
assert.equal(peers.count(), 0);
|
|
assert.deepEqual(peers.ids(), []);
|
|
assert.equal(disposals, 0);
|
|
peers.dispose();
|
|
peers.dispose();
|
|
assert.equal(disposals, 1);
|
|
assert.equal(peers.upsert(actor("late", "humanoid", 1, 1_000, 0)), false);
|
|
});
|
|
|
|
it("contains invalid snapshots and projection failures", () => {
|
|
const peers = fixture({ project: () => [Number.NaN, 0] });
|
|
assert.equal(peers.upsert({} as EntityPoseSnapshot), false);
|
|
peers.upsert(vehicle(1, 1_000, 34));
|
|
assert.equal(peers.tick(1_000), 0);
|
|
assert.equal(peers.root.children[0]?.visible, false);
|
|
assert.equal(peers.tick(Number.NaN), 0);
|
|
peers.dispose();
|
|
assert.throws(() => fixture({ maximumPeers: 0 }), RangeError);
|
|
});
|
|
});
|