1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/vite.config.ts
T
karti 2aa4049258 feat(brand): re-shoot everything at the new engine, and make it one command
**Seven new shots**, because the world grew the most photogenic things in it
after the last pass: `sfo`, `lax`, `golden-gate`, `bay-bridge`, `freeway`,
`california-relief` and `pacific-sea`. All nine existing ids are unchanged — the
manifest emits a `ShotId` union that lumbridge-v4 imports, so ids are added and
never renamed.

**A shot can now aim itself.** The chapter list has no camera for SFO, LAX, the
bridges, the freeway or the open sea, and the engine has no `?pose=` back door,
so a shot points itself by driving the app's own inputs: a click on the plan view
slides the orbit target to a lat/lng while keeping the chapter's stance, wheel
notches set the standoff, and a drag sets azimuth and elevation. The plan view's
pixel-to-coordinate map is solved at runtime from three hovers of
`#minimap-readout` rather than hard-coded, so it survives a board resize or a
restyle of the widget.

**The tera share card was a picture of the wrong thing, and had been.** Its art
came from `keyboard.press("2")`, which had landed on the California board's drive
mode once the default board changed — so the card under the headline "Cities from
above." was a chase camera on US-101, showing metre-scale cars driving between
kilometre-wide buildings, with the DRIVE readout and the mode pill baked into the
art. It renders, it looks deliberate, and it is why an unguarded key press has no
place in a capture script. `capture.mjs` now clicks an indexed chapter and
asserts its `shortLabel` the way `shots.mjs` does, waits on `#boot` and
`#chapters` instead of sleeping twenty seconds, and gives each card its own hour.

**`npm run refresh` is the durable half.** One command: build, stills, cards,
films, both manifests, and a hashed before/after diff of every deliverable. It
fails loudly and specifically on the two conditions that otherwise produce
confident wrong output — the renderer coming up as SwiftShader, and a chapter
`expect` guard firing. `--stills-only` / `--cards-only` / `--films-only` compose,
`--dry-run` lists the plan without opening a browser, and
`shots.mjs --list` prints the whole shot plan — board, chapter, expect, aim, both
hours — which is what to run first when a guard does fire.

It also re-stamps `PROVENANCE.json`, narrowly: only entries whose origin is
`repository-generated` and whose `generator` names a script the run actually
executed, by literal hash substitution rather than re-serialising the file.
Without that, every legitimate card re-shoot leaves `npm run provenance` red.

**Every film re-shot.** They were at `9c9e78f`, captured 2026-08-07, and predated
the tone mapping, the reflective sea, the sky dome, terrain shadows, the rebuilt
California board, SFO, LAX, both bridges and the moving aircraft.

Tests 1137, typecheck, build, eight budget cells and every provenance and licence
check pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:49:06 -07:00

209 lines
10 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 — a studio you can walk around</title>
<meta
name="description"
content="Three buildings you can walk around: a live/work studio above San Francisco, a courtyard block in the LA Arts District, and a hangar at Alameda Point. Real sun, real hours, the lights on at night, the microphone on the desk and the speaker on the machine. 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 — a studio you can walk around" />
<meta
property="og:description"
content="The rooms, the desks and who is at them — floor plans rendered from a data file anybody can copy, under the sun of the day you are reading this."
/>
<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" />
<!--
Describes the card as it is actually composed, which is not the same thing
as describing the app. FURNITURE.BARE takes the plan view off the share
card — the card supplies its own typography and the app's panels shrunk to
1200x630 are unreadable furniture — so an alt that promises "the plan drawn
beside it" is describing a screenshot nobody is looking at. Re-read it
against public/og-office.png whenever capture.mjs changes the framing.
-->
<meta
property="og:image:alt"
content="A studio interior rendered as an open-topped model and seen from above: two desks of monitors, chairs, shelving, planting, a kitchen counter and floor-to-ceiling glazing, with a humanoid standing on the floor beside the bench."
/>
<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 — floor plans rendered from a data file anybody can copy, under the sun of the day you are reading this."
/>
<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"],
},
},
},
},
});