/**
 * Optimus robots walking around the office.
 *
 * This is `presence.ts`'s noisy cousin and it is deliberately a different shape,
 * because it is solving a different problem. A presence is a person *at a seat*:
 * it has an id, it comes from an API, it never moves, and the whole design
 * effort went into making sure the geometry and the people stay on opposite
 * sides of a line. A robot is nobody. It has no id worth publishing, it comes
 * from nowhere, and it exists to make a still building look like a place where
 * something is happening. So there is no palette, no `colorKey`, no binding to
 * anything in the pack, and nothing here can leak: the only inputs are a `Plan`
 * and a list of levels.
 *
 * ### What it costs
 *
 * Four robots is the budget and roughly the right number — one is a mascot, ten
 * is a warehouse.
 *
 *   - **18 draw calls each, 72 for four.** This is the whole cost and it is not
 *     small; the office shell and its furniture together draw in about forty.
 *     The reason is in `assets/office/optimus.ts`: a figure that has to bend at
 *     eleven joints cannot be one merged mesh, so it is eighteen small merged
 *     meshes instead. If a floor needs the budget back, drop the count — the
 *     cost is exactly linear in it, and a pack that only wants a robot standing
 *     somewhere should place the `tera:robot.optimus` asset, which is two.
 *   - **One set of geometry, 7.1k triangles, however many robots there are.**
 *     `buildOptimus` runs once and every figure after the first is a
 *     `cloneOptimus`, which shares every buffer and both materials.
 *   - **About 20 µs per tick for the crowd**, measured over thirty simulated
 *     minutes on the reference office — roughly 0.1% of a 60 Hz frame. Most of a
 *     tick is `plan.blocked`, which is linear in the level's collision segments
 *     (fifty-four on that floor); a robot spends one or two calls a frame
 *     steering and up to fifteen on the frames where it is boxed in and fanning
 *     out. Nothing here is worth caching.
 *
 *     It used to be 24 µs, and errands made it *cheaper* rather than dearer.
 *     Choosing a destination with a reason costs one `blocked` call; choosing one
 *     at random cost up to forty-eight, because every candidate had to be tested
 *     for standing room and then again for line of sight. Knowing where you are
 *     going is less work than not.
 *   - **Picking a destination costs one `blocked` call** in the normal case and
 *     at most `SHORTLIST` of them, plus one `roomAt` — and only on the frame a
 *     robot arrives somewhere, which is every few seconds. The fallbacks are the
 *     old prices: up to two `blocked` calls on each of `PICK_ATTEMPTS` random
 *     candidates, then a pass over the doors. A robot that finds nowhere to go
 *     waits `RETRY_PAUSE` before trying again, so even one sealed into a cupboard
 *     costs a burst every second and a half rather than one every frame.
 *   - **An address book costs about 2.5 ms per level, once.** 224 places on the
 *     reference ground floor, each checked for standing room and a run-in, built
 *     the first time a robot on that level picks a destination and never again.
 *     It is deliberately not built at construction: a level with no robots on it
 *     never needs one, and a hitch during the load is a hitch nobody attributes
 *     correctly.
 *
 * ### Errands: where a robot goes, and why it is there
 *
 * The difference between a robot that is working and a robot that is patrolling
 * is not the walk. It is the destination and the arrival.
 *
 * This used to pick a uniformly random reachable point, walk to it, stop at
 * whatever angle it happened to be facing, wait between 1.4 and 4.6 seconds, and
 * repeat. Every part of that is defensible on its own and the sum of it is a
 * security guard: nowhere it goes is a place, nothing it does when it gets there
 * is different from anything else it does, and the only thing distinguishing one
 * stop from the next is a random number.
 *
 * So a destination is now an **address** — somewhere `Plan` already has a name
 * for — and an address comes with an angle and a reason to linger:
 *
 *   - a **seat**, approached from behind and held at the seat's own `facing`, so
 *     the robot stands at somebody's desk looking at the desk;
 *   - a **fixture** — an authored prop, which is to say a whiteboard, a locker,
 *     a shelf, a meeting chair — stood in front of and looked at;
 *   - a **room's centre**, which is the one that sends a robot to the kitchen.
 *
 * `ErrandKind` covers the choosing and `Address` the arriving. Three things
 * about it are worth knowing before changing any of it:
 *
 *   - **The kind is drawn before the address**, so the pack cannot decide the
 *     mix by how many desks it happens to author. See `ERRAND_MIX`.
 *   - **Candidates are shortlisted and scored, not taken first-fit**, on how
 *     recently anyone went there, how close it is to another robot or to where
 *     another robot is heading, and whether it is out of the room this robot is
 *     already standing in. That is what spreads four robots over a building
 *     instead of letting them pool. See `scoreAddress`.
 *   - **The last stretch is walked along the angle the robot will hold**, via a
 *     waypoint behind the destination, so it arrives lined up rather than
 *     stopping crooked and pivoting. See `APPROACH_RUNS`.
 *
 * Measured over ten thirty-minute runs on each reference pack, against the same
 * harness running the version this replaced: a robot arriving somewhere with an
 * angle to hold now arrives a median of 9° off it rather than stopping wherever,
 * and the crowd enters 15 to 18 of the reference building's 26 rooms in half an
 * hour rather than 12 to 17 — and all six of the second pack's every single run,
 * rather than four to six.
 *
 * It also stands still more: 42% of the session against 29%. That is the point
 * rather than a regression. The old 29% was a robot with nothing to do having
 * nothing to do; the new 42% is four robots holding at desks, at whiteboards and
 * in doorways, and the number to watch is not that one but the one below it —
 * how much of the standing is a robot that has genuinely failed to find anywhere
 * to go, which is what the watchdog and `pickDoor` exist to keep near zero.
 *
 * ### Navigation: rejection sampling, not a navmesh
 *
 * Underneath the errands, and still the whole of the fallback.
 *
 * Building a navmesh for an office would mean a floor decomposition, a portal
 * graph, A*, string-pulling and a funnel — several hundred lines, a new build
 * product to keep in step with `Plan`, and a whole second definition of "where
 * can you stand" beside the one the wall split already produces. All of that to
 * decide which way a decorative robot walks round a desk.
 *
 * So: pick a point on the floor, keep it if `plan.roomAt` says it is indoors and
 * `plan.blocked` says the straight line from here to there crosses no wall, and
 * walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch one robot for
 * a minute and it looks like it is wandering; watch the algorithm and it is
 * playing join-the-dots with its own line of sight. Both readings are correct
 * and only one of them is visible.
 *
 * Two refinements on top of that, and both exist because the plain version was
 * measured and found wanting rather than because they seemed like good ideas.
 * Each is documented where it lives:
 *
 *   - Candidates are drawn from a **room chosen by area**, not from the level's
 *     bounding box, because a level's rooms cover a fraction of its bounds and
 *     most candidates were landing in the void outside the walls (`samplerFor`).
 *   - A robot that can see nowhere to go walks to a **doorway** instead, via a
 *     waypoint in the opening itself, because line of sight out of a small room
 *     through a 0.9 m gap almost never exists and three of four robots spent
 *     nineteen simulated minutes parked in one (`pickDoor`).
 *
 * Together those took the crowd from 82% of the session standing still to under
 * 30%, which is the difference between an office with robots in it and an office
 * with four statues. Both still run, and both still matter: an errand needs an
 * address it can see, and the two things that produce a robot which cannot see
 * one — a small room, and a pack with nothing in it — are exactly what these two
 * were built for.
 *
 * Two things this deliberately does not know about:
 *
 *   - **Furniture.** `plan.blocked` is the wall collider and nothing else, so
 *     robots walk through desks. Fixing it means asking the asset registry for
 *     every prop's footprint and building a second collider, which is a real
 *     feature with a real cost and is not this. If it ever matters, the place to
 *     put it is `Plan`, next to the wall split, so that the walk controller and
 *     the robots get the same answer.
 *
 *     Errands make this more conspicuous rather than less, because a robot now
 *     walks *up to* furniture on purpose. What keeps that looking right is that
 *     it stops short of it: `DESK_STANDOFF` and `FIXTURE_STANDOFF` are the two
 *     places in this file that know a prop takes up room, and both are stated as
 *     distances rather than looked up, precisely so that this stays true.
 *   - **Stairs.** A robot belongs to one level for its whole life. Levels are
 *     connected by nothing in the office contract, so there is nowhere for it to
 *     go, and a robot that walked off a mezzanine would be a bug rather than a
 *     feature.
 *
 * ### Getting unstuck, which is the part that actually needs care
 *
 * A straight line that was clear when the destination was chosen can stop being
 * clear, because a robot turns on an arc rather than pivoting on the spot. So
 * every step is re-checked, and the machinery for that is three rules that have
 * to hold together — each of them broke on its own during development, and each
 * failure looked like a robot standing still and thinking:
 *
 *   1. **One desired heading, one turn.** The heading is chosen by fanning out
 *      from the direction the destination wants, and then the yaw is turned
 *      toward it once, rate-limited. Turning toward the destination *and* toward
 *      the probe result in the same frame gives two rate-limited turns that
 *      cancel exactly, and a robot locked at a fixed angle off course forever.
 *   2. **Walk only what was tested.** The rate limit means that after turning,
 *      the robot faces somewhere between where it was and where it probed. That
 *      direction has not been checked, and walking it is how a robot ends up
 *      inside the clearance band.
 *   3. **Probe a fixed lookahead, never the step length.** `plan.blocked` is
 *      true when the capsule comes within `radius` of a wall *including at its
 *      start*, so a robot already closer to a wall than its own radius has every
 *      direction blocked, away from the wall included. `PROBE_AHEAD` keeps it
 *      well clear of that band, and `PROBE_RELIEF` gets it back out if it ever
 *      gets in.
 *
 * And because none of that is a proof, there is a watchdog on top: a robot that
 * covers less than `STUCK_DISTANCE` in `STUCK_WINDOW` seconds throws its
 * destination away and picks another. That is what guarantees the worst failure
 * is a robot standing still and looking thoughtful, rather than one buzzing
 * against a partition until somebody closes the tab. It also breaks the one
 * deadlock the yielding rule can produce, where two robots stop nose to nose and
 * politely wait for each other.
 *
 * Soaked over ten thirty-minute runs across both reference packs, four robots
 * each, with a four-second frame thrown in every fifty seconds to imitate a tab
 * waking up: no robot left a room and none entered the clearance band. Re-run
 * unchanged after errands arrived, since a destination with a name is still just
 * a point as far as everything below here is concerned — same result.
 *
 * ### The walk cycle runs on distance, not on time
 *
 * `phase = distance / STRIDE`, never `phase += dt`. This is the difference
 * between feet that push the floor and feet that skate on it: a robot slowing
 * into a turn takes shorter steps rather than the same steps more slowly, and a
 * stopped robot's cycle stops with it instead of running on the spot.
 *
 * That is necessary and it is not sufficient. The shape of the swing has to
 * match the distance too, which is what `legAngle` and `STRIDE` are about and is
 * worth reading before touching either — a hand-picked stride against a
 * sinusoidal hip measured 65% of distance travelled coming back out as foot
 * slip, and the fix was arithmetic rather than taste. It now measures 5%.
 *
 * Stopping is the other half. A frozen phase is a frozen mid-stride, so the
 * whole pose is *interpolated* toward the rest stance by `gait`, which eases to
 * zero over about a quarter of a second. The phase freezes and the amplitude
 * drains out of it, settling the figure from wherever it was without moving a
 * foot across the floor. Fading the pose out is the only way to stop that does
 * not slide; running the cycle on to the end of the stride is the way that does.
 *
 * One consequence, and it is the price of the arrival turn. A robot settling
 * onto its seat's facing rotates with `gait` at zero and its feet planted — the
 * whole figure swings about its own axis, because there is no shuffle to play
 * and faking one out of the walk joints would be a stride taken on the spot,
 * which is the exact thing the paragraph above is about. `SETTLE_RATE` makes
 * that slow enough to read as deliberate, and `APPROACH_RUNS` makes it small
 * enough to mostly not happen. A shuffle would need turn-in-place footwork the
 * rig has never had, and it would have to be driven by yaw the way the walk is
 * driven by distance, or it would skate for the same reason.
 */

