1
0

feat: add deterministic office robot jobs

This commit is contained in:
2026-08-19 02:54:49 -07:00
parent 0557a26e6b
commit e378a03740
18 changed files with 1937 additions and 1779 deletions
+14 -14
View File
@@ -96,9 +96,9 @@ import {
import {
createRobotLayer,
type RobotLayer,
type RobotSpec,
type RobotView,
} from "./robots.ts";
import type { RobotOperationsDefinition } from "./robotOperations.ts";
import type {
ScenePeers,
ScenePeersOptions,
@@ -220,17 +220,8 @@ export interface OfficeSceneOptions {
* those are the three things you actually feel.
*/
horizon?: { drop: number };
/**
* Humanoids to walk about the floor, one entry per robot.
*
* **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.**
* The build-time-exclusion rule in this file's header is about *occupancy* — a
* `Presence` names a person and comes from an authenticated API, so the public
* office must not construct one. A robot is nobody: it carries no id anybody
* issued, no seat binding, and no data from anywhere. There is nothing to
* withhold, so a stranger gets them too.
*/
robots?: readonly RobotSpec[];
/** Authored seeded simulation; omitted means this office has no robot activity. */
robotOperations?: RobotOperationsDefinition;
/** Optional local walk actor. Constructed inactive unless `walker.active` says otherwise. */
walker?: OfficeWalkerOptions;
/** Full-depth-only authoritative remote actors/vehicles in local metre coordinates. */
@@ -319,6 +310,8 @@ export interface OfficeScene extends StageScene {
* The plan panel and the ceiling lights both consume it that way.
*/
robots(): readonly RobotView[];
/** Honest provenance for UI and diagnostics; null when no simulation is authored. */
robotActivityInfo(): { operationsId: string; disclosure: string; simulated: true } | null;
upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
removeRemoteEntity(id: string): boolean;
clearRemoteEntities(): void;
@@ -521,8 +514,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
const robots: RobotLayer | null =
options.robots && options.robots.length > 0
? createRobotLayer(plan, { materials, robots: options.robots })
options.robotOperations && options.robotOperations.robots.length > 0
? createRobotLayer(plan, { materials, operations: options.robotOperations })
: null;
const officeWalker = options.walker ? createOfficeWalker(plan, options.walker) : null;
if (officeWalker) scene.add(officeWalker.root);
@@ -856,6 +849,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
},
houseLevel: () => luminaires.houseLevel(),
robots: () => robots?.robots() ?? NO_ROBOTS,
robotActivityInfo: () => robots
? {
operationsId: options.robotOperations!.id,
disclosure: robots.disclosure,
simulated: true,
}
: null,
setWalkers(walkers) {
luminaires.setWalkers(walkers);
},
+636
View File
@@ -0,0 +1,636 @@
import { createWalker, normalizeWalkerAction, type WalkerController } from "./walker.ts";
import type { Plan } from "./plan.ts";
import {
type ResolvedRobotOperations,
type RobotActivityMode,
type RobotJobKind,
type RobotSpawnDefinition,
} from "./robotOperations.ts";
import { createRobotRouter, type RobotRouter } from "./robotRoutes.ts";
import type { Point2 } from "./types.ts";
export const ROBOT_ACTIVITY_SCHEMA_VERSION = 1;
export const ROBOT_ACTIVITY_FIXED_STEP_SECONDS = 0.1;
const ROBOT_SPEED_MPS = 1.05;
const ROBOT_RADIUS_M = 0.28;
const ARRIVAL_RADIUS_M = 0.3;
const WAYPOINT_RADIUS_M = 0.035;
const BLOCKED_TICKS = 10;
const RECOVERY_TICKS = 6;
const MAX_RECOVERIES = 4;
const MOVE_BATTERY_PER_M = 0.00055;
const IDLE_BATTERY_PER_TICK = 0.000004;
const CHARGE_PER_TICK = 0.018;
const CHARGE_TARGET = 0.96;
const LOW_BATTERY = 0.16;
export interface RobotActivityAction {
x: number;
z: number;
interact: boolean;
}
export type RobotPayload = "parcel" | null;
export interface RobotActivityState {
id: string;
label: string;
levelId: string;
position: Point2;
facing: Point2;
mode: RobotActivityMode;
phase: string;
activeJobId: string | null;
activeJobKind: RobotJobKind | null;
activeStationIds: string[];
jobSequence: number;
scheduleCursor: number;
stationIndex: number;
route: Point2[];
routeIndex: number;
battery: number;
payload: RobotPayload;
progress: number;
objectiveTicks: number;
blockedTicks: number;
recoveryTicks: number;
recoveryCount: number;
idleTicks: number;
completedJobs: number;
travelledM: number;
terminalReason: "battery-depleted" | "blocked-unrecoverable" | null;
/** This is always true so snapshots cannot be mistaken for live operations. */
simulated: true;
}
export interface RobotActivitySnapshot {
schemaVersion: typeof ROBOT_ACTIVITY_SCHEMA_VERSION;
operationsId: string;
operationsVersion: number;
seed: number;
tick: number;
accumulatorSeconds: number;
robots: RobotActivityState[];
}
export interface RobotActivityTrace {
schemaVersion: typeof ROBOT_ACTIVITY_SCHEMA_VERSION;
initial: RobotActivitySnapshot;
actions: Array<Record<string, RobotActivityAction>>;
final: RobotActivitySnapshot;
}
export interface RobotActivityOptions {
seed?: number;
/** Omit to instantiate every robot authored by the operations definition. */
robotIds?: readonly string[];
/** These robots require explicit movement and interaction actions. */
controlledRobotIds?: readonly string[];
/** Arena/eval hook: begin these robots on an explicit authored job. */
initialJobIds?: Readonly<Record<string, string>>;
}
export interface RobotActivityController {
readonly fixedStepSeconds: number;
readonly disclosure: string;
states(): readonly RobotActivityState[];
step(actions?: Readonly<Record<string, RobotActivityAction>>): RobotActivitySnapshot;
tick(elapsedSeconds: number, actions?: Readonly<Record<string, RobotActivityAction>>): RobotActivitySnapshot;
snapshot(): RobotActivitySnapshot;
restore(snapshot: RobotActivitySnapshot): RobotActivitySnapshot;
trace(): RobotActivityTrace;
replay(trace: RobotActivityTrace): RobotActivitySnapshot;
}
interface RuntimeRobot {
definition: RobotSpawnDefinition;
controlled: boolean;
walker: WalkerController;
state: RobotActivityState;
}
/** Shared renderer-independent authority for browser robots and Arena episodes. */
export function createRobotActivity(
plan: Plan,
operations: ResolvedRobotOperations,
options: RobotActivityOptions = {},
): RobotActivityController {
const seed = normalizeSeed(options.seed ?? 0x54455241);
const selectedIds = options.robotIds ? new Set(options.robotIds) : null;
const controlledIds = new Set(options.controlledRobotIds ?? []);
const router = createRobotRouter(plan);
const runtimes: RuntimeRobot[] = [];
for (const definition of operations.robots.values()) {
if (selectedIds && !selectedIds.has(definition.id)) continue;
const station = operations.stations.get(definition.spawnStationId)!;
const walker = createWalker(plan, {
levelId: definition.levelId,
position: station.position,
facing: station.facing,
radius: ROBOT_RADIUS_M,
speed: ROBOT_SPEED_MPS,
fixedStep: ROBOT_ACTIVITY_FIXED_STEP_SECONDS,
maxCatchUpSteps: 1,
});
const offset = hash(seed, definition.id, "schedule") % definition.schedule.length;
const idleTicks = 2 + hash(seed, definition.id, "stagger") % 12;
const walkerState = walker.state();
runtimes.push({
definition,
controlled: controlledIds.has(definition.id),
walker,
state: {
id: definition.id,
label: definition.label,
levelId: definition.levelId,
position: copy(walkerState.position),
facing: copy(walkerState.facing),
mode: "idle",
phase: "scheduled-idle",
activeJobId: null,
activeJobKind: null,
activeStationIds: [],
jobSequence: 0,
scheduleCursor: offset,
stationIndex: 0,
route: [],
routeIndex: 0,
battery: definition.initialBattery,
payload: null,
progress: 0,
objectiveTicks: 0,
blockedTicks: 0,
recoveryTicks: 0,
recoveryCount: 0,
idleTicks,
completedJobs: 0,
travelledM: 0,
terminalReason: null,
simulated: true,
},
});
const initialJobId = options.initialJobIds?.[definition.id];
if (initialJobId) {
const initialJob = operations.jobs.get(initialJobId);
if (!initialJob || !definition.schedule.includes(initialJobId)) {
throw new Error(`robot ${definition.id} cannot start unknown or unscheduled job ${initialJobId}`);
}
assign(runtimes[runtimes.length - 1]!.state, initialJob.id, initialJob.kind, initialJob.stationIds);
}
}
if (selectedIds) {
for (const id of selectedIds) {
if (!runtimes.some((runtime) => runtime.definition.id === id)) {
throw new Error(`unknown robot ${id}`);
}
}
}
for (const id of controlledIds) {
if (!runtimes.some((runtime) => runtime.definition.id === id)) {
throw new Error(`controlled robot ${id} was not instantiated`);
}
}
runtimes.sort((a, b) => a.definition.id.localeCompare(b.definition.id));
let tickIndex = 0;
let accumulatorSeconds = 0;
let initialSnapshot: RobotActivitySnapshot;
let traceActions: Array<Record<string, RobotActivityAction>> = [];
function step(rawActions: Readonly<Record<string, RobotActivityAction>> = {}): RobotActivitySnapshot {
const actions = normalizeActions(runtimes, rawActions);
for (const runtime of runtimes) advanceRobot(runtime, actions[runtime.definition.id], router, operations, seed);
tickIndex += 1;
traceActions.push(structuredClone(actions));
return snapshot();
}
function tick(
elapsedSeconds: number,
actions: Readonly<Record<string, RobotActivityAction>> = {},
): RobotActivitySnapshot {
if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();
accumulatorSeconds = Math.min(
ROBOT_ACTIVITY_FIXED_STEP_SECONDS * 8,
accumulatorSeconds + elapsedSeconds,
);
let steps = 0;
while (accumulatorSeconds + 1e-9 >= ROBOT_ACTIVITY_FIXED_STEP_SECONDS && steps < 8) {
accumulatorSeconds -= ROBOT_ACTIVITY_FIXED_STEP_SECONDS;
if (accumulatorSeconds < 0) accumulatorSeconds = 0;
step(actions);
steps += 1;
}
return snapshot();
}
function snapshot(): RobotActivitySnapshot {
return {
schemaVersion: ROBOT_ACTIVITY_SCHEMA_VERSION,
operationsId: operations.definition.id,
operationsVersion: operations.definition.version,
seed,
tick: tickIndex,
accumulatorSeconds,
robots: runtimes.map((runtime) => cloneState(runtime.state)),
};
}
function restore(next: RobotActivitySnapshot): RobotActivitySnapshot {
assertSnapshot(next, operations, seed, runtimes);
tickIndex = next.tick;
accumulatorSeconds = next.accumulatorSeconds;
for (const runtime of runtimes) {
const state = next.robots.find((candidate) => candidate.id === runtime.definition.id)!;
runtime.walker.restore({
levelId: state.levelId,
position: state.position,
facing: state.facing,
distance: state.travelledM,
});
runtime.state = cloneState(state);
}
initialSnapshot = snapshot();
traceActions = [];
return snapshot();
}
function trace(): RobotActivityTrace {
return {
schemaVersion: ROBOT_ACTIVITY_SCHEMA_VERSION,
initial: structuredClone(initialSnapshot),
actions: structuredClone(traceActions),
final: snapshot(),
};
}
function replay(traceEnvelope: RobotActivityTrace): RobotActivitySnapshot {
if (traceEnvelope.schemaVersion !== ROBOT_ACTIVITY_SCHEMA_VERSION || !Array.isArray(traceEnvelope.actions)) {
throw new Error("robot activity trace is invalid");
}
restore(structuredClone(traceEnvelope.initial));
for (const actions of traceEnvelope.actions) step(actions);
const actual = snapshot();
if (canonical(actual) !== canonical(traceEnvelope.final)) {
throw new Error("robot activity trace diverged");
}
return actual;
}
initialSnapshot = snapshot();
return {
fixedStepSeconds: ROBOT_ACTIVITY_FIXED_STEP_SECONDS,
disclosure: operations.definition.disclosure,
states: () => snapshot().robots,
step,
tick,
snapshot,
restore,
trace,
replay,
};
}
function advanceRobot(
runtime: RuntimeRobot,
action: RobotActivityAction | undefined,
router: RobotRouter,
operations: ResolvedRobotOperations,
seed: number,
): void {
const state = runtime.state;
if (state.terminalReason) return;
state.battery = clamp01(state.battery - IDLE_BATTERY_PER_TICK);
if (state.battery <= 0) {
state.battery = 0;
state.mode = "idle";
state.phase = "terminal";
state.terminalReason = "battery-depleted";
return;
}
if (state.mode === "blocked-recovery") {
recover(runtime, seed);
return;
}
if (!state.activeJobId) {
if (state.idleTicks > 0) {
state.idleTicks -= 1;
return;
}
const charge = nearestCharge(operations, state.levelId);
if (state.battery <= LOW_BATTERY && charge) {
assign(state, `$battery:${charge.id}`, "charge", [charge.id]);
} else {
const jobId = runtime.definition.schedule[state.scheduleCursor]!;
const job = operations.jobs.get(jobId)!;
assign(state, job.id, job.kind, job.stationIds);
state.scheduleCursor = (state.scheduleCursor + 1) % runtime.definition.schedule.length;
}
}
const stationId = state.activeStationIds[state.stationIndex];
const station = stationId ? operations.stations.get(stationId) : undefined;
if (!station || station.levelId !== state.levelId) {
state.mode = "idle";
state.phase = "terminal";
state.terminalReason = "blocked-unrecoverable";
return;
}
const distanceToStation = distance(state.position, station.position);
if (distanceToStation <= ARRIVAL_RADIUS_M) {
state.route = [];
state.routeIndex = 0;
state.blockedTicks = 0;
const walker = runtime.walker.state();
runtime.walker.restore({ ...walker, facing: station.facing });
syncWalker(runtime);
const canWork = !runtime.controlled || action?.interact === true;
if (state.activeJobKind === "charge" && canWork) {
state.phase = "charging";
state.objectiveTicks += 1;
state.battery = clamp01(state.battery + CHARGE_PER_TICK);
updateProgress(state);
if (state.battery >= CHARGE_TARGET) completeJob(state, runtime.definition, seed);
return;
}
if (!canWork) {
state.phase = "awaiting-interaction";
return;
}
state.phase = activityPhase(state.activeJobKind, state.stationIndex);
state.objectiveTicks += 1;
updateProgress(state);
const job = operations.jobs.get(state.activeJobId!);
const dwellTicks = job?.dwellTicks ?? 8;
if (state.objectiveTicks >= dwellTicks) completeObjective(state, runtime.definition, seed);
return;
}
state.objectiveTicks = 0;
state.phase = navigationPhase(state.activeJobKind, state.stationIndex);
if (state.route.length === 0 || state.routeIndex >= state.route.length) {
const route = router.route(state.levelId, state.position, station.position);
if (!route) {
enterRecovery(state);
return;
}
state.route = route.waypoints.map(copy);
state.routeIndex = 0;
}
while (
state.routeIndex < state.route.length - 1 &&
distance(state.position, state.route[state.routeIndex]!) <= WAYPOINT_RADIUS_M
) state.routeIndex += 1;
const waypoint = state.route[state.routeIndex] ?? station.position;
const autonomous = movementToward(state.position, waypoint);
const movement = runtime.controlled
? normalizeWalkerAction(action ?? { x: 0, z: 0 })
: autonomous;
const demand = Math.hypot(movement.x, movement.z);
const before = runtime.walker.state();
const after = runtime.walker.tick(ROBOT_ACTIVITY_FIXED_STEP_SECONDS, movement);
const moved = distance(before.position, after.position);
state.battery = clamp01(state.battery - moved * MOVE_BATTERY_PER_M);
syncWalker(runtime);
state.blockedTicks = demand > 0.2 && moved < ROBOT_SPEED_MPS * ROBOT_ACTIVITY_FIXED_STEP_SECONDS * 0.12
? state.blockedTicks + 1
: 0;
if (state.blockedTicks >= BLOCKED_TICKS) enterRecovery(state);
updateProgress(state);
}
function recover(runtime: RuntimeRobot, seed: number): void {
const state = runtime.state;
state.phase = "backoff-and-replan";
const angle = (hash(seed, state.id, state.recoveryCount) / 0xffff_ffff) * Math.PI * 2;
const action = { x: Math.sin(angle), z: Math.cos(angle) };
runtime.walker.tick(ROBOT_ACTIVITY_FIXED_STEP_SECONDS, action);
syncWalker(runtime);
state.recoveryTicks -= 1;
if (state.recoveryTicks > 0) return;
if (state.recoveryCount >= MAX_RECOVERIES) {
state.mode = "idle";
state.phase = "terminal";
state.terminalReason = "blocked-unrecoverable";
return;
}
state.mode = state.activeJobKind ?? "idle";
state.phase = "replanning";
state.route = [];
state.routeIndex = 0;
state.blockedTicks = 0;
}
function enterRecovery(state: RobotActivityState): void {
state.mode = "blocked-recovery";
state.phase = "backoff-and-replan";
state.recoveryTicks = RECOVERY_TICKS;
state.recoveryCount += 1;
state.route = [];
state.routeIndex = 0;
state.blockedTicks = 0;
}
function assign(
state: RobotActivityState,
id: string,
kind: RobotJobKind,
stationIds: readonly string[],
): void {
state.activeJobId = id;
state.activeJobKind = kind;
state.activeStationIds = [...stationIds];
state.stationIndex = 0;
state.mode = kind;
state.phase = navigationPhase(kind, 0);
state.route = [];
state.routeIndex = 0;
state.objectiveTicks = 0;
state.recoveryCount = 0;
state.jobSequence += 1;
updateProgress(state);
}
function completeObjective(
state: RobotActivityState,
definition: RobotSpawnDefinition,
seed: number,
): void {
if (state.activeJobKind === "deliver" && state.stationIndex === 0) state.payload = "parcel";
if (state.activeJobKind === "deliver" && state.stationIndex === 1) state.payload = null;
state.objectiveTicks = 0;
state.stationIndex += 1;
state.route = [];
state.routeIndex = 0;
if (state.stationIndex >= state.activeStationIds.length) completeJob(state, definition, seed);
else {
state.phase = navigationPhase(state.activeJobKind, state.stationIndex);
updateProgress(state);
}
}
function completeJob(state: RobotActivityState, definition: RobotSpawnDefinition, seed: number): void {
state.activeJobId = null;
state.activeJobKind = null;
state.activeStationIds = [];
state.stationIndex = 0;
state.mode = "idle";
state.phase = "scheduled-idle";
state.progress = 1;
state.objectiveTicks = 0;
state.completedJobs += 1;
state.idleTicks = 3 + hash(seed, definition.id, state.jobSequence, "idle") % 8;
}
function updateProgress(state: RobotActivityState): void {
const count = Math.max(1, state.activeStationIds.length);
const local = state.activeJobKind === "charge"
? Math.min(1, state.battery / CHARGE_TARGET)
: Math.min(0.95, state.objectiveTicks / 20);
state.progress = Math.min(1, (state.stationIndex + local) / count);
}
function navigationPhase(kind: RobotJobKind | null, stationIndex: number): string {
if (kind === "deliver") return stationIndex === 0 ? "to-pickup" : "to-dropoff";
if (kind === "inspect") return "to-inspection";
if (kind === "charge") return "to-charge";
return "patrolling";
}
function activityPhase(kind: RobotJobKind | null, stationIndex: number): string {
if (kind === "deliver") return stationIndex === 0 ? "loading-parcel" : "delivering-parcel";
if (kind === "inspect") return "inspecting";
if (kind === "charge") return "charging";
return "patrol-check";
}
function nearestCharge(operations: ResolvedRobotOperations, levelId: string) {
return [...operations.stations.values()]
.filter((station) => station.levelId === levelId && station.role === "charge")
.sort((a, b) => a.id.localeCompare(b.id))[0];
}
function syncWalker(runtime: RuntimeRobot): void {
const walker = runtime.walker.state();
runtime.state.position = copy(walker.position);
runtime.state.facing = copy(walker.facing);
runtime.state.travelledM = walker.distance;
}
function normalizeActions(
runtimes: readonly RuntimeRobot[],
actions: Readonly<Record<string, RobotActivityAction>>,
): Record<string, RobotActivityAction> {
const normalized: Record<string, RobotActivityAction> = {};
for (const runtime of runtimes) {
if (!runtime.controlled) continue;
const raw = actions[runtime.definition.id];
const movement = normalizeWalkerAction(raw ?? { x: 0, z: 0 });
normalized[runtime.definition.id] = {
x: movement.x,
z: movement.z,
interact: raw?.interact === true,
};
}
return normalized;
}
function assertSnapshot(
snapshot: RobotActivitySnapshot,
operations: ResolvedRobotOperations,
seed: number,
runtimes: readonly RuntimeRobot[],
): void {
if (
snapshot.schemaVersion !== ROBOT_ACTIVITY_SCHEMA_VERSION ||
snapshot.operationsId !== operations.definition.id ||
snapshot.operationsVersion !== operations.definition.version ||
snapshot.seed !== seed || !Number.isSafeInteger(snapshot.tick) || snapshot.tick < 0 ||
!(snapshot.accumulatorSeconds >= 0 && snapshot.accumulatorSeconds < ROBOT_ACTIVITY_FIXED_STEP_SECONDS + 1e-9) ||
!Array.isArray(snapshot.robots) || snapshot.robots.length !== runtimes.length
) throw new Error("robot activity snapshot is incompatible or invalid");
const expectedIds = runtimes.map((runtime) => runtime.definition.id).sort();
const actualIds = snapshot.robots.map((robot) => robot.id).sort();
if (canonical(expectedIds) !== canonical(actualIds)) throw new Error("robot activity snapshot robot set differs");
for (const robot of snapshot.robots) {
if (
!finite(robot.position) || !finite(robot.facing) || robot.levelId.length === 0 ||
!Number.isFinite(robot.battery) || robot.battery < 0 || robot.battery > 1 ||
!Number.isFinite(robot.progress) || robot.progress < 0 || robot.progress > 1 ||
!Number.isFinite(robot.travelledM) || robot.travelledM < 0 || robot.simulated !== true ||
!Number.isSafeInteger(robot.scheduleCursor) || !Number.isSafeInteger(robot.stationIndex) ||
!Number.isSafeInteger(robot.jobSequence) || !Number.isSafeInteger(robot.completedJobs) ||
!Array.isArray(robot.route) || robot.route.some((point) => !finite(point))
) throw new Error(`robot activity snapshot state for ${robot.id} is invalid`);
}
}
function cloneState(state: RobotActivityState): RobotActivityState {
return {
...state,
position: copy(state.position),
facing: copy(state.facing),
activeStationIds: [...state.activeStationIds],
route: state.route.map(copy),
};
}
function direction(from: Point2, to: Point2): Point2 {
const dx = to.x - from.x;
const dz = to.z - from.z;
const length = Math.hypot(dx, dz);
return length > 1e-9 ? { x: dx / length, z: dz / length } : { x: 0, z: 0 };
}
function movementToward(from: Point2, to: Point2): Point2 {
const heading = direction(from, to);
const magnitude = Math.min(
1,
distance(from, to) / (ROBOT_SPEED_MPS * ROBOT_ACTIVITY_FIXED_STEP_SECONDS),
);
return { x: heading.x * magnitude, z: heading.z * magnitude };
}
function distance(a: Point2, b: Point2): number {
return Math.hypot(a.x - b.x, a.z - b.z);
}
function finite(point: Point2): boolean {
return Number.isFinite(point.x) && Number.isFinite(point.z);
}
function copy(point: Point2): Point2 {
return { x: point.x, z: point.z };
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
function normalizeSeed(value: number): number {
return Number.isFinite(value) ? Math.trunc(value) >>> 0 : 0;
}
function hash(seed: number, ...parts: Array<string | number>): number {
let result = (0x811c9dc5 ^ seed) >>> 0;
const text = parts.join("\u0000");
for (let index = 0; index < text.length; index += 1) {
result ^= text.charCodeAt(index);
result = Math.imul(result, 0x01000193) >>> 0;
}
return result;
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(
([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`,
).join(",")}}`;
}
return JSON.stringify(value);
}
+204
View File
@@ -0,0 +1,204 @@
import type { Plan } from "./plan.ts";
import type { Point2 } from "./types.ts";
export type RobotJobKind = "patrol" | "deliver" | "inspect" | "charge";
export type RobotActivityMode = RobotJobKind | "idle" | "blocked-recovery";
export type RobotStationAnchor =
| { kind: "prop"; propId: string; standoffM?: number; side?: 1 | -1 }
| { kind: "seat"; seatId: string; standoffM?: number }
| { kind: "room"; roomId: string }
| { kind: "point"; levelId: string; position: Point2; facing?: Point2 };
export interface RobotStationDefinition {
id: string;
label: string;
role: "patrol" | "pickup" | "dropoff" | "inspect" | "charge";
anchor: RobotStationAnchor;
}
export interface RobotJobDefinition {
id: string;
label: string;
kind: RobotJobKind;
stationIds: readonly string[];
/** Fixed simulation ticks spent performing work at each station. */
dwellTicks: number;
}
export interface RobotSpawnDefinition {
id: string;
label: string;
levelId: string;
spawnStationId: string;
schedule: readonly string[];
initialBattery: number;
}
/**
* Authored simulated operations for one office. This is demonstration data,
* never presence data and never a representation of real people or real work.
*/
export interface RobotOperationsDefinition {
id: string;
version: number;
officeId: string;
disclosure: string;
stations: readonly RobotStationDefinition[];
jobs: readonly RobotJobDefinition[];
robots: readonly RobotSpawnDefinition[];
}
export interface ResolvedRobotStation extends RobotStationDefinition {
levelId: string;
position: Point2;
facing: Point2;
}
export interface ResolvedRobotOperations {
definition: RobotOperationsDefinition;
stations: ReadonlyMap<string, ResolvedRobotStation>;
jobs: ReadonlyMap<string, RobotJobDefinition>;
robots: ReadonlyMap<string, RobotSpawnDefinition>;
}
const ROBOT_RADIUS_M = 0.28;
const DEFAULT_PROP_STANDOFF_M = 0.9;
const DEFAULT_SEAT_STANDOFF_M = 0.65;
/** Resolve authored addresses and reject ambiguous, cross-floor, or obstructed work. */
export function resolveRobotOperations(
plan: Plan,
definition: RobotOperationsDefinition,
): ResolvedRobotOperations {
if (definition.officeId !== plan.office.id) {
throw new Error(`robot operations ${definition.id} target ${definition.officeId}, not ${plan.office.id}`);
}
if (!Number.isSafeInteger(definition.version) || definition.version < 1) {
throw new Error("robot operations version must be a positive integer");
}
if (!definition.disclosure.toLowerCase().includes("simulat")) {
throw new Error("robot operations disclosure must identify the activity as simulated");
}
const stations = new Map<string, ResolvedRobotStation>();
for (const station of definition.stations) {
unique(stations, station.id, "station");
const resolved = resolveStation(plan, station);
if (!plan.level(resolved.levelId)) {
throw new Error(`robot station ${station.id} names unknown level ${resolved.levelId}`);
}
if (!plan.roomAt(resolved.levelId, resolved.position)) {
throw new Error(`robot station ${station.id} is not on a resolved room floor`);
}
if (plan.blocked(resolved.levelId, resolved.position, resolved.position, ROBOT_RADIUS_M)) {
throw new Error(`robot station ${station.id} has no robot clearance`);
}
stations.set(station.id, resolved);
}
const jobs = new Map<string, RobotJobDefinition>();
for (const job of definition.jobs) {
unique(jobs, job.id, "job");
if (!Number.isSafeInteger(job.dwellTicks) || job.dwellTicks < 1) {
throw new Error(`robot job ${job.id} dwellTicks must be a positive integer`);
}
const expected = job.kind === "deliver" ? 2 : job.kind === "patrol" ? 1 : 1;
if (job.stationIds.length < expected || (job.kind !== "patrol" && job.stationIds.length !== expected)) {
throw new Error(`robot job ${job.id} has the wrong number of stations for ${job.kind}`);
}
const levels = new Set<string>();
for (const stationId of job.stationIds) {
const station = stations.get(stationId);
if (!station) throw new Error(`robot job ${job.id} names unknown station ${stationId}`);
levels.add(station.levelId);
}
if (levels.size !== 1) throw new Error(`robot job ${job.id} crosses levels without an authored connector`);
jobs.set(job.id, Object.freeze({ ...job, stationIds: Object.freeze([...job.stationIds]) }));
}
const robots = new Map<string, RobotSpawnDefinition>();
for (const robot of definition.robots) {
unique(robots, robot.id, "robot");
if (!(robot.initialBattery > 0 && robot.initialBattery <= 1)) {
throw new Error(`robot ${robot.id} initialBattery must be in (0, 1]`);
}
const spawn = stations.get(robot.spawnStationId);
if (!spawn || spawn.levelId !== robot.levelId) {
throw new Error(`robot ${robot.id} spawn is missing or on a different level`);
}
if (robot.schedule.length === 0) throw new Error(`robot ${robot.id} has an empty schedule`);
for (const jobId of robot.schedule) {
const job = jobs.get(jobId);
if (!job) throw new Error(`robot ${robot.id} names unknown job ${jobId}`);
const station = stations.get(job.stationIds[0]!);
if (station?.levelId !== robot.levelId) {
throw new Error(`robot ${robot.id} schedule crosses levels at job ${jobId}`);
}
}
robots.set(robot.id, Object.freeze({ ...robot, schedule: Object.freeze([...robot.schedule]) }));
}
return { definition, stations, jobs, robots };
}
function resolveStation(plan: Plan, station: RobotStationDefinition): ResolvedRobotStation {
const anchor = station.anchor;
if (anchor.kind === "point") {
return {
...station,
levelId: anchor.levelId,
position: copy(anchor.position),
facing: normalized(anchor.facing ?? { x: 0, z: -1 }),
};
}
if (anchor.kind === "room") {
const room = plan.levels.flatMap((level) => level.rooms).find((candidate) => candidate.id === anchor.roomId);
if (!room) throw new Error(`robot station ${station.id} names unknown room ${anchor.roomId}`);
return { ...station, levelId: room.levelId, position: copy(room.centroid), facing: { x: 0, z: -1 } };
}
if (anchor.kind === "seat") {
const seat = plan.seat(anchor.seatId);
if (!seat) throw new Error(`robot station ${station.id} names unknown seat ${anchor.seatId}`);
const forward = yawForward(seat.facing);
const standoff = anchor.standoffM ?? DEFAULT_SEAT_STANDOFF_M;
return {
...station,
levelId: seat.levelId,
position: { x: seat.position.x - forward.x * standoff, z: seat.position.z - forward.z * standoff },
facing: forward,
};
}
const prop = plan.prop(anchor.propId);
if (!prop) throw new Error(`robot station ${station.id} names unknown prop ${anchor.propId}`);
const outward = yawForward(prop.rotation);
const side = anchor.side ?? 1;
const standoff = anchor.standoffM ?? DEFAULT_PROP_STANDOFF_M;
const position = {
x: prop.position.x + outward.x * side * standoff,
z: prop.position.z + outward.z * side * standoff,
};
return {
...station,
levelId: prop.levelId,
position,
facing: normalized({ x: prop.position.x - position.x, z: prop.position.z - position.z }),
};
}
function yawForward(yaw: number): Point2 {
return { x: -Math.sin(yaw), z: -Math.cos(yaw) };
}
function normalized(point: Point2): Point2 {
const length = Math.hypot(point.x, point.z);
return length > 1e-9 ? { x: point.x / length, z: point.z / length } : { x: 0, z: -1 };
}
function unique<T>(map: ReadonlyMap<string, T>, id: string, kind: string): void {
if (!id || map.has(id)) throw new Error(`robot ${kind} id ${JSON.stringify(id)} is empty or duplicated`);
}
function copy(point: Point2): Point2 {
return { x: point.x, z: point.z };
}
+128
View File
@@ -0,0 +1,128 @@
import type { Plan } from "./plan.ts";
import type { Point2 } from "./types.ts";
const ROUTE_RADIUS_M = 0.28;
const PORTAL_OFFSET_M = 0.48;
export interface RobotRoute {
levelId: string;
waypoints: Point2[];
lengthM: number;
}
export interface RobotRouter {
route(levelId: string, start: Point2, goal: Point2): RobotRoute | null;
}
interface Node {
id: string;
point: Point2;
}
/**
* Build a deterministic visibility graph from Plan's passable openings. The
* graph never links storeys: offices do not author lifts or stairs as routes.
*/
export function createRobotRouter(plan: Plan): RobotRouter {
const portals = new Map<string, readonly Node[]>();
for (const level of plan.levels) {
const nodes: Node[] = [];
for (const opening of level.openings) {
if (!opening.passable) continue;
const normal = { x: Math.sin(opening.yaw), z: Math.cos(opening.yaw) };
const candidates = [
opening.center,
{
x: opening.center.x + normal.x * PORTAL_OFFSET_M,
z: opening.center.z + normal.z * PORTAL_OFFSET_M,
},
{
x: opening.center.x - normal.x * PORTAL_OFFSET_M,
z: opening.center.z - normal.z * PORTAL_OFFSET_M,
},
];
candidates.forEach((point, index) => {
if (!plan.blocked(level.id, point, point, ROUTE_RADIUS_M)) {
nodes.push({ id: `${opening.id}:${index}`, point: copy(point) });
}
});
}
portals.set(level.id, Object.freeze(nodes.sort((a, b) => a.id.localeCompare(b.id))));
}
return {
route(levelId, start, goal) {
const level = plan.level(levelId);
if (!level || !finite(start) || !finite(goal)) return null;
if (!plan.roomAt(levelId, start) || !plan.roomAt(levelId, goal)) return null;
if (plan.blocked(levelId, start, start, ROUTE_RADIUS_M)) return null;
if (plan.blocked(levelId, goal, goal, ROUTE_RADIUS_M)) return null;
if (!plan.blocked(levelId, start, goal, ROUTE_RADIUS_M)) {
return { levelId, waypoints: [copy(goal)], lengthM: distance(start, goal) };
}
const nodes: Node[] = [
{ id: "$start", point: copy(start) },
...(portals.get(levelId) ?? []),
{ id: "$goal", point: copy(goal) },
];
const startIndex = 0;
const goalIndex = nodes.length - 1;
const costs = nodes.map(() => Number.POSITIVE_INFINITY);
const previous = nodes.map(() => -1);
const open = new Set<number>([startIndex]);
costs[startIndex] = 0;
while (open.size > 0) {
let current = -1;
for (const index of open) {
if (
current < 0 || costs[index]! < costs[current]! - 1e-9 ||
(Math.abs(costs[index]! - costs[current]!) <= 1e-9 && nodes[index]!.id < nodes[current]!.id)
) current = index;
}
if (current === goalIndex) break;
open.delete(current);
for (let next = 0; next < nodes.length; next += 1) {
if (next === current || next === startIndex) continue;
const from = nodes[current]!.point;
const to = nodes[next]!.point;
if (plan.blocked(levelId, from, to, ROUTE_RADIUS_M)) continue;
const candidate = costs[current]! + distance(from, to);
if (
candidate < costs[next]! - 1e-9 ||
(Math.abs(candidate - costs[next]!) <= 1e-9 && current < previous[next]!)
) {
costs[next] = candidate;
previous[next] = current;
open.add(next);
}
}
}
if (!Number.isFinite(costs[goalIndex])) return null;
const reversed: Point2[] = [];
for (let cursor = goalIndex; cursor !== startIndex; cursor = previous[cursor]!) {
if (cursor < 0) return null;
reversed.push(copy(nodes[cursor]!.point));
}
reversed.reverse();
return { levelId, waypoints: compact(reversed), lengthM: costs[goalIndex]! };
},
};
}
function compact(points: readonly Point2[]): Point2[] {
return points.filter((point, index) => index === points.length - 1 || distance(point, points[index + 1]!) > 0.08);
}
function finite(point: Point2): boolean {
return Number.isFinite(point.x) && Number.isFinite(point.z);
}
function distance(a: Point2, b: Point2): number {
return Math.hypot(a.x - b.x, a.z - b.z);
}
function copy(point: Point2): Point2 {
return { x: point.x, z: point.z };
}
+186 -1738
View File
File diff suppressed because it is too large Load Diff