1
0

The Southland gets a door, and the people in it move

**Mateo Court**, a courtyard block in the Arts District — a third pack and
a third *kind* of building. Two office floors and a shed already existed,
so this is the plan the format had not been shown: a ring of rooms round
an open-air yard with **no corridor anywhere**. Every door opens onto the
yard, and the yard does the job a corridor does in the other two. It is
also the first pack whose `ceiling: null` means there is genuinely no
ceiling at any height rather than "take the lid off so the shot can see
in", and the first sited off the Bay Area board at all.

That last part needed a fix, not just a coordinate. `OFFICE_MARKERS` was
gated on `id === "sf"`, which was correct for exactly as long as every
office was in the Bay Area — it would have kept the Los Angeles building
off the Los Angeles board and pinned it to San Francisco's. The gate is
the board's own bounds now, which is the same question asked honestly.

Every door through the 0.25 m courtyard skin is 1.2 m rather than the
usual 0.9, and that is not a style choice: `blocked` inflates by the
walker's radius *and* the wall's thickness, so a 0.9 m leaf through a wall
that thick seals the room behind it while rendering perfectly. Two rooms
were sealed exactly that way on the first pass. The test says so, because
a well-meaning edit back to 0.9 for consistency would do it again.

**People move.** `samplePresenceAt` thinned the roster by hour, which
fixed a building that was full at 1 a.m., but everybody was still pinned
to their own desk all day. Occupancy is a hand-written booking table now:
meetings fill a room for a plausible length, the kitchen island fills at
lunch, and a `Presence` binds to a seat id — so "in a meeting" means
occupying a meeting-room seat, and the whole thing is choosing seat ids
rather than inventing positions.

The flood fill that checks a pack is walkable existed three times over.
One copy now, since the third is where a divergence lives.

**Films.** A new `office-dusk` reel — six hours over Lumbridge HQ catching
the moment the house lights take over from the sun, which is the one thing
only a time-lapse can show and which did not exist. The three existing
reels are re-rendered rather than re-captioned: `films.ts` in v4 named a
commit eleven behind HEAD, so the published reels were shot before there
were any clouds and before the city drew office pins.

