1
0

The floor keeps up with the room

Occupancy was fetched once, on the way in. Somebody sat down and you did not
find out until you left the office and came back, which for a view whose entire
subject is who is in the building is the point missed by one request.

`watchPresence` is `watchWeather`'s shape without the two pieces of judgement
that one needs and this does not: weather has to decide whether an observation
is too old to be honest about and whether it describes the place you are looking
at, and a roster is neither — it is true when it is served, and it is addressed
by office id, so it cannot arrive about somewhere else.

Thirty seconds, and the number comes from what the data does rather than from
what the network will stand. Ten minutes is right for weather because nothing
upstream of it moves faster; occupancy moves when a person stands up. Backoff
tops out at five minutes rather than the weather watch's hour, because the
difference between the two is what the user is doing while the box is down:
nobody is staring at the sky waiting for it to be redescribed, and somebody *is*
standing in a room they expect to see people arrive in. An hour of silence there
reads as a broken feature rather than as a quiet API.

Two things it does that the weather watch does not, both because this one runs
while a person is looking at the thing it describes. It stops dead while the tab
is hidden and asks again the moment it comes back — a backgrounded tab polling a
roster nobody can see is waste on both ends, and it is the commonest state a
long-lived office tab is in; coming back has to be immediate rather than at the
next tick, or you return to a floor up to thirty seconds stale at exactly the
moment you are looking hardest. And it publishes only on change, by signature
rather than by identity: every answer is a fresh array, so without the
comparison a still floor would rebuild its presence meshes twice a minute
forever.

The watch belongs to the visit and not to the page, which is `weatherWatch`'s
rule for a sharper reason — a roster is requested with a credential and names
people, so one left running after somebody stepped out to the city is a page
quietly asking about a room nobody is looking at. Stopped before the scene swap
in `leaveOffice`, so the last thing it can do is abort a request rather than
publish into a room already left, and stopped again in `mountCity`, which
disposes the office under it.

