From dac12cecec3ffbb708f8b05e421efee8742941ea Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 6 Aug 2026 02:22:47 -0700 Subject: [PATCH] The floor keeps up with the room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/adapters/http.ts | 190 +++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 58 +++++++++---- 2 files changed, 232 insertions(+), 16 deletions(-) diff --git a/src/adapters/http.ts b/src/adapters/http.ts index cf22a6a..c69abfa 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -177,6 +177,24 @@ export interface WeatherWatch { 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 { /** What the deployment turned out to be, or `null` if there is no server. */ health(): Promise; @@ -225,6 +243,16 @@ export interface TeraClient { * correct picture of an office nobody has told it about. */ presence(officeId: string): Promise; + /** + * 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) => get(`/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"; 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 | 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 { + 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(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); + } + }, + }; +} diff --git a/src/main.ts b/src/main.ts index dba552a..586e84c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,7 @@ import SOCAL from "./cities/socal.ts"; import { createTeraClient, describeLiveness, + type PresenceWatch, type TrafficSource, type WeatherWatch, } from "./adapters/http.ts"; @@ -204,6 +205,17 @@ let officePlan: OfficeMinimap | null = null; * possible to take it without the caption. */ 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 @@ -351,6 +363,7 @@ async function mountCity(id: string) { weatherWatch = null; poseEditor?.destroy(); poseEditor = null; + stopWatchingOccupancy(); officePlan?.dispose(); officePlan = null; office?.dispose(); @@ -623,11 +636,11 @@ async function enterOffice() { showDetail(null); refreshGodmodePlace(); renderLegend(); - // After the room is on screen, not before. Occupancy is a second request 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 - // scene the user cannot see yet. - void refreshPresence(); + // After the room is on screen, not before. The first ask is a second request + // 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 scene + // the user cannot see yet. + watchOccupancy(); } /** @@ -720,6 +733,9 @@ function showPlan() { function leaveOffice() { 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); inside = false; showPlan(); @@ -807,19 +823,29 @@ function officeName(): string { * 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. */ -async function refreshPresence() { +function watchOccupancy() { + stopWatchingOccupancy(); const scene = office; if (!scene || scene.depth !== "full") return; - const body = await tera.presence(officePack?.id ?? "lumbridge-hq"); - const people: Presence[] = body?.people ?? SAMPLE_PRESENCE; - presenceIsSample = body === null; - // The scene may have been torn down while the request was in flight — a city - // switch disposes the office — and writing people into a disposed layer is a - // use-after-free with a friendly name. - if (office !== scene) return; - scene.setPresence(people); - officePlan?.setPresence(people); - renderOfficeBadge(); + presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => { + const people: Presence[] = body?.people ?? SAMPLE_PRESENCE; + presenceIsSample = body === null; + // The scene may have been torn down between a request going out and coming + // back — a city switch disposes the office — and writing people into a + // disposed layer is a use-after-free with a friendly name. The watch's own + // `stop()` already drops late answers; this is the belt to that brace, + // because the office can also be *replaced* (signing in rebuilds it at full + // depth) without the watch having been stopped in between. + if (office !== scene) return; + scene.setPresence(people); + officePlan?.setPresence(people); + renderOfficeBadge(); + }); +} + +function stopWatchingOccupancy() { + presenceWatch?.stop(); + presenceWatch = null; } // ---- Chrome ---------------------------------------------------------------