Reviewers caught roughly a dozen false statements in the new prose across
these files — a room census that did not add up, a wall-thickness count,
a claim that every room has daylight when one does not, and a cost figure
saying `updateSun` runs once a second when it runs once a minute. The
consequential ones are fixed. In a codebase where the comments are the
design record, a confident wrong number is a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 05:50:32 -07:00
parent df9641cddd
commit 9c9e78f6f9
6 changed files with 2700 additions and 149 deletions
+120 -26
View File
@@ -84,13 +84,24 @@ const deliverSize = (spec) => {
};
/**
* `chapter` and `expect` work exactly as they do in `shots.mjs` — an index into
* the chapter list, and the `shortLabel` it is asserted to be.
* `door`, `chapter` and `expect` work exactly as they do in `shots.mjs`: which
* building the engine is showing, an index into the chapter list, and the
* `shortLabel` that index is asserted to be before the shutter opens.
*
* `from` and `to` are the ends of the day the camera watches. They are not
* midnight to midnight: the hours either side of dawn and dusk are the whole
* point and the small hours are eighteen identical dark frames, so the window
* is cropped to the part that moves.
* What is deliberately *not* carried over is `shots.mjs`'s `office` field and
* the picker press behind it. Every film below is of the building its door opens
* on, so that press would be a capture path no film exercises — and an untried
* step in a pipeline is worse than an absent one, because it looks like coverage.
* A Frontier Valley film means porting those fifteen lines along with it; the
* office entry at the bottom says why the hangar is the second office film to
* shoot rather than the first.
*
* `from` and `to` are the ends of the stretch of day the camera watches, and
* every film crops to the part of it that moves. For the city films that means
* dropping the small hours, which are dark frames indistinguishable from each
* other; for `office-dusk` it means a window narrow enough that the lights
* coming on lasts half a second instead of one frame. `hero-soma` is the
* exception, and argues for itself on its own entry.
*/
const FILMS = [
{
@@ -108,7 +119,7 @@ const FILMS = [
poster: 0.86,
place: "Financial District, San Francisco",
caption:
"One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky and the window lights all follow from that one number. The clock in the corner is the film captioning itself.",
"One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky, the map in the corner and the window lights all follow from that one number. The readout at the top of the panel is the film captioning itself — the hour, how high the sun is, which band of twilight that puts it in, and how much of the moon is lit.",
alt: "A time-lapse of the San Francisco financial district seen from above. Shadow sweeps across the towers as the sun crosses the sky, the water changes colour, and after sunset the tower windows light up one by one.",
},
{
@@ -162,15 +173,103 @@ const FILMS = [
to: "2026-08-06T22:40:00-07:00",
frames: 180,
fps: 30,
// The region shot is the one where the day does the most work. Shadow
// sweeps the length of two mountain ranges, and then the thing the relief
// was hiding turns up: the cities, which are somewhere else entirely.
/**
* The region shot, which is now the weather film as much as the light one.
*
* This is the only one of the three city cameras that is *above* the cloud
* deck — `clouds.ts` sizes its own hard case off exactly this chapter, "the
* camera 430 units above the ground with the cloud base at 52" — so it is
* the only one that watches the marine layer from on top rather than from
* under it. And with no weather feed wired, which is what a capture against
* a local `dist/` always is, there is a marine layer to watch: `atmosphere.ts`
* models the cover from the layer's own diurnal curve on apparent solar time
* rather than defaulting to an empty sky. Over San Francisco on this date the
* model runs about 0.88 before dawn, thins to 0.27 by mid-afternoon, and is
* back over 0.8 by nine.
*
* **Do not judge this one from a rough cut**, and the reason is a trap worth
* writing down. Cover is eased toward its target over a six-second time
* constant in *real* time (`COVER_TAU` in `clouds.ts`), while a frame costs
* about half a second of real time no matter how many hours of sky it steps
* over — measured here at 0.51 s, by differencing an 8-frame run against a
* 28-frame one. So the deck always runs *behind* the day, and how far behind
* is decided by the frame count rather than by the weather. At `--frames 40`
* one frame is twenty-eight minutes of sky, the layer never gets near its
* target, and the board reads as permanently socked in — which it is not.
* At 180 frames a frame is six minutes and the lag through the steepest part
* of the burn-off works out around a fifth of cover: trailing, but the deck
* visibly opens and shuts. The frame count is part of what this picture *is*,
* not just how smooth it is.
*
* The poster moved off 0.20 for a related reason: 0.20 is twenty past eight
* in the morning, which is now a white rectangle. 0.51 is ten to two, the
* clearest the board gets.
*/
title: "A day across the whole board",
poster: 0.2,
poster: 0.51,
place: "San Francisco Bay Area",
caption:
"Relief is easiest to read when the light moves. Shadow runs the length of two mountain ranges and back, and then at the end the thing the terrain never told you appears on its own: the cities, which are somewhere else entirely.",
alt: "A time-lapse of the whole San Francisco Bay Area from above. Long shadows rake across the hills as the sun crosses, and after dark the built-up ground emerges as scattered clusters of light between the ranges.",
"Relief is easiest to read when the light moves, and this camera is high enough to read the weather the same way. With nobody to ask for an observation — which is what a fresh clone of this engine has, and what this camera had — the sky is modelled from the Pacific marine layer's own daily curve instead of left empty: the deck that covers the board before dawn burns back through the afternoon and closes in again after sunset. Under it, shadow runs the length of two mountain ranges and back and at the end the thing the terrain never told you comes through the gaps, which is the cities, which are somewhere else entirely.",
alt: "A time-lapse of the whole San Francisco Bay Area from above, seen from over a cloud deck. The cloud covers the board at dawn, thins through the middle of the day to let long shadows rake across the hills and the bay show through, then closes back in at dusk, after which the built-up ground reads as scattered clusters of light between the dark ranges.",
},
{
/**
* The one film the stills cannot stand in for.
*
* `office-floor` in `shots.mjs` is this exact camera at two hours — 11:20 and
* 21:30 — and that pair is a fair account of both of them. What a pair cannot
* show is the part in between, which is the part that is actually the claim:
* the building has no night theme, it has a sun going down, and the fittings
* come up as it does. An event with a duration wants the medium that has one.
*
* **The window is cut to the switch-over, not to the working day.**
* `luminaires.ts` ramps the house lights from nothing at six degrees of solar
* elevation to full at zero, and over this building's own coordinate on this
* date that band is 19:38 to 20:12 — thirty-four minutes. Spread across the
* eighteen-hour window the city films use it would be six frames and a
* blink. Across these six hours it is seventeen, a little over half a second,
* with another thirty-nine frames of twilight behind it in which the fittings
* hold the building on their own. Dawn does the same thing in reverse and is
* deliberately out of frame: one legible switch-over beats two unreadable
* ones, and a sixteen-hour window buys the second one by making both a blur.
*
* The other end is chosen too. Solar noon here is 13:15 with the sun at 69°,
* which is straight down into an open-topped model and flat on every surface
* — the interior version of the midday problem the shot list keeps away from.
* By half past three the sun is at 54° and falling, and the shadows on the
* two plates have somewhere to go for the whole reel.
*
* The last frame is 21:30, which is `office-floor`'s night frame exactly. So
* the film ends on the still, and anyone who suspects one of them is lying
* can put them side by side.
*
* `chapter: 0` is the arrival pose and needs no click. It is also the only
* camera that holds all forty-eight metres of the plate and both storeys at
* once, which is what makes this read as a *building* changing state rather
* than as one room dimming. The Commons was the other candidate: it shows the
* void far better and the ceiling far worse, and the ceiling is the subject.
*
* Lumbridge HQ rather than Frontier Valley, and the hangar is the better
* second film than first — it is one storey and two robots against two
* storeys and four, its lights are a single run of trusses, and reaching it
* needs the office-picker step this script does not have. See the note above
* `FILMS`.
*/
id: "office-dusk",
door: "office",
chapter: 0,
expect: "The Floor",
from: "2026-08-06T15:30:00-07:00",
to: "2026-08-06T21:30:00-07:00",
frames: 180,
fps: 30,
/** Frame 147, twenty-five past eight: fittings at full, sky not yet black. */
poster: 0.82,
title: "Six hours in the office, and the lights taking over",
place: "Lumbridge HQ",
caption:
"Forty-eight metres by eighteen, two storeys with fourteen metres of interstitial between them, and a sun that belongs to the building rather than to the picture: the pack declares where on the earth it stands, so this floor is lit by the real afternoon over Transbay and switches to its own fittings as that afternoon ends. The changeover is a ramp across six degrees of solar elevation rather than a theme, which is why it takes a film to show it at all. The four Optimus units walking the two floors brighten whatever fitting they pass under.",
alt: "A time-lapse of an office interior seen from outside as an open-topped model, both storeys visible at once and held a long way apart. Afternoon daylight rakes across the two floor plates and the sky behind the building warms to orange and then goes dark blue; the ceiling fittings on both levels come up as it does, until the building is lit entirely from within. Small figures sit at the desks and walk between them.",
},
];
@@ -344,22 +443,13 @@ async function film(browser, spec, dir) {
}
}
/**
* Frames to an MP4, plus the poster a `<video>` shows before anybody presses
* play.
*
* `yuv420p` and `+faststart` are not decoration: without the first, Safari and
* a good deal of Android will not decode the file at all, and without the
* second the index sits at the end and the video will not begin until the whole
* thing has arrived.
*/
/**
* Frames to a master, the master to deliverables, and a poster off the frames.
*
* Two stages rather than one, deliberately. The master is a near-lossless
* archive encode that stays in the render directory; every shipped file is
* derived from it. That makes "the hero is too heavy, try CRF 28" a ten-second
* job instead of a twenty-two-minute re-shoot, and — the part that matters —
* job instead of a re-shoot, and — the part that matters —
* it means what is on the site is always exactly what this script produces,
* rather than something hand-rolled with ffmpeg the day the size became a
* problem.
@@ -369,7 +459,11 @@ async function film(browser, spec, dir) {
* second the index sits at the end and playback waits for the whole download.
*
* The poster comes off the PNG, not off the master, because it is a still and
* has no reason to inherit a video codec's compromises.
* has no reason to inherit a video codec's compromises. The price of that is
* worth knowing before you go looking for a flag that does not exist: the frames
* are deleted the moment this returns, so **moving a `poster` fraction costs a
* re-shoot**, where moving a CRF costs ten seconds. Everything else about a film
* can be changed for free; that one cannot.
*/
function encode(dir, out, fps, frames, posterAt, deliverables) {
const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
@@ -557,8 +651,8 @@ function manifest() {
* rewrites this file wholesale.
*
* To change a film or its caption: edit \`FILMS\` in that script and run it.
* A caption-only change can use \`--manifest-only\` and skip the ~22 minutes a
* film costs to shoot.
* A caption-only change can use \`--manifest-only\` and skip the shoot entirely —
* which is a minute and a half a film on the GPU and twenty-two without one.
*
* Every frame of every film below is a screenshot of tera's built \`dist/\` at
* the commit named here, with the app's own clock stepped between frames.
+726 -120
View File
@@ -443,32 +443,96 @@ export const SAMPLE_PRESENCE_PALETTE: Record<string, number> = {
guest: 0x7fb886,
};
// ---- The office, over a day ------------------------------------------------
/**
* The fabricated roster as it stands at one particular instant.
* The fabricated roster as it stands at one particular instant: who is in the
* building, and **which seat each of them is in right now**.
*
* `SAMPLE_PRESENCE` is a photograph of one good morning, and a photograph was
* the whole truth for as long as the office had no clock. It is not any more.
* The room is lit by the same sun the city is, the house lights come up as that
* sun goes down, and the robots go on walking the floor after dark — so a
* building with all twenty-five of these people still sitting in it at one in
* the morning is now the least believable thing in it. Everything else in the
* scene has learned what time it is; the people had not.
* the morning was the least believable thing in it. Everything else in the scene
* had learned what time it was; the people had not.
*
* The first version of this function fixed half of that: it thinned the roster
* by hour, so the floor filled and emptied. The other half is that everybody it
* kept was pinned to one seat from dawn to dusk, and a floor where the same
* twenty-five silhouettes sit in the same twenty-five chairs for eleven hours is
* a texture rather than a place. A real floor has people in the meeting rooms
* when there is a meeting, at the kitchen island at lunch, and in the lounge in
* the afternoon. So this now answers a harder question than "are you in": it
* answers "where are you", and a `Presence` binds to a `seatId`, so the whole
* feature is **choosing seat ids**. Nothing here invents a position; it could
* not if it wanted to, which is the point of the seat-id split and is argued at
* length on `Presence` in `interiors/types.ts`.
*
* This is deliberately a **pure function of an instant the caller supplies**,
* not a ticking source. The app already has exactly one clock — the wall clock,
* or whatever the godmode scrubber has overridden it with — and a module that
* read `new Date()` for itself would be a second one, correct until the moment
* somebody drags the scrubber to midnight and finds the desks still full. That
* is the bug being fixed here, so it is not one to reintroduce one layer down.
* is the bug this exists to fix, so it is not one to reintroduce one layer down.
*
* The result is always a **subset of `SAMPLE_PRESENCE`**, which is where the two
* sharpest constraints go away for free: one entry per seat in, one entry per
* seat or fewer out, so the roster can never name more people than there are
* seats, and nobody is ever moved to a seat they do not sit at. `SAMPLE_PRESENCE`
* itself is untouched and still exported — it is what a caller with no clock,
* and every test written before this existed, is entitled to keep getting.
* ### What replaced "always a subset of SAMPLE_PRESENCE"
*
* It used to be one. Returning a filtered `SAMPLE_PRESENCE` bought two hard
* properties for nothing — never more people than seats, and never two people in
* one seat — because the input already had one person per seat and filtering
* cannot create a collision. **That is gone**, and it has to be: a person who is
* at their desk at eleven and in Alcatraz at ten is by definition not always at
* the seat the photograph gives them. The result is still `Presence[]` and the
* exported signature has not moved, but the objects in it now carry seat ids
* that are nowhere in `SAMPLE_PRESENCE`, and a `colorKey` that can differ from
* the one there.
*
* The two properties are bought back a different way, and it is worth being
* precise because the second one is the easy bug here:
*
* - **One person, one seat.** The loop below emits at most one `Presence` per
* roster entry, ever. A person is either inside exactly one booking or at
* their own desk, never both, and the `bookings` list for one person is
* audited at import for self-overlap. Twenty-five people therefore produce at
* most twenty-five entries, at twenty-five distinct seats — so the roster can
* never name more people than the floor can hold.
* - **One seat, one person.** This is not free and is not derivable from the
* per-person view, because it is a statement about *pairs* of people. It comes
* from `BOOKINGS` being a hand-written table rather than a hash: within one
* booking the room seats are the object's keys, so a repeat is a **compile
* error** (TS1117), and across bookings that are live at the same moment the
* import-time audit at the bottom of this file checks every pair. A booking
* may also never name a seat that somebody sits at all day; that is checked
* too, since sending a visitor to `eng-03` would put two people in a chair
* without either booking overlapping the other.
*
* `SAMPLE_PRESENCE` itself is untouched and still exported — it is what a caller
* with no clock, and every test written before this existed, is entitled to keep
* getting.
*
* ### What it costs
*
* Called on every clock tick — `updateSun` in `main.ts` runs on a once-a-minute wall-clock interval, and on every godmode scrub
* — and on every frame of a godmode scrub, so it is worth stating rather than
* hoping.
*
* Per call: one pass over the twenty-five-entry roster; for each person a walk
* of their own bookings which stops at the first one starting after `t`; and for
* whoever is not in a booking, four number comparisons against a day that was
* computed once. Summed over the roster the booking walk is bounded by the whole
* table — fifty seat assignments across fifteen bookings, nobody holding more
* than four — so the worst case is a couple of hundred number comparisons.
* **Nothing is allocated except the result array**: every `Presence` this can
* return, at a desk or in a room, is built once at import and handed out by
* reference, exactly as the old filter handed out `SAMPLE_PRESENCE`'s own
* objects. There is no hashing on this path at all; the hashes run once per seat
* while `ROSTER` is built.
*
* Import-time work is the roster build and the audit, together a couple of
* thousand comparisons over those fifty entries, once, and neither of them on
* any frame.
*
* ### No weekend, on purpose
*
@@ -492,24 +556,31 @@ export const SAMPLE_PRESENCE_PALETTE: Record<string, number> = {
*
* `when.getHours()` — the viewer's local time, matching `#clock`, the godmode
* scrubber and every other reading of a `Date` in this app. That is right for
* the flagship case and imperfect for one that exists: both shipped buildings
* stand in San Francisco, but the sun in the windows is computed from the site's
* latitude and longitude while the roster here follows the viewer, so a visitor
* in another time zone gets a floor that fills up at their nine o'clock under a
* sky that belongs to the building's. Fixing that properly needs a real time
* zone on `OfficeSite`, which is a change to the office contract and not to a
* demo adapter. Guessing one from `site.lng` is *not* the fix and was rejected:
* a longitude gives you mean solar time, and it knows nothing about daylight
* saving. The two shipped sites sit at -122.40° and -122.32°, seven kilometres
* apart, which is about 8 h 10 m of solar offset — ten minutes adrift of Pacific
* Standard Time, and seventy minutes adrift of the clock for the two thirds of
* the year that are daylight time. That is wrong in the way that looks right.
* Until there is a real zone, a caller that genuinely knows the building's can
* shift the `Date` it passes.
* the flagship case and imperfect for one that exists: the sun in the windows is
* computed from the site's latitude and longitude while the roster here follows
* the viewer, so a visitor in another time zone gets a floor that fills up at
* their nine o'clock under a sky that belongs to the building's. Fixing that
* properly needs a real time zone on `OfficeSite`, which is a change to the
* office contract and not to a demo adapter. Guessing one from `site.lng` is
* *not* the fix and was rejected: a longitude gives you mean solar time, and it
* knows nothing about daylight saving. Until there is a real zone, a caller that
* genuinely knows the building's can shift the `Date` it passes.
*/
export function samplePresenceAt(when: Date): Presence[] {
const t = when.getHours() * 60 + when.getMinutes() + when.getSeconds() / 60;
return SAMPLE_PRESENCE.filter((person) => isPresentAt(person, t));
const out: Presence[] = [];
for (const person of ROSTER) {
// A booking wins outright. Somebody in a meeting is not also at their desk,
// and the order of these two branches is the entire "one person, one seat"
// guarantee for a single roster entry.
const booked = bookedAt(person, t);
if (booked) {
out.push(booked);
continue;
}
if (person.desk && isIn(person.desk.day, t)) out.push(person.desk.at);
}
return out;
}
/**
@@ -521,7 +592,40 @@ function at(hour: number, minute: number): number {
}
/**
* The three shifts that are *not* seeded, and the reason the building is never
* Where the six people the photograph catches mid-meeting actually sit.
*
* `SAMPLE_PRESENCE` is a snapshot at one moment, so for most of the roster the
* seat it gives somebody *is* their desk and this table has nothing to say. For
* the six in Alcatraz it is not: the snapshot caught them in a room they were
* booked into, and a model of a whole day needs to know where they go when the
* meeting ends. "Somebody in Alcatraz at 10:00 is at their desk at 11:00" is the
* requirement, and this is the line that says which desk.
*
* The alternative was to move them to desks in `SAMPLE_PRESENCE` itself and let
* the schedule put them back in the room. That was rejected because the snapshot
* is a deliberately varied fixture — its own comment lists the spread as the
* reason it exists — and a `SAMPLE_PRESENCE` in which everybody sits at a desk
* exercises exactly one kind of seat. Six lines here cost less than that.
*
* Keyed on the snapshot's seat rather than on the person id for the same reason
* `deskDay` is seeded from the seat: the seat is the address, and renaming
* Cormac Dellwood should not move anybody's desk. The desks chosen are free ones
* — nobody else in the roster sits at a `sales-*` station, at `design-02`,
* `ops-04` or `eng-07` — which the audit below rechecks, because two people
* sharing a home desk would be a permanent double occupancy rather than an
* occasional one.
*/
const DESKS: Record<string, string> = {
"alcatraz-01": "sales-01",
"alcatraz-02": "sales-02",
"alcatraz-03": "sales-03",
"alcatraz-06": "design-02",
"alcatraz-07": "ops-04",
"alcatraz-08": "eng-07",
};
/**
* The three shifts that are *not* hashed, and the reason the building is never
* empty during working hours.
*
* Everyone else's day is drawn from a hash, and a hash makes no promises: there
@@ -544,9 +648,17 @@ function at(hour: number, minute: number): number {
* step out at half past twelve is precisely the accident that would put an empty
* floor on screen at the one hour nobody would think to check.
*
* The keys are seat ids and they must exist in `SAMPLE_PRESENCE`; see the check
* below, which is there because renaming a seat would otherwise dissolve the
* guarantee without a word.
* A booking does **not** break the guarantee, and this is the thing to check
* before adding one. All three anchors are in `BOOKINGS` — the ops shift at the
* morning stand-up, reception in Bernal at twenty to four, the late engineer in
* Alcatraz and then alone in the kitchen at quarter past seven — but a booking
* *relocates* somebody, it never removes them. The claim above is that the floor
* is not empty, not that any particular chair is filled, so an anchor who is in
* a meeting still holds it up.
*
* The keys are desk seat ids and they must belong to somebody in the roster; see
* the audit below, which is there because renaming a seat would otherwise
* dissolve the guarantee without a word.
*/
const PRESENCE_ANCHORS: Record<string, { from: number; to: number }> = {
"ops-01": { from: at(6, 15), to: at(15, 5) },
@@ -555,75 +667,460 @@ const PRESENCE_ANCHORS: Record<string, { from: number; to: number }> = {
};
/**
* Two bookings in Alcatraz, and nothing else all day.
* One room, booked, with a named person in each chair.
*
* A meeting room whose chairs are full at eight in the evening is the same lie
* as a floor whose desks are, one room in — worse, really, because a meeting is
* the one thing in an office that everybody knows has a start and an end.
* `seats` is keyed on the **room seat** and valued with the occupant's **desk
* seat**, which is the opposite of how it reads out loud and is that way round
* on purpose: object keys are unique, so a table that puts two people in
* `alcatraz-03` does not compile (TS1117). The single easiest mistake in this
* whole feature is caught by `tsc` rather than by anybody noticing two figures
* z-fighting in one chair.
*/
const PRESENCE_SESSIONS: { from: number; to: number; attendance: number }[] = [
{ from: at(9, 40), to: at(10, 30), attendance: 0.8 },
{ from: at(14, 5), to: at(15, 15), attendance: 0.6 },
];
/**
* Whoever booked the room is in it.
*
* Without this a session can come round and, on this seed or the next one, draw
* nobody — and a lit meeting room with an empty table for fifty minutes reads as
* the seat binding having failed rather than as a meeting that was cancelled.
* The other five chairs are left to the hash, which is what makes the room look
* like a meeting rather than like a roll call.
*/
const PRESENCE_SESSION_CHAIR = "alcatraz-01";
/**
* When the visitor is in the building.
*
* Two short windows, because the lobby seat is the one place on this floor where
* a permanent occupant is obviously wrong: somebody who has been waiting in
* reception since dawn is not a guest, they are furniture.
*/
const PRESENCE_VISITS: { from: number; to: number }[] = [
{ from: at(10, 5), to: at(10, 50) },
{ from: at(15, 20), to: at(15, 55) },
];
/**
* Is this person in the building at `t` minutes past local midnight?
*
* Four models, chosen by what the roster already says about the person rather
* than by a second table of seat ids that would drift out of step with the
* first. `colorKey` is opaque to the engine and to `presence.ts` — that is the
* contract and it is not being bent here — but this module *defines* those keys,
* a few lines up in `SAMPLE_PRESENCE_PALETTE`, so it is the one place in the
* repo entitled to know that `"meeting"` means somebody is in a meeting.
*/
function isPresentAt(person: Presence, t: number): boolean {
const anchor = PRESENCE_ANCHORS[person.seatId];
if (anchor) return t >= anchor.from && t < anchor.to;
if (person.colorKey === "guest") {
return PRESENCE_VISITS.some((visit) => t >= visit.from && t < visit.to);
interface Booking {
/** For the audit's messages. Nothing reads it at runtime. */
room: string;
from: number;
to: number;
/** The `colorKey` an occupant wears while they are here. See `isGuest`. */
colorKey: string;
/** Room seat -> the desk seat of whoever is in it. */
seats: Record<string, string>;
}
if (person.colorKey === "meeting") {
return PRESENCE_SESSIONS.some(
(session, index) =>
t >= session.from &&
t < session.to &&
(person.seatId === PRESENCE_SESSION_CHAIR ||
unit(person.seatId, `session-${index}`) < session.attendance),
);
/**
* The day's diary: fifteen bookings, fifty chairs filled, hand-written.
*
* ### Why this is a table and not code
*
* The obvious shape is generative — hash a person against a session the way
* `deskDay` hashes their arrival, and let the room fill up. That is what the
* previous version did for its two Alcatraz sessions, and it needed a designated
* chair-of-the-meeting to stop the room drawing empty on an unlucky seed. Scaled
* up to the nine places this table books, it fails for reasons that are not
* about taste:
*
* - **Collisions become a search problem.** Two independently-hashed sessions
* that overlap in time can pick the same room, and two independently-hashed
* people can pick the same chair. Fixing that at read time means resolving
* conflicts against whoever else happens to be present — and a resolution that
* depends on the rest of the roster is a decision that changes as the clock
* moves. That is the popping this whole section is built to avoid: person B
* would slide from `kitchen-02` to `kitchen-03` at the instant person A walked
* in, for no reason a viewer could see.
* - **A hash cannot make a meeting look like a meeting.** Six named people round
* one table for fifty minutes is the thing worth drawing. A per-person
* probability will happily put one person alone in a ten-seat board room —
* that is precisely the failure the old chair-of-the-meeting rule was there to
* paper over — and every fix for it, a threshold, a floor on attendance, a
* quorum, is another coefficient that adds up to writing the guest list badly.
* - **The invariant becomes visible.** Read down `seats` and you can see that
* nobody is in two rooms; that is not a property you can read off a hash at
* all, only test for.
*
* So the diary is data. It is also the honest shape for this file, whose header
* has said from the first line that everything in it is invented: a day somebody
* wrote down is more obviously fiction than a day a hash produced, not less.
*
* ### The shape of the day
*
* Ops stand up at twenty past nine, five of six round Bernal. The big review
* fills eight of Alcatraz's ten from five to ten until quarter to eleven. A
* visitor waits in reception, is taken into Bolinas, and leaves. Somebody sits
* in Lands End with the door shut for an hour — one person in a two-seat quiet
* room is what a quiet room looks like, and it is the one place on this floor
* where a single occupant is the correct picture rather than a failed meeting.
* Lunch is two sittings, because thirteen of these seat ids draw a hashed lunch
* window and the kitchen has five seats. Ten of the thirteen eat here; the other
* three go out and are simply gone, which is the truth about a lunch hour and is
* why the building thins at half past twelve without emptying. The afternoon is
* a lounge, a
* second Alcatraz session, a Bernal, two focus booths and a second visitor. The
* late engineer eats alone in the kitchen at quarter past seven.
*
* ### Constraints an edit has to keep
*
* All of these are checked at import, so breaking one is a line in the console
* and not a mystery on screen:
*
* 1. Every value is the desk seat of somebody in `ROSTER`.
* 2. No room seat is anybody's desk. Bookings move people to rooms, never into
* another person's chair.
* 3. Bookings that are live at the same instant share neither a room seat nor a
* person.
*
* There is a fourth that is not checked and does not need to be: every booking
* here lies inside its attendee's working day. `dayFor` widens a day to cover
* its bookings anyway, so a booking that ran past somebody's departure would
* keep them in the building rather than making them flicker — but as authored,
* with the hashed days these seat ids produce, that widening is a no-op today.
* It is there so that the *next* edit to this table cannot open a hole.
*/
const BOOKINGS: Booking[] = [
{
// Ops, standing up. The early shift is an anchor and is in from a quarter
// past six; the other four draw arrivals between half seven and a quarter
// to nine, so nobody here is being dragged in for their own stand-up.
room: "Bernal",
from: at(9, 20),
to: at(9, 50),
colorKey: "meeting",
seats: {
"bernal-01": "ops-01", // Anouk Sterling
"bernal-02": "ops-02", // Emeka Farrow
"bernal-03": "ops-05", // Bettina Kovač
"bernal-04": "ops-10", // Aurelio Pinsent
"bernal-05": "ops-04", // Vasco Underhill
},
},
{
// The morning review — eight of ten, which is what a full room looks like
// without being a roll call. The six the photograph caught are all here.
room: "Alcatraz",
from: at(9, 55),
to: at(10, 45),
colorKey: "meeting",
seats: {
"alcatraz-01": "sales-01", // Cormac Dellwood
"alcatraz-02": "sales-02", // Solveig Amankwah
"alcatraz-03": "sales-03", // Ignatius Pell
"alcatraz-04": "design-02", // Rosalind Achebe
"alcatraz-05": "design-01", // Halcyon Reeve
"alcatraz-06": "ops-04", // Vasco Underhill
"alcatraz-07": "eng-07", // Clementine Roux
"alcatraz-08": "eng-01", // Tobias Quillon
},
},
{
// The visitor waiting, which is a booking of the lobby sofa by the person
// who has no desk to be at instead. `lobby-01 -> lobby-01` looks like a
// no-op and is the point: a guest is in the building only when the diary
// says so, so their whole day is bookings and there is no fall-through.
room: "Reception",
from: at(10, 15),
to: at(10, 35),
colorKey: "guest",
seats: { "lobby-01": "lobby-01" },
},
{
// ...and then taken in. Three in the four-seat room.
room: "Bolinas",
from: at(10, 35),
to: at(11, 20),
colorKey: "meeting",
seats: {
"bolinas-01": "design-03", // Zephyrine Mbeki
"bolinas-02": "eng-02", // Ines Marchetti
"bolinas-03": "lobby-01", // the visitor, who stays green — see `isGuest`
},
},
{
// Lands End, door shut, one person. See the note on quiet rooms above.
room: "Lands End",
from: at(11, 0),
to: at(12, 0),
colorKey: "focus",
seats: { "quiet-01": "eng-05" }, // Casimir Vane
},
{
// First sitting. Every one of these five has a hashed lunch window that
// overlaps this, so `dayFor` drops the window and they eat here instead of
// vanishing — which is the point of putting the island in the diary at all.
room: "Kitchen",
from: at(12, 5),
to: at(12, 40),
colorKey: "in",
seats: {
"kitchen-01": "design-03", // Zephyrine Mbeki
"kitchen-02": "eng-06", // Perpetua Nkemelu
"kitchen-03": "design-01", // Halcyon Reeve
"kitchen-04": "sales-01", // Cormac Dellwood
"kitchen-05": "ops-05", // Bettina Kovač
},
},
{
// Second sitting, five minutes after the first clears.
room: "Kitchen",
from: at(12, 45),
to: at(13, 20),
colorKey: "in",
seats: {
"kitchen-01": "design-04", // Ptolemy Sandoval
"kitchen-02": "ops-07", // Xiomara Belfry
"kitchen-03": "eng-04", // Sunniva Holt
"kitchen-04": "eng-01", // Tobias Quillon
"kitchen-05": "booth-02", // Ottoline Grieve
},
},
{
// Two in the afternoon, all four lounge chairs.
room: "Lounge",
from: at(14, 5),
to: at(14, 45),
colorKey: "in",
seats: {
"lounge-01": "eng-03", // Dara Oyelaran-Pike
"lounge-02": "ops-02", // Emeka Farrow
"lounge-03": "design-02", // Rosalind Achebe
"lounge-04": "eng-08", // Rafferty Osgood
},
},
{
// The long one. Overlaps the lounge above by twenty-five minutes and shares
// nobody with it, which is the pair the audit is really watching.
room: "Alcatraz",
from: at(14, 20),
to: at(15, 25),
colorKey: "meeting",
seats: {
"alcatraz-01": "sales-02", // Solveig Amankwah
"alcatraz-02": "sales-03", // Ignatius Pell
"alcatraz-03": "eng-11", // Marisol Thibault
"alcatraz-04": "design-01", // Halcyon Reeve
"alcatraz-05": "ops-05", // Bettina Kovač
"alcatraz-06": "eng-06", // Perpetua Nkemelu
},
},
{
// Heads-down until five. A booth is one seat, which is the point of it.
room: "Focus booth 1",
from: at(15, 30),
to: at(17, 0),
colorKey: "focus",
seats: { "booth-01": "eng-07" }, // Clementine Roux
},
{
// The second visitor, waiting while reception is still at the front desk.
room: "Reception",
from: at(15, 20),
to: at(15, 40),
colorKey: "guest",
seats: { "lobby-01": "lobby-01" },
},
{
// Handed over at twenty to four, which is also when reception leaves the
// desk for Bernal below. That is a story and not a coincidence.
room: "Bolinas",
from: at(15, 40),
to: at(16, 15),
colorKey: "meeting",
seats: {
"bolinas-01": "sales-01", // Cormac Dellwood
"bolinas-02": "design-03", // Zephyrine Mbeki
"bolinas-03": "lobby-01", // the visitor
},
},
{
room: "Bernal",
from: at(15, 40),
to: at(16, 20),
colorKey: "meeting",
seats: {
"bernal-01": "eng-02", // Ines Marchetti
"bernal-02": "eng-04", // Sunniva Holt
"bernal-03": "ops-07", // Xiomara Belfry
"bernal-04": "design-04", // Ptolemy Sandoval
"bernal-05": "reception-01", // Wren Abaddon, off the front desk
},
},
{
room: "Focus booth 3",
from: at(16, 40),
to: at(17, 45),
colorKey: "focus",
seats: { "booth-03": "sales-03" }, // Ignatius Pell
},
{
// The late shift eats alone at quarter past seven, in a building that by
// then holds one or two people. This is the booking that makes the evening
// read as an evening rather than as one figure that never moves.
room: "Kitchen",
from: at(19, 15),
to: at(19, 40),
colorKey: "in",
seats: { "kitchen-04": "eng-11" }, // Marisol Thibault
},
];
/** One person's whole day, resolved once. */
interface Rostered {
/** The desk seat, which is the key everything else about this person is on. */
home: string;
/**
* Where they are when nothing is booked, and when they are there at all.
*
* The two travel together in one object so that the check in
* `samplePresenceAt` narrows both at once. `null` for the visitor, who has no
* desk and exists only inside a booking.
*/
desk: { at: Presence; day: DeskDay } | null;
/** Their own bookings, earliest first, audited not to overlap each other. */
bookings: Episode[];
}
const day = deskDay(person.seatId);
if (t < day.arrive || t >= day.leave) return false;
// Out at lunch, for those who go. Deliberately checked after the arrive/leave
// pair rather than folded into it, because these are two different facts about
// a person and a single boolean expression covering both is the one that grows
// an off-by-one the first time somebody edits it.
return !(t >= day.lunchFrom && t < day.lunchTo);
/** One person, in one room, for one window. */
interface Episode {
from: number;
to: number;
/**
* The finished `Presence`, built at import.
*
* A person's diary never changes, so neither does the object describing them
* in it, and building one per call would be twenty-five allocations a second
* to produce values that are already known. This is what lets
* `samplePresenceAt` allocate nothing but its result array.
*/
who: Presence;
}
/**
* A guest stays a guest wherever they are sitting.
*
* The `colorKey` on a booking describes the *room* — amber for a booth, the
* meeting red for a table — and it is right for the twenty-four people who work
* here. It is wrong for the one who does not: the green is the only hue that is
* not in the office's own materials precisely because a visitor is the one
* person in the building who is not part of it, and a visitor who turns red on
* walking into Bolinas has lost the only thing that marked them out.
*
* `colorKey` is opaque to the engine and to `presence.ts` — that is the contract
* and it is not being bent here — but this module *defines* these keys, in
* `SAMPLE_PRESENCE_PALETTE` above, so it is the one place in the repo entitled
* to know that `"guest"` means somebody is a guest.
*/
function isGuest(person: Presence): boolean {
return person.colorKey === "guest";
}
/**
* The roster, resolved: twenty-five people, each with a desk, a day and a diary.
*
* Built at import rather than lazily, because the audit at the bottom of this
* file has to walk the same tables anyway and two passes over fifty entries at
* module load is not a cost worth deferring. `SAMPLE_PRESENCE` is a constant and
* this file is the only thing that writes it, so there is nothing to invalidate.
*/
const ROSTER: Rostered[] = SAMPLE_PRESENCE.map((person) => {
const home = DESKS[person.seatId] ?? person.seatId;
const bookings = bookingsFor(person, home);
if (isGuest(person)) return { home, desk: null, bookings };
const anchor = PRESENCE_ANCHORS[home];
// An anchor is a fixed day with no lunch hole, which is exactly a `DeskDay`
// with an empty interval in it — so the two kinds of person go down one path
// from here rather than two. `t >= 0 && t < 0` is false at every instant,
// which is what "takes no lunch break" means arithmetically.
const base: DeskDay = anchor
? { arrive: anchor.from, leave: anchor.to, lunchFrom: 0, lunchTo: 0 }
: deskDay(home);
return {
home,
desk: {
// The snapshot's colour describes the snapshot. `"meeting"` is a statement
// about a room, and a person sitting at their own desk wearing it would be
// telling the viewer they were somewhere they are not; the other three
// keys — in, focus, guest — are statements about the person and survive.
at: { ...person, seatId: home, colorKey: person.colorKey === "meeting" ? "in" : person.colorKey },
day: dayFor(base, bookings),
},
bookings,
};
});
/**
* Everything in `BOOKINGS` that names this person, earliest first.
*
* O(bookings × seats) per person and therefore O(people × table) overall, which
* is twenty-five passes over fifty entries at import and nothing at all
* afterwards. The obvious index — invert the table once into a map keyed on desk
* seat — would save about twelve hundred string comparisons that happen once,
* and cost a reader a level of indirection between the diary they are editing
* and the thing that reads it.
*/
function bookingsFor(person: Presence, home: string): Episode[] {
const out: Episode[] = [];
for (const booking of BOOKINGS) {
for (const [roomSeat, deskSeat] of Object.entries(booking.seats)) {
if (deskSeat !== home) continue;
out.push({
from: booking.from,
to: booking.to,
who: {
...person,
seatId: roomSeat,
colorKey: isGuest(person) ? person.colorKey : booking.colorKey,
},
});
}
}
out.sort((a, b) => a.from - b.from);
return out;
}
/**
* One person's day, widened to make room for their diary.
*
* Two adjustments, both computed once and both therefore free of the popping a
* clock-dependent adjustment would cause:
*
* **The day covers its bookings.** A meeting that starts before somebody's
* hashed arrival, or runs past their hashed departure, would otherwise show them
* appearing for the meeting, vanishing when it ended, and reappearing at their
* own arrival time — present, absent, present, which is worse than either. As
* authored no booking in the table needs this; it is here so that the next one
* cannot open that hole. Note that widening can only ever add minutes, so it
* cannot shorten an anchor's shift or break the coverage guarantee.
*
* **A booking over lunch replaces lunch.** Somebody who spent half past twelve
* at the kitchen island did not also go out to eat. Without this, the ten people
* the diary feeds would still be carrying their own hashed lunch window
* underneath it, and the leftovers of that window would show on both sides of
* the meal: they would vanish from their desks a few minutes before the sitting
* they are about to be seen at, and four of the five in the first sitting would
* stand up from the island straight into what was left of it — for `ops-05`,
* whose window runs to 13:06 against a sitting that clears at 12:40,
* twenty-six minutes of absence beginning the moment the room emptied.
*
* The whole hole is dropped rather than trimmed to what the booking does not
* cover. A trimmed hole leaves exactly those leftovers, of whatever length the
* two hashes happen to disagree by, and an absence whose length nobody chose is
* a glitch rather than a lunch.
*/
function dayFor(day: DeskDay, bookings: Episode[]): DeskDay {
if (bookings.length === 0) return day;
let first = Infinity;
let last = -Infinity;
let overLunch = false;
for (const booking of bookings) {
first = Math.min(first, booking.from);
last = Math.max(last, booking.to);
// Half-open intervals, so touching at an endpoint is not an overlap.
if (booking.from < day.lunchTo && booking.to > day.lunchFrom) overLunch = true;
}
return {
arrive: Math.min(day.arrive, first),
leave: Math.max(day.leave, last),
lunchFrom: overLunch ? 0 : day.lunchFrom,
lunchTo: overLunch ? 0 : day.lunchTo,
};
}
/**
* The booking this person is inside at `t`, or `null`.
*
* The list is sorted and a person's bookings never overlap each other — audited
* below — so the first one whose window contains `t` is the only one that can,
* and the scan can stop as soon as it reaches one that has not started yet.
* Nobody in the current table has more than four.
*/
function bookedAt(person: Rostered, t: number): Presence | null {
for (const booking of person.bookings) {
if (t < booking.from) break;
if (t < booking.to) return booking.who;
}
return null;
}
/** One desk worker's day, in minutes since local midnight. */
@@ -635,12 +1132,17 @@ interface DeskDay {
}
/**
* Memoised because a seat's day never changes. `samplePresenceAt` may be called
* on every clock tick and on every frame of a scrub, and re-hashing six streams
* per person per call to get an answer that is by construction the same answer
* is work nobody asked for.
* Is somebody with this day at their own desk at `t`?
*
* The lunch hole is deliberately checked after the arrive/leave pair rather than
* folded into it, because these are two different facts about a person and a
* single boolean expression covering both is the one that grows an off-by-one
* the first time somebody edits it.
*/
const deskDays = new Map<string, DeskDay>();
function isIn(day: DeskDay, t: number): boolean {
if (t < day.arrive || t >= day.leave) return false;
return !(t >= day.lunchFrom && t < day.lunchTo);
}
/**
* A believable working day for one seat, from the seat id and nothing else.
@@ -662,7 +1164,9 @@ const deskDays = new Map<string, DeskDay>();
* twenty-four hours and each crossing goes one way. Advancing the clock by a
* second can only change the answer for somebody whose boundary falls inside
* that second, and it changes it once. Scrubbing backwards is symmetric for the
* same reason.
* same reason. Bookings extend that property rather than spending it: a booking
* is two more fixed instants, so a person with four of them crosses at most a
* dozen times all day and every crossing is still a single, one-way step.
*
* Note what this is *instead of* hysteresis. Hysteresis needs memory of the last
* answer, and this function must give the same answer on a fresh reload as it
@@ -688,30 +1192,40 @@ const deskDays = new Map<string, DeskDay>();
* Lunch is `12:20` ± 40 min, taken away from the desk by the roughly two thirds
* who draw under the threshold, and it lasts 24 to 52 minutes. The shortest of
* those is still twenty-four minutes of world time, which is long enough to read
* as somebody having gone to eat rather than as a figure that glitched.
* as somebody having gone to eat rather than as a figure that glitched. Ten of
* the people who draw a lunch are in the diary at the kitchen island instead and
* never leave the building; see `dayFor`.
*
* Every window lies inside one calendar day and none of them wraps midnight.
* That is a constraint worth keeping: an interval that wraps needs `from > to`
* handling in `isPresentAt`, and the first person to write one without it gets a
* night owl who is never in rather than always.
* That is a constraint worth keeping, and it applies to `BOOKINGS` too: an
* interval that wraps needs `from > to` handling in `isIn` and `bookedAt`, and
* the first person to write one without it gets a night owl who is never in
* rather than always.
*
* Seeded from the **seat** id and not the person id, because the seat is the
* address — it is what a `Presence` binds to and what survives an edit to this
* file. Renaming Tobias Quillon should not change when the person at `eng-01`
* comes in.
*
* ### It is no longer memoised
*
* It used to be, into a `Map`, and the reason was good: the old
* `samplePresenceAt` called this per person on every clock tick, so without the
* cache each tick re-hashed a day that was by construction the same day. That is
* gone. `ROSTER` now calls this exactly once per seat at import and nothing
* calls it again, and a cache in front of a function called once per key can
* only ever be wrong, never fast. Worth a paragraph rather than a silent
* deletion, because "it is memoised, so call it freely" was a documented
* property of this function and whoever adds the next caller should know it has
* lapsed.
*/
function deskDay(seatId: string): DeskDay {
const hit = deskDays.get(seatId);
if (hit) return hit;
const arrive = at(8, 45) + 75 * triangular(seatId, "arrive");
const leave = at(17, 50) + 95 * triangular(seatId, "leave") + 0.6 * (arrive - at(8, 45));
const lunchFrom = at(12, 20) + 40 * triangular(seatId, "lunch");
const lunchLength = unit(seatId, "lunch-length") < 0.65 ? 38 + 14 * triangular(seatId, "bite") : 0;
const made: DeskDay = { arrive, leave, lunchFrom, lunchTo: lunchFrom + lunchLength };
deskDays.set(seatId, made);
return made;
return { arrive, leave, lunchFrom, lunchTo: lunchFrom + lunchLength };
}
/**
@@ -751,21 +1265,113 @@ function triangular(seatId: string, stream: string): number {
return unit(seatId, `${stream}-a`) + unit(seatId, `${stream}-b`) - 1;
}
// ---- The audit -------------------------------------------------------------
/**
* The anchors have to be anchored to something.
* Everything the tables above promise each other, checked once at import.
*
* A seat id in `PRESENCE_ANCHORS` that is not in `SAMPLE_PRESENCE` matches
* nobody, and the guarantee above quietly becomes a hope — the failure being an
* empty office at eleven in the morning on some seeds and not others, which is
* about the worst shape a bug can have. Twenty-five names against three ids, at
* import, is not a cost worth measuring, and it turns that into a line in the
* console the first time somebody renames a seat.
* These are all invariants of *source data* rather than runtime conditions, so
* this is the right place for them and there is deliberately no defensive check
* on the hot path. A runtime de-duplicator in `samplePresenceAt` would be code
* that cannot fire until somebody breaks the diary, and on the day they did it
* would turn a visible bug — two figures in one chair — into an invisible one,
* a person silently missing from the floor. Better to fail loudly here, where
* the thing that is wrong is twenty lines from the message.
*
* `console.warn` and not `throw`, matching the rest of this file's posture: a
* demo adapter with one bad row in it should still open the building.
*/
function auditRoster(): void {
const say = (message: string) => console.warn(`[tera/sample] ${message}`);
// 1. Desks are one to a person. Two roster entries resolving to the same home
// seat is a permanent double occupancy — every hour of every day, not just
// during a booking — which is the worst thing `DESKS` can get wrong.
const owner = new Map<string, string>();
for (const person of ROSTER) {
const name = person.desk?.at.label ?? "the visitor";
const held = owner.get(person.home);
if (held) say(`seat "${person.home}" is the home seat of both ${held} and ${name}`);
owner.set(person.home, name);
}
// 2. The anchors have to be anchored to something. A seat id in
// `PRESENCE_ANCHORS` that is nobody's desk matches nobody, and the continuous
// 06:1522:40 guarantee quietly becomes a hope — the failure being an empty
// office at eleven in the morning on some seeds and not others, which is about
// the worst shape a bug can have.
for (const seatId of Object.keys(PRESENCE_ANCHORS)) {
if (!SAMPLE_PRESENCE.some((person) => person.seatId === seatId)) {
console.warn(
`[tera/sample] anchor seat "${seatId}" is not in SAMPLE_PRESENCE; ` +
"the sample office can now be empty during working hours",
);
if (!owner.has(seatId)) {
say(`anchor seat "${seatId}" is nobody's desk; the sample office can now be empty during working hours`);
}
}
// 3. Within one booking: everybody named exists, nobody is named twice, and
// nowhere it seats them is a desk.
//
// The last of those is the one that is easy to miss when reading the sweep
// below: a booking that puts somebody at `eng-03` collides with whoever lives
// at `eng-03` without overlapping any other *booking*, so no amount of
// booking-against-booking checking would ever see it. Repeating a room seat
// inside one booking is the fourth case and is not checked here at all,
// because `seats` is keyed on it and `tsc` rejects the file (TS1117).
const desks = new Set<string>();
for (const person of ROSTER) if (person.desk) desks.add(person.home);
for (const booking of BOOKINGS) {
const named = new Set<string>();
for (const [roomSeat, deskSeat] of Object.entries(booking.seats)) {
if (!owner.has(deskSeat)) say(`${label(booking)} seats "${deskSeat}", who is nobody`);
if (desks.has(roomSeat)) say(`${label(booking)} uses "${roomSeat}", which is somebody's desk`);
if (named.has(deskSeat)) say(`${label(booking)} seats "${deskSeat}" twice`);
named.add(deskSeat);
}
}
// 4. No two bookings that are live at the same instant share a chair or a
// person.
//
// Sweeping the `from` instants is exact rather than a sample, and it is the
// only subtle thing in this function: the set of live bookings only ever
// *gains* a member at some booking's `from`. So if two bookings ever overlap,
// they overlap at the later of their two starts, and visiting every `from`
// cannot miss a pair. Fifteen starts against fifty entries, once.
//
// The person half of this is also what `bookedAt` leans on when it stops its
// scan at the first booking that has not started: if nobody is ever in two
// bookings at once, the first match is the only match.
for (const instant of BOOKINGS.map((booking) => booking.from)) {
const chair = new Map<string, Booking>();
const busy = new Map<string, Booking>();
for (const booking of BOOKINGS) {
if (instant < booking.from || instant >= booking.to) continue;
for (const [roomSeat, deskSeat] of Object.entries(booking.seats)) {
// Same-booking repeats belong to check 3, which can name them better.
const other = chair.get(roomSeat);
if (other && other !== booking) {
say(`at ${clock(instant)}, "${roomSeat}" is booked by both ${label(other)} and ${label(booking)}`);
}
chair.set(roomSeat, booking);
const elsewhere = busy.get(deskSeat);
if (elsewhere && elsewhere !== booking) {
say(`at ${clock(instant)}, "${deskSeat}" is in both ${label(elsewhere)} and ${label(booking)}`);
}
busy.set(deskSeat, booking);
}
}
}
}
/** A booking, named the way somebody scanning the diary would look for it. */
function label(booking: Booking): string {
return `${booking.room} at ${clock(booking.from)}`;
}
/** `hh:mm`, for the audit's messages only. */
function clock(t: number): string {
const hh = String(Math.floor(t / 60)).padStart(2, "0");
const mm = String(Math.floor(t % 60)).padStart(2, "0");
return `${hh}:${mm}`;
}
auditRoster();
+24 -6
View File
@@ -101,6 +101,7 @@ const CITIES: { id: string; label: string; city: City }[] = [
const OFFICES: { id: string; label: string; load: () => Promise<{ default: Office }> }[] = [
{ id: "lumbridge-hq", label: "Lumbridge HQ", load: () => import("./offices/lumbridge-hq.ts") },
{ id: "frontier-valley", label: "Frontier Valley", load: () => import("./offices/frontier-valley.ts") },
{ id: "mateo-court", label: "Mateo Court", load: () => import("./offices/mateo-court.ts") },
];
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
@@ -817,11 +818,28 @@ async function mountCity(id: string) {
// not a decoration. LA gets its own weather, not San Francisco's fog.
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
});
// The offices are in the Bay Area, so they ride along with that board's
// markers and are absent from the Southland's — the same rule the sample set
// already follows, for the same reason: a pin for a building six hundred
// kilometres off the board is a pin in the wrong place.
city.setMarkers(id === "sf" ? [...markers, ...OFFICE_MARKERS] : []);
/**
* A door belongs to the board it stands on.
*
* The gate here used to be `id === "sf"`, which was correct for exactly as
* long as every office was in the Bay Area. It stopped being correct the
* moment one was not: a hard-coded city id would have kept the Los Angeles
* building off the Los Angeles board and pinned it to San Francisco's.
*
* So the test is the board's own bounds, which is the same question asked
* honestly — a pin for a building outside the rectangle being drawn is a pin
* in the wrong place, whichever city that happens to be. The sample company
* markers stay SF-only; they are sample data about one city and always were.
*/
const bounds = entry.city.bounds;
const doors = OFFICE_MARKERS.filter(
(m) =>
m.lat >= bounds.minLat &&
m.lat <= bounds.maxLat &&
m.lng >= bounds.minLng &&
m.lng <= bounds.maxLng,
);
city.setMarkers(id === "sf" ? [...markers, ...doors] : doors);
city.onChapterChange(() => renderLegend());
/**
@@ -871,7 +889,7 @@ async function mountCity(id: string) {
},
});
showPlan();
minimap.setMarkers(id === "sf" ? [...markers, ...OFFICE_MARKERS] : []);
minimap.setMarkers(id === "sf" ? [...markers, ...doors] : doors);
// The instruments, for the one visitor in a deployment who has them. The pose
// editor holds a `World`, a camera and a controls, so it belongs to the board
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -44,16 +44,31 @@ export const FRONTIER_VALLEY_SITE: OfficeSite = {
label: "Alameda Point",
};
/**
* A courtyard block in the Arts District, square to the pueblo grid rather than
* to the compass — which is the whole reason `heading` is a field. See
* `mateo-court.ts` for where the numbers come from: 1.2 m is a loading dock, and
* 36° is the 1781 survey the downtown street grid still follows.
*/
export const MATEO_COURT_SITE: OfficeSite = {
lat: 34.0395,
lng: -118.2288,
elevation: 1.2,
heading: 36,
label: "Arts District, Los Angeles",
};
/**
* Every building this build can walk into, for the city to point at.
*
* `id` matches the pack's own `Office.id` and the `OFFICES` table in `main.ts`,
* which is what lets a click on a marker resolve to a door. Keeping the three in
* step is not enforced by the type system; it is enforced by there being exactly
* two of them and by `office.test.ts` asserting that each pack's site is the one
* named here.
* step is not enforced by the type system; it is enforced by there being three of
* them and by `office.test.ts` asserting that each pack's site is the very
* object named here — identity, not equality, so a restated coordinate fails.
*/
export const OFFICE_SITES: { id: string; name: string; site: OfficeSite }[] = [
{ id: "lumbridge-hq", name: "Lumbridge HQ", site: LUMBRIDGE_HQ_SITE },
{ id: "frontier-valley", name: "Frontier Valley", site: FRONTIER_VALLEY_SITE },
{ id: "mateo-court", name: "Mateo Court", site: MATEO_COURT_SITE },
];
+171 -3
View File
@@ -27,10 +27,77 @@ import { describe, it } from "node:test";
import { Plan } from "../interiors/plan.ts";
import LUMBRIDGE_HQ from "../offices/lumbridge-hq.ts";
import FRONTIER_VALLEY from "../offices/frontier-valley.ts";
import MATEO_COURT from "../offices/mateo-court.ts";
import { OFFICE_SITES } from "../offices/sites.ts";
const plan = new Plan(LUMBRIDGE_HQ, { warn: false });
/**
* Which rooms on a level can be walked to from a point, at 0.1 m.
*
* Shared by all three packs, because it was written three times otherwise and
* the third copy is where a subtle divergence lives. The grid is finer than it
* looks like it needs to be and that is not optional: `blocked` inflates by the
* walker's radius, so threading a 0.9 m door with a 0.3 m radius leaves 0.3 m of
* clear width, and a 0.25 m grid can straddle that and report a sealed room that
* is fine. Erring the other way is not possible — a coarser grid only ever
* reaches fewer rooms, so a pass is always a real pass.
*/
function reachableRoomsOf(
plan: Plan,
levelId: string,
entry: { x: number; z: number },
): Set<string> {
const STEP = 0.1;
const RADIUS = 0.3;
const b = plan.bounds;
const cols = Math.ceil(b.width / STEP);
const rows = Math.ceil(b.depth / STEP);
const at = (c: number, r: number) => ({ x: b.minX + c * STEP, z: b.minZ + r * STEP });
const room: (string | null)[] = new Array(cols * rows).fill(null);
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
room[r * cols + c] = plan.roomAt(levelId, at(c, r))?.id ?? null;
}
}
const sc = Math.round((entry.x - b.minX) / STEP);
const sr = Math.round((entry.z - b.minZ) / STEP);
assert.ok(room[sr * cols + sc], `${levelId}'s entry point is not inside a room`);
const seen = new Uint8Array(cols * rows);
const queue: [number, number][] = [[sc, sr]];
seen[sr * cols + sc] = 1;
while (queue.length > 0) {
const [c, r] = queue.pop() as [number, number];
for (const [dc, dr] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const nc = c + (dc as number);
const nr = r + (dr as number);
if (nc < 0 || nr < 0 || nc >= cols || nr >= rows) continue;
const i = nr * cols + nc;
if (seen[i] === 1 || room[i] === null) continue;
if (plan.blocked(levelId, at(c, r), at(nc, nr), RADIUS)) continue;
seen[i] = 1;
queue.push([nc, nr]);
}
}
const reached = new Set<string>();
for (let i = 0; i < seen.length; i += 1) {
const id = room[i];
if (seen[i] === 1 && id != null) reached.add(id);
}
return reached;
}
describe("the reference pack resolves cleanly", () => {
/** Checklist item 1, and the one everything else here depends on. */
it("reports no problems at all", () => {
@@ -398,7 +465,7 @@ describe("the sites", () => {
});
it("carry headings inside the compass", () => {
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT]) {
const h = pack.site?.heading ?? 0;
assert.ok(h >= 0 && h < 360, `${pack.id} has a heading of ${h}`);
}
@@ -430,12 +497,12 @@ describe("the office site table", () => {
it("lists exactly the packs this build ships", () => {
assert.deepEqual(
OFFICE_SITES.map((e) => e.id).sort(),
[FRONTIER_VALLEY.id, LUMBRIDGE_HQ.id].sort(),
[FRONTIER_VALLEY.id, LUMBRIDGE_HQ.id, MATEO_COURT.id].sort(),
);
});
it("hands each pack the very same site object it publishes", () => {
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT]) {
const entry = OFFICE_SITES.find((e) => e.id === pack.id);
assert.ok(entry, `${pack.id} is missing from OFFICE_SITES`);
// Identity, not equality: the packs import from the table, so anything
@@ -444,3 +511,104 @@ describe("the office site table", () => {
}
});
});
/**
* The third pack, and the first on the other board.
*
* A courtyard block: a ring of rooms round an open-air yard with **no corridor
* anywhere** — every door opens onto the yard, and the yard does the job a
* corridor does in the other two. That makes it the first pack whose
* circulation is outdoors, and the first whose `ceiling: null` means there is
* genuinely no ceiling rather than "take the lid off so the shot can see in".
*/
describe("the Mateo Court pack", () => {
const mc = new Plan(MATEO_COURT, { warn: false });
it("resolves with no problems", () => {
assert.deepEqual(
mc.problems.map((p) => `${p.where}: ${p.message} (${p.action})`),
[],
);
});
it("stacks its upper floor floor-to-floor", () => {
assert.deepEqual(
mc.levels.map((l) => [l.id, l.floorY]),
[
["level-1", 0],
["level-2", 5],
],
);
});
it("keeps its seat ids disjoint from both other packs", () => {
const mine = mc.allSeats().map((s) => s.id);
assert.equal(new Set(mine).size, mine.length);
const others = new Set([
...plan.allSeats().map((s) => s.id),
...new Plan(FRONTIER_VALLEY, { warn: false }).allSeats().map((s) => s.id),
]);
assert.deepEqual(mine.filter((id) => others.has(id)), []);
});
/**
* **The check that matters most in this pack.**
*
* Every door through the 0.25 m courtyard skin is 1.2 m rather than the 0.9 m
* the rest of the library uses, and that is not a style choice: `blocked`
* inflates by the walker's radius *and* by the wall's thickness, so a 0.9 m
* leaf in a wall this thick leaves too little clear width and seals the room
* behind it — while rendering perfectly. Two rooms were sealed exactly that
* way on the pack's first pass. A well-meaning edit back to 0.9 for
* consistency would do it again, and this is the only thing that would say so.
*/
for (const [levelId, entry] of [
["level-1", { x: 18.1, z: 1.0 }],
["level-2", { x: 8.4, z: 10.4 }],
] as const) {
it(`${levelId}: every room reachable from the way in`, () => {
const reached = reachableRoomsOf(mc, levelId, entry);
const sealed = mc.levels
.find((l) => l.id === levelId)!
.rooms.map((r) => r.id)
.filter((id) => !reached.has(id));
assert.deepEqual(sealed, [], `sealed rooms on ${levelId}`);
});
}
/**
* The loggia rail, and the gap in it.
*
* Both halves matter and only together: a rail that also sealed the stair head
* would pass an assertion that only checked you cannot fall off.
*/
it("guards the loggia edge without sealing the way up", () => {
assert.ok(mc.blocked("level-2", { x: 8.8, z: 9.0 }, { x: 10.6, z: 9.0 }, 0.3));
assert.ok(mc.blocked("level-2", { x: 8.8, z: 14.0 }, { x: 10.6, z: 14.0 }, 0.3));
assert.ok(mc.blocked("level-2", { x: 18.0, z: 7.6 }, { x: 18.0, z: 9.4 }, 0.3));
assert.ok(
!mc.blocked("level-2", { x: 8.8, z: 10.4 }, { x: 10.4, z: 10.4 }, 0.3),
"the stair head is walled off",
);
});
it("stands its upper-floor occupants on the deck", () => {
for (const id of ["loggia-01", "palmetto-01"]) {
assert.equal(mc.seat(id)?.y, 5, `${id} is not on the deck`);
}
});
it("leaves the yard open to the sky", () => {
const court = mc.levels[0]?.rooms.find((r) => r.id === "court");
assert.ok(court, "no courtyard");
assert.equal(court.ceiling, null);
});
/** The point of the pack: the Southland board finally has a door. */
it("stands on the Southland board", () => {
const site = MATEO_COURT.site;
assert.ok(site);
assert.ok(site.lat > 33.28 && site.lat < 34.36, `latitude ${site.lat}`);
assert.ok(site.lng > -118.88 && site.lng < -117.22, `longitude ${site.lng}`);
});
});