diff --git a/ARENA.md b/ARENA.md index ba81437..2676c4d 100644 --- a/ARENA.md +++ b/ARENA.md @@ -1,7 +1,7 @@ # Tera Arena environments `@lumbridge/tera/arena` is Tera's renderer-independent RL boundary. It imports -no Three.js scene, canvas, DOM input, network client, or asset code. The four +no Three.js scene, canvas, DOM input, network client, or asset code. The five shipped environments wrap the same fixed-step controllers and plans used by the interactive client: @@ -9,6 +9,7 @@ interactive client: |---|---|---|---| | `drive-101-v1` | `VehicleController` + California transport pack | complete a short US-101 or I-5 route leg | guardrail contact | | `office-nav-v1` | `Plan(FRONTIER_VALLEY)` + `createWalker` | reach a collision-clear office waypoint | repeated collision stall | +| `office-jobs-v1` | `Plan` + `robotRoutes` + `robotActivity` | complete an authored simulated patrol/delivery/inspection job | collision stall or repeated invalid interaction | | `crow-nav-v1` | `ActorController` in crow flight mode | reach a 3D waypoint | altitude/horizontal envelope contact | | `california-flight-v1` | `AircraftController` | reach a geographic/altitude waypoint | California flight-envelope contact | @@ -85,6 +86,11 @@ The tests execute every train and dev scenario at two seeds and require: These are smoke-proof baselines, not optimal policies. They exist to make reward regressions and impossible tasks fail in CI before any training budget is spent. +`office-jobs-v1` observes only current job facts and the current route waypoint; +future seeded schedule entries are not leaked. SF and LA definitions are +explicit same-floor scenarios tied to resolved room/prop anchors. They are +demonstration data and never claim to describe live staff or company operations. + ## Adding an environment Keep the environment under `src/arena/`, wrap an existing renderer-neutral diff --git a/README.md b/README.md index 0ec2726..d1aa060 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,10 @@ npm run dev ## Headless RL environments -Tera also exports a versioned, renderer-independent Arena contract with four +Tera also exports a versioned, renderer-independent Arena contract with five deterministic environments: US-101/I-5 driving, Frontier Valley office -navigation, crow waypoint flight, and California electric-aircraft flight. +navigation, seeded SF/LA office robot jobs, crow waypoint flight, and California +electric-aircraft flight. They share the client controllers and office plan, but require no canvas, DOM, Three.js scene, network service, or new runtime dependency. @@ -85,6 +86,11 @@ component rewards, safety terminals, maximum steps, snapshots, checksummed traces, exact replay and executable inaction/scripted baseline proofs are documented in [ARENA.md](ARENA.md). +The visible SF and LA office robots use that same fixed-step job state. Their +patrol, parcel, inspection, and charging loops are authored demonstration +scenarios—not presence, telemetry, or evidence of real company work—and the UI +labels them as a seeded simulation. + ## Using the engine ```ts diff --git a/scripts/check-arena-source-hashes.mjs b/scripts/check-arena-source-hashes.mjs index f9524c4..a59e5e8 100644 --- a/scripts/check-arena-source-hashes.mjs +++ b/scripts/check-arena-source-hashes.mjs @@ -34,6 +34,21 @@ const sourceSets = { "src/offices/frontier-valley.ts", ], }, + "office-jobs-v1": { + environment: [...sharedEnvironment, "src/arena/officeJobs.ts"], + simulator: [ + "src/interiors/plan.ts", + "src/interiors/types.ts", + "src/interiors/walker.ts", + "src/interiors/robotActivity.ts", + "src/interiors/robotOperations.ts", + "src/interiors/robotRoutes.ts", + "src/offices/lumbridge-hq.ts", + "src/offices/mateo-court.ts", + "src/offices/operations/lumbridge-hq.ts", + "src/offices/operations/mateo-court.ts", + ], + }, "crow-nav-v1": { environment: [...sharedEnvironment, "src/arena/crowNav.ts"], simulator: ["src/actors/controller.ts"], diff --git a/src/arena/index.ts b/src/arena/index.ts index d350b54..503c3c4 100644 --- a/src/arena/index.ts +++ b/src/arena/index.ts @@ -44,6 +44,16 @@ export { type OfficeNavObservation, type OfficeNavReward, } from "./officeNav.ts"; +export { + OFFICE_JOBS_INACTION, + OFFICE_JOBS_MANIFEST, + OFFICE_JOBS_SCENARIOS, + OfficeJobsEnvironment, + officeJobsScriptedBaseline, + type OfficeJobsAction, + type OfficeJobsObservation, + type OfficeJobsReward, +} from "./officeJobs.ts"; export { CROW_NAV_INACTION, CROW_NAV_MANIFEST, @@ -69,11 +79,13 @@ import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts"; import { CROW_NAV_MANIFEST } from "./crowNav.ts"; import { DRIVE_101_MANIFEST } from "./drive101.ts"; import { OFFICE_NAV_MANIFEST } from "./officeNav.ts"; +import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts"; /** Machine-readable public environment catalogue. */ export const ARENA_MANIFESTS = Object.freeze([ DRIVE_101_MANIFEST, OFFICE_NAV_MANIFEST, + OFFICE_JOBS_MANIFEST, CROW_NAV_MANIFEST, CALIFORNIA_FLIGHT_MANIFEST, ]); diff --git a/src/arena/officeJobs.ts b/src/arena/officeJobs.ts new file mode 100644 index 0000000..08cfbe9 --- /dev/null +++ b/src/arena/officeJobs.ts @@ -0,0 +1,356 @@ +import { Plan } from "../interiors/plan.ts"; +import { + createRobotActivity, + type RobotActivityController, + type RobotActivitySnapshot, + type RobotActivityState, +} from "../interiors/robotActivity.ts"; +import { + resolveRobotOperations, + type ResolvedRobotOperations, + type RobotJobKind, +} from "../interiors/robotOperations.ts"; +import { LUMBRIDGE_HQ } from "../offices/lumbridge-hq.ts"; +import { MATEO_COURT } from "../offices/mateo-court.ts"; +import { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "../offices/operations/lumbridge-hq.ts"; +import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.ts"; +import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts"; +import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts"; +import { ArenaScenarioRegistry } from "./scenarios.ts"; +import { ARENA_API_VERSION, type ArenaManifest, type ArenaScenario } from "./types.ts"; + +export interface OfficeJobsAction { + x: number; + z: number; + interact: boolean; +} + +export interface OfficeJobsObservation { + officeId: string; + robotId: string; + levelId: string; + x: number; + z: number; + mode: string; + phase: string; + jobKind: RobotJobKind | "none"; + payload: "parcel" | null; + battery: number; + jobProgress: number; + nextStationId: string; + nextX: number; + nextZ: number; + deltaX: number; + deltaZ: number; + distanceToNextM: number; + canInteract: boolean; + blockedStreak: number; + recoveryCount: number; + completedJobs: number; +} + +export type OfficeJobsReward = Record< + "navigation" | "job" | "success" | "time" | "control" | "collision" | "interaction" | "safety", + number +>; + +interface OfficeJobsScenarioParameters { + officeId: "lumbridge-hq" | "mateo-court"; + robotId: string; + jobId: string; +} + +interface OfficeJobsSimulationSnapshot { + activity: RobotActivitySnapshot; + previousDistanceM: number; + previousProgress: number; + blockedStreak: number; + wrongInteractions: number; + initialCompletedJobs: number; +} + +const MAX_STEPS = 720; +// Slightly inside robotActivity's 0.30 m arrival radius so an agent never sees +// an interaction affordance the authoritative controller would still reject. +const INTERACTION_RADIUS_M = 0.295; +const WRONG_INTERACTION_LIMIT = 8; + +const SF_PLAN = new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false }); +const LA_PLAN = new Plan(MATEO_COURT, { depth: "public", warn: false }); +const SF_OPERATIONS = resolveRobotOperations(SF_PLAN, LUMBRIDGE_HQ_ROBOT_OPERATIONS); +const LA_OPERATIONS = resolveRobotOperations(LA_PLAN, MATEO_COURT_ROBOT_OPERATIONS); + +const DEFINITIONS = [ + { + id: "train-sf-display-inspection", + split: "train" as const, + parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-l1-inspector", jobId: "sf-l1-inspect-display" }, + }, + { + id: "train-la-directory-inspection", + split: "train" as const, + parameters: { officeId: "mateo-court" as const, robotId: "la-l1-inspector", jobId: "la-l1-inspect-directory" }, + }, + { + id: "dev-sf-parcel-delivery", + split: "dev" as const, + parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-l1-courier", jobId: "sf-l1-deliver" }, + }, + { + id: "dev-la-loft-delivery", + split: "dev" as const, + parameters: { officeId: "mateo-court" as const, robotId: "la-l2-courier", jobId: "la-l2-deliver" }, + }, +] as const; + +export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry( + "office-jobs-v1", + DEFINITIONS, + (parameters) => ({ ...parameters }), +); + +export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({ + apiVersion: ARENA_API_VERSION, + id: "office-jobs-v1", + version: 1, + title: "Seeded office robot jobs", + description: "Headless job execution over explicit simulated SF/LA operations and resolved office collision.", + simulator: "Plan + robotRoutes + fixed-step robotActivity", + fixedStepSeconds: 0.1, + maxSteps: MAX_STEPS, + actionFields: ["x", "z", "interact"], + observationFields: [ + "officeId", "robotId", "levelId", "x", "z", "mode", "phase", "jobKind", "payload", + "battery", "jobProgress", "nextStationId", "nextX", "nextZ", "deltaX", "deltaZ", + "distanceToNextM", "canInteract", "blockedStreak", "recoveryCount", "completedJobs", + ], + rewardComponents: { + navigation: "Bounded reduction in distance to the current authored job station.", + job: "Dense progress through pickup, delivery, inspection, or charge phases.", + success: "Sparse bonus for completing the assigned job.", + time: "Per-step pressure that keeps inaction below zero.", + control: "Small cost on planar action magnitude.", + collision: "Cost for commanded movement that fails against resolved collision.", + interaction: "Penalty for interaction away from the current work station.", + safety: "Terminal collision-stall or repeated wrong-interaction penalty.", + }, + safetyTerminals: ["collision-stall", "wrong-interaction-limit"], + scenarioIds: { + train: OFFICE_JOBS_SCENARIOS.ids("train"), + dev: OFFICE_JOBS_SCENARIOS.ids("dev"), + }, + baselines: { + inaction: "x=0,z=0,interact=false never completes a job and accumulates the time floor.", + scripted: "Follow the exposed current route waypoint and interact only inside the station radius.", + }, +}); + +export const OFFICE_JOBS_INACTION: Readonly = Object.freeze({ + x: 0, + z: 0, + interact: false, +}); + +export function officeJobsScriptedBaseline(observation: OfficeJobsObservation): OfficeJobsAction { + if (observation.canInteract) return { x: 0, z: 0, interact: true }; + const length = Math.hypot(observation.deltaX, observation.deltaZ); + if (length <= 1e-9) return { x: 0, z: 0, interact: false }; + const magnitude = Math.min(1, length / (1.05 * OFFICE_JOBS_MANIFEST.fixedStepSeconds)); + return { + x: observation.deltaX / length * magnitude, + z: observation.deltaZ / length * magnitude, + interact: false, + }; +} + +export class OfficeJobsEnvironment extends BaseArenaEnvironment< + OfficeJobsAction, + OfficeJobsObservation, + OfficeJobsReward, + OfficeJobsSimulationSnapshot, + OfficeJobsScenarioParameters +> { + readonly manifest = OFFICE_JOBS_MANIFEST; + protected readonly registry = OFFICE_JOBS_SCENARIOS; + protected readonly sourceHashes = ARENA_SOURCE_HASHES["office-jobs-v1"]!; + private activity: RobotActivityController | null = null; + private operations: ResolvedRobotOperations | null = null; + private robotId = ""; + private previousDistanceM = 0; + private previousProgress = 0; + private blockedStreak = 0; + private wrongInteractions = 0; + private initialCompletedJobs = 0; + + protected resetSimulation( + scenario: ArenaScenario, + ): OfficeJobsObservation { + const selected = setupFor(scenario.parameters.officeId); + this.operations = selected.operations; + this.robotId = scenario.parameters.robotId; + this.activity = createRobotActivity(selected.plan, selected.operations, { + seed: scenario.seed, + robotIds: [this.robotId], + controlledRobotIds: [this.robotId], + initialJobIds: { [this.robotId]: scenario.parameters.jobId }, + }); + const robot = this.robot(); + this.previousDistanceM = this.distanceToNext(robot); + this.previousProgress = robot.progress; + this.blockedStreak = 0; + this.wrongInteractions = 0; + this.initialCompletedJobs = robot.completedJobs; + return this.observation(); + } + + protected normalizeAction(action: OfficeJobsAction): OfficeJobsAction { + const x = Number.isFinite(action?.x) ? action.x : 0; + const z = Number.isFinite(action?.z) ? action.z : 0; + const length = Math.hypot(x, z); + return { + x: length > 1 ? x / length : x, + z: length > 1 ? z / length : z, + interact: action?.interact === true, + }; + } + + protected advanceSimulation( + action: OfficeJobsAction, + ): SimulationTransition { + const before = this.robot(); + const beforePosition = { ...before.position }; + const canInteract = this.canInteract(before); + if (action.interact && !canInteract) this.wrongInteractions += 1; + const after = this.requireActivity().step({ [this.robotId]: action }).robots[0]!; + const moved = Math.hypot(after.position.x - beforePosition.x, after.position.z - beforePosition.z); + const demand = Math.hypot(action.x, action.z); + const blocked = demand > 0.2 && moved < 0.012; + this.blockedStreak = blocked ? this.blockedStreak + 1 : 0; + const distance = this.distanceToNext(after); + const navigationProgress = Math.max(-0.3, Math.min(0.3, this.previousDistanceM - distance)); + const jobProgress = Math.max(-0.5, Math.min(0.5, after.progress - this.previousProgress)); + this.previousDistanceM = distance; + this.previousProgress = after.progress; + const success = after.completedJobs > this.initialCompletedJobs; + const collisionStall = after.mode === "blocked-recovery"; + const wrongInteractionFailure = this.wrongInteractions >= WRONG_INTERACTION_LIMIT; + const controllerFailure = after.terminalReason; + const terminated = success || collisionStall || wrongInteractionFailure || controllerFailure !== null; + const terminalReason = success + ? "job-complete" + : collisionStall + ? "collision-stall" + : wrongInteractionFailure + ? "wrong-interaction-limit" + : controllerFailure ?? undefined; + return { + observation: this.observation(), + rewardComponents: { + navigation: navigationProgress * 0.18, + job: jobProgress * 1.2, + success: success ? 8 : 0, + time: -0.006, + control: -0.001 * demand ** 2, + collision: blocked ? -0.08 : 0, + interaction: action.interact && !canInteract ? -0.12 : 0, + safety: collisionStall || wrongInteractionFailure || controllerFailure ? -2 : 0, + }, + terminated, + terminalReason, + }; + } + + protected simulationSnapshot(): OfficeJobsSimulationSnapshot { + return { + activity: this.requireActivity().snapshot(), + previousDistanceM: this.previousDistanceM, + previousProgress: this.previousProgress, + blockedStreak: this.blockedStreak, + wrongInteractions: this.wrongInteractions, + initialCompletedJobs: this.initialCompletedJobs, + }; + } + + protected restoreSimulation(snapshot: OfficeJobsSimulationSnapshot): OfficeJobsObservation { + this.resetSimulation(this.currentScenario()); + if ( + !Number.isFinite(snapshot.previousDistanceM) || !Number.isFinite(snapshot.previousProgress) || + !Number.isSafeInteger(snapshot.blockedStreak) || snapshot.blockedStreak < 0 || + !Number.isSafeInteger(snapshot.wrongInteractions) || snapshot.wrongInteractions < 0 || + !Number.isSafeInteger(snapshot.initialCompletedJobs) || snapshot.initialCompletedJobs < 0 + ) throw new Error("office jobs simulation snapshot is invalid"); + this.requireActivity().restore(snapshot.activity); + this.previousDistanceM = snapshot.previousDistanceM; + this.previousProgress = snapshot.previousProgress; + this.blockedStreak = snapshot.blockedStreak; + this.wrongInteractions = snapshot.wrongInteractions; + this.initialCompletedJobs = snapshot.initialCompletedJobs; + return this.observation(); + } + + private observation(): OfficeJobsObservation { + const robot = this.robot(); + const station = this.nextStation(robot); + const waypoint = robot.route[robot.routeIndex] ?? station?.position ?? robot.position; + const distance = station ? this.distanceToNext(robot) : 0; + return { + officeId: this.requireOperations().definition.officeId, + robotId: robot.id, + levelId: robot.levelId, + x: robot.position.x, + z: robot.position.z, + mode: robot.mode, + phase: robot.phase, + jobKind: robot.activeJobKind ?? "none", + payload: robot.payload, + battery: robot.battery, + jobProgress: robot.progress, + nextStationId: station?.id ?? "", + nextX: waypoint.x, + nextZ: waypoint.z, + deltaX: waypoint.x - robot.position.x, + deltaZ: waypoint.z - robot.position.z, + distanceToNextM: distance, + canInteract: this.canInteract(robot), + blockedStreak: this.blockedStreak, + recoveryCount: robot.recoveryCount, + completedJobs: robot.completedJobs, + }; + } + + private nextStation(robot: RobotActivityState) { + const id = robot.activeStationIds[robot.stationIndex]; + return id ? this.requireOperations().stations.get(id) : undefined; + } + + private distanceToNext(robot: RobotActivityState): number { + const station = this.nextStation(robot); + return station ? Math.hypot(station.position.x - robot.position.x, station.position.z - robot.position.z) : 0; + } + + private canInteract(robot: RobotActivityState): boolean { + return this.distanceToNext(robot) <= INTERACTION_RADIUS_M; + } + + private robot(): RobotActivityState { + const robot = this.requireActivity().states()[0]; + if (!robot) throw new Error("office jobs environment has no controlled robot"); + return robot; + } + + private requireActivity(): RobotActivityController { + if (!this.activity) throw new Error("office jobs environment has not been reset"); + return this.activity; + } + + private requireOperations(): ResolvedRobotOperations { + if (!this.operations) throw new Error("office jobs environment has not been reset"); + return this.operations; + } +} + +function setupFor(officeId: OfficeJobsScenarioParameters["officeId"]) { + return officeId === "lumbridge-hq" + ? { plan: SF_PLAN, operations: SF_OPERATIONS } + : { plan: LA_PLAN, operations: LA_OPERATIONS }; +} diff --git a/src/arena/sourceHashes.ts b/src/arena/sourceHashes.ts index 2a6fbd0..84fc793 100644 --- a/src/arena/sourceHashes.ts +++ b/src/arena/sourceHashes.ts @@ -13,6 +13,10 @@ export const ARENA_SOURCE_HASHES: Readonly> = environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a", simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3", }, + "office-jobs-v1": { + environment: "sha256:525bdab6cdc5ea1ca684654d0745392936df68ecec415a145eb05a07c003d9bd", + simulator: "sha256:df19af3cd7f906c54dc87f15e4a6df0003bd585c13ccdb63ad3b5632e8c49200", + }, "crow-nav-v1": { environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797", simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8", diff --git a/src/index.ts b/src/index.ts index d280723..30fcc95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,9 @@ export * from "./arena/index.ts"; export * from "./actors/controller.ts"; export * from "./aircraft/controller.ts"; export * from "./interiors/plan.ts"; +export * from "./interiors/robotActivity.ts"; +export * from "./interiors/robotOperations.ts"; +export * from "./interiors/robotRoutes.ts"; export * from "./interiors/types.ts"; export * from "./interiors/walker.ts"; export * from "./transport/types.ts"; diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index 72e64e0..0f67fa8 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -96,9 +96,9 @@ import { import { createRobotLayer, type RobotLayer, - type RobotSpec, type RobotView, } from "./robots.ts"; +import type { RobotOperationsDefinition } from "./robotOperations.ts"; import type { ScenePeers, ScenePeersOptions, @@ -220,17 +220,8 @@ export interface OfficeSceneOptions { * those are the three things you actually feel. */ horizon?: { drop: number }; - /** - * Humanoids to walk about the floor, one entry per robot. - * - * **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.** - * The build-time-exclusion rule in this file's header is about *occupancy* — a - * `Presence` names a person and comes from an authenticated API, so the public - * office must not construct one. A robot is nobody: it carries no id anybody - * issued, no seat binding, and no data from anywhere. There is nothing to - * withhold, so a stranger gets them too. - */ - robots?: readonly RobotSpec[]; + /** Authored seeded simulation; omitted means this office has no robot activity. */ + robotOperations?: RobotOperationsDefinition; /** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */ walker?: OfficeWalkerOptions; /** Full-depth-only authoritative remote actors/vehicles in local metre coordinates. */ @@ -319,6 +310,8 @@ export interface OfficeScene extends StageScene { * The plan panel and the ceiling lights both consume it that way. */ robots(): readonly RobotView[]; + /** Honest provenance for UI and diagnostics; null when no simulation is authored. */ + robotActivityInfo(): { operationsId: string; disclosure: string; simulated: true } | null; upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean; removeRemoteEntity(id: string): boolean; clearRemoteEntities(): void; @@ -521,8 +514,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): const luminaires: Luminaires = createLuminaires(furnishings.luminaires); const robots: RobotLayer | null = - options.robots && options.robots.length > 0 - ? createRobotLayer(plan, { materials, robots: options.robots }) + options.robotOperations && options.robotOperations.robots.length > 0 + ? createRobotLayer(plan, { materials, operations: options.robotOperations }) : null; const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null; if (officeWalker) scene.add(officeWalker.root); @@ -856,6 +849,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions): }, houseLevel: () => luminaires.houseLevel(), robots: () => robots?.robots() ?? NO_ROBOTS, + robotActivityInfo: () => robots + ? { + operationsId: options.robotOperations!.id, + disclosure: robots.disclosure, + simulated: true, + } + : null, setWalkers(walkers) { luminaires.setWalkers(walkers); }, diff --git a/src/interiors/robotActivity.ts b/src/interiors/robotActivity.ts new file mode 100644 index 0000000..7bd7036 --- /dev/null +++ b/src/interiors/robotActivity.ts @@ -0,0 +1,636 @@ +import { createWalker, normalizeWalkerAction, type WalkerController } from "./walker.ts"; +import type { Plan } from "./plan.ts"; +import { + type ResolvedRobotOperations, + type RobotActivityMode, + type RobotJobKind, + type RobotSpawnDefinition, +} from "./robotOperations.ts"; +import { createRobotRouter, type RobotRouter } from "./robotRoutes.ts"; +import type { Point2 } from "./types.ts"; + +export const ROBOT_ACTIVITY_SCHEMA_VERSION = 1; +export const ROBOT_ACTIVITY_FIXED_STEP_SECONDS = 0.1; + +const ROBOT_SPEED_MPS = 1.05; +const ROBOT_RADIUS_M = 0.28; +const ARRIVAL_RADIUS_M = 0.3; +const WAYPOINT_RADIUS_M = 0.035; +const BLOCKED_TICKS = 10; +const RECOVERY_TICKS = 6; +const MAX_RECOVERIES = 4; +const MOVE_BATTERY_PER_M = 0.00055; +const IDLE_BATTERY_PER_TICK = 0.000004; +const CHARGE_PER_TICK = 0.018; +const CHARGE_TARGET = 0.96; +const LOW_BATTERY = 0.16; + +export interface RobotActivityAction { + x: number; + z: number; + interact: boolean; +} + +export type RobotPayload = "parcel" | null; + +export interface RobotActivityState { + id: string; + label: string; + levelId: string; + position: Point2; + facing: Point2; + mode: RobotActivityMode; + phase: string; + activeJobId: string | null; + activeJobKind: RobotJobKind | null; + activeStationIds: string[]; + jobSequence: number; + scheduleCursor: number; + stationIndex: number; + route: Point2[]; + routeIndex: number; + battery: number; + payload: RobotPayload; + progress: number; + objectiveTicks: number; + blockedTicks: number; + recoveryTicks: number; + recoveryCount: number; + idleTicks: number; + completedJobs: number; + travelledM: number; + terminalReason: "battery-depleted" | "blocked-unrecoverable" | null; + /** This is always true so snapshots cannot be mistaken for live operations. */ + simulated: true; +} + +export interface RobotActivitySnapshot { + schemaVersion: typeof ROBOT_ACTIVITY_SCHEMA_VERSION; + operationsId: string; + operationsVersion: number; + seed: number; + tick: number; + accumulatorSeconds: number; + robots: RobotActivityState[]; +} + +export interface RobotActivityTrace { + schemaVersion: typeof ROBOT_ACTIVITY_SCHEMA_VERSION; + initial: RobotActivitySnapshot; + actions: Array>; + final: RobotActivitySnapshot; +} + +export interface RobotActivityOptions { + seed?: number; + /** Omit to instantiate every robot authored by the operations definition. */ + robotIds?: readonly string[]; + /** These robots require explicit movement and interaction actions. */ + controlledRobotIds?: readonly string[]; + /** Arena/eval hook: begin these robots on an explicit authored job. */ + initialJobIds?: Readonly>; +} + +export interface RobotActivityController { + readonly fixedStepSeconds: number; + readonly disclosure: string; + states(): readonly RobotActivityState[]; + step(actions?: Readonly>): RobotActivitySnapshot; + tick(elapsedSeconds: number, actions?: Readonly>): RobotActivitySnapshot; + snapshot(): RobotActivitySnapshot; + restore(snapshot: RobotActivitySnapshot): RobotActivitySnapshot; + trace(): RobotActivityTrace; + replay(trace: RobotActivityTrace): RobotActivitySnapshot; +} + +interface RuntimeRobot { + definition: RobotSpawnDefinition; + controlled: boolean; + walker: WalkerController; + state: RobotActivityState; +} + +/** Shared renderer-independent authority for browser robots and Arena episodes. */ +export function createRobotActivity( + plan: Plan, + operations: ResolvedRobotOperations, + options: RobotActivityOptions = {}, +): RobotActivityController { + const seed = normalizeSeed(options.seed ?? 0x54455241); + const selectedIds = options.robotIds ? new Set(options.robotIds) : null; + const controlledIds = new Set(options.controlledRobotIds ?? []); + const router = createRobotRouter(plan); + const runtimes: RuntimeRobot[] = []; + for (const definition of operations.robots.values()) { + if (selectedIds && !selectedIds.has(definition.id)) continue; + const station = operations.stations.get(definition.spawnStationId)!; + const walker = createWalker(plan, { + levelId: definition.levelId, + position: station.position, + facing: station.facing, + radius: ROBOT_RADIUS_M, + speed: ROBOT_SPEED_MPS, + fixedStep: ROBOT_ACTIVITY_FIXED_STEP_SECONDS, + maxCatchUpSteps: 1, + }); + const offset = hash(seed, definition.id, "schedule") % definition.schedule.length; + const idleTicks = 2 + hash(seed, definition.id, "stagger") % 12; + const walkerState = walker.state(); + runtimes.push({ + definition, + controlled: controlledIds.has(definition.id), + walker, + state: { + id: definition.id, + label: definition.label, + levelId: definition.levelId, + position: copy(walkerState.position), + facing: copy(walkerState.facing), + mode: "idle", + phase: "scheduled-idle", + activeJobId: null, + activeJobKind: null, + activeStationIds: [], + jobSequence: 0, + scheduleCursor: offset, + stationIndex: 0, + route: [], + routeIndex: 0, + battery: definition.initialBattery, + payload: null, + progress: 0, + objectiveTicks: 0, + blockedTicks: 0, + recoveryTicks: 0, + recoveryCount: 0, + idleTicks, + completedJobs: 0, + travelledM: 0, + terminalReason: null, + simulated: true, + }, + }); + const initialJobId = options.initialJobIds?.[definition.id]; + if (initialJobId) { + const initialJob = operations.jobs.get(initialJobId); + if (!initialJob || !definition.schedule.includes(initialJobId)) { + throw new Error(`robot ${definition.id} cannot start unknown or unscheduled job ${initialJobId}`); + } + assign(runtimes[runtimes.length - 1]!.state, initialJob.id, initialJob.kind, initialJob.stationIds); + } + } + if (selectedIds) { + for (const id of selectedIds) { + if (!runtimes.some((runtime) => runtime.definition.id === id)) { + throw new Error(`unknown robot ${id}`); + } + } + } + for (const id of controlledIds) { + if (!runtimes.some((runtime) => runtime.definition.id === id)) { + throw new Error(`controlled robot ${id} was not instantiated`); + } + } + runtimes.sort((a, b) => a.definition.id.localeCompare(b.definition.id)); + + let tickIndex = 0; + let accumulatorSeconds = 0; + let initialSnapshot: RobotActivitySnapshot; + let traceActions: Array> = []; + + function step(rawActions: Readonly> = {}): RobotActivitySnapshot { + const actions = normalizeActions(runtimes, rawActions); + for (const runtime of runtimes) advanceRobot(runtime, actions[runtime.definition.id], router, operations, seed); + tickIndex += 1; + traceActions.push(structuredClone(actions)); + return snapshot(); + } + + function tick( + elapsedSeconds: number, + actions: Readonly> = {}, + ): RobotActivitySnapshot { + if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot(); + accumulatorSeconds = Math.min( + ROBOT_ACTIVITY_FIXED_STEP_SECONDS * 8, + accumulatorSeconds + elapsedSeconds, + ); + let steps = 0; + while (accumulatorSeconds + 1e-9 >= ROBOT_ACTIVITY_FIXED_STEP_SECONDS && steps < 8) { + accumulatorSeconds -= ROBOT_ACTIVITY_FIXED_STEP_SECONDS; + if (accumulatorSeconds < 0) accumulatorSeconds = 0; + step(actions); + steps += 1; + } + return snapshot(); + } + + function snapshot(): RobotActivitySnapshot { + return { + schemaVersion: ROBOT_ACTIVITY_SCHEMA_VERSION, + operationsId: operations.definition.id, + operationsVersion: operations.definition.version, + seed, + tick: tickIndex, + accumulatorSeconds, + robots: runtimes.map((runtime) => cloneState(runtime.state)), + }; + } + + function restore(next: RobotActivitySnapshot): RobotActivitySnapshot { + assertSnapshot(next, operations, seed, runtimes); + tickIndex = next.tick; + accumulatorSeconds = next.accumulatorSeconds; + for (const runtime of runtimes) { + const state = next.robots.find((candidate) => candidate.id === runtime.definition.id)!; + runtime.walker.restore({ + levelId: state.levelId, + position: state.position, + facing: state.facing, + distance: state.travelledM, + }); + runtime.state = cloneState(state); + } + initialSnapshot = snapshot(); + traceActions = []; + return snapshot(); + } + + function trace(): RobotActivityTrace { + return { + schemaVersion: ROBOT_ACTIVITY_SCHEMA_VERSION, + initial: structuredClone(initialSnapshot), + actions: structuredClone(traceActions), + final: snapshot(), + }; + } + + function replay(traceEnvelope: RobotActivityTrace): RobotActivitySnapshot { + if (traceEnvelope.schemaVersion !== ROBOT_ACTIVITY_SCHEMA_VERSION || !Array.isArray(traceEnvelope.actions)) { + throw new Error("robot activity trace is invalid"); + } + restore(structuredClone(traceEnvelope.initial)); + for (const actions of traceEnvelope.actions) step(actions); + const actual = snapshot(); + if (canonical(actual) !== canonical(traceEnvelope.final)) { + throw new Error("robot activity trace diverged"); + } + return actual; + } + + initialSnapshot = snapshot(); + return { + fixedStepSeconds: ROBOT_ACTIVITY_FIXED_STEP_SECONDS, + disclosure: operations.definition.disclosure, + states: () => snapshot().robots, + step, + tick, + snapshot, + restore, + trace, + replay, + }; +} + +function advanceRobot( + runtime: RuntimeRobot, + action: RobotActivityAction | undefined, + router: RobotRouter, + operations: ResolvedRobotOperations, + seed: number, +): void { + const state = runtime.state; + if (state.terminalReason) return; + state.battery = clamp01(state.battery - IDLE_BATTERY_PER_TICK); + if (state.battery <= 0) { + state.battery = 0; + state.mode = "idle"; + state.phase = "terminal"; + state.terminalReason = "battery-depleted"; + return; + } + + if (state.mode === "blocked-recovery") { + recover(runtime, seed); + return; + } + + if (!state.activeJobId) { + if (state.idleTicks > 0) { + state.idleTicks -= 1; + return; + } + const charge = nearestCharge(operations, state.levelId); + if (state.battery <= LOW_BATTERY && charge) { + assign(state, `$battery:${charge.id}`, "charge", [charge.id]); + } else { + const jobId = runtime.definition.schedule[state.scheduleCursor]!; + const job = operations.jobs.get(jobId)!; + assign(state, job.id, job.kind, job.stationIds); + state.scheduleCursor = (state.scheduleCursor + 1) % runtime.definition.schedule.length; + } + } + + const stationId = state.activeStationIds[state.stationIndex]; + const station = stationId ? operations.stations.get(stationId) : undefined; + if (!station || station.levelId !== state.levelId) { + state.mode = "idle"; + state.phase = "terminal"; + state.terminalReason = "blocked-unrecoverable"; + return; + } + + const distanceToStation = distance(state.position, station.position); + if (distanceToStation <= ARRIVAL_RADIUS_M) { + state.route = []; + state.routeIndex = 0; + state.blockedTicks = 0; + const walker = runtime.walker.state(); + runtime.walker.restore({ ...walker, facing: station.facing }); + syncWalker(runtime); + const canWork = !runtime.controlled || action?.interact === true; + if (state.activeJobKind === "charge" && canWork) { + state.phase = "charging"; + state.objectiveTicks += 1; + state.battery = clamp01(state.battery + CHARGE_PER_TICK); + updateProgress(state); + if (state.battery >= CHARGE_TARGET) completeJob(state, runtime.definition, seed); + return; + } + if (!canWork) { + state.phase = "awaiting-interaction"; + return; + } + state.phase = activityPhase(state.activeJobKind, state.stationIndex); + state.objectiveTicks += 1; + updateProgress(state); + const job = operations.jobs.get(state.activeJobId!); + const dwellTicks = job?.dwellTicks ?? 8; + if (state.objectiveTicks >= dwellTicks) completeObjective(state, runtime.definition, seed); + return; + } + + state.objectiveTicks = 0; + state.phase = navigationPhase(state.activeJobKind, state.stationIndex); + if (state.route.length === 0 || state.routeIndex >= state.route.length) { + const route = router.route(state.levelId, state.position, station.position); + if (!route) { + enterRecovery(state); + return; + } + state.route = route.waypoints.map(copy); + state.routeIndex = 0; + } + while ( + state.routeIndex < state.route.length - 1 && + distance(state.position, state.route[state.routeIndex]!) <= WAYPOINT_RADIUS_M + ) state.routeIndex += 1; + const waypoint = state.route[state.routeIndex] ?? station.position; + const autonomous = movementToward(state.position, waypoint); + const movement = runtime.controlled + ? normalizeWalkerAction(action ?? { x: 0, z: 0 }) + : autonomous; + const demand = Math.hypot(movement.x, movement.z); + const before = runtime.walker.state(); + const after = runtime.walker.tick(ROBOT_ACTIVITY_FIXED_STEP_SECONDS, movement); + const moved = distance(before.position, after.position); + state.battery = clamp01(state.battery - moved * MOVE_BATTERY_PER_M); + syncWalker(runtime); + state.blockedTicks = demand > 0.2 && moved < ROBOT_SPEED_MPS * ROBOT_ACTIVITY_FIXED_STEP_SECONDS * 0.12 + ? state.blockedTicks + 1 + : 0; + if (state.blockedTicks >= BLOCKED_TICKS) enterRecovery(state); + updateProgress(state); +} + +function recover(runtime: RuntimeRobot, seed: number): void { + const state = runtime.state; + state.phase = "backoff-and-replan"; + const angle = (hash(seed, state.id, state.recoveryCount) / 0xffff_ffff) * Math.PI * 2; + const action = { x: Math.sin(angle), z: Math.cos(angle) }; + runtime.walker.tick(ROBOT_ACTIVITY_FIXED_STEP_SECONDS, action); + syncWalker(runtime); + state.recoveryTicks -= 1; + if (state.recoveryTicks > 0) return; + if (state.recoveryCount >= MAX_RECOVERIES) { + state.mode = "idle"; + state.phase = "terminal"; + state.terminalReason = "blocked-unrecoverable"; + return; + } + state.mode = state.activeJobKind ?? "idle"; + state.phase = "replanning"; + state.route = []; + state.routeIndex = 0; + state.blockedTicks = 0; +} + +function enterRecovery(state: RobotActivityState): void { + state.mode = "blocked-recovery"; + state.phase = "backoff-and-replan"; + state.recoveryTicks = RECOVERY_TICKS; + state.recoveryCount += 1; + state.route = []; + state.routeIndex = 0; + state.blockedTicks = 0; +} + +function assign( + state: RobotActivityState, + id: string, + kind: RobotJobKind, + stationIds: readonly string[], +): void { + state.activeJobId = id; + state.activeJobKind = kind; + state.activeStationIds = [...stationIds]; + state.stationIndex = 0; + state.mode = kind; + state.phase = navigationPhase(kind, 0); + state.route = []; + state.routeIndex = 0; + state.objectiveTicks = 0; + state.recoveryCount = 0; + state.jobSequence += 1; + updateProgress(state); +} + +function completeObjective( + state: RobotActivityState, + definition: RobotSpawnDefinition, + seed: number, +): void { + if (state.activeJobKind === "deliver" && state.stationIndex === 0) state.payload = "parcel"; + if (state.activeJobKind === "deliver" && state.stationIndex === 1) state.payload = null; + state.objectiveTicks = 0; + state.stationIndex += 1; + state.route = []; + state.routeIndex = 0; + if (state.stationIndex >= state.activeStationIds.length) completeJob(state, definition, seed); + else { + state.phase = navigationPhase(state.activeJobKind, state.stationIndex); + updateProgress(state); + } +} + +function completeJob(state: RobotActivityState, definition: RobotSpawnDefinition, seed: number): void { + state.activeJobId = null; + state.activeJobKind = null; + state.activeStationIds = []; + state.stationIndex = 0; + state.mode = "idle"; + state.phase = "scheduled-idle"; + state.progress = 1; + state.objectiveTicks = 0; + state.completedJobs += 1; + state.idleTicks = 3 + hash(seed, definition.id, state.jobSequence, "idle") % 8; +} + +function updateProgress(state: RobotActivityState): void { + const count = Math.max(1, state.activeStationIds.length); + const local = state.activeJobKind === "charge" + ? Math.min(1, state.battery / CHARGE_TARGET) + : Math.min(0.95, state.objectiveTicks / 20); + state.progress = Math.min(1, (state.stationIndex + local) / count); +} + +function navigationPhase(kind: RobotJobKind | null, stationIndex: number): string { + if (kind === "deliver") return stationIndex === 0 ? "to-pickup" : "to-dropoff"; + if (kind === "inspect") return "to-inspection"; + if (kind === "charge") return "to-charge"; + return "patrolling"; +} + +function activityPhase(kind: RobotJobKind | null, stationIndex: number): string { + if (kind === "deliver") return stationIndex === 0 ? "loading-parcel" : "delivering-parcel"; + if (kind === "inspect") return "inspecting"; + if (kind === "charge") return "charging"; + return "patrol-check"; +} + +function nearestCharge(operations: ResolvedRobotOperations, levelId: string) { + return [...operations.stations.values()] + .filter((station) => station.levelId === levelId && station.role === "charge") + .sort((a, b) => a.id.localeCompare(b.id))[0]; +} + +function syncWalker(runtime: RuntimeRobot): void { + const walker = runtime.walker.state(); + runtime.state.position = copy(walker.position); + runtime.state.facing = copy(walker.facing); + runtime.state.travelledM = walker.distance; +} + +function normalizeActions( + runtimes: readonly RuntimeRobot[], + actions: Readonly>, +): Record { + const normalized: Record = {}; + for (const runtime of runtimes) { + if (!runtime.controlled) continue; + const raw = actions[runtime.definition.id]; + const movement = normalizeWalkerAction(raw ?? { x: 0, z: 0 }); + normalized[runtime.definition.id] = { + x: movement.x, + z: movement.z, + interact: raw?.interact === true, + }; + } + return normalized; +} + +function assertSnapshot( + snapshot: RobotActivitySnapshot, + operations: ResolvedRobotOperations, + seed: number, + runtimes: readonly RuntimeRobot[], +): void { + if ( + snapshot.schemaVersion !== ROBOT_ACTIVITY_SCHEMA_VERSION || + snapshot.operationsId !== operations.definition.id || + snapshot.operationsVersion !== operations.definition.version || + snapshot.seed !== seed || !Number.isSafeInteger(snapshot.tick) || snapshot.tick < 0 || + !(snapshot.accumulatorSeconds >= 0 && snapshot.accumulatorSeconds < ROBOT_ACTIVITY_FIXED_STEP_SECONDS + 1e-9) || + !Array.isArray(snapshot.robots) || snapshot.robots.length !== runtimes.length + ) throw new Error("robot activity snapshot is incompatible or invalid"); + const expectedIds = runtimes.map((runtime) => runtime.definition.id).sort(); + const actualIds = snapshot.robots.map((robot) => robot.id).sort(); + if (canonical(expectedIds) !== canonical(actualIds)) throw new Error("robot activity snapshot robot set differs"); + for (const robot of snapshot.robots) { + if ( + !finite(robot.position) || !finite(robot.facing) || robot.levelId.length === 0 || + !Number.isFinite(robot.battery) || robot.battery < 0 || robot.battery > 1 || + !Number.isFinite(robot.progress) || robot.progress < 0 || robot.progress > 1 || + !Number.isFinite(robot.travelledM) || robot.travelledM < 0 || robot.simulated !== true || + !Number.isSafeInteger(robot.scheduleCursor) || !Number.isSafeInteger(robot.stationIndex) || + !Number.isSafeInteger(robot.jobSequence) || !Number.isSafeInteger(robot.completedJobs) || + !Array.isArray(robot.route) || robot.route.some((point) => !finite(point)) + ) throw new Error(`robot activity snapshot state for ${robot.id} is invalid`); + } +} + +function cloneState(state: RobotActivityState): RobotActivityState { + return { + ...state, + position: copy(state.position), + facing: copy(state.facing), + activeStationIds: [...state.activeStationIds], + route: state.route.map(copy), + }; +} + +function direction(from: Point2, to: Point2): Point2 { + const dx = to.x - from.x; + const dz = to.z - from.z; + const length = Math.hypot(dx, dz); + return length > 1e-9 ? { x: dx / length, z: dz / length } : { x: 0, z: 0 }; +} + +function movementToward(from: Point2, to: Point2): Point2 { + const heading = direction(from, to); + const magnitude = Math.min( + 1, + distance(from, to) / (ROBOT_SPEED_MPS * ROBOT_ACTIVITY_FIXED_STEP_SECONDS), + ); + return { x: heading.x * magnitude, z: heading.z * magnitude }; +} + +function distance(a: Point2, b: Point2): number { + return Math.hypot(a.x - b.x, a.z - b.z); +} + +function finite(point: Point2): boolean { + return Number.isFinite(point.x) && Number.isFinite(point.z); +} + +function copy(point: Point2): Point2 { + return { x: point.x, z: point.z }; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function normalizeSeed(value: number): number { + return Number.isFinite(value) ? Math.trunc(value) >>> 0 : 0; +} + +function hash(seed: number, ...parts: Array): number { + let result = (0x811c9dc5 ^ seed) >>> 0; + const text = parts.join("\u0000"); + for (let index = 0; index < text.length; index += 1) { + result ^= text.charCodeAt(index); + result = Math.imul(result, 0x01000193) >>> 0; + } + return result; +} + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map( + ([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`, + ).join(",")}}`; + } + return JSON.stringify(value); +} diff --git a/src/interiors/robotOperations.ts b/src/interiors/robotOperations.ts new file mode 100644 index 0000000..ebb03e2 --- /dev/null +++ b/src/interiors/robotOperations.ts @@ -0,0 +1,204 @@ +import type { Plan } from "./plan.ts"; +import type { Point2 } from "./types.ts"; + +export type RobotJobKind = "patrol" | "deliver" | "inspect" | "charge"; +export type RobotActivityMode = RobotJobKind | "idle" | "blocked-recovery"; + +export type RobotStationAnchor = + | { kind: "prop"; propId: string; standoffM?: number; side?: 1 | -1 } + | { kind: "seat"; seatId: string; standoffM?: number } + | { kind: "room"; roomId: string } + | { kind: "point"; levelId: string; position: Point2; facing?: Point2 }; + +export interface RobotStationDefinition { + id: string; + label: string; + role: "patrol" | "pickup" | "dropoff" | "inspect" | "charge"; + anchor: RobotStationAnchor; +} + +export interface RobotJobDefinition { + id: string; + label: string; + kind: RobotJobKind; + stationIds: readonly string[]; + /** Fixed simulation ticks spent performing work at each station. */ + dwellTicks: number; +} + +export interface RobotSpawnDefinition { + id: string; + label: string; + levelId: string; + spawnStationId: string; + schedule: readonly string[]; + initialBattery: number; +} + +/** + * Authored simulated operations for one office. This is demonstration data, + * never presence data and never a representation of real people or real work. + */ +export interface RobotOperationsDefinition { + id: string; + version: number; + officeId: string; + disclosure: string; + stations: readonly RobotStationDefinition[]; + jobs: readonly RobotJobDefinition[]; + robots: readonly RobotSpawnDefinition[]; +} + +export interface ResolvedRobotStation extends RobotStationDefinition { + levelId: string; + position: Point2; + facing: Point2; +} + +export interface ResolvedRobotOperations { + definition: RobotOperationsDefinition; + stations: ReadonlyMap; + jobs: ReadonlyMap; + robots: ReadonlyMap; +} + +const ROBOT_RADIUS_M = 0.28; +const DEFAULT_PROP_STANDOFF_M = 0.9; +const DEFAULT_SEAT_STANDOFF_M = 0.65; + +/** Resolve authored addresses and reject ambiguous, cross-floor, or obstructed work. */ +export function resolveRobotOperations( + plan: Plan, + definition: RobotOperationsDefinition, +): ResolvedRobotOperations { + if (definition.officeId !== plan.office.id) { + throw new Error(`robot operations ${definition.id} target ${definition.officeId}, not ${plan.office.id}`); + } + if (!Number.isSafeInteger(definition.version) || definition.version < 1) { + throw new Error("robot operations version must be a positive integer"); + } + if (!definition.disclosure.toLowerCase().includes("simulat")) { + throw new Error("robot operations disclosure must identify the activity as simulated"); + } + + const stations = new Map(); + for (const station of definition.stations) { + unique(stations, station.id, "station"); + const resolved = resolveStation(plan, station); + if (!plan.level(resolved.levelId)) { + throw new Error(`robot station ${station.id} names unknown level ${resolved.levelId}`); + } + if (!plan.roomAt(resolved.levelId, resolved.position)) { + throw new Error(`robot station ${station.id} is not on a resolved room floor`); + } + if (plan.blocked(resolved.levelId, resolved.position, resolved.position, ROBOT_RADIUS_M)) { + throw new Error(`robot station ${station.id} has no robot clearance`); + } + stations.set(station.id, resolved); + } + + const jobs = new Map(); + for (const job of definition.jobs) { + unique(jobs, job.id, "job"); + if (!Number.isSafeInteger(job.dwellTicks) || job.dwellTicks < 1) { + throw new Error(`robot job ${job.id} dwellTicks must be a positive integer`); + } + const expected = job.kind === "deliver" ? 2 : job.kind === "patrol" ? 1 : 1; + if (job.stationIds.length < expected || (job.kind !== "patrol" && job.stationIds.length !== expected)) { + throw new Error(`robot job ${job.id} has the wrong number of stations for ${job.kind}`); + } + const levels = new Set(); + for (const stationId of job.stationIds) { + const station = stations.get(stationId); + if (!station) throw new Error(`robot job ${job.id} names unknown station ${stationId}`); + levels.add(station.levelId); + } + if (levels.size !== 1) throw new Error(`robot job ${job.id} crosses levels without an authored connector`); + jobs.set(job.id, Object.freeze({ ...job, stationIds: Object.freeze([...job.stationIds]) })); + } + + const robots = new Map(); + for (const robot of definition.robots) { + unique(robots, robot.id, "robot"); + if (!(robot.initialBattery > 0 && robot.initialBattery <= 1)) { + throw new Error(`robot ${robot.id} initialBattery must be in (0, 1]`); + } + const spawn = stations.get(robot.spawnStationId); + if (!spawn || spawn.levelId !== robot.levelId) { + throw new Error(`robot ${robot.id} spawn is missing or on a different level`); + } + if (robot.schedule.length === 0) throw new Error(`robot ${robot.id} has an empty schedule`); + for (const jobId of robot.schedule) { + const job = jobs.get(jobId); + if (!job) throw new Error(`robot ${robot.id} names unknown job ${jobId}`); + const station = stations.get(job.stationIds[0]!); + if (station?.levelId !== robot.levelId) { + throw new Error(`robot ${robot.id} schedule crosses levels at job ${jobId}`); + } + } + robots.set(robot.id, Object.freeze({ ...robot, schedule: Object.freeze([...robot.schedule]) })); + } + + return { definition, stations, jobs, robots }; +} + +function resolveStation(plan: Plan, station: RobotStationDefinition): ResolvedRobotStation { + const anchor = station.anchor; + if (anchor.kind === "point") { + return { + ...station, + levelId: anchor.levelId, + position: copy(anchor.position), + facing: normalized(anchor.facing ?? { x: 0, z: -1 }), + }; + } + if (anchor.kind === "room") { + const room = plan.levels.flatMap((level) => level.rooms).find((candidate) => candidate.id === anchor.roomId); + if (!room) throw new Error(`robot station ${station.id} names unknown room ${anchor.roomId}`); + return { ...station, levelId: room.levelId, position: copy(room.centroid), facing: { x: 0, z: -1 } }; + } + if (anchor.kind === "seat") { + const seat = plan.seat(anchor.seatId); + if (!seat) throw new Error(`robot station ${station.id} names unknown seat ${anchor.seatId}`); + const forward = yawForward(seat.facing); + const standoff = anchor.standoffM ?? DEFAULT_SEAT_STANDOFF_M; + return { + ...station, + levelId: seat.levelId, + position: { x: seat.position.x - forward.x * standoff, z: seat.position.z - forward.z * standoff }, + facing: forward, + }; + } + const prop = plan.prop(anchor.propId); + if (!prop) throw new Error(`robot station ${station.id} names unknown prop ${anchor.propId}`); + const outward = yawForward(prop.rotation); + const side = anchor.side ?? 1; + const standoff = anchor.standoffM ?? DEFAULT_PROP_STANDOFF_M; + const position = { + x: prop.position.x + outward.x * side * standoff, + z: prop.position.z + outward.z * side * standoff, + }; + return { + ...station, + levelId: prop.levelId, + position, + facing: normalized({ x: prop.position.x - position.x, z: prop.position.z - position.z }), + }; +} + +function yawForward(yaw: number): Point2 { + return { x: -Math.sin(yaw), z: -Math.cos(yaw) }; +} + +function normalized(point: Point2): Point2 { + const length = Math.hypot(point.x, point.z); + return length > 1e-9 ? { x: point.x / length, z: point.z / length } : { x: 0, z: -1 }; +} + +function unique(map: ReadonlyMap, id: string, kind: string): void { + if (!id || map.has(id)) throw new Error(`robot ${kind} id ${JSON.stringify(id)} is empty or duplicated`); +} + +function copy(point: Point2): Point2 { + return { x: point.x, z: point.z }; +} diff --git a/src/interiors/robotRoutes.ts b/src/interiors/robotRoutes.ts new file mode 100644 index 0000000..bd8f924 --- /dev/null +++ b/src/interiors/robotRoutes.ts @@ -0,0 +1,128 @@ +import type { Plan } from "./plan.ts"; +import type { Point2 } from "./types.ts"; + +const ROUTE_RADIUS_M = 0.28; +const PORTAL_OFFSET_M = 0.48; + +export interface RobotRoute { + levelId: string; + waypoints: Point2[]; + lengthM: number; +} + +export interface RobotRouter { + route(levelId: string, start: Point2, goal: Point2): RobotRoute | null; +} + +interface Node { + id: string; + point: Point2; +} + +/** + * Build a deterministic visibility graph from Plan's passable openings. The + * graph never links storeys: offices do not author lifts or stairs as routes. + */ +export function createRobotRouter(plan: Plan): RobotRouter { + const portals = new Map(); + for (const level of plan.levels) { + const nodes: Node[] = []; + for (const opening of level.openings) { + if (!opening.passable) continue; + const normal = { x: Math.sin(opening.yaw), z: Math.cos(opening.yaw) }; + const candidates = [ + opening.center, + { + x: opening.center.x + normal.x * PORTAL_OFFSET_M, + z: opening.center.z + normal.z * PORTAL_OFFSET_M, + }, + { + x: opening.center.x - normal.x * PORTAL_OFFSET_M, + z: opening.center.z - normal.z * PORTAL_OFFSET_M, + }, + ]; + candidates.forEach((point, index) => { + if (!plan.blocked(level.id, point, point, ROUTE_RADIUS_M)) { + nodes.push({ id: `${opening.id}:${index}`, point: copy(point) }); + } + }); + } + portals.set(level.id, Object.freeze(nodes.sort((a, b) => a.id.localeCompare(b.id)))); + } + + return { + route(levelId, start, goal) { + const level = plan.level(levelId); + if (!level || !finite(start) || !finite(goal)) return null; + if (!plan.roomAt(levelId, start) || !plan.roomAt(levelId, goal)) return null; + if (plan.blocked(levelId, start, start, ROUTE_RADIUS_M)) return null; + if (plan.blocked(levelId, goal, goal, ROUTE_RADIUS_M)) return null; + if (!plan.blocked(levelId, start, goal, ROUTE_RADIUS_M)) { + return { levelId, waypoints: [copy(goal)], lengthM: distance(start, goal) }; + } + + const nodes: Node[] = [ + { id: "$start", point: copy(start) }, + ...(portals.get(levelId) ?? []), + { id: "$goal", point: copy(goal) }, + ]; + const startIndex = 0; + const goalIndex = nodes.length - 1; + const costs = nodes.map(() => Number.POSITIVE_INFINITY); + const previous = nodes.map(() => -1); + const open = new Set([startIndex]); + costs[startIndex] = 0; + + while (open.size > 0) { + let current = -1; + for (const index of open) { + if ( + current < 0 || costs[index]! < costs[current]! - 1e-9 || + (Math.abs(costs[index]! - costs[current]!) <= 1e-9 && nodes[index]!.id < nodes[current]!.id) + ) current = index; + } + if (current === goalIndex) break; + open.delete(current); + for (let next = 0; next < nodes.length; next += 1) { + if (next === current || next === startIndex) continue; + const from = nodes[current]!.point; + const to = nodes[next]!.point; + if (plan.blocked(levelId, from, to, ROUTE_RADIUS_M)) continue; + const candidate = costs[current]! + distance(from, to); + if ( + candidate < costs[next]! - 1e-9 || + (Math.abs(candidate - costs[next]!) <= 1e-9 && current < previous[next]!) + ) { + costs[next] = candidate; + previous[next] = current; + open.add(next); + } + } + } + if (!Number.isFinite(costs[goalIndex])) return null; + const reversed: Point2[] = []; + for (let cursor = goalIndex; cursor !== startIndex; cursor = previous[cursor]!) { + if (cursor < 0) return null; + reversed.push(copy(nodes[cursor]!.point)); + } + reversed.reverse(); + return { levelId, waypoints: compact(reversed), lengthM: costs[goalIndex]! }; + }, + }; +} + +function compact(points: readonly Point2[]): Point2[] { + return points.filter((point, index) => index === points.length - 1 || distance(point, points[index + 1]!) > 0.08); +} + +function finite(point: Point2): boolean { + return Number.isFinite(point.x) && Number.isFinite(point.z); +} + +function distance(a: Point2, b: Point2): number { + return Math.hypot(a.x - b.x, a.z - b.z); +} + +function copy(point: Point2): Point2 { + return { x: point.x, z: point.z }; +} diff --git a/src/interiors/robots.ts b/src/interiors/robots.ts index 630ef9f..8270c1e 100644 --- a/src/interiors/robots.ts +++ b/src/interiors/robots.ts @@ -1,228 +1,10 @@ /** - * Optimus robots walking around the office. + * Thin Three.js presentation for `robotActivity.ts`. * - * This is `presence.ts`'s noisy cousin and it is deliberately a different shape, - * because it is solving a different problem. A presence is a person *at a seat*: - * it has an id, it comes from an API, it never moves, and the whole design - * effort went into making sure the geometry and the people stay on opposite - * sides of a line. A robot is nobody. It has no id worth publishing, it comes - * from nowhere, and it exists to make a still building look like a place where - * something is happening. So there is no palette, no `colorKey`, no binding to - * anything in the pack, and nothing here can leak: the only inputs are a `Plan` - * and a list of levels. - * - * ### What it costs - * - * Four robots is the budget and roughly the right number — one is a mascot, ten - * is a warehouse. - * - * - **18 draw calls each, 72 for four.** This is the whole cost and it is not - * small; the office shell and its furniture together draw in about forty. - * The reason is in `assets/office/optimus.ts`: a figure that has to bend at - * eleven joints cannot be one merged mesh, so it is eighteen small merged - * meshes instead. If a floor needs the budget back, drop the count — the - * cost is exactly linear in it, and a pack that only wants a robot standing - * somewhere should place the `tera:robot.optimus` asset, which is two. - * - **One set of geometry, 7.1k triangles, however many robots there are.** - * `buildOptimus` runs once and every figure after the first is a - * `cloneOptimus`, which shares every buffer and both materials. - * - **About 20 µs per tick for the crowd**, measured over thirty simulated - * minutes on the reference office — roughly 0.1% of a 60 Hz frame. Most of a - * tick is `plan.blocked`, which is linear in the level's collision segments - * (fifty-four on that floor); a robot spends one or two calls a frame - * steering and up to fifteen on the frames where it is boxed in and fanning - * out. Nothing here is worth caching. - * - * It used to be 24 µs, and errands made it *cheaper* rather than dearer. - * Choosing a destination with a reason costs one `blocked` call; choosing one - * at random cost up to forty-eight, because every candidate had to be tested - * for standing room and then again for line of sight. Knowing where you are - * going is less work than not. - * - **Picking a destination costs one `blocked` call** in the normal case and - * at most `SHORTLIST` of them, plus one `roomAt` — and only on the frame a - * robot arrives somewhere, which is every few seconds. The fallbacks are the - * old prices: up to two `blocked` calls on each of `PICK_ATTEMPTS` random - * candidates, then a pass over the doors. A robot that finds nowhere to go - * waits `RETRY_PAUSE` before trying again, so even one sealed into a cupboard - * costs a burst every second and a half rather than one every frame. - * - **An address book costs about 2.5 ms per level, once.** 224 places on the - * reference ground floor, each checked for standing room and a run-in, built - * the first time a robot on that level picks a destination and never again. - * It is deliberately not built at construction: a level with no robots on it - * never needs one, and a hitch during the load is a hitch nobody attributes - * correctly. - * - * ### Errands: where a robot goes, and why it is there - * - * The difference between a robot that is working and a robot that is patrolling - * is not the walk. It is the destination and the arrival. - * - * This used to pick a uniformly random reachable point, walk to it, stop at - * whatever angle it happened to be facing, wait between 1.4 and 4.6 seconds, and - * repeat. Every part of that is defensible on its own and the sum of it is a - * security guard: nowhere it goes is a place, nothing it does when it gets there - * is different from anything else it does, and the only thing distinguishing one - * stop from the next is a random number. - * - * So a destination is now an **address** — somewhere `Plan` already has a name - * for — and an address comes with an angle and a reason to linger: - * - * - a **seat**, approached from behind and held at the seat's own `facing`, so - * the robot stands at somebody's desk looking at the desk; - * - a **fixture** — an authored prop, which is to say a whiteboard, a locker, - * a shelf, a meeting chair — stood in front of and looked at; - * - a **room's centre**, which is the one that sends a robot to the kitchen. - * - * `ErrandKind` covers the choosing and `Address` the arriving. Three things - * about it are worth knowing before changing any of it: - * - * - **The kind is drawn before the address**, so the pack cannot decide the - * mix by how many desks it happens to author. See `ERRAND_MIX`. - * - **Candidates are shortlisted and scored, not taken first-fit**, on how - * recently anyone went there, how close it is to another robot or to where - * another robot is heading, and whether it is out of the room this robot is - * already standing in. That is what spreads four robots over a building - * instead of letting them pool. See `scoreAddress`. - * - **The last stretch is walked along the angle the robot will hold**, via a - * waypoint behind the destination, so it arrives lined up rather than - * stopping crooked and pivoting. See `APPROACH_RUNS`. - * - * Measured over ten thirty-minute runs on each reference pack, against the same - * harness running the version this replaced: a robot arriving somewhere with an - * angle to hold now arrives a median of 9° off it rather than stopping wherever, - * and the crowd enters 15 to 18 of the reference building's 26 rooms in half an - * hour rather than 12 to 17 — and all six of the second pack's every single run, - * rather than four to six. - * - * It also stands still more: 42% of the session against 29%. That is the point - * rather than a regression. The old 29% was a robot with nothing to do having - * nothing to do; the new 42% is four robots holding at desks, at whiteboards and - * in doorways, and the number to watch is not that one but the one below it — - * how much of the standing is a robot that has genuinely failed to find anywhere - * to go, which is what the watchdog and `pickDoor` exist to keep near zero. - * - * ### Navigation: rejection sampling, not a navmesh - * - * Underneath the errands, and still the whole of the fallback. - * - * Building a navmesh for an office would mean a floor decomposition, a portal - * graph, A*, string-pulling and a funnel — several hundred lines, a new build - * product to keep in step with `Plan`, and a whole second definition of "where - * can you stand" beside the one the wall split already produces. All of that to - * decide which way a decorative robot walks round a desk. - * - * So: pick a point on the floor, keep it if `plan.roomAt` says it is indoors and - * `plan.blocked` says the straight line from here to there crosses no wall, and - * walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch one robot for - * a minute and it looks like it is wandering; watch the algorithm and it is - * playing join-the-dots with its own line of sight. Both readings are correct - * and only one of them is visible. - * - * Two refinements on top of that, and both exist because the plain version was - * measured and found wanting rather than because they seemed like good ideas. - * Each is documented where it lives: - * - * - Candidates are drawn from a **room chosen by area**, not from the level's - * bounding box, because a level's rooms cover a fraction of its bounds and - * most candidates were landing in the void outside the walls (`samplerFor`). - * - A robot that can see nowhere to go walks to a **doorway** instead, via a - * waypoint in the opening itself, because line of sight out of a small room - * through a 0.9 m gap almost never exists and three of four robots spent - * nineteen simulated minutes parked in one (`pickDoor`). - * - * Together those took the crowd from 82% of the session standing still to under - * 30%, which is the difference between an office with robots in it and an office - * with four statues. Both still run, and both still matter: an errand needs an - * address it can see, and the two things that produce a robot which cannot see - * one — a small room, and a pack with nothing in it — are exactly what these two - * were built for. - * - * Two things this deliberately does not know about: - * - * - **Furniture.** `plan.blocked` is the wall collider and nothing else, so - * robots walk through desks. Fixing it means asking the asset registry for - * every prop's footprint and building a second collider, which is a real - * feature with a real cost and is not this. If it ever matters, the place to - * put it is `Plan`, next to the wall split, so that the walk controller and - * the robots get the same answer. - * - * Errands make this more conspicuous rather than less, because a robot now - * walks *up to* furniture on purpose. What keeps that looking right is that - * it stops short of it: `DESK_STANDOFF` and `FIXTURE_STANDOFF` are the two - * places in this file that know a prop takes up room, and both are stated as - * distances rather than looked up, precisely so that this stays true. - * - **Stairs.** A robot belongs to one level for its whole life. Levels are - * connected by nothing in the office contract, so there is nowhere for it to - * go, and a robot that walked off a mezzanine would be a bug rather than a - * feature. - * - * ### Getting unstuck, which is the part that actually needs care - * - * A straight line that was clear when the destination was chosen can stop being - * clear, because a robot turns on an arc rather than pivoting on the spot. So - * every step is re-checked, and the machinery for that is three rules that have - * to hold together — each of them broke on its own during development, and each - * failure looked like a robot standing still and thinking: - * - * 1. **One desired heading, one turn.** The heading is chosen by fanning out - * from the direction the destination wants, and then the yaw is turned - * toward it once, rate-limited. Turning toward the destination *and* toward - * the probe result in the same frame gives two rate-limited turns that - * cancel exactly, and a robot locked at a fixed angle off course forever. - * 2. **Walk only what was tested.** The rate limit means that after turning, - * the robot faces somewhere between where it was and where it probed. That - * direction has not been checked, and walking it is how a robot ends up - * inside the clearance band. - * 3. **Probe a fixed lookahead, never the step length.** `plan.blocked` is - * true when the capsule comes within `radius` of a wall *including at its - * start*, so a robot already closer to a wall than its own radius has every - * direction blocked, away from the wall included. `PROBE_AHEAD` keeps it - * well clear of that band, and `PROBE_RELIEF` gets it back out if it ever - * gets in. - * - * And because none of that is a proof, there is a watchdog on top: a robot that - * covers less than `STUCK_DISTANCE` in `STUCK_WINDOW` seconds throws its - * destination away and picks another. That is what guarantees the worst failure - * is a robot standing still and looking thoughtful, rather than one buzzing - * against a partition until somebody closes the tab. It also breaks the one - * deadlock the yielding rule can produce, where two robots stop nose to nose and - * politely wait for each other. - * - * Soaked over ten thirty-minute runs across both reference packs, four robots - * each, with a four-second frame thrown in every fifty seconds to imitate a tab - * waking up: no robot left a room and none entered the clearance band. Re-run - * unchanged after errands arrived, since a destination with a name is still just - * a point as far as everything below here is concerned — same result. - * - * ### The walk cycle runs on distance, not on time - * - * `phase = distance / STRIDE`, never `phase += dt`. This is the difference - * between feet that push the floor and feet that skate on it: a robot slowing - * into a turn takes shorter steps rather than the same steps more slowly, and a - * stopped robot's cycle stops with it instead of running on the spot. - * - * That is necessary and it is not sufficient. The shape of the swing has to - * match the distance too, which is what `legAngle` and `STRIDE` are about and is - * worth reading before touching either — a hand-picked stride against a - * sinusoidal hip measured 65% of distance travelled coming back out as foot - * slip, and the fix was arithmetic rather than taste. It now measures 5%. - * - * Stopping is the other half. A frozen phase is a frozen mid-stride, so the - * whole pose is *interpolated* toward the rest stance by `gait`, which eases to - * zero over about a quarter of a second. The phase freezes and the amplitude - * drains out of it, settling the figure from wherever it was without moving a - * foot across the floor. Fading the pose out is the only way to stop that does - * not slide; running the cycle on to the end of the stride is the way that does. - * - * One consequence, and it is the price of the arrival turn. A robot settling - * onto its seat's facing rotates with `gait` at zero and its feet planted — the - * whole figure swings about its own axis, because there is no shuffle to play - * and faking one out of the walk joints would be a stride taken on the spot, - * which is the exact thing the paragraph above is about. `SETTLE_RATE` makes - * that slow enough to read as deliberate, and `APPROACH_RUNS` makes it small - * enough to mostly not happen. A shuffle would need turn-in-place footwork the - * rig has never had, and it would have to be driven by yaw the way the walk is - * driven by distance, or it would skate for the same reason. + * Position, job, battery, payload, progress, recovery, and terminals are owned + * by the fixed-step renderer-independent controller. This module never invents + * an errand and never moves a robot independently; it mirrors snapshots into a + * shared procedural rig and honest visual cues. */ import * as THREE from "three"; @@ -232,1566 +14,232 @@ import { buildOptimus, cloneOptimus, disposeOptimus, - OPTIMUS, - OPTIMUS_REST, restOptimus, - type OptimusJoints, type OptimusRig, } from "../assets/office/optimus.ts"; -import { seededRandom } from "../engine/world.ts"; -import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom } from "./plan.ts"; -import type { Point2 } from "./types.ts"; +import { + createRobotActivity, + type RobotActivitySnapshot, + type RobotActivityState, + type RobotPayload, +} from "./robotActivity.ts"; +import { + resolveRobotOperations, + type RobotActivityMode, + type RobotOperationsDefinition, +} from "./robotOperations.ts"; +import type { Plan } from "./plan.ts"; -// ---- Tuning --------------------------------------------------------------- - -/** Metres per second on the straight. A brisk indoor human walk. */ -const CRUISE = 1.2; - -/** - * How wide a robot is to the collider. - * - * The number to size this against is not the shoulder span. `optimus.ts` puts - * the shoulder *pivots* 0.35 m apart and this comment used to quote that, which - * made 0.28 look like a radius with 100 mm of slack in it. Measured off the - * built figure, a standing Optimus is 0.520 m across at its widest — the - * shoulder drums, with the splayed hands 4 mm inside them — so 0.56 is 20 mm of - * slack a side, not 100. - * - * That is still the right answer, because a doorway `Plan` calls passable is - * 0.9 m and 0.56 goes through one with room to turn in it. But it is the number - * to think with if anything about the arms, the shoulders or the stance - * changes, and there is far less room in it than the old comment implied: the - * figure is within 40 mm of its own collider, so a wider robot silently starts - * clipping door frames rather than failing. - */ -const RADIUS = 0.28; - -/** Radians per second of yaw. About a second and a half for a half turn. */ -const TURN_RATE = 2.2; - -/** - * Hip to ankle in the rest pose — the length of the pendulum the whole gait is. - * Two numbers below are derived from it rather than dialled in, which is the - * only reason the feet stay on the floor. - */ -const LEG = OPTIMUS.hipY - OPTIMUS.ankleY; - -/** How close counts as arrived. Inside this the robot stops looking for the point. */ -const ARRIVE = 0.35; -/** - * How close counts as having reached a waypoint. Tighter than `ARRIVE`, because - * the only waypoint there is is a doorway and the whole point of going there is - * to end up lined up with it. - */ -const REACHED = 0.22; - -/** A destination has to be at least this far away, or a robot shuffles on the spot. */ -const MIN_TRIP = 1.8; -/** - * How far a doorway has to be before it counts as somewhere to go. - * - * Small on purpose. The obvious value is something like 1.4 m — far enough that - * a robot cannot immediately turn round and go back through the door it just - * came out of — and it is wrong, because a phone booth is 2.0 × 1.8 m and its - * own door is never more than 1.3 m from anywhere inside it. Set it high and the - * one room a robot most needs help escaping from is the one room it cannot. The - * doubling-back problem is solved by remembering the last door instead, which is - * what `Robot.lastDoor` is for. - */ -const MIN_DOOR = 0.45; -/** How far past a doorway to aim, so a robot ends up in the next space and not in the gap. */ -const THROUGH_DOOR = 0.85; - -const PICK_ATTEMPTS = 24; -/** Seconds to wait after failing to find anywhere to go. */ -const RETRY_PAUSE = 1.5; -/** - * Seconds a robot stands still after arriving somewhere that was **not** an - * errand — a random point on the floor, or the far side of a doorway. An errand - * sets its own dwell from `DWELL`; this is the shrug. - * - * Also the spread on the initial stagger, so four robots do not all set off on - * the same frame. - */ -const PAUSE_MIN = 1.4; -const PAUSE_MAX = 4.6; - -/** How far ahead a step is tested. See the header — this must not be the step length. */ -const PROBE_AHEAD = 0.34; -/** Headings tried, in order, when the way ahead is blocked. Radians off course. */ -const PROBE_TURNS = [0, 0.45, -0.45, 0.95, -0.95, 1.5, -1.5]; -/** - * Fractions of the collision radius the probe will settle for, in order. The - * second one only ever comes into play for a robot that is already wedged; see - * the note at the probe. - */ -const PROBE_RELIEF = [1, 0.4]; - -const STUCK_WINDOW = 1.6; -const STUCK_DISTANCE = 0.15; - -/** Another robot this close and roughly ahead makes this one wait. */ -const YIELD_RANGE = 0.95; -const YIELD_CONE = 0.4; - -/** Seconds for the walk pose to fade in or out when a robot starts or stops. */ -const GAIT_EASE = 0.24; - -/** - * The biggest step a single tick may take, in seconds. - * - * A backgrounded tab hands back a `dt` of whole seconds when it wakes, and an - * unclamped robot would move several metres in one step — through a wall, since - * the collider is a capsule test against that step and a step that long sweeps - * across whole rooms. Clamping means a robot that was in a background tab is - * simply where it was, which is right: nobody was watching. - */ -const MAX_STEP = 0.1; - -// ---- Errands -------------------------------------------------------------- - -/** - * What a robot went somewhere *for*. - * - * It decides exactly two things — how often that kind of place gets picked, and - * how long a robot stands there once it arrives — and those two are most of the - * difference between a crowd that is working and a crowd that is patrolling. - * Nothing about the walk itself branches on it. - * - * - **`desk`** is a seat. `Plan` publishes every one of them with a `facing`, - * which is the whole reason this exists: arriving at a named spot and - * turning to the angle that spot says is the single detail that reads as - * purpose. See `DESK_STANDOFF` for why the robot stops short of the seat - * rather than on it. - * - **`fixture`** is an authored prop — a whiteboard, a locker, a shelf, a - * meeting chair. Identified by what it is *not*: not bound to a seat and not - * generated by a desk bank, so it is something the pack author put there on - * purpose rather than the second half of a workstation. - * - **`room`** is a room's centroid. The only kind with no facing, and the - * only kind that is about the building rather than about the furniture — - * it is what sends a robot to the kitchen or across the commons. - */ -type ErrandKind = "desk" | "fixture" | "room"; - -/** - * How the three kinds are mixed, as relative weights. - * - * **The kind is drawn first and the address second**, and that ordering is the - * point. Drawing uniformly over one flat list of addresses would let the pack - * decide the mix by accident: the reference office resolves 76 seats, roughly - * 150 fixtures and 17 rooms on its ground floor, so a flat draw would send a - * robot to a room's centre about 9% of the time and to a desk about 31% — - * neither of which anybody chose. Picking the kind first fixes the *behaviour* - * and lets the pack decide only which whiteboard. - * - * Weighted toward desks because a desk is the strongest read and because there - * are enough of them that four robots do not visibly repeat. Kinds a level has - * none of are skipped and their weight goes to the others, so a pack with no - * authored props still gets desks and rooms rather than a stalled robot. - */ -const ERRAND_MIX: readonly (readonly [ErrandKind, number])[] = [ - ["desk", 0.5], - ["fixture", 0.2], - ["room", 0.3], -]; - -/** - * Seconds spent standing at each kind, low and high of a uniform spread. - * - * A robot that pauses for the same length of time everywhere reads as a state - * machine no matter how good the destinations are, so the dwell is the errand's - * and not the walk's. The ordering is the story: you stand at a desk because you - * are doing something there, you look at a whiteboard for a moment, and a room's - * centre is somewhere you are passing through. - * - * The upper end matters more than it looks. Four robots with a mean dwell around - * six seconds and trips that take rather longer than that leaves most of them - * walking at any instant, which is the balance that reads as an office; push the - * desk dwell to half a minute and you get four robots standing about. - */ -const DWELL: Record = { - desk: [5, 12], - fixture: [3, 7], - room: [1.5, 4], +const STATUS_COLORS: Record = { + idle: 0x74808b, + patrol: 0x61d7a8, + deliver: 0xf2b134, + inspect: 0x67b7ff, + charge: 0xb78cff, + "blocked-recovery": 0xff6f61, }; -/** Seconds to stand after stepping through a doorway. Short: the point was to leave. */ -const DWELL_DOOR: readonly [number, number] = [0.4, 1.2]; - -/** - * How far behind a seat a robot stops, in metres. - * - * **A robot must never stand on a seat**, and that is a hard rule rather than a - * preference: `presence.ts` puts a person mesh at exactly `seat.position` with - * exactly `seat.facing`, so a robot that treated the seat as its own destination - * would stand inside whoever is sitting there. Seats are addresses for - * occupants; a robot visiting one is a visitor. - * - * The number is sized off the chair rather than picked. `seating.ts` gives - * `tera:seat.task-chair` a 0.64 m footprint — the star base, which is its widest - * part — so 0.32 m from the seat centre to the chair's edge, plus the 0.28 m - * robot radius, is 0.60 m before the two touch. 0.75 leaves 150 mm of air. - * - * That is cosmetic and not a collision guarantee: `plan.blocked` is the wall - * collider and knows nothing about furniture, as the header says. It is the - * difference between a robot standing at somebody's desk and a robot standing - * in their chair, which is visible from every camera angle in the building. - * - * The offset direction falls out of `plan.ts`: a bank puts its seat at the desk - * centre plus `(sin f, cos f) · seatOffset`, so stepping further along that same - * ray is further from the desk — behind the occupant, looking the way they look. - */ -const DESK_STANDOFF = 0.75; - -/** - * How far in front of a fixture a robot stops. - * - * Chosen rather than derived, because deriving it would mean asking the asset - * registry for every prop's footprint, and this file deliberately does not know - * that the registry exists — the same line the header draws around furniture - * collision. 0.9 m is the distance a person stands from a whiteboard, and it is - * far enough that the error on a fixture with a deeper footprint than expected - * is a robot standing a little close rather than a robot standing inside it. - * - * The facing convention is the desk's, taken from `plan.ts` and not invented - * here: a prop's front is its local **+Z**, `(sin r, cos r)`, because that is the - * side a desk bank puts its seat on. A prop authored with a meaningless rotation - * — a rug — gets a meaningless standing spot, which costs a robot a few seconds - * looking at the floor and breaks nothing. - */ -const FIXTURE_STANDOFF = 0.9; - -/** - * How far back along its own facing an errand's run-in starts, longest first. - * - * The last leg of an approach is walked *along* the facing, so the robot arrives - * already lined up instead of stopping at a random angle and then pivoting. The - * first entry is a turn budget: a quarter turn at `TURN_RATE` takes (π/2)/2.2 = - * 0.71 s, which at `CRUISE` is 0.86 m — so 0.9 m is one right-angle's worth of - * turning, and better than that in practice, because a turning robot walks - * slower and therefore turns further per metre. - * - * The second entry is there because the first one alone is not available often - * enough, and this is the measurement that says so. A run-in has to be somewhere - * a robot could stand, and 0.9 m behind the standing spot is 1.65 m behind the - * seat itself — which on the reference ground floor is inside a wall for 27 of - * 72 desks and outside every room for 9 more, because that is what a meeting - * room is. Only 36 desks got a run-in at all. Falling back to 0.45 m takes it to - * 62, and the arrivals it buys are as well aligned as the long ones. - * - * Note what 0.45 does *not* buy, so nobody re-derives it as a bug: `REACHED` is - * 0.22 and `ARRIVE` is 0.35, so a robot that clears a 0.45 m run-in can already - * be inside the arrival radius and stop on the spot without walking a step of - * the final leg. It still helps, because the alignment mostly comes from having - * steered at a point on the destination's own axis rather than from the metre - * after it. Measured against dropping the fallback entirely, it moves the 75th - * percentile of arrival error from 97° to 86°; both against 90° for a robot that - * simply stops where it gets to. - * - * Same shape as `PROBE_RELIEF`, and for the same reason: a value that is right - * when there is room for it and a smaller one that is better than nothing. - */ -const APPROACH_RUNS = [0.9, 0.45]; - -/** - * A prop whose base sits higher than this is not something you walk up to. - * - * `OPTIMUS.shoulderY` rather than a number, because the question this is asking - * is "is this thing in front of the robot or above it". It exists because the - * "authored prop" test catches ceiling lights: the reference ground floor - * authors 81 troffers and 22 pendants, none bound to a seat, and every one of - * them would otherwise be a place to stand and stare upward. - * - * Measured across both reference packs, the split is not close: the highest - * floor-standing fixture base is a wall display at 1.15 m and the lowest light - * is a troffer at 2.30 m, so the cutoff sits in the middle of a metre-wide gap - * and no plausible pack lands on the boundary. - */ -const FIXTURE_MAX_BASE = OPTIMUS.shoulderY; - -/** How many addresses are scored before one is committed to. See `pickErrand`. */ -const SHORTLIST = 6; - -/** - * Seconds before somewhere a robot went is fully interesting again. - * - * Without this the crowd converges: the score is the same every time it is - * asked, so the best desk in the building is the best desk for every robot for - * the whole session. A minute is long enough that a repeat is a coincidence - * rather than a rut, and the floor below keeps a just-visited address merely - * unlikely rather than banned — on a level with three addresses and four robots, - * banning is how you get a robot with nowhere to go. - */ -const REVISIT_COOLDOWN = 60; -const COOL_FLOOR = 0.05; - -/** - * Distance from the nearest other robot at which a destination stops being - * penalised for crowding, and the floor under that penalty. - * - * "Nearest other robot" counts where they *are* and where they are *going*, so - * two robots do not set off for the same whiteboard from opposite ends of the - * floor and discover the problem on arrival. - */ -const SPREAD_FULL = 7; -const SPREAD_FLOOR = 0.15; - -/** - * What a destination in the room the robot is already standing in is worth, - * against one somewhere else. - * - * This is the term that actually spreads the crowd through the building rather - * than round one floor plate, and it is nearly free: every address knows its - * room from the check that admitted it, so the only cost is one `roomAt` for the - * robot itself, once per errand. - */ -const SAME_ROOM = 0.35; - -/** - * Radians per second of yaw while standing still. - * - * Slower than `TURN_RATE` on purpose. The figure has no pivot-in-place - * animation — the gait is driven by distance travelled, so a robot turning - * without moving has its feet planted and swings the whole body — and the - * faster that happens the more it looks like a turntable. At this rate a half - * turn on the spot takes π/(2.2 · 0.55) = 2.6 s, which reads as settling. - * - * It is usually a small turn anyway, because `APPROACH_RUN` has the robot walk - * the last stretch along the angle it is going to hold. - */ -const SETTLE_RATE = TURN_RATE * 0.55; - -// ---- Gait ----------------------------------------------------------------- - -/** Peak hip angle, radians. Everything else about the stride follows from it. */ -const HIP_SWING = 0.34; - -/** - * Half the ground a planted foot covers, and the ground covered by one full - * two-step cycle. - * - * These are derived from the swing rather than chosen, and that is what decides - * whether the feet push the floor or skate on it. A planted foot sits at - * `LEG · sin θ` in front of the hips, so the ground one step covers is fixed by - * the swing amplitude and the length of the leg, and the body has to move - * exactly that far in the same time or the foot makes up the difference by - * sliding. Hand-set at 1.32 m against a 0.4 rad swing, the measured slip was 65% - * of distance travelled — the robots were gliding with their legs waving. - * - * At 1.2 m/s this is about 129 steps a minute, which is a brisk walk and the - * right read for a machine with somewhere to be. - */ -const HALF_STEP = LEG * Math.sin(HIP_SWING); -const STRIDE = 4 * HALF_STEP; - -const KNEE_BEND = 0.58; -const ARM_SWING = 0.3; -const ELBOW_SWING = 0.22; -const SWAY = 0.05; -const TWIST = 0.055; -/** Forward lean at full speed. Small, but it is what stops a walk looking passive. */ -const LEAN = 0.035; - -/** - * A leg's hip angle at `psi`, its own phase in `[0, 2π)`: stance for the first - * half, swing for the second. - * - * **The stance half is an arcsine and not a sine, and that is the whole point of - * this function.** A sine looks like the obvious choice and it is wrong for a - * reason that is easy to miss: a planted foot has to travel backwards under the - * body at *exactly* walking speed, which means its position is linear in time, - * which means the hip angle is the arcsine of a straight line. Drive the hip - * with a sine instead and the foot's backward speed is fastest as the leg passes - * vertical and zero at the ends of the stance, so it matches the body's speed at - * one instant per step and slides for the rest of it. Worse, both legs pass - * vertical at the same moment, so there is no instant at which either foot is - * genuinely planted. Measured: 30% of distance travelled came out as foot slip - * with everything else already tuned, and no amount of adjusting the stride - * length got it below about a quarter, because the shape was wrong rather than - * the scale. - * - * The swing half is a cubic Hermite from the back of the stride to the front - * whose end slopes are the stance's own — `−2 tan(HIP_SWING)` at both — so the - * thigh does not visibly jerk at toe-off or at heel strike. Nothing about the - * swing affects foot slip, because the foot is in the air for all of it; it only - * has to be smooth and to arrive in the right place. - */ -function legAngle(psi: number): number { - if (psi < Math.PI) { - const u = psi / Math.PI; - return Math.asin(Math.sin(HIP_SWING) * (1 - 2 * u)); - } - const v = (psi - Math.PI) / Math.PI; - const slope = -2 * Math.tan(HIP_SWING); - const v2 = v * v; - const v3 = v2 * v; - return ( - (2 * v3 - 3 * v2 + 1) * -HIP_SWING + - (v3 - 2 * v2 + v) * slope + - (-2 * v3 + 3 * v2) * HIP_SWING + - (v3 - v2) * slope - ); -} - -/** - * How bent a leg's knee is at `psi`, as a fraction of `KNEE_BEND`. - * - * Zero for the whole of stance, and that is deliberate rather than lazy: a bent - * stance knee shortens the leg, and the body's height is computed from the - * stance leg being straight. Bend it and the planted foot either floats or sinks - * by the difference. So the knee does all of its work in the air, which is also - * the only place it is doing anything useful — lifting the foot over the floor. - * - * `sin²` rather than `sin` so the bend starts and ends with zero rate and there - * is no kink at toe-off. - */ -function kneeFlex(psi: number): number { - if (psi < Math.PI) return 0; - const wave = Math.sin(psi - Math.PI); - return wave * wave; -} - -/** - * Pose one figure for a distance travelled and a gait strength. - * - * `distance` is metres since the robot was created — monotonic, never reset, so - * the cycle is continuous across every stop and start. `gait` is 0 for standing - * still and 1 for walking, and every joint is *interpolated* between its rest - * value and its walking value by it, so `gait === 0` reproduces `restOptimus` - * exactly and a robot easing to a halt settles rather than snapping. - * - * Signs, all of which are the header of `optimus.ts` applied: hips, shoulders - * and elbows bend positive, knees bend **negative**, positive `rotation.y` turns - * left and positive `rotation.z` leans left. - */ -function pose(j: OptimusJoints, distance: number, gait: number): void { - const phase = (distance / STRIDE) * Math.PI * 2; - /** - * Two waves, a quarter cycle apart, and using the wrong one is the mistake - * this comment exists to stop somebody making a second time. - * - * `s` peaks when the legs are **passing each other** — mid-stance. Lateral - * sway and the head's counter-lean belong on it, because that is genuinely - * when a walking body is furthest over its planted foot. - * - * `swing` peaks when the legs are **furthest apart** — heel strike. Anything - * that counter-balances the legs belongs on it: the arms, the elbows, and the - * twist through the waist. - * - * They were all on `s` at first, which put the arms a quarter cycle early: at - * the instant the left leg reached full forward the left shoulder was at dead - * neutral, and both arms hit their extremes as the legs passed vertical - * together. It reads as a figure whose arms are swinging to a different beat - * from its legs, which is uncanny in a way that is hard to name until it is - * pointed at. - * - * `swing` is derived from the leg angle itself rather than restated as - * `cos(phase)`, so the two cannot drift apart if `legAngle` is ever reshaped. - */ - const s = Math.sin(phase); - - // The left leg's own phase, and the right exactly half a cycle behind it — - // so one leg is always in stance and the other always in swing, and there is - // no moment when the robot is standing on neither. - const psiL = ((phase % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); - const psiR = (psiL + Math.PI) % (Math.PI * 2); - const hipL = legAngle(psiL); - const hipR = legAngle(psiR); - // −1..1, in step with the legs. See the note on `s` above. `HIP_SWING` is a - // non-zero literal, so no guard is needed and TypeScript will say so if that - // ever stops being true. - const swing = hipL / HIP_SWING; - - j.hipL.rotation.x = OPTIMUS_REST.hipX + gait * (hipL - OPTIMUS_REST.hipX); - j.hipR.rotation.x = OPTIMUS_REST.hipX + gait * (hipR - OPTIMUS_REST.hipX); - const kneeL = -KNEE_BEND * kneeFlex(psiL); - const kneeR = -KNEE_BEND * kneeFlex(psiR); - j.kneeL.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeL - OPTIMUS_REST.kneeX); - j.kneeR.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeR - OPTIMUS_REST.kneeX); - - // Arms counter-swing to the legs: the left arm goes forward with the right - // leg. Get this backwards and the figure paces like a soldier at attention, - // which is a surprisingly strong and surprisingly wrong-looking effect. - j.shoulderL.rotation.x = OPTIMUS_REST.shoulderX - gait * ARM_SWING * swing; - j.shoulderR.rotation.x = OPTIMUS_REST.shoulderX + gait * ARM_SWING * swing; - // And an elbow closes a little further on the forward stroke, which is what - // stops the arms reading as two pendulums bolted to a box. `-swing` is the - // left arm's own forward stroke, since it swings against the left leg. - j.elbowL.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, -swing); - j.elbowR.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, swing); - - // The body's height is not a bob that was dialled in. It is where the hips - // have to be for the straight, planted, stance leg to reach the floor: - // `LEG · cos θ`, exactly. That puts the body at its highest as the stance leg - // passes vertical and at its lowest at heel strike and toe-off, twice per - // cycle, which is what a real gait does and is not something this had to be - // told. Damping it — an earlier version scaled it to a third, to stop an - // imagined pogo — was most of that 65% of foot slip. It does not pogo: the - // whole travel is 48 mm, about what a walking person's head does. - const stance = psiL < Math.PI ? hipL : hipR; - j.pelvis.position.y = OPTIMUS.hipY - gait * LEG * (1 - Math.cos(stance)); - - // One consequence of all this, stated so nobody spends an afternoon on it: - // the figure has no ankle joint, so a foot pitches with its shin and the toe - // passes about 50 mm under the floor plane at the ends of each stride. That is - // hidden by the floor slab, and what remains visible above it is a heel strike - // and a toe-off the rig never had to be given. Correcting it would need a - // twelfth joint and would cost every robot two more meshes. - - // Hips twist one way, shoulders the other. `torso` is a child of `pelvis`, so - // its rotation adds: −2× puts the shoulders at −1× in world space. - j.pelvis.rotation.y = gait * TWIST * swing; - j.torso.rotation.y = -gait * TWIST * 2 * swing; - j.torso.rotation.z = gait * SWAY * s; - j.torso.rotation.x = gait * LEAN; - // The head keeps about half the lean instead of all of it. A head that stays - // perfectly level looks gimballed; one that swings with the chest looks - // drunk. - j.head.rotation.z = -gait * SWAY * 0.55 * s; -} - -// ---- Layer ---------------------------------------------------------------- - -/** Where one robot lives. A robot belongs to its level for its whole life. */ -export interface RobotSpec { - levelId: string; - /** For the caller's own bookkeeping. Defaults to `robot-1`, `robot-2`, … */ - id?: string; -} - export interface RobotLayerOptions { - /** The office's material registry. Its `paper` and `screenBezel` roles are used. */ materials: MaterialRegistry; - /** One entry per robot. About four is the budget; see the header. */ - robots: readonly RobotSpec[]; - /** - * Seeds where the robots start and where they wander. Change it to reshuffle - * the whole crowd; leave it and a reload puts them back where they were, which - * is the same discipline the rest of the scene keeps. - */ + operations: RobotOperationsDefinition; seed?: number; - /** Metres per second on the straight. Defaults to 1.2. */ - speed?: number; - /** Collision radius. Defaults to 0.28 — see `RADIUS`. */ - radius?: number; } -/** One robot's world position, live. See `RobotLayer.robots`. */ +/** Stable live projection consumed by lighting, minimap, and diagnostics. */ export interface RobotView { id: string; levelId: string; - /** - * Office-world metres, at the robot's feet. **Updated in place** every tick — - * hold the reference, read it, and do not write to it. - */ position: THREE.Vector3; + mode: RobotActivityMode; + phase: string; + battery: number; + payload: RobotPayload; + progress: number; + terminalReason: RobotActivityState["terminalReason"]; + simulated: true; } export interface RobotLayer { group: THREE.Group; + disclosure: string; tick(dt: number): void; - /** - * Every robot, for anything that wants to react to one — a ceiling light - * brightening as one passes under it, a minimap dot, an occupancy heatmap. - * - * The array and the `Vector3`s in it are **stable and live**: the same objects - * come back every call and their contents change under you. That is - * deliberate, because the caller for this is a per-frame loop and allocating - * four vectors sixty times a second to answer the same question is exactly the - * kind of garbage that shows up as a stutter and not as a profile entry. If - * you need a snapshot, clone what you take. - */ robots(): readonly RobotView[]; + snapshot(): RobotActivitySnapshot; dispose(): void; } -/** - * A direction a robot may walk in, and the clearance the probe settled for to - * find it. There is exactly one of these per layer and it is scratch — see - * `chooseHeading`. - */ -interface Heading { - heading: number; - clearance: number; -} - -/** Everything about one robot that changes. */ -interface Robot { - view: RobotView; - level: LevelPlan; +interface PresentedRobot { rig: OptimusRig; - yaw: number; - target: Point2 | null; - /** Seconds left of the current stand-still. Only meaningful with no target. */ - wait: number; - /** - * A yaw to turn to while standing still, or nothing to stand as it stopped. - * **Only meaningful with no target**, and cleared once reached, so a robot - * that settles onto its seat's facing and then waits out the rest of its dwell - * is doing no work at all. - */ - settle: number | null; - /** - * The errand's plan for the moment of arrival: the yaw to hold and the seconds - * to hold it for. Chosen when the destination is, spent when it is reached — - * `arriveFacing` becomes `settle` and `arriveDwell` becomes `wait`. - * - * Two fields rather than one small object because a destination is picked - * every few seconds per robot and this file allocates only where it must. - */ - arriveFacing: number | null; - arriveDwell: number; - /** 0 standing, 1 walking. Eased, never snapped. See the header. */ - gait: number; - /** Metres travelled ever. Drives the walk cycle and is never reset. */ - distance: number; - /** Metres travelled since the watchdog last looked, and how long ago that was. */ - sinceCheck: number; - checkAge: number; - /** - * An intermediate point to reach before `target`, or nothing. - * - * There is at most one, ever, and that is the rule that keeps this from - * becoming a path — see `pickDoor` for why one is enough and two would need a - * graph. It is either a **doorway**, when a robot could see nowhere to go and - * is leaving the room, or an errand's **run-in**, when the last stretch is - * walked along the angle the robot is going to hold on arrival. Both are - * checked as two independent legs, which is the only reason either works. - */ - waypoint: Point2 | null; - /** The opening this robot last walked through, so it does not turn straight round. */ - lastDoor: string | null; - rand: () => number; + view: RobotView; + status: THREE.Mesh; + parcel: THREE.Mesh; + tool: THREE.Group; } export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotLayer { + const operations = resolveRobotOperations(plan, options.operations); + const activity = createRobotActivity(plan, operations, { seed: options.seed }); const group = new THREE.Group(); - group.name = "robots"; + group.name = "simulated-robot-operations"; + group.userData.disclosure = activity.disclosure; - const speed = options.speed ?? CRUISE; - const radius = options.radius ?? RADIUS; - const seed = options.seed ?? 0x0117; - - // One figure is built and the rest are clones of it, so the crowd costs draw - // calls and no memory. The prototype is never added to the scene; it exists - // only to be cloned from and to be disposed at the end, because it is the one - // that owns the geometry every clone points at. - const ctx = createAssetContext({ materials: options.materials }); - const prototype = buildOptimus(ctx); - - const robots: Robot[] = []; - const views: RobotView[] = []; - - // Scratch, reused every frame. Four robots at sixty frames is 240 chances a - // second to allocate a `Point2` for nothing. `chosen` is the same discipline - // applied to the one place it had been forgotten; see `chooseHeading`, which - // is the only thing allowed to write to it. - const from: Point2 = { x: 0, z: 0 }; - const to: Point2 = { x: 0, z: 0 }; - const chosen: Heading = { heading: 0, clearance: 0 }; - - /** - * A level's rooms with a running area total, so a candidate point can be - * drawn from the *floor* rather than from the level's bounding box. - * - * Sampling the bounding box was the first version, and it is why this exists. - * A level's bounds are the whole building's extent, a level's rooms cover - * maybe a third of it and a mezzanine covers a tenth, so most candidates - * landed in the void outside the walls and most picks failed. Measured over - * twenty simulated minutes in the reference office, the crowd spent 82% of it - * standing still waiting to retry — and worst on exactly the small upper floor - * where a single robot is most conspicuous, which walked 49 m to the ground - * floor's 393. Choosing a room first puts nearly every candidate somewhere a - * robot could actually stand. - * - * Weighted by area rather than uniformly over rooms, because uniform sends a - * robot into the 6 m² phone booth as often as into the 400 m² floor plate, - * and what that looks like is four robots queueing for a cupboard. - */ - interface Sampler { - rooms: readonly ResolvedRoom[]; - /** Running area totals, one per room; the last one is `total`. */ - cumulative: readonly number[]; - total: number; - /** Every opening a walker fits through. `Plan` has already decided which. */ - doors: readonly ResolvedOpening[]; + const context = createAssetContext({ materials: options.materials }); + const prototype = buildOptimus(context); + const ringGeometry = new THREE.TorusGeometry(0.34, 0.025, 6, 24); + ringGeometry.rotateX(Math.PI / 2); + const parcelGeometry = new THREE.BoxGeometry(0.25, 0.18, 0.2); + const parcelMaterial = new THREE.MeshStandardMaterial({ color: 0xc78b48, roughness: 0.8 }); + const statusMaterials = new Map(); + for (const [mode, color] of Object.entries(STATUS_COLORS) as Array<[RobotActivityMode, number]>) { + statusMaterials.set(mode, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.88 })); } - - const samplers = new Map(); - function samplerFor(level: LevelPlan): Sampler { - const hit = samplers.get(level.id); - if (hit) return hit; - const cumulative: number[] = []; - let total = 0; - for (const room of level.rooms) { - total += room.area; - cumulative.push(total); - } - const made: Sampler = { - rooms: level.rooms, - cumulative, - total, - doors: level.openings.filter((opening) => opening.passable), - }; - samplers.set(level.id, made); - return made; - } - - /** - * One candidate: somewhere a robot could stand — inside a room, and clear of - * every wall by its own radius. - * - * A room's `bounds` are its bounding box and a room need not be rectangular, - * so the `roomAt` check is not made redundant by having chosen a room first: - * it is what rejects the missing corner of an L-shaped floor plate. It also - * *re-resolves* which room the point is in, and later rooms win, which is the - * answer you want — a point that lands inside a meeting room while sampling - * the open floor's bounding box is a point in the meeting room, and standing - * there is fine. - * - * `blocked` with the same point at both ends is a degenerate capsule, which is - * exactly a point-to-wall distance test. Reusing it rather than writing one - * keeps the "how much room does a robot need" arithmetic in the one place - * `Plan` already documents it. - */ - function trySample(sampler: Sampler, levelId: string, rand: () => number, into: Point2): boolean { - if (!(sampler.total > 0)) return false; - const roll = rand() * sampler.total; - let index = sampler.cumulative.length - 1; - for (let i = 0; i < sampler.cumulative.length; i++) { - if (roll <= (sampler.cumulative[i] ?? 0)) { - index = i; - break; - } - } - const room = sampler.rooms[index]; - if (!room) return false; - - into.x = room.bounds.minX + rand() * room.bounds.width; - into.z = room.bounds.minZ + rand() * room.bounds.depth; - if (!plan.roomAt(levelId, into)) return false; - return !plan.blocked(levelId, into, into, radius); - } - - /** Somewhere on this level a robot could stand, or nothing. Used to place one. */ - function samplePoint(level: LevelPlan, rand: () => number, into: Point2): boolean { - const sampler = samplerFor(level); - for (let i = 0; i < PICK_ATTEMPTS; i++) { - if (trySample(sampler, level.id, rand, into)) return true; - } - return false; - } - - // ---- The address book --------------------------------------------------- - - /** - * Somewhere worth going, and what to do on arrival. - * - * Every field except `visitedAt` is decided once, when the level's book is - * built, and never changes — which is what makes an errand cost one `blocked` - * call to commit to instead of four. `at` has already been checked to be - * indoors and clear of every wall, and `approach` has been checked the same way - * *plus* the leg between the two, so by the time a robot is choosing, the only - * open question is whether it can see the thing from where it is standing. - */ - interface Address { - kind: ErrandKind; - /** Where the robot ends up standing. Never on a seat; see `DESK_STANDOFF`. */ - at: Point2; - /** The yaw to hold once there, or nothing to stop on the arrival heading. */ - facing: number | null; - /** - * Where the last leg starts, so the robot walks in already lined up. Absent - * when there is no facing to line up with, or when the run-in does not fit — - * a desk in an alcove with its back 0.5 m from a wall, say, which is still a - * perfectly good place to stand and simply gets approached from wherever. - */ - approach: Point2 | null; - /** Which room `at` is in. Falls out of the check that admitted it; see `SAME_ROOM`. */ - roomId: string; - /** Layer clock when a robot last set out for here. See `REVISIT_COOLDOWN`. */ - visitedAt: number; - } - - /** One level's addresses, split by kind because the kind is drawn first. */ - interface AddressBook { - desk: Address[]; - fixture: Address[]; - room: Address[]; - } - - /** - * Seconds of simulated time since the layer was made, advanced by the same - * clamped `dt` the robots move on — so a tab that was asleep for a minute does - * not come back to a crowd whose cooldowns have all expired at once, for the - * same reason it does not come back to robots three rooms away. - */ - let clock = 0; - - const books = new Map(); - - /** The id of the room a robot could stand at this point in, or nothing. */ - function standable(levelId: string, point: Point2): string | null { - const room = plan.roomAt(levelId, point); - if (!room) return null; - return plan.blocked(levelId, point, point, radius) ? null : room.id; - } - - /** - * One address, if a robot can stand at it. - * - * The rejections here are the whole reason this is done once per level rather - * than per pick: a desk pushed against a wall, a whiteboard in a stairwell, a - * fixture whose front is inside a partition, a centroid outside its own - * L-shaped room. Every one of those is a fact about the pack that never - * changes, and paying for it at 60 Hz would be the expensive way to learn it. - */ - function makeAddress( - level: LevelPlan, - kind: ErrandKind, - at: Point2, - facing: number | null, - ): Address | null { - const roomId = standable(level.id, at); - if (roomId === null) return null; - - let approach: Point2 | null = null; - if (facing !== null) { - for (const run of APPROACH_RUNS) { - // Back along the facing: the robot walks from here to `at` looking the - // way `at` says, so the run-in and the hold are the same direction. - const back: Point2 = { - x: at.x + Math.sin(facing) * run, - z: at.z + Math.cos(facing) * run, - }; - if (standable(level.id, back) === null) continue; - // Measured on both reference packs: this leg has never once been the - // thing that failed, because it is short and colinear with two points - // already known to be clear. It is checked anyway — a pack is allowed to - // put a partition between a desk and the space behind it, and finding - // that out at 60 Hz with a robot walking through it is not the way. - if (plan.blocked(level.id, back, at, radius)) continue; - approach = back; - break; - } - } - // Far enough in the past that everything starts fully interesting, without - // any special case for "never visited" in the scoring. - return { kind, at, facing, approach, roomId, visitedAt: -REVISIT_COOLDOWN }; - } - - /** - * Every place on a level worth walking to, built once and kept. - * - * Cost is a few `roomAt` and `blocked` calls per candidate — up to three of - * each for a facing address — over every seat, every authored prop and every - * room on the level. On the reference ground floor that is 76 seats, 147 - * qualifying props and 17 rooms, and it is paid on the frame the first robot - * on that level picks its first destination and never again. Doing it eagerly - * at construction would move the same work to a worse moment, since a level - * with no robots on it never needs a book at all. - * - * Seats come from `level.seats` rather than `plan.allSeats()` on purpose: a - * robot belongs to one level for its whole life, and the building's seat list - * would offer it addresses on a floor it can never reach. - */ - function bookFor(level: LevelPlan): AddressBook { - const hit = books.get(level.id); - if (hit) return hit; - const made: AddressBook = { desk: [], fixture: [], room: [] }; - - for (const seat of level.seats) { - const spot = makeAddress( - level, - "desk", - { - x: seat.position.x + Math.sin(seat.facing) * DESK_STANDOFF, - z: seat.position.z + Math.cos(seat.facing) * DESK_STANDOFF, - }, - seat.facing, - ); - if (spot) made.desk.push(spot); - } - - for (const prop of level.props) { - // A prop bound to a seat, or generated by a desk bank, is the furniture of - // a workstation — the seat itself is already a better address for it, and - // adding the desk and the chair as well would put three addresses on one - // spot and weight the whole floor toward whichever room has the most desks. - if (prop.seat !== undefined || prop.source !== undefined) continue; - // `position.y` is the base of the prop with the level's elevation already - // in it, so the level's own floor has to come back out before it can be - // compared with a height on the robot. - if (prop.position.y - level.floorY > FIXTURE_MAX_BASE) continue; - const spot = makeAddress( - level, - "fixture", - { - x: prop.position.x + Math.sin(prop.rotation) * FIXTURE_STANDOFF, - z: prop.position.z + Math.cos(prop.rotation) * FIXTURE_STANDOFF, - }, - prop.rotation, - ); - if (spot) made.fixture.push(spot); - } - - for (const room of level.rooms) { - // No facing: there is nothing at a room's centre to look at, and inventing - // one — face the longest wall, face the door — would be a guess dressed up - // as intent. A robot arriving at a centroid stops looking the way it came - // in, which is into the room, which is enough. - // - // `centroid` is the area centroid and a room need not be convex, so this - // can land outside its own outline; `makeAddress` drops those rather than - // falling back to the bounding box centre, which is not more likely to be - // inside. Both reference packs have none. - const spot = makeAddress(level, "room", { x: room.centroid.x, z: room.centroid.z }, null); - if (spot) made.room.push(spot); - } - - books.set(level.id, made); - return made; - } - - // Scratch for the shortlist, reused by every robot on every pick. Same - // discipline as `from`, `to` and `chosen`: nothing here outlives the call that - // fills it, and `pickErrand` is the only thing allowed to read or write it. - const shortlist: (Address | null)[] = new Array(SHORTLIST).fill(null); - const shortlistScore: number[] = new Array(SHORTLIST).fill(0); - - /** - * Which kind of errand this one is, weighted by `ERRAND_MIX` over the kinds - * this level actually has any of. - * - * The empty-kind skip is not defensive coding for its own sake. Nothing in the - * office contract obliges a level to have seats, or props, or more than one - * room — the second reference pack's mezzanine resolves three desks and a - * single room, and a floor of meeting rooms with no authored furniture is an - * ordinary thing to write. A weight table that did not renormalise would spend - * a fifth of its draws on an empty list and fail whole picks for no reason, - * which presents as a robot that thinks for a second and a half. - */ - function drawKind(rand: () => number, book: AddressBook): ErrandKind | null { - let total = 0; - let last: ErrandKind | null = null; - for (const [kind, weight] of ERRAND_MIX) { - if (book[kind].length === 0) continue; - total += weight; - last = kind; - } - if (last === null) return null; - - let roll = rand() * total; - for (const [kind, weight] of ERRAND_MIX) { - if (book[kind].length === 0) continue; - roll -= weight; - if (roll <= 0) return kind; - } - // Floating-point slop only: the loop above subtracts exactly `total`. - return last; - } - - /** - * How much this robot wants this address, as a number in (0, 1]. - * - * Three factors, multiplied, and each one exists to stop a specific way four - * robots stop looking like four people: - * - * - **Cooldown**, so the crowd does not converge on the same few best - * addresses and pace a rut between them for the rest of the session. - * - **Elbow room**, so a robot does not walk across the building to stand - * where another one already is. Other robots' *destinations* count as much - * as their positions, which is the half that stops two robots setting off - * for the same desk and discovering it on arrival. - * - **Somewhere else**, so a robot in the kitchen tends to leave the - * kitchen. This is the term that spreads the crowd through the building - * rather than round one room, and it is the cheapest of the three. - * - * Multiplied rather than summed, because these are qualities a destination can - * lack independently and a sum lets one good factor carry two bad ones — the - * desk you were just at, with another robot already standing at it, would - * still score well for being in the next room. The floors under the first two - * keep the product away from zero, so a level with only bad options still - * produces an ordering rather than a tie. - */ - function scoreAddress(robot: Robot, address: Address, hereRoom: string | null): number { - const cool = Math.max(COOL_FLOOR, Math.min(1, (clock - address.visitedAt) / REVISIT_COOLDOWN)); - - let nearest = Infinity; - for (const other of robots) { - if (other === robot || other.level.id !== robot.level.id) continue; - const here = other.view.position; - nearest = Math.min(nearest, Math.hypot(here.x - address.at.x, here.z - address.at.z)); - const bound = other.target; - if (bound) { - nearest = Math.min(nearest, Math.hypot(bound.x - address.at.x, bound.z - address.at.z)); - } - } - const elbow = - nearest === Infinity ? 1 : Math.max(SPREAD_FLOOR, Math.min(1, nearest / SPREAD_FULL)); - - return cool * elbow * (address.roomId === hereRoom ? SAME_ROOM : 1); - } - - /** - * Pick somewhere with a reason to be there, or fail and let the caller fall - * back to the sampler. - * - * Shortlist, then commit — and the split is what keeps this cheap. Scoring is - * arithmetic over four robots and costs nothing, so `SHORTLIST` candidates are - * drawn and ranked without touching the collider at all; only then is line of - * sight tested, best first, and the first one that can be seen wins. The - * measured cost is one `plan.blocked` call for most picks, because the best - * candidate is usually visible, and at most `SHORTLIST` of them. - * - * That is strictly cheaper than the sampler it replaced, which spent up to two - * `blocked` calls on each of `PICK_ATTEMPTS` candidates and still ended up - * somewhere with no name — and it is most of why the whole tick got faster - * rather than slower. - */ - function pickErrand(robot: Robot): boolean { - const book = bookFor(robot.level); - const here = robot.view.position; - const hereRoom = plan.roomAt(robot.level.id, here)?.id ?? null; - - let filled = 0; - for (let i = 0; i < SHORTLIST; i++) { - const kind = drawKind(robot.rand, book); - if (kind === null) return false; - const pool = book[kind]; - const candidate = pool[Math.floor(robot.rand() * pool.length)]; - if (!candidate) continue; - // Same rule as the sampler's: too close and the robot shuffles rather than - // walks, and the walk is the part anybody sees. - if (Math.hypot(candidate.at.x - here.x, candidate.at.z - here.z) < MIN_TRIP) continue; - - // Insertion sort, best first. Six entries at most, so this is a handful of - // compares and — unlike sorting an array of pairs — no allocation. - const value = scoreAddress(robot, candidate, hereRoom); - let slot = filled; - while (slot > 0 && (shortlistScore[slot - 1] ?? 0) < value) { - shortlist[slot] = shortlist[slot - 1] ?? null; - shortlistScore[slot] = shortlistScore[slot - 1] ?? 0; - slot--; - } - shortlist[slot] = candidate; - shortlistScore[slot] = value; - filled++; - } - - for (let i = 0; i < filled; i++) { - const address = shortlist[i]; - if (!address) continue; - - // The run-in is taken whenever there is one, from wherever the robot is - // standing — including from the far side, where taking it means walking - // past the destination and coming back at it. That looked like the wrong - // trade and the measurement said otherwise. Taking it only from the near - // side, on the sign of a dot product, halved how often it was used at all - // — 128 of 399 picks over half an hour rather than 267 — and left the - // median arrival 99° off the angle it was supposed to hold. Taking it - // always costs 1.3% more walking and brings that median to 9°. - const goal = address.approach ?? address.at; - from.x = here.x; - from.z = here.z; - if (plan.blocked(robot.level.id, from, goal, radius)) continue; - - // Copied rather than aliased. The address is shared by every robot and - // lives for the whole session; `waypoint` and `target` are one robot's and - // are cleared and replaced constantly, and one line that reached for - // `robot.target.x = …` would quietly move the desk for everybody. - robot.waypoint = - address.approach === null ? null : { x: address.approach.x, z: address.approach.z }; - robot.target = { x: address.at.x, z: address.at.z }; - robot.arriveFacing = address.facing; - const [low, high] = DWELL[address.kind]; - robot.arriveDwell = low + robot.rand() * (high - low); - // Heading somewhere with a name, so the last door stops defining this - // robot — the same reasoning as the sampler's, and the reason a long - // circuit can come back through the door it left by. - robot.lastDoor = null; - address.visitedAt = clock; - return true; - } - return false; - } - - /** - * A doorway to head for when nowhere in the room is worth walking to. - * - * This is the fix for the one thing pure line-of-sight sampling cannot do, and - * it is not a small thing: a robot inside a small room can see almost nothing - * outside it, because every candidate has to be visible through a 0.9 m gap - * with 0.28 m of clearance either side. Measured on the reference office, three - * of four robots wandered into a kitchen, a stair core and a 2 × 2 m phone - * booth within the first minute and then stood there for the remaining - * nineteen. Not vibrating, not erroring — parked, forever, which is a worse - * failure than a visible one because it looks deliberate. - * - * So a robot that cannot see anywhere to go walks to a door instead: a - * **waypoint** at the middle of the opening and a target `THROUGH_DOOR` metres - * past it, so it ends up in the next space with sight lines into it rather - * than stopped in the gap still looking at the room it wanted to leave. - * `Plan` has already decided which openings a walker fits through — the - * `passable` flag is the wall split's own answer, computed from the same sill - * and head heights that punched the hole — so this invents no geometry and - * cannot disagree with the collider. - * - * The waypoint is the difference between working and not. A 0.9 m door with a - * 0.28 m robot leaves 0.22 m of usable width once both jambs are cleared, so a - * single straight line from an off-axis corner of a room to a point beyond the - * door misses by centimetres and the whole door is rejected — which is what - * left the last robot in a phone booth after every other fix. Split into two - * legs, each checked on its own, both are easy: any point in the room can see - * the middle of its own door, and the middle of a door can always see straight - * out of it. Which is the general shape of the thing: **one** waypoint, chosen - * from data `Plan` already publishes. Two would be a path, and a path needs a - * graph, and a graph is the navmesh this file exists to not build. - * - * Two passes over the doors, and the second one is why a booth works. The - * first skips the door this robot last came through, so a robot that has just - * walked into the open floor does not turn straight round. The second allows - * it, because a room with exactly one door — a booth, a store, a server room — - * has no other way out, and refusing to reuse it is refusing to leave. - */ - function pickDoor(robot: Robot): boolean { - const doors = samplerFor(robot.level).doors; - if (doors.length === 0) return false; - const here = robot.view.position; - const beyond: Point2 = { x: 0, z: 0 }; - - // Started at a random index rather than at zero, so a robot with two doors - // in sight does not always take the same one and pace a rut between two - // rooms for the rest of the session. - const start = Math.floor(robot.rand() * doors.length); - for (const allowLast of [false, true]) { - for (let i = 0; i < doors.length; i++) { - const door = doors[(start + i) % doors.length]; - if (!door) continue; - if (!allowLast && door.id === robot.lastDoor) continue; - const dx = door.center.x - here.x; - const dz = door.center.z - here.z; - if (Math.hypot(dx, dz) < MIN_DOOR) continue; - - from.x = here.x; - from.z = here.z; - if (plan.blocked(robot.level.id, from, door.center, radius)) continue; - - // A wall at yaw φ runs along (cos φ, −sin φ), so its normal is - // (sin φ, cos φ). Step out along whichever end of that normal is - // further from the robot — that is the far side, which is the side - // worth going to. - const nx = Math.sin(door.yaw); - const nz = Math.cos(door.yaw); - const sign = dx * nx + dz * nz >= 0 ? 1 : -1; - beyond.x = door.center.x + sign * THROUGH_DOOR * nx; - beyond.z = door.center.z + sign * THROUGH_DOOR * nz; - if (!plan.roomAt(robot.level.id, beyond)) continue; - if (plan.blocked(robot.level.id, door.center, beyond, radius)) continue; - - robot.waypoint = { x: door.center.x, z: door.center.z }; - robot.target = { x: beyond.x, z: beyond.z }; - robot.arriveFacing = null; - robot.arriveDwell = DWELL_DOOR[0] + robot.rand() * (DWELL_DOOR[1] - DWELL_DOOR[0]); - robot.lastDoor = door.id; - return true; - } - } - return false; - } - - /** - * A random reachable point on the floor. The fallback, and no longer the plan. - * - * This used to be the whole of destination selection, and everything that read - * as patrolling was here: a point drawn from a room's bounding box is not a - * place, it is a coordinate — so a robot walked to the middle of nowhere, - * stopped at whatever angle it happened to arrive at, waited a fixed-ish beat - * and set off again. `pickErrand` runs first now, and this catches the two - * things it cannot do: a robot in a room with no address it can see, and a - * pack that authors no seats, no props and no usable centroids at all. - * - * It is worth keeping precisely because it asks so little of the pack. An - * office is a `Plan`, and a `Plan` is allowed to be four walls and a door. - * - * The line-of-sight test is against the segment from here to there, and a - * segment includes its endpoints — so this is also the check that the - * destination itself has room to stand in, and there is no separate one. - */ - function pickWander(robot: Robot): boolean { - const candidate: Point2 = { x: 0, z: 0 }; - const level = robot.level; - const sampler = samplerFor(level); - const here = robot.view.position; - for (let i = 0; i < PICK_ATTEMPTS; i++) { - if (!trySample(sampler, level.id, robot.rand, candidate)) continue; - if (Math.hypot(candidate.x - here.x, candidate.z - here.z) < MIN_TRIP) continue; - from.x = here.x; - from.z = here.z; - if (plan.blocked(level.id, from, candidate, radius)) continue; - robot.target = { x: candidate.x, z: candidate.z }; - robot.waypoint = null; - // Nothing there to look at and no reason to linger, so a shrug of a pause - // and off again. - robot.arriveFacing = null; - robot.arriveDwell = PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN); - // Somewhere in the open: this robot is no longer defined by the last door - // it used, and forgetting it is what lets a long circuit of the building - // come back through the same doorway without a special case. - robot.lastDoor = null; - return true; - } - return false; - } - - /** - * Choose somewhere to walk to, or fail. - * - * Three tiers, in descending order of how much the destination means: - * somewhere with a name and a facing, then anywhere at all on this floor, then - * out through the nearest door. A robot reaches the second only because it can - * see no address from where it is standing, and the third only because it can - * see nothing at all — which is why the order is this way round, and it is a - * happy accident of the shortlist that the tier that means the most is also - * the one that costs the least. - * - * Failure is still a normal outcome, not an error — a robot boxed into a - * corner with no door in sight will wait and try again from wherever it is — - * and nothing is logged, because a robot with nowhere to go looks exactly like - * a robot taking a moment. - * - * Every tier sets `target`, `waypoint`, `arriveFacing` and `arriveDwell` - * rather than returning a destination, because two of the three have to set a - * waypoint as well and a function that returned one field and mutated three - * would be the worst of both. - */ - function pickTarget(robot: Robot): boolean { - if (pickErrand(robot)) return true; - if (pickWander(robot)) return true; - return pickDoor(robot); - } - - /** Whether a robot could walk `PROBE_AHEAD` metres on this heading with `clearance` to spare. */ - function clearAhead(robot: Robot, heading: number, clearance: number): boolean { - const here = robot.view.position; - from.x = here.x; - from.z = here.z; - to.x = here.x - Math.sin(heading) * PROBE_AHEAD; - to.z = here.z - Math.cos(heading) * PROBE_AHEAD; - return !plan.blocked(robot.level.id, from, to, clearance); - } - - /** Whether another robot on the same level is close enough and far enough ahead to yield to. */ - function shouldYield(robot: Robot): boolean { - const here = robot.view.position; - const fx = -Math.sin(robot.yaw); - const fz = -Math.cos(robot.yaw); - for (const other of robots) { - if (other === robot || other.level.id !== robot.level.id) continue; - const dx = other.view.position.x - here.x; - const dz = other.view.position.z - here.z; - const distance = Math.hypot(dx, dz); - if (distance > YIELD_RANGE || distance < 1e-4) continue; - if ((dx * fx + dz * fz) / distance > YIELD_CONE) return true; - } - return false; - } - - /** - * Stop, and stand there for `seconds`. - * - * `settle` is the yaw to turn to while standing, and it is a parameter rather - * than something read off the robot because the two callers want opposite - * things from it. Arriving somewhere passes the errand's facing — that is the - * point of the errand. Giving up — wedged, deadlocked, watchdogged — passes - * `null`, because a robot that failed to get somewhere has no business - * adopting the pose of having got there. - */ - function beginPause(robot: Robot, seconds: number, settle: number | null): void { - robot.target = null; - robot.waypoint = null; - robot.wait = seconds; - robot.settle = settle; - robot.sinceCheck = 0; - robot.checkAge = 0; - } - - // ---- Population --------------------------------------------------------- - - const warned = new Set(); - options.robots.forEach((spec, index) => { - const level = plan.level(spec.levelId); - if (!level) { - if (!warned.has(spec.levelId)) { - warned.add(spec.levelId); - console.warn(`[tera/interiors] no level "${spec.levelId}" for a robot; skipping it`); - } - return; - } - - // Seeded per robot rather than from one shared stream, so adding a fifth - // robot does not move the other four. Same reasoning as `furnish.ts` keying - // its randomness on the batch rather than on a counter. - const rand = seededRandom(seed + index * 0x9e37); - const start: Point2 = { x: 0, z: 0 }; - if (!samplePoint(level, rand, start)) { - // Nowhere on this level a robot fits. That is a fact about the pack — a - // level of corridors narrower than 0.56 m, or one with no rooms — and it - // is worth one line, because the symptom otherwise is a robot that is - // simply absent with no explanation anywhere. - console.warn( - `[tera/interiors] found nowhere to stand on level "${level.id}" after ` + - `${PICK_ATTEMPTS} tries; that robot is not in the scene`, - ); - return; - } - - const rig = cloneOptimus(prototype); - rig.root.name = spec.id ?? `robot-${index + 1}`; - rig.root.position.set(start.x, level.floorY, start.z); - rig.root.rotation.y = rand() * Math.PI * 2; - group.add(rig.root); - - const view: RobotView = { - id: rig.root.name, - levelId: level.id, - position: new THREE.Vector3(start.x, level.floorY, start.z), - }; - views.push(view); - robots.push({ - view, - level, - rig, - yaw: rig.root.rotation.y, - target: null, - // Staggered, so four robots do not all set off on the same frame. - wait: rand() * PAUSE_MAX, - // Nothing to settle to and nowhere to have arrived from: a robot's first - // errand overwrites both of these before either is read. - settle: null, - arriveFacing: null, - arriveDwell: PAUSE_MIN, - gait: 0, - distance: rand() * STRIDE, - waypoint: null, - lastDoor: null, - sinceCheck: 0, - checkAge: 0, - rand, - }); + const toolMaterial = new THREE.MeshStandardMaterial({ + color: 0x73c6ff, + emissive: 0x15384f, + roughness: 0.34, + metalness: 0.18, }); + const dockMaterial = new THREE.MeshStandardMaterial({ color: 0x3b4651, roughness: 0.7, metalness: 0.25 }); + const dockLightMaterial = new THREE.MeshBasicMaterial({ color: STATUS_COLORS.charge }); + const dockPadGeometry = new THREE.BoxGeometry(0.72, 0.035, 0.62); + const dockPostGeometry = new THREE.BoxGeometry(0.12, 0.55, 0.12); + const dockLightGeometry = new THREE.BoxGeometry(0.14, 0.08, 0.06); + const toolBodyGeometry = new THREE.BoxGeometry(0.07, 0.2, 0.05); + const toolLensGeometry = new THREE.BoxGeometry(0.09, 0.05, 0.07); - /** - * The heading nearest `want` that a robot can actually walk, and the clearance - * it was found at. - * - * `PROBE_RELIEF` is the escape hatch, and it is the reason a wedged robot - * cannot stay wedged. `plan.blocked` measures from the *start* of the segment - * as well as along it, so a robot standing closer to a wall than its own - * radius has every direction blocked — including straight away from the wall. - * The step check below is supposed to make that unreachable, and it is an - * invariant rather than a proof: an earlier version of it broke, and three of - * four robots spent nineteen simulated minutes welded to the spot. So if - * nothing is clear at full radius the robot is allowed to be thinner until - * something is, and creeps back out. No relief can push it through a wall, - * because `segmentDistance` returns zero for segments that actually cross and - * zero is under every clearance there is. - * - * Falls back to `want` itself when everything is blocked, so the caller still - * turns toward where it wanted to go and simply does not move. - * - * **The returned object is `chosen`, every time.** This used to be a fresh - * `{ heading, clearance }` per call, which is once a frame for every robot - * that is moving — the same allocation-per-frame this file goes out of its - * way to avoid in `from`, `to` and `RobotLayer.robots`, and it is odd that - * one survived where those did not. It is scratch now, and it is safe - * *because* of how it is used: `step` reads both fields on the line after the - * call and never keeps the reference. Anything that wants to hold on to a - * choice — comparing this frame's against last frame's, say — has to copy the - * two numbers out, or it will find that both of them changed underneath it on - * the next robot's turn. - */ - function chooseHeading(robot: Robot, want: number): Heading { - for (const relief of PROBE_RELIEF) { - const clearance = radius * relief; - for (const offset of PROBE_TURNS) { - if (clearAhead(robot, want + offset, clearance)) { - chosen.heading = want + offset; - chosen.clearance = clearance; - return chosen; - } - } - } - chosen.heading = want; - chosen.clearance = radius; - return chosen; + for (const station of operations.stations.values()) { + if (station.role !== "charge") continue; + const floorY = plan.level(station.levelId)?.floorY ?? 0; + const dock = new THREE.Group(); + dock.name = `charge-dock:${station.id}`; + dock.position.set(station.position.x, floorY, station.position.z); + dock.rotation.y = Math.atan2(-station.facing.x, -station.facing.z); + const pad = new THREE.Mesh(dockPadGeometry, dockMaterial); + pad.position.y = 0.018; + const post = new THREE.Mesh(dockPostGeometry, dockMaterial); + post.position.set(0, 0.275, 0.25); + const light = new THREE.Mesh(dockLightGeometry, dockLightMaterial); + light.position.set(0, 0.44, 0.18); + dock.add(pad, post, light); + group.add(dock); } - // ---- Step --------------------------------------------------------------- + const presented: PresentedRobot[] = []; + for (const state of activity.states()) { + const rig = cloneOptimus(prototype); + rig.root.name = `simulated-robot:${state.id}`; + rig.root.userData.simulated = true; + const status = new THREE.Mesh(ringGeometry, statusMaterials.get(state.mode)!); + status.name = "activity-status"; + status.position.y = 0.035; + const parcel = new THREE.Mesh(parcelGeometry, parcelMaterial); + parcel.name = "parcel-cue"; + parcel.position.set(0, 1.04, -0.21); + parcel.visible = false; + const tool = new THREE.Group(); + tool.name = "inspection-tool-cue"; + const toolBody = new THREE.Mesh(toolBodyGeometry, toolMaterial); + const toolLens = new THREE.Mesh(toolLensGeometry, toolMaterial); + toolLens.position.y = 0.11; + tool.add(toolBody, toolLens); + tool.position.set(0.27, 1.15, -0.1); + tool.rotation.z = -0.24; + tool.visible = false; + rig.root.add(status, parcel, tool); + group.add(rig.root); + presented.push({ + rig, + status, + parcel, + tool, + view: { + id: state.id, + levelId: state.levelId, + position: new THREE.Vector3(), + mode: state.mode, + phase: state.phase, + battery: state.battery, + payload: state.payload, + progress: state.progress, + terminalReason: state.terminalReason, + simulated: true, + }, + }); + } + const views = presented.map((robot) => robot.view); - function step(robot: Robot, dt: number): void { - let moved = 0; - let effort = 0; - - if (robot.target === null) { - robot.wait -= dt; - if (robot.wait <= 0) { - if (pickTarget(robot)) { - robot.sinceCheck = 0; - robot.checkAge = 0; - } else { - robot.wait = RETRY_PAUSE; - } - } - - // Still nothing to walk to, so this is a robot standing somewhere on - // purpose: turn it to the angle its errand asked for. Guarded on `target` - // rather than sequenced before the pick because `arriveFacing` has already - // been copied into `settle` by then and a pick that succeeded has replaced - // it with the *next* destination's — turning toward that one from here - // would have the robot aim itself across the building before setting off. - // - // This is the one place the yaw moves without a destination, and the - // reason `SETTLE_RATE` is slower than `TURN_RATE`. - if (robot.target === null && robot.settle !== null) { - let swing = robot.settle - robot.yaw; - swing = Math.atan2(Math.sin(swing), Math.cos(swing)); - const limit = SETTLE_RATE * dt; - if (Math.abs(swing) <= limit) { - // Arrived at the angle. `+= swing` rather than `= settle` keeps the - // yaw continuous — the facing came out of the pack and may be any - // multiple of a turn away from where this robot has wound up to. - robot.yaw += swing; - robot.settle = null; - } else { - robot.yaw += limit * Math.sign(swing); - } - } + function present(snapshot: RobotActivitySnapshot): void { + for (let index = 0; index < snapshot.robots.length; index += 1) { + const state = snapshot.robots[index]!; + const robot = presented[index]!; + const floorY = plan.level(state.levelId)?.floorY ?? 0; + robot.view.position.set(state.position.x, floorY, state.position.z); + robot.view.mode = state.mode; + robot.view.phase = state.phase; + robot.view.battery = state.battery; + robot.view.payload = state.payload; + robot.view.progress = state.progress; + robot.view.terminalReason = state.terminalReason; + robot.rig.root.position.copy(robot.view.position); + robot.rig.root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z); + robot.status.material = statusMaterials.get(state.mode)!; + robot.status.scale.setScalar(0.9 + state.progress * 0.18); + robot.status.visible = state.terminalReason === null; + robot.parcel.visible = state.payload === "parcel"; + robot.tool.visible = state.mode === "inspect" && state.phase === "inspecting"; + pose(robot.rig, state); } - - // Steer at the waypoint while there is one, and at the destination after - // that. There is at most one waypoint and it is always a doorway. - const goal = robot.waypoint ?? robot.target; - if (goal && robot.target) { - const here = robot.view.position; - const dx = goal.x - here.x; - const dz = goal.z - here.z; - const remaining = Math.hypot(dx, dz); - - if (remaining < (robot.waypoint ? REACHED : ARRIVE)) { - if (robot.waypoint) robot.waypoint = null; - // Arrived. Both halves of what the errand asked for are spent here and - // nowhere else: how long to stand, and which way to look while doing it. - else beginPause(robot, robot.arriveDwell, robot.arriveFacing); - } else { - // A figure faces −Z at yaw 0, so the heading that points along (dx, dz) - // is the one whose (−sin, −cos) matches it. This is the same convention - // `Yaw` carries everywhere else and it is why there is no conversion. - const want = Math.atan2(-dx, -dz); - let error = want - robot.yaw; - error = Math.atan2(Math.sin(error), Math.cos(error)); - - // Slow while turning and slow into the destination. The first is what - // makes a robot pivot toward a doorway instead of arcing into its - // frame; the second is what stops it overshooting and orbiting the - // point it was aiming at. Both fall out of the walk cycle for free, - // because the cycle is driven by distance — a slow robot takes short - // steps rather than the same steps more slowly. - const facing = Math.max(0, Math.cos(error)); - const approach = Math.min(1, remaining / (ARRIVE * 2.5)); - let pace = speed * facing * approach; - if (shouldYield(robot)) pace = 0; - - // Fan out from the direction the destination wants until something is - // clear. The straight line was clear when the destination was chosen, - // but a robot turns on an arc rather than pivoting, so it can end up - // aimed at a corner the original line missed. - // - // Probing around `want` and not around the robot's own heading is not a - // detail. Probing around the heading, and then steering toward whatever - // came back, gives *two* rate-limited turns in one frame — one toward - // the destination and one toward the probe — and at equal rates they - // cancel exactly. The observed symptom was a robot locked 0.95 rad off - // course, pacing on the spot in a phone booth for the whole session, - // with every individual line of it looking correct. One desired heading - // and one turn. - const choice = chooseHeading(robot, want); - - // The turn happens whether or not anything was clear, so a robot that - // has walked into a dead end keeps rotating and finds its way out by - // looking around rather than by waiting for the watchdog. - let swing = choice.heading - robot.yaw; - swing = Math.atan2(Math.sin(swing), Math.cos(swing)); - robot.yaw += Math.min(Math.abs(swing), TURN_RATE * dt) * Math.sign(swing); - - // And it only walks if the direction it actually ended up facing is - // clear. The turn rate caps how far the yaw got, so mid-turn the robot - // faces somewhere nothing has tested; walking that is exactly how one - // ends up inside the clearance band, which presents as a robot standing - // in a kitchen for the rest of the session rather than as an error. - if (pace > 0 && clearAhead(robot, robot.yaw, choice.clearance)) { - const advance = pace * dt; - robot.view.position.x -= Math.sin(robot.yaw) * advance; - robot.view.position.z -= Math.cos(robot.yaw) * advance; - moved = advance; - effort = pace / speed; - } - - robot.distance += moved; - robot.sinceCheck += moved; - robot.checkAge += dt; - if (robot.checkAge >= STUCK_WINDOW) { - if (robot.sinceCheck < STUCK_DISTANCE) { - // Wedged, deadlocked with another robot, or aiming at somewhere it - // can no longer reach. Throwing the destination away and standing - // still for a moment resolves all three, and is the reason this - // cannot vibrate against a wall forever. - beginPause(robot, RETRY_PAUSE, null); - } else { - robot.sinceCheck = 0; - robot.checkAge = 0; - } - } - } - } - - // Ease rather than snap, so a robot that stops settles its limbs over about - // a quarter of a second instead of jumping to attention mid-stride. - robot.gait += (effort - robot.gait) * Math.min(1, dt / GAIT_EASE); - if (robot.gait < 1e-3) { - robot.gait = 0; - restOptimus(robot.rig.joints); - } else { - pose(robot.rig.joints, robot.distance, robot.gait); - } - - robot.rig.root.position.x = robot.view.position.x; - robot.rig.root.position.z = robot.view.position.z; - robot.rig.root.rotation.y = robot.yaw; } + present(activity.snapshot()); return { group, + disclosure: activity.disclosure, tick(dt) { - if (!(dt > 0)) return; - const clamped = Math.min(dt, MAX_STEP); - // The clamped step, deliberately: the clock exists to age destinations - // against how much walking has happened, and in a backgrounded tab none - // has. See `clock`. - clock += clamped; - for (const robot of robots) step(robot, clamped); - }, - robots() { - return views; + present(activity.tick(dt)); }, + robots: () => views, + snapshot: () => activity.snapshot(), dispose() { - for (const robot of robots) group.remove(robot.rig.root); - robots.length = 0; - views.length = 0; - // Every clone shares the prototype's buffers, so this frees all of them - // exactly once. Materials belong to the caller's registry and are left - // alone, the same way every asset in this library leaves them alone. + for (const robot of presented) group.remove(robot.rig.root); disposeOptimus(prototype); + ringGeometry.dispose(); + parcelGeometry.dispose(); + parcelMaterial.dispose(); + for (const material of statusMaterials.values()) material.dispose(); + toolMaterial.dispose(); + dockMaterial.dispose(); + dockLightMaterial.dispose(); + dockPadGeometry.dispose(); + dockPostGeometry.dispose(); + dockLightGeometry.dispose(); + toolBodyGeometry.dispose(); + toolLensGeometry.dispose(); }, }; } + +function pose(rig: OptimusRig, state: RobotActivityState): void { + restOptimus(rig.joints); + if (state.mode === "charge") { + rig.joints.head.rotation.x = 0.12; + rig.joints.shoulderL.rotation.x = 0.18; + rig.joints.shoulderR.rotation.x = 0.18; + return; + } + if (state.mode === "inspect" && state.phase === "inspecting") { + rig.joints.shoulderR.rotation.x = -0.72; + rig.joints.elbowR.rotation.x = -0.58; + rig.joints.head.rotation.y = 0.08 * Math.sin(state.objectiveTicks * 0.3); + return; + } + if (state.phase.startsWith("to-") || state.phase === "patrolling" || state.mode === "blocked-recovery") { + const wave = Math.sin(state.travelledM * 8.8); + rig.joints.hipL.rotation.x = wave * 0.34; + rig.joints.hipR.rotation.x = -wave * 0.34; + rig.joints.kneeL.rotation.x = Math.max(0, -wave) * 0.42; + rig.joints.kneeR.rotation.x = Math.max(0, wave) * 0.42; + rig.joints.shoulderL.rotation.x = -wave * 0.22; + rig.joints.shoulderR.rotation.x = wave * 0.22; + } +} diff --git a/src/main.ts b/src/main.ts index 6a2b1c9..018b628 100644 --- a/src/main.ts +++ b/src/main.ts @@ -57,7 +57,6 @@ import { sampleRoutesFor, } from "./adapters/sample.ts"; import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts"; -import { activityRobotsForOffice } from "./offices/runtime.ts"; import { authFetch } from "./session.ts"; import { capabilitiesFor, resolveAccess, type Access } from "./access.ts"; import { createMinimap, type Minimap } from "./engine/minimap.ts"; @@ -115,6 +114,7 @@ import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts */ import type { OfficeScene } from "./interiors/officeScene.ts"; import type { Office, Presence } from "./interiors/types.ts"; +import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts"; import type { MaterialRegistry } from "./assets/materials.ts"; // Type-only for the reason above, and it matters more here than it looks: // `officeMinimap.ts` imports the asset registry as a *value*, to read prop @@ -201,10 +201,10 @@ function journeyToCity(city: JourneyCity): void { /** * The buildings this page can walk into. * - * Two of them, and the second one is why this is a table rather than the single + * Three of them, and the second one is why this is a table rather than the single * hardcoded `import("./offices/lumbridge-hq.ts")` it replaces. They are - * deliberately unalike — a two-storey tower floor 188 m above Transbay, and a - * hangar four metres above reclaimed ground at Alameda Point — because the + * deliberately unalike — a tower above Transbay, a hangar at Alameda Point, + * and an Arts District courtyard — because the * thing worth showing is that one engine and one format render both, and that * `OfficeSite` is what makes them feel like different places rather than the * same room with different furniture. @@ -220,10 +220,23 @@ const OFFICE_LOADERS: Readonly Promise<{ default: "mateo-court": () => import("./offices/mateo-court.ts"), }; +const OFFICE_OPERATION_LOADERS: Readonly Promise<{ default: RobotOperationsDefinition }> +>>> = { + "lumbridge-hq": async () => ({ + default: (await import("./offices/operations/lumbridge-hq.ts")).LUMBRIDGE_HQ_ROBOT_OPERATIONS, + }), + "mateo-court": async () => ({ + default: (await import("./offices/operations/mateo-court.ts")).MATEO_COURT_ROBOT_OPERATIONS, + }), +}; + const OFFICES = OFFICE_SITES.map((entry) => ({ ...entry, label: entry.name, load: OFFICE_LOADERS[entry.id], + loadOperations: OFFICE_OPERATION_LOADERS[entry.id], })); const canvas = document.querySelector("#scene"); @@ -1210,7 +1223,7 @@ async function enterOffice() { // it did not arrive at all. Either way there is no room to walk into and // `loadOffice` has already said so on the button. if (!built || !city) return; - const { createOfficeScene, createOfficeMinimap, pack, materials } = built; + const { createOfficeScene, createOfficeMinimap, pack, materials, robotOperations } = built; const depth = access.can.officeDepth; // Keep the remote-avatar renderer behind the authenticated boundary. The // public Office door can build its full local scene without downloading it. @@ -1270,10 +1283,9 @@ async function enterOffice() { ...(pack.site ? {} : { background: 0x11161c }), ...(pack.site ? { lighting: officeLighting(pack.site) } : {}), ...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}), - // Environment-authored activity, not inferred headcount. These stable - // specs keep active HQs intentionally sparse and give the robot-jobs - // runtime ids, seeds and job sets it can adopt without a migration. - robots: activityRobotsForOffice(pack), + // Only offices with explicit, validated simulated operations get robots. + // No geometry-derived random errands and no implication of live work. + ...(robotOperations ? { robotOperations } : {}), depth, materials, // Ignored entirely at `"public"` depth, where no layer is built to colour. @@ -1446,17 +1458,16 @@ function disposeLoadedOffice(): void { * the interior, the furniture catalogue, the material registry and the * floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be * downloaded, parsed and executed on every load of a map page by people who - * came to look at a city. Behind these three `await import()`s Vite gives them + * came to look at a city. Behind these lazy imports Vite gives them * chunks of their own and the door fetches them on the way through. Measured, * entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after — * the difference is smaller than the chunks because three.js is shared and * stays where it was. * - * All three in one `Promise.all` because they are one arrival: the pack without + * All modules in one `Promise.all` because they are one arrival: the pack without * the builder is a data file nobody can draw, so the fetches overlap rather - * than queue. Rollup happens to emit them as three chunks the browser asks for - * together; awaiting them in sequence would make that three round trips on a - * slow link for no reason at all. + * than queue. Rollup emits chunks the browser asks for together; awaiting them + * in sequence would add avoidable round trips on a slow link. * * There is deliberately no retry and no cache-busting. A failed chunk fetch is * a deploy that moved the file under an open tab; the honest answer is to say @@ -1468,20 +1479,22 @@ async function loadOffice(): Promise<{ createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap; pack: Office; materials: MaterialRegistry; + robotOperations: RobotOperationsDefinition | null; } | null> { try { - // A fourth import and still one arrival. The plan renderer reads prop + // More modules and still one arrival. The plan renderer reads prop // footprints off the asset registry, so it is already downstream of the // furniture catalogue this chunk exists to hold back — asking for it here // costs nothing beyond the module itself, and asking for it anywhere else // would cost the whole catalogue in the entry chunk. const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0]; if (!entry) return null; - const [interiors, pack, assets, plan] = await Promise.all([ + const [interiors, pack, assets, plan, operations] = await Promise.all([ import("./interiors/officeScene.ts"), entry.load(), import("./assets/materials.ts"), import("./engine/officeMinimap.ts"), + entry.loadOperations?.() ?? Promise.resolve(null), ]); // Assigned rather than memoised with `??=`: the memo was what made this // single-office forever, quietly serving the first pack fetched for every @@ -1493,6 +1506,7 @@ async function loadOffice(): Promise<{ createOfficeMinimap: plan.createOfficeMinimap, pack: officePack, materials: officeMaterials, + robotOperations: operations?.default ?? null, }; } catch { showDetail("The office did not load. Check the connection and try the door again."); @@ -1953,18 +1967,23 @@ function renderOfficeBadge() { const mediaSurfaces = inside && office !== null && office.depth === "full" ? office.listMediaSurfaces() : []; + const robotActivity = inside && office !== null ? office.robotActivityInfo() : null; // The fabricated-occupancy caption used to be here too and is now on // `#source` — see `renderSource`. This badge keeps the message that is a call // to action rather than a disclosure, because that one belongs beside the // office controls and survives being missed; the other one does not. - officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0; + officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0 && robotActivity === null; if (!publicOffice) { - if (mediaSurfaces.length === 0) return; + if (mediaSurfaces.length === 0) { + if (robotActivity) officeBadge.textContent = robotActivity.disclosure; + return; + } const noun = mediaSurfaces.length === 1 ? "screen" : "screens"; const active = mediaSurfaces.filter((surface) => surface.bound).length; - officeBadge.textContent = active > 0 + const media = active > 0 ? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.` : `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`; + officeBadge.textContent = robotActivity ? `${media} ${robotActivity.disclosure}` : media; return; } officeBadge.replaceChildren( @@ -1978,6 +1997,7 @@ function renderOfficeBadge() { } else { officeBadge.append(document.createTextNode("Sign in to see who's in.")); } + if (robotActivity) officeBadge.append(document.createTextNode(` ${robotActivity.disclosure}`)); } /** diff --git a/src/offices/operations/index.ts b/src/offices/operations/index.ts new file mode 100644 index 0000000..d2ce499 --- /dev/null +++ b/src/offices/operations/index.ts @@ -0,0 +1,2 @@ +export { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "./lumbridge-hq.ts"; +export { MATEO_COURT_ROBOT_OPERATIONS } from "./mateo-court.ts"; diff --git a/src/offices/operations/lumbridge-hq.ts b/src/offices/operations/lumbridge-hq.ts new file mode 100644 index 0000000..6762369 --- /dev/null +++ b/src/offices/operations/lumbridge-hq.ts @@ -0,0 +1,46 @@ +import type { RobotOperationsDefinition } from "../../interiors/robotOperations.ts"; + +/** Authored, seeded demonstration operations for the public Lumbridge HQ plan. */ +export const LUMBRIDGE_HQ_ROBOT_OPERATIONS = Object.freeze({ + id: "lumbridge-hq-robot-operations", + version: 1, + officeId: "lumbridge-hq", + disclosure: "Seeded robot simulation — no live people, company activity, or operational data.", + stations: [ + { id: "sf-l1-dock", label: "Level 1 charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 9.2, z: 7.2 }, facing: { x: 1, z: 0 } } }, + { id: "sf-l1-lobby", label: "Lobby patrol point", role: "patrol", anchor: { kind: "room", roomId: "lobby" } }, + { id: "sf-l1-kitchen", label: "Kitchen patrol point", role: "patrol", anchor: { kind: "room", roomId: "kitchen" } }, + { id: "sf-l1-commons", label: "Commons patrol point", role: "patrol", anchor: { kind: "room", roomId: "commons" } }, + { id: "sf-l1-parcel-pickup", label: "Open floor parcel lockers", role: "pickup", anchor: { kind: "prop", propId: "open-locker-01", standoffM: 0.8 } }, + { id: "sf-l1-parcel-drop", label: "Facilities shelf", role: "dropoff", anchor: { kind: "prop", propId: "fac-shelf-01", standoffM: 0.82 } }, + { id: "sf-l1-display", label: "Open floor display", role: "inspect", anchor: { kind: "prop", propId: "open-display", standoffM: 0.72 } }, + { id: "sf-l1-board", label: "Workshop board", role: "inspect", anchor: { kind: "prop", propId: "shop-whiteboard", standoffM: 0.72 } }, + + { id: "sf-l2-dock", label: "Level 2 charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 5.0, z: 5.2 }, facing: { x: 1, z: 0 } } }, + { id: "sf-l2-landing", label: "Landing patrol point", role: "patrol", anchor: { kind: "room", roomId: "l2-landing" } }, + { id: "sf-l2-studio", label: "Studio patrol point", role: "patrol", anchor: { kind: "room", roomId: "l2-studio" } }, + { id: "sf-l2-gallery", label: "Gallery patrol point", role: "patrol", anchor: { kind: "room", roomId: "l2-gallery" } }, + { id: "sf-l2-parcel-pickup", label: "Library shelf", role: "pickup", anchor: { kind: "prop", propId: "l2-library-shelf-03", standoffM: 0.78, side: -1 } }, + { id: "sf-l2-parcel-drop", label: "Project shelf", role: "dropoff", anchor: { kind: "prop", propId: "l2-project-shelf-01", standoffM: 0.78, side: -1 } }, + { id: "sf-l2-board", label: "Studio board", role: "inspect", anchor: { kind: "prop", propId: "l2-studio-board", standoffM: 0.72 } }, + { id: "sf-l2-project-board", label: "Project board", role: "inspect", anchor: { kind: "prop", propId: "l2-project-board-02", standoffM: 0.72 } }, + ], + jobs: [ + { id: "sf-l1-patrol", label: "Level 1 safety patrol", kind: "patrol", stationIds: ["sf-l1-lobby", "sf-l1-kitchen", "sf-l1-commons"], dwellTicks: 6 }, + { id: "sf-l1-deliver", label: "Locker-to-facilities parcel", kind: "deliver", stationIds: ["sf-l1-parcel-pickup", "sf-l1-parcel-drop"], dwellTicks: 9 }, + { id: "sf-l1-inspect-display", label: "Display inspection", kind: "inspect", stationIds: ["sf-l1-display"], dwellTicks: 14 }, + { id: "sf-l1-inspect-board", label: "Workshop board inspection", kind: "inspect", stationIds: ["sf-l1-board"], dwellTicks: 12 }, + { id: "sf-l1-charge", label: "Level 1 charge cycle", kind: "charge", stationIds: ["sf-l1-dock"], dwellTicks: 1 }, + { id: "sf-l2-patrol", label: "Level 2 safety patrol", kind: "patrol", stationIds: ["sf-l2-landing", "sf-l2-studio", "sf-l2-gallery"], dwellTicks: 6 }, + { id: "sf-l2-deliver", label: "Library-to-project parcel", kind: "deliver", stationIds: ["sf-l2-parcel-pickup", "sf-l2-parcel-drop"], dwellTicks: 9 }, + { id: "sf-l2-inspect-studio", label: "Studio board inspection", kind: "inspect", stationIds: ["sf-l2-board"], dwellTicks: 14 }, + { id: "sf-l2-inspect-project", label: "Project board inspection", kind: "inspect", stationIds: ["sf-l2-project-board"], dwellTicks: 12 }, + { id: "sf-l2-charge", label: "Level 2 charge cycle", kind: "charge", stationIds: ["sf-l2-dock"], dwellTicks: 1 }, + ], + robots: [ + { id: "sf-l1-courier", label: "SF L1 courier", levelId: "level-1", spawnStationId: "sf-l1-dock", schedule: ["sf-l1-deliver", "sf-l1-patrol", "sf-l1-charge"], initialBattery: 0.7 }, + { id: "sf-l1-inspector", label: "SF L1 inspector", levelId: "level-1", spawnStationId: "sf-l1-lobby", schedule: ["sf-l1-inspect-display", "sf-l1-inspect-board", "sf-l1-patrol", "sf-l1-charge"], initialBattery: 0.48 }, + { id: "sf-l2-courier", label: "SF L2 courier", levelId: "level-2", spawnStationId: "sf-l2-dock", schedule: ["sf-l2-deliver", "sf-l2-patrol", "sf-l2-charge"], initialBattery: 0.66 }, + { id: "sf-l2-inspector", label: "SF L2 inspector", levelId: "level-2", spawnStationId: "sf-l2-landing", schedule: ["sf-l2-inspect-studio", "sf-l2-inspect-project", "sf-l2-patrol", "sf-l2-charge"], initialBattery: 0.44 }, + ], +} satisfies RobotOperationsDefinition); diff --git a/src/offices/operations/mateo-court.ts b/src/offices/operations/mateo-court.ts new file mode 100644 index 0000000..2557d1c --- /dev/null +++ b/src/offices/operations/mateo-court.ts @@ -0,0 +1,46 @@ +import type { RobotOperationsDefinition } from "../../interiors/robotOperations.ts"; + +/** Authored, seeded demonstration operations for the public Mateo Court plan. */ +export const MATEO_COURT_ROBOT_OPERATIONS = Object.freeze({ + id: "mateo-court-robot-operations", + version: 1, + officeId: "mateo-court", + disclosure: "Seeded robot simulation — no live people, company activity, or operational data.", + stations: [ + { id: "la-l1-dock", label: "Court charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 18.4, z: 13.2 }, facing: { x: 1, z: 0 } } }, + { id: "la-l1-paseo", label: "Paseo patrol point", role: "patrol", anchor: { kind: "room", roomId: "paseo" } }, + { id: "la-l1-mess", label: "Mess patrol point", role: "patrol", anchor: { kind: "room", roomId: "mess" } }, + { id: "la-l1-court", label: "Court patrol point", role: "patrol", anchor: { kind: "room", roomId: "court" } }, + { id: "la-l1-parcel-pickup", label: "Store shelf", role: "pickup", anchor: { kind: "prop", propId: "store-shelf-02", standoffM: 0.8, side: -1 } }, + { id: "la-l1-parcel-drop", label: "Works locker", role: "dropoff", anchor: { kind: "prop", propId: "works-locker-02", standoffM: 0.8, side: -1 } }, + { id: "la-l1-directory", label: "Paseo directory", role: "inspect", anchor: { kind: "prop", propId: "paseo-directory", standoffM: 0.7, side: -1 } }, + { id: "la-l1-display", label: "Works display", role: "inspect", anchor: { kind: "prop", propId: "works-display", standoffM: 0.72, side: -1 } }, + + { id: "la-l2-dock", label: "Loft charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 13.2, z: 3.0 }, facing: { x: 1, z: 0 } } }, + { id: "la-l2-loft", label: "Loft patrol point", role: "patrol", anchor: { kind: "room", roomId: "loft" } }, + { id: "la-l2-palmetto", label: "Palmetto patrol point", role: "patrol", anchor: { kind: "room", roomId: "palmetto" } }, + { id: "la-l2-loggia", label: "Loggia patrol point", role: "patrol", anchor: { kind: "point", levelId: "level-2", position: { x: 10.0, z: 8.0 } } }, + { id: "la-l2-parcel-pickup", label: "Loft locker", role: "pickup", anchor: { kind: "prop", propId: "loft-locker-02", standoffM: 0.8, side: -1 } }, + { id: "la-l2-parcel-drop", label: "Jesse shelf", role: "dropoff", anchor: { kind: "prop", propId: "jesse-shelf-01", standoffM: 0.78, side: -1 } }, + { id: "la-l2-display", label: "Loft display", role: "inspect", anchor: { kind: "prop", propId: "loft-display", standoffM: 0.72 } }, + { id: "la-l2-board", label: "Jesse board", role: "inspect", anchor: { kind: "prop", propId: "jesse-board-02", standoffM: 0.72, side: -1 } }, + ], + jobs: [ + { id: "la-l1-patrol", label: "Ground-floor safety patrol", kind: "patrol", stationIds: ["la-l1-paseo", "la-l1-mess", "la-l1-court"], dwellTicks: 6 }, + { id: "la-l1-deliver", label: "Store-to-works parcel", kind: "deliver", stationIds: ["la-l1-parcel-pickup", "la-l1-parcel-drop"], dwellTicks: 9 }, + { id: "la-l1-inspect-directory", label: "Directory inspection", kind: "inspect", stationIds: ["la-l1-directory"], dwellTicks: 12 }, + { id: "la-l1-inspect-display", label: "Works display inspection", kind: "inspect", stationIds: ["la-l1-display"], dwellTicks: 14 }, + { id: "la-l1-charge", label: "Court charge cycle", kind: "charge", stationIds: ["la-l1-dock"], dwellTicks: 1 }, + { id: "la-l2-patrol", label: "Upper-floor safety patrol", kind: "patrol", stationIds: ["la-l2-loft", "la-l2-palmetto", "la-l2-loggia"], dwellTicks: 6 }, + { id: "la-l2-deliver", label: "Loft-to-Jesse parcel", kind: "deliver", stationIds: ["la-l2-parcel-pickup", "la-l2-parcel-drop"], dwellTicks: 9 }, + { id: "la-l2-inspect-display", label: "Loft display inspection", kind: "inspect", stationIds: ["la-l2-display"], dwellTicks: 14 }, + { id: "la-l2-inspect-board", label: "Jesse board inspection", kind: "inspect", stationIds: ["la-l2-board"], dwellTicks: 12 }, + { id: "la-l2-charge", label: "Loft charge cycle", kind: "charge", stationIds: ["la-l2-dock"], dwellTicks: 1 }, + ], + robots: [ + { id: "la-l1-courier", label: "LA L1 courier", levelId: "level-1", spawnStationId: "la-l1-dock", schedule: ["la-l1-deliver", "la-l1-patrol", "la-l1-charge"], initialBattery: 0.7 }, + { id: "la-l1-inspector", label: "LA L1 inspector", levelId: "level-1", spawnStationId: "la-l1-paseo", schedule: ["la-l1-inspect-directory", "la-l1-inspect-display", "la-l1-patrol", "la-l1-charge"], initialBattery: 0.48 }, + { id: "la-l2-courier", label: "LA L2 courier", levelId: "level-2", spawnStationId: "la-l2-dock", schedule: ["la-l2-deliver", "la-l2-patrol", "la-l2-charge"], initialBattery: 0.66 }, + { id: "la-l2-inspector", label: "LA L2 inspector", levelId: "level-2", spawnStationId: "la-l2-palmetto", schedule: ["la-l2-inspect-display", "la-l2-inspect-board", "la-l2-patrol", "la-l2-charge"], initialBattery: 0.44 }, + ], +} satisfies RobotOperationsDefinition); diff --git a/src/test/arena.test.ts b/src/test/arena.test.ts index 1f093ec..7530fb5 100644 --- a/src/test/arena.test.ts +++ b/src/test/arena.test.ts @@ -12,15 +12,19 @@ import { DRIVE_INACTION, OFFICE_NAV_INACTION, OFFICE_NAV_SCENARIOS, + OFFICE_JOBS_INACTION, + OFFICE_JOBS_SCENARIOS, CaliforniaFlightEnvironment, CrowNavEnvironment, Drive101Environment, OfficeNavEnvironment, + OfficeJobsEnvironment, arenaChecksum, californiaFlightScriptedBaseline, crowNavScriptedBaseline, driveScriptedBaseline, officeNavScriptedBaseline, + officeJobsScriptedBaseline, type ArenaEnvironment, type ArenaManifest, type ArenaScenarioRegistry, @@ -36,6 +40,7 @@ interface EnvironmentCase { registry: ArenaScenarioRegistry; inaction: unknown; scripted(observation: unknown): unknown; + successReason: string; } const CASES: EnvironmentCase[] = [ @@ -45,6 +50,7 @@ const CASES: EnvironmentCase[] = [ registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry, inaction: DRIVE_INACTION, scripted: () => driveScriptedBaseline(), + successReason: "goal", }, { name: "office", @@ -54,6 +60,17 @@ const CASES: EnvironmentCase[] = [ scripted: (observation) => officeNavScriptedBaseline( observation as Parameters[0], ), + successReason: "goal", + }, + { + name: "office-jobs", + create: () => new OfficeJobsEnvironment() as AnyEnvironment, + registry: OFFICE_JOBS_SCENARIOS as ArenaScenarioRegistry, + inaction: OFFICE_JOBS_INACTION, + scripted: (observation) => officeJobsScriptedBaseline( + observation as Parameters[0], + ), + successReason: "job-complete", }, { name: "crow", @@ -63,6 +80,7 @@ const CASES: EnvironmentCase[] = [ scripted: (observation) => crowNavScriptedBaseline( observation as Parameters[0], ), + successReason: "goal", }, { name: "flight", @@ -72,6 +90,7 @@ const CASES: EnvironmentCase[] = [ scripted: (observation) => californiaFlightScriptedBaseline( observation as Parameters[0], ), + successReason: "goal", }, ]; @@ -96,9 +115,9 @@ function run( } describe("arena contract and manifests", () => { - it("exports four versioned renderer-independent manifests with disjoint public splits", () => { + it("exports five versioned renderer-independent manifests with disjoint public splits", () => { assert.deepEqual(ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id), [ - "drive-101-v1", "office-nav-v1", "crow-nav-v1", "california-flight-v1", + "drive-101-v1", "office-nav-v1", "office-jobs-v1", "crow-nav-v1", "california-flight-v1", ]); for (const manifest of ARENA_MANIFESTS) { assert.equal(manifest.apiVersion, ARENA_API_VERSION); @@ -158,7 +177,7 @@ describe("arena contract and manifests", () => { const scripted = run(entry, id, 5, entry.scripted).final; assert.equal(scripted.terminated, true, entry.name); assert.equal(scripted.truncated, false, entry.name); - assert.equal(scripted.info.terminalReason, "goal", entry.name); + assert.equal(scripted.info.terminalReason, entry.successReason, entry.name); } }); }); @@ -170,12 +189,12 @@ describe("arena baseline proofs", () => { for (const seed of [1, 0xdecafbad]) { const idle = run(entry, definition.id, seed, () => entry.inaction); assert.ok(idle.total < 0, `${entry.name}/${definition.id}/${seed} inaction=${idle.total}`); - assert.notEqual(idle.final.info.terminalReason, "goal"); + assert.notEqual(idle.final.info.terminalReason, entry.successReason); const scripted = run(entry, definition.id, seed, entry.scripted); assert.equal( scripted.final.info.terminalReason, - "goal", + entry.successReason, `${entry.name}/${definition.id}/${seed}`, ); assert.ok(scripted.total > 0, `${entry.name}/${definition.id}/${seed}=${scripted.total}`); @@ -206,6 +225,26 @@ describe("arena baseline proofs", () => { } assert.equal(officeReason, "collision-stall"); + const officeJobs = new OfficeJobsEnvironment(); + officeJobs.reset(2, "train-la-directory-inspection"); + let officeJobsReason: string | null = null; + for (let index = 0; index < 8; index += 1) { + const result = officeJobs.step({ x: 0, z: 0, interact: true }); + officeJobsReason = result.info.terminalReason; + if (result.terminated) break; + } + assert.equal(officeJobsReason, "wrong-interaction-limit"); + + const collisionJobs = new OfficeJobsEnvironment(); + collisionJobs.reset(2, "dev-sf-parcel-delivery"); + let collisionJobsReason: string | null = null; + for (let index = 0; index < 80; index += 1) { + const result = collisionJobs.step({ x: 0, z: 1, interact: false }); + collisionJobsReason = result.info.terminalReason; + if (result.terminated) break; + } + assert.equal(collisionJobsReason, "collision-stall"); + const crow = new CrowNavEnvironment(); crow.reset(2, "train-east-crosswind"); let crowReason: string | null = null; @@ -229,6 +268,33 @@ describe("arena baseline proofs", () => { }); describe("arena snapshot, trace and replay", () => { + it("restores and replays every SF/LA office-job scenario", () => { + for (const definition of OFFICE_JOBS_SCENARIOS.definitions) { + const environment = new OfficeJobsEnvironment(); + let observation = environment.reset(311, definition.id).observation; + for (let index = 0; index < 10; index += 1) { + observation = environment.step(officeJobsScriptedBaseline(observation)).observation; + } + const checkpoint = environment.snapshot(); + const action = officeJobsScriptedBaseline(observation); + const expected = environment.step(action); + environment.restore(checkpoint); + assert.deepEqual(environment.step(action), expected, definition.id); + + const traced = new OfficeJobsEnvironment(); + observation = traced.reset(311, definition.id).observation; + for (let index = 0; index < 50; index += 1) { + const result = traced.step(officeJobsScriptedBaseline(observation)); + observation = result.observation; + if (result.terminated) break; + } + const trace = traced.trace(); + const replay = new OfficeJobsEnvironment().replay(trace); + assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, definition.id); + assert.equal(replay.cumulativeReward, trace.cumulativeReward, definition.id); + } + }); + it("restores each simulator bit-for-bit and preserves the next transition", () => { for (const entry of CASES) { const environment = entry.create(); @@ -301,6 +367,17 @@ describe("arena snapshot, trace and replay", () => { }), /frame 1 is invalid/, ); + + const jobs = new OfficeJobsEnvironment(); + jobs.reset(17, "train-sf-display-inspection"); + const jobsSnapshot = jobs.snapshot(); + const invalidSimulation = structuredClone(jobsSnapshot.simulation); + invalidSimulation.activity.robots[0]!.battery = 9; + const { checksum: _jobsChecksum, ...jobsCore } = { ...jobsSnapshot, simulation: invalidSimulation }; + assert.throws( + () => jobs.restore({ ...jobsCore, checksum: arenaChecksum(jobsCore) }), + /invalid/, + ); }); it("refuses post-terminal stepping until reset", () => { diff --git a/src/test/robotActivity.test.ts b/src/test/robotActivity.test.ts new file mode 100644 index 0000000..153b6f3 --- /dev/null +++ b/src/test/robotActivity.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createRobotActivity } from "../interiors/robotActivity.ts"; +import { resolveRobotOperations } from "../interiors/robotOperations.ts"; +import { createRobotRouter } from "../interiors/robotRoutes.ts"; +import { Plan } from "../interiors/plan.ts"; +import { LUMBRIDGE_HQ } from "../offices/lumbridge-hq.ts"; +import { MATEO_COURT } from "../offices/mateo-court.ts"; +import { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "../offices/operations/lumbridge-hq.ts"; +import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.ts"; + +const CASES = [ + [LUMBRIDGE_HQ, LUMBRIDGE_HQ_ROBOT_OPERATIONS], + [MATEO_COURT, MATEO_COURT_ROBOT_OPERATIONS], +] as const; + +describe("robot operations and routes", () => { + it("resolves explicit Plan anchors and keeps every authored job on one routable floor", () => { + for (const [office, definition] of CASES) { + const plan = new Plan(office, { depth: "public", warn: false }); + const operations = resolveRobotOperations(plan, definition); + const router = createRobotRouter(plan); + for (const robot of operations.robots.values()) { + let previous = operations.stations.get(robot.spawnStationId)!; + for (const jobId of robot.schedule) { + const job = operations.jobs.get(jobId)!; + for (const stationId of job.stationIds) { + const station = operations.stations.get(stationId)!; + assert.equal(station.levelId, robot.levelId, `${robot.id}/${job.id}`); + const route = router.route(robot.levelId, previous.position, station.position); + assert.ok(route, `${robot.id}/${job.id}/${station.id}`); + assert.ok(route.lengthM >= 0); + previous = station; + } + } + } + } + }); + + it("rejects operations that imply real activity or invent a vertical route", () => { + const plan = new Plan(LUMBRIDGE_HQ, { warn: false }); + assert.throws( + () => resolveRobotOperations(plan, { + ...LUMBRIDGE_HQ_ROBOT_OPERATIONS, + disclosure: "Live operations", + }), + /simulated/, + ); + assert.throws( + () => resolveRobotOperations(plan, { + ...LUMBRIDGE_HQ_ROBOT_OPERATIONS, + jobs: [{ + id: "bad-route", + label: "Bad route", + kind: "deliver", + stationIds: ["sf-l1-parcel-pickup", "sf-l2-parcel-drop"], + dwellTicks: 1, + }], + robots: [], + }), + /crosses levels/, + ); + }); +}); + +describe("robot activity determinism", () => { + it("is cadence-independent at the fixed-step boundary and seed-stable", () => { + const plan = new Plan(LUMBRIDGE_HQ, { warn: false }); + const operations = resolveRobotOperations(plan, LUMBRIDGE_HQ_ROBOT_OPERATIONS); + const stepped = createRobotActivity(plan, operations, { seed: 73 }); + const ticked = createRobotActivity(plan, operations, { seed: 73 }); + for (let index = 0; index < 600; index += 1) stepped.step(); + for (let index = 0; index < 300; index += 1) ticked.tick(0.2); + assert.deepEqual(ticked.snapshot(), stepped.snapshot()); + + const different = createRobotActivity(plan, operations, { seed: 74 }); + assert.notDeepEqual( + different.states().map((robot) => [robot.id, robot.scheduleCursor, robot.idleTicks]), + stepped.trace().initial.robots.map((robot) => [robot.id, robot.scheduleCursor, robot.idleTicks]), + ); + }); + + it("restores the exact next transition and replays JSON-safe traces", () => { + const plan = new Plan(MATEO_COURT, { warn: false }); + const operations = resolveRobotOperations(plan, MATEO_COURT_ROBOT_OPERATIONS); + const activity = createRobotActivity(plan, operations, { seed: 0xdecafbad }); + for (let index = 0; index < 420; index += 1) activity.step(); + const checkpoint = activity.snapshot(); + const expected = activity.step(); + activity.restore(JSON.parse(JSON.stringify(checkpoint))); + assert.deepEqual(activity.step(), expected); + + for (let index = 0; index < 60; index += 1) activity.step(); + const trace = JSON.parse(JSON.stringify(activity.trace())); + assert.deepEqual(activity.replay(trace), trace.final); + }); + + it("rejects tampered snapshots before state mutation", () => { + const plan = new Plan(LUMBRIDGE_HQ, { warn: false }); + const operations = resolveRobotOperations(plan, LUMBRIDGE_HQ_ROBOT_OPERATIONS); + const activity = createRobotActivity(plan, operations, { seed: 4 }); + const before = activity.snapshot(); + const tampered = structuredClone(before); + tampered.robots[0]!.battery = 9; + assert.throws(() => activity.restore(tampered), /invalid/); + assert.deepEqual(activity.snapshot(), before); + }); + + it("exposes deterministic blocked recovery for a controlled robot", () => { + const plan = new Plan(LUMBRIDGE_HQ, { warn: false }); + const operations = resolveRobotOperations(plan, LUMBRIDGE_HQ_ROBOT_OPERATIONS); + const id = "sf-l1-courier"; + const activity = createRobotActivity(plan, operations, { + seed: 1, + robotIds: [id], + controlledRobotIds: [id], + }); + let recovered = false; + for (let index = 0; index < 80; index += 1) { + const robot = activity.step({ [id]: { x: 0, z: 1, interact: false } }).robots[0]!; + if (robot.mode === "blocked-recovery") recovered = true; + } + assert.equal(recovered, true); + }); +}); + +describe("robot activity soak", () => { + it("runs thirty deterministic simulated minutes without a floor escape or terminal", () => { + for (const [office, definition] of CASES) { + const plan = new Plan(office, { warn: false }); + const operations = resolveRobotOperations(plan, definition); + const activity = createRobotActivity(plan, operations, { seed: 0x5eed1234 }); + const seen = new Set(); + for (let tick = 0; tick < 18_000; tick += 1) { + for (const robot of activity.step().robots) { + seen.add(robot.mode); + assert.equal(robot.terminalReason, null, `${office.id}/${robot.id}/${tick}`); + assert.ok(plan.roomAt(robot.levelId, robot.position), `${office.id}/${robot.id}/${tick}`); + assert.equal(plan.blocked(robot.levelId, robot.position, robot.position, 0.28), false); + } + } + assert.ok(seen.has("patrol")); + assert.ok(seen.has("deliver")); + assert.ok(seen.has("inspect")); + assert.ok(seen.has("charge")); + assert.ok(activity.states().every((robot) => robot.completedJobs > 20)); + } + }); +});