1
0

feat: add playable actors and seamless journey state

This commit is contained in:
2026-08-11 19:10:14 -07:00
parent 4e313c4a79
commit a2a52bfdae
19 changed files with 2778 additions and 24 deletions
+187
View File
@@ -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);
});
});
+297
View File
@@ -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");
});
});
+99
View File
@@ -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);
});
});
+135
View File
@@ -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);
});
});