feat: add deterministic office robot jobs
This commit is contained in:
@@ -44,6 +44,16 @@ export {
|
||||
type OfficeNavObservation,
|
||||
type OfficeNavReward,
|
||||
} from "./officeNav.ts";
|
||||
export {
|
||||
OFFICE_JOBS_INACTION,
|
||||
OFFICE_JOBS_MANIFEST,
|
||||
OFFICE_JOBS_SCENARIOS,
|
||||
OfficeJobsEnvironment,
|
||||
officeJobsScriptedBaseline,
|
||||
type OfficeJobsAction,
|
||||
type OfficeJobsObservation,
|
||||
type OfficeJobsReward,
|
||||
} from "./officeJobs.ts";
|
||||
export {
|
||||
CROW_NAV_INACTION,
|
||||
CROW_NAV_MANIFEST,
|
||||
@@ -69,11 +79,13 @@ import { CALIFORNIA_FLIGHT_MANIFEST } from "./californiaFlight.ts";
|
||||
import { CROW_NAV_MANIFEST } from "./crowNav.ts";
|
||||
import { DRIVE_101_MANIFEST } from "./drive101.ts";
|
||||
import { OFFICE_NAV_MANIFEST } from "./officeNav.ts";
|
||||
import { OFFICE_JOBS_MANIFEST } from "./officeJobs.ts";
|
||||
|
||||
/** Machine-readable public environment catalogue. */
|
||||
export const ARENA_MANIFESTS = Object.freeze([
|
||||
DRIVE_101_MANIFEST,
|
||||
OFFICE_NAV_MANIFEST,
|
||||
OFFICE_JOBS_MANIFEST,
|
||||
CROW_NAV_MANIFEST,
|
||||
CALIFORNIA_FLIGHT_MANIFEST,
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
simulator: "sha256:34bb82d70b471cb3734d196f6ad1c685b9154083cf7e319c6089127f7a87eaf3",
|
||||
},
|
||||
"office-jobs-v1": {
|
||||
environment: "sha256:525bdab6cdc5ea1ca684654d0745392936df68ecec415a145eb05a07c003d9bd",
|
||||
simulator: "sha256:df19af3cd7f906c54dc87f15e4a6df0003bd585c13ccdb63ad3b5632e8c49200",
|
||||
},
|
||||
"crow-nav-v1": {
|
||||
environment: "sha256:141b1850ac01b1922a7db88ea6c30b15521302d720099de5f91c07472633f797",
|
||||
simulator: "sha256:f03ba9ff320d5231a728a7f9733492ed41fa8608509e476c6d84ae567b1f24d8",
|
||||
|
||||
@@ -3,6 +3,9 @@ export * from "./arena/index.ts";
|
||||
export * from "./actors/controller.ts";
|
||||
export * from "./aircraft/controller.ts";
|
||||
export * from "./interiors/plan.ts";
|
||||
export * from "./interiors/robotActivity.ts";
|
||||
export * from "./interiors/robotOperations.ts";
|
||||
export * from "./interiors/robotRoutes.ts";
|
||||
export * from "./interiors/types.ts";
|
||||
export * from "./interiors/walker.ts";
|
||||
export * from "./transport/types.ts";
|
||||
|
||||
@@ -96,9 +96,9 @@ import {
|
||||
import {
|
||||
createRobotLayer,
|
||||
type RobotLayer,
|
||||
type RobotSpec,
|
||||
type RobotView,
|
||||
} from "./robots.ts";
|
||||
import type { RobotOperationsDefinition } from "./robotOperations.ts";
|
||||
import type {
|
||||
ScenePeers,
|
||||
ScenePeersOptions,
|
||||
@@ -220,17 +220,8 @@ export interface OfficeSceneOptions {
|
||||
* those are the three things you actually feel.
|
||||
*/
|
||||
horizon?: { drop: number };
|
||||
/**
|
||||
* Humanoids to walk about the floor, one entry per robot.
|
||||
*
|
||||
* **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.**
|
||||
* The build-time-exclusion rule in this file's header is about *occupancy* — a
|
||||
* `Presence` names a person and comes from an authenticated API, so the public
|
||||
* office must not construct one. A robot is nobody: it carries no id anybody
|
||||
* issued, no seat binding, and no data from anywhere. There is nothing to
|
||||
* withhold, so a stranger gets them too.
|
||||
*/
|
||||
robots?: readonly RobotSpec[];
|
||||
/** Authored seeded simulation; omitted means this office has no robot activity. */
|
||||
robotOperations?: RobotOperationsDefinition;
|
||||
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
|
||||
walker?: OfficeWalkerOptions;
|
||||
/** Full-depth-only authoritative remote actors/vehicles in local metre coordinates. */
|
||||
@@ -319,6 +310,8 @@ export interface OfficeScene extends StageScene {
|
||||
* The plan panel and the ceiling lights both consume it that way.
|
||||
*/
|
||||
robots(): readonly RobotView[];
|
||||
/** Honest provenance for UI and diagnostics; null when no simulation is authored. */
|
||||
robotActivityInfo(): { operationsId: string; disclosure: string; simulated: true } | null;
|
||||
upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
|
||||
removeRemoteEntity(id: string): boolean;
|
||||
clearRemoteEntities(): void;
|
||||
@@ -521,8 +514,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
|
||||
|
||||
const robots: RobotLayer | null =
|
||||
options.robots && options.robots.length > 0
|
||||
? createRobotLayer(plan, { materials, robots: options.robots })
|
||||
options.robotOperations && options.robotOperations.robots.length > 0
|
||||
? createRobotLayer(plan, { materials, operations: options.robotOperations })
|
||||
: null;
|
||||
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
|
||||
if (officeWalker) scene.add(officeWalker.root);
|
||||
@@ -856,6 +849,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
},
|
||||
houseLevel: () => luminaires.houseLevel(),
|
||||
robots: () => robots?.robots() ?? NO_ROBOTS,
|
||||
robotActivityInfo: () => robots
|
||||
? {
|
||||
operationsId: options.robotOperations!.id,
|
||||
disclosure: robots.disclosure,
|
||||
simulated: true,
|
||||
}
|
||||
: null,
|
||||
setWalkers(walkers) {
|
||||
luminaires.setWalkers(walkers);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
} from "./adapters/sample.ts";
|
||||
import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts";
|
||||
import { activityRobotsForOffice } from "./offices/runtime.ts";
|
||||
import { authFetch } from "./session.ts";
|
||||
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
||||
import { createMinimap, type Minimap } from "./engine/minimap.ts";
|
||||
@@ -115,6 +114,7 @@ import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts
|
||||
*/
|
||||
import type { OfficeScene } from "./interiors/officeScene.ts";
|
||||
import type { Office, Presence } from "./interiors/types.ts";
|
||||
import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts";
|
||||
import type { MaterialRegistry } from "./assets/materials.ts";
|
||||
// Type-only for the reason above, and it matters more here than it looks:
|
||||
// `officeMinimap.ts` imports the asset registry as a *value*, to read prop
|
||||
@@ -201,10 +201,10 @@ function journeyToCity(city: JourneyCity): void {
|
||||
/**
|
||||
* The buildings this page can walk into.
|
||||
*
|
||||
* Two of them, and the second one is why this is a table rather than the single
|
||||
* Three of them, and the second one is why this is a table rather than the single
|
||||
* hardcoded `import("./offices/lumbridge-hq.ts")` it replaces. They are
|
||||
* deliberately unalike — a two-storey tower floor 188 m above Transbay, and a
|
||||
* hangar four metres above reclaimed ground at Alameda Point — because the
|
||||
* deliberately unalike — a tower above Transbay, a hangar at Alameda Point,
|
||||
* and an Arts District courtyard — because the
|
||||
* thing worth showing is that one engine and one format render both, and that
|
||||
* `OfficeSite` is what makes them feel like different places rather than the
|
||||
* same room with different furniture.
|
||||
@@ -220,10 +220,23 @@ const OFFICE_LOADERS: Readonly<Record<ShippedOfficeId, () => Promise<{ default:
|
||||
"mateo-court": () => import("./offices/mateo-court.ts"),
|
||||
};
|
||||
|
||||
const OFFICE_OPERATION_LOADERS: Readonly<Partial<Record<
|
||||
ShippedOfficeId,
|
||||
() => Promise<{ default: RobotOperationsDefinition }>
|
||||
>>> = {
|
||||
"lumbridge-hq": async () => ({
|
||||
default: (await import("./offices/operations/lumbridge-hq.ts")).LUMBRIDGE_HQ_ROBOT_OPERATIONS,
|
||||
}),
|
||||
"mateo-court": async () => ({
|
||||
default: (await import("./offices/operations/mateo-court.ts")).MATEO_COURT_ROBOT_OPERATIONS,
|
||||
}),
|
||||
};
|
||||
|
||||
const OFFICES = OFFICE_SITES.map((entry) => ({
|
||||
...entry,
|
||||
label: entry.name,
|
||||
load: OFFICE_LOADERS[entry.id],
|
||||
loadOperations: OFFICE_OPERATION_LOADERS[entry.id],
|
||||
}));
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
||||
@@ -1210,7 +1223,7 @@ async function enterOffice() {
|
||||
// it did not arrive at all. Either way there is no room to walk into and
|
||||
// `loadOffice` has already said so on the button.
|
||||
if (!built || !city) return;
|
||||
const { createOfficeScene, createOfficeMinimap, pack, materials } = built;
|
||||
const { createOfficeScene, createOfficeMinimap, pack, materials, robotOperations } = built;
|
||||
const depth = access.can.officeDepth;
|
||||
// Keep the remote-avatar renderer behind the authenticated boundary. The
|
||||
// public Office door can build its full local scene without downloading it.
|
||||
@@ -1270,10 +1283,9 @@ async function enterOffice() {
|
||||
...(pack.site ? {} : { background: 0x11161c }),
|
||||
...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
|
||||
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
|
||||
// Environment-authored activity, not inferred headcount. These stable
|
||||
// specs keep active HQs intentionally sparse and give the robot-jobs
|
||||
// runtime ids, seeds and job sets it can adopt without a migration.
|
||||
robots: activityRobotsForOffice(pack),
|
||||
// Only offices with explicit, validated simulated operations get robots.
|
||||
// No geometry-derived random errands and no implication of live work.
|
||||
...(robotOperations ? { robotOperations } : {}),
|
||||
depth,
|
||||
materials,
|
||||
// Ignored entirely at `"public"` depth, where no layer is built to colour.
|
||||
@@ -1446,17 +1458,16 @@ function disposeLoadedOffice(): void {
|
||||
* the interior, the furniture catalogue, the material registry and the
|
||||
* floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be
|
||||
* downloaded, parsed and executed on every load of a map page by people who
|
||||
* came to look at a city. Behind these three `await import()`s Vite gives them
|
||||
* came to look at a city. Behind these lazy imports Vite gives them
|
||||
* chunks of their own and the door fetches them on the way through. Measured,
|
||||
* entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after —
|
||||
* the difference is smaller than the chunks because three.js is shared and
|
||||
* stays where it was.
|
||||
*
|
||||
* All three in one `Promise.all` because they are one arrival: the pack without
|
||||
* All modules in one `Promise.all` because they are one arrival: the pack without
|
||||
* the builder is a data file nobody can draw, so the fetches overlap rather
|
||||
* than queue. Rollup happens to emit them as three chunks the browser asks for
|
||||
* together; awaiting them in sequence would make that three round trips on a
|
||||
* slow link for no reason at all.
|
||||
* than queue. Rollup emits chunks the browser asks for together; awaiting them
|
||||
* in sequence would add avoidable round trips on a slow link.
|
||||
*
|
||||
* There is deliberately no retry and no cache-busting. A failed chunk fetch is
|
||||
* a deploy that moved the file under an open tab; the honest answer is to say
|
||||
@@ -1468,20 +1479,22 @@ async function loadOffice(): Promise<{
|
||||
createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap;
|
||||
pack: Office;
|
||||
materials: MaterialRegistry;
|
||||
robotOperations: RobotOperationsDefinition | null;
|
||||
} | null> {
|
||||
try {
|
||||
// A fourth import and still one arrival. The plan renderer reads prop
|
||||
// More modules and still one arrival. The plan renderer reads prop
|
||||
// footprints off the asset registry, so it is already downstream of the
|
||||
// furniture catalogue this chunk exists to hold back — asking for it here
|
||||
// costs nothing beyond the module itself, and asking for it anywhere else
|
||||
// would cost the whole catalogue in the entry chunk.
|
||||
const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
|
||||
if (!entry) return null;
|
||||
const [interiors, pack, assets, plan] = await Promise.all([
|
||||
const [interiors, pack, assets, plan, operations] = await Promise.all([
|
||||
import("./interiors/officeScene.ts"),
|
||||
entry.load(),
|
||||
import("./assets/materials.ts"),
|
||||
import("./engine/officeMinimap.ts"),
|
||||
entry.loadOperations?.() ?? Promise.resolve(null),
|
||||
]);
|
||||
// Assigned rather than memoised with `??=`: the memo was what made this
|
||||
// single-office forever, quietly serving the first pack fetched for every
|
||||
@@ -1493,6 +1506,7 @@ async function loadOffice(): Promise<{
|
||||
createOfficeMinimap: plan.createOfficeMinimap,
|
||||
pack: officePack,
|
||||
materials: officeMaterials,
|
||||
robotOperations: operations?.default ?? null,
|
||||
};
|
||||
} catch {
|
||||
showDetail("The office did not load. Check the connection and try the door again.");
|
||||
@@ -1953,18 +1967,23 @@ function renderOfficeBadge() {
|
||||
const mediaSurfaces = inside && office !== null && office.depth === "full"
|
||||
? office.listMediaSurfaces()
|
||||
: [];
|
||||
const robotActivity = inside && office !== null ? office.robotActivityInfo() : null;
|
||||
// The fabricated-occupancy caption used to be here too and is now on
|
||||
// `#source` — see `renderSource`. This badge keeps the message that is a call
|
||||
// to action rather than a disclosure, because that one belongs beside the
|
||||
// office controls and survives being missed; the other one does not.
|
||||
officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0;
|
||||
officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0 && robotActivity === null;
|
||||
if (!publicOffice) {
|
||||
if (mediaSurfaces.length === 0) return;
|
||||
if (mediaSurfaces.length === 0) {
|
||||
if (robotActivity) officeBadge.textContent = robotActivity.disclosure;
|
||||
return;
|
||||
}
|
||||
const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
|
||||
const active = mediaSurfaces.filter((surface) => surface.bound).length;
|
||||
officeBadge.textContent = active > 0
|
||||
const media = active > 0
|
||||
? `${active} of ${mediaSurfaces.length} ${noun} active · stop control in Office screens.`
|
||||
: `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
|
||||
officeBadge.textContent = robotActivity ? `${media} ${robotActivity.disclosure}` : media;
|
||||
return;
|
||||
}
|
||||
officeBadge.replaceChildren(
|
||||
@@ -1978,6 +1997,7 @@ function renderOfficeBadge() {
|
||||
} else {
|
||||
officeBadge.append(document.createTextNode("Sign in to see who's in."));
|
||||
}
|
||||
if (robotActivity) officeBadge.append(document.createTextNode(` ${robotActivity.disclosure}`));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
OFFICE_NAV_INACTION,
|
||||
OFFICE_NAV_SCENARIOS,
|
||||
OFFICE_JOBS_INACTION,
|
||||
OFFICE_JOBS_SCENARIOS,
|
||||
CaliforniaFlightEnvironment,
|
||||
CrowNavEnvironment,
|
||||
Drive101Environment,
|
||||
OfficeNavEnvironment,
|
||||
OfficeJobsEnvironment,
|
||||
arenaChecksum,
|
||||
californiaFlightScriptedBaseline,
|
||||
crowNavScriptedBaseline,
|
||||
driveScriptedBaseline,
|
||||
officeNavScriptedBaseline,
|
||||
officeJobsScriptedBaseline,
|
||||
type ArenaEnvironment,
|
||||
type ArenaManifest,
|
||||
type ArenaScenarioRegistry,
|
||||
@@ -36,6 +40,7 @@ interface EnvironmentCase {
|
||||
registry: ArenaScenarioRegistry<object>;
|
||||
inaction: unknown;
|
||||
scripted(observation: unknown): unknown;
|
||||
successReason: string;
|
||||
}
|
||||
|
||||
const CASES: EnvironmentCase[] = [
|
||||
@@ -45,6 +50,7 @@ const CASES: EnvironmentCase[] = [
|
||||
registry: DRIVE_101_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||
inaction: DRIVE_INACTION,
|
||||
scripted: () => driveScriptedBaseline(),
|
||||
successReason: "goal",
|
||||
},
|
||||
{
|
||||
name: "office",
|
||||
@@ -54,6 +60,17 @@ const CASES: EnvironmentCase[] = [
|
||||
scripted: (observation) => officeNavScriptedBaseline(
|
||||
observation as Parameters<typeof officeNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
},
|
||||
{
|
||||
name: "office-jobs",
|
||||
create: () => new OfficeJobsEnvironment() as AnyEnvironment,
|
||||
registry: OFFICE_JOBS_SCENARIOS as ArenaScenarioRegistry<object>,
|
||||
inaction: OFFICE_JOBS_INACTION,
|
||||
scripted: (observation) => officeJobsScriptedBaseline(
|
||||
observation as Parameters<typeof officeJobsScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "job-complete",
|
||||
},
|
||||
{
|
||||
name: "crow",
|
||||
@@ -63,6 +80,7 @@ const CASES: EnvironmentCase[] = [
|
||||
scripted: (observation) => crowNavScriptedBaseline(
|
||||
observation as Parameters<typeof crowNavScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
},
|
||||
{
|
||||
name: "flight",
|
||||
@@ -72,6 +90,7 @@ const CASES: EnvironmentCase[] = [
|
||||
scripted: (observation) => californiaFlightScriptedBaseline(
|
||||
observation as Parameters<typeof californiaFlightScriptedBaseline>[0],
|
||||
),
|
||||
successReason: "goal",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -96,9 +115,9 @@ function run(
|
||||
}
|
||||
|
||||
describe("arena contract and manifests", () => {
|
||||
it("exports four versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
it("exports five versioned renderer-independent manifests with disjoint public splits", () => {
|
||||
assert.deepEqual(ARENA_MANIFESTS.map((manifest: ArenaManifest) => manifest.id), [
|
||||
"drive-101-v1", "office-nav-v1", "crow-nav-v1", "california-flight-v1",
|
||||
"drive-101-v1", "office-nav-v1", "office-jobs-v1", "crow-nav-v1", "california-flight-v1",
|
||||
]);
|
||||
for (const manifest of ARENA_MANIFESTS) {
|
||||
assert.equal(manifest.apiVersion, ARENA_API_VERSION);
|
||||
@@ -158,7 +177,7 @@ describe("arena contract and manifests", () => {
|
||||
const scripted = run(entry, id, 5, entry.scripted).final;
|
||||
assert.equal(scripted.terminated, true, entry.name);
|
||||
assert.equal(scripted.truncated, false, entry.name);
|
||||
assert.equal(scripted.info.terminalReason, "goal", entry.name);
|
||||
assert.equal(scripted.info.terminalReason, entry.successReason, entry.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -170,12 +189,12 @@ describe("arena baseline proofs", () => {
|
||||
for (const seed of [1, 0xdecafbad]) {
|
||||
const idle = run(entry, definition.id, seed, () => entry.inaction);
|
||||
assert.ok(idle.total < 0, `${entry.name}/${definition.id}/${seed} inaction=${idle.total}`);
|
||||
assert.notEqual(idle.final.info.terminalReason, "goal");
|
||||
assert.notEqual(idle.final.info.terminalReason, entry.successReason);
|
||||
|
||||
const scripted = run(entry, definition.id, seed, entry.scripted);
|
||||
assert.equal(
|
||||
scripted.final.info.terminalReason,
|
||||
"goal",
|
||||
entry.successReason,
|
||||
`${entry.name}/${definition.id}/${seed}`,
|
||||
);
|
||||
assert.ok(scripted.total > 0, `${entry.name}/${definition.id}/${seed}=${scripted.total}`);
|
||||
@@ -206,6 +225,26 @@ describe("arena baseline proofs", () => {
|
||||
}
|
||||
assert.equal(officeReason, "collision-stall");
|
||||
|
||||
const officeJobs = new OfficeJobsEnvironment();
|
||||
officeJobs.reset(2, "train-la-directory-inspection");
|
||||
let officeJobsReason: string | null = null;
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const result = officeJobs.step({ x: 0, z: 0, interact: true });
|
||||
officeJobsReason = result.info.terminalReason;
|
||||
if (result.terminated) break;
|
||||
}
|
||||
assert.equal(officeJobsReason, "wrong-interaction-limit");
|
||||
|
||||
const collisionJobs = new OfficeJobsEnvironment();
|
||||
collisionJobs.reset(2, "dev-sf-parcel-delivery");
|
||||
let collisionJobsReason: string | null = null;
|
||||
for (let index = 0; index < 80; index += 1) {
|
||||
const result = collisionJobs.step({ x: 0, z: 1, interact: false });
|
||||
collisionJobsReason = result.info.terminalReason;
|
||||
if (result.terminated) break;
|
||||
}
|
||||
assert.equal(collisionJobsReason, "collision-stall");
|
||||
|
||||
const crow = new CrowNavEnvironment();
|
||||
crow.reset(2, "train-east-crosswind");
|
||||
let crowReason: string | null = null;
|
||||
@@ -229,6 +268,33 @@ describe("arena baseline proofs", () => {
|
||||
});
|
||||
|
||||
describe("arena snapshot, trace and replay", () => {
|
||||
it("restores and replays every SF/LA office-job scenario", () => {
|
||||
for (const definition of OFFICE_JOBS_SCENARIOS.definitions) {
|
||||
const environment = new OfficeJobsEnvironment();
|
||||
let observation = environment.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
observation = environment.step(officeJobsScriptedBaseline(observation)).observation;
|
||||
}
|
||||
const checkpoint = environment.snapshot();
|
||||
const action = officeJobsScriptedBaseline(observation);
|
||||
const expected = environment.step(action);
|
||||
environment.restore(checkpoint);
|
||||
assert.deepEqual(environment.step(action), expected, definition.id);
|
||||
|
||||
const traced = new OfficeJobsEnvironment();
|
||||
observation = traced.reset(311, definition.id).observation;
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
const result = traced.step(officeJobsScriptedBaseline(observation));
|
||||
observation = result.observation;
|
||||
if (result.terminated) break;
|
||||
}
|
||||
const trace = traced.trace();
|
||||
const replay = new OfficeJobsEnvironment().replay(trace);
|
||||
assert.equal(replay.finalStateChecksum, trace.finalStateChecksum, definition.id);
|
||||
assert.equal(replay.cumulativeReward, trace.cumulativeReward, definition.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("restores each simulator bit-for-bit and preserves the next transition", () => {
|
||||
for (const entry of CASES) {
|
||||
const environment = entry.create();
|
||||
@@ -301,6 +367,17 @@ describe("arena snapshot, trace and replay", () => {
|
||||
}),
|
||||
/frame 1 is invalid/,
|
||||
);
|
||||
|
||||
const jobs = new OfficeJobsEnvironment();
|
||||
jobs.reset(17, "train-sf-display-inspection");
|
||||
const jobsSnapshot = jobs.snapshot();
|
||||
const invalidSimulation = structuredClone(jobsSnapshot.simulation);
|
||||
invalidSimulation.activity.robots[0]!.battery = 9;
|
||||
const { checksum: _jobsChecksum, ...jobsCore } = { ...jobsSnapshot, simulation: invalidSimulation };
|
||||
assert.throws(
|
||||
() => jobs.restore({ ...jobsCore, checksum: arenaChecksum(jobsCore) }),
|
||||
/invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses post-terminal stepping until reset", () => {
|
||||
|
||||
@@ -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