1
0

feat: add deterministic office robot jobs

This commit is contained in:
2026-08-19 02:54:49 -07:00
parent 0557a26e6b
commit e378a03740
18 changed files with 1937 additions and 1779 deletions
+12
View File
@@ -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,
]);
+356
View File
@@ -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<OfficeJobsScenarioParameters>(
"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<OfficeJobsAction> = 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<OfficeJobsScenarioParameters>,
): 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<OfficeJobsObservation, OfficeJobsReward> {
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 };
}
+4
View File
@@ -13,6 +13,10 @@ export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> =
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
},
"office-jobs-v1": {
environment: "sha256:525bdab6cdc5ea1ca684654d0745392936df68ecec415a145eb05a07c003d9bd",
simulator: "sha256:df19af3cd7f906c54dc87f15e4a6df0003bd585c13ccdb63ad3b5632e8c49200",
},
"crow-nav-v1": {
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
+3
View File
@@ -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";
+14 -14
View File
@@ -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);
},
+636
View File
@@ -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<Record<string, RobotActivityAction>>;
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<Record<string, string>>;
}
export interface RobotActivityController {
readonly fixedStepSeconds: number;
readonly disclosure: string;
states(): readonly RobotActivityState[];
step(actions?: Readonly<Record<string, RobotActivityAction>>): RobotActivitySnapshot;
tick(elapsedSeconds: number, actions?: Readonly<Record<string, RobotActivityAction>>): 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<Record<string, RobotActivityAction>> = [];
function step(rawActions: Readonly<Record<string, RobotActivityAction>> = {}): 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<Record<string, RobotActivityAction>> = {},
): 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<string, RobotActivityAction>>,
): Record<string, RobotActivityAction> {
const normalized: Record<string, RobotActivityAction> = {};
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<string | number>): 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);
}
+204
View File
@@ -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<string, ResolvedRobotStation>;
jobs: ReadonlyMap<string, RobotJobDefinition>;
robots: ReadonlyMap<string, RobotSpawnDefinition>;
}
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<string, ResolvedRobotStation>();
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<string, RobotJobDefinition>();
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<string>();
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<string, RobotSpawnDefinition>();
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<T>(map: ReadonlyMap<string, T>, 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 };
}
+128
View File
@@ -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<string, readonly Node[]>();
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<number>([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 };
}
+186 -1738
View File
File diff suppressed because it is too large Load Diff
+39 -19
View File
@@ -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<Record<ShippedOfficeId, () => Promise<{ default:
"mateo-court": () => import("./offices/mateo-court.ts"),
};
const OFFICE_OPERATION_LOADERS: Readonly<Partial<Record<
ShippedOfficeId,
() => 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<HTMLCanvasElement>("#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}`));
}
/**
+2
View File
@@ -0,0 +1,2 @@
export { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "./lumbridge-hq.ts";
export { MATEO_COURT_ROBOT_OPERATIONS } from "./mateo-court.ts";
+46
View File
@@ -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);
+46
View File
@@ -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);
+82 -5
View File
@@ -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<object>;
inaction: unknown;
scripted(observation: unknown): unknown;
successReason: string;
}
const CASES: EnvironmentCase[] = [
@@ -45,6 +50,7 @@ const CASES: EnvironmentCase[] = [
registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: DRIVE_INACTION,
scripted: () => driveScriptedBaseline(),
successReason: "goal",
},
{
name: "office",
@@ -54,6 +60,17 @@ const CASES: EnvironmentCase[] = [
scripted: (observation) => officeNavScriptedBaseline(
observation as Parameters<typeof officeNavScriptedBaseline>[0],
),
successReason: "goal",
},
{
name: "office-jobs",
create: () => new OfficeJobsEnvironment() as AnyEnvironment,
registry: OFFICE_JOBS_SCENARIOS as ArenaScenarioRegistry<object>,
inaction: OFFICE_JOBS_INACTION,
scripted: (observation) => officeJobsScriptedBaseline(
observation as Parameters<typeof officeJobsScriptedBaseline>[0],
),
successReason: "job-complete",
},
{
name: "crow",
@@ -63,6 +80,7 @@ const CASES: EnvironmentCase[] = [
scripted: (observation) => crowNavScriptedBaseline(
observation as Parameters<typeof crowNavScriptedBaseline>[0],
),
successReason: "goal",
},
{
name: "flight",
@@ -72,6 +90,7 @@ const CASES: EnvironmentCase[] = [
scripted: (observation) => californiaFlightScriptedBaseline(
observation as Parameters<typeof californiaFlightScriptedBaseline>[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", () => {
+149
View File
@@ -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<string>();
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));
}
});
});