1
0

The city points at its own buildings, and the sky stops depending on an API

**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.

**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.

**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.

That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.

**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.

**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.

**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.

Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 04:18:00 -07:00
parent 51979feea0
commit 2d87d9f354
14 changed files with 2304 additions and 72 deletions
+13
View File
@@ -247,6 +247,16 @@ export interface OfficeScene extends StageScene {
/** Scene-space label anchors per presence id, for an HTML overlay. Empty at public depth. */
anchors: Map<string, THREE.Vector3>;
setCeilingsVisible(visible: boolean): void;
/**
* Draw the robots, or do not.
*
* Visibility only, deliberately. A hidden robot still walks and still moves
* the vectors the luminaires hold, so the fittings above it still come up —
* which is the useful half of the switch rather than a caveat: it is how you
* watch the ceiling respond without a figure in the way. Gating `tick` would
* freeze the building instead.
*/
setRobotsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
/**
* The sun's height, in degrees, from whatever clock the app is running.
@@ -769,6 +779,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
setCeilingsVisible(visible) {
shell.ceilings.visible = visible;
},
setRobotsVisible(visible) {
if (robots) robots.group.visible = visible;
},
setLighting(state) {
kit.applyLighting(state);
paintHorizon(state);
+776 -33
View File
@@ -26,32 +26,97 @@
* - **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 30 µs per tick for the crowd**, measured over thirty simulated
* minutes on the reference office — roughly 0.2% of a 60 Hz frame. Most of a
* - **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.
* - **Picking a destination costs up to 24 `blocked` calls**, but only on the
* frame a robot arrives somewhere, which is every few seconds. A robot that
* finds nowhere to go waits `RETRY_PAUSE` before trying again, so even one
* that has been sealed into a cupboard costs a burst every second and a half
* rather than one every frame.
*
* 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 random 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.
* 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.
@@ -65,9 +130,12 @@
* through a 0.9 m gap almost never exists and three of four robots spent
* nineteen simulated minutes parked in one (`pickDoor`).
*
* Together those take the crowd from 82% of the session standing still to under
* 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.
* 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:
*
@@ -77,6 +145,12 @@
* 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
@@ -116,7 +190,9 @@
*
* 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.
* 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
*
@@ -137,6 +213,16 @@
* 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";
@@ -219,7 +305,14 @@ 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 on arrival, before and after a random spread. */
/**
* 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;
@@ -255,6 +348,219 @@ const GAIT_EASE = 0.24;
*/
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. */
@@ -518,6 +824,23 @@ interface Robot {
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. */
@@ -526,9 +849,14 @@ interface Robot {
sinceCheck: number;
checkAge: number;
/**
* An intermediate point to reach before `target`, or nothing. Only ever a
* doorway; see `pickDoor` for why one waypoint is enough and two would be
* pathfinding.
* 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. */
@@ -654,6 +982,340 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
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.
*
@@ -729,6 +1391,8 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
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;
}
@@ -737,22 +1401,24 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
}
/**
* Choose somewhere to walk to, or fail.
* A random reachable point on the floor. The fallback, and no longer the plan.
*
* 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.
* 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.
*
* Sets `target` and `waypoint` on the robot rather than returning a point,
* because the doorway case has to set both and a function that returns one of
* them and mutates the other would be the worst of the two.
*/
function pickTarget(robot: Robot): boolean {
function pickWander(robot: Robot): boolean {
const candidate: Point2 = { x: 0, z: 0 };
const level = robot.level;
const sampler = samplerFor(level);
@@ -765,12 +1431,43 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
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);
}
@@ -800,10 +1497,21 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
return false;
}
function beginPause(robot: Robot, seconds: number): void {
/**
* 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;
}
@@ -858,6 +1566,11 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
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,
@@ -930,6 +1643,30 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
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
@@ -943,7 +1680,9 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
if (remaining < (robot.waypoint ? REACHED : ARRIVE)) {
if (robot.waypoint) robot.waypoint = null;
else beginPause(robot, PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN));
// 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
@@ -1007,7 +1746,7 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
// 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);
beginPause(robot, RETRY_PAUSE, null);
} else {
robot.sinceCheck = 0;
robot.checkAge = 0;
@@ -1036,6 +1775,10 @@ export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotL
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() {