import * as THREE from "three";
import { createAssetContext } from "../assets/kit.ts";
import type { MaterialRegistry } from "../assets/materials.ts";
import {
  buildOptimus,
  cloneOptimus,
  disposeOptimus,
  OPTIMUS,
  OPTIMUS_REST,
  restOptimus,
  type OptimusJoints,
  type OptimusRig,
} from "../assets/office/optimus.ts";
import { seededRandom } from "../engine/world.ts";
import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom } from "./plan.ts";
import type { Point2 } from "./types.ts";

// ---- Tuning ---------------------------------------------------------------

/** Metres per second on the straight. A brisk indoor human walk. */
const CRUISE = 1.2;

/**
 * How wide a robot is to the collider.
 *
 * The number to size this against is not the shoulder span. `optimus.ts` puts
 * the shoulder *pivots* 0.35 m apart and this comment used to quote that, which
 * made 0.28 look like a radius with 100 mm of slack in it. Measured off the
 * built figure, a standing Optimus is 0.520 m across at its widest — the
 * shoulder drums, with the splayed hands 4 mm inside them — so 0.56 is 20 mm of
 * slack a side, not 100.
 *
 * That is still the right answer, because a doorway `Plan` calls passable is
 * 0.9 m and 0.56 goes through one with room to turn in it. But it is the number
 * to think with if anything about the arms, the shoulders or the stance
 * changes, and there is far less room in it than the old comment implied: the
 * figure is within 40 mm of its own collider, so a wider robot silently starts
 * clipping door frames rather than failing.
 */
const RADIUS = 0.28;

/** Radians per second of yaw. About a second and a half for a half turn. */
const TURN_RATE = 2.2;

/**
 * Hip to ankle in the rest pose — the length of the pendulum the whole gait is.
 * Two numbers below are derived from it rather than dialled in, which is the
 * only reason the feet stay on the floor.
 */
const LEG = OPTIMUS.hipY - OPTIMUS.ankleY;

/** How close counts as arrived. Inside this the robot stops looking for the point. */
const ARRIVE = 0.35;
/**
 * How close counts as having reached a waypoint. Tighter than `ARRIVE`, because
 * the only waypoint there is is a doorway and the whole point of going there is
 * to end up lined up with it.
 */
const REACHED = 0.22;

/** A destination has to be at least this far away, or a robot shuffles on the spot. */
const MIN_TRIP = 1.8;
/**
 * How far a doorway has to be before it counts as somewhere to go.
 *
 * Small on purpose. The obvious value is something like 1.4 m — far enough that
 * a robot cannot immediately turn round and go back through the door it just
 * came out of — and it is wrong, because a phone booth is 2.0 × 1.8 m and its
 * own door is never more than 1.3 m from anywhere inside it. Set it high and the
 * one room a robot most needs help escaping from is the one room it cannot. The
 * doubling-back problem is solved by remembering the last door instead, which is
 * what `Robot.lastDoor` is for.
 */
const MIN_DOOR = 0.45;
/** How far past a doorway to aim, so a robot ends up in the next space and not in the gap. */
const THROUGH_DOOR = 0.85;

const PICK_ATTEMPTS = 24;
/** Seconds to wait after failing to find anywhere to go. */
const RETRY_PAUSE = 1.5;
/**
 * Seconds a robot stands still after arriving somewhere that was **not** an
 * errand — a random point on the floor, or the far side of a doorway. An errand
 * sets its own dwell from `DWELL`; this is the shrug.
 *
 * Also the spread on the initial stagger, so four robots do not all set off on
 * the same frame.
 */
const PAUSE_MIN = 1.4;
const PAUSE_MAX = 4.6;

/** How far ahead a step is tested. See the header — this must not be the step length. */
const PROBE_AHEAD = 0.34;
/** Headings tried, in order, when the way ahead is blocked. Radians off course. */
const PROBE_TURNS = [0, 0.45, -0.45, 0.95, -0.95, 1.5, -1.5];
/**
 * Fractions of the collision radius the probe will settle for, in order. The
 * second one only ever comes into play for a robot that is already wedged; see
 * the note at the probe.
 */
const PROBE_RELIEF = [1, 0.4];

const STUCK_WINDOW = 1.6;
const STUCK_DISTANCE = 0.15;

/** Another robot this close and roughly ahead makes this one wait. */
const YIELD_RANGE = 0.95;
const YIELD_CONE = 0.4;

/** Seconds for the walk pose to fade in or out when a robot starts or stops. */
const GAIT_EASE = 0.24;

/**
 * The biggest step a single tick may take, in seconds.
 *
 * A backgrounded tab hands back a `dt` of whole seconds when it wakes, and an
 * unclamped robot would move several metres in one step — through a wall, since
 * the collider is a capsule test against that step and a step that long sweeps
 * across whole rooms. Clamping means a robot that was in a background tab is
 * simply where it was, which is right: nobody was watching.
 */
const MAX_STEP = 0.1;

// ---- Errands --------------------------------------------------------------

/**
 * What a robot went somewhere *for*.
 *
 * It decides exactly two things — how often that kind of place gets picked, and
 * how long a robot stands there once it arrives — and those two are most of the
 * difference between a crowd that is working and a crowd that is patrolling.
 * Nothing about the walk itself branches on it.
 *
 *   - **`desk`** is a seat. `Plan` publishes every one of them with a `facing`,
 *     which is the whole reason this exists: arriving at a named spot and
 *     turning to the angle that spot says is the single detail that reads as
 *     purpose. See `DESK_STANDOFF` for why the robot stops short of the seat
 *     rather than on it.
 *   - **`fixture`** is an authored prop — a whiteboard, a locker, a shelf, a
 *     meeting chair. Identified by what it is *not*: not bound to a seat and not
 *     generated by a desk bank, so it is something the pack author put there on
 *     purpose rather than the second half of a workstation.
 *   - **`room`** is a room's centroid. The only kind with no facing, and the
 *     only kind that is about the building rather than about the furniture —
 *     it is what sends a robot to the kitchen or across the commons.
 */
type ErrandKind = "desk" | "fixture" | "room";

/**
 * How the three kinds are mixed, as relative weights.
 *
 * **The kind is drawn first and the address second**, and that ordering is the
 * point. Drawing uniformly over one flat list of addresses would let the pack
 * decide the mix by accident: the reference office resolves 76 seats, roughly
 * 150 fixtures and 17 rooms on its ground floor, so a flat draw would send a
 * robot to a room's centre about 9% of the time and to a desk about 31% —
 * neither of which anybody chose. Picking the kind first fixes the *behaviour*
 * and lets the pack decide only which whiteboard.
 *
 * Weighted toward desks because a desk is the strongest read and because there
 * are enough of them that four robots do not visibly repeat. Kinds a level has
 * none of are skipped and their weight goes to the others, so a pack with no
 * authored props still gets desks and rooms rather than a stalled robot.
 */
const ERRAND_MIX: readonly (readonly [ErrandKind, number])[] = [
  ["desk", 0.5],
  ["fixture", 0.2],
  ["room", 0.3],
];

