feat: add deterministic office robot jobs
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Tera Arena environments
|
# Tera Arena environments
|
||||||
|
|
||||||
`@lumbridge/tera/arena` is Tera's renderer-independent RL boundary. It imports
|
`@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
|
shipped environments wrap the same fixed-step controllers and plans used by the
|
||||||
interactive client:
|
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 |
|
| `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-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 |
|
| `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 |
|
| `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
|
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.
|
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
|
## Adding an environment
|
||||||
|
|
||||||
Keep the environment under `src/arena/`, wrap an existing renderer-neutral
|
Keep the environment under `src/arena/`, wrap an existing renderer-neutral
|
||||||
|
|||||||
@@ -74,9 +74,10 @@ npm run dev
|
|||||||
|
|
||||||
## Headless RL environments
|
## 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
|
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,
|
They share the client controllers and office plan, but require no canvas, DOM,
|
||||||
Three.js scene, network service, or new runtime dependency.
|
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
|
traces, exact replay and executable inaction/scripted baseline proofs are
|
||||||
documented in [ARENA.md](ARENA.md).
|
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
|
## Using the engine
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ const sourceSets = {
|
|||||||
"src/offices/frontier-valley.ts",
|
"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": {
|
"crow-nav-v1": {
|
||||||
environment: [...sharedEnvironment, "src/arena/crowNav.ts"],
|
environment: [...sharedEnvironment, "src/arena/crowNav.ts"],
|
||||||
simulator: ["src/actors/controller.ts"],
|
simulator: ["src/actors/controller.ts"],
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ export {
|
|||||||
type OfficeNavObservation,
|
type OfficeNavObservation,
|
||||||
type OfficeNavReward,
|
type OfficeNavReward,
|
||||||
} from "./officeNav.ts";
|
} 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 {
|
export {
|
||||||
CROW_NAV_INACTION,
|
CROW_NAV_INACTION,
|
||||||
CROW_NAV_MANIFEST,
|
CROW_NAV_MANIFEST,
|
||||||
@@ -69,11 +79,13 @@ import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
|
|||||||
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
|
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
|
||||||
import { DRIVE_101_MANIFEST } from "./drive101.ts";
|
import { DRIVE_101_MANIFEST } from "./drive101.ts";
|
||||||
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
|
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
|
||||||
|
import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts";
|
||||||
|
|
||||||
/** Machine-readable public environment catalogue. */
|
/** Machine-readable public environment catalogue. */
|
||||||
export const ARENA_MANIFESTS = Object.freeze([
|
export const ARENA_MANIFESTS = Object.freeze([
|
||||||
DRIVE_101_MANIFEST,
|
DRIVE_101_MANIFEST,
|
||||||
OFFICE_NAV_MANIFEST,
|
OFFICE_NAV_MANIFEST,
|
||||||
|
OFFICE_JOBS_MANIFEST,
|
||||||
CROW_NAV_MANIFEST,
|
CROW_NAV_MANIFEST,
|
||||||
CALIFORNIA_FLIGHT_MANIFEST,
|
CALIFORNIA_FLIGHT_MANIFEST,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> =
|
|||||||
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
|
environment: "sha256:870b1924a7ac641d2523d52a537f6b1538803f9cc732bbfc422522a65b4eb09a",
|
||||||
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
|
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
|
||||||
},
|
},
|
||||||
|
"office-jobs-v1": {
|
||||||
|
environment: "sha256:525bdab6cdc5ea1ca684654d0745392936df68ecec415a145eb05a07c003d9bd",
|
||||||
|
simulator: "sha256:df19af3cd7f906c54dc87f15e4a6df0003bd585c13ccdb63ad3b5632e8c49200",
|
||||||
|
},
|
||||||
"crow-nav-v1": {
|
"crow-nav-v1": {
|
||||||
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
|
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
|
||||||
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
|
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export * from "./arena/index.ts";
|
|||||||
export * from "./actors/controller.ts";
|
export * from "./actors/controller.ts";
|
||||||
export * from "./aircraft/controller.ts";
|
export * from "./aircraft/controller.ts";
|
||||||
export * from "./interiors/plan.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/types.ts";
|
||||||
export * from "./interiors/walker.ts";
|
export * from "./interiors/walker.ts";
|
||||||
export * from "./transport/types.ts";
|
export * from "./transport/types.ts";
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
createRobotLayer,
|
createRobotLayer,
|
||||||
type RobotLayer,
|
type RobotLayer,
|
||||||
type RobotSpec,
|
|
||||||
type RobotView,
|
type RobotView,
|
||||||
} from "./robots.ts";
|
} from "./robots.ts";
|
||||||
|
import type { RobotOperationsDefinition } from "./robotOperations.ts";
|
||||||
import type {
|
import type {
|
||||||
ScenePeers,
|
ScenePeers,
|
||||||
ScenePeersOptions,
|
ScenePeersOptions,
|
||||||
@@ -220,17 +220,8 @@ export interface OfficeSceneOptions {
|
|||||||
* those are the three things you actually feel.
|
* those are the three things you actually feel.
|
||||||
*/
|
*/
|
||||||
horizon?: { drop: number };
|
horizon?: { drop: number };
|
||||||
/**
|
/** Authored seeded simulation; omitted means this office has no robot activity. */
|
||||||
* Humanoids to walk about the floor, one entry per robot.
|
robotOperations?: RobotOperationsDefinition;
|
||||||
*
|
|
||||||
* **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[];
|
|
||||||
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
|
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
|
||||||
walker?: OfficeWalkerOptions;
|
walker?: OfficeWalkerOptions;
|
||||||
/** Full-depth-only authoritative remote actors/vehicles in local metre coordinates. */
|
/** 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.
|
* The plan panel and the ceiling lights both consume it that way.
|
||||||
*/
|
*/
|
||||||
robots(): readonly RobotView[];
|
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;
|
upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
|
||||||
removeRemoteEntity(id: string): boolean;
|
removeRemoteEntity(id: string): boolean;
|
||||||
clearRemoteEntities(): void;
|
clearRemoteEntities(): void;
|
||||||
@@ -521,8 +514,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
|||||||
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
|
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
|
||||||
|
|
||||||
const robots: RobotLayer | null =
|
const robots: RobotLayer | null =
|
||||||
options.robots && options.robots.length > 0
|
options.robotOperations && options.robotOperations.robots.length > 0
|
||||||
? createRobotLayer(plan, { materials, robots: options.robots })
|
? createRobotLayer(plan, { materials, operations: options.robotOperations })
|
||||||
: null;
|
: null;
|
||||||
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
|
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
|
||||||
if (officeWalker) scene.add(officeWalker.root);
|
if (officeWalker) scene.add(officeWalker.root);
|
||||||
@@ -856,6 +849,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
|||||||
},
|
},
|
||||||
houseLevel: () => luminaires.houseLevel(),
|
houseLevel: () => luminaires.houseLevel(),
|
||||||
robots: () => robots?.robots() ?? NO_ROBOTS,
|
robots: () => robots?.robots() ?? NO_ROBOTS,
|
||||||
|
robotActivityInfo: () => robots
|
||||||
|
? {
|
||||||
|
operationsId: options.robotOperations!.id,
|
||||||
|
disclosure: robots.disclosure,
|
||||||
|
simulated: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
setWalkers(walkers) {
|
setWalkers(walkers) {
|
||||||
luminaires.setWalkers(walkers);
|
luminaires.setWalkers(walkers);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+39
-19
@@ -57,7 +57,6 @@ import {
|
|||||||
sampleRoutesFor,
|
sampleRoutesFor,
|
||||||
} from "./adapters/sample.ts";
|
} from "./adapters/sample.ts";
|
||||||
import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts";
|
import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts";
|
||||||
import { activityRobotsForOffice } from "./offices/runtime.ts";
|
|
||||||
import { authFetch } from "./session.ts";
|
import { authFetch } from "./session.ts";
|
||||||
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
||||||
import { createMinimap, type Minimap } from "./engine/minimap.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 { OfficeScene } from "./interiors/officeScene.ts";
|
||||||
import type { Office, Presence } from "./interiors/types.ts";
|
import type { Office, Presence } from "./interiors/types.ts";
|
||||||
|
import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts";
|
||||||
import type { MaterialRegistry } from "./assets/materials.ts";
|
import type { MaterialRegistry } from "./assets/materials.ts";
|
||||||
// Type-only for the reason above, and it matters more here than it looks:
|
// 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
|
// `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.
|
* 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
|
* hardcoded `import("./offices/lumbridge-hq.ts")` it replaces. They are
|
||||||
* deliberately unalike — a two-storey tower floor 188 m above Transbay, and a
|
* deliberately unalike — a tower above Transbay, a hangar at Alameda Point,
|
||||||
* hangar four metres above reclaimed ground at Alameda Point — because the
|
* and an Arts District courtyard — because the
|
||||||
* thing worth showing is that one engine and one format render both, and that
|
* 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
|
* `OfficeSite` is what makes them feel like different places rather than the
|
||||||
* same room with different furniture.
|
* same room with different furniture.
|
||||||
@@ -220,10 +220,23 @@ const OFFICE_LOADERS: Readonly<Record<ShippedOfficeId, () => Promise<{ default:
|
|||||||
"mateo-court": () => import("./offices/mateo-court.ts"),
|
"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) => ({
|
const OFFICES = OFFICE_SITES.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
label: entry.name,
|
label: entry.name,
|
||||||
load: OFFICE_LOADERS[entry.id],
|
load: OFFICE_LOADERS[entry.id],
|
||||||
|
loadOperations: OFFICE_OPERATION_LOADERS[entry.id],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
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
|
// it did not arrive at all. Either way there is no room to walk into and
|
||||||
// `loadOffice` has already said so on the button.
|
// `loadOffice` has already said so on the button.
|
||||||
if (!built || !city) return;
|
if (!built || !city) return;
|
||||||
const { createOfficeScene, createOfficeMinimap, pack, materials } = built;
|
const { createOfficeScene, createOfficeMinimap, pack, materials, robotOperations } = built;
|
||||||
const depth = access.can.officeDepth;
|
const depth = access.can.officeDepth;
|
||||||
// Keep the remote-avatar renderer behind the authenticated boundary. The
|
// Keep the remote-avatar renderer behind the authenticated boundary. The
|
||||||
// public Office door can build its full local scene without downloading it.
|
// 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 ? {} : { background: 0x11161c }),
|
||||||
...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
|
...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
|
||||||
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
|
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
|
||||||
// Environment-authored activity, not inferred headcount. These stable
|
// Only offices with explicit, validated simulated operations get robots.
|
||||||
// specs keep active HQs intentionally sparse and give the robot-jobs
|
// No geometry-derived random errands and no implication of live work.
|
||||||
// runtime ids, seeds and job sets it can adopt without a migration.
|
...(robotOperations ? { robotOperations } : {}),
|
||||||
robots: activityRobotsForOffice(pack),
|
|
||||||
depth,
|
depth,
|
||||||
materials,
|
materials,
|
||||||
// Ignored entirely at `"public"` depth, where no layer is built to colour.
|
// 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
|
* 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
|
* 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
|
* 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,
|
* 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 —
|
* 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
|
* the difference is smaller than the chunks because three.js is shared and
|
||||||
* stays where it was.
|
* 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
|
* 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
|
* than queue. Rollup emits chunks the browser asks for together; awaiting them
|
||||||
* together; awaiting them in sequence would make that three round trips on a
|
* in sequence would add avoidable round trips on a slow link.
|
||||||
* slow link for no reason at all.
|
|
||||||
*
|
*
|
||||||
* There is deliberately no retry and no cache-busting. A failed chunk fetch is
|
* 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
|
* 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;
|
createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap;
|
||||||
pack: Office;
|
pack: Office;
|
||||||
materials: MaterialRegistry;
|
materials: MaterialRegistry;
|
||||||
|
robotOperations: RobotOperationsDefinition | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
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
|
// footprints off the asset registry, so it is already downstream of the
|
||||||
// furniture catalogue this chunk exists to hold back — asking for it here
|
// furniture catalogue this chunk exists to hold back — asking for it here
|
||||||
// costs nothing beyond the module itself, and asking for it anywhere else
|
// costs nothing beyond the module itself, and asking for it anywhere else
|
||||||
// would cost the whole catalogue in the entry chunk.
|
// would cost the whole catalogue in the entry chunk.
|
||||||
const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
|
const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
|
||||||
if (!entry) return null;
|
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"),
|
import("./interiors/officeScene.ts"),
|
||||||
entry.load(),
|
entry.load(),
|
||||||
import("./assets/materials.ts"),
|
import("./assets/materials.ts"),
|
||||||
import("./engine/officeMinimap.ts"),
|
import("./engine/officeMinimap.ts"),
|
||||||
|
entry.loadOperations?.() ?? Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
// Assigned rather than memoised with `??=`: the memo was what made this
|
// Assigned rather than memoised with `??=`: the memo was what made this
|
||||||
// single-office forever, quietly serving the first pack fetched for every
|
// single-office forever, quietly serving the first pack fetched for every
|
||||||
@@ -1493,6 +1506,7 @@ async function loadOffice(): Promise<{
|
|||||||
createOfficeMinimap: plan.createOfficeMinimap,
|
createOfficeMinimap: plan.createOfficeMinimap,
|
||||||
pack: officePack,
|
pack: officePack,
|
||||||
materials: officeMaterials,
|
materials: officeMaterials,
|
||||||
|
robotOperations: operations?.default ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
showDetail("The office did not load. Check the connection and try the door again.");
|
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"
|
const mediaSurfaces = inside && office !== null && office.depth === "full"
|
||||||
? office.listMediaSurfaces()
|
? office.listMediaSurfaces()
|
||||||
: [];
|
: [];
|
||||||
|
const robotActivity = inside && office !== null ? office.robotActivityInfo() : null;
|
||||||
// The fabricated-occupancy caption used to be here too and is now on
|
// 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
|
// `#source` — see `renderSource`. This badge keeps the message that is a call
|
||||||
// to action rather than a disclosure, because that one belongs beside the
|
// to action rather than a disclosure, because that one belongs beside the
|
||||||
// office controls and survives being missed; the other one does not.
|
// 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 (!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 noun = mediaSurfaces.length === 1 ? "screen" : "screens";
|
||||||
const active = mediaSurfaces.filter((surface) => surface.bound).length;
|
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.`
|
? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.`
|
||||||
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
|
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
|
||||||
|
officeBadge.textContent = robotActivity ? `${media} ${robotActivity.disclosure}` : media;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
officeBadge.replaceChildren(
|
officeBadge.replaceChildren(
|
||||||
@@ -1978,6 +1997,7 @@ function renderOfficeBadge() {
|
|||||||
} else {
|
} else {
|
||||||
officeBadge.append(document.createTextNode("Sign in to see who's in."));
|
officeBadge.append(document.createTextNode("Sign in to see who's in."));
|
||||||
}
|
}
|
||||||
|
if (robotActivity) officeBadge.append(document.createTextNode(` ${robotActivity.disclosure}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "./lumbridge-hq.ts";
|
||||||
|
export { MATEO_COURT_ROBOT_OPERATIONS } from "./mateo-court.ts";
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { RobotOperationsDefinition } from "../../interiors/robotOperations.ts";
|
||||||
|
|
||||||
|
/** Authored, seeded demonstration operations for the public Lumbridge HQ plan. */
|
||||||
|
export const LUMBRIDGE_HQ_ROBOT_OPERATIONS = Object.freeze({
|
||||||
|
id: "lumbridge-hq-robot-operations",
|
||||||
|
version: 1,
|
||||||
|
officeId: "lumbridge-hq",
|
||||||
|
disclosure: "Seeded robot simulation — no live people, company activity, or operational data.",
|
||||||
|
stations: [
|
||||||
|
{ id: "sf-l1-dock", label: "Level 1 charge dock", role: "charge", anchor: { kind: "point", levelId: "level-1", position: { x: 9.2, z: 7.2 }, facing: { x: 1, z: 0 } } },
|
||||||
|
{ id: "sf-l1-lobby", label: "Lobby patrol point", role: "patrol", anchor: { kind: "room", roomId: "lobby" } },
|
||||||
|
{ id: "sf-l1-kitchen", label: "Kitchen patrol point", role: "patrol", anchor: { kind: "room", roomId: "kitchen" } },
|
||||||
|
{ id: "sf-l1-commons", label: "Commons patrol point", role: "patrol", anchor: { kind: "room", roomId: "commons" } },
|
||||||
|
{ id: "sf-l1-parcel-pickup", label: "Open floor parcel lockers", role: "pickup", anchor: { kind: "prop", propId: "open-locker-01", standoffM: 0.8 } },
|
||||||
|
{ id: "sf-l1-parcel-drop", label: "Facilities shelf", role: "dropoff", anchor: { kind: "prop", propId: "fac-shelf-01", standoffM: 0.82 } },
|
||||||
|
{ id: "sf-l1-display", label: "Open floor display", role: "inspect", anchor: { kind: "prop", propId: "open-display", standoffM: 0.72 } },
|
||||||
|
{ id: "sf-l1-board", label: "Workshop board", role: "inspect", anchor: { kind: "prop", propId: "shop-whiteboard", standoffM: 0.72 } },
|
||||||
|
|
||||||
|
{ id: "sf-l2-dock", label: "Level 2 charge dock", role: "charge", anchor: { kind: "point", levelId: "level-2", position: { x: 5.0, z: 5.2 }, facing: { x: 1, z: 0 } } },
|
||||||
|
{ id: "sf-l2-landing", label: "Landing patrol point", role: "patrol", anchor: { kind: "room", roomId: "l2-landing" } },
|
||||||
|
{ id: "sf-l2-studio", label: "Studio patrol point", role: "patrol", anchor: { kind: "room", roomId: "l2-studio" } },
|
||||||
|
{ id: "sf-l2-gallery", label: "Gallery patrol point", role: "patrol", anchor: { kind: "room", roomId: "l2-gallery" } },
|
||||||
|
{ id: "sf-l2-parcel-pickup", label: "Library shelf", role: "pickup", anchor: { kind: "prop", propId: "l2-library-shelf-03", standoffM: 0.78, side: -1 } },
|
||||||
|
{ id: "sf-l2-parcel-drop", label: "Project shelf", role: "dropoff", anchor: { kind: "prop", propId: "l2-project-shelf-01", standoffM: 0.78, side: -1 } },
|
||||||
|
{ id: "sf-l2-board", label: "Studio board", role: "inspect", anchor: { kind: "prop", propId: "l2-studio-board", standoffM: 0.72 } },
|
||||||
|
{ id: "sf-l2-project-board", label: "Project board", role: "inspect", anchor: { kind: "prop", propId: "l2-project-board-02", standoffM: 0.72 } },
|
||||||
|
],
|
||||||
|
jobs: [
|
||||||
|
{ id: "sf-l1-patrol", label: "Level 1 safety patrol", kind: "patrol", stationIds: ["sf-l1-lobby", "sf-l1-kitchen", "sf-l1-commons"], dwellTicks: 6 },
|
||||||
|
{ id: "sf-l1-deliver", label: "Locker-to-facilities parcel", kind: "deliver", stationIds: ["sf-l1-parcel-pickup", "sf-l1-parcel-drop"], dwellTicks: 9 },
|
||||||
|
{ id: "sf-l1-inspect-display", label: "Display inspection", kind: "inspect", stationIds: ["sf-l1-display"], dwellTicks: 14 },
|
||||||
|
{ id: "sf-l1-inspect-board", label: "Workshop board inspection", kind: "inspect", stationIds: ["sf-l1-board"], dwellTicks: 12 },
|
||||||
|
{ id: "sf-l1-charge", label: "Level 1 charge cycle", kind: "charge", stationIds: ["sf-l1-dock"], dwellTicks: 1 },
|
||||||
|
{ id: "sf-l2-patrol", label: "Level 2 safety patrol", kind: "patrol", stationIds: ["sf-l2-landing", "sf-l2-studio", "sf-l2-gallery"], dwellTicks: 6 },
|
||||||
|
{ id: "sf-l2-deliver", label: "Library-to-project parcel", kind: "deliver", stationIds: ["sf-l2-parcel-pickup", "sf-l2-parcel-drop"], dwellTicks: 9 },
|
||||||
|
{ id: "sf-l2-inspect-studio", label: "Studio board inspection", kind: "inspect", stationIds: ["sf-l2-board"], dwellTicks: 14 },
|
||||||
|
{ id: "sf-l2-inspect-project", label: "Project board inspection", kind: "inspect", stationIds: ["sf-l2-project-board"], dwellTicks: 12 },
|
||||||
|
{ id: "sf-l2-charge", label: "Level 2 charge cycle", kind: "charge", stationIds: ["sf-l2-dock"], dwellTicks: 1 },
|
||||||
|
],
|
||||||
|
robots: [
|
||||||
|
{ id: "sf-l1-courier", label: "SF L1 courier", levelId: "level-1", spawnStationId: "sf-l1-dock", schedule: ["sf-l1-deliver", "sf-l1-patrol", "sf-l1-charge"], initialBattery: 0.7 },
|
||||||
|
{ id: "sf-l1-inspector", label: "SF L1 inspector", levelId: "level-1", spawnStationId: "sf-l1-lobby", schedule: ["sf-l1-inspect-display", "sf-l1-inspect-board", "sf-l1-patrol", "sf-l1-charge"], initialBattery: 0.48 },
|
||||||
|
{ id: "sf-l2-courier", label: "SF L2 courier", levelId: "level-2", spawnStationId: "sf-l2-dock", schedule: ["sf-l2-deliver", "sf-l2-patrol", "sf-l2-charge"], initialBattery: 0.66 },
|
||||||
|
{ id: "sf-l2-inspector", label: "SF L2 inspector", levelId: "level-2", spawnStationId: "sf-l2-landing", schedule: ["sf-l2-inspect-studio", "sf-l2-inspect-project", "sf-l2-patrol", "sf-l2-charge"], initialBattery: 0.44 },
|
||||||
|
],
|
||||||
|
} satisfies RobotOperationsDefinition);
|
||||||
@@ -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
@@ -12,15 +12,19 @@ import {
|
|||||||
DRIVE_INACTION,
|
DRIVE_INACTION,
|
||||||
OFFICE_NAV_INACTION,
|
OFFICE_NAV_INACTION,
|
||||||
OFFICE_NAV_SCENARIOS,
|
OFFICE_NAV_SCENARIOS,
|
||||||
|
OFFICE_JOBS_INACTION,
|
||||||
|
OFFICE_JOBS_SCENARIOS,
|
||||||
CaliforniaFlightEnvironment,
|
CaliforniaFlightEnvironment,
|
||||||
CrowNavEnvironment,
|
CrowNavEnvironment,
|
||||||
Drive101Environment,
|
Drive101Environment,
|
||||||
OfficeNavEnvironment,
|
OfficeNavEnvironment,
|
||||||
|
OfficeJobsEnvironment,
|
||||||
arenaChecksum,
|
arenaChecksum,
|
||||||
californiaFlightScriptedBaseline,
|
californiaFlightScriptedBaseline,
|
||||||
crowNavScriptedBaseline,
|
crowNavScriptedBaseline,
|
||||||
driveScriptedBaseline,
|
driveScriptedBaseline,
|
||||||
officeNavScriptedBaseline,
|
officeNavScriptedBaseline,
|
||||||
|
officeJobsScriptedBaseline,
|
||||||
type ArenaEnvironment,
|
type ArenaEnvironment,
|
||||||
type ArenaManifest,
|
type ArenaManifest,
|
||||||
type ArenaScenarioRegistry,
|
type ArenaScenarioRegistry,
|
||||||
@@ -36,6 +40,7 @@ interface EnvironmentCase {
|
|||||||
registry: ArenaScenarioRegistry<object>;
|
registry: ArenaScenarioRegistry<object>;
|
||||||
inaction: unknown;
|
inaction: unknown;
|
||||||
scripted(observation: unknown): unknown;
|
scripted(observation: unknown): unknown;
|
||||||
|
successReason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CASES: EnvironmentCase[] = [
|
const CASES: EnvironmentCase[] = [
|
||||||
@@ -45,6 +50,7 @@ const CASES: EnvironmentCase[] = [
|
|||||||
registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry<object>,
|
registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||||
inaction: DRIVE_INACTION,
|
inaction: DRIVE_INACTION,
|
||||||
scripted: () => driveScriptedBaseline(),
|
scripted: () => driveScriptedBaseline(),
|
||||||
|
successReason: "goal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "office",
|
name: "office",
|
||||||
@@ -54,6 +60,17 @@ const CASES: EnvironmentCase[] = [
|
|||||||
scripted: (observation) => officeNavScriptedBaseline(
|
scripted: (observation) => officeNavScriptedBaseline(
|
||||||
observation as Parameters<typeof officeNavScriptedBaseline>[0],
|
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",
|
name: "crow",
|
||||||
@@ -63,6 +80,7 @@ const CASES: EnvironmentCase[] = [
|
|||||||
scripted: (observation) => crowNavScriptedBaseline(
|
scripted: (observation) => crowNavScriptedBaseline(
|
||||||
observation as Parameters<typeof crowNavScriptedBaseline>[0],
|
observation as Parameters<typeof crowNavScriptedBaseline>[0],
|
||||||
),
|
),
|
||||||
|
successReason: "goal",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "flight",
|
name: "flight",
|
||||||
@@ -72,6 +90,7 @@ const CASES: EnvironmentCase[] = [
|
|||||||
scripted: (observation) => californiaFlightScriptedBaseline(
|
scripted: (observation) => californiaFlightScriptedBaseline(
|
||||||
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
|
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
|
||||||
),
|
),
|
||||||
|
successReason: "goal",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -96,9 +115,9 @@ function run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("arena contract and manifests", () => {
|
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), [
|
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) {
|
for (const manifest of ARENA_MANIFESTS) {
|
||||||
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
|
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;
|
const scripted = run(entry, id, 5, entry.scripted).final;
|
||||||
assert.equal(scripted.terminated, true, entry.name);
|
assert.equal(scripted.terminated, true, entry.name);
|
||||||
assert.equal(scripted.truncated, false, 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]) {
|
for (const seed of [1, 0xdecafbad]) {
|
||||||
const idle = run(entry, definition.id, seed, () => entry.inaction);
|
const idle = run(entry, definition.id, seed, () => entry.inaction);
|
||||||
assert.ok(idle.total < 0, `${entry.name}/${definition.id}/${seed} inaction=${idle.total}`);
|
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);
|
const scripted = run(entry, definition.id, seed, entry.scripted);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
scripted.final.info.terminalReason,
|
scripted.final.info.terminalReason,
|
||||||
"goal",
|
entry.successReason,
|
||||||
`${entry.name}/${definition.id}/${seed}`,
|
`${entry.name}/${definition.id}/${seed}`,
|
||||||
);
|
);
|
||||||
assert.ok(scripted.total > 0, `${entry.name}/${definition.id}/${seed}=${scripted.total}`);
|
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");
|
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();
|
const crow = new CrowNavEnvironment();
|
||||||
crow.reset(2, "train-east-crosswind");
|
crow.reset(2, "train-east-crosswind");
|
||||||
let crowReason: string | null = null;
|
let crowReason: string | null = null;
|
||||||
@@ -229,6 +268,33 @@ describe("arena baseline proofs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("arena snapshot, trace and replay", () => {
|
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", () => {
|
it("restores each simulator bit-for-bit and preserves the next transition", () => {
|
||||||
for (const entry of CASES) {
|
for (const entry of CASES) {
|
||||||
const environment = entry.create();
|
const environment = entry.create();
|
||||||
@@ -301,6 +367,17 @@ describe("arena snapshot, trace and replay", () => {
|
|||||||
}),
|
}),
|
||||||
/frame 1 is invalid/,
|
/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", () => {
|
it("refuses post-terminal stepping until reset", () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user