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
+7 -1
View File
@@ -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
+8 -2
View File
@@ -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
+15
View File
@@ -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"],
+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));
}
});
});