/**
 * Seconds spent standing at each kind, low and high of a uniform spread.
 *
 * A robot that pauses for the same length of time everywhere reads as a state
 * machine no matter how good the destinations are, so the dwell is the errand's
 * and not the walk's. The ordering is the story: you stand at a desk because you
 * are doing something there, you look at a whiteboard for a moment, and a room's
 * centre is somewhere you are passing through.
 *
 * The upper end matters more than it looks. Four robots with a mean dwell around
 * six seconds and trips that take rather longer than that leaves most of them
 * walking at any instant, which is the balance that reads as an office; push the
 * desk dwell to half a minute and you get four robots standing about.
 */
const DWELL: Record<ErrandKind, readonly [number, number]> = {
  desk: [5, 12],
  fixture: [3, 7],
  room: [1.5, 4],
};

/** Seconds to stand after stepping through a doorway. Short: the point was to leave. */
const DWELL_DOOR: readonly [number, number] = [0.4, 1.2];

/**
 * How far behind a seat a robot stops, in metres.
 *
 * **A robot must never stand on a seat**, and that is a hard rule rather than a
 * preference: `presence.ts` puts a person mesh at exactly `seat.position` with
 * exactly `seat.facing`, so a robot that treated the seat as its own destination
 * would stand inside whoever is sitting there. Seats are addresses for
 * occupants; a robot visiting one is a visitor.
 *
 * The number is sized off the chair rather than picked. `seating.ts` gives
 * `tera:seat.task-chair` a 0.64 m footprint — the star base, which is its widest
 * part — so 0.32 m from the seat centre to the chair's edge, plus the 0.28 m
 * robot radius, is 0.60 m before the two touch. 0.75 leaves 150 mm of air.
 *
 * That is cosmetic and not a collision guarantee: `plan.blocked` is the wall
 * collider and knows nothing about furniture, as the header says. It is the
 * difference between a robot standing at somebody's desk and a robot standing
 * in their chair, which is visible from every camera angle in the building.
 *
 * The offset direction falls out of `plan.ts`: a bank puts its seat at the desk
 * centre plus `(sin f, cos f) · seatOffset`, so stepping further along that same
 * ray is further from the desk — behind the occupant, looking the way they look.
 */
const DESK_STANDOFF = 0.75;

/**
 * How far in front of a fixture a robot stops.
 *
 * Chosen rather than derived, because deriving it would mean asking the asset
 * registry for every prop's footprint, and this file deliberately does not know
 * that the registry exists — the same line the header draws around furniture
 * collision. 0.9 m is the distance a person stands from a whiteboard, and it is
 * far enough that the error on a fixture with a deeper footprint than expected
 * is a robot standing a little close rather than a robot standing inside it.
 *
 * The facing convention is the desk's, taken from `plan.ts` and not invented
 * here: a prop's front is its local **+Z**, `(sin r, cos r)`, because that is the
 * side a desk bank puts its seat on. A prop authored with a meaningless rotation
 * — a rug — gets a meaningless standing spot, which costs a robot a few seconds
 * looking at the floor and breaks nothing.
 */
const FIXTURE_STANDOFF = 0.9;

/**
 * How far back along its own facing an errand's run-in starts, longest first.
 *
 * The last leg of an approach is walked *along* the facing, so the robot arrives
 * already lined up instead of stopping at a random angle and then pivoting. The
 * first entry is a turn budget: a quarter turn at `TURN_RATE` takes (π/2)/2.2 =
 * 0.71 s, which at `CRUISE` is 0.86 m — so 0.9 m is one right-angle's worth of
 * turning, and better than that in practice, because a turning robot walks
 * slower and therefore turns further per metre.
 *
 * The second entry is there because the first one alone is not available often
 * enough, and this is the measurement that says so. A run-in has to be somewhere
 * a robot could stand, and 0.9 m behind the standing spot is 1.65 m behind the
 * seat itself — which on the reference ground floor is inside a wall for 27 of
 * 72 desks and outside every room for 9 more, because that is what a meeting
 * room is. Only 36 desks got a run-in at all. Falling back to 0.45 m takes it to
 * 62, and the arrivals it buys are as well aligned as the long ones.
 *
 * Note what 0.45 does *not* buy, so nobody re-derives it as a bug: `REACHED` is
 * 0.22 and `ARRIVE` is 0.35, so a robot that clears a 0.45 m run-in can already
 * be inside the arrival radius and stop on the spot without walking a step of
 * the final leg. It still helps, because the alignment mostly comes from having
 * steered at a point on the destination's own axis rather than from the metre
 * after it. Measured against dropping the fallback entirely, it moves the 75th
 * percentile of arrival error from 97° to 86°; both against 90° for a robot that
 * simply stops where it gets to.
 *
 * Same shape as `PROBE_RELIEF`, and for the same reason: a value that is right
 * when there is room for it and a smaller one that is better than nothing.
 */
const APPROACH_RUNS = [0.9, 0.45];

/**
 * A prop whose base sits higher than this is not something you walk up to.
 *
 * `OPTIMUS.shoulderY` rather than a number, because the question this is asking
 * is "is this thing in front of the robot or above it". It exists because the
 * "authored prop" test catches ceiling lights: the reference ground floor
 * authors 81 troffers and 22 pendants, none bound to a seat, and every one of
 * them would otherwise be a place to stand and stare upward.
 *
 * Measured across both reference packs, the split is not close: the highest
 * floor-standing fixture base is a wall display at 1.15 m and the lowest light
 * is a troffer at 2.30 m, so the cutoff sits in the middle of a metre-wide gap
 * and no plausible pack lands on the boundary.
 */
const FIXTURE_MAX_BASE = OPTIMUS.shoulderY;

/** How many addresses are scored before one is committed to. See `pickErrand`. */
const SHORTLIST = 6;

/**
 * Seconds before somewhere a robot went is fully interesting again.
 *
 * Without this the crowd converges: the score is the same every time it is
 * asked, so the best desk in the building is the best desk for every robot for
 * the whole session. A minute is long enough that a repeat is a coincidence
 * rather than a rut, and the floor below keeps a just-visited address merely
 * unlikely rather than banned — on a level with three addresses and four robots,
 * banning is how you get a robot with nowhere to go.
 */
const REVISIT_COOLDOWN = 60;
const COOL_FLOOR = 0.05;

/**
 * Distance from the nearest other robot at which a destination stops being
 * penalised for crowding, and the floor under that penalty.
 *
 * "Nearest other robot" counts where they *are* and where they are *going*, so
 * two robots do not set off for the same whiteboard from opposite ends of the
 * floor and discover the problem on arrival.
 */
const SPREAD_FULL = 7;
const SPREAD_FLOOR = 0.15;

/**
 * What a destination in the room the robot is already standing in is worth,
 * against one somewhere else.
 *
 * This is the term that actually spreads the crowd through the building rather
 * than round one floor plate, and it is nearly free: every address knows its
 * room from the check that admitted it, so the only cost is one `roomAt` for the
 * robot itself, once per errand.
 */
const SAME_ROOM = 0.35;

/**
 * Radians per second of yaw while standing still.
 *
 * Slower than `TURN_RATE` on purpose. The figure has no pivot-in-place
 * animation — the gait is driven by distance travelled, so a robot turning
 * without moving has its feet planted and swings the whole body — and the
 * faster that happens the more it looks like a turntable. At this rate a half
 * turn on the spot takes π/(2.2 · 0.55) = 2.6 s, which reads as settling.
 *
 * It is usually a small turn anyway, because `APPROACH_RUN` has the robot walk
 * the last stretch along the angle it is going to hold.
 */
const SETTLE_RATE = TURN_RATE * 0.55;

// ---- Gait -----------------------------------------------------------------

/** Peak hip angle, radians. Everything else about the stride follows from it. */
const HIP_SWING = 0.34;

/**
 * Half the ground a planted foot covers, and the ground covered by one full
 * two-step cycle.
 *
 * These are derived from the swing rather than chosen, and that is what decides
 * whether the feet push the floor or skate on it. A planted foot sits at
 * `LEG · sin θ` in front of the hips, so the ground one step covers is fixed by
 * the swing amplitude and the length of the leg, and the body has to move
 * exactly that far in the same time or the foot makes up the difference by
 * sliding. Hand-set at 1.32 m against a 0.4 rad swing, the measured slip was 65%
 * of distance travelled — the robots were gliding with their legs waving.
 *
 * At 1.2 m/s this is about 129 steps a minute, which is a brisk walk and the
 * right read for a machine with somewhere to be.
 */
const HALF_STEP = LEG * Math.sin(HIP_SWING);
const STRIDE = 4 * HALF_STEP;

const KNEE_BEND = 0.58;
const ARM_SWING = 0.3;
const ELBOW_SWING = 0.22;
const SWAY = 0.05;
const TWIST = 0.055;
/** Forward lean at full speed. Small, but it is what stops a walk looking passive. */
const LEAN = 0.035;

/**
 * A leg's hip angle at `psi`, its own phase in `[0, 2π)`: stance for the first
 * half, swing for the second.
 *
 * **The stance half is an arcsine and not a sine, and that is the whole point of
 * this function.** A sine looks like the obvious choice and it is wrong for a
 * reason that is easy to miss: a planted foot has to travel backwards under the
 * body at *exactly* walking speed, which means its position is linear in time,
 * which means the hip angle is the arcsine of a straight line. Drive the hip
 * with a sine instead and the foot's backward speed is fastest as the leg passes
 * vertical and zero at the ends of the stance, so it matches the body's speed at
 * one instant per step and slides for the rest of it. Worse, both legs pass
 * vertical at the same moment, so there is no instant at which either foot is
 * genuinely planted. Measured: 30% of distance travelled came out as foot slip
 * with everything else already tuned, and no amount of adjusting the stride
 * length got it below about a quarter, because the shape was wrong rather than
 * the scale.
 *
 * The swing half is a cubic Hermite from the back of the stride to the front
 * whose end slopes are the stance's own — `−2 tan(HIP_SWING)` at both — so the
 * thigh does not visibly jerk at toe-off or at heel strike. Nothing about the
 * swing affects foot slip, because the foot is in the air for all of it; it only
 * has to be smooth and to arrive in the right place.
 */
