feat: add deterministic office robot jobs
This commit is contained in:
@@ -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
Reference in New Issue
Block a user