California gets roads, traffic, and a car to follow
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
CROW_METRICS,
|
||||
DOG_METRICS,
|
||||
HUMANOID_METRICS,
|
||||
buildCrow,
|
||||
buildDog,
|
||||
buildHumanoid,
|
||||
cloneCrow,
|
||||
cloneDog,
|
||||
cloneHumanoid,
|
||||
disposeCrow,
|
||||
disposeDog,
|
||||
disposeHumanoid,
|
||||
poseCrowFlight,
|
||||
poseDogAttention,
|
||||
poseDogWalk,
|
||||
poseHumanoid,
|
||||
} from "../assets/actors/index.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const result: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) result.push(object);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertSharedResources(source: THREE.Group, clone: THREE.Group): void {
|
||||
const a = meshes(source);
|
||||
const b = meshes(clone);
|
||||
assert.equal(b.length, a.length);
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
assert.equal(b[i]!.geometry, a[i]!.geometry);
|
||||
assert.equal(b[i]!.material, a[i]!.material);
|
||||
}
|
||||
}
|
||||
|
||||
describe("procedural actor assets", () => {
|
||||
it("builds a metre-scale customizable humanoid with a front face surface", () => {
|
||||
const rig = buildHumanoid({ bodyShape: "broad", outfitColor: 0x224466 });
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
assert.ok(Math.abs(size.y - HUMANOID_METRICS.height) < 0.08, `height ${size.y}`);
|
||||
assert.ok(box.min.y > -0.015, `floor ${box.min.y}`);
|
||||
assert.equal(rig.root.userData.forwardAxis, "-Z");
|
||||
assert.ok(rig.face.getWorldPosition(new THREE.Vector3()).z < 0);
|
||||
|
||||
const clone = cloneHumanoid(rig);
|
||||
assertSharedResources(rig.root, clone.root);
|
||||
poseHumanoid(clone, { walkPhase: Math.PI / 2, stride: 0.5, headYaw: 0.3 });
|
||||
assert.notEqual(clone.joints.hipLeft.rotation.x, rig.joints.hipLeft.rotation.x);
|
||||
assert.equal(clone.joints.head.rotation.y, 0.3);
|
||||
assert.equal(clone.ownsMaterials, false);
|
||||
disposeHumanoid(rig);
|
||||
});
|
||||
|
||||
it("builds an anonymous office dog with independent attention and gait joints", () => {
|
||||
const rig = buildDog();
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
assert.ok(size.y <= DOG_METRICS.height + 0.08, `height ${size.y}`);
|
||||
assert.ok(size.z >= DOG_METRICS.length - 0.1, `length ${size.z}`);
|
||||
assert.equal(rig.root.userData.actorType, "anonymous-dog");
|
||||
|
||||
const clone = cloneDog(rig);
|
||||
assertSharedResources(rig.root, clone.root);
|
||||
poseDogWalk(clone, Math.PI / 2);
|
||||
poseDogAttention(clone, 0.4, Math.PI / 2);
|
||||
assert.notEqual(clone.joints.legFrontLeft.rotation.x, rig.joints.legFrontLeft.rotation.x);
|
||||
assert.equal(clone.joints.head.rotation.y, 0.4);
|
||||
assert.notEqual(clone.joints.tail.rotation.z, 0);
|
||||
disposeDog(rig);
|
||||
});
|
||||
|
||||
it("builds a Tera crow with mirrored independent wing joints", () => {
|
||||
const rig = buildCrow();
|
||||
const box = new THREE.Box3().setFromObject(rig.root);
|
||||
assert.ok(box.max.y <= CROW_METRICS.perchedHeight + 0.1, `height ${box.max.y}`);
|
||||
assert.equal(rig.root.userData.actorType, "anonymous-crow");
|
||||
const beak = rig.root.getObjectByName("crow.beak");
|
||||
assert.ok(beak);
|
||||
assert.ok(beak.getWorldPosition(new THREE.Vector3()).z < 0);
|
||||
|
||||
const clone = cloneCrow(rig);
|
||||
assertSharedResources(rig.root, clone.root);
|
||||
poseCrowFlight(clone, Math.PI / 2, 1);
|
||||
assert.equal(clone.joints.wingLeft.rotation.z, -clone.joints.wingRight.rotation.z);
|
||||
assert.notEqual(clone.joints.wingLeft.rotation.z, rig.joints.wingLeft.rotation.z);
|
||||
disposeCrow(rig);
|
||||
});
|
||||
|
||||
it("never disposes caller-owned materials unless explicitly requested", () => {
|
||||
const source = buildCrow();
|
||||
const shared = meshes(source.root)[0]!.material as THREE.Material;
|
||||
let disposals = 0;
|
||||
shared.addEventListener("dispose", () => disposals++);
|
||||
const external = {
|
||||
feather: shared,
|
||||
sheen: shared,
|
||||
beak: shared,
|
||||
eye: shared,
|
||||
foot: shared,
|
||||
};
|
||||
const rig = buildCrow({ materials: external });
|
||||
disposeCrow(rig);
|
||||
assert.equal(disposals, 0);
|
||||
disposeCrow(source);
|
||||
assert.equal(disposals, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { layoutBuildingGlyph } from "../engine/buildingGlyph.ts";
|
||||
import type { BuildingGlyph } from "../engine/types.ts";
|
||||
|
||||
function glyph(overrides: Partial<BuildingGlyph> = {}): BuildingGlyph {
|
||||
return {
|
||||
kind: "building",
|
||||
width: 40,
|
||||
depth: 30,
|
||||
height: 100,
|
||||
storeys: 24,
|
||||
heading: 0,
|
||||
profile: "block",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("map building glyph layouts", () => {
|
||||
it("steps a tower inward without changing its declared height", () => {
|
||||
const segments = layoutBuildingGlyph(glyph({ profile: "tower" }));
|
||||
assert.equal(segments.length, 3);
|
||||
assert.ok(segments[1]!.width < segments[0]!.width);
|
||||
assert.ok(segments[2]!.width < segments[1]!.width);
|
||||
assert.equal(Math.max(...segments.map((s) => s.base + s.height)), 100);
|
||||
});
|
||||
|
||||
it("leaves a real hole inside a courtyard ring", () => {
|
||||
const segments = layoutBuildingGlyph(
|
||||
glyph({ profile: "courtyard", width: 36, depth: 26, height: 9 }),
|
||||
);
|
||||
assert.equal(segments.length, 4);
|
||||
|
||||
const north = segments[0]!;
|
||||
const west = segments[2]!;
|
||||
const openWidth = 36 - west.width * 2;
|
||||
const openDepth = 26 - north.depth * 2;
|
||||
assert.ok(openWidth > 0, `courtyard closes across its width: ${openWidth}`);
|
||||
assert.ok(openDepth > 0, `courtyard closes across its depth: ${openDepth}`);
|
||||
});
|
||||
|
||||
it("reserves the top of a hangar for its pitched roof", () => {
|
||||
const [body] = layoutBuildingGlyph(glyph({ profile: "hangar", height: 11 }));
|
||||
assert.ok(body);
|
||||
assert.equal(body.base, 0);
|
||||
assert.equal(body.height, 11 * 0.72);
|
||||
});
|
||||
|
||||
it("keeps undersized authored footprints renderable", () => {
|
||||
const [segment] = layoutBuildingGlyph(
|
||||
glyph({ width: 0, depth: -2, height: 0, profile: "block" }),
|
||||
);
|
||||
assert.ok(segment);
|
||||
assert.deepEqual(
|
||||
{ width: segment.width, depth: segment.depth, height: segment.height },
|
||||
{ width: 4, depth: 4, height: 3 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Contract checks for the state-scale rendering adapter. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import CALIFORNIA_CITY, { CALIFORNIA_I_5, CALIFORNIA_US_101 } from "../cities/california.ts";
|
||||
|
||||
describe("California corridor city", () => {
|
||||
it("keeps the two transport routes on one coarse render board", () => {
|
||||
assert.equal(CALIFORNIA_CITY.id, "california");
|
||||
assert.equal(CALIFORNIA_CITY.roads.length, 2);
|
||||
assert.ok(CALIFORNIA_US_101.length > 10);
|
||||
assert.ok(CALIFORNIA_I_5.length > 10);
|
||||
for (const path of [CALIFORNIA_US_101, CALIFORNIA_I_5]) {
|
||||
for (const [lat, lng] of path) {
|
||||
assert.ok(lat >= CALIFORNIA_CITY.bounds.minLat && lat <= CALIFORNIA_CITY.bounds.maxLat);
|
||||
assert.ok(lng >= CALIFORNIA_CITY.bounds.minLng && lng <= CALIFORNIA_CITY.bounds.maxLng);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("offers route chapters plus doors into both detailed city boards", () => {
|
||||
assert.deepEqual(
|
||||
CALIFORNIA_CITY.chapters.map((chapter) => chapter.id),
|
||||
["california-overview", "la-sf-us-101", "la-sf-i-5", "los-angeles", "san-francisco"],
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a state-scale field rather than city-scale cells", () => {
|
||||
assert.ok(CALIFORNIA_CITY.cellLat >= 0.01);
|
||||
assert.ok(CALIFORNIA_CITY.cellLng >= 0.01);
|
||||
assert.equal(CALIFORNIA_CITY.districts.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Contract tests for the authored California transport pack. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||
import type { GeographicPoint, TransportRoute } from "../transport/types.ts";
|
||||
|
||||
function assertUnique(values: readonly string[], what: string): void {
|
||||
assert.equal(new Set(values).size, values.length, `${what} must be unique`);
|
||||
}
|
||||
|
||||
function assertPoint(point: GeographicPoint, what: string): void {
|
||||
assert.ok(Number.isFinite(point.lat), `${what} latitude must be finite`);
|
||||
assert.ok(Number.isFinite(point.lng), `${what} longitude must be finite`);
|
||||
assert.ok(point.lat >= -90 && point.lat <= 90, `${what} latitude is outside the globe`);
|
||||
assert.ok(point.lng >= -180 && point.lng <= 180, `${what} longitude is outside the globe`);
|
||||
}
|
||||
|
||||
function assertProvenance(value: string, what: string): void {
|
||||
assert.ok(value.trim().length > 20, `${what} needs meaningful provenance`);
|
||||
}
|
||||
|
||||
function routeSegments(route: TransportRoute) {
|
||||
const byId = new Map(CALIFORNIA_TRANSPORT.segments.map((segment) => [segment.id, segment]));
|
||||
return route.segmentIds.map((id) => {
|
||||
const segment = byId.get(id);
|
||||
assert.ok(segment, `route ${route.id} references missing segment ${id}`);
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
|
||||
describe("California transport graph", () => {
|
||||
it("has stable, unique identifiers and valid references", () => {
|
||||
assertUnique(
|
||||
CALIFORNIA_TRANSPORT.nodes.map((node) => node.id),
|
||||
"node ids",
|
||||
);
|
||||
assertUnique(
|
||||
CALIFORNIA_TRANSPORT.segments.map((segment) => segment.id),
|
||||
"segment ids",
|
||||
);
|
||||
assertUnique(
|
||||
CALIFORNIA_TRANSPORT.routes.map((route) => route.id),
|
||||
"route ids",
|
||||
);
|
||||
assertUnique(
|
||||
CALIFORNIA_TRANSPORT.anchors.map((anchor) => anchor.id),
|
||||
"anchor ids",
|
||||
);
|
||||
|
||||
const nodeIds = new Set(CALIFORNIA_TRANSPORT.nodes.map((node) => node.id));
|
||||
for (const segment of CALIFORNIA_TRANSPORT.segments) {
|
||||
assert.ok(nodeIds.has(segment.fromNodeId), `${segment.id} has no start node`);
|
||||
assert.ok(nodeIds.has(segment.toNodeId), `${segment.id} has no end node`);
|
||||
}
|
||||
for (const anchor of CALIFORNIA_TRANSPORT.anchors) {
|
||||
assert.ok(nodeIds.has(anchor.nodeId), `${anchor.id} has no corridor node`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every route continuous from its declared start to its declared end", () => {
|
||||
for (const route of CALIFORNIA_TRANSPORT.routes) {
|
||||
const segments = routeSegments(route);
|
||||
assert.ok(segments.length > 0, `${route.id} is empty`);
|
||||
assert.equal(segments[0]?.fromNodeId, route.fromNodeId);
|
||||
assert.equal(segments.at(-1)?.toNodeId, route.toNodeId);
|
||||
|
||||
for (let index = 1; index < segments.length; index += 1) {
|
||||
assert.equal(
|
||||
segments[index - 1]?.toNodeId,
|
||||
segments[index]?.fromNodeId,
|
||||
`${route.id} breaks between segments ${index - 1} and ${index}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("names I-5's real Bay connectors instead of pretending I-5 reaches San Francisco", () => {
|
||||
const route = CALIFORNIA_TRANSPORT.routes.find((candidate) => candidate.id === "la-sf-i-5");
|
||||
assert.ok(route);
|
||||
const roads = routeSegments(route).map((segment) => segment.roadName);
|
||||
|
||||
assert.deepEqual([...new Set(roads)], ["I-5", "I-580", "I-80 / Bay Bridge", "I-80"]);
|
||||
assert.match(route.label, /I-580/);
|
||||
assert.match(route.label, /I-80/);
|
||||
});
|
||||
|
||||
it("keeps US-101 on US-101 for the complete authored itinerary", () => {
|
||||
const route = CALIFORNIA_TRANSPORT.routes.find((candidate) => candidate.id === "la-sf-us-101");
|
||||
assert.ok(route);
|
||||
assert.ok(routeSegments(route).every((segment) => segment.roadName === "US-101"));
|
||||
});
|
||||
|
||||
it("contains finite California coordinates and usable simulation envelopes", () => {
|
||||
for (const node of CALIFORNIA_TRANSPORT.nodes) {
|
||||
assertPoint(node.position, `node ${node.id}`);
|
||||
assert.ok(node.position.lat >= 32 && node.position.lat <= 42, `${node.id} is outside California`);
|
||||
assert.ok(node.position.lng >= -125 && node.position.lng <= -114, `${node.id} is outside California`);
|
||||
}
|
||||
for (const anchor of CALIFORNIA_TRANSPORT.anchors) {
|
||||
assertPoint(anchor.position, `anchor ${anchor.id}`);
|
||||
}
|
||||
for (const segment of CALIFORNIA_TRANSPORT.segments) {
|
||||
assert.ok(segment.speedLimitMph > 0 && segment.speedLimitMph <= 70);
|
||||
assert.ok(Number.isInteger(segment.lanesPerDirection));
|
||||
assert.ok(segment.lanesPerDirection >= 1 && segment.lanesPerDirection <= 8);
|
||||
}
|
||||
});
|
||||
|
||||
it("records provenance on every authored item", () => {
|
||||
for (const note of CALIFORNIA_TRANSPORT.provenance) assertProvenance(note, "pack");
|
||||
for (const node of CALIFORNIA_TRANSPORT.nodes) assertProvenance(node.provenance, node.id);
|
||||
for (const segment of CALIFORNIA_TRANSPORT.segments) {
|
||||
assertProvenance(segment.provenance, segment.id);
|
||||
}
|
||||
for (const route of CALIFORNIA_TRANSPORT.routes) assertProvenance(route.provenance, route.id);
|
||||
for (const anchor of CALIFORNIA_TRANSPORT.anchors) {
|
||||
assertProvenance(anchor.provenance, anchor.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("has scene anchors for both cities and all shipped offices", () => {
|
||||
const cityIds = CALIFORNIA_TRANSPORT.anchors
|
||||
.filter((anchor) => anchor.kind === "city")
|
||||
.map((anchor) => anchor.cityId)
|
||||
.sort();
|
||||
const officeIds = CALIFORNIA_TRANSPORT.anchors
|
||||
.filter((anchor) => anchor.kind === "office")
|
||||
.map((anchor) => anchor.officeId)
|
||||
.sort();
|
||||
|
||||
assert.deepEqual(cityIds, ["sf", "socal"]);
|
||||
assert.deepEqual(officeIds, ["frontier-valley", "lumbridge-hq", "mateo-court"]);
|
||||
});
|
||||
|
||||
it("survives a JSON round trip without losing data", () => {
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(CALIFORNIA_TRANSPORT)), CALIFORNIA_TRANSPORT);
|
||||
});
|
||||
});
|
||||
+28
-8
@@ -444,13 +444,14 @@ describe("the Frontier Valley pack", () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Both shipped packs declare where they stand, and the two are deliberately
|
||||
* All shipped packs declare where they stand, and the three are deliberately
|
||||
* nothing alike — which is the entire argument for the field existing.
|
||||
*/
|
||||
describe("the sites", () => {
|
||||
it("are both declared", () => {
|
||||
assert.ok(LUMBRIDGE_HQ.site, "Lumbridge HQ has no site");
|
||||
assert.ok(FRONTIER_VALLEY.site, "Frontier Valley has no site");
|
||||
const packs = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
|
||||
|
||||
it("are all declared", () => {
|
||||
for (const pack of packs) assert.ok(pack.site, `${pack.name} has no site`);
|
||||
});
|
||||
|
||||
it("put one high in the air and one on the ground", () => {
|
||||
@@ -465,15 +466,15 @@ describe("the sites", () => {
|
||||
});
|
||||
|
||||
it("carry headings inside the compass", () => {
|
||||
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT]) {
|
||||
for (const pack of packs) {
|
||||
const h = pack.site?.heading ?? 0;
|
||||
assert.ok(h >= 0 && h < 360, `${pack.id} has a heading of ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("are both on the board the city view draws", () => {
|
||||
// Not a format requirement — an office may stand anywhere — but both of
|
||||
// these are meant to be places in *this* product's San Francisco, and a
|
||||
it("puts the Bay Area offices on the board the city view draws", () => {
|
||||
// Not a format requirement — an office may stand anywhere — but these two
|
||||
// are meant to be places in *this* product's San Francisco, and a
|
||||
// coordinate typo that put one in Nevada would otherwise render fine.
|
||||
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
|
||||
const site = pack.site;
|
||||
@@ -482,6 +483,25 @@ describe("the sites", () => {
|
||||
assert.ok(site.lng > -122.8 && site.lng < -121.8, `${pack.id} longitude ${site.lng}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every map destination a finite, aligned exterior glyph", () => {
|
||||
for (const pack of packs) {
|
||||
const site = pack.site;
|
||||
assert.ok(site, `${pack.id} has no site`);
|
||||
const exterior = site.exterior;
|
||||
assert.ok(exterior, `${pack.id} has no exterior glyph`);
|
||||
assert.equal(exterior.kind, "building");
|
||||
assert.equal(exterior.heading, site.heading, `${pack.id} exterior faces away from its plan`);
|
||||
for (const [field, value] of Object.entries({
|
||||
width: exterior.width,
|
||||
depth: exterior.depth,
|
||||
height: exterior.height,
|
||||
storeys: exterior.storeys,
|
||||
})) {
|
||||
assert.ok(Number.isFinite(value) && value > 0, `${pack.id} has invalid ${field}: ${value}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/** Render-layer contract tests that do not require a WebGL context. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||
import { createRoadTrafficLayer } from "../engine/roadTraffic.ts";
|
||||
import type { World } from "../engine/world.ts";
|
||||
|
||||
function fixture() {
|
||||
const world = {
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 121) * 20, -(lat - 36) * 20];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
} as unknown as World;
|
||||
const camera = new THREE.PerspectiveCamera(42, 16 / 9, 0.1, 2_000);
|
||||
const controls = {
|
||||
target: new THREE.Vector3(),
|
||||
enabled: true,
|
||||
} as unknown as OrbitControls;
|
||||
const layer = createRoadTrafficLayer(world, camera, controls, {
|
||||
pack: CALIFORNIA_TRANSPORT,
|
||||
routeId: "la-sf-us-101",
|
||||
count: 8,
|
||||
seed: 115,
|
||||
});
|
||||
return { layer, camera, controls };
|
||||
}
|
||||
|
||||
describe("road traffic render layer", () => {
|
||||
it("draws one articulated hero and instanced background parts", () => {
|
||||
const { layer } = fixture();
|
||||
const hero = layer.group.getObjectByName("model-x-hero");
|
||||
assert.ok(hero);
|
||||
assert.equal(hero.scale.x, 0.18);
|
||||
assert.ok(layer.group.children.some((child) => child instanceof THREE.InstancedMesh));
|
||||
assert.equal(layer.hero()?.routeId, "la-sf-us-101");
|
||||
assert.ok(Math.abs(hero.position.y - 0.155) < 1e-9);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("switches routes and owns the orbit-control handoff while following", () => {
|
||||
const { layer, camera, controls } = fixture();
|
||||
layer.setRoute("la-sf-i-5");
|
||||
assert.equal(layer.routeId(), "la-sf-i-5");
|
||||
assert.equal(layer.hero()?.routeId, "la-sf-i-5");
|
||||
|
||||
layer.setFollowing(true);
|
||||
assert.equal(layer.following(), true);
|
||||
assert.equal(controls.enabled, false);
|
||||
layer.tick(0.1);
|
||||
assert.ok(camera.position.toArray().every(Number.isFinite));
|
||||
assert.ok(controls.target.toArray().every(Number.isFinite));
|
||||
|
||||
layer.setCameraMode("driver");
|
||||
assert.equal(layer.cameraMode(), "driver");
|
||||
layer.tick(1 / 60);
|
||||
assert.ok(camera.position.y > layer.group.getObjectByName("model-x-hero")!.position.y);
|
||||
|
||||
layer.setFollowing(false);
|
||||
assert.equal(controls.enabled, true);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it("hands manual input to the deterministic hero and resumes assistance", () => {
|
||||
const { layer } = fixture();
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
layer.setVehicleActions({ throttle: 1, steering: 0.8 });
|
||||
layer.tick(1 / 30);
|
||||
}
|
||||
assert.equal(layer.hero().mode, "manual");
|
||||
assert.ok(layer.hero().lateralOffsetM > 0);
|
||||
|
||||
layer.setVehicleActions({ modeRequest: "assisted" });
|
||||
layer.tick(1 / 30);
|
||||
assert.equal(layer.hero().mode, "assisted");
|
||||
layer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
MODEL_X_METRICS,
|
||||
advanceModelXWheels,
|
||||
buildModelX,
|
||||
cloneModelX,
|
||||
disposeModelX,
|
||||
modelXInstanceParts,
|
||||
setModelXSteering,
|
||||
setModelXWheelRotation,
|
||||
} from "../assets/vehicles/index.ts";
|
||||
|
||||
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
root.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("procedural Model X vehicle asset", () => {
|
||||
it("has a metre-scale crossover silhouette and faces -Z", () => {
|
||||
const rig = buildModelX({ detail: "corridor" });
|
||||
const size = new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3());
|
||||
|
||||
assert.ok(Math.abs(size.x - MODEL_X_METRICS.width) < 0.12, `width ${size.x}`);
|
||||
assert.ok(Math.abs(size.y - MODEL_X_METRICS.height) < 0.08, `height ${size.y}`);
|
||||
assert.ok(Math.abs(size.z - MODEL_X_METRICS.length) < 0.08, `length ${size.z}`);
|
||||
assert.equal(rig.root.userData.forwardAxis, "-Z");
|
||||
assert.ok(rig.wheels.frontLeft.steering.position.z < 0);
|
||||
assert.ok(rig.wheels.rearLeft.steering.position.z > 0);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("exposes independent steering and rolling joints", () => {
|
||||
const rig = buildModelX();
|
||||
setModelXWheelRotation(rig, 1.25);
|
||||
for (const wheel of Object.values(rig.wheels)) assert.equal(wheel.spin.rotation.x, 1.25);
|
||||
|
||||
setModelXSteering(rig, 99);
|
||||
assert.equal(rig.wheels.frontLeft.steering.rotation.y, MODEL_X_METRICS.maxSteeringAngle);
|
||||
assert.equal(rig.wheels.frontRight.steering.rotation.y, MODEL_X_METRICS.maxSteeringAngle);
|
||||
assert.equal(rig.wheels.rearLeft.steering.rotation.y, 0);
|
||||
|
||||
advanceModelXWheels(rig, MODEL_X_METRICS.wheelRadius);
|
||||
assert.equal(rig.wheels.frontLeft.spin.rotation.x, 0.25);
|
||||
disposeModelX(rig);
|
||||
});
|
||||
|
||||
it("clones cheaply while keeping its pose independent", () => {
|
||||
const original = buildModelX();
|
||||
const clone = cloneModelX(original);
|
||||
const sourceMeshes = meshes(original.root);
|
||||
const clonedMeshes = meshes(clone.root);
|
||||
|
||||
assert.equal(clonedMeshes.length, sourceMeshes.length);
|
||||
for (let i = 0; i < sourceMeshes.length; i++) {
|
||||
assert.equal(clonedMeshes[i]!.geometry, sourceMeshes[i]!.geometry);
|
||||
assert.equal(clonedMeshes[i]!.material, sourceMeshes[i]!.material);
|
||||
}
|
||||
|
||||
setModelXSteering(clone, -0.3);
|
||||
assert.equal(clone.wheels.frontLeft.steering.rotation.y, -0.3);
|
||||
assert.equal(original.wheels.frontLeft.steering.rotation.y, 0);
|
||||
assert.equal(clone.ownsMaterials, false);
|
||||
disposeModelX(original);
|
||||
});
|
||||
|
||||
it("publishes stable neutral-pose pieces for instanced traffic", () => {
|
||||
const rig = buildModelX({ detail: "corridor" });
|
||||
const parts = modelXInstanceParts(rig);
|
||||
assert.equal(parts.length, meshes(rig.root).length);
|
||||
assert.ok(parts.some((part) => part.name === "frontLeft.tire"));
|
||||
assert.ok(parts.some((part) => part.name.startsWith("model-x.body:")));
|
||||
assert.ok(parts.every((part) => Number.isFinite(part.matrix.determinant())));
|
||||
disposeModelX(rig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||
import {
|
||||
VehicleController,
|
||||
normalizeVehicleActions,
|
||||
replayVehicleInputs,
|
||||
} from "../transport/vehicleController.ts";
|
||||
|
||||
describe("vehicle controller", () => {
|
||||
it("normalizes arbitrary adapter input into safe device-neutral actions", () => {
|
||||
assert.deepEqual(
|
||||
normalizeVehicleActions({
|
||||
throttle: 4,
|
||||
brake: Number.NaN,
|
||||
steering: -3,
|
||||
handbrake: true,
|
||||
modeRequest: "manual",
|
||||
reset: true,
|
||||
}),
|
||||
{
|
||||
throttle: 1,
|
||||
brake: 0,
|
||||
steering: -1,
|
||||
handbrake: true,
|
||||
modeRequest: "manual",
|
||||
reset: true,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(normalizeVehicleActions(undefined), {
|
||||
throttle: 0,
|
||||
brake: 0,
|
||||
steering: 0,
|
||||
handbrake: false,
|
||||
modeRequest: "none",
|
||||
reset: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("advances assisted driving on a deterministic fixed clock", () => {
|
||||
const a = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialDistanceM: 1_000,
|
||||
});
|
||||
const b = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialDistanceM: 1_000,
|
||||
});
|
||||
for (let index = 0; index < 240; index += 1) {
|
||||
a.stepFixed();
|
||||
b.stepFixed();
|
||||
}
|
||||
assert.deepEqual(a.snapshot(), b.snapshot());
|
||||
assert.equal(a.state().mode, "assisted");
|
||||
assert.ok(a.state().distanceM > 1_000);
|
||||
assert.ok(a.state().speedMps > 0);
|
||||
assert.ok(Number.isFinite(a.state().lat));
|
||||
assert.ok(Number.isFinite(a.state().lng));
|
||||
});
|
||||
|
||||
it("gives manual input priority and cleanly resumes assistance", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-i-5",
|
||||
initialSpeedMps: 25,
|
||||
});
|
||||
for (let index = 0; index < 90; index += 1) {
|
||||
controller.stepFixed({ throttle: 0.7, steering: 0.8 });
|
||||
}
|
||||
assert.equal(controller.state().mode, "manual");
|
||||
assert.ok(controller.state().lateralOffsetM > 0.5);
|
||||
|
||||
controller.stepFixed({ modeRequest: "assisted", steering: 0.9 });
|
||||
assert.equal(controller.state().mode, "manual", "simultaneous human input must win");
|
||||
const takeoverOffset = controller.state().lateralOffsetM;
|
||||
|
||||
controller.stepFixed({ modeRequest: "assisted" });
|
||||
assert.equal(controller.state().mode, "assisted");
|
||||
assert.ok(
|
||||
Math.abs(controller.state().lateralOffsetM - takeoverOffset) < 0.25,
|
||||
"assistance must not teleport the car to centre",
|
||||
);
|
||||
for (let index = 0; index < 300; index += 1) controller.stepFixed();
|
||||
assert.ok(Math.abs(controller.state().lateralOffsetM) < Math.abs(takeoverOffset));
|
||||
});
|
||||
|
||||
it("enforces speed and road-edge guardrails and exposes contact", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
mode: "manual",
|
||||
initialSpeedMps: 200,
|
||||
initialLateralOffsetM: 100,
|
||||
maximumSpeedMps: 30,
|
||||
guardrailOffsetM: 4,
|
||||
});
|
||||
assert.equal(controller.state().speedMps, 30);
|
||||
assert.equal(controller.state().lateralOffsetM, 4);
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
controller.stepFixed({ throttle: 1, steering: 1 });
|
||||
}
|
||||
assert.ok(controller.state().lateralOffsetM <= 4);
|
||||
assert.ok(controller.state().speedMps <= 30);
|
||||
assert.equal(controller.state().guardrailContact, true);
|
||||
});
|
||||
|
||||
it("resets exactly to its configured spawn state", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-i-5",
|
||||
mode: "manual",
|
||||
initialDistanceM: 12_345,
|
||||
initialLateralOffsetM: -1.25,
|
||||
initialSpeedMps: 8,
|
||||
});
|
||||
const spawn = controller.snapshot();
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
controller.stepFixed({ throttle: 1, steering: 0.5 });
|
||||
}
|
||||
controller.stepFixed({ reset: true });
|
||||
assert.deepEqual(controller.snapshot(), spawn);
|
||||
});
|
||||
|
||||
it("caps sleeping-tab catch-up and ignores invalid render deltas", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
});
|
||||
assert.equal(controller.tick(Number.NaN), 0);
|
||||
assert.equal(controller.tick(-1), 0);
|
||||
const steps = controller.tick(600);
|
||||
assert.ok(steps <= 15);
|
||||
assert.equal(controller.state().elapsedSteps, steps);
|
||||
});
|
||||
|
||||
it("replays timed input frames bit-for-bit", () => {
|
||||
const options = {
|
||||
routeId: "la-sf-i-5",
|
||||
initialSpeedMps: 15,
|
||||
fixedStepSeconds: 1 / 30,
|
||||
} as const;
|
||||
const frames = [
|
||||
{ steps: 40, actions: { throttle: 0.8, steering: -0.3 } },
|
||||
{ steps: 1, actions: { modeRequest: "assisted" as const } },
|
||||
{ steps: 80 },
|
||||
{ steps: 20, actions: { brake: 0.7 } },
|
||||
];
|
||||
const first = replayVehicleInputs(CALIFORNIA_TRANSPORT, options, frames);
|
||||
const second = replayVehicleInputs(CALIFORNIA_TRANSPORT, options, frames);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.trajectory.length, 142);
|
||||
assert.equal(first.final.elapsedSteps, 141);
|
||||
assert.equal(first.final.mode, "manual");
|
||||
});
|
||||
|
||||
it("can switch route variants while preserving normalized progress", () => {
|
||||
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialDistanceM: 220_000,
|
||||
});
|
||||
const progress = controller.state().progress;
|
||||
controller.setRoute("la-sf-i-5", true);
|
||||
assert.equal(controller.routeId(), "la-sf-i-5");
|
||||
assert.ok(Math.abs(controller.state().progress - progress) < 1e-12);
|
||||
assert.equal(controller.state().elapsedSteps, 0);
|
||||
});
|
||||
|
||||
it("compresses corridor progress without changing vehicle dynamics", () => {
|
||||
const normal = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialSpeedMps: 20,
|
||||
mode: "manual",
|
||||
});
|
||||
const compressed = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
initialSpeedMps: 20,
|
||||
mode: "manual",
|
||||
travelScale: 900,
|
||||
});
|
||||
normal.stepFixed();
|
||||
compressed.stepFixed();
|
||||
assert.equal(compressed.state().speedMps, normal.state().speedMps);
|
||||
assert.equal(compressed.state().steering, normal.state().steering);
|
||||
assert.ok(compressed.state().distanceM > normal.state().distanceM * 800);
|
||||
assert.equal(compressed.state().wheelRadians, normal.state().wheelRadians);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { mergeVehicleActions, sampleStandardGamepad } from "../input/vehicle.ts";
|
||||
|
||||
function pad(over: { axes?: number[]; buttons?: Record<number, number> } = {}) {
|
||||
const buttons = Array.from({ length: 8 }, (_, index) => ({
|
||||
pressed: (over.buttons?.[index] ?? 0) > 0.5,
|
||||
value: over.buttons?.[index] ?? 0,
|
||||
}));
|
||||
return { axes: over.axes ?? [0, 0, 0, 0], buttons };
|
||||
}
|
||||
|
||||
describe("vehicle input adapters", () => {
|
||||
it("maps a standard gamepad with a steering deadzone and analogue triggers", () => {
|
||||
const sample = sampleStandardGamepad(pad({ axes: [0.5], buttons: { 6: 0.2, 7: 0.75, 1: 1 } }));
|
||||
assert.ok(sample.actions.steering > 0 && sample.actions.steering < 0.5);
|
||||
assert.equal(sample.actions.brake, 0.2);
|
||||
assert.equal(sample.actions.throttle, 0.75);
|
||||
assert.equal(sample.actions.handbrake, true);
|
||||
assert.equal(sampleStandardGamepad(pad({ axes: [0.05] })).actions.steering, 0);
|
||||
});
|
||||
|
||||
it("publishes assisted/reset buttons only on their rising edge", () => {
|
||||
const first = sampleStandardGamepad(pad({ buttons: { 2: 1, 3: 1 } }));
|
||||
assert.equal(first.actions.modeRequest, "assisted");
|
||||
assert.equal(first.actions.reset, true);
|
||||
const held = sampleStandardGamepad(pad({ buttons: { 2: 1, 3: 1 } }), first.buttons);
|
||||
assert.equal(held.actions.modeRequest, "none");
|
||||
assert.equal(held.actions.reset, false);
|
||||
});
|
||||
|
||||
it("merges simultaneous adapters by strongest analogue and any safety input", () => {
|
||||
assert.deepEqual(
|
||||
mergeVehicleActions(
|
||||
{ throttle: 1, steering: -0.4 },
|
||||
{ brake: 0.7, steering: 0.8, handbrake: true },
|
||||
),
|
||||
{
|
||||
throttle: 1,
|
||||
brake: 0.7,
|
||||
steering: 0.8,
|
||||
handbrake: true,
|
||||
modeRequest: "none",
|
||||
reset: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/** Determinism and lifecycle tests for statewide road traffic. */
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||
import {
|
||||
VehicleSimulation,
|
||||
buildRoutePath,
|
||||
distanceMetres,
|
||||
sampleRoute,
|
||||
} from "../transport/vehicleSim.ts";
|
||||
|
||||
describe("vehicle simulation", () => {
|
||||
it("builds both complete routes and samples their declared endpoints", () => {
|
||||
for (const id of ["la-sf-us-101", "la-sf-i-5"]) {
|
||||
const path = buildRoutePath(CALIFORNIA_TRANSPORT, id);
|
||||
assert.ok(path.lengthM > 500_000);
|
||||
const start = sampleRoute(path, 0);
|
||||
const finish = sampleRoute(path, path.lengthM - 0.01);
|
||||
assert.ok(distanceMetres(start, { lat: 34.0522, lng: -118.2437 }) < 10);
|
||||
assert.ok(distanceMetres(finish, { lat: 37.7749, lng: -122.4194 }) < 20);
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for the same seed, route, and frame sequence", () => {
|
||||
const a = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
count: 8,
|
||||
seed: 42,
|
||||
});
|
||||
const b = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
count: 8,
|
||||
seed: 42,
|
||||
});
|
||||
for (const dt of [0.016, 0.033, 0.2, 0.041, 0.08]) {
|
||||
a.tick(dt);
|
||||
b.tick(dt);
|
||||
}
|
||||
assert.deepEqual(a.poses(), b.poses());
|
||||
});
|
||||
|
||||
it("caps a background-tab delta and keeps every pose finite", () => {
|
||||
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-i-5",
|
||||
count: 16,
|
||||
});
|
||||
const path = buildRoutePath(CALIFORNIA_TRANSPORT, "la-sf-i-5");
|
||||
const before = sim.poses()[0]?.distanceM ?? 0;
|
||||
sim.tick(600);
|
||||
const after = sim.poses()[0]?.distanceM ?? 0;
|
||||
const direct = Math.abs(after - before);
|
||||
const travelled = Math.min(direct, path.lengthM - direct);
|
||||
assert.ok(travelled < 20_000, "a sleeping tab must not replay ten minutes");
|
||||
for (const pose of sim.poses()) {
|
||||
assert.ok(Number.isFinite(pose.lat));
|
||||
assert.ok(Number.isFinite(pose.lng));
|
||||
assert.ok(Number.isFinite(pose.headingDeg));
|
||||
assert.ok(pose.progress >= 0 && pose.progress < 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("changes route as one deterministic state transition", () => {
|
||||
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
count: 4,
|
||||
seed: 7,
|
||||
});
|
||||
sim.setRoute("la-sf-i-5");
|
||||
assert.equal(sim.routeId(), "la-sf-i-5");
|
||||
assert.ok(sim.poses().every((pose) => pose.routeId === "la-sf-i-5"));
|
||||
assert.equal(sim.poses()[0]?.id, "model-x-hero");
|
||||
});
|
||||
|
||||
it("moves reverse traffic south while keeping its heading finite", () => {
|
||||
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
count: 4,
|
||||
seed: 115,
|
||||
timeScale: 1,
|
||||
});
|
||||
const southbound = sim.poses().find((pose) => pose.direction === -1);
|
||||
assert.equal(southbound?.direction, -1);
|
||||
const before = southbound?.distanceM ?? 0;
|
||||
const beforeLat = southbound?.lat ?? 0;
|
||||
sim.tick(0.1);
|
||||
assert.ok((southbound?.distanceM ?? before) < before);
|
||||
assert.ok((southbound?.lat ?? beforeLat) < beforeLat);
|
||||
assert.ok(Number.isFinite(southbound?.headingDeg));
|
||||
});
|
||||
|
||||
it("returns one stable pose view for allocation-free render polling", () => {
|
||||
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||
routeId: "la-sf-us-101",
|
||||
count: 4,
|
||||
});
|
||||
const poses = sim.poses();
|
||||
sim.tick(0.1);
|
||||
assert.equal(sim.poses(), poses);
|
||||
assert.equal(sim.poses()[0], poses[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { Plan } from "../interiors/plan.ts";
|
||||
import {
|
||||
createWalker,
|
||||
normalizeWalkerAction,
|
||||
type WalkerOptions,
|
||||
} from "../interiors/walker.ts";
|
||||
import type { Level, Office, Room, Wall } from "../interiors/types.ts";
|
||||
|
||||
const ROOM: Room = {
|
||||
id: "floor",
|
||||
name: "Floor",
|
||||
floor: "floor" as never,
|
||||
outline: [
|
||||
{ x: 0, z: 0 },
|
||||
{ x: 10, z: 0 },
|
||||
{ x: 10, z: 8 },
|
||||
{ x: 0, z: 8 },
|
||||
],
|
||||
};
|
||||
|
||||
function planWith(walls: Wall[] = []): Plan {
|
||||
const level: Level = {
|
||||
id: "ground",
|
||||
name: "Ground",
|
||||
elevation: 0,
|
||||
wallHeight: 3,
|
||||
wallThickness: 0.1,
|
||||
floorplan: { rooms: [ROOM], walls },
|
||||
};
|
||||
const office: Office = { id: "walk-test", name: "Walk Test", levels: [level], viewpoints: [] };
|
||||
return new Plan(office, { warn: false });
|
||||
}
|
||||
|
||||
function walker(plan: Plan, over: Partial<WalkerOptions> = {}) {
|
||||
return createWalker(plan, {
|
||||
levelId: "ground",
|
||||
position: { x: 2, z: 2 },
|
||||
speed: 1,
|
||||
fixedStep: 0.1,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe("walker input and clock", () => {
|
||||
it("normalizes planar actions without amplifying smaller input", () => {
|
||||
assert.deepEqual(normalizeWalkerAction({ x: 0.3, z: -0.4 }), { x: 0.3, z: -0.4 });
|
||||
const diagonal = normalizeWalkerAction({ x: 1, z: 1 });
|
||||
assert.ok(Math.abs(Math.hypot(diagonal.x, diagonal.z) - 1) < 1e-12);
|
||||
assert.deepEqual(normalizeWalkerAction({ x: Number.NaN, z: 1 }), { x: 0, z: 0 });
|
||||
});
|
||||
|
||||
it("moves only in fixed steps and is deterministic across frame chunking", () => {
|
||||
const plan = planWith();
|
||||
const one = walker(plan);
|
||||
const many = walker(plan);
|
||||
one.tick(0.05, { x: 1, z: 0 });
|
||||
assert.deepEqual(one.state().position, { x: 2, z: 2 });
|
||||
one.tick(0.35, { x: 1, z: 0 });
|
||||
for (let index = 0; index < 4; index += 1) many.tick(0.1, { x: 1, z: 0 });
|
||||
assert.deepEqual(one.state(), many.state());
|
||||
assert.ok(Math.abs(one.state().distance - 0.4) < 1e-12);
|
||||
});
|
||||
|
||||
it("returns defensive state snapshots", () => {
|
||||
const controller = walker(planWith());
|
||||
const leaked = controller.state();
|
||||
leaked.position.x = Number.NaN;
|
||||
assert.deepEqual(controller.state().position, { x: 2, z: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker collision", () => {
|
||||
it("sweeps its circular footprint and cannot tunnel through a wall", () => {
|
||||
const plan = planWith([{ id: "divider", from: { x: 5, z: 0 }, to: { x: 5, z: 8 } }]);
|
||||
const controller = walker(plan, { speed: 100, fixedStep: 0.1 });
|
||||
const state = controller.tick(0.1, { x: 1, z: 0 });
|
||||
assert.ok(state.position.x < 4.65 && state.position.x > 4.64, `${state.position.x}`);
|
||||
assert.equal(plan.blocked("ground", state.position, state.position, 0.3), false);
|
||||
});
|
||||
|
||||
it("slides the unblocked component of diagonal movement along a wall", () => {
|
||||
const plan = planWith([{ id: "divider", from: { x: 5, z: 0 }, to: { x: 5, z: 8 } }]);
|
||||
const controller = walker(plan, { position: { x: 4.6, z: 2 }, speed: 2 });
|
||||
for (let index = 0; index < 10; index += 1) controller.tick(0.1, { x: 1, z: 1 });
|
||||
const state = controller.state();
|
||||
assert.ok(state.position.x < 4.65, `${state.position.x}`);
|
||||
assert.ok(state.position.z > 3, `${state.position.z}`);
|
||||
assert.equal(plan.blocked("ground", state.position, state.position, 0.3), false);
|
||||
});
|
||||
|
||||
it("walks through a resolved door gap without a door-specific exception", () => {
|
||||
const plan = planWith([
|
||||
{
|
||||
id: "divider",
|
||||
from: { x: 5, z: 0 },
|
||||
to: { x: 5, z: 8 },
|
||||
openings: [{ kind: "door", start: 3.4, width: 1.2, sill: 0, head: 2.1 }],
|
||||
},
|
||||
]);
|
||||
const controller = walker(plan, { position: { x: 4, z: 4 }, speed: 2 });
|
||||
for (let index = 0; index < 10; index += 1) controller.tick(0.1, { x: 1, z: 0 });
|
||||
assert.ok(controller.state().position.x > 5.5, `${controller.state().position.x}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker guardrails", () => {
|
||||
it("stays within finite level bounds and ignores invalid time/input", () => {
|
||||
const controller = walker(planWith(), { position: { x: 9.6, z: 4 }, speed: 10 });
|
||||
controller.tick(Number.NaN, { x: 1, z: 0 });
|
||||
controller.tick(1, { x: Number.POSITIVE_INFINITY, z: 0 });
|
||||
assert.deepEqual(controller.state().position, { x: 9.6, z: 4 });
|
||||
controller.tick(1, { x: 1, z: 0 });
|
||||
const state = controller.state();
|
||||
assert.equal(state.position.x, 9.7);
|
||||
assert.ok(Number.isFinite(state.position.x) && Number.isFinite(state.distance));
|
||||
});
|
||||
|
||||
it("resets atomically and rejects invalid spawns/configuration", () => {
|
||||
const plan = planWith();
|
||||
const controller = walker(plan);
|
||||
controller.tick(0.2, { x: 1, z: 0 });
|
||||
assert.deepEqual(controller.reset({ levelId: "ground", position: { x: 7, z: 6 } }).position, { x: 7, z: 6 });
|
||||
assert.equal(controller.state().distance, 0);
|
||||
assert.throws(() => controller.reset({ levelId: "ground", position: { x: Number.NaN, z: 1 } }), RangeError);
|
||||
assert.deepEqual(controller.state().position, { x: 7, z: 6 });
|
||||
assert.throws(() => walker(plan, { radius: 0 }), RangeError);
|
||||
assert.throws(() => walker(plan, { position: { x: 11, z: 2 } }), RangeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user