function legAngle(psi: number): number {
  if (psi < Math.PI) {
    const u = psi / Math.PI;
    return Math.asin(Math.sin(HIP_SWING) * (1 - 2 * u));
  }
  const v = (psi - Math.PI) / Math.PI;
  const slope = -2 * Math.tan(HIP_SWING);
  const v2 = v * v;
  const v3 = v2 * v;
  return (
    (2 * v3 - 3 * v2 + 1) * -HIP_SWING +
    (v3 - 2 * v2 + v) * slope +
    (-2 * v3 + 3 * v2) * HIP_SWING +
    (v3 - v2) * slope
  );
}

/**
 * How bent a leg's knee is at `psi`, as a fraction of `KNEE_BEND`.
 *
 * Zero for the whole of stance, and that is deliberate rather than lazy: a bent
 * stance knee shortens the leg, and the body's height is computed from the
 * stance leg being straight. Bend it and the planted foot either floats or sinks
 * by the difference. So the knee does all of its work in the air, which is also
 * the only place it is doing anything useful — lifting the foot over the floor.
 *
 * `sin²` rather than `sin` so the bend starts and ends with zero rate and there
 * is no kink at toe-off.
 */
function kneeFlex(psi: number): number {
  if (psi < Math.PI) return 0;
  const wave = Math.sin(psi - Math.PI);
  return wave * wave;
}

/**
 * Pose one figure for a distance travelled and a gait strength.
 *
 * `distance` is metres since the robot was created — monotonic, never reset, so
 * the cycle is continuous across every stop and start. `gait` is 0 for standing
 * still and 1 for walking, and every joint is *interpolated* between its rest
 * value and its walking value by it, so `gait === 0` reproduces `restOptimus`
 * exactly and a robot easing to a halt settles rather than snapping.
 *
 * Signs, all of which are the header of `optimus.ts` applied: hips, shoulders
 * and elbows bend positive, knees bend **negative**, positive `rotation.y` turns
 * left and positive `rotation.z` leans left.
 */
function pose(j: OptimusJoints, distance: number, gait: number): void {
  const phase = (distance / STRIDE) * Math.PI * 2;
  /**
   * Two waves, a quarter cycle apart, and using the wrong one is the mistake
   * this comment exists to stop somebody making a second time.
   *
   * `s` peaks when the legs are **passing each other** — mid-stance. Lateral
   * sway and the head's counter-lean belong on it, because that is genuinely
   * when a walking body is furthest over its planted foot.
   *
   * `swing` peaks when the legs are **furthest apart** — heel strike. Anything
   * that counter-balances the legs belongs on it: the arms, the elbows, and the
   * twist through the waist.
   *
   * They were all on `s` at first, which put the arms a quarter cycle early: at
   * the instant the left leg reached full forward the left shoulder was at dead
   * neutral, and both arms hit their extremes as the legs passed vertical
   * together. It reads as a figure whose arms are swinging to a different beat
   * from its legs, which is uncanny in a way that is hard to name until it is
   * pointed at.
   *
   * `swing` is derived from the leg angle itself rather than restated as
   * `cos(phase)`, so the two cannot drift apart if `legAngle` is ever reshaped.
   */
  const s = Math.sin(phase);

  // The left leg's own phase, and the right exactly half a cycle behind it —
  // so one leg is always in stance and the other always in swing, and there is
  // no moment when the robot is standing on neither.
  const psiL = ((phase % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
  const psiR = (psiL + Math.PI) % (Math.PI * 2);
  const hipL = legAngle(psiL);
  const hipR = legAngle(psiR);
  // −1..1, in step with the legs. See the note on `s` above. `HIP_SWING` is a
  // non-zero literal, so no guard is needed and TypeScript will say so if that
  // ever stops being true.
  const swing = hipL / HIP_SWING;

  j.hipL.rotation.x = OPTIMUS_REST.hipX + gait * (hipL - OPTIMUS_REST.hipX);
  j.hipR.rotation.x = OPTIMUS_REST.hipX + gait * (hipR - OPTIMUS_REST.hipX);
  const kneeL = -KNEE_BEND * kneeFlex(psiL);
  const kneeR = -KNEE_BEND * kneeFlex(psiR);
  j.kneeL.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeL - OPTIMUS_REST.kneeX);
  j.kneeR.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeR - OPTIMUS_REST.kneeX);

  // Arms counter-swing to the legs: the left arm goes forward with the right
  // leg. Get this backwards and the figure paces like a soldier at attention,
  // which is a surprisingly strong and surprisingly wrong-looking effect.
  j.shoulderL.rotation.x = OPTIMUS_REST.shoulderX - gait * ARM_SWING * swing;
  j.shoulderR.rotation.x = OPTIMUS_REST.shoulderX + gait * ARM_SWING * swing;
  // And an elbow closes a little further on the forward stroke, which is what
  // stops the arms reading as two pendulums bolted to a box. `-swing` is the
  // left arm's own forward stroke, since it swings against the left leg.
  j.elbowL.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, -swing);
  j.elbowR.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, swing);

  // The body's height is not a bob that was dialled in. It is where the hips
  // have to be for the straight, planted, stance leg to reach the floor:
  // `LEG · cos θ`, exactly. That puts the body at its highest as the stance leg
  // passes vertical and at its lowest at heel strike and toe-off, twice per
  // cycle, which is what a real gait does and is not something this had to be
  // told. Damping it — an earlier version scaled it to a third, to stop an
  // imagined pogo — was most of that 65% of foot slip. It does not pogo: the
  // whole travel is 48 mm, about what a walking person's head does.
  const stance = psiL < Math.PI ? hipL : hipR;
  j.pelvis.position.y = OPTIMUS.hipY - gait * LEG * (1 - Math.cos(stance));

  // One consequence of all this, stated so nobody spends an afternoon on it:
  // the figure has no ankle joint, so a foot pitches with its shin and the toe
  // passes about 50 mm under the floor plane at the ends of each stride. That is
  // hidden by the floor slab, and what remains visible above it is a heel strike
  // and a toe-off the rig never had to be given. Correcting it would need a
  // twelfth joint and would cost every robot two more meshes.

  // Hips twist one way, shoulders the other. `torso` is a child of `pelvis`, so
  // its rotation adds: −2× puts the shoulders at −1× in world space.
  j.pelvis.rotation.y = gait * TWIST * swing;
  j.torso.rotation.y = -gait * TWIST * 2 * swing;
  j.torso.rotation.z = gait * SWAY * s;
  j.torso.rotation.x = gait * LEAN;
  // The head keeps about half the lean instead of all of it. A head that stays
  // perfectly level looks gimballed; one that swings with the chest looks
  // drunk.
  j.head.rotation.z = -gait * SWAY * 0.55 * s;
}

// ---- Layer ----------------------------------------------------------------

/** Where one robot lives. A robot belongs to its level for its whole life. */
export interface RobotSpec {
  levelId: string;
  /** For the caller's own bookkeeping. Defaults to `robot-1`, `robot-2`, … */
  id?: string;
}

export interface RobotLayerOptions {
  /** The office's material registry. Its `paper` and `screenBezel` roles are used. */
  materials: MaterialRegistry;
  /** One entry per robot. About four is the budget; see the header. */
  robots: readonly RobotSpec[];
  /**
   * Seeds where the robots start and where they wander. Change it to reshuffle
   * the whole crowd; leave it and a reload puts them back where they were, which
   * is the same discipline the rest of the scene keeps.
   */
  seed?: number;
  /** Metres per second on the straight. Defaults to 1.2. */
  speed?: number;
  /** Collision radius. Defaults to 0.28 — see `RADIUS`. */
  radius?: number;
}

/** One robot's world position, live. See `RobotLayer.robots`. */
export interface RobotView {
  id: string;
  levelId: string;
  /**
   * Office-world metres, at the robot's feet. **Updated in place** every tick —
   * hold the reference, read it, and do not write to it.
   */
  position: THREE.Vector3;
}

export interface RobotLayer {
  group: THREE.Group;
  tick(dt: number): void;
  /**
   * Every robot, for anything that wants to react to one — a ceiling light
   * brightening as one passes under it, a minimap dot, an occupancy heatmap.
   *
   * The array and the `Vector3`s in it are **stable and live**: the same objects
   * come back every call and their contents change under you. That is
   * deliberate, because the caller for this is a per-frame loop and allocating
   * four vectors sixty times a second to answer the same question is exactly the
   * kind of garbage that shows up as a stutter and not as a profile entry. If
   * you need a snapshot, clone what you take.
   */
  robots(): readonly RobotView[];
  dispose(): void;
}

/**
 * A direction a robot may walk in, and the clearance the probe settled for to
 * find it. There is exactly one of these per layer and it is scratch — see
 * `chooseHeading`.
 */
interface Heading {
  heading: number;
  clearance: number;
}