Checked in the browser, both exits. Standing in the office: one ask on arrival,
the next 31 s later under failure backoff, not a flood. Stepping out by the `O`
key on the office host (a real navigation) and by the scene swap on
`?view=office` (which is what actually exercises `stop()`): zero requests in the
following 45 s, in both.
This commit is contained in:
2026-08-06 02:22:47 -07:00
parent d188db9299
commit dac12cecec
2 changed files with 232 additions and 16 deletions
+190
View File
@@ -177,6 +177,24 @@ export interface WeatherWatch {
stop(): void; stop(): void;
} }
/**
* A running poll of one office's occupancy.
*
* Same shape as `WeatherWatch` and deliberately so — both are "keep telling me
* about this one thing until I leave" — but without the two pieces of judgement
* that one carries. Weather has to decide whether an observation is too old to
* be honest about and whether it describes the place you are looking at;
* occupancy is neither. A roster is true when it is served and meaningless a
* moment later, and it is addressed by office id, so it cannot arrive about
* somewhere else.
*/
export interface PresenceWatch {
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
refresh(): void;
/** Stop polling, abort anything in flight, and drop any late answer. */
stop(): void;
}
export interface TeraClient { export interface TeraClient {
/** What the deployment turned out to be, or `null` if there is no server. */ /** What the deployment turned out to be, or `null` if there is no server. */
health(): Promise<HealthBody | null>; health(): Promise<HealthBody | null>;
@@ -225,6 +243,16 @@ export interface TeraClient {
* correct picture of an office nobody has told it about. * correct picture of an office nobody has told it about.
*/ */
presence(officeId: string): Promise<PresenceBody | null>; presence(officeId: string): Promise<PresenceBody | null>;
/**
* The same question, asked repeatedly, until the caller stops it.
*
* `null` reaches the callback for every refusal, exactly as the one-shot
* does, so a deployment that goes down mid-session is reported rather than
* frozen on the last roster it served — the difference between an office that
* emptied and an office you have stopped hearing about is one the caller has
* to be able to draw.
*/
watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch;
} }
/** /**
@@ -347,6 +375,10 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
*/ */
presence: (officeId) => presence: (officeId) =>
get<PresenceBody>(`/offices/${encodeURIComponent(officeId)}/presence`), get<PresenceBody>(`/offices/${encodeURIComponent(officeId)}/presence`),
watchPresence(officeId, onBody) {
return watchPresence(get, officeId, onBody);
},
}; };
} }
@@ -1011,3 +1043,161 @@ export function describeLiveness(live: Liveness): string {
if (parts.length === 3) return "live data"; if (parts.length === 3) return "live data";
return `live ${parts.join(" + ")}`; return `live ${parts.join(" + ")}`;
} }
// ---- Presence --------------------------------------------------------------
/**
* How often to ask who is in, while somebody is standing in the room.
*
* Thirty seconds, and the number comes from what the data does rather than from
* what the network can stand. Weather is polled every ten minutes because
* nothing upstream of it moves faster; occupancy moves when a person stands up,
* which is a scale of seconds, and a floor that takes ten minutes to notice
* somebody sat down is a floor plan of the recent past. Thirty is close enough
* to feel like the room and far enough that a member with the tab open all day
* makes about a thousand requests for a few kilobytes each.
*/
const PRESENCE_INTERVAL_MS = 30_000;
/**
* The ceiling on backoff. Five minutes, not the weather watch's hour.
*
* A box that is down comes back, and the difference between the two watches is
* what the user is doing while it is down: nobody is staring at the sky waiting
* for it to be redescribed, and somebody *is* standing in a room they expect to
* see people arrive in. An hour of silence there reads as a broken feature
* rather than as a quiet API.
*/
const PRESENCE_MAX_INTERVAL_MS = 5 * 60_000;
/**
* Poll one office's occupancy until told to stop.
*
* Two things this does that `watchWeather` does not, both for the same reason —
* this one runs while a person is looking at the thing it describes:
*
* - **It stops dead while the tab is hidden**, and asks again the moment it
* comes back. A backgrounded tab polling a roster nobody can see is waste on
* both ends, and it is the commonest state a long-lived office tab is in.
* Coming back has to be immediate rather than at the next tick, or returning
* to the tab shows a floor up to thirty seconds stale at exactly the moment
* somebody is looking hardest.
* - **It publishes only on change**, by comparing a signature rather than the
* object. Every answer is a fresh array, so identity says nothing; without
* the comparison a still floor would rebuild its presence meshes twice a
* minute forever, which is a visible cost for no information.
*/
function watchPresence(
get: Get,
officeId: string,
onBody: (body: PresenceBody | null) => void,
): PresenceWatch {
let failures = 0;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let inFlight: AbortController | null = null;
let signature: string | null = null;
let published = false;
const path = `/offices/${encodeURIComponent(officeId)}/presence`;
function schedule(delayMs: number) {
if (stopped) return;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => void tick(), delayMs);
}
/**
* What a body amounts to, for the purpose of "is this news".
*
* Seat, colour and label, in the order served. Not `observedAt`, which moves
* on every poll of an unchanged floor and would defeat the whole comparison,
* and not a `JSON.stringify` of the body for the same reason.
*/
function signatureOf(body: PresenceBody | null): string {
if (body === null) return "none";
return body.people.map((p) => `${p.seatId}${p.colorKey}${p.label}`).join("");
}
function publish(body: PresenceBody | null) {
const next = signatureOf(body);
// The first answer always goes through, even when it matches the empty
// signature a caller might have assumed. "I asked and there is nobody" and
// "I have not asked yet" are different states, and only one of them should
// leave a building unpopulated on purpose.
if (published && next === signature) return;
signature = next;
published = true;
onBody(body);
}
async function tick(): Promise<void> {
timer = null;
if (stopped) return;
// Hidden tabs do not ask. The visibility listener below is what wakes this
// up again, so there is no timer left running to catch.
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
// Nothing reaches this with a request already out, but a watch that stalled
// would stay stalled until the page reloaded, and that is too quiet a
// failure to leave to the reasoning being right.
if (inFlight) {
schedule(PRESENCE_INTERVAL_MS);
return;
}
inFlight = new AbortController();
const body = await get<PresenceBody>(path, { signal: inFlight.signal });
inFlight = null;
// The watch was stopped while this was in the air — the office was left or
// disposed. Whatever came back describes a room nobody is in any more, and
// publishing it would write people into a torn-down scene. It is also why a
// cancelled request must not count as a failure below.
if (stopped) return;
publish(body);
if (body === null) {
failures += 1;
schedule(Math.min(PRESENCE_INTERVAL_MS * 2 ** (failures - 1), PRESENCE_MAX_INTERVAL_MS));
return;
}
failures = 0;
schedule(PRESENCE_INTERVAL_MS);
}
function onVisibility() {
if (stopped) return;
if (document.visibilityState === "visible") {
// Immediately, not at the next tick. Somebody has just come back to this
// tab, and the first thing they look at is the room.
failures = 0;
schedule(0);
} else if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
void tick();
return {
refresh() {
if (stopped || inFlight) return;
schedule(0);
},
stop() {
stopped = true;
if (timer !== null) clearTimeout(timer);
timer = null;
inFlight?.abort();
inFlight = null;
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
},
};
}
+42 -16
View File
@@ -29,6 +29,7 @@ import SOCAL from "./cities/socal.ts";
import { import {
createTeraClient, createTeraClient,
describeLiveness, describeLiveness,
type PresenceWatch,
type TrafficSource, type TrafficSource,
type WeatherWatch, type WeatherWatch,
} from "./adapters/http.ts"; } from "./adapters/http.ts";
@@ -204,6 +205,17 @@ let officePlan: OfficeMinimap | null = null;
* possible to take it without the caption. * possible to take it without the caption.
*/ */
let presenceIsSample = false; let presenceIsSample = false;
/**
* The running poll of who is in, or `null` when nobody is standing in the room.
*
* It belongs to the visit and not to the page, which is the same rule
* `weatherWatch` follows one line up and for a sharper reason: a roster is
* requested with a credential and describes people, so a watch left running
* after somebody stepped back out to the city is a page quietly asking about a
* room nobody is looking at. Started by `enterOffice`, stopped by `leaveOffice`
* and by `mountCity`, and there is never more than one.
*/
let presenceWatch: PresenceWatch | null = null;
/** /**
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
@@ -351,6 +363,7 @@ async function mountCity(id: string) {
weatherWatch = null; weatherWatch = null;
poseEditor?.destroy(); poseEditor?.destroy();
poseEditor = null; poseEditor = null;
stopWatchingOccupancy();
officePlan?.dispose(); officePlan?.dispose();
officePlan = null; officePlan = null;
office?.dispose(); office?.dispose();
@@ -623,11 +636,11 @@ async function enterOffice() {
showDetail(null); showDetail(null);
refreshGodmodePlace(); refreshGodmodePlace();
renderLegend(); renderLegend();
// After the room is on screen, not before. Occupancy is a second request and // After the room is on screen, not before. The first ask is a second request
// the building is worth looking at while it is in flight; awaiting it here // and the building is worth looking at while it is in flight; awaiting it here
// would hold the door shut on a network round trip to draw people into a // would hold the door shut on a network round trip to draw people into a scene
// scene the user cannot see yet. // the user cannot see yet.
void refreshPresence(); watchOccupancy();
} }
/** /**
@@ -720,6 +733,9 @@ function showPlan() {
function leaveOffice() { function leaveOffice() {
if (!city) return; if (!city) return;
// Before the scene swap, so the last thing the watch can do is abort a request
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
city.stage.setScene(city.stageScene); city.stage.setScene(city.stageScene);
inside = false; inside = false;
showPlan(); showPlan();
@@ -807,19 +823,29 @@ function officeName(): string {
* is a real fact about an office, and overwriting it with invented people to * is a real fact about an office, and overwriting it with invented people to
* make the demo livelier is the one thing this file must never do. * make the demo livelier is the one thing this file must never do.
*/ */
async function refreshPresence() { function watchOccupancy() {
stopWatchingOccupancy();
const scene = office; const scene = office;
if (!scene || scene.depth !== "full") return; if (!scene || scene.depth !== "full") return;
const body = await tera.presence(officePack?.id ?? "lumbridge-hq"); presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => {
const people: Presence[] = body?.people ?? SAMPLE_PRESENCE; const people: Presence[] = body?.people ?? SAMPLE_PRESENCE;
presenceIsSample = body === null; presenceIsSample = body === null;
// The scene may have been torn down while the request was in flight — a city // The scene may have been torn down between a request going out and coming
// switch disposes the office — and writing people into a disposed layer is a // back — a city switch disposes the office — and writing people into a
// use-after-free with a friendly name. // disposed layer is a use-after-free with a friendly name. The watch's own
if (office !== scene) return; // `stop()` already drops late answers; this is the belt to that brace,
scene.setPresence(people); // because the office can also be *replaced* (signing in rebuilds it at full
officePlan?.setPresence(people); // depth) without the watch having been stopped in between.
renderOfficeBadge(); if (office !== scene) return;
scene.setPresence(people);
officePlan?.setPresence(people);
renderOfficeBadge();
});
}
function stopWatchingOccupancy() {
presenceWatch?.stop();
presenceWatch = null;
} }
// ---- Chrome --------------------------------------------------------------- // ---- Chrome ---------------------------------------------------------------