2d87d9f354
**Clouds were invisible to everyone who had not wired up NWS.** The layer
took `currentWeather()?.cloudCover ?? 0`, and `currentWeather()` is null on
any deployment without a weather source — which is the default, and the
exact configuration this repo is held to: a stranger clones it, runs one
command, and gets a city with no account and no key. Their sky was
permanently, silently empty. `atmosphere.ts` already models a sky when
nobody has observed one; it now models cover too, an observed reading
still wins outright, and the clouds are there on a bare clone.
**Both offices are pins on the city, and clicking one walks you in.** Each
pack has carried a real `site` since the sun needed one, and that
coordinate was known to the lighting and to nothing else — a visitor
looking at the board had no way to tell that two of those buildings are
ones they can go inside. The coordinates move to a tiny eagerly-imported
`offices/sites.ts` that the packs import *from*, because a pack is a 25 kB
lazy chunk and the board wants its pins long before anybody opens a door.
A test asserts the pack and the table hold the **same object**, not merely
equal values: a drifted coordinate would put the marker on one building
and the sun on another and both would look entirely plausible.
**Aircraft bank into their turns.** The roll channel existed and was never
written, so every turn was flat. Bank comes from the coordinated-turn
relation against the measured turn rate, damped by a first-order lag so it
settles rather than oscillates, and clamped at 30° like a real limiter.
Six regression tests, because roll is the one channel that feeds itself —
position and heading are recomputed from the last two observations and
wash out a bad value, while a NaN in the roll would persist for the life
of the track.
That fed straight into a real defect: `AdsbFlights` substituted
`heading: 0` for records with no `track` field, which is harmless for a
symmetrical dart and is a **sustained full-scale artefact** once aircraft
bank — a target whose real heading is 200° reported as 0° reads as a 160°
turn and pins the roll at its limiter for as long as it is in the feed.
Those records are dropped now. An aeroplane the feed will not give a
heading for is one this layer cannot draw honestly.
**The office empties out overnight.** A full complement of seated people
at one in the morning, under house lights that came on because the sun is
down, was the least believable thing left in the room once the clock
became real. A live roster always wins — an API that says the building is
empty is telling the truth about the building.
**Robots go somewhere.** They pick real addresses — a seat, a room — and
turn to face the seat when they arrive, rather than stopping at a random
angle. Godmode gets an office section: house lights forced on or off or
following the sun, robots and ceilings toggled, with a readout.
**The bundle is split.** Entry chunk 758 kB to 208 kB, with three.js and
satellite.js in a vendor chunk that survives an app deploy instead of
being re-downloaded on every one. Rollup's 500 kB warning still fires and
should — it now points at three.js, where it is true, instead of at our
code, where it was pointing at three.js all along.
Reviewers caught two false geography claims in the new prose ("both
shipped buildings stand in San Francisco" — one is across the estuary at
Alameda Point) and several miscounted figures. Fixed. In a codebase where
the comments are the design record, those are defects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
9.2 KiB
TypeScript
201 lines
9.2 KiB
TypeScript
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { defineConfig, type Plugin } from "vite";
|
|
|
|
/**
|
|
* The office's copy of the page, emitted from the city's.
|
|
*
|
|
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
|
|
* two names, and the app works out which door it is at runtime by reading its
|
|
* own hostname. A crawler cannot: it reads the HTML and nothing else. So a
|
|
* single `index.html` means both doors unfurl with the same title, the same
|
|
* sentence and the same picture — and the office door's whole reason to exist is
|
|
* that it is a different place.
|
|
*
|
|
* The obvious fix, a second hand-written `office.html`, is the one the Caddy
|
|
* config already argues against for the static root: "sharing the root rather
|
|
* than copying it means a deploy cannot leave the two doors on different
|
|
* builds". Two 900-line files each carrying the whole inline stylesheet would
|
|
* drift on the first CSS change, and nothing would notice, because the drift is
|
|
* invisible until somebody opens the other door.
|
|
*
|
|
* So there is one source and the build emits both shells. Everything outside the
|
|
* `ogc:` markers in `index.html` is copied byte for byte — the stylesheet, the
|
|
* DOM, the script tag Vite has already rewritten to the hashed bundle — and only
|
|
* the block between them is replaced. A change to the interface reaches both
|
|
* doors by construction, and the only thing that can differ is the thing that is
|
|
* supposed to.
|
|
*
|
|
* It runs in `writeBundle` rather than `transformIndexHtml` because it needs the
|
|
* *finished* document, after Vite has substituted the asset URLs. Transforming
|
|
* earlier would emit a page pointing at unhashed source paths.
|
|
*/
|
|
function twoDoors(): Plugin {
|
|
const MARKER_END = "<!-- ogc:end -->";
|
|
|
|
const OFFICE = `
|
|
<title>Spaces — an office you can walk around</title>
|
|
<meta
|
|
name="description"
|
|
content="Lumbridge HQ, one floor: the rooms, the desks and who is at them. Rendered in the browser from a data file anybody can copy. Apache-2.0, self-hostable."
|
|
/>
|
|
<link rel="canonical" href="https://office.lumbridgecorp.com/" />
|
|
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:site_name" content="Lumbridge Spaces" />
|
|
<meta property="og:title" content="Spaces — an office you can walk around" />
|
|
<meta
|
|
property="og:description"
|
|
content="The rooms, the desks and who is at them — one floor plan, rendered from a data file anybody can copy."
|
|
/>
|
|
<meta property="og:url" content="https://office.lumbridgecorp.com/" />
|
|
<meta property="og:image" content="https://office.lumbridgecorp.com/og-office.png" />
|
|
<meta property="og:image:type" content="image/png" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta
|
|
property="og:image:alt"
|
|
content="Lumbridge HQ seen from above as a cutaway model, desks and meeting rooms visible, people at their seats."
|
|
/>
|
|
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content="Spaces — an office you can walk around" />
|
|
<meta
|
|
name="twitter:description"
|
|
content="The rooms, the desks and who is at them — one floor plan, rendered from a data file anybody can copy."
|
|
/>
|
|
<meta name="twitter:image" content="https://office.lumbridgecorp.com/og-office.png" />`;
|
|
|
|
return {
|
|
name: "lumbridge:two-doors",
|
|
apply: "build",
|
|
async writeBundle(options) {
|
|
const dir = options.dir ?? "dist";
|
|
const html = await readFile(join(dir, "index.html"), "utf8");
|
|
|
|
const open = html.indexOf("ogc:start");
|
|
const end = html.indexOf(MARKER_END);
|
|
// Loudly, rather than by silently shipping two identical shells. A card
|
|
// that is quietly the wrong one is the failure this plugin exists to
|
|
// prevent, so losing the markers must not be a no-op.
|
|
if (open === -1 || end === -1) {
|
|
this.error("index.html has no ogc:start/ogc:end markers — cannot emit office.html");
|
|
return;
|
|
}
|
|
// The opening marker sits inside a larger comment, so cut from the start
|
|
// of that comment: otherwise the explanation ships on both doors while
|
|
// being true of only one.
|
|
const from = html.lastIndexOf("<!--", open);
|
|
const office =
|
|
html.slice(0, from) +
|
|
"<!-- The office door. Generated from index.html by `twoDoors` in vite.config.ts. -->" +
|
|
OFFICE +
|
|
html.slice(end + MARKER_END.length);
|
|
|
|
await writeFile(join(dir, "office.html"), office, "utf8");
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cut `satellite.js`'s WASM runtime out of the bundle.
|
|
*
|
|
* `satellite.js`'s entry point ends with `export * from './wasm/index.js'`, and
|
|
* that subtree is an Emscripten build: a megabyte of generated glue that reaches
|
|
* for `node:module` and `node:worker_threads`, wraps itself in a top-level
|
|
* `await`, and cannot be tree-shaken away because a star re-export of a module
|
|
* with side effects is not something a bundler may drop on its own.
|
|
*
|
|
* Measured, before this existed: **308 kB** of minified WASM loader shipped to
|
|
* every visitor, in a chunk nothing ever called. `engine/satellites.ts` uses the
|
|
* pure-JS `propagate` and says in its own header why it does not want the
|
|
* `BulkPropagator` — a binary loaded at runtime plus a fallback path for when
|
|
* that fails, to buy back two milliseconds a frame that are already budgeted.
|
|
* Paying 308 kB to *not* use it was the worst of both.
|
|
*
|
|
* The stub is empty because nothing imports a name from it. If a future version
|
|
* of this repo does want the bulk propagator, the fix is to delete this plugin
|
|
* and pay the kilobytes deliberately — not to widen the stub.
|
|
*
|
|
* Matching is on the **importer** as well as the specifier, so this cannot
|
|
* silently swallow some other package's `./wasm/index.js`.
|
|
*/
|
|
function noWasmPropagator(): Plugin {
|
|
const STUB = "\0lumbridge:satellite-wasm-stub";
|
|
return {
|
|
name: "lumbridge:no-wasm-propagator",
|
|
enforce: "pre",
|
|
resolveId(source, importer) {
|
|
if (source !== "./wasm/index.js") return null;
|
|
if (importer === undefined || !importer.includes("satellite.js")) return null;
|
|
return STUB;
|
|
},
|
|
load(id) {
|
|
return id === STUB ? "export {};" : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig({
|
|
// Mounted under tera.lumbridgecorp.com in production; the trailing
|
|
// slash matters, since every asset URL is resolved against it.
|
|
base: process.env.TERA_BASE ?? "/",
|
|
plugins: [noWasmPropagator(), twoDoors()],
|
|
/**
|
|
* The heightfield worker is constructed as `{ type: "module" }` in
|
|
* `world.ts`, and this is the build setting that agrees with it.
|
|
*
|
|
* Vite's default worker format is `iife`, which was survivable while nothing
|
|
* in the graph needed anything an IIFE cannot express — and stopped being
|
|
* survivable the moment it did. `satellite.js` reaches a WASM runtime with a
|
|
* top-level `await` in it, and rollup's answer to a top-level await in an
|
|
* `iife` chunk is to fail the whole build, with an error naming a file no
|
|
* worker imports.
|
|
*
|
|
* So this is agreement rather than a workaround: the module the browser is
|
|
* told to load as an ES module is now built as one.
|
|
*/
|
|
worker: { format: "es" },
|
|
build: {
|
|
outDir: "dist",
|
|
target: "es2022",
|
|
// Two entries. `login.html` used to sit in `public/`, which Vite copies
|
|
// verbatim — so `import.meta.env` was never substituted there and the page
|
|
// could not be told which identity provider to sign in against. It is a real
|
|
// entry now; the built URL (`/login.html`) is unchanged.
|
|
//
|
|
// `office.html` is deliberately not a third entry: it is the same document
|
|
// as `index.html` with a different head, so making it one would give it its
|
|
// own copy of the bundle graph. It is emitted after the build instead.
|
|
rollupOptions: {
|
|
input: { index: "index.html", login: "login.html" },
|
|
output: {
|
|
/**
|
|
* three.js and the SGP4 propagator get a chunk of their own.
|
|
*
|
|
* Not to make the download smaller — it is the same bytes either way —
|
|
* but to stop them being *re-downloaded*. They were inside the entry
|
|
* chunk, so every deploy that changed a line of app code invalidated
|
|
* three quarters of a megabyte of dependency that had not changed since
|
|
* the last release. Split out, a returning visitor pays for the app and
|
|
* keeps the vendor chunk it already has.
|
|
*
|
|
* They are one chunk rather than two because they are always wanted
|
|
* together: `engine/satellites.ts` imports both, and it is reached from
|
|
* the entry on every board.
|
|
*
|
|
* Measured: the entry chunk goes from 758 kB to 208 kB and the vendor
|
|
* chunk is 550 kB. Rollup's 500 kB warning therefore still fires — and
|
|
* it should. It now points at three.js, where it is a true statement
|
|
* about a dependency nobody here can shrink, instead of at our own code,
|
|
* where it was pointing at three.js all along and reading as if it were
|
|
* about us. Raising `chunkSizeWarningLimit` would have hidden both.
|
|
*/
|
|
manualChunks: {
|
|
vendor: ["three", "satellite.js"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|