/** Everything about one robot that changes. */
interface Robot {
  view: RobotView;
  level: LevelPlan;
  rig: OptimusRig;
  yaw: number;
  target: Point2 | null;
  /** Seconds left of the current stand-still. Only meaningful with no target. */
  wait: number;
  /**
   * A yaw to turn to while standing still, or nothing to stand as it stopped.
   * **Only meaningful with no target**, and cleared once reached, so a robot
   * that settles onto its seat's facing and then waits out the rest of its dwell
   * is doing no work at all.
   */
  settle: number | null;
  /**
   * The errand's plan for the moment of arrival: the yaw to hold and the seconds
   * to hold it for. Chosen when the destination is, spent when it is reached —
   * `arriveFacing` becomes `settle` and `arriveDwell` becomes `wait`.
   *
   * Two fields rather than one small object because a destination is picked
   * every few seconds per robot and this file allocates only where it must.
   */
  arriveFacing: number | null;
  arriveDwell: number;
  /** 0 standing, 1 walking. Eased, never snapped. See the header. */
  gait: number;
  /** Metres travelled ever. Drives the walk cycle and is never reset. */
  distance: number;
  /** Metres travelled since the watchdog last looked, and how long ago that was. */
  sinceCheck: number;
  checkAge: number;
  /**
   * An intermediate point to reach before `target`, or nothing.
   *
   * There is at most one, ever, and that is the rule that keeps this from
   * becoming a path — see `pickDoor` for why one is enough and two would need a
   * graph. It is either a **doorway**, when a robot could see nowhere to go and
   * is leaving the room, or an errand's **run-in**, when the last stretch is
   * walked along the angle the robot is going to hold on arrival. Both are
   * checked as two independent legs, which is the only reason either works.
   */
  waypoint: Point2 | null;
  /** The opening this robot last walked through, so it does not turn straight round. */
  lastDoor: string | null;
  rand: () => number;
}

