feat: add playable actors and seamless journey state
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
ActorController,
|
||||
normalizeActorActions,
|
||||
replayActorInputs,
|
||||
type ActorControllerOptions,
|
||||
type ActorIdentity,
|
||||
} from "../actors/index.ts";
|
||||
|
||||
const IDENTITY: ActorIdentity = {
|
||||
id: "person-42",
|
||||
displayName: "River",
|
||||
authenticated: true,
|
||||
profile: {
|
||||
handle: "river",
|
||||
pronouns: "they/them",
|
||||
faceImageUrl: "/profiles/42/face",
|
||||
appearance: { skinTone: "#8f6048", primaryColor: "#17324d", accentColor: "#64b5c8" },
|
||||
},
|
||||
};
|
||||
|
||||
function groundOptions(over: Partial<ActorControllerOptions> = {}): ActorControllerOptions {
|
||||
return {
|
||||
kind: "humanoid",
|
||||
identity: IDENTITY,
|
||||
position: { x: 2, y: 99, z: 3 },
|
||||
fixedStepSeconds: 0.1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("playable actor input and ground mode", () => {
|
||||
it("normalizes every adapter axis and the ground movement disc", () => {
|
||||
const action = normalizeActorActions({
|
||||
forward: 3,
|
||||
right: 4,
|
||||
turn: -9,
|
||||
pitch: Number.NaN,
|
||||
climb: Infinity,
|
||||
sprint: true,
|
||||
glide: true,
|
||||
kindRequest: "crow",
|
||||
modeRequest: "flight",
|
||||
});
|
||||
assert.ok(Math.abs(Math.hypot(action.forward, action.right) - 1) < 1e-12);
|
||||
assert.equal(action.turn, -1);
|
||||
assert.equal(action.pitch, 0);
|
||||
assert.equal(action.climb, 0);
|
||||
assert.equal(action.kindRequest, "crow");
|
||||
assert.equal(action.modeRequest, "flight");
|
||||
assert.equal(action.sprint, true);
|
||||
assert.deepEqual(normalizeActorActions(undefined), {
|
||||
forward: 0,
|
||||
right: 0,
|
||||
turn: 0,
|
||||
pitch: 0,
|
||||
climb: 0,
|
||||
sprint: false,
|
||||
glide: false,
|
||||
modeRequest: "none",
|
||||
kindRequest: "none",
|
||||
reset: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves and turns in deterministic fixed steps while requesting a gait phase", () => {
|
||||
const one = new ActorController(groundOptions());
|
||||
const many = new ActorController(groundOptions());
|
||||
one.tick(0.05, { forward: 1 });
|
||||
assert.equal(one.state().z, 3);
|
||||
one.tick(0.15, { forward: 1, turn: 0.25 });
|
||||
one.tick(0.2, { forward: 1, turn: 0.25 });
|
||||
for (let index = 0; index < 4; index++) many.tick(0.1, { forward: 1, turn: 0.25 });
|
||||
assert.deepEqual(one.snapshot(), many.snapshot());
|
||||
assert.ok(one.state().z < 3);
|
||||
assert.ok(one.state().yaw > 0);
|
||||
assert.ok(one.state().posePhase > 0 && one.state().posePhase < Math.PI * 2);
|
||||
assert.equal(one.state().poseAmount, 1);
|
||||
assert.equal(one.state().y, 0, "ground datum wins over a stale spawn y");
|
||||
});
|
||||
|
||||
it("bounds a ground actor and applies dog gait speed without producing non-finite state", () => {
|
||||
const controller = new ActorController(groundOptions({
|
||||
kind: "dog",
|
||||
position: { x: 0.95, z: 0.95 },
|
||||
horizontalBounds: { minX: -1, maxX: 1, minZ: -1, maxZ: 1 },
|
||||
}));
|
||||
for (let index = 0; index < 20; index++) controller.stepFixed({ right: 1, forward: -1, sprint: true });
|
||||
assert.equal(controller.state().x, 1);
|
||||
assert.equal(controller.state().z, 1);
|
||||
assert.ok(Number.isFinite(controller.state().distanceM));
|
||||
assert.equal(controller.state().kind, "dog");
|
||||
});
|
||||
});
|
||||
|
||||
describe("crow flight", () => {
|
||||
it("turns, pitches, climbs and glides inside strict altitude bounds", () => {
|
||||
const controller = new ActorController(groundOptions({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
position: { x: 0, y: 1, z: 0 },
|
||||
minFlightAltitude: 1,
|
||||
maxFlightAltitude: 3,
|
||||
}));
|
||||
for (let index = 0; index < 30; index++) {
|
||||
controller.stepFixed({ forward: 1, turn: 0.5, pitch: 1, climb: 1 });
|
||||
}
|
||||
assert.equal(controller.state().y, 3);
|
||||
assert.equal(controller.state().altitudeBoundContact, "maximum");
|
||||
assert.ok(controller.state().yaw !== 0);
|
||||
assert.ok(controller.state().pitch > 0 && controller.state().pitch < Math.PI / 2);
|
||||
const phase = controller.state().posePhase;
|
||||
controller.stepFixed({ glide: true, climb: -1, pitch: -1 });
|
||||
assert.equal(controller.state().gliding, true);
|
||||
assert.equal(controller.state().posePhase, phase, "gliding holds the wing phase");
|
||||
for (let index = 0; index < 80; index++) controller.stepFixed({ glide: true, climb: -1, pitch: -1 });
|
||||
assert.equal(controller.state().y, 1);
|
||||
assert.equal(controller.state().altitudeBoundContact, "minimum");
|
||||
});
|
||||
|
||||
it("allows only a crow to enter flight and lands exactly on the ground datum", () => {
|
||||
const controller = new ActorController(groundOptions({ groundY: 12 }));
|
||||
controller.stepFixed({ modeRequest: "flight" });
|
||||
assert.equal(controller.state().mode, "ground");
|
||||
controller.stepFixed({ kindRequest: "crow", modeRequest: "flight" });
|
||||
assert.equal(controller.state().mode, "flight");
|
||||
assert.ok(controller.state().y >= 12.75);
|
||||
controller.stepFixed({ modeRequest: "ground" });
|
||||
assert.equal(controller.state().mode, "ground");
|
||||
assert.equal(controller.state().y, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actor identity, reset and replay", () => {
|
||||
it("switches actor kind without losing a detached serializable identity", () => {
|
||||
const controller = new ActorController(groundOptions());
|
||||
const identityBefore = JSON.stringify(controller.snapshot().identity);
|
||||
controller.setActorKind("dog");
|
||||
controller.setActorKind("crow");
|
||||
assert.equal(JSON.stringify(controller.snapshot().identity), identityBefore);
|
||||
const detached = controller.snapshot();
|
||||
detached.identity.profile.handle = "tampered";
|
||||
assert.equal(controller.state().identity.profile.handle, "river");
|
||||
assert.doesNotThrow(() => JSON.stringify(controller.snapshot()));
|
||||
});
|
||||
|
||||
it("resets exactly and caps a resumed background tab", () => {
|
||||
const controller = new ActorController(groundOptions());
|
||||
const spawn = controller.snapshot();
|
||||
controller.stepFixed({ kindRequest: "crow", modeRequest: "flight", climb: 1 });
|
||||
controller.setIdentity({ id: "anon-9", displayName: "Guest", authenticated: false, profile: {} });
|
||||
controller.tick(Number.NaN, { forward: 1 });
|
||||
controller.tick(-4, { forward: 1 });
|
||||
const steps = controller.tick(600, { forward: 1 });
|
||||
assert.ok(steps <= 3);
|
||||
controller.stepFixed({ reset: true });
|
||||
assert.deepEqual(controller.snapshot(), spawn);
|
||||
});
|
||||
|
||||
it("replays timed cross-kind input bit-for-bit", () => {
|
||||
const frames = [
|
||||
{ steps: 12, actions: { forward: 1, turn: 0.2 } },
|
||||
{ steps: 1, actions: { kindRequest: "crow" as const, modeRequest: "flight" as const } },
|
||||
{ steps: 30, actions: { forward: 0.7, turn: -0.4, climb: 0.5 } },
|
||||
{ steps: 15, actions: { glide: true, pitch: -0.2 } },
|
||||
{ steps: 1, actions: { kindRequest: "dog" as const } },
|
||||
{ steps: 8, actions: { right: 1, sprint: true } },
|
||||
];
|
||||
const first = replayActorInputs(groundOptions(), frames);
|
||||
const second = replayActorInputs(groundOptions(), frames);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.trajectory.length, 68);
|
||||
assert.equal(first.final.elapsedSteps, 67);
|
||||
assert.equal(first.final.kind, "dog");
|
||||
assert.equal(first.final.mode, "ground");
|
||||
});
|
||||
|
||||
it("rejects invalid configuration and identity before creating state", () => {
|
||||
assert.throws(() => new ActorController(groundOptions({ minFlightAltitude: 5, maxFlightAltitude: 2 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({ walkSpeedMps: 0 })), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({
|
||||
horizontalBounds: { minX: 2, maxX: 1, minZ: 0, maxZ: 1 },
|
||||
})), RangeError);
|
||||
assert.throws(() => new ActorController(groundOptions({ identity: { ...IDENTITY, id: "" } })), RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
createJourney,
|
||||
decodeJourneySnapshot,
|
||||
encodeJourneySnapshot,
|
||||
endpointCity,
|
||||
clearJourneySession,
|
||||
journeyReducer,
|
||||
loadJourneySession,
|
||||
officeCity,
|
||||
saveJourneySession,
|
||||
transitionJourney,
|
||||
type JourneyActor,
|
||||
type JourneyEvent,
|
||||
type JourneyState,
|
||||
type JourneyStorageAdapter,
|
||||
} from "../journey/index.ts";
|
||||
|
||||
const CROW: JourneyActor = {
|
||||
id: "anon-115",
|
||||
kind: "crow",
|
||||
signedIn: false,
|
||||
profile: { displayName: "Visitor", color: "midnight" },
|
||||
};
|
||||
|
||||
const KARTI: JourneyActor = {
|
||||
id: "user-karti",
|
||||
kind: "humanoid",
|
||||
signedIn: true,
|
||||
profile: { displayName: "Karti", face: "profile:user-karti", color: "black" },
|
||||
};
|
||||
|
||||
function playFromLosAngeles(): JourneyState {
|
||||
let state = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
|
||||
state = journeyReducer(state, {
|
||||
type: "select-route",
|
||||
routeId: "la-sf-i-5",
|
||||
direction: 1,
|
||||
});
|
||||
state = journeyReducer(state, { type: "enter-vehicle", vehicleId: "model-x-hero" });
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("journey state machine", () => {
|
||||
it("navigates explicitly between California and either detailed city board", () => {
|
||||
const california = createJourney({ actor: CROW, mode: "play" });
|
||||
const actor = california.actor;
|
||||
const bayArea = journeyReducer(california, {
|
||||
type: "navigate-to-city",
|
||||
city: "bay-area",
|
||||
});
|
||||
assert.deepEqual(bayArea.location, { scale: "bay-area" });
|
||||
assert.equal(bayArea.actor, actor);
|
||||
|
||||
const returned = journeyReducer(bayArea, { type: "return-to-california" });
|
||||
assert.deepEqual(returned.location, { scale: "california" });
|
||||
assert.equal(returned.actor, actor);
|
||||
const southern = journeyReducer(returned, { type: "navigate-to-city", city: "socal" });
|
||||
assert.deepEqual(southern.location, { scale: "socal" });
|
||||
});
|
||||
|
||||
it("requires leaving an office or vehicle before board navigation", () => {
|
||||
let driving = playFromLosAngeles();
|
||||
assert.deepEqual(
|
||||
transitionJourney(driving, { type: "navigate-to-city", city: "bay-area" }),
|
||||
{ accepted: false, state: driving, reason: "exit-vehicle-first" },
|
||||
);
|
||||
|
||||
driving = journeyReducer(driving, { type: "exit-vehicle" });
|
||||
const bayArea = journeyReducer(driving, { type: "navigate-to-city", city: "bay-area" });
|
||||
let office = journeyReducer(bayArea, {
|
||||
type: "enter-office",
|
||||
officeId: "lumbridge-hq",
|
||||
});
|
||||
assert.deepEqual(
|
||||
transitionJourney(office, { type: "return-to-california" }),
|
||||
{ accepted: false, state: office, reason: "leave-office-first" },
|
||||
);
|
||||
office = journeyReducer(office, { type: "leave-office" });
|
||||
assert.equal(journeyReducer(office, { type: "return-to-california" }).location.scale, "california");
|
||||
});
|
||||
|
||||
it("maps route endpoints honestly onto the detailed city boards", () => {
|
||||
assert.equal(endpointCity("los-angeles"), "socal");
|
||||
assert.equal(endpointCity("san-francisco"), "bay-area");
|
||||
|
||||
let northbound = playFromLosAngeles();
|
||||
northbound = journeyReducer(northbound, { type: "update-route-progress", progress: 0.74 });
|
||||
northbound = journeyReducer(northbound, {
|
||||
type: "reach-route-endpoint",
|
||||
endpoint: "san-francisco",
|
||||
});
|
||||
assert.equal(northbound.location.scale, "bay-area");
|
||||
assert.equal(northbound.route?.progress, 1);
|
||||
assert.equal(northbound.vehicle?.vehicleId, "model-x-hero");
|
||||
|
||||
let southbound = journeyReducer(northbound, { type: "exit-vehicle" });
|
||||
southbound = journeyReducer(southbound, {
|
||||
type: "select-route",
|
||||
routeId: "la-sf-us-101",
|
||||
direction: -1,
|
||||
});
|
||||
southbound = journeyReducer(southbound, { type: "enter-vehicle", vehicleId: "model-x-hero" });
|
||||
southbound = journeyReducer(southbound, {
|
||||
type: "reach-route-endpoint",
|
||||
endpoint: "los-angeles",
|
||||
});
|
||||
assert.equal(southbound.location.scale, "socal");
|
||||
assert.equal(southbound.route?.progress, 0);
|
||||
});
|
||||
|
||||
it("preserves identity and prior city across office doors", () => {
|
||||
let state = createJourney({ actor: KARTI, location: { scale: "bay-area" }, mode: "play" });
|
||||
const actor = state.actor;
|
||||
state = journeyReducer(state, { type: "enter-office", officeId: "frontier-valley" });
|
||||
assert.deepEqual(state.location, {
|
||||
scale: "office",
|
||||
officeId: "frontier-valley",
|
||||
priorCity: "bay-area",
|
||||
});
|
||||
assert.equal(state.actor, actor);
|
||||
state = journeyReducer(state, { type: "leave-office" });
|
||||
assert.deepEqual(state.location, { scale: "bay-area" });
|
||||
assert.equal(state.actor, actor);
|
||||
assert.equal(officeCity("mateo-court"), "socal");
|
||||
});
|
||||
|
||||
it("swaps a signed-in actor without disturbing their journey", () => {
|
||||
const before = playFromLosAngeles();
|
||||
const after = journeyReducer(before, { type: "sign-in-actor-swap", actor: KARTI });
|
||||
assert.deepEqual(after.actor, KARTI);
|
||||
assert.notEqual(after.actor, KARTI, "the reducer owns a defensive actor copy");
|
||||
assert.deepEqual(after.location, before.location);
|
||||
assert.deepEqual(after.vehicle, before.vehicle);
|
||||
assert.deepEqual(after.route, before.route);
|
||||
});
|
||||
|
||||
it("rejects invalid and out-of-order transitions as exact no-ops", () => {
|
||||
const state = createJourney({ actor: CROW });
|
||||
const badEvents: JourneyEvent[] = [
|
||||
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
|
||||
{ type: "enter-office", officeId: "lumbridge-hq" },
|
||||
{ type: "leave-office" },
|
||||
{ type: "update-route-progress", progress: 0.5 },
|
||||
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
|
||||
{ type: "sign-in-actor-swap", actor: CROW },
|
||||
];
|
||||
for (const event of badEvents) {
|
||||
const result = transitionJourney(state, event);
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.state, state);
|
||||
assert.equal(journeyReducer(state, event), state);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires the endpoint that agrees with the selected direction", () => {
|
||||
const state = playFromLosAngeles();
|
||||
const rejected = transitionJourney(state, {
|
||||
type: "reach-route-endpoint",
|
||||
endpoint: "los-angeles",
|
||||
});
|
||||
assert.deepEqual(rejected, { accepted: false, state, reason: "wrong-endpoint" });
|
||||
});
|
||||
|
||||
it("requires play mode, a route, and exiting the car before an office", () => {
|
||||
const observer = createJourney({ actor: CROW, location: { scale: "socal" } });
|
||||
assert.equal(
|
||||
transitionJourney(observer, { type: "enter-vehicle", vehicleId: "x" }).accepted,
|
||||
false,
|
||||
);
|
||||
|
||||
let driving = playFromLosAngeles();
|
||||
driving = journeyReducer(driving, {
|
||||
type: "reach-route-endpoint",
|
||||
endpoint: "san-francisco",
|
||||
});
|
||||
const result = transitionJourney(driving, {
|
||||
type: "enter-office",
|
||||
officeId: "lumbridge-hq",
|
||||
});
|
||||
assert.deepEqual(result, { accepted: false, state: driving, reason: "exit-vehicle-first" });
|
||||
});
|
||||
|
||||
it("encodes and decodes an isolated versioned reconnect snapshot", () => {
|
||||
const state = playFromLosAngeles();
|
||||
const encoded = encodeJourneySnapshot(state);
|
||||
const decoded = decodeJourneySnapshot(encoded);
|
||||
assert.equal(decoded.ok, true);
|
||||
if (!decoded.ok) return;
|
||||
assert.equal(decoded.snapshot.version, 1);
|
||||
assert.deepEqual(decoded.state, state);
|
||||
assert.notEqual(decoded.state, state);
|
||||
decoded.state.actor.profile.displayName = "Changed offline";
|
||||
assert.equal(state.actor.profile.displayName, "Visitor");
|
||||
});
|
||||
|
||||
it("fails closed on corrupt, inconsistent, and future snapshots", () => {
|
||||
assert.deepEqual(decodeJourneySnapshot("{"), {
|
||||
ok: false,
|
||||
error: "journey: snapshot is not valid JSON",
|
||||
});
|
||||
assert.equal(decodeJourneySnapshot({ version: 2, state: {} }).ok, false);
|
||||
assert.equal(
|
||||
decodeJourneySnapshot({
|
||||
version: 1,
|
||||
state: {
|
||||
...createJourney({ actor: CROW }),
|
||||
location: {
|
||||
scale: "office",
|
||||
officeId: "mateo-court",
|
||||
priorCity: "bay-area",
|
||||
},
|
||||
},
|
||||
}).ok,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic for the same initial state and event log", () => {
|
||||
const initial = createJourney({ actor: CROW, location: { scale: "socal" }, mode: "play" });
|
||||
const events: JourneyEvent[] = [
|
||||
{ type: "select-route", routeId: "la-sf-us-101", direction: 1 },
|
||||
{ type: "enter-vehicle", vehicleId: "model-x-hero" },
|
||||
{ type: "update-route-progress", progress: 0.25 },
|
||||
{ type: "update-route-progress", progress: 0.75 },
|
||||
{ type: "reach-route-endpoint", endpoint: "san-francisco" },
|
||||
{ type: "exit-vehicle" },
|
||||
{ type: "enter-office", officeId: "lumbridge-hq" },
|
||||
{ type: "sign-in-actor-swap", actor: KARTI },
|
||||
];
|
||||
const run = (): JourneyState => events.reduce(journeyReducer, initial);
|
||||
assert.deepEqual(run(), run());
|
||||
});
|
||||
});
|
||||
|
||||
class MemoryStorage implements JourneyStorageAdapter {
|
||||
readonly values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
describe("journey session persistence", () => {
|
||||
it("round-trips through a caller-owned storage adapter and clears cleanly", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const state = playFromLosAngeles();
|
||||
assert.deepEqual(saveJourneySession(storage, "tera.journey", state), { ok: true });
|
||||
const loaded = loadJourneySession(storage, "tera.journey");
|
||||
assert.equal(loaded.status, "loaded");
|
||||
if (loaded.status !== "loaded") return;
|
||||
assert.deepEqual(loaded.state, state);
|
||||
assert.notEqual(loaded.state, state);
|
||||
assert.deepEqual(clearJourneySession(storage, "tera.journey"), { ok: true });
|
||||
assert.deepEqual(loadJourneySession(storage, "tera.journey"), { status: "missing" });
|
||||
});
|
||||
|
||||
it("rejects corrupt and future sessions without throwing or mutating storage", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.values.set("corrupt", "{");
|
||||
storage.values.set("future", JSON.stringify({ version: 2, state: {} }));
|
||||
assert.equal(loadJourneySession(storage, "corrupt").status, "invalid");
|
||||
assert.deepEqual(loadJourneySession(storage, "future"), {
|
||||
status: "invalid",
|
||||
error: "journey: unsupported snapshot version",
|
||||
});
|
||||
assert.equal(storage.values.has("future"), true);
|
||||
});
|
||||
|
||||
it("contains adapter exceptions and invalid keys", () => {
|
||||
const unavailable: JourneyStorageAdapter = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("full");
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
};
|
||||
const state = playFromLosAngeles();
|
||||
assert.equal(loadJourneySession(unavailable, "tera.journey").status, "unavailable");
|
||||
assert.equal(saveJourneySession(unavailable, "tera.journey", state).ok, false);
|
||||
assert.equal(clearJourneySession(unavailable, "tera.journey").ok, false);
|
||||
assert.equal(loadJourneySession(unavailable, " ").status, "invalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { createOfficeWalker } from "../interiors/officeWalker.ts";
|
||||
import { Plan } from "../interiors/plan.ts";
|
||||
import type { Level, Office, Room, Wall } from "../interiors/types.ts";
|
||||
|
||||
const FLOOR: Room = {
|
||||
id: "floor",
|
||||
name: "Floor",
|
||||
floor: "floor" as never,
|
||||
outline: [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 8 }, { x: 0, z: 8 }],
|
||||
};
|
||||
|
||||
function makePlan(walls: Wall[] = [], elevation = 2.5): Plan {
|
||||
const level: Level = {
|
||||
id: "ground",
|
||||
name: "Ground",
|
||||
elevation,
|
||||
wallHeight: 3,
|
||||
wallThickness: 0.1,
|
||||
floorplan: { rooms: [FLOOR], walls },
|
||||
};
|
||||
const office: Office = { id: "actor-test", name: "Actor Test", levels: [level], viewpoints: [] };
|
||||
return new Plan(office, { warn: false });
|
||||
}
|
||||
|
||||
describe("office walker actor adapter", () => {
|
||||
it("is inert by default and becomes a floor-aware humanoid when activated", () => {
|
||||
const actor = createOfficeWalker(makePlan(), {
|
||||
levelId: "ground",
|
||||
position: { x: 2, z: 2 },
|
||||
speed: 1,
|
||||
fixedStep: 0.1,
|
||||
});
|
||||
assert.equal(actor.state().actor, "humanoid");
|
||||
assert.equal(actor.root.position.y, 2.5);
|
||||
actor.setAction({ x: 2, z: 0 });
|
||||
actor.tick(0.2);
|
||||
assert.deepEqual(actor.state().position, { x: 2, z: 2 });
|
||||
actor.setActive(true);
|
||||
assert.deepEqual(actor.action(), { x: 0, z: 0 }, "activation never replays stale input");
|
||||
actor.setAction({ x: 2, z: 0 });
|
||||
actor.tick(0.2);
|
||||
assert.ok(actor.state().position.x > 2.19);
|
||||
assert.equal(actor.view.position.y, 2.5);
|
||||
assert.ok(Math.abs(actor.root.rotation.y + Math.PI / 2) < 1e-12);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("builds an anonymous dog and publishes a defensive chase-camera contract", () => {
|
||||
const actor = createOfficeWalker(makePlan(), {
|
||||
levelId: "ground",
|
||||
position: { x: 4, z: 4 },
|
||||
actor: { kind: "anonymous-dog", coatColor: 0x222222 },
|
||||
active: true,
|
||||
});
|
||||
assert.equal(actor.root.userData.actorType, "anonymous-dog");
|
||||
const pose = actor.followPose();
|
||||
assert.ok(pose.position.z > 4, "camera is behind a -Z-facing actor");
|
||||
assert.ok(pose.target.z < 4, "camera aims ahead of the actor");
|
||||
pose.position.x = 999;
|
||||
assert.notEqual(actor.followPose().position.x, 999);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("uses resolved door gaps while retaining wall collision and reset state", () => {
|
||||
const plan = makePlan([{
|
||||
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 actor = createOfficeWalker(plan, {
|
||||
levelId: "ground",
|
||||
position: { x: 4, z: 4 },
|
||||
speed: 2,
|
||||
fixedStep: 0.1,
|
||||
active: true,
|
||||
});
|
||||
actor.setAction({ x: 1, z: 0 });
|
||||
for (let index = 0; index < 10; index += 1) actor.tick(0.1);
|
||||
assert.ok(actor.state().position.x > 5.5);
|
||||
const reset = actor.reset({ levelId: "ground", position: { x: 2, z: 2 } });
|
||||
assert.deepEqual(reset.position, { x: 2, z: 2 });
|
||||
assert.deepEqual(reset.action, { x: 0, z: 0 });
|
||||
assert.equal(reset.distance, 0);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("removes and disposes its actor root idempotently", () => {
|
||||
const actor = createOfficeWalker(makePlan(), { levelId: "ground", position: { x: 2, z: 2 } });
|
||||
const parent = new THREE.Group();
|
||||
parent.add(actor.root);
|
||||
actor.dispose();
|
||||
actor.dispose();
|
||||
assert.equal(actor.root.parent, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
createSceneActor,
|
||||
type ActorIdentity,
|
||||
type SceneActorOptions,
|
||||
} from "../actors/index.ts";
|
||||
|
||||
const MEMBER: ActorIdentity = {
|
||||
id: "member-7",
|
||||
displayName: "Avery",
|
||||
authenticated: true,
|
||||
profile: {
|
||||
handle: "avery",
|
||||
appearance: { skinTone: "#9c6b50", primaryColor: "#183d57", accentColor: "#55b5c6" },
|
||||
},
|
||||
};
|
||||
|
||||
function options(over: Partial<SceneActorOptions> = {}): SceneActorOptions {
|
||||
return {
|
||||
kind: "humanoid",
|
||||
identity: MEMBER,
|
||||
fixedStepSeconds: 0.1,
|
||||
position: { x: 2, z: 3 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("playable city scene actor", () => {
|
||||
it("is inert by default, exposes defensive actions, and animates when active", () => {
|
||||
const actor = createSceneActor(options());
|
||||
assert.equal(actor.root.name, "playable-scene-actor");
|
||||
assert.ok(actor.root.getObjectByName("humanoid"));
|
||||
actor.setActions({ forward: 1 });
|
||||
const leaked = actor.actions();
|
||||
leaked.forward = -1;
|
||||
actor.tick(0.2);
|
||||
assert.equal(actor.state().z, 3);
|
||||
actor.setActive(true);
|
||||
assert.equal(actor.actions().forward, 0, "activation clears stale movement");
|
||||
actor.setActions({ forward: 1 });
|
||||
actor.tick(0.2);
|
||||
assert.ok(actor.state().z < 3);
|
||||
assert.ok(actor.state().posePhase > 0);
|
||||
assert.equal(actor.view.position.z, actor.root.position.z);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("maps crow metre-space flight into a scaled California board and chase pose", () => {
|
||||
const actor = createSceneActor(options({
|
||||
kind: "crow",
|
||||
mode: "flight",
|
||||
position: { x: 10, y: 4, z: -20 },
|
||||
sceneUnitsPerMetre: 0.02,
|
||||
sceneOrigin: { x: 100, y: 3, z: -50 },
|
||||
active: true,
|
||||
minFlightAltitude: 1,
|
||||
maxFlightAltitude: 6,
|
||||
}));
|
||||
assert.equal(actor.root.scale.x, 0.02);
|
||||
assert.deepEqual(actor.root.position.toArray(), [100.2, 3.08, -50.4]);
|
||||
actor.setActions({ forward: 1, climb: 1, pitch: 0.6, turn: 0.3 });
|
||||
actor.tick(0.4);
|
||||
assert.equal(actor.state().mode, "flight");
|
||||
assert.ok(actor.state().y > 4 && actor.state().y <= 6);
|
||||
assert.equal(actor.root.rotation.order, "YXZ");
|
||||
assert.equal(actor.root.rotation.x, actor.state().pitch);
|
||||
const pose = actor.followPose();
|
||||
assert.ok(pose.position.toArray().every(Number.isFinite));
|
||||
assert.ok(pose.target.toArray().every(Number.isFinite));
|
||||
pose.position.x = 999;
|
||||
assert.notEqual(actor.followPose().position.x, 999);
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("keeps its stable root and identity while replacing procedural rigs", () => {
|
||||
const actor = createSceneActor(options({ active: true }));
|
||||
const root = actor.root;
|
||||
const firstRig = root.children[0];
|
||||
actor.switchActor("crow", undefined, "flight");
|
||||
assert.equal(actor.root, root);
|
||||
assert.equal(firstRig?.parent, null);
|
||||
assert.ok(root.getObjectByName("crow"));
|
||||
assert.equal(actor.state().mode, "flight");
|
||||
assert.deepEqual(actor.state().identity, MEMBER);
|
||||
|
||||
const guest: ActorIdentity = {
|
||||
id: "anon-3",
|
||||
displayName: "Guest Crow",
|
||||
authenticated: false,
|
||||
profile: { appearance: { primaryColor: "#20252b" } },
|
||||
};
|
||||
const crowRig = root.children[0];
|
||||
actor.setIdentity(guest);
|
||||
assert.equal(crowRig?.parent, null);
|
||||
assert.deepEqual(actor.state().identity, guest);
|
||||
actor.switchActor("dog");
|
||||
assert.ok(root.getObjectByName("dog"));
|
||||
assert.equal(actor.state().mode, "ground");
|
||||
assert.deepEqual(actor.state().identity, guest);
|
||||
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 });
|
||||
actor.tick(0.1);
|
||||
assert.equal(actor.state().kind, "crow");
|
||||
assert.equal(actor.state().mode, "flight");
|
||||
assert.equal(actor.actions().kindRequest, "none");
|
||||
assert.equal(actor.actions().modeRequest, "none");
|
||||
assert.equal(actor.actions().forward, 0.4, "held axes survive edge clearing");
|
||||
assert.ok(actor.root.getObjectByName("crow"));
|
||||
actor.dispose();
|
||||
});
|
||||
|
||||
it("removes and disposes the stable root idempotently", () => {
|
||||
const actor = createSceneActor(options());
|
||||
const parent = new THREE.Group();
|
||||
parent.add(actor.root);
|
||||
actor.dispose();
|
||||
actor.dispose();
|
||||
assert.equal(actor.root.parent, null);
|
||||
const before = actor.state();
|
||||
actor.tick(1);
|
||||
assert.deepEqual(actor.state(), before);
|
||||
});
|
||||
|
||||
it("rejects invalid scene scaling and camera configuration", () => {
|
||||
assert.throws(() => createSceneActor(options({ sceneUnitsPerMetre: 0 })), RangeError);
|
||||
assert.throws(() => createSceneActor(options({ sceneOrigin: { x: Infinity, y: 0, z: 0 } })), RangeError);
|
||||
assert.throws(() => createSceneActor(options({ camera: { distance: -1 } })), RangeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user