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 ---------------------------------------------------------------