export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotLayer {
  const group = new THREE.Group();
  group.name = "robots";

  const speed = options.speed ?? CRUISE;
  const radius = options.radius ?? RADIUS;
  const seed = options.seed ?? 0x0117;

  // One figure is built and the rest are clones of it, so the crowd costs draw
  // calls and no memory. The prototype is never added to the scene; it exists
  // only to be cloned from and to be disposed at the end, because it is the one
  // that owns the geometry every clone points at.
  const ctx = createAssetContext({ materials: options.materials });
  const prototype = buildOptimus(ctx);

  const robots: Robot[] = [];
  const views: RobotView[] = [];

  // Scratch, reused every frame. Four robots at sixty frames is 240 chances a
  // second to allocate a `Point2` for nothing. `chosen` is the same discipline
  // applied to the one place it had been forgotten; see `chooseHeading`, which
  // is the only thing allowed to write to it.
  const from: Point2 = { x: 0, z: 0 };
  const to: Point2 = { x: 0, z: 0 };
  const chosen: Heading = { heading: 0, clearance: 0 };

  /**
   * A level's rooms with a running area total, so a candidate point can be
   * drawn from the *floor* rather than from the level's bounding box.
   *
   * Sampling the bounding box was the first version, and it is why this exists.
   * A level's bounds are the whole building's extent, a level's rooms cover
   * maybe a third of it and a mezzanine covers a tenth, so most candidates
   * landed in the void outside the walls and most picks failed. Measured over
   * twenty simulated minutes in the reference office, the crowd spent 82% of it
   * standing still waiting to retry — and worst on exactly the small upper floor
   * where a single robot is most conspicuous, which walked 49 m to the ground
   * floor's 393. Choosing a room first puts nearly every candidate somewhere a
   * robot could actually stand.
   *
   * Weighted by area rather than uniformly over rooms, because uniform sends a
   * robot into the 6 m² phone booth as often as into the 400 m² floor plate,
   * and what that looks like is four robots queueing for a cupboard.
   */
  interface Sampler {
    rooms: readonly ResolvedRoom[];
    /** Running area totals, one per room; the last one is `total`. */
    cumulative: readonly number[];
    total: number;
    /** Every opening a walker fits through. `Plan` has already decided which. */
    doors: readonly ResolvedOpening[];
  }

  const samplers = new Map<string, Sampler>();
  function samplerFor(level: LevelPlan): Sampler {
    const hit = samplers.get(level.id);
    if (hit) return hit;
    const cumulative: number[] = [];
    let total = 0;
    for (const room of level.rooms) {
      total += room.area;
      cumulative.push(total);
    }
    const made: Sampler = {
      rooms: level.rooms,
      cumulative,
      total,
      doors: level.openings.filter((opening) => opening.passable),
    };
    samplers.set(level.id, made);
    return made;
  }

  /**
   * One candidate: somewhere a robot could stand — inside a room, and clear of
   * every wall by its own radius.
   *
   * A room's `bounds` are its bounding box and a room need not be rectangular,
   * so the `roomAt` check is not made redundant by having chosen a room first:
   * it is what rejects the missing corner of an L-shaped floor plate. It also
   * *re-resolves* which room the point is in, and later rooms win, which is the
   * answer you want — a point that lands inside a meeting room while sampling
   * the open floor's bounding box is a point in the meeting room, and standing
   * there is fine.
   *
   * `blocked` with the same point at both ends is a degenerate capsule, which is
   * exactly a point-to-wall distance test. Reusing it rather than writing one
   * keeps the "how much room does a robot need" arithmetic in the one place
   * `Plan` already documents it.
   */
  function trySample(sampler: Sampler, levelId: string, rand: () => number, into: Point2): boolean {
    if (!(sampler.total > 0)) return false;
    const roll = rand() * sampler.total;
    let index = sampler.cumulative.length - 1;
    for (let i = 0; i < sampler.cumulative.length; i++) {
      if (roll <= (sampler.cumulative[i] ?? 0)) {
        index = i;
        break;
      }
    }
    const room = sampler.rooms[index];
    if (!room) return false;

    into.x = room.bounds.minX + rand() * room.bounds.width;
    into.z = room.bounds.minZ + rand() * room.bounds.depth;
    if (!plan.roomAt(levelId, into)) return false;
    return !plan.blocked(levelId, into, into, radius);
  }

  /** Somewhere on this level a robot could stand, or nothing. Used to place one. */
  function samplePoint(level: LevelPlan, rand: () => number, into: Point2): boolean {
    const sampler = samplerFor(level);
    for (let i = 0; i < PICK_ATTEMPTS; i++) {
      if (trySample(sampler, level.id, rand, into)) return true;
    }
    return false;
  }

  // ---- The address book ---------------------------------------------------

  /**
   * Somewhere worth going, and what to do on arrival.
   *
   * Every field except `visitedAt` is decided once, when the level's book is
   * built, and never changes — which is what makes an errand cost one `blocked`
   * call to commit to instead of four. `at` has already been checked to be
   * indoors and clear of every wall, and `approach` has been checked the same way
   * *plus* the leg between the two, so by the time a robot is choosing, the only
   * open question is whether it can see the thing from where it is standing.
   */
  interface Address {
    kind: ErrandKind;
    /** Where the robot ends up standing. Never on a seat; see `DESK_STANDOFF`. */
    at: Point2;
    /** The yaw to hold once there, or nothing to stop on the arrival heading. */
    facing: number | null;
    /**
     * Where the last leg starts, so the robot walks in already lined up. Absent
     * when there is no facing to line up with, or when the run-in does not fit —
     * a desk in an alcove with its back 0.5 m from a wall, say, which is still a
     * perfectly good place to stand and simply gets approached from wherever.
     */
    approach: Point2 | null;
    /** Which room `at` is in. Falls out of the check that admitted it; see `SAME_ROOM`. */
    roomId: string;
    /** Layer clock when a robot last set out for here. See `REVISIT_COOLDOWN`. */
    visitedAt: number;
  }

  /** One level's addresses, split by kind because the kind is drawn first. */
  interface AddressBook {
    desk: Address[];
    fixture: Address[];
    room: Address[];
  }

  /**
   * Seconds of simulated time since the layer was made, advanced by the same
   * clamped `dt` the robots move on — so a tab that was asleep for a minute does
   * not come back to a crowd whose cooldowns have all expired at once, for the
   * same reason it does not come back to robots three rooms away.
   */
  let clock = 0;

  const books = new Map<string, AddressBook>();

  /** The id of the room a robot could stand at this point in, or nothing. */
  function standable(levelId: string, point: Point2): string | null {
    const room = plan.roomAt(levelId, point);
    if (!room) return null;
    return plan.blocked(levelId, point, point, radius) ? null : room.id;
  }

  /**
   * One address, if a robot can stand at it.
   *
   * The rejections here are the whole reason this is done once per level rather
   * than per pick: a desk pushed against a wall, a whiteboard in a stairwell, a
   * fixture whose front is inside a partition, a centroid outside its own
   * L-shaped room. Every one of those is a fact about the pack that never
   * changes, and paying for it at 60 Hz would be the expensive way to learn it.
   */
  function makeAddress(
    level: LevelPlan,
    kind: ErrandKind,
    at: Point2,
    facing: number | null,
  ): Address | null {
    const roomId = standable(level.id, at);
    if (roomId === null) return null;

    let approach: Point2 | null = null;
    if (facing !== null) {
      for (const run of APPROACH_RUNS) {
        // Back along the facing: the robot walks from here to `at` looking the
        // way `at` says, so the run-in and the hold are the same direction.
        const back: Point2 = {
          x: at.x + Math.sin(facing) * run,
          z: at.z + Math.cos(facing) * run,
        };
        if (standable(level.id, back) === null) continue;
        // Measured on both reference packs: this leg has never once been the
        // thing that failed, because it is short and colinear with two points
        // already known to be clear. It is checked anyway — a pack is allowed to
        // put a partition between a desk and the space behind it, and finding
        // that out at 60 Hz with a robot walking through it is not the way.
        if (plan.blocked(level.id, back, at, radius)) continue;
        approach = back;
        break;
      }
    }
    // Far enough in the past that everything starts fully interesting, without
    // any special case for "never visited" in the scoring.
    return { kind, at, facing, approach, roomId, visitedAt: -REVISIT_COOLDOWN };
  }

  /**
   * Every place on a level worth walking to, built once and kept.
   *
   * Cost is a few `roomAt` and `blocked` calls per candidate — up to three of
   * each for a facing address — over every seat, every authored prop and every
   * room on the level. On the reference ground floor that is 76 seats, 147
   * qualifying props and 17 rooms, and it is paid on the frame the first robot
   * on that level picks its first destination and never again. Doing it eagerly
   * at construction would move the same work to a worse moment, since a level
   * with no robots on it never needs a book at all.
   *
   * Seats come from `level.seats` rather than `plan.allSeats()` on purpose: a
   * robot belongs to one level for its whole life, and the building's seat list
   * would offer it addresses on a floor it can never reach.
   */
  function bookFor(level: LevelPlan): AddressBook {
    const hit = books.get(level.id);
    if (hit) return hit;
    const made: AddressBook = { desk: [], fixture: [], room: [] };

    for (const seat of level.seats) {
      const spot = makeAddress(
        level,
        "desk",
        {
          x: seat.position.x + Math.sin(seat.facing) * DESK_STANDOFF,
          z: seat.position.z + Math.cos(seat.facing) * DESK_STANDOFF,
        },
        seat.facing,
      );
      if (spot) made.desk.push(spot);
    }

    for (const prop of level.props) {
      // A prop bound to a seat, or generated by a desk bank, is the furniture of
      // a workstation — the seat itself is already a better address for it, and
      // adding the desk and the chair as well would put three addresses on one
      // spot and weight the whole floor toward whichever room has the most desks.
      if (prop.seat !== undefined || prop.source !== undefined) continue;
      // `position.y` is the base of the prop with the level's elevation already
      // in it, so the level's own floor has to come back out before it can be
      // compared with a height on the robot.
      if (prop.position.y - level.floorY > FIXTURE_MAX_BASE) continue;
      const spot = makeAddress(
        level,
        "fixture",
        {
          x: prop.position.x + Math.sin(prop.rotation) * FIXTURE_STANDOFF,
          z: prop.position.z + Math.cos(prop.rotation) * FIXTURE_STANDOFF,
        },
        prop.rotation,
      );
      if (spot) made.fixture.push(spot);
    }

    for (const room of level.rooms) {
      // No facing: there is nothing at a room's centre to look at, and inventing
      // one — face the longest wall, face the door — would be a guess dressed up
      // as intent. A robot arriving at a centroid stops looking the way it came
      // in, which is into the room, which is enough.
      //
      // `centroid` is the area centroid and a room need not be convex, so this
      // can land outside its own outline; `makeAddress` drops those rather than
      // falling back to the bounding box centre, which is not more likely to be
      // inside. Both reference packs have none.
      const spot = makeAddress(level, "room", { x: room.centroid.x, z: room.centroid.z }, null);
      if (spot) made.room.push(spot);
    }

    books.set(level.id, made);
    return made;
  }

  // Scratch for the shortlist, reused by every robot on every pick. Same
  // discipline as `from`, `to` and `chosen`: nothing here outlives the call that
  // fills it, and `pickErrand` is the only thing allowed to read or write it.
  const shortlist: (Address | null)[] = new Array(SHORTLIST).fill(null);
  const shortlistScore: number[] = new Array(SHORTLIST).fill(0);

  /**
   * Which kind of errand this one is, weighted by `ERRAND_MIX` over the kinds
   * this level actually has any of.
   *
   * The empty-kind skip is not defensive coding for its own sake. Nothing in the
   * office contract obliges a level to have seats, or props, or more than one
   * room — the second reference pack's mezzanine resolves three desks and a
   * single room, and a floor of meeting rooms with no authored furniture is an
   * ordinary thing to write. A weight table that did not renormalise would spend
   * a fifth of its draws on an empty list and fail whole picks for no reason,
   * which presents as a robot that thinks for a second and a half.
   */
  function drawKind(rand: () => number, book: AddressBook): ErrandKind | null {
    let total = 0;
    let last: ErrandKind | null = null;
    for (const [kind, weight] of ERRAND_MIX) {
      if (book[kind].length === 0) continue;
      total += weight;
      last = kind;
    }
    if (last === null) return null;

    let roll = rand() * total;
    for (const [kind, weight] of ERRAND_MIX) {
      if (book[kind].length === 0) continue;
      roll -= weight;
      if (roll <= 0) return kind;
    }
    // Floating-point slop only: the loop above subtracts exactly `total`.
    return last;
  }

  /**
   * How much this robot wants this address, as a number in (0, 1].
   *
   * Three factors, multiplied, and each one exists to stop a specific way four
   * robots stop looking like four people:
   *
   *   - **Cooldown**, so the crowd does not converge on the same few best
   *     addresses and pace a rut between them for the rest of the session.
   *   - **Elbow room**, so a robot does not walk across the building to stand
   *     where another one already is. Other robots' *destinations* count as much
   *     as their positions, which is the half that stops two robots setting off
   *     for the same desk and discovering it on arrival.
   *   - **Somewhere else**, so a robot in the kitchen tends to leave the
   *     kitchen. This is the term that spreads the crowd through the building
   *     rather than round one room, and it is the cheapest of the three.
   *
   * Multiplied rather than summed, because these are qualities a destination can
   * lack independently and a sum lets one good factor carry two bad ones — the
   * desk you were just at, with another robot already standing at it, would
   * still score well for being in the next room. The floors under the first two
   * keep the product away from zero, so a level with only bad options still
   * produces an ordering rather than a tie.
   */
  function scoreAddress(robot: Robot, address: Address, hereRoom: string | null): number {
    const cool = Math.max(COOL_FLOOR, Math.min(1, (clock - address.visitedAt) / REVISIT_COOLDOWN));

    let nearest = Infinity;
    for (const other of robots) {
      if (other === robot || other.level.id !== robot.level.id) continue;
      const here = other.view.position;
      nearest = Math.min(nearest, Math.hypot(here.x - address.at.x, here.z - address.at.z));
      const bound = other.target;
      if (bound) {
        nearest = Math.min(nearest, Math.hypot(bound.x - address.at.x, bound.z - address.at.z));
      }
    }
    const elbow =
      nearest === Infinity ? 1 : Math.max(SPREAD_FLOOR, Math.min(1, nearest / SPREAD_FULL));

    return cool * elbow * (address.roomId === hereRoom ? SAME_ROOM : 1);
  }

  /**
   * Pick somewhere with a reason to be there, or fail and let the caller fall
   * back to the sampler.
   *
   * Shortlist, then commit — and the split is what keeps this cheap. Scoring is
   * arithmetic over four robots and costs nothing, so `SHORTLIST` candidates are
   * drawn and ranked without touching the collider at all; only then is line of
   * sight tested, best first, and the first one that can be seen wins. The
   * measured cost is one `plan.blocked` call for most picks, because the best
   * candidate is usually visible, and at most `SHORTLIST` of them.
   *
   * That is strictly cheaper than the sampler it replaced, which spent up to two
   * `blocked` calls on each of `PICK_ATTEMPTS` candidates and still ended up
   * somewhere with no name — and it is most of why the whole tick got faster
   * rather than slower.
   */
  function pickErrand(robot: Robot): boolean {
    const book = bookFor(robot.level);
    const here = robot.view.position;
    const hereRoom = plan.roomAt(robot.level.id, here)?.id ?? null;

    let filled = 0;
    for (let i = 0; i < SHORTLIST; i++) {
      const kind = drawKind(robot.rand, book);
      if (kind === null) return false;
      const pool = book[kind];
      const candidate = pool[Math.floor(robot.rand() * pool.length)];
      if (!candidate) continue;
      // Same rule as the sampler's: too close and the robot shuffles rather than
      // walks, and the walk is the part anybody sees.
      if (Math.hypot(candidate.at.x - here.x, candidate.at.z - here.z) < MIN_TRIP) continue;

      // Insertion sort, best first. Six entries at most, so this is a handful of
      // compares and — unlike sorting an array of pairs — no allocation.
      const value = scoreAddress(robot, candidate, hereRoom);
      let slot = filled;
      while (slot > 0 && (shortlistScore[slot - 1] ?? 0) < value) {
        shortlist[slot] = shortlist[slot - 1] ?? null;
        shortlistScore[slot] = shortlistScore[slot - 1] ?? 0;
        slot--;
      }
      shortlist[slot] = candidate;
      shortlistScore[slot] = value;
      filled++;
    }

    for (let i = 0; i < filled; i++) {
      const address = shortlist[i];
      if (!address) continue;

      // The run-in is taken whenever there is one, from wherever the robot is
      // standing — including from the far side, where taking it means walking
      // past the destination and coming back at it. That looked like the wrong
      // trade and the measurement said otherwise. Taking it only from the near
      // side, on the sign of a dot product, halved how often it was used at all
      // — 128 of 399 picks over half an hour rather than 267 — and left the
      // median arrival 99° off the angle it was supposed to hold. Taking it
      // always costs 1.3% more walking and brings that median to 9°.
      const goal = address.approach ?? address.at;
      from.x = here.x;
      from.z = here.z;
      if (plan.blocked(robot.level.id, from, goal, radius)) continue;

      // Copied rather than aliased. The address is shared by every robot and
      // lives for the whole session; `waypoint` and `target` are one robot's and
      // are cleared and replaced constantly, and one line that reached for
      // `robot.target.x = …` would quietly move the desk for everybody.
      robot.waypoint =
        address.approach === null ? null : { x: address.approach.x, z: address.approach.z };
      robot.target = { x: address.at.x, z: address.at.z };
      robot.arriveFacing = address.facing;
      const [low, high] = DWELL[address.kind];
      robot.arriveDwell = low + robot.rand() * (high - low);
      // Heading somewhere with a name, so the last door stops defining this
      // robot — the same reasoning as the sampler's, and the reason a long
      // circuit can come back through the door it left by.
      robot.lastDoor = null;
      address.visitedAt = clock;
      return true;
    }
    return false;
  }

  /**
   * A doorway to head for when nowhere in the room is worth walking to.
   *
   * This is the fix for the one thing pure line-of-sight sampling cannot do, and
   * it is not a small thing: a robot inside a small room can see almost nothing
   * outside it, because every candidate has to be visible through a 0.9 m gap
   * with 0.28 m of clearance either side. Measured on the reference office, three
   * of four robots wandered into a kitchen, a stair core and a 2 × 2 m phone
   * booth within the first minute and then stood there for the remaining
   * nineteen. Not vibrating, not erroring — parked, forever, which is a worse
   * failure than a visible one because it looks deliberate.
   *
   * So a robot that cannot see anywhere to go walks to a door instead: a
   * **waypoint** at the middle of the opening and a target `THROUGH_DOOR` metres
   * past it, so it ends up in the next space with sight lines into it rather
   * than stopped in the gap still looking at the room it wanted to leave.
   * `Plan` has already decided which openings a walker fits through — the
   * `passable` flag is the wall split's own answer, computed from the same sill
   * and head heights that punched the hole — so this invents no geometry and
   * cannot disagree with the collider.
   *
   * The waypoint is the difference between working and not. A 0.9 m door with a
   * 0.28 m robot leaves 0.22 m of usable width once both jambs are cleared, so a
   * single straight line from an off-axis corner of a room to a point beyond the
   * door misses by centimetres and the whole door is rejected — which is what
   * left the last robot in a phone booth after every other fix. Split into two
   * legs, each checked on its own, both are easy: any point in the room can see
   * the middle of its own door, and the middle of a door can always see straight
   * out of it. Which is the general shape of the thing: **one** waypoint, chosen
   * from data `Plan` already publishes. Two would be a path, and a path needs a
   * graph, and a graph is the navmesh this file exists to not build.
   *
   * Two passes over the doors, and the second one is why a booth works. The
   * first skips the door this robot last came through, so a robot that has just
   * walked into the open floor does not turn straight round. The second allows
   * it, because a room with exactly one door — a booth, a store, a server room —
   * has no other way out, and refusing to reuse it is refusing to leave.
   */
  function pickDoor(robot: Robot): boolean {
    const doors = samplerFor(robot.level).doors;
    if (doors.length === 0) return false;
    const here = robot.view.position;
    const beyond: Point2 = { x: 0, z: 0 };

    // Started at a random index rather than at zero, so a robot with two doors
    // in sight does not always take the same one and pace a rut between two
    // rooms for the rest of the session.
    const start = Math.floor(robot.rand() * doors.length);
    for (const allowLast of [false, true]) {
      for (let i = 0; i < doors.length; i++) {
        const door = doors[(start + i) % doors.length];
        if (!door) continue;
        if (!allowLast && door.id === robot.lastDoor) continue;
        const dx = door.center.x - here.x;
        const dz = door.center.z - here.z;
        if (Math.hypot(dx, dz) < MIN_DOOR) continue;

        from.x = here.x;
        from.z = here.z;
        if (plan.blocked(robot.level.id, from, door.center, radius)) continue;

        // A wall at yaw φ runs along (cos φ, −sin φ), so its normal is
        // (sin φ, cos φ). Step out along whichever end of that normal is
        // further from the robot — that is the far side, which is the side
        // worth going to.
        const nx = Math.sin(door.yaw);
        const nz = Math.cos(door.yaw);
        const sign = dx * nx + dz * nz >= 0 ? 1 : -1;
        beyond.x = door.center.x + sign * THROUGH_DOOR * nx;
        beyond.z = door.center.z + sign * THROUGH_DOOR * nz;
        if (!plan.roomAt(robot.level.id, beyond)) continue;
        if (plan.blocked(robot.level.id, door.center, beyond, radius)) continue;

        robot.waypoint = { x: door.center.x, z: door.center.z };
        robot.target = { x: beyond.x, z: beyond.z };
        robot.arriveFacing = null;
        robot.arriveDwell = DWELL_DOOR[0] + robot.rand() * (DWELL_DOOR[1] - DWELL_DOOR[0]);
        robot.lastDoor = door.id;
        return true;
      }
    }
    return false;
  }

  /**
   * A random reachable point on the floor. The fallback, and no longer the plan.
   *
   * This used to be the whole of destination selection, and everything that read
   * as patrolling was here: a point drawn from a room's bounding box is not a
   * place, it is a coordinate — so a robot walked to the middle of nowhere,
   * stopped at whatever angle it happened to arrive at, waited a fixed-ish beat
   * and set off again. `pickErrand` runs first now, and this catches the two
   * things it cannot do: a robot in a room with no address it can see, and a
   * pack that authors no seats, no props and no usable centroids at all.
   *
   * It is worth keeping precisely because it asks so little of the pack. An
   * office is a `Plan`, and a `Plan` is allowed to be four walls and a door.
   *
   * The line-of-sight test is against the segment from here to there, and a
   * segment includes its endpoints — so this is also the check that the
   * destination itself has room to stand in, and there is no separate one.
   */
  function pickWander(robot: Robot): boolean {
    const candidate: Point2 = { x: 0, z: 0 };
    const level = robot.level;
    const sampler = samplerFor(level);
    const here = robot.view.position;
    for (let i = 0; i < PICK_ATTEMPTS; i++) {
      if (!trySample(sampler, level.id, robot.rand, candidate)) continue;
      if (Math.hypot(candidate.x - here.x, candidate.z - here.z) < MIN_TRIP) continue;
      from.x = here.x;
      from.z = here.z;
      if (plan.blocked(level.id, from, candidate, radius)) continue;
      robot.target = { x: candidate.x, z: candidate.z };
      robot.waypoint = null;
      // Nothing there to look at and no reason to linger, so a shrug of a pause
      // and off again.
      robot.arriveFacing = null;
      robot.arriveDwell = PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN);
      // Somewhere in the open: this robot is no longer defined by the last door
      // it used, and forgetting it is what lets a long circuit of the building
      // come back through the same doorway without a special case.
      robot.lastDoor = null;
      return true;
    }
    return false;
  }

  /**
   * Choose somewhere to walk to, or fail.
   *
   * Three tiers, in descending order of how much the destination means:
   * somewhere with a name and a facing, then anywhere at all on this floor, then
   * out through the nearest door. A robot reaches the second only because it can
   * see no address from where it is standing, and the third only because it can
   * see nothing at all — which is why the order is this way round, and it is a
   * happy accident of the shortlist that the tier that means the most is also
   * the one that costs the least.
   *
   * Failure is still a normal outcome, not an error — a robot boxed into a
   * corner with no door in sight will wait and try again from wherever it is —
   * and nothing is logged, because a robot with nowhere to go looks exactly like
   * a robot taking a moment.
   *
   * Every tier sets `target`, `waypoint`, `arriveFacing` and `arriveDwell`
   * rather than returning a destination, because two of the three have to set a
   * waypoint as well and a function that returned one field and mutated three
   * would be the worst of both.
   */
  function pickTarget(robot: Robot): boolean {
    if (pickErrand(robot)) return true;
    if (pickWander(robot)) return true;
    return pickDoor(robot);
  }

  /** Whether a robot could walk `PROBE_AHEAD` metres on this heading with `clearance` to spare. */
  function clearAhead(robot: Robot, heading: number, clearance: number): boolean {
    const here = robot.view.position;
    from.x = here.x;
    from.z = here.z;
    to.x = here.x - Math.sin(heading) * PROBE_AHEAD;
    to.z = here.z - Math.cos(heading) * PROBE_AHEAD;
    return !plan.blocked(robot.level.id, from, to, clearance);
  }

  /** Whether another robot on the same level is close enough and far enough ahead to yield to. */
  function shouldYield(robot: Robot): boolean {
    const here = robot.view.position;
    const fx = -Math.sin(robot.yaw);
    const fz = -Math.cos(robot.yaw);
    for (const other of robots) {
      if (other === robot || other.level.id !== robot.level.id) continue;
      const dx = other.view.position.x - here.x;
      const dz = other.view.position.z - here.z;
      const distance = Math.hypot(dx, dz);
      if (distance > YIELD_RANGE || distance < 1e-4) continue;
      if ((dx * fx + dz * fz) / distance > YIELD_CONE) return true;
    }
    return false;
  }

  /**
   * Stop, and stand there for `seconds`.
   *
   * `settle` is the yaw to turn to while standing, and it is a parameter rather
   * than something read off the robot because the two callers want opposite
   * things from it. Arriving somewhere passes the errand's facing — that is the
   * point of the errand. Giving up — wedged, deadlocked, watchdogged — passes
   * `null`, because a robot that failed to get somewhere has no business
   * adopting the pose of having got there.
   */
  function beginPause(robot: Robot, seconds: number, settle: number | null): void {
    robot.target = null;
    robot.waypoint = null;
    robot.wait = seconds;
    robot.settle = settle;
    robot.sinceCheck = 0;
    robot.checkAge = 0;
  }

  // ---- Population ---------------------------------------------------------

  const warned = new Set<string>();
  options.robots.forEach((spec, index) => {
    const level = plan.level(spec.levelId);
    if (!level) {
      if (!warned.has(spec.levelId)) {
        warned.add(spec.levelId);
        console.warn(`[tera/interiors] no level "${spec.levelId}" for a robot; skipping it`);
      }
      return;
    }

    // Seeded per robot rather than from one shared stream, so adding a fifth
    // robot does not move the other four. Same reasoning as `furnish.ts` keying
    // its randomness on the batch rather than on a counter.
    const rand = seededRandom(seed + index * 0x9e37);
    const start: Point2 = { x: 0, z: 0 };
    if (!samplePoint(level, rand, start)) {
      // Nowhere on this level a robot fits. That is a fact about the pack — a
      // level of corridors narrower than 0.56 m, or one with no rooms — and it
      // is worth one line, because the symptom otherwise is a robot that is
      // simply absent with no explanation anywhere.
      console.warn(
        `[tera/interiors] found nowhere to stand on level "${level.id}" after ` +
          `${PICK_ATTEMPTS} tries; that robot is not in the scene`,
      );
      return;
    }

    const rig = cloneOptimus(prototype);
    rig.root.name = spec.id ?? `robot-${index + 1}`;
    rig.root.position.set(start.x, level.floorY, start.z);
    rig.root.rotation.y = rand() * Math.PI * 2;
    group.add(rig.root);

    const view: RobotView = {
      id: rig.root.name,
      levelId: level.id,
      position: new THREE.Vector3(start.x, level.floorY, start.z),
    };
    views.push(view);
    robots.push({
      view,
      level,
      rig,
      yaw: rig.root.rotation.y,
      target: null,
      // Staggered, so four robots do not all set off on the same frame.
      wait: rand() * PAUSE_MAX,
      // Nothing to settle to and nowhere to have arrived from: a robot's first
      // errand overwrites both of these before either is read.
      settle: null,
      arriveFacing: null,
      arriveDwell: PAUSE_MIN,
      gait: 0,
      distance: rand() * STRIDE,
      waypoint: null,
      lastDoor: null,
      sinceCheck: 0,
      checkAge: 0,
      rand,
    });
  });

  /**
   * The heading nearest `want` that a robot can actually walk, and the clearance
   * it was found at.
   *
   * `PROBE_RELIEF` is the escape hatch, and it is the reason a wedged robot
   * cannot stay wedged. `plan.blocked` measures from the *start* of the segment
   * as well as along it, so a robot standing closer to a wall than its own
   * radius has every direction blocked — including straight away from the wall.
   * The step check below is supposed to make that unreachable, and it is an
   * invariant rather than a proof: an earlier version of it broke, and three of
   * four robots spent nineteen simulated minutes welded to the spot. So if
   * nothing is clear at full radius the robot is allowed to be thinner until
   * something is, and creeps back out. No relief can push it through a wall,
   * because `segmentDistance` returns zero for segments that actually cross and
   * zero is under every clearance there is.
   *
   * Falls back to `want` itself when everything is blocked, so the caller still
   * turns toward where it wanted to go and simply does not move.
   *
   * **The returned object is `chosen`, every time.** This used to be a fresh
   * `{ heading, clearance }` per call, which is once a frame for every robot
   * that is moving — the same allocation-per-frame this file goes out of its
   * way to avoid in `from`, `to` and `RobotLayer.robots`, and it is odd that
   * one survived where those did not. It is scratch now, and it is safe
   * *because* of how it is used: `step` reads both fields on the line after the
   * call and never keeps the reference. Anything that wants to hold on to a
   * choice — comparing this frame's against last frame's, say — has to copy the
   * two numbers out, or it will find that both of them changed underneath it on
   * the next robot's turn.
   */
  function chooseHeading(robot: Robot, want: number): Heading {
    for (const relief of PROBE_RELIEF) {
      const clearance = radius * relief;
      for (const offset of PROBE_TURNS) {
        if (clearAhead(robot, want + offset, clearance)) {
          chosen.heading = want + offset;
          chosen.clearance = clearance;
          return chosen;
        }
      }
    }
    chosen.heading = want;
    chosen.clearance = radius;
    return chosen;
  }

  // ---- Step ---------------------------------------------------------------

  function step(robot: Robot, dt: number): void {
    let moved = 0;
    let effort = 0;

    if (robot.target === null) {
      robot.wait -= dt;
      if (robot.wait <= 0) {
        if (pickTarget(robot)) {
          robot.sinceCheck = 0;
          robot.checkAge = 0;
        } else {
          robot.wait = RETRY_PAUSE;
        }
      }

      // Still nothing to walk to, so this is a robot standing somewhere on
      // purpose: turn it to the angle its errand asked for. Guarded on `target`
      // rather than sequenced before the pick because `arriveFacing` has already
      // been copied into `settle` by then and a pick that succeeded has replaced
      // it with the *next* destination's — turning toward that one from here
      // would have the robot aim itself across the building before setting off.
      //
      // This is the one place the yaw moves without a destination, and the
      // reason `SETTLE_RATE` is slower than `TURN_RATE`.
      if (robot.target === null && robot.settle !== null) {
        let swing = robot.settle - robot.yaw;
        swing = Math.atan2(Math.sin(swing), Math.cos(swing));
        const limit = SETTLE_RATE * dt;
        if (Math.abs(swing) <= limit) {
          // Arrived at the angle. `+= swing` rather than `= settle` keeps the
          // yaw continuous — the facing came out of the pack and may be any
          // multiple of a turn away from where this robot has wound up to.
          robot.yaw += swing;
          robot.settle = null;
        } else {
          robot.yaw += limit * Math.sign(swing);
        }
      }
    }

    // Steer at the waypoint while there is one, and at the destination after
    // that. There is at most one waypoint and it is always a doorway.
    const goal = robot.waypoint ?? robot.target;
    if (goal && robot.target) {
      const here = robot.view.position;
      const dx = goal.x - here.x;
      const dz = goal.z - here.z;
      const remaining = Math.hypot(dx, dz);

      if (remaining < (robot.waypoint ? REACHED : ARRIVE)) {
        if (robot.waypoint) robot.waypoint = null;
        // Arrived. Both halves of what the errand asked for are spent here and
        // nowhere else: how long to stand, and which way to look while doing it.
        else beginPause(robot, robot.arriveDwell, robot.arriveFacing);
      } else {
        // A figure faces −Z at yaw 0, so the heading that points along (dx, dz)
        // is the one whose (−sin, −cos) matches it. This is the same convention
        // `Yaw` carries everywhere else and it is why there is no conversion.
        const want = Math.atan2(-dx, -dz);
        let error = want - robot.yaw;
        error = Math.atan2(Math.sin(error), Math.cos(error));

        // Slow while turning and slow into the destination. The first is what
        // makes a robot pivot toward a doorway instead of arcing into its
        // frame; the second is what stops it overshooting and orbiting the
        // point it was aiming at. Both fall out of the walk cycle for free,
        // because the cycle is driven by distance — a slow robot takes short
        // steps rather than the same steps more slowly.
        const facing = Math.max(0, Math.cos(error));
        const approach = Math.min(1, remaining / (ARRIVE * 2.5));
        let pace = speed * facing * approach;
        if (shouldYield(robot)) pace = 0;

        // Fan out from the direction the destination wants until something is
        // clear. The straight line was clear when the destination was chosen,
        // but a robot turns on an arc rather than pivoting, so it can end up
        // aimed at a corner the original line missed.
        //
        // Probing around `want` and not around the robot's own heading is not a
        // detail. Probing around the heading, and then steering toward whatever
        // came back, gives *two* rate-limited turns in one frame — one toward
        // the destination and one toward the probe — and at equal rates they
        // cancel exactly. The observed symptom was a robot locked 0.95 rad off
        // course, pacing on the spot in a phone booth for the whole session,
        // with every individual line of it looking correct. One desired heading
        // and one turn.
        const choice = chooseHeading(robot, want);

        // The turn happens whether or not anything was clear, so a robot that
        // has walked into a dead end keeps rotating and finds its way out by
        // looking around rather than by waiting for the watchdog.
        let swing = choice.heading - robot.yaw;
        swing = Math.atan2(Math.sin(swing), Math.cos(swing));
        robot.yaw += Math.min(Math.abs(swing), TURN_RATE * dt) * Math.sign(swing);

        // And it only walks if the direction it actually ended up facing is
        // clear. The turn rate caps how far the yaw got, so mid-turn the robot
        // faces somewhere nothing has tested; walking that is exactly how one
        // ends up inside the clearance band, which presents as a robot standing
        // in a kitchen for the rest of the session rather than as an error.
        if (pace > 0 && clearAhead(robot, robot.yaw, choice.clearance)) {
          const advance = pace * dt;
          robot.view.position.x -= Math.sin(robot.yaw) * advance;
          robot.view.position.z -= Math.cos(robot.yaw) * advance;
          moved = advance;
          effort = pace / speed;
        }

        robot.distance += moved;
        robot.sinceCheck += moved;
        robot.checkAge += dt;
        if (robot.checkAge >= STUCK_WINDOW) {
          if (robot.sinceCheck < STUCK_DISTANCE) {
            // Wedged, deadlocked with another robot, or aiming at somewhere it
            // can no longer reach. Throwing the destination away and standing
            // still for a moment resolves all three, and is the reason this
            // cannot vibrate against a wall forever.
            beginPause(robot, RETRY_PAUSE, null);
          } else {
            robot.sinceCheck = 0;
            robot.checkAge = 0;
          }
        }
      }
    }

    // Ease rather than snap, so a robot that stops settles its limbs over about
    // a quarter of a second instead of jumping to attention mid-stride.
    robot.gait += (effort - robot.gait) * Math.min(1, dt / GAIT_EASE);
    if (robot.gait < 1e-3) {
      robot.gait = 0;
      restOptimus(robot.rig.joints);
    } else {
      pose(robot.rig.joints, robot.distance, robot.gait);
    }

    robot.rig.root.position.x = robot.view.position.x;
    robot.rig.root.position.z = robot.view.position.z;
    robot.rig.root.rotation.y = robot.yaw;
  }

  return {
    group,
    tick(dt) {
      if (!(dt > 0)) return;
      const clamped = Math.min(dt, MAX_STEP);
      // The clamped step, deliberately: the clock exists to age destinations
      // against how much walking has happened, and in a backgrounded tab none
      // has. See `clock`.
      clock += clamped;
      for (const robot of robots) step(robot, clamped);
    },
    robots() {
      return views;
    },
    dispose() {
      for (const robot of robots) group.remove(robot.rig.root);
      robots.length = 0;
      views.length = 0;
      // Every clone shares the prototype's buffers, so this frees all of them
      // exactly once. Materials belong to the caller's registry and are left
      // alone, the same way every asset in this library leaves them alone.
      disposeOptimus(prototype);
    },